pi-agent-browser-native 0.2.38 → 0.2.40

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.
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Purpose: Manage pi-agent-browser-native package config under Pi-scoped config paths.
4
+ * Responsibilities: Print config paths/status, write redacted web-search and browser profile settings, preserve safe permissions, and avoid echoing secrets.
5
+ * Scope: Maintainer/user setup CLI only; extension runtime validation and tool execution live under extensions/agent-browser/lib/.
6
+ */
7
+
8
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { dirname, resolve } from "node:path";
10
+ import process from "node:process";
11
+
12
+ const CONFIG_ENV = "PI_AGENT_BROWSER_CONFIG";
13
+ const BRAVE_API_KEY_ENV = "BRAVE_API_KEY";
14
+ const RELATIVE_CONFIG = [".pi", "config", "pi-agent-browser-native", "config.json"];
15
+ const DEFAULT_CONFIG = { version: 1 };
16
+
17
+ class UsageError extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = "UsageError";
21
+ }
22
+ }
23
+
24
+ function usage() {
25
+ return `pi-agent-browser-config
26
+
27
+ Usage:
28
+ pi-agent-browser-config paths
29
+ pi-agent-browser-config show
30
+ pi-agent-browser-config web-search status
31
+ pi-agent-browser-config web-search set-key --stdin [--global]
32
+ pi-agent-browser-config web-search set-env <ENV_VAR> [--global|--project]
33
+ pi-agent-browser-config web-search set-command <command> [--global]
34
+ pi-agent-browser-config web-search clear [--global|--project]
35
+ pi-agent-browser-config browser profile status
36
+ pi-agent-browser-config browser profile set <name> [--policy explicit-only|authenticated-only|always] [--global|--project]
37
+ pi-agent-browser-config browser profile clear [--global|--project]
38
+
39
+ Notes:
40
+ Global config: ~/.pi/config/pi-agent-browser-native/config.json
41
+ Project config: .pi/config/pi-agent-browser-native/config.json
42
+ Override: PI_AGENT_BROWSER_CONFIG=/path/to/config.json
43
+ Project-local plaintext, interpolation-literal, malformed, and command-backed web-search keys are refused; use exact set-env references there.
44
+ `;
45
+ }
46
+
47
+ function getHome(env = process.env) {
48
+ return env.HOME?.trim() || env.USERPROFILE?.trim();
49
+ }
50
+
51
+ function getGlobalConfigPath(env = process.env) {
52
+ const home = getHome(env);
53
+ if (!home) throw new Error("Could not resolve home directory for global config.");
54
+ return resolve(home, ...RELATIVE_CONFIG);
55
+ }
56
+
57
+ function getProjectConfigPath(cwd = process.cwd()) {
58
+ return resolve(cwd, ...RELATIVE_CONFIG);
59
+ }
60
+
61
+ function getPaths(env = process.env, cwd = process.cwd()) {
62
+ const override = env[CONFIG_ENV]?.trim();
63
+ return {
64
+ global: getGlobalConfigPath(env),
65
+ project: getProjectConfigPath(cwd),
66
+ override: override ? resolve(override) : undefined,
67
+ };
68
+ }
69
+
70
+ function parseArgs(argv) {
71
+ const positional = [];
72
+ const flags = new Map();
73
+ for (let index = 0; index < argv.length; index += 1) {
74
+ const arg = argv[index];
75
+ if (!arg.startsWith("--")) {
76
+ positional.push(arg);
77
+ continue;
78
+ }
79
+ if (arg === "--global" || arg === "--project" || arg === "--stdin" || arg === "--help") {
80
+ flags.set(arg, true);
81
+ continue;
82
+ }
83
+ if (arg === "--policy") {
84
+ const value = argv[index + 1];
85
+ if (!value || value.startsWith("--")) throw new UsageError("--policy requires a value.");
86
+ flags.set(arg, value);
87
+ index += 1;
88
+ continue;
89
+ }
90
+ throw new UsageError(`Unknown option: ${arg}`);
91
+ }
92
+ if (flags.get("--global") && flags.get("--project")) throw new UsageError("Use only one of --global or --project.");
93
+ return { flags, positional };
94
+ }
95
+
96
+ function readConfig(path) {
97
+ if (!existsSync(path)) return { ...DEFAULT_CONFIG };
98
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
99
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${path} must contain a JSON object.`);
100
+ return { ...DEFAULT_CONFIG, ...parsed };
101
+ }
102
+
103
+ function writeConfig(path, config) {
104
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
105
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
106
+ try {
107
+ chmodSync(dirname(path), 0o700);
108
+ chmodSync(path, 0o600);
109
+ } catch {
110
+ // Best effort on platforms/filesystems that do not support POSIX modes.
111
+ }
112
+ }
113
+
114
+ function selectWritePath(flags) {
115
+ const paths = getPaths();
116
+ if (flags.get("--project")) return { path: paths.project, scope: "project" };
117
+ return { path: paths.global, scope: "global" };
118
+ }
119
+
120
+ function classifyCredential(rawValue) {
121
+ const trimmed = String(rawValue ?? "").trim();
122
+ if (!trimmed) return "not configured";
123
+ if (trimmed.startsWith("!")) return "configured via command";
124
+ if (trimmed.includes("$")) return "configured via environment interpolation";
125
+ return "configured as plaintext [redacted]";
126
+ }
127
+
128
+ function mergeConfig() {
129
+ const paths = getPaths();
130
+ const layers = [];
131
+ for (const [scope, path] of [["global", paths.global], ["project", paths.project], ...(paths.override ? [["override", paths.override]] : [])]) {
132
+ if (!existsSync(path)) continue;
133
+ layers.push({ scope, path, config: readConfig(path) });
134
+ }
135
+ const merged = layers.reduce((current, layer) => ({
136
+ ...current,
137
+ ...layer.config,
138
+ browser: { ...(current.browser ?? {}), ...(layer.config.browser ?? {}) },
139
+ webSearch: { ...(current.webSearch ?? {}), ...(layer.config.webSearch ?? {}) },
140
+ }), { ...DEFAULT_CONFIG });
141
+ return { layers, merged, paths };
142
+ }
143
+
144
+ function printPaths() {
145
+ const paths = getPaths();
146
+ console.log(`Global: ${paths.global}`);
147
+ console.log(`Project: ${paths.project}`);
148
+ console.log(`Override: ${paths.override ?? `${CONFIG_ENV} not set`}`);
149
+ }
150
+
151
+ function printStatus() {
152
+ const { layers, merged, paths } = mergeConfig();
153
+ printPaths();
154
+ console.log("");
155
+ console.log("Config files:");
156
+ for (const [scope, path] of [["global", paths.global], ["project", paths.project], ...(paths.override ? [["override", paths.override]] : [])]) {
157
+ console.log(` ${scope}: ${path} ${existsSync(path) ? "[exists]" : "[missing]"}`);
158
+ }
159
+ console.log("");
160
+ console.log("Effective config:");
161
+ const source = merged.webSearch?.braveApiKey;
162
+ console.log(` webSearch.braveApiKey: ${source ? classifyCredential(source) : process.env[BRAVE_API_KEY_ENV]?.trim() ? `configured via ${BRAVE_API_KEY_ENV} environment fallback` : "not configured"}`);
163
+ const profile = merged.browser?.defaultProfile;
164
+ console.log(` browser.defaultProfile: ${profile?.name ? `${profile.name} (policy: ${profile.policy ?? "authenticated-only"})` : "not configured"}`);
165
+ if (layers.length === 0) console.log(" layers: none");
166
+ }
167
+
168
+ async function readSecretFromStdin(useStdin) {
169
+ if (!useStdin) throw new UsageError("set-key requires --stdin so the key is not passed through argv or an echoed prompt.");
170
+ let input = "";
171
+ for await (const chunk of process.stdin) input += chunk;
172
+ const value = input.trim();
173
+ if (!value) throw new UsageError("No key was provided on stdin.");
174
+ return value;
175
+ }
176
+
177
+ function mutateConfig(path, mutate) {
178
+ const config = readConfig(path);
179
+ mutate(config);
180
+ writeConfig(path, config);
181
+ }
182
+
183
+ async function handleWebSearch(args, flags) {
184
+ const action = args[0];
185
+ if (action === "status") {
186
+ printStatus();
187
+ return;
188
+ }
189
+ if (action === "set-key") {
190
+ if (flags.get("--project")) throw new UsageError("Plaintext Brave keys cannot be written to project-local config. Use set-env or set-command.");
191
+ const key = await readSecretFromStdin(Boolean(flags.get("--stdin")));
192
+ const { path } = selectWritePath(flags);
193
+ mutateConfig(path, (config) => {
194
+ config.webSearch = { ...(config.webSearch ?? {}), braveApiKey: key };
195
+ });
196
+ console.log(`Saved Brave Search key to global config: ${path}`);
197
+ return;
198
+ }
199
+ if (action === "set-env") {
200
+ const envName = args[1];
201
+ if (!envName || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName)) throw new UsageError("set-env requires a valid environment variable name.");
202
+ const { path, scope } = selectWritePath(flags);
203
+ mutateConfig(path, (config) => {
204
+ config.webSearch = { ...(config.webSearch ?? {}), braveApiKey: `$${envName}` };
205
+ });
206
+ console.log(`Saved Brave Search ${scope} env reference to: ${path}`);
207
+ return;
208
+ }
209
+ if (action === "set-command") {
210
+ if (flags.get("--project")) throw new UsageError("Command-backed Brave keys cannot be written to project-local config. Use set-env there.");
211
+ const command = args.slice(1).join(" ").trim();
212
+ if (!command) throw new UsageError("set-command requires a command string.");
213
+ const { path, scope } = selectWritePath(flags);
214
+ mutateConfig(path, (config) => {
215
+ config.webSearch = { ...(config.webSearch ?? {}), braveApiKey: `!${command}` };
216
+ });
217
+ console.log(`Saved Brave Search ${scope} command source to: ${path}`);
218
+ return;
219
+ }
220
+ if (action === "clear") {
221
+ const { path, scope } = selectWritePath(flags);
222
+ mutateConfig(path, (config) => {
223
+ if (config.webSearch) delete config.webSearch.braveApiKey;
224
+ });
225
+ console.log(`Cleared Brave Search credential source in ${scope} config: ${path}`);
226
+ return;
227
+ }
228
+ throw new UsageError(`Unsupported web-search action: ${action ?? ""}`);
229
+ }
230
+
231
+ function handleBrowser(args, flags) {
232
+ if (args[0] !== "profile") throw new UsageError(`Unsupported browser action: ${args[0] ?? ""}`);
233
+ const action = args[1];
234
+ if (action === "status") {
235
+ printStatus();
236
+ return;
237
+ }
238
+ if (action === "set") {
239
+ const name = args[2]?.trim();
240
+ if (!name) throw new UsageError("browser profile set requires a profile name.");
241
+ const policy = flags.get("--policy") || "authenticated-only";
242
+ if (!["explicit-only", "authenticated-only", "always"].includes(policy)) throw new UsageError("Invalid --policy value.");
243
+ const { path, scope } = selectWritePath(flags);
244
+ mutateConfig(path, (config) => {
245
+ config.browser = { ...(config.browser ?? {}), defaultProfile: { name, policy } };
246
+ });
247
+ console.log(`Saved browser default profile in ${scope} config: ${path}`);
248
+ return;
249
+ }
250
+ if (action === "clear") {
251
+ const { path, scope } = selectWritePath(flags);
252
+ mutateConfig(path, (config) => {
253
+ if (config.browser) delete config.browser.defaultProfile;
254
+ });
255
+ console.log(`Cleared browser default profile in ${scope} config: ${path}`);
256
+ return;
257
+ }
258
+ throw new UsageError(`Unsupported browser profile action: ${action ?? ""}`);
259
+ }
260
+
261
+ export async function main(argv = process.argv.slice(2)) {
262
+ const { flags, positional } = parseArgs(argv);
263
+ if (flags.get("--help") || positional.length === 0) {
264
+ console.log(usage());
265
+ return 0;
266
+ }
267
+ const command = positional[0];
268
+ if (command === "paths") {
269
+ printPaths();
270
+ return 0;
271
+ }
272
+ if (command === "show") {
273
+ printStatus();
274
+ return 0;
275
+ }
276
+ if (command === "web-search") {
277
+ await handleWebSearch(positional.slice(1), flags);
278
+ return 0;
279
+ }
280
+ if (command === "browser") {
281
+ handleBrowser(positional.slice(1), flags);
282
+ return 0;
283
+ }
284
+ throw new UsageError(`Unknown command: ${command}`);
285
+ }
286
+
287
+ if (import.meta.url === `file://${process.argv[1]}`) {
288
+ main().catch((error) => {
289
+ if (error instanceof UsageError) {
290
+ console.error(error.message);
291
+ console.error(usage());
292
+ process.exit(2);
293
+ }
294
+ console.error(error instanceof Error ? error.message : String(error));
295
+ process.exit(1);
296
+ });
297
+ }
@@ -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
+ }