Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@
`-dev` prerelease marker so the version on `main` never equals a published version (registry
scanners diff the tool surface per version string, and a moving surface under a released number
reads as a republish). `release:prepare` refuses to publish while the `-dev` marker is in place.
- Windows `--platform web` works again. `agent-device web setup` no longer fails with
`npm not found in PATH`, and every web command — including `web doctor` — no longer fails with
`spawn EINVAL`. The managed `agent-browser` backend is now launched as `node <js-entry>` on every
platform instead of through its `node_modules/.bin` console shim, which is a `.cmd` file on
Windows that `child_process.spawn` refuses without a shell (CVE-2024-27980 hardening);
`shell: true` would only trade that for argument-quoting hazards and a `DEP0190` warning on every
command. Setup spawns `npm` from PATH unchanged on macOS and Linux, and runs npm's own
`npm-cli.js` under the current Node only on Windows, where a bare `npm` is not spawnable. A
managed install now counts as present only when the backend package itself is, and
`web setup --json` / `web doctor --json` gain `entryScript` and `packageDir`; the published
`binaryPath` is unchanged and still names npm's console shim, now informational rather than the
spawned command (#2022).
- Parameterized `fill --record-as` protection is now recording-session-scoped instead of
fill-step-scoped (ADR 0017 amendment): a later, unrelated recorded action (`wait`, `is`, `get`) can no
longer re-serialize an app-rendered echo of an already-parameterized value into its own result or
Expand Down
6 changes: 6 additions & 0 deletions docs/agents/web-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ Web automation uses a managed `agent-browser` backend as an implementation detai
- Use `agent-device web doctor` to run the backend health check.
- The managed install respects `--state-dir` / `AGENT_DEVICE_STATE_DIR`.
- Web automation requires Node 24+ while the rest of agent-device keeps its Node 22 baseline.
- Every backend call spawns the package's declared `bin` entry with the current Node runtime,
never the `node_modules/.bin` console shim: Windows ships that shim as `.cmd`, which
`child_process.spawn` refuses without a shell (CVE-2024-27980 hardening), and a shell would
reintroduce argument-quoting hazards. `runManagedAgentBrowser` is the only path that executes
the backend. Setup spawns `npm` from PATH as before, except on Windows, where a bare `npm` is
not spawnable and npm's own `npm-cli.js` runs under the current Node instead.

Default first-run flow:

Expand Down
108 changes: 108 additions & 0 deletions src/__tests__/cli-web.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { test } from 'vitest';
import { runCliCapture } from './cli-capture.ts';
import { mkdtempForTestSync } from './test-utils/tmp-dir.ts';
import { withCommandExecutorOverride } from '../utils/exec.ts';
import {
installFakeManagedAgentBrowser,
withNodeRuntime,
writeFakeManagedAgentBrowserPackage,
} from '../platforms/web/__tests__/test-utils.ts';

type SpawnedCommand = { cmd: string; args: string[] };

// `binaryPath` has been in the published `web setup`/`web doctor` JSON since #833.
// The Windows spawn fix (#2022) moves execution to `node <entryScript>` and adds
// `entryScript`/`packageDir`, but the released field stays in the contract.
test('web doctor --json keeps the published status fields and spawns the JS entry', async () => {
const stateDir = mkdtempForTestSync('agent device cli web doctor ');
try {
const install = installFakeManagedAgentBrowser(stateDir);
const spawned: SpawnedCommand[] = [];

const result = await withCommandExecutorOverride(
async (cmd, args) => {
spawned.push({ cmd, args });
return { stdout: 'ok', stderr: '', exitCode: 0 };
},
async () =>
await runCliCapture(['web', 'doctor', '--json'], {
env: { AGENT_DEVICE_STATE_DIR: stateDir },
}),
);

const status = parseStatus(result.stdout);
assert.equal(status.binaryPath, install.binaryPath);
assert.equal(status.entryScript, install.entryScript);
assert.equal(status.packageDir, install.packageDir);
assert.equal(status.installDir, install.installDir);
assert.equal(status.installed, true);
assert.equal(status.socketDir, undefined);
assert.deepEqual(spawned, [
{ cmd: process.execPath, args: [install.entryScript, 'doctor', '--offline', '--quick'] },
]);
assert.equal(result.calls.length, 0);
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});

test('web setup --json keeps the published status fields after installing', async () => {
const stateDir = mkdtempForTestSync('agent device cli web setup ');
try {
let install: ReturnType<typeof writeFakeManagedAgentBrowserPackage> | undefined;
let stdout = '';

await withNodeRuntime({ version: '24.13.0' }, async () => {
const result = await withCommandExecutorOverride(
async (_cmd, args) => {
// Stand in for the npm run that writes the managed package tree.
if (args.includes('--prefix')) install = writeFakeManagedAgentBrowserPackage(stateDir);
return { stdout: '', stderr: '', exitCode: 0 };
},
async () =>
await runCliCapture(['web', 'setup', '--json'], {
env: { AGENT_DEVICE_STATE_DIR: stateDir },
}),
);
stdout = result.stdout;
});

const status = parseStatus(stdout);
assert.equal(status.binaryPath, install?.binaryPath);
assert.equal(status.entryScript, install?.entryScript);
assert.equal(status.packageDir, install?.packageDir);
assert.equal(status.installed, true);
assert.equal(status.socketDir, undefined);
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});

function parseStatus(stdout: string): Record<string, unknown> {
const payload: unknown = JSON.parse(firstJsonDocument(stdout));
assert.ok(isRecord(payload) && payload.success === true, stdout);
const data = payload.data;
assert.ok(isRecord(data), stdout);
const status = data.status;
assert.ok(isRecord(status), stdout);
return status;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}

/**
* The CLI dispatches `web` inside its top-level try, and the capture harness
* turns `process.exit` into a throw, so the command's payload is followed by the
* CLI's own report of that synthetic exit. Only the first document is the
* command's own output; real runs exit for real and print it once.
*/
function firstJsonDocument(stdout: string): string {
const lines = stdout.split('\n');
const end = lines.indexOf('}');
assert.ok(end >= 0, stdout);
return lines.slice(0, end + 1).join('\n');
}
Original file line number Diff line number Diff line change
Expand Up @@ -351,8 +351,10 @@ test('daemon session teardown closes an open web session immediately, not on age
// fleet right away, the same way an explicit `session close` does, instead of leaving the
// Chrome processes to agent-browser's own multi-minute idle timer.
expect(mockRunCmd).toHaveBeenCalledTimes(1);
const [, args] = mockRunCmd.mock.calls[0] as [string, string[]];
expect(args).toEqual(['close', '--json', '--session', sessionName]);
const [cmd, args] = mockRunCmd.mock.calls[0] as [string, string[]];
// `node <entry> close ...`: the managed backend never runs through its `.bin` shim (#2022).
expect(cmd).toBe(process.execPath);
expect(args.slice(1)).toEqual(['close', '--json', '--session', sessionName]);
});

test('daemon session teardown surfaces a web close failure through the cleanup-failure channel', async () => {
Expand Down
9 changes: 8 additions & 1 deletion src/daemon/server/daemon-runtime-web-close-teardown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,14 @@ test('daemon shutdown closes an open web session immediately, without waiting fo
expect(stderrChunks.join('')).toBe('');
expect(sessionStore.get(session.name)).toBeUndefined();
const closeCall = mockRunCmd.mock.calls.find(([, args]) => (args as string[]).includes('close'));
expect(closeCall?.[1]).toEqual(['close', '--json', '--session', session.name]);
// `node <entry> close ...`: the managed backend never runs through its `.bin` shim (#2022).
expect(closeCall?.[0]).toBe(process.execPath);
expect((closeCall?.[1] as string[] | undefined)?.slice(1)).toEqual([
'close',
'--json',
'--session',
session.name,
]);
});

test('daemon shutdown reports a web close failure on stderr instead of losing it silently', async () => {
Expand Down
57 changes: 56 additions & 1 deletion src/platforms/web/__tests__/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,81 @@ const TEST_AGENT_BROWSER_VERSION = '0.27.1';
type FakeManagedAgentBrowserInstall = ReturnType<typeof expectedManagedAgentBrowserInstall>;

export function installFakeManagedAgentBrowser(stateDir: string): FakeManagedAgentBrowserInstall {
const install = writeFakeManagedAgentBrowserPackage(stateDir);
fs.writeFileSync(path.join(install.installDir, 'manifest.json'), '{}');
return install;
}

/** The package tree `npm install` leaves behind, without the setup manifest. */
export function writeFakeManagedAgentBrowserPackage(
stateDir: string,
): FakeManagedAgentBrowserInstall {
const install = expectedManagedAgentBrowserInstall(stateDir);
fs.mkdirSync(path.dirname(install.entryScript), { recursive: true });
fs.writeFileSync(install.entryScript, 'process.exit(0)\n');
// npm links a console shim beside the package; the fixture carries it so tests
// prove the shim is never spawned rather than merely absent (#2022).
fs.mkdirSync(path.dirname(install.binaryPath), { recursive: true });
fs.writeFileSync(install.binaryPath, '#!/bin/sh\nexit 0\n');
fs.chmodSync(install.binaryPath, 0o755);
fs.writeFileSync(path.join(install.installDir, 'manifest.json'), '{}');
fs.writeFileSync(
path.join(install.packageDir, 'package.json'),
JSON.stringify({
name: 'agent-browser',
version: TEST_AGENT_BROWSER_VERSION,
bin: { 'agent-browser': './dist/cli.js' },
}),
);
return install;
}

/** npm's own JS launcher, for tests that drive managed setup without a real npm. */
export function writeFakeNpmCliScript(root: string): string {
const npmCliScript = path.join(root, 'node runtime', 'node_modules', 'npm', 'bin', 'npm-cli.js');
fs.mkdirSync(path.dirname(npmCliScript), { recursive: true });
fs.writeFileSync(npmCliScript, 'process.exit(0)\n');
return npmCliScript;
}

/** Reports a different Node runtime to the code under test for one scenario. */
export async function withNodeRuntime(
overrides: { version?: string; platform?: NodeJS.Platform; execPath?: string },
testFn: () => void | Promise<void>,
): Promise<void> {
const restore: (() => void)[] = [];
const override = (target: object, key: string, value: unknown) => {
const original = (target as Record<string, unknown>)[key];
Object.defineProperty(target, key, { value, configurable: true });
restore.push(() => Object.defineProperty(target, key, { value: original, configurable: true }));
};
if (overrides.version !== undefined) {
override(process.versions, 'node', overrides.version);
override(process, 'version', `v${overrides.version}`);
}
if (overrides.platform !== undefined) override(process, 'platform', overrides.platform);
if (overrides.execPath !== undefined) override(process, 'execPath', overrides.execPath);
try {
await testFn();
} finally {
for (const undo of restore.reverse()) undo();
}
}

function expectedManagedAgentBrowserInstall(stateDir: string) {
const installDir = path.join(stateDir, 'tools', 'agent-browser', TEST_AGENT_BROWSER_VERSION);
const packageDir = path.join(installDir, 'package', 'node_modules', 'agent-browser');
return {
version: TEST_AGENT_BROWSER_VERSION,
installDir,
packageDir,
binaryPath: path.join(
installDir,
'package',
'node_modules',
'.bin',
process.platform === 'win32' ? 'agent-browser.cmd' : 'agent-browser',
),
entryScript: path.join(packageDir, 'dist', 'cli.js'),
homeDir: path.join(installDir, 'home'),
runtimeHomeDir:
process.platform === 'win32'
Expand Down
93 changes: 93 additions & 0 deletions src/platforms/web/agent-browser-install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import fs from 'node:fs';
import path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import { runCmd } from '../../utils/exec.ts';

/**
* How the managed backend gets onto disk. Kept apart from the tool module,
* which owns locating and running what this leaves behind.
*/
export async function installManagedAgentBrowserPackage(params: {
packageRoot: string;
packageSpec: string;
timeoutMs: number;
}): Promise<void> {
fs.mkdirSync(params.packageRoot, { recursive: true });
// `--no-global` keeps an ambient `npm_config_global` from redirecting the
// install out of the managed prefix, where the backend entry would be missed.
const npm = npmCommand([
'install',
'--prefix',
params.packageRoot,
'--no-global',
'--no-audit',
'--no-fund',
'--no-save',
params.packageSpec,
]);
await runCmd(npm.command, npm.args, { env: process.env, timeoutMs: params.timeoutMs });
}

export function writeManagedAgentBrowserManifest(params: {
installDir: string;
packageName: string;
version: string;
}): void {
fs.writeFileSync(
path.join(params.installDir, 'manifest.json'),
JSON.stringify(
{
package: params.packageName,
version: params.version,
node: process.version,
installedAt: new Date().toISOString(),
},
null,
2,
),
'utf8',
);
}

/**
* POSIX spawns `npm` from PATH exactly as it always has. Only Windows is
* broken: npm ships as `npm.cmd` there, which `child_process.spawn` refuses
* without a shell since the CVE-2024-27980 fix, so its JS entry runs under the
* current Node runtime instead (#2022).
*/
function npmCommand(args: string[]): { command: string; args: string[] } {
if (process.platform !== 'win32') return { command: 'npm', args };
const npmCliScript = resolveWindowsNpmCliScript(process.env);
if (!npmCliScript) {
throw new AppError('TOOL_MISSING', 'npm not found in PATH', {
nodeExecPath: process.execPath,
hint: 'Install Node.js with npm, or add npm to PATH, and run `agent-device web setup` again.',
});
}
return { command: process.execPath, args: [npmCliScript, ...args] };
}

function resolveWindowsNpmCliScript(env: NodeJS.ProcessEnv): string | undefined {
const advertised = env.npm_execpath?.trim();
if (advertised) {
const scriptPath = path.resolve(advertised);
// pnpm and yarn advertise their own launcher through the same variable.
if (path.basename(scriptPath) === 'npm-cli.js' && isFile(scriptPath)) return scriptPath;
}
const bundled = path.join(
path.dirname(process.execPath),
'node_modules',
'npm',
'bin',
'npm-cli.js',
);
return isFile(bundled) ? bundled : undefined;
}

function isFile(filePath: string): boolean {
try {
return fs.statSync(filePath).isFile();
} catch {
return false;
}
}
8 changes: 5 additions & 3 deletions src/platforms/web/agent-browser-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ test('records the managed browser daemon and Chrome fleet at the spawn owner', a
const store = { replace: vi.fn(), clear: vi.fn(), read: vi.fn(() => []) };
mockRunCmd.mockResolvedValue({
stdout: [
` 101 1 ${status.binaryPath}`,
` 101 1 ${process.execPath} ${status.entryScript}`,
` 201 101 /Applications/Chromium.app/Contents/MacOS/Chromium ${marker}`,
' 301 201 /Applications/Chromium.app/Contents/Frameworks/Chromium Helper --type=gpu',
' 999 1 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
Expand Down Expand Up @@ -315,8 +315,10 @@ test('managed process summary includes the recordable agent-browser daemon', ()
installFakeManagedAgentBrowser(stateDir);
const status = getManagedAgentBrowserStatus({ stateDir });
expect(
summarizeManagedAgentBrowserProcesses([{ pid: 101, command: status.binaryPath }], status)
.processes[0]?.reason,
summarizeManagedAgentBrowserProcesses(
[{ pid: 101, command: `${process.execPath} ${status.entryScript}` }],
status,
).processes[0]?.reason,
).toBe('agent-browser-daemon');
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
Expand Down
Loading
Loading