Fix remote Agent Host dependency packaging (#326019)

* Fix remote Agent Host dependencies

Include fs-copyfile in REH packages and verify that startup imports are declared in the remote manifest.

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Smoke test packaged remote Agent Host

Launch the packaged Agent Host with its bundled Node after native REH builds and require it to bind a WebSocket listener.

(Written by Copilot)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Rob Lourens
2026-07-15 16:08:06 -07:00
committed by GitHub
parent d4ea5d4a2c
commit b7d0a0bb1c
7 changed files with 299 additions and 0 deletions
@@ -0,0 +1,129 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { type ChildProcess, fork } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { setTimeout as delay } from 'timers/promises';
const startupTimeoutMs = 30_000;
const shutdownTimeoutMs = 5_000;
const readyPattern = /Agent host server listening on \S+/;
async function main(serverRoot: string | undefined): Promise<void> {
if (!serverRoot) {
throw new Error('Usage: node smokeTestAgentHost.ts <server-root>');
}
const nodePath = path.join(serverRoot, process.platform === 'win32' ? 'node.exe' : 'node');
const bootstrapPath = path.join(serverRoot, 'out', 'bootstrap-fork.js');
for (const requiredPath of [nodePath, bootstrapPath]) {
if (!fs.existsSync(requiredPath)) {
throw new Error(`Packaged Agent Host dependency not found: ${requiredPath}`);
}
}
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-agent-host-smoke-'));
const logsPath = path.join(temporaryRoot, 'logs');
const userDataPath = path.join(temporaryRoot, 'user-data');
fs.mkdirSync(logsPath, { recursive: true });
fs.mkdirSync(userDataPath, { recursive: true });
const child = fork(bootstrapPath, [
'--type=agentHost',
'--logsPath', logsPath,
'--user-data-dir', userDataPath,
'--disable-telemetry',
], {
cwd: serverRoot,
env: {
...process.env,
VSCODE_DEV: undefined,
VSCODE_ESM_ENTRYPOINT: 'vs/platform/agentHost/node/agentHostMain',
VSCODE_AGENT_HOST_CONNECTION_TOKEN: undefined,
VSCODE_AGENT_HOST_HOST: '127.0.0.1',
VSCODE_AGENT_HOST_PORT: '0',
VSCODE_AGENT_HOST_SOCKET_PATH: undefined,
VSCODE_NLS_CONFIG: JSON.stringify({
userLocale: 'en',
osLocale: 'en',
resolvedLanguage: 'en',
defaultMessagesFile: path.join(serverRoot, 'out', 'nls.messages.json'),
}),
VSCODE_PIPE_LOGGING: 'false',
VSCODE_VERBOSE_LOGGING: 'false',
},
execPath: nodePath,
silent: true,
});
try {
await waitForReady(child);
console.log('Packaged Agent Host started successfully.');
} finally {
await stopProcess(child);
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
}
function waitForReady(child: ChildProcess): Promise<void> {
return new Promise((resolve, reject) => {
let output = '';
let settled = false;
const finish = (error?: Error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
if (error) {
reject(error);
} else {
resolve();
}
};
const appendOutput = (data: Buffer) => {
const text = data.toString();
process.stdout.write(text);
output = `${output}${text}`.slice(-64 * 1024);
if (readyPattern.test(output)) {
finish();
}
};
child.stdout?.on('data', appendOutput);
child.stderr?.on('data', appendOutput);
child.once('error', error => finish(error));
child.once('exit', (code, signal) => {
finish(new Error(`Packaged Agent Host exited before becoming ready (code: ${code}, signal: ${signal}).\n${output}`));
});
const timeout = setTimeout(() => {
finish(new Error(`Timed out after ${startupTimeoutMs}ms waiting for the packaged Agent Host to start.\n${output}`));
}, startupTimeoutMs);
});
}
async function stopProcess(child: ChildProcess): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
const exited = new Promise<void>(resolve => child.once('exit', () => resolve()));
child.kill();
await Promise.race([exited, delay(shutdownTimeoutMs)]);
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
await exited;
}
}
main(process.argv[2]).catch(error => {
console.error(error);
process.exit(1);
});
@@ -200,6 +200,10 @@ steps:
AZURE_DEVOPS_EXT_PAT: $(System.AccessToken)
displayName: Build server
- ${{ if eq(parameters.VSCODE_ARCH, 'arm64') }}:
- script: node build/azure-pipelines/common/smokeTestAgentHost.ts "$(Agent.BuildDirectory)/vscode-server-darwin-$(VSCODE_ARCH)"
displayName: 🧪 Smoke test packaged Agent Host
- script: |
set -e
npm run gulp vscode-reh-web-darwin-$(VSCODE_ARCH)-min-ci
@@ -290,6 +290,10 @@ steps:
AZURE_DEVOPS_EXT_PAT: $(System.AccessToken)
displayName: Build server
- ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}:
- script: node build/azure-pipelines/common/smokeTestAgentHost.ts "$(Agent.BuildDirectory)/vscode-server-linux-$(VSCODE_ARCH)"
displayName: 🧪 Smoke test packaged Agent Host
- script: |
set -e
npm run gulp vscode-reh-web-linux-$(VSCODE_ARCH)-min-ci
@@ -229,6 +229,10 @@ steps:
AZURE_DEVOPS_EXT_PAT: $(System.AccessToken)
displayName: Build server
- ${{ if eq(parameters.VSCODE_ARCH, 'x64') }}:
- powershell: node build/azure-pipelines/common/smokeTestAgentHost.ts "$(Agent.BuildDirectory)\vscode-server-win32-$(VSCODE_ARCH)"
displayName: 🧪 Smoke test packaged Agent Host
- powershell: |
. build/azure-pipelines/win32/exec.ps1
$ErrorActionPreference = "Stop"
@@ -0,0 +1,136 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import fs from 'fs';
import { builtinModules } from 'module';
import path from 'path';
import { suite, test } from 'node:test';
import * as ts from 'typescript';
const repositoryRoot = path.resolve(import.meta.dirname, '../../..');
const agentHostEntryPoints = [
'src/vs/platform/agentHost/node/agentHostMain.ts',
'src/vs/platform/agentHost/node/agentHostServerMain.ts',
];
suite('Agent Host dependencies', () => {
test('runtime packages are included in the remote server', () => {
const remotePackageJson = JSON.parse(fs.readFileSync(path.join(repositoryRoot, 'remote/package.json'), 'utf8')) as {
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
const packagedDependencies = new Set([
...Object.keys(remotePackageJson.dependencies ?? {}),
...Object.keys(remotePackageJson.optionalDependencies ?? {}),
]);
const runtimeImports = collectRuntimePackageImports(
agentHostEntryPoints.map(entryPoint => path.join(repositoryRoot, entryPoint))
);
const missingDependencies = [...runtimeImports]
.filter(([packageName]) => !packagedDependencies.has(packageName))
.map(([packageName, importers]) => `${packageName}: ${[...importers].sort().map(importer => path.relative(repositoryRoot, importer)).join(', ')}`)
.sort();
assert.deepStrictEqual(missingDependencies, []);
});
});
function collectRuntimePackageImports(entryPoints: readonly string[]): Map<string, Set<string>> {
const pendingFiles = [...entryPoints];
const visitedFiles = new Set<string>();
const packageImports = new Map<string, Set<string>>();
const builtInModules = new Set([...builtinModules, ...builtinModules.map(moduleName => `node:${moduleName}`)]);
while (pendingFiles.length > 0) {
const file = pendingFiles.pop()!;
if (visitedFiles.has(file)) {
continue;
}
visitedFiles.add(file);
const sourceFile = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
for (const moduleSpecifier of getRuntimeModuleSpecifiers(sourceFile)) {
if (moduleSpecifier.startsWith('.')) {
const importedFile = resolveSourceImport(file, moduleSpecifier);
if (importedFile) {
pendingFiles.push(importedFile);
}
continue;
}
if (builtInModules.has(moduleSpecifier)) {
continue;
}
const packageName = getPackageName(moduleSpecifier);
let importers = packageImports.get(packageName);
if (!importers) {
importers = new Set<string>();
packageImports.set(packageName, importers);
}
importers.add(file);
}
}
return packageImports;
}
function getRuntimeModuleSpecifiers(sourceFile: ts.SourceFile): string[] {
const moduleSpecifiers: string[] = [];
for (const statement of sourceFile.statements) {
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && isRuntimeImport(statement)) {
moduleSpecifiers.push(statement.moduleSpecifier.text);
} else if (ts.isExportDeclaration(statement) && !statement.isTypeOnly && statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) {
moduleSpecifiers.push(statement.moduleSpecifier.text);
}
}
const visit = (node: ts.Node): void => {
if (
ts.isCallExpression(node)
&& ts.isIdentifier(node.expression)
&& (node.expression.text === 'require' || node.expression.text === 'nativeRequire')
&& node.arguments.length === 1
&& ts.isStringLiteral(node.arguments[0])
) {
moduleSpecifiers.push(node.arguments[0].text);
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return moduleSpecifiers;
}
function isRuntimeImport(statement: ts.ImportDeclaration): boolean {
const clause = statement.importClause;
if (!clause) {
return true;
}
if (clause.isTypeOnly) {
return false;
}
if (clause.name || !clause.namedBindings || ts.isNamespaceImport(clause.namedBindings)) {
return true;
}
return clause.namedBindings.elements.some(element => !element.isTypeOnly);
}
function resolveSourceImport(importer: string, moduleSpecifier: string): string | undefined {
const unresolvedPath = path.resolve(path.dirname(importer), moduleSpecifier);
const candidates = [
unresolvedPath,
unresolvedPath.replace(/\.js$/, '.ts'),
unresolvedPath.replace(/\.js$/, '.tsx'),
path.join(unresolvedPath, 'index.ts'),
];
return candidates.find(candidate => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
}
function getPackageName(moduleSpecifier: string): string {
const segments = moduleSpecifier.split('/');
return moduleSpecifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0];
}
+20
View File
@@ -16,6 +16,7 @@
"@parcel/watcher": "^2.5.6",
"@vscode/copilot-api": "^0.4.2",
"@vscode/deviceid": "^0.1.1",
"@vscode/fs-copyfile": "2.0.0",
"@vscode/iconv-lite-umd": "0.7.1",
"@vscode/native-watchdog": "^1.4.6",
"@vscode/proxy-agent": "^0.44.0",
@@ -644,6 +645,25 @@
"uuid": "^14.0.0"
}
},
"node_modules/@vscode/fs-copyfile": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@vscode/fs-copyfile/-/fs-copyfile-2.0.0.tgz",
"integrity": "sha512-ARb4+9rN905WjJtQ2mSBG/q4pjJkSRun/MkfCeRkk7h/5J8w4vd18NCePFJ/ZucIwXx/7mr9T6nz9Vtt1tk7hg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.0.0"
},
"engines": {
"node": ">=22.6.0"
}
},
"node_modules/@vscode/fs-copyfile/node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/@vscode/iconv-lite-umd": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/@vscode/iconv-lite-umd/-/iconv-lite-umd-0.7.1.tgz",
+2
View File
@@ -11,6 +11,7 @@
"@parcel/watcher": "^2.5.6",
"@vscode/copilot-api": "^0.4.2",
"@vscode/deviceid": "^0.1.1",
"@vscode/fs-copyfile": "2.0.0",
"@vscode/iconv-lite-umd": "0.7.1",
"@vscode/native-watchdog": "^1.4.6",
"@vscode/proxy-agent": "^0.44.0",
@@ -65,6 +66,7 @@
"node-pty@1.2.0-beta.13": true,
"@parcel/watcher@2.5.6": true,
"@vscode/deviceid@0.1.5": true,
"@vscode/fs-copyfile@2.0.0": true,
"@vscode/native-watchdog@1.4.6": true,
"@vscode/spdlog@0.15.8": true,
"@vscode/windows-registry@1.2.0": true,