pi-agent-browser-native 0.2.38 → 0.2.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +18 -5
- package/docs/ARCHITECTURE.md +2 -2
- package/docs/COMMAND_REFERENCE.md +15 -12
- package/docs/RELEASE.md +19 -7
- package/docs/SUPPORT_MATRIX.md +9 -7
- package/docs/TOOL_CONTRACT.md +1 -1
- package/docs/platform-smoke.md +176 -0
- package/extensions/agent-browser/lib/playbook.ts +2 -2
- package/extensions/agent-browser/lib/process.ts +72 -13
- package/package.json +13 -1
- package/platform-smoke.config.mjs +18 -0
- package/scripts/agent-browser-capability-baseline.mjs +9 -6
- package/scripts/platform-smoke/artifacts.mjs +94 -0
- package/scripts/platform-smoke/browser-dogfood-windows.ps1 +110 -0
- package/scripts/platform-smoke/crabbox-runner.mjs +149 -0
- package/scripts/platform-smoke/doctor.mjs +307 -0
- package/scripts/platform-smoke/linux-image/Dockerfile +23 -0
- package/scripts/platform-smoke/platform-build-windows.ps1 +103 -0
- package/scripts/platform-smoke/targets.mjs +471 -0
- package/scripts/platform-smoke.mjs +161 -0
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Purpose: Execute the upstream agent-browser binary for the pi-agent-browser extension.
|
|
3
|
-
* Responsibilities: Spawn the agent-browser subprocess
|
|
3
|
+
* Responsibilities: Spawn the agent-browser subprocess, forward a curated environment surface, stream optional stdin, bound in-memory output buffering, spill oversized stdout safely to a private temp file under a disk budget, and honor abort signals.
|
|
4
4
|
* Scope: Process execution only; argument planning, output formatting, and pi tool registration live elsewhere.
|
|
5
5
|
* Usage: Called by the extension tool after argument validation and session planning are complete.
|
|
6
|
-
* Invariants/Assumptions: The binary name is always `agent-browser
|
|
6
|
+
* Invariants/Assumptions: The binary name is always `agent-browser`; Windows routes through PowerShell to invoke npm launchers with escaped argv; callers handle semantic success/error interpretation.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
|
10
10
|
import { chmod, mkdir } from "node:fs/promises";
|
|
11
11
|
import { env as processEnv, platform as processPlatform } from "node:process";
|
|
12
12
|
|
|
13
|
+
import { GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES, GLOBAL_VALUE_FLAGS, getFlagName } from "./argv-grammar.js";
|
|
13
14
|
import { openSecureTempFile, writeSecureTempChunk } from "./temp.js";
|
|
14
15
|
|
|
15
16
|
const MAX_BUFFERED_STDOUT_BYTES = 512 * 1_024;
|
|
@@ -107,6 +108,52 @@ function appendTail(text: string, addition: string, maxChars: number): string {
|
|
|
107
108
|
return combined.length <= maxChars ? combined : combined.slice(combined.length - maxChars);
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
function quoteWindowsPowerShellArg(value: string): string {
|
|
112
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const WINDOWS_LEADING_GLOBAL_VALUE_FLAGS = new Set<string>(GLOBAL_VALUE_FLAGS);
|
|
116
|
+
|
|
117
|
+
/** Exported for unit tests that lock Windows launcher argv ordering. */
|
|
118
|
+
export function reorderWindowsLeadingGlobalArgs(args: string[]): string[] {
|
|
119
|
+
const leadingGlobals: string[] = [];
|
|
120
|
+
let index = 0;
|
|
121
|
+
while (index < args.length && args[index]?.startsWith("-")) {
|
|
122
|
+
const token = args[index];
|
|
123
|
+
const flagName = getFlagName(token);
|
|
124
|
+
leadingGlobals.push(token);
|
|
125
|
+
index += 1;
|
|
126
|
+
if (WINDOWS_LEADING_GLOBAL_VALUE_FLAGS.has(flagName) && !token.includes("=") && index < args.length) {
|
|
127
|
+
leadingGlobals.push(args[index]);
|
|
128
|
+
index += 1;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (GLOBAL_BOOLEAN_FLAGS_WITH_OPTIONAL_VALUES.has(flagName) && ["true", "false"].includes(args[index] ?? "")) {
|
|
132
|
+
leadingGlobals.push(args[index]);
|
|
133
|
+
index += 1;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (leadingGlobals.length === 0 || index >= args.length) return args;
|
|
137
|
+
return [args[index], ...leadingGlobals, ...args.slice(index + 1)];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildAgentBrowserSpawnCommand(args: string[]): { command: string; args: string[] } {
|
|
141
|
+
if (processPlatform !== "win32") {
|
|
142
|
+
return { command: "agent-browser", args };
|
|
143
|
+
}
|
|
144
|
+
const commandLine = ["&", "agent-browser", ...reorderWindowsLeadingGlobalArgs(args).map(quoteWindowsPowerShellArg)].join(" ");
|
|
145
|
+
return { command: "powershell.exe", args: ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", commandLine] };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function terminateSpawnedChild(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void {
|
|
149
|
+
if (processPlatform === "win32" && child.pid) {
|
|
150
|
+
const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
151
|
+
killer.on("error", () => undefined);
|
|
152
|
+
killer.unref();
|
|
153
|
+
}
|
|
154
|
+
child.kill(signal);
|
|
155
|
+
}
|
|
156
|
+
|
|
110
157
|
/** Exported for unit tests that lock subprocess exit-code precedence. */
|
|
111
158
|
export function resolveSpawnedChildExitCode(input: {
|
|
112
159
|
closeCode?: number | null;
|
|
@@ -234,17 +281,27 @@ async function ensureAgentBrowserSocketDir(socketDir: string): Promise<boolean>
|
|
|
234
281
|
}
|
|
235
282
|
}
|
|
236
283
|
|
|
284
|
+
function getChildEnvName(name: string): string | undefined {
|
|
285
|
+
if (processPlatform === "win32") {
|
|
286
|
+
const upperName = name.toUpperCase();
|
|
287
|
+
if (INHERITED_ENV_NAMES.has(upperName)) return upperName;
|
|
288
|
+
return INHERITED_ENV_PREFIXES.some((prefix) => upperName.startsWith(prefix)) ? upperName : undefined;
|
|
289
|
+
}
|
|
290
|
+
if (INHERITED_ENV_NAMES.has(name) || INHERITED_ENV_PREFIXES.some((prefix) => name.startsWith(prefix))) {
|
|
291
|
+
return name;
|
|
292
|
+
}
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
237
296
|
export function buildAgentBrowserProcessEnv(
|
|
238
297
|
baseEnv: NodeJS.ProcessEnv = processEnv,
|
|
239
298
|
overrides: NodeJS.ProcessEnv | undefined = undefined,
|
|
240
299
|
): NodeJS.ProcessEnv {
|
|
241
300
|
const childEnv: NodeJS.ProcessEnv = {};
|
|
242
301
|
for (const [name, value] of Object.entries(baseEnv)) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
) {
|
|
247
|
-
childEnv[name] = value;
|
|
302
|
+
const childName = getChildEnvName(name);
|
|
303
|
+
if (value !== undefined && childName) {
|
|
304
|
+
childEnv[childName] = value;
|
|
248
305
|
}
|
|
249
306
|
}
|
|
250
307
|
|
|
@@ -254,10 +311,11 @@ export function buildAgentBrowserProcessEnv(
|
|
|
254
311
|
}
|
|
255
312
|
|
|
256
313
|
for (const [name, value] of Object.entries(overrides)) {
|
|
314
|
+
const childName = getChildEnvName(name) ?? name;
|
|
257
315
|
if (value === undefined) {
|
|
258
|
-
delete childEnv[
|
|
316
|
+
delete childEnv[childName];
|
|
259
317
|
} else {
|
|
260
|
-
childEnv[
|
|
318
|
+
childEnv[childName] = value;
|
|
261
319
|
}
|
|
262
320
|
}
|
|
263
321
|
clampUpstreamDefaultTimeout(childEnv);
|
|
@@ -371,7 +429,8 @@ export async function runAgentBrowserProcess(options: {
|
|
|
371
429
|
});
|
|
372
430
|
};
|
|
373
431
|
|
|
374
|
-
const
|
|
432
|
+
const spawnCommand = buildAgentBrowserSpawnCommand(args);
|
|
433
|
+
const child = spawn(spawnCommand.command, spawnCommand.args, {
|
|
375
434
|
cwd,
|
|
376
435
|
env: buildAgentBrowserProcessEnv(processEnv, effectiveEnv),
|
|
377
436
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -384,15 +443,15 @@ export async function runAgentBrowserProcess(options: {
|
|
|
384
443
|
} else {
|
|
385
444
|
timedOut = true;
|
|
386
445
|
}
|
|
387
|
-
child
|
|
446
|
+
terminateSpawnedChild(child, "SIGTERM");
|
|
388
447
|
killTimer = setTimeout(() => {
|
|
389
|
-
child
|
|
448
|
+
terminateSpawnedChild(child, "SIGKILL");
|
|
390
449
|
}, 2_000);
|
|
391
450
|
};
|
|
392
451
|
const recordStdinError = (error: unknown) => {
|
|
393
452
|
const stdinError = error instanceof Error ? error : new Error(String(error));
|
|
394
453
|
const errorCode = (stdinError as NodeJS.ErrnoException).code;
|
|
395
|
-
if (errorCode === "EPIPE" || errorCode === "ERR_STREAM_DESTROYED") {
|
|
454
|
+
if (errorCode === "EPIPE" || errorCode === "EOF" || errorCode === "ERR_STREAM_DESTROYED") {
|
|
396
455
|
return;
|
|
397
456
|
}
|
|
398
457
|
if (!spawnError) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-agent-browser-native",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.39",
|
|
4
4
|
"description": "pi extension that exposes agent-browser as a native tool for browser automation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Mitch Fultz (https://github.com/fitchmultz)",
|
|
@@ -31,8 +31,11 @@
|
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"extensions",
|
|
34
|
+
"platform-smoke.config.mjs",
|
|
34
35
|
"scripts/doctor.mjs",
|
|
35
36
|
"scripts/agent-browser-capability-baseline.mjs",
|
|
37
|
+
"scripts/platform-smoke.mjs",
|
|
38
|
+
"scripts/platform-smoke",
|
|
36
39
|
"README.md",
|
|
37
40
|
"CHANGELOG.md",
|
|
38
41
|
"LICENSE",
|
|
@@ -40,6 +43,7 @@
|
|
|
40
43
|
"docs/COMMAND_REFERENCE.md",
|
|
41
44
|
"docs/ELECTRON.md",
|
|
42
45
|
"docs/RELEASE.md",
|
|
46
|
+
"docs/platform-smoke.md",
|
|
43
47
|
"docs/REQUIREMENTS.md",
|
|
44
48
|
"docs/SUPPORT_MATRIX.md",
|
|
45
49
|
"docs/TOOL_CONTRACT.md"
|
|
@@ -71,6 +75,14 @@
|
|
|
71
75
|
"docs": "node ./scripts/project.mjs docs",
|
|
72
76
|
"doctor": "node ./scripts/doctor.mjs",
|
|
73
77
|
"benchmark:agent-browser": "node ./scripts/agent-browser-efficiency-benchmark.mjs",
|
|
78
|
+
"check:platform-smoke": "node --check platform-smoke.config.mjs && node --check scripts/platform-smoke.mjs && node --check scripts/platform-smoke/doctor.mjs && node --check scripts/platform-smoke/crabbox-runner.mjs && node --check scripts/platform-smoke/targets.mjs && node --check scripts/platform-smoke/artifacts.mjs && tsx --test test/platform-smoke.test.ts",
|
|
79
|
+
"smoke:platform": "node scripts/platform-smoke.mjs",
|
|
80
|
+
"smoke:platform:doctor": "node scripts/platform-smoke.mjs doctor",
|
|
81
|
+
"smoke:platform:ubuntu-image": "docker build -t pi-agent-browser-native-platform:node24-agent-browser0.27.1 --build-arg AGENT_BROWSER_VERSION=0.27.1 -f scripts/platform-smoke/linux-image/Dockerfile .",
|
|
82
|
+
"smoke:platform:macos": "node scripts/platform-smoke.mjs run --target macos",
|
|
83
|
+
"smoke:platform:ubuntu": "node scripts/platform-smoke.mjs run --target ubuntu",
|
|
84
|
+
"smoke:platform:windows-native": "node scripts/platform-smoke.mjs run --target windows-native",
|
|
85
|
+
"smoke:platform:all": "npm run smoke:platform:doctor && node scripts/platform-smoke.mjs run --target macos,ubuntu,windows-native",
|
|
74
86
|
"typecheck": "node ./scripts/project.mjs verify typecheck",
|
|
75
87
|
"test": "tsx --test test/**/*.test.ts",
|
|
76
88
|
"verify": "node ./scripts/project.mjs verify",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Platform smoke configuration for pi-agent-browser-native.
|
|
2
|
+
// Crabbox owns the target lease/sync loop; this file is the project source of truth for release-blocking platform coverage.
|
|
3
|
+
|
|
4
|
+
import { CAPABILITY_BASELINE } from "./scripts/agent-browser-capability-baseline.mjs";
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
packageName: "pi-agent-browser-native",
|
|
8
|
+
artifactRoot: ".artifacts/platform-smoke",
|
|
9
|
+
requiredTargets: ["macos", "ubuntu", "windows-native"],
|
|
10
|
+
requiredSuites: ["platform-build", "browser-dogfood-smoke"],
|
|
11
|
+
requiredCrabbox: {
|
|
12
|
+
install: "Homebrew package or PLATFORM_SMOKE_CRABBOX override",
|
|
13
|
+
minVersion: "0.24.0",
|
|
14
|
+
},
|
|
15
|
+
ubuntuContainerImage: "pi-agent-browser-native-platform:node24-agent-browser0.27.1",
|
|
16
|
+
nodeValidationMajor: 22,
|
|
17
|
+
agentBrowserVersion: CAPABILITY_BASELINE.targetVersion,
|
|
18
|
+
};
|
|
@@ -14,8 +14,8 @@ export const COMMAND_REFERENCE_BASELINE_BLOCK_IDS = Object.freeze(["upstream-bas
|
|
|
14
14
|
|
|
15
15
|
const sourceEvidence = Object.freeze({
|
|
16
16
|
repository: "vercel-labs/agent-browser",
|
|
17
|
-
upstreamHead: "
|
|
18
|
-
upstreamPackageVersion: "0.27.
|
|
17
|
+
upstreamHead: "90050f2913159875e2c3719e424746396ccb3cbf",
|
|
18
|
+
upstreamPackageVersion: "0.27.1",
|
|
19
19
|
inspectedSources: Object.freeze([
|
|
20
20
|
"agent-browser --version",
|
|
21
21
|
"agent-browser --help",
|
|
@@ -349,7 +349,8 @@ const inventorySections = Object.freeze([
|
|
|
349
349
|
"diff screenshot --baseline <file> --output <file> --threshold <0-1> --selector <sel> --full",
|
|
350
350
|
"diff url <u1> <u2>",
|
|
351
351
|
"diff url <u1> <u2> --screenshot --wait-until <strategy> --selector <sel> --compact --depth <n>",
|
|
352
|
-
"trace start
|
|
352
|
+
"trace start",
|
|
353
|
+
"trace stop [path]",
|
|
353
354
|
"profiler start|stop [path]",
|
|
354
355
|
"record start <path> [url]",
|
|
355
356
|
"record restart <path> [url]",
|
|
@@ -386,7 +387,8 @@ const inventorySections = Object.freeze([
|
|
|
386
387
|
root("storage <local|session>"),
|
|
387
388
|
root("diff snapshot"),
|
|
388
389
|
root("diff screenshot --baseline"),
|
|
389
|
-
root("trace start
|
|
390
|
+
root("trace start"),
|
|
391
|
+
root("trace stop [path]"),
|
|
390
392
|
root("profiler start|stop [path]"),
|
|
391
393
|
root("record start <path> [url]"),
|
|
392
394
|
root("record stop"),
|
|
@@ -422,7 +424,8 @@ const inventorySections = Object.freeze([
|
|
|
422
424
|
["diff help", "--threshold <0-1>"],
|
|
423
425
|
["diff help", "--wait-until <strategy>"],
|
|
424
426
|
["diff help", "diff screenshot --baseline <f>"],
|
|
425
|
-
["trace help", "trace
|
|
427
|
+
["trace help", "trace start"],
|
|
428
|
+
["trace help", "trace stop [path]"],
|
|
426
429
|
["profiler help", "--categories <list>"],
|
|
427
430
|
["record help", "record restart <path.webm> [url]"],
|
|
428
431
|
["console help", "--clear"],
|
|
@@ -703,7 +706,7 @@ const inventorySections = Object.freeze([
|
|
|
703
706
|
]);
|
|
704
707
|
|
|
705
708
|
export const CAPABILITY_BASELINE = Object.freeze({
|
|
706
|
-
targetVersion: "0.27.
|
|
709
|
+
targetVersion: "0.27.1",
|
|
707
710
|
sourceEvidence,
|
|
708
711
|
helpCommands,
|
|
709
712
|
inventorySections,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Artifact helpers for platform smoke suites. */
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { relative, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
export function createSuiteDir(artifactRoot, runId, targetName, suiteName) {
|
|
7
|
+
const dir = resolve(process.cwd(), artifactRoot, runId, targetName, suiteName);
|
|
8
|
+
mkdirSync(dir, { recursive: true });
|
|
9
|
+
return dir;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function writeCommand(dir, command) {
|
|
13
|
+
writeFileSync(resolve(dir, "command.txt"), `${command}\n`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function writeExitCode(dir, code, signal) {
|
|
17
|
+
writeFileSync(resolve(dir, "exit-code.txt"), `code=${code}\nsignal=${signal ?? "none"}\n`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function writeSummary(dir, data) {
|
|
21
|
+
writeFileSync(resolve(dir, "summary.json"), JSON.stringify({ ...data, writtenAt: new Date().toISOString() }, null, 2));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function writeManifest(dir, expectedFiles) {
|
|
25
|
+
const present = [];
|
|
26
|
+
function walk(current) {
|
|
27
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
28
|
+
const path = resolve(current, entry.name);
|
|
29
|
+
if (entry.isDirectory()) walk(path);
|
|
30
|
+
else if (entry.isFile()) present.push(relative(dir, path));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (existsSync(dir)) walk(dir);
|
|
34
|
+
const allPresent = [...new Set([...present, "artifact-manifest.json"])].sort();
|
|
35
|
+
const manifest = {
|
|
36
|
+
expected: expectedFiles,
|
|
37
|
+
present: allPresent,
|
|
38
|
+
missing: expectedFiles.filter((file) => !allPresent.includes(file)),
|
|
39
|
+
writtenAt: new Date().toISOString(),
|
|
40
|
+
};
|
|
41
|
+
writeFileSync(resolve(dir, "artifact-manifest.json"), JSON.stringify(manifest, null, 2));
|
|
42
|
+
return manifest;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function collectSecretValues(envNames, env = process.env) {
|
|
46
|
+
return [...new Set(envNames.map((name) => env[name]).filter((value) => typeof value === "string" && value.length >= 8))];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function redactSecrets(text, secretValues = []) {
|
|
50
|
+
let redacted = String(text ?? "");
|
|
51
|
+
for (const secret of secretValues) {
|
|
52
|
+
redacted = redacted.split(secret).join("[REDACTED_SECRET]");
|
|
53
|
+
}
|
|
54
|
+
return redacted;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function scanForSecrets(text, secretValues = []) {
|
|
58
|
+
const content = String(text ?? "");
|
|
59
|
+
const violations = [];
|
|
60
|
+
for (const secret of secretValues) {
|
|
61
|
+
if (secret && content.includes(secret)) violations.push("raw forwarded secret value");
|
|
62
|
+
}
|
|
63
|
+
for (const [pattern, label] of [
|
|
64
|
+
[/bearer\s+[A-Za-z0-9\-._~+/]{20,}=*/gi, "bearer token"],
|
|
65
|
+
[/Authorization:\s*Bearer\s+[A-Za-z0-9\-._~+/]{20,}=*/gi, "authorization header"],
|
|
66
|
+
[/(?:api[_-]?key|access[_-]?token|refresh[_-]?token|cookie)\s*[:=]\s*["']?[A-Za-z0-9_./+\-=]{20,}/gi, "token-like field"],
|
|
67
|
+
]) {
|
|
68
|
+
if (pattern.test(content)) violations.push(label);
|
|
69
|
+
}
|
|
70
|
+
return [...new Set(violations)];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function scanArtifactTextFiles(dir, secretValues = []) {
|
|
74
|
+
const findings = [];
|
|
75
|
+
function walk(current) {
|
|
76
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
77
|
+
const path = resolve(current, entry.name);
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
walk(path);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!entry.isFile()) continue;
|
|
83
|
+
if (!/\.(?:txt|json|jsonl|md|log|ps1|mjs|js)$/i.test(entry.name)) continue;
|
|
84
|
+
try {
|
|
85
|
+
const text = readFileSync(path, "utf8");
|
|
86
|
+
for (const violation of scanForSecrets(text, secretValues)) findings.push({ file: relative(dir, path), violation });
|
|
87
|
+
} catch {
|
|
88
|
+
// Ignore unreadable or non-text files.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
walk(dir);
|
|
93
|
+
return findings;
|
|
94
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[Parameter(Mandatory=$true)][string]$AgentBrowserVersion
|
|
3
|
+
)
|
|
4
|
+
|
|
5
|
+
$ErrorActionPreference = "Continue"
|
|
6
|
+
$SourceRoot = (Get-Location).Path
|
|
7
|
+
$RunRoot = Join-Path ".platform-smoke-runs" ("browser-dogfood-{0}-{1}" -f ((Get-Date).ToUniversalTime().ToString("yyyyMMddTHHmmssZ")), $PID)
|
|
8
|
+
$DogfoodDir = Join-Path $SourceRoot (Join-Path $RunRoot "dogfood")
|
|
9
|
+
$DogfoodArtifactDir = Join-Path $env:TEMP ("pi-agent-browser-dogfood-artifacts-{0}" -f $PID)
|
|
10
|
+
New-Item -ItemType Directory -Force -Path $DogfoodDir | Out-Null
|
|
11
|
+
New-Item -ItemType Directory -Force -Path $DogfoodArtifactDir | Out-Null
|
|
12
|
+
|
|
13
|
+
function Write-Section($Name, $Path) {
|
|
14
|
+
Write-Output "--- $Name START ---"
|
|
15
|
+
if (Test-Path $Path) { Get-Content -Raw $Path }
|
|
16
|
+
Write-Output "--- $Name END ---"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function Get-AgentBrowserVersion() {
|
|
20
|
+
if (-not (Get-Command agent-browser -ErrorAction SilentlyContinue)) { return "" }
|
|
21
|
+
return (& agent-browser --version 2>$null)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function Test-AgentBrowser($Version) {
|
|
25
|
+
$Expected = "agent-browser $Version"
|
|
26
|
+
$Current = Get-AgentBrowserVersion
|
|
27
|
+
Write-Output "PLATFORM_AGENT_BROWSER_VERSION=$Current"
|
|
28
|
+
$script:AgentBrowserReadyExit = if ($Current -eq $Expected) { 0 } else { 1 }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function Test-AgentBrowserBrowserCache() {
|
|
32
|
+
$Candidates = @(
|
|
33
|
+
(Join-Path $env:USERPROFILE ".agent-browser\browsers"),
|
|
34
|
+
"C:\WINDOWS\system32\config\systemprofile\.agent-browser\browsers"
|
|
35
|
+
)
|
|
36
|
+
foreach ($Candidate in $Candidates) {
|
|
37
|
+
if (-not $Candidate -or -not (Test-Path $Candidate)) { continue }
|
|
38
|
+
$Chrome = Get-ChildItem -Path $Candidate -Recurse -Filter chrome.exe -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
39
|
+
if ($Chrome) {
|
|
40
|
+
Write-Output "PLATFORM_AGENT_BROWSER_BROWSER_PATH=$($Chrome.FullName)"
|
|
41
|
+
$script:BrowserCacheExit = 0
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
Write-Output "PLATFORM_AGENT_BROWSER_BROWSER_PATH="
|
|
46
|
+
$script:BrowserCacheExit = 1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
Write-Output "Starting browser-dogfood-smoke in $SourceRoot at $((Get-Date).ToUniversalTime().ToString('o'))"
|
|
50
|
+
Write-Output "PLATFORM_RUN_ROOT=$RunRoot"
|
|
51
|
+
Write-Output "PLATFORM_DOGFOOD_ARTIFACT_DIR=$DogfoodArtifactDir"
|
|
52
|
+
|
|
53
|
+
$NodeVersion = (& node --version 2>$null)
|
|
54
|
+
Write-Output "PLATFORM_NODE_VERSION=$NodeVersion"
|
|
55
|
+
|
|
56
|
+
& npm ci 2>&1
|
|
57
|
+
$NpmCiExit = $LASTEXITCODE
|
|
58
|
+
Write-Output "PLATFORM_NPM_CI_EXIT=$NpmCiExit"
|
|
59
|
+
|
|
60
|
+
$script:AgentBrowserReadyExit = 1
|
|
61
|
+
Test-AgentBrowser $AgentBrowserVersion
|
|
62
|
+
$AgentBrowserExit = $script:AgentBrowserReadyExit
|
|
63
|
+
Write-Output "PLATFORM_AGENT_BROWSER_READY_EXIT=$AgentBrowserExit"
|
|
64
|
+
$script:BrowserCacheExit = 1
|
|
65
|
+
if ($AgentBrowserExit -eq 0) { Test-AgentBrowserBrowserCache }
|
|
66
|
+
$BrowserCacheExit = $script:BrowserCacheExit
|
|
67
|
+
Write-Output "PLATFORM_AGENT_BROWSER_BROWSER_CACHE_EXIT=$BrowserCacheExit"
|
|
68
|
+
$BrowserPrewarmExit = 1
|
|
69
|
+
if ($BrowserCacheExit -eq 0) {
|
|
70
|
+
$PrewarmPath = Join-Path $DogfoodArtifactDir "prewarm.html"
|
|
71
|
+
"<h1>Example Domain</h1>" | Set-Content $PrewarmPath
|
|
72
|
+
$PrewarmUrl = "file:///" + ($PrewarmPath -replace "\\", "/")
|
|
73
|
+
for ($Attempt = 1; $Attempt -le 3; $Attempt++) {
|
|
74
|
+
Write-Output "PLATFORM_AGENT_BROWSER_PREWARM_ATTEMPT=$Attempt"
|
|
75
|
+
& agent-browser open --json --session "platform-smoke-prewarm-$Attempt" $PrewarmUrl 2>&1
|
|
76
|
+
$BrowserPrewarmExit = $LASTEXITCODE
|
|
77
|
+
& agent-browser close --json --session "platform-smoke-prewarm-$Attempt" 2>&1
|
|
78
|
+
if ($BrowserPrewarmExit -eq 0) { break }
|
|
79
|
+
Start-Sleep -Seconds 2
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
Write-Output "PLATFORM_AGENT_BROWSER_PREWARM_EXIT=$BrowserPrewarmExit"
|
|
83
|
+
|
|
84
|
+
$env:PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS = "55000"
|
|
85
|
+
Write-Output "PLATFORM_PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS=$env:PI_AGENT_BROWSER_PROCESS_TIMEOUT_MS"
|
|
86
|
+
|
|
87
|
+
$TsxCli = Join-Path $SourceRoot "node_modules/.bin/tsx.cmd"
|
|
88
|
+
if (-not (Test-Path $TsxCli)) { $TsxCli = Join-Path $SourceRoot "node_modules/.bin/tsx" }
|
|
89
|
+
if (-not (Test-Path $TsxCli)) { $TsxCli = "tsx" }
|
|
90
|
+
Write-Output "PLATFORM_TSX_CLI=$TsxCli"
|
|
91
|
+
|
|
92
|
+
$DogfoodStdout = Join-Path $DogfoodDir "dogfood.stdout.txt"
|
|
93
|
+
$DogfoodStderr = Join-Path $DogfoodDir "dogfood.stderr.txt"
|
|
94
|
+
if ($NpmCiExit -eq 0 -and $AgentBrowserExit -eq 0 -and $BrowserCacheExit -eq 0 -and $BrowserPrewarmExit -eq 0) {
|
|
95
|
+
& $TsxCli "scripts/verify-agent-browser-dogfood.ts" --artifact-dir $DogfoodArtifactDir --json >$DogfoodStdout 2>$DogfoodStderr
|
|
96
|
+
$DogfoodExit = $LASTEXITCODE
|
|
97
|
+
} else {
|
|
98
|
+
"npm ci or agent-browser setup failed" | Set-Content $DogfoodStderr
|
|
99
|
+
$DogfoodExit = 1
|
|
100
|
+
}
|
|
101
|
+
Write-Output "PLATFORM_DOGFOOD_EXIT=$DogfoodExit"
|
|
102
|
+
Write-Section "DOGFOOD_STDOUT" $DogfoodStdout
|
|
103
|
+
Write-Section "DOGFOOD_STDERR" $DogfoodStderr
|
|
104
|
+
|
|
105
|
+
if ($NpmCiExit -ne 0 -or $AgentBrowserExit -ne 0 -or $BrowserCacheExit -ne 0 -or $BrowserPrewarmExit -ne 0 -or $DogfoodExit -ne 0) {
|
|
106
|
+
Write-Output "PLATFORM_BROWSER_DOGFOOD_FAILED"
|
|
107
|
+
exit 1
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
Write-Output "PLATFORM_BROWSER_DOGFOOD_OK"
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** Thin Crabbox CLI wrapper for cross-platform smoke tests. */
|
|
2
|
+
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
function env(name) {
|
|
6
|
+
return process.env[name] ?? "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function crabboxBin() {
|
|
10
|
+
return process.env.PLATFORM_SMOKE_CRABBOX || "crabbox";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function packageSlug(config = {}) {
|
|
14
|
+
return process.env.PLATFORM_SMOKE_PACKAGE_SLUG || config.packageName || "pi-agent-browser-native";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildTargetBaseArgs(targetName, config = {}) {
|
|
18
|
+
switch (targetName) {
|
|
19
|
+
case "macos": {
|
|
20
|
+
const user = env("PLATFORM_SMOKE_MAC_USER") || env("USER");
|
|
21
|
+
const host = env("PLATFORM_SMOKE_MAC_HOST") || "localhost";
|
|
22
|
+
const workRoot = env("PLATFORM_SMOKE_MAC_WORK_ROOT") || `/Users/${user}/crabbox/${packageSlug(config)}`;
|
|
23
|
+
return [
|
|
24
|
+
"--provider", "ssh",
|
|
25
|
+
"--target", "macos",
|
|
26
|
+
"--static-host", host,
|
|
27
|
+
"--static-user", user,
|
|
28
|
+
"--static-port", "22",
|
|
29
|
+
"--static-work-root", workRoot,
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
case "ubuntu": {
|
|
33
|
+
const image = env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config.ubuntuContainerImage || "pi-agent-browser-native-platform:node24-agent-browser0.27.1";
|
|
34
|
+
return [
|
|
35
|
+
"--provider", "local-container",
|
|
36
|
+
"--target", "linux",
|
|
37
|
+
"--local-container-image", image,
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
case "windows-native": {
|
|
41
|
+
const vm = env("PLATFORM_SMOKE_WINDOWS_VM") || "pi-extension-windows-template";
|
|
42
|
+
const snapshot = env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || "crabbox-ready";
|
|
43
|
+
const user = env("PLATFORM_SMOKE_WINDOWS_USER") || env("USER");
|
|
44
|
+
const workRoot = env("PLATFORM_SMOKE_WINDOWS_WORK_ROOT") || `C:\\crabbox\\${packageSlug(config)}`;
|
|
45
|
+
return [
|
|
46
|
+
"--provider", "parallels",
|
|
47
|
+
"--target", "windows",
|
|
48
|
+
"--windows-mode", "normal",
|
|
49
|
+
"--parallels-source", vm,
|
|
50
|
+
"--parallels-source-snapshot", snapshot,
|
|
51
|
+
"--parallels-user", user,
|
|
52
|
+
"--parallels-work-root", workRoot,
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
default:
|
|
56
|
+
throw new Error(`unknown platform smoke target: ${targetName}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function leaseIdFor(targetName, slug) {
|
|
61
|
+
if (targetName === "macos") return "static_localhost";
|
|
62
|
+
return slug;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseLeaseId(text) {
|
|
66
|
+
return text.match(/\bleased\s+(\S+)/)?.[1]
|
|
67
|
+
?? text.match(/\blease=(\S+)/)?.[1]
|
|
68
|
+
?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function execCrabbox(args, options = {}) {
|
|
72
|
+
return new Promise((resolvePromise) => {
|
|
73
|
+
const child = spawn(crabboxBin(), args, {
|
|
74
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
75
|
+
env: { ...process.env, CRABBOX_SYNC_GIT_SEED: "false", ...options.env },
|
|
76
|
+
});
|
|
77
|
+
const stdout = [];
|
|
78
|
+
const stderr = [];
|
|
79
|
+
let timeout;
|
|
80
|
+
let killTimeout;
|
|
81
|
+
if (options.timeout) {
|
|
82
|
+
timeout = setTimeout(() => {
|
|
83
|
+
stderr.push(Buffer.from(`\n[platform-smoke] crabbox timed out after ${options.timeout}ms\n`));
|
|
84
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
85
|
+
killTimeout = setTimeout(() => {
|
|
86
|
+
try { child.kill("SIGKILL"); } catch {}
|
|
87
|
+
}, 10_000);
|
|
88
|
+
}, options.timeout);
|
|
89
|
+
}
|
|
90
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
91
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
92
|
+
child.on("error", (error) => {
|
|
93
|
+
if (timeout) clearTimeout(timeout);
|
|
94
|
+
if (killTimeout) clearTimeout(killTimeout);
|
|
95
|
+
resolvePromise({ stdout: Buffer.concat(stdout).toString(), stderr: `${Buffer.concat(stderr).toString()}${error.message}\n`, code: 1, signal: null });
|
|
96
|
+
});
|
|
97
|
+
child.on("close", (code, signal) => {
|
|
98
|
+
if (timeout) clearTimeout(timeout);
|
|
99
|
+
if (killTimeout) clearTimeout(killTimeout);
|
|
100
|
+
resolvePromise({ stdout: Buffer.concat(stdout).toString(), stderr: Buffer.concat(stderr).toString(), code: code ?? (signal ? 1 : 0), signal });
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isRetryableWarmupFailure(targetName, result) {
|
|
106
|
+
if (targetName !== "windows-native" || result.code === 0) return false;
|
|
107
|
+
return /Could not create a linked clone of the virtual hard disk|due to an internal error|context canceled|timed out after 300000ms/i.test(`${result.stdout}\n${result.stderr}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function warmupLease(targetName, slug, config = {}) {
|
|
111
|
+
const args = ["warmup", ...buildTargetBaseArgs(targetName, config), "--slug", slug, "--keep"];
|
|
112
|
+
let result;
|
|
113
|
+
for (let attempt = 1; attempt <= 2; attempt += 1) {
|
|
114
|
+
console.log(` [crabbox] ${args.join(" ")}${attempt > 1 ? ` (retry ${attempt})` : ""}`);
|
|
115
|
+
result = await execCrabbox(args, { timeout: 300_000 });
|
|
116
|
+
if (!isRetryableWarmupFailure(targetName, result)) break;
|
|
117
|
+
await cleanupStaleTargetState(targetName, config);
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
...result,
|
|
121
|
+
ok: result.code === 0,
|
|
122
|
+
leaseId: parseLeaseId(`${result.stdout}\n${result.stderr}`) ?? leaseIdFor(targetName, slug),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function runOnLease(targetName, leaseId, command, options = {}) {
|
|
127
|
+
const args = ["run", ...buildTargetBaseArgs(targetName, options.config ?? {}), "--id", leaseId];
|
|
128
|
+
for (const name of options.allowEnv ?? []) {
|
|
129
|
+
args.push("--allow-env", name);
|
|
130
|
+
}
|
|
131
|
+
if (options.sync === false) args.push("--no-sync");
|
|
132
|
+
else args.push("--fresh-sync");
|
|
133
|
+
args.push("--shell", command);
|
|
134
|
+
console.log(` [crabbox] run ${targetName} ${options.sync === false ? "--no-sync" : "--fresh-sync"}`);
|
|
135
|
+
return execCrabbox(args, { timeout: options.timeout ?? 900_000 });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function stopLease(targetName, leaseId, config = {}) {
|
|
139
|
+
const args = ["stop", ...buildTargetBaseArgs(targetName, config), "--id", leaseId];
|
|
140
|
+
console.log(` [crabbox] ${args.join(" ")}`);
|
|
141
|
+
return execCrabbox(args, { timeout: 90_000 });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function cleanupStaleTargetState(targetName, config = {}) {
|
|
145
|
+
if (targetName === "macos") return null;
|
|
146
|
+
const args = ["cleanup", ...buildTargetBaseArgs(targetName, config)];
|
|
147
|
+
console.log(` [crabbox] ${args.join(" ")}`);
|
|
148
|
+
return execCrabbox(args, { timeout: 120_000 });
|
|
149
|
+
}
|