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.
@@ -0,0 +1,471 @@
1
+ /** Target/suite runner for pi-agent-browser-native platform smoke. */
2
+
3
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { dirname, resolve } from "node:path";
5
+
6
+ import {
7
+ collectSecretValues,
8
+ createSuiteDir,
9
+ redactSecrets,
10
+ scanArtifactTextFiles,
11
+ scanForSecrets,
12
+ writeCommand,
13
+ writeExitCode,
14
+ writeManifest,
15
+ writeSummary,
16
+ } from "./artifacts.mjs";
17
+ import { cleanupStaleTargetState, runOnLease, stopLease, warmupLease } from "./crabbox-runner.mjs";
18
+
19
+ export function platformFor(targetName) {
20
+ return targetName === "windows-native" ? "powershell" : "posix";
21
+ }
22
+
23
+ function makeRunId() {
24
+ return `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
25
+ }
26
+
27
+ function shellQuote(value) {
28
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
29
+ }
30
+
31
+ function psSingleQuote(value) {
32
+ return `'${String(value).replace(/'/g, "''")}'`;
33
+ }
34
+
35
+ function authEnvAllowList(config = {}) {
36
+ const raw = process.env.PLATFORM_SMOKE_AUTH_ENV;
37
+ const names = raw ? raw.split(",") : (config.defaultAuthEnv ?? []);
38
+ return names.map((name) => String(name).trim()).filter(Boolean);
39
+ }
40
+
41
+ function writeRedacted(path, text, secretValues) {
42
+ writeFileSync(path, redactSecrets(text ?? "", secretValues));
43
+ }
44
+
45
+ function section(text, name) {
46
+ const start = `--- ${name} START ---`;
47
+ const end = `--- ${name} END ---`;
48
+ const startIndex = text.indexOf(start);
49
+ if (startIndex === -1) return "";
50
+ const contentStart = startIndex + start.length;
51
+ const endIndex = text.indexOf(end, contentStart);
52
+ return (endIndex === -1 ? text.slice(contentStart) : text.slice(contentStart, endIndex)).replace(/^\r?\n/, "").replace(/\r?\n$/, "");
53
+ }
54
+
55
+ function marker(text, name) {
56
+ return text.match(new RegExp(`^${name}=(.*)$`, "m"))?.[1]?.trim() ?? "";
57
+ }
58
+
59
+ function parseJsonObject(text) {
60
+ const trimmed = String(text ?? "").trim();
61
+ if (!trimmed) return {};
62
+ try {
63
+ return JSON.parse(trimmed);
64
+ } catch {
65
+ const first = trimmed.indexOf("{");
66
+ const last = trimmed.lastIndexOf("}");
67
+ if (first !== -1 && last > first) {
68
+ try {
69
+ return JSON.parse(trimmed.slice(first, last + 1));
70
+ } catch {
71
+ return {};
72
+ }
73
+ }
74
+ return {};
75
+ }
76
+ }
77
+
78
+ function writePlatformExtracts(suiteDir, stdout, secretValues = []) {
79
+ writeFileSync(resolve(suiteDir, "node-version.txt"), `${marker(stdout, "PLATFORM_NODE_VERSION")}\n`);
80
+ writeRedacted(resolve(suiteDir, "packed-tarball.txt"), `${marker(stdout, "PLATFORM_PACKED_TARBALL")}\n`, secretValues);
81
+ writeRedacted(resolve(suiteDir, "packed-node-install.stdout.txt"), section(stdout, "PACKED_NODE_INSTALL_STDOUT"), secretValues);
82
+ writeRedacted(resolve(suiteDir, "packed-node-install.stderr.txt"), section(stdout, "PACKED_NODE_INSTALL_STDERR"), secretValues);
83
+ writeRedacted(resolve(suiteDir, "pi-install.stdout.txt"), section(stdout, "PI_INSTALL_STDOUT"), secretValues);
84
+ writeRedacted(resolve(suiteDir, "pi-install.stderr.txt"), section(stdout, "PI_INSTALL_STDERR"), secretValues);
85
+ writeRedacted(resolve(suiteDir, "pi-list.stdout.txt"), section(stdout, "PI_LIST_STDOUT"), secretValues);
86
+ writeRedacted(resolve(suiteDir, "pi-list.stderr.txt"), section(stdout, "PI_LIST_STDERR"), secretValues);
87
+ }
88
+
89
+ function writeDogfoodExtracts(suiteDir, stdout, secretValues = []) {
90
+ writeFileSync(resolve(suiteDir, "node-version.txt"), `${marker(stdout, "PLATFORM_NODE_VERSION")}\n`);
91
+ writeRedacted(resolve(suiteDir, "dogfood-artifacts.txt"), `${marker(stdout, "PLATFORM_DOGFOOD_ARTIFACT_DIR")}\n`, secretValues);
92
+ const dogfoodStdout = section(stdout, "DOGFOOD_STDOUT");
93
+ writeRedacted(resolve(suiteDir, "dogfood.stdout.txt"), dogfoodStdout, secretValues);
94
+ writeRedacted(resolve(suiteDir, "dogfood.stderr.txt"), section(stdout, "DOGFOOD_STDERR"), secretValues);
95
+ const report = parseJsonObject(dogfoodStdout);
96
+ writeRedacted(resolve(suiteDir, "dogfood-report.json"), JSON.stringify(report, null, 2), secretValues);
97
+ return report;
98
+ }
99
+
100
+ function assertionsFromChecks(checks) {
101
+ const evaluated = checks.map((check) => {
102
+ let ok = false;
103
+ let error = check.error;
104
+ try {
105
+ ok = check.fn() === true;
106
+ } catch (err) {
107
+ error = err.message;
108
+ }
109
+ return { id: check.id, ok, ...(ok ? {} : { error: error ?? `${check.id} failed` }) };
110
+ });
111
+ return { ok: evaluated.every((check) => check.ok), checks: evaluated, writtenAt: new Date().toISOString() };
112
+ }
113
+
114
+ function writeAssertions(suiteDir, checks) {
115
+ const assertions = assertionsFromChecks(checks);
116
+ writeFileSync(resolve(suiteDir, "assertions.json"), JSON.stringify(assertions, null, 2));
117
+ if (!assertions.ok) {
118
+ writeFileSync(resolve(suiteDir, "failures.md"), [
119
+ "# Platform smoke failures",
120
+ "",
121
+ ...assertions.checks.filter((check) => !check.ok).map((check) => `- ${check.id}: ${check.error ?? "failed"}`),
122
+ "",
123
+ "Inspect command.txt, crabbox.stdout.txt, and crabbox.stderr.txt in this suite directory.",
124
+ "",
125
+ ].join("\n"));
126
+ }
127
+ return assertions;
128
+ }
129
+
130
+ function finalizeSuite(suiteDir, checks, summary, expectedFiles) {
131
+ const assertions = writeAssertions(suiteDir, checks);
132
+ writeSummary(suiteDir, { ...summary, ok: assertions.ok });
133
+ const expected = assertions.ok ? expectedFiles : [...expectedFiles, "failures.md"];
134
+ const manifest = writeManifest(suiteDir, expected);
135
+ if (manifest.missing.length === 0) return { assertions, manifest };
136
+ const finalAssertions = writeAssertions(suiteDir, [
137
+ ...checks,
138
+ { id: "artifact-manifest-complete", fn: () => false, error: `missing required artifact(s): ${manifest.missing.join(", ")}` },
139
+ ]);
140
+ writeSummary(suiteDir, { ...summary, ok: false });
141
+ return { assertions: finalAssertions, manifest: writeManifest(suiteDir, [...expectedFiles, "failures.md"]) };
142
+ }
143
+
144
+ export function createLeaseCleanupResult(config, targetName, leaseId, stopResult, staleCleanupResult = null) {
145
+ const suiteName = "lease-cleanup";
146
+ const runId = makeRunId();
147
+ const suiteDir = createSuiteDir(config.artifactRoot, runId, targetName, suiteName);
148
+ const secretValues = collectSecretValues(authEnvAllowList(config));
149
+ writeFileSync(resolve(suiteDir, "target.json"), JSON.stringify({ targetName, platform: platformFor(targetName), runId, slug: `${config.packageName}-${targetName}` }, null, 2));
150
+ writeFileSync(resolve(suiteDir, "suite.json"), JSON.stringify({ suiteName, leaseId, modelCalls: 0 }, null, 2));
151
+ writeCommand(suiteDir, `crabbox stop ${targetName} --id ${leaseId}`);
152
+ writeExitCode(suiteDir, stopResult.code, stopResult.signal);
153
+ writeRedacted(resolve(suiteDir, "crabbox.stop.stdout.txt"), stopResult.stdout ?? "", secretValues);
154
+ writeRedacted(resolve(suiteDir, "crabbox.stop.stderr.txt"), stopResult.stderr ?? "", secretValues);
155
+ writeFileSync(resolve(suiteDir, "crabbox.stop.exit-code.txt"), `code=${stopResult.code}\nsignal=${stopResult.signal ?? "none"}\n`);
156
+ if (staleCleanupResult) {
157
+ writeRedacted(resolve(suiteDir, "crabbox.cleanup.stdout.txt"), staleCleanupResult.stdout ?? "", secretValues);
158
+ writeRedacted(resolve(suiteDir, "crabbox.cleanup.stderr.txt"), staleCleanupResult.stderr ?? "", secretValues);
159
+ writeFileSync(resolve(suiteDir, "crabbox.cleanup.exit-code.txt"), `code=${staleCleanupResult.code}\nsignal=${staleCleanupResult.signal ?? "none"}\n`);
160
+ }
161
+ const secretViolations = [
162
+ ...scanForSecrets(`${stopResult.stdout ?? ""}\n${stopResult.stderr ?? ""}`, secretValues),
163
+ ...scanArtifactTextFiles(suiteDir, secretValues).map((finding) => `${finding.file}: ${finding.violation}`),
164
+ ];
165
+ const { assertions } = finalizeSuite(
166
+ suiteDir,
167
+ [
168
+ { id: "lease-cleanup", fn: () => stopResult.code === 0, error: `Crabbox stop failed with exit ${stopResult.code}` },
169
+ { id: "stale-cleanup", fn: () => !staleCleanupResult || staleCleanupResult.code === 0, error: `Crabbox cleanup failed with exit ${staleCleanupResult?.code}` },
170
+ { id: "no-secret-artifacts", fn: () => secretViolations.length === 0, error: secretViolations.join(", ") },
171
+ ],
172
+ { target: targetName, suite: suiteName, exitCode: stopResult.code, signal: stopResult.signal, elapsedMs: 0 },
173
+ [
174
+ "summary.json", "artifact-manifest.json", "target.json", "suite.json", "command.txt", "exit-code.txt",
175
+ "crabbox.stop.stdout.txt", "crabbox.stop.stderr.txt", "crabbox.stop.exit-code.txt",
176
+ ...(staleCleanupResult ? ["crabbox.cleanup.stdout.txt", "crabbox.cleanup.stderr.txt", "crabbox.cleanup.exit-code.txt"] : []),
177
+ "assertions.json",
178
+ ],
179
+ );
180
+ return { ok: assertions.ok, suiteDir, assertions };
181
+ }
182
+
183
+ export function createLeaseCleanupFailureResult(config, targetName, leaseId, stopResult) {
184
+ return createLeaseCleanupResult(config, targetName, leaseId, stopResult);
185
+ }
186
+
187
+ export function createLeaseWarmupFailureResult(config, targetName, warmupResult) {
188
+ const suiteName = "lease-warmup";
189
+ const runId = makeRunId();
190
+ const suiteDir = createSuiteDir(config.artifactRoot, runId, targetName, suiteName);
191
+ const secretValues = collectSecretValues(authEnvAllowList(config));
192
+ writeFileSync(resolve(suiteDir, "target.json"), JSON.stringify({ targetName, platform: platformFor(targetName), runId, slug: `${config.packageName}-${targetName}` }, null, 2));
193
+ writeFileSync(resolve(suiteDir, "suite.json"), JSON.stringify({ suiteName, modelCalls: 0 }, null, 2));
194
+ writeCommand(suiteDir, `crabbox warmup ${targetName}`);
195
+ writeExitCode(suiteDir, warmupResult.code, warmupResult.signal);
196
+ writeRedacted(resolve(suiteDir, "crabbox.stdout.txt"), warmupResult.stdout ?? "", secretValues);
197
+ writeRedacted(resolve(suiteDir, "crabbox.stderr.txt"), warmupResult.stderr ?? "", secretValues);
198
+ const secretViolations = [
199
+ ...scanForSecrets(`${warmupResult.stdout ?? ""}\n${warmupResult.stderr ?? ""}`, secretValues),
200
+ ...scanArtifactTextFiles(suiteDir, secretValues).map((finding) => `${finding.file}: ${finding.violation}`),
201
+ ];
202
+ const { assertions } = finalizeSuite(
203
+ suiteDir,
204
+ [
205
+ { id: "lease-warmup", fn: () => false, error: `Crabbox warmup failed with exit ${warmupResult.code}` },
206
+ { id: "no-secret-artifacts", fn: () => secretViolations.length === 0, error: secretViolations.join(", ") },
207
+ ],
208
+ { target: targetName, suite: suiteName, exitCode: warmupResult.code, signal: warmupResult.signal, elapsedMs: 0, ok: false },
209
+ ["summary.json", "artifact-manifest.json", "target.json", "suite.json", "command.txt", "exit-code.txt", "crabbox.stdout.txt", "crabbox.stderr.txt", "assertions.json"],
210
+ );
211
+ return { ok: false, suiteDir, assertions };
212
+ }
213
+
214
+ export function buildPlatformBuildCommand(targetName, packageName = "pi-agent-browser-native", nodeValidationMajor = 22) {
215
+ if (platformFor(targetName) === "powershell") {
216
+ return `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\\scripts\\platform-smoke\\platform-build-windows.ps1 -PackageName ${psSingleQuote(packageName)} -NodeValidationMajor ${nodeValidationMajor}`;
217
+ }
218
+
219
+ const lines = [];
220
+ lines.push(`echo "Starting platform-build in $(pwd) at $(date -u +%Y-%m-%dT%H:%M:%SZ)"`);
221
+ lines.push(`RUN_ROOT=".platform-smoke-runs/platform-build-$(date -u +%Y%m%dT%H%M%SZ)-$$"`);
222
+ lines.push(`SOURCE_ROOT="$(pwd)"`);
223
+ lines.push(`PACK_DIR="$SOURCE_ROOT/$RUN_ROOT/pack"`);
224
+ lines.push(`PI_PROJECT="$SOURCE_ROOT/$RUN_ROOT/pi-project"`);
225
+ lines.push(`mkdir -p "$PACK_DIR" "$PI_PROJECT"`);
226
+ lines.push(`echo "PLATFORM_RUN_ROOT=$RUN_ROOT"`);
227
+ lines.push(`NODE_VERSION=$(node --version)`);
228
+ lines.push(`NODE_MAJOR="${"${NODE_VERSION#v}"}"`);
229
+ lines.push(`NODE_MAJOR="${"${NODE_MAJOR%%.*}"}"`);
230
+ lines.push(`echo "PLATFORM_NODE_VERSION=$NODE_VERSION"`);
231
+ lines.push(`if [ "$NODE_MAJOR" -ge ${nodeValidationMajor} ]; then NODE_VERSION_EXIT=0; else NODE_VERSION_EXIT=1; fi`);
232
+ lines.push(`echo "PLATFORM_NODE_VERSION_EXIT=$NODE_VERSION_EXIT"`);
233
+ lines.push(`npm ci 2>&1`);
234
+ lines.push(`NPM_CI_EXIT=$?`);
235
+ lines.push(`echo "PLATFORM_NPM_CI_EXIT=$NPM_CI_EXIT"`);
236
+ lines.push(`npm run verify -- platform-target 2>&1`);
237
+ lines.push(`VERIFY_EXIT=$?`);
238
+ lines.push(`echo "PLATFORM_VERIFY_EXIT=$VERIFY_EXIT"`);
239
+ lines.push(`PACK_TARBALL=$(npm pack --silent --pack-destination "$PACK_DIR" 2>"$PACK_DIR/npm-pack.stderr.txt")`);
240
+ lines.push(`PACK_EXIT=$?`);
241
+ lines.push(`cat "$PACK_DIR/npm-pack.stderr.txt"`);
242
+ lines.push(`PACK_FILE="$PACK_DIR/$PACK_TARBALL"`);
243
+ lines.push(`echo "PLATFORM_NPM_PACK_EXIT=$PACK_EXIT"`);
244
+ lines.push(`echo "PLATFORM_PACKED_TARBALL=$PACK_FILE"`);
245
+ lines.push(`PI_CLI="$SOURCE_ROOT/node_modules/.bin/pi"`);
246
+ lines.push(`if [ ! -x "$PI_CLI" ]; then PI_CLI="$(command -v pi || true)"; fi`);
247
+ lines.push(`echo "PLATFORM_PI_CLI=$PI_CLI"`);
248
+ lines.push(`if [ -n "$PACK_TARBALL" ] && [ -f "$PACK_FILE" ]; then (cd "$PI_PROJECT" && npm init -y >"$PACK_DIR/packed-node-install.stdout.txt" 2>"$PACK_DIR/packed-node-install.stderr.txt" && npm install --no-save "$PACK_FILE" >>"$PACK_DIR/packed-node-install.stdout.txt" 2>>"$PACK_DIR/packed-node-install.stderr.txt"); PACKED_NODE_INSTALL_EXIT=$?; else echo "missing tarball" >"$PACK_DIR/packed-node-install.stderr.txt"; PACKED_NODE_INSTALL_EXIT=1; fi`);
249
+ lines.push(`echo "PLATFORM_PACKED_NODE_INSTALL_EXIT=$PACKED_NODE_INSTALL_EXIT"`);
250
+ lines.push(`echo "--- PACKED_NODE_INSTALL_STDOUT START ---"; cat "$PACK_DIR/packed-node-install.stdout.txt" 2>/dev/null || true; echo "--- PACKED_NODE_INSTALL_STDOUT END ---"`);
251
+ lines.push(`echo "--- PACKED_NODE_INSTALL_STDERR START ---"; cat "$PACK_DIR/packed-node-install.stderr.txt" 2>/dev/null || true; echo "--- PACKED_NODE_INSTALL_STDERR END ---"`);
252
+ lines.push(`if [ "$PACKED_NODE_INSTALL_EXIT" -eq 0 ] && [ -n "$PI_CLI" ]; then (cd "$PI_PROJECT" && PI_OFFLINE=1 "$PI_CLI" install -l ./node_modules/${packageName} >"$PACK_DIR/pi-install.stdout.txt" 2>"$PACK_DIR/pi-install.stderr.txt"); PI_INSTALL_EXIT=$?; else echo "missing pi cli or packed install" >"$PACK_DIR/pi-install.stderr.txt"; PI_INSTALL_EXIT=1; fi`);
253
+ lines.push(`echo "PLATFORM_PI_INSTALL_EXIT=$PI_INSTALL_EXIT"`);
254
+ lines.push(`echo "--- PI_INSTALL_STDOUT START ---"; cat "$PACK_DIR/pi-install.stdout.txt" 2>/dev/null || true; echo "--- PI_INSTALL_STDOUT END ---"`);
255
+ lines.push(`echo "--- PI_INSTALL_STDERR START ---"; cat "$PACK_DIR/pi-install.stderr.txt" 2>/dev/null || true; echo "--- PI_INSTALL_STDERR END ---"`);
256
+ lines.push(`if [ -n "$PI_CLI" ]; then (cd "$PI_PROJECT" && PI_OFFLINE=1 "$PI_CLI" list >"$PACK_DIR/pi-list.stdout.txt" 2>"$PACK_DIR/pi-list.stderr.txt"); PI_LIST_EXIT=$?; else echo "missing pi cli" >"$PACK_DIR/pi-list.stderr.txt"; PI_LIST_EXIT=1; fi`);
257
+ lines.push(`echo "PLATFORM_PI_LIST_EXIT=$PI_LIST_EXIT"`);
258
+ lines.push(`echo "--- PI_LIST_STDOUT START ---"; cat "$PACK_DIR/pi-list.stdout.txt" 2>/dev/null || true; echo "--- PI_LIST_STDOUT END ---"`);
259
+ lines.push(`echo "--- PI_LIST_STDERR START ---"; cat "$PACK_DIR/pi-list.stderr.txt" 2>/dev/null || true; echo "--- PI_LIST_STDERR END ---"`);
260
+ lines.push(`if [ "$NODE_VERSION_EXIT" -ne 0 ] || [ "$NPM_CI_EXIT" -ne 0 ] || [ "$VERIFY_EXIT" -ne 0 ] || [ "$PACK_EXIT" -ne 0 ] || [ "$PACKED_NODE_INSTALL_EXIT" -ne 0 ] || [ "$PI_INSTALL_EXIT" -ne 0 ] || [ "$PI_LIST_EXIT" -ne 0 ]; then echo "PLATFORM_BUILD_FAILED"; exit 1; fi`);
261
+ lines.push(`echo "PLATFORM_BUILD_OK"`);
262
+ return lines.join("\n");
263
+ }
264
+
265
+ export function buildBrowserDogfoodCommand(targetName, agentBrowserVersion = "0.27.1") {
266
+ if (platformFor(targetName) === "powershell") {
267
+ return `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\\scripts\\platform-smoke\\browser-dogfood-windows.ps1 -AgentBrowserVersion ${psSingleQuote(agentBrowserVersion)}`;
268
+ }
269
+
270
+ const lines = [];
271
+ lines.push(`echo "Starting browser-dogfood-smoke in $(pwd) at $(date -u +%Y-%m-%dT%H:%M:%SZ)"`);
272
+ lines.push(`RUN_ROOT=".platform-smoke-runs/browser-dogfood-$(date -u +%Y%m%dT%H%M%SZ)-$$"`);
273
+ lines.push(`SOURCE_ROOT="$(pwd)"`);
274
+ lines.push(`DOGFOOD_DIR="$SOURCE_ROOT/$RUN_ROOT/dogfood"`);
275
+ lines.push(`DOGFOOD_ARTIFACT_DIR="$DOGFOOD_DIR/artifacts"`);
276
+ lines.push(`mkdir -p "$DOGFOOD_ARTIFACT_DIR"`);
277
+ lines.push(`echo "PLATFORM_RUN_ROOT=$RUN_ROOT"`);
278
+ lines.push(`echo "PLATFORM_DOGFOOD_ARTIFACT_DIR=$DOGFOOD_ARTIFACT_DIR"`);
279
+ lines.push(`NODE_VERSION=$(node --version)`);
280
+ lines.push(`NODE_MAJOR="${"${NODE_VERSION#v}"}"`);
281
+ lines.push(`NODE_MAJOR="${"${NODE_MAJOR%%.*}"}"`);
282
+ lines.push(`echo "PLATFORM_NODE_VERSION=$NODE_VERSION"`);
283
+ lines.push(`EXPECTED_AGENT_BROWSER_VERSION=${shellQuote(`agent-browser ${agentBrowserVersion}`)}`);
284
+ lines.push(`AGENT_BROWSER_VERSION_OUTPUT=$(agent-browser --version 2>&1)`);
285
+ lines.push(`AGENT_BROWSER_VERSION_COMMAND_EXIT=$?`);
286
+ lines.push(`echo "PLATFORM_AGENT_BROWSER_VERSION=$AGENT_BROWSER_VERSION_OUTPUT"`);
287
+ lines.push(`if [ "$AGENT_BROWSER_VERSION_COMMAND_EXIT" -eq 0 ] && [ "$AGENT_BROWSER_VERSION_OUTPUT" = "$EXPECTED_AGENT_BROWSER_VERSION" ]; then AGENT_BROWSER_READY_EXIT=0; else AGENT_BROWSER_READY_EXIT=1; fi`);
288
+ lines.push(`echo "PLATFORM_AGENT_BROWSER_READY_EXIT=$AGENT_BROWSER_READY_EXIT"`);
289
+ lines.push(`npm ci 2>&1`);
290
+ lines.push(`NPM_CI_EXIT=$?`);
291
+ lines.push(`echo "PLATFORM_NPM_CI_EXIT=$NPM_CI_EXIT"`);
292
+ lines.push(`TSX_CLI="$SOURCE_ROOT/node_modules/.bin/tsx"`);
293
+ lines.push(`if [ ! -x "$TSX_CLI" ]; then TSX_CLI="$(command -v tsx || true)"; fi`);
294
+ lines.push(`echo "PLATFORM_TSX_CLI=$TSX_CLI"`);
295
+ lines.push(`if [ "$NPM_CI_EXIT" -eq 0 ] && [ "$AGENT_BROWSER_READY_EXIT" -eq 0 ] && [ -n "$TSX_CLI" ]; then "$TSX_CLI" scripts/verify-agent-browser-dogfood.ts --artifact-dir "$DOGFOOD_ARTIFACT_DIR" --json >"$DOGFOOD_DIR/dogfood.stdout.txt" 2>"$DOGFOOD_DIR/dogfood.stderr.txt"; DOGFOOD_EXIT=$?; else echo "missing tsx, npm ci failed, or agent-browser baseline mismatch" >"$DOGFOOD_DIR/dogfood.stderr.txt"; DOGFOOD_EXIT=1; fi`);
296
+ lines.push(`echo "PLATFORM_DOGFOOD_EXIT=$DOGFOOD_EXIT"`);
297
+ lines.push(`echo "--- DOGFOOD_STDOUT START ---"; cat "$DOGFOOD_DIR/dogfood.stdout.txt" 2>/dev/null || true; echo "--- DOGFOOD_STDOUT END ---"`);
298
+ lines.push(`echo "--- DOGFOOD_STDERR START ---"; cat "$DOGFOOD_DIR/dogfood.stderr.txt" 2>/dev/null || true; echo "--- DOGFOOD_STDERR END ---"`);
299
+ lines.push(`if [ "$NPM_CI_EXIT" -ne 0 ] || [ "$AGENT_BROWSER_READY_EXIT" -ne 0 ] || [ "$DOGFOOD_EXIT" -ne 0 ]; then echo "PLATFORM_BROWSER_DOGFOOD_FAILED"; exit 1; fi`);
300
+ lines.push(`echo "PLATFORM_BROWSER_DOGFOOD_OK"`);
301
+ return lines.join("\n");
302
+ }
303
+
304
+ async function runBrowserDogfoodSuite(config, targetName, suiteName, leaseSession) {
305
+ const runId = makeRunId();
306
+ const suiteDir = createSuiteDir(config.artifactRoot, runId, targetName, suiteName);
307
+ const startedAt = Date.now();
308
+ const platform = platformFor(targetName);
309
+ const slug = `${config.packageName}-${targetName}`;
310
+ const command = buildBrowserDogfoodCommand(targetName, config.agentBrowserVersion);
311
+ writeFileSync(resolve(suiteDir, "target.json"), JSON.stringify({ targetName, platform, runId, slug }, null, 2));
312
+ writeFileSync(resolve(suiteDir, "suite.json"), JSON.stringify({ suiteName, modelCalls: 0, realBrowser: true }, null, 2));
313
+ writeCommand(suiteDir, command);
314
+
315
+ let lease = leaseSession;
316
+ const ownsLease = !lease;
317
+ if (!lease) lease = await warmupLease(targetName, slug, config);
318
+ if (!lease.ok) {
319
+ writeExitCode(suiteDir, lease.code, lease.signal);
320
+ writeFileSync(resolve(suiteDir, "crabbox.stdout.txt"), lease.stdout ?? "");
321
+ writeFileSync(resolve(suiteDir, "crabbox.stderr.txt"), lease.stderr ?? "");
322
+ const { assertions } = finalizeSuite(suiteDir, [{ id: "crabbox-warmup", fn: () => false, error: "Crabbox warmup failed" }], { target: targetName, suite: suiteName, elapsedMs: Date.now() - startedAt }, ["summary.json", "artifact-manifest.json", "target.json", "suite.json", "command.txt", "exit-code.txt", "crabbox.stdout.txt", "crabbox.stderr.txt", "assertions.json"]);
323
+ return { ok: false, suiteDir, assertions };
324
+ }
325
+
326
+ const secretValues = collectSecretValues(authEnvAllowList(config));
327
+ const result = await runOnLease(targetName, lease.leaseId, command, { timeout: 900_000, sync: leaseSession?.sync, config });
328
+ const elapsedMs = Date.now() - startedAt;
329
+ writeRedacted(resolve(suiteDir, "crabbox.stdout.txt"), result.stdout, secretValues);
330
+ writeRedacted(resolve(suiteDir, "crabbox.stderr.txt"), result.stderr, secretValues);
331
+ writeFileSync(resolve(suiteDir, "crabbox.timing.json"), JSON.stringify({ elapsedMs, code: result.code, signal: result.signal }, null, 2));
332
+ writeExitCode(suiteDir, result.code, result.signal);
333
+ const dogfoodReport = writeDogfoodExtracts(suiteDir, result.stdout, secretValues);
334
+ let stopResult;
335
+ if (ownsLease) {
336
+ stopResult = await stopLease(targetName, lease.leaseId, config);
337
+ writeRedacted(resolve(suiteDir, "crabbox.stop.stdout.txt"), stopResult.stdout, secretValues);
338
+ writeRedacted(resolve(suiteDir, "crabbox.stop.stderr.txt"), stopResult.stderr, secretValues);
339
+ writeFileSync(resolve(suiteDir, "crabbox.stop.exit-code.txt"), `code=${stopResult.code}\nsignal=${stopResult.signal ?? "none"}\n`);
340
+ }
341
+
342
+ const secretViolations = [
343
+ ...scanForSecrets(`${result.stdout}\n${result.stderr}`, secretValues),
344
+ ...scanArtifactTextFiles(suiteDir, secretValues).map((finding) => `${finding.file}: ${finding.violation}`),
345
+ ];
346
+ const reportIds = new Set((dogfoodReport.reports ?? []).map((report) => report.id));
347
+ const checks = [
348
+ { id: "command-exit-zero", fn: () => result.code === 0, error: `exit ${result.code}` },
349
+ { id: "browser-dogfood-marker", fn: () => result.stdout.includes("PLATFORM_BROWSER_DOGFOOD_OK") },
350
+ { id: "npm-ci", fn: () => /PLATFORM_NPM_CI_EXIT=0/.test(result.stdout) },
351
+ { id: "agent-browser-baseline", fn: () => /PLATFORM_AGENT_BROWSER_READY_EXIT=0/.test(result.stdout) },
352
+ { id: "agent-browser-browser-cache", fn: () => platform !== "powershell" || /PLATFORM_AGENT_BROWSER_BROWSER_CACHE_EXIT=0/.test(result.stdout) },
353
+ { id: "agent-browser-prewarm", fn: () => platform !== "powershell" || /PLATFORM_AGENT_BROWSER_PREWARM_EXIT=0/.test(result.stdout) },
354
+ { id: "dogfood-exit-zero", fn: () => /PLATFORM_DOGFOOD_EXIT=0/.test(result.stdout) },
355
+ { id: "dogfood-report", fn: () => Array.isArray(dogfoodReport.reports) && dogfoodReport.reports.length >= 5 },
356
+ { id: "dogfood-qa", fn: () => reportIds.has("qa-url") },
357
+ { id: "dogfood-session-close", fn: () => reportIds.has("close-session") },
358
+ { id: "no-secret-artifacts", fn: () => secretViolations.length === 0, error: secretViolations.join(", ") },
359
+ ];
360
+ if (stopResult) checks.push({ id: "lease-cleanup", fn: () => stopResult.code === 0, error: `stop exit ${stopResult.code}` });
361
+ const expectedFiles = [
362
+ "summary.json", "artifact-manifest.json", "target.json", "suite.json", "command.txt", "exit-code.txt", "crabbox.stdout.txt", "crabbox.stderr.txt", "crabbox.timing.json",
363
+ "node-version.txt", "dogfood-artifacts.txt", "dogfood.stdout.txt", "dogfood.stderr.txt", "dogfood-report.json", "assertions.json",
364
+ ];
365
+ if (stopResult) expectedFiles.push("crabbox.stop.stdout.txt", "crabbox.stop.stderr.txt", "crabbox.stop.exit-code.txt");
366
+ const { assertions } = finalizeSuite(suiteDir, checks, { target: targetName, suite: suiteName, elapsedMs, exitCode: result.code, signal: result.signal }, expectedFiles);
367
+ return { ok: assertions.ok, suiteDir, assertions };
368
+ }
369
+
370
+ async function runPlatformBuildSuite(config, targetName, suiteName, leaseSession) {
371
+ const runId = makeRunId();
372
+ const suiteDir = createSuiteDir(config.artifactRoot, runId, targetName, suiteName);
373
+ const startedAt = Date.now();
374
+ const platform = platformFor(targetName);
375
+ const slug = `${config.packageName}-${targetName}`;
376
+ const command = buildPlatformBuildCommand(targetName, config.packageName, config.nodeValidationMajor);
377
+ mkdirSync(dirname(suiteDir), { recursive: true });
378
+ writeFileSync(resolve(suiteDir, "target.json"), JSON.stringify({ targetName, platform, runId, slug }, null, 2));
379
+ writeFileSync(resolve(suiteDir, "suite.json"), JSON.stringify({ suiteName, modelCalls: 0 }, null, 2));
380
+ writeCommand(suiteDir, command);
381
+
382
+ let lease = leaseSession;
383
+ const ownsLease = !lease;
384
+ if (!lease) lease = await warmupLease(targetName, slug, config);
385
+ if (!lease.ok) {
386
+ writeExitCode(suiteDir, lease.code, lease.signal);
387
+ writeFileSync(resolve(suiteDir, "crabbox.stdout.txt"), lease.stdout ?? "");
388
+ writeFileSync(resolve(suiteDir, "crabbox.stderr.txt"), lease.stderr ?? "");
389
+ const { assertions } = finalizeSuite(suiteDir, [{ id: "crabbox-warmup", fn: () => false, error: "Crabbox warmup failed" }], { target: targetName, suite: suiteName, elapsedMs: Date.now() - startedAt }, ["summary.json", "artifact-manifest.json", "target.json", "suite.json", "command.txt", "exit-code.txt", "crabbox.stdout.txt", "crabbox.stderr.txt", "assertions.json"]);
390
+ return { ok: false, suiteDir, assertions };
391
+ }
392
+
393
+ const secretValues = collectSecretValues(authEnvAllowList(config));
394
+ const result = await runOnLease(targetName, lease.leaseId, command, { timeout: 1_500_000, sync: leaseSession?.sync, config });
395
+ const elapsedMs = Date.now() - startedAt;
396
+ writeRedacted(resolve(suiteDir, "crabbox.stdout.txt"), result.stdout, secretValues);
397
+ writeRedacted(resolve(suiteDir, "crabbox.stderr.txt"), result.stderr, secretValues);
398
+ writeFileSync(resolve(suiteDir, "crabbox.timing.json"), JSON.stringify({ elapsedMs, code: result.code, signal: result.signal }, null, 2));
399
+ writeExitCode(suiteDir, result.code, result.signal);
400
+ writePlatformExtracts(suiteDir, result.stdout, secretValues);
401
+ let stopResult;
402
+ if (ownsLease) {
403
+ stopResult = await stopLease(targetName, lease.leaseId, config);
404
+ writeRedacted(resolve(suiteDir, "crabbox.stop.stdout.txt"), stopResult.stdout, secretValues);
405
+ writeRedacted(resolve(suiteDir, "crabbox.stop.stderr.txt"), stopResult.stderr, secretValues);
406
+ writeFileSync(resolve(suiteDir, "crabbox.stop.exit-code.txt"), `code=${stopResult.code}\nsignal=${stopResult.signal ?? "none"}\n`);
407
+ }
408
+
409
+ const stdout = result.stdout;
410
+ const listOutput = section(stdout, "PI_LIST_STDOUT");
411
+ const nodeMajor = Number(marker(stdout, "PLATFORM_NODE_VERSION").replace(/^v/, "").split(".")[0] ?? 0);
412
+ const secretViolations = [
413
+ ...scanForSecrets(`${result.stdout}\n${result.stderr}`, secretValues),
414
+ ...scanArtifactTextFiles(suiteDir, secretValues).map((finding) => `${finding.file}: ${finding.violation}`),
415
+ ];
416
+ const checks = [
417
+ { id: "command-exit-zero", fn: () => result.code === 0, error: `exit ${result.code}` },
418
+ { id: "platform-marker", fn: () => stdout.includes("PLATFORM_BUILD_OK") },
419
+ { id: "node-version", fn: () => nodeMajor >= (config.nodeValidationMajor ?? 22), error: `Node major ${nodeMajor}` },
420
+ { id: "npm-ci", fn: () => /PLATFORM_NPM_CI_EXIT=0/.test(stdout) },
421
+ { id: "npm-run-verify", fn: () => /PLATFORM_VERIFY_EXIT=0/.test(stdout) },
422
+ { id: "npm-pack", fn: () => /PLATFORM_NPM_PACK_EXIT=0/.test(stdout) && marker(stdout, "PLATFORM_PACKED_TARBALL").length > 0 },
423
+ { id: "packed-node-install", fn: () => /PLATFORM_PACKED_NODE_INSTALL_EXIT=0/.test(stdout) },
424
+ { id: "pi-install-local-package", fn: () => /PLATFORM_PI_INSTALL_EXIT=0/.test(stdout) },
425
+ { id: "pi-list-local-package", fn: () => /PLATFORM_PI_LIST_EXIT=0/.test(stdout) && listOutput.includes(config.packageName) },
426
+ { id: "no-source-extension-shortcut", fn: () => !/\bpi\s+(?:-e|--extension)\s+\./.test(stdout) },
427
+ { id: "no-secret-artifacts", fn: () => secretViolations.length === 0, error: secretViolations.join(", ") },
428
+ ];
429
+ if (stopResult) checks.push({ id: "lease-cleanup", fn: () => stopResult.code === 0, error: `stop exit ${stopResult.code}` });
430
+ const expectedFiles = [
431
+ "summary.json", "artifact-manifest.json", "target.json", "suite.json", "command.txt", "exit-code.txt", "crabbox.stdout.txt", "crabbox.stderr.txt", "crabbox.timing.json",
432
+ "node-version.txt", "packed-tarball.txt", "packed-node-install.stdout.txt", "packed-node-install.stderr.txt", "pi-install.stdout.txt", "pi-install.stderr.txt", "pi-list.stdout.txt", "pi-list.stderr.txt", "assertions.json",
433
+ ];
434
+ if (stopResult) expectedFiles.push("crabbox.stop.stdout.txt", "crabbox.stop.stderr.txt", "crabbox.stop.exit-code.txt");
435
+ const { assertions } = finalizeSuite(suiteDir, checks, { target: targetName, suite: suiteName, elapsedMs, exitCode: result.code, signal: result.signal }, expectedFiles);
436
+ return { ok: assertions.ok, suiteDir, assertions };
437
+ }
438
+
439
+ export async function runTargetSuite(config, targetName, suiteName, leaseSession) {
440
+ if (suiteName === "platform-build") return await runPlatformBuildSuite(config, targetName, suiteName, leaseSession);
441
+ if (suiteName === "browser-dogfood-smoke") return await runBrowserDogfoodSuite(config, targetName, suiteName, leaseSession);
442
+ throw new Error(`unknown suite: ${suiteName}`);
443
+ }
444
+
445
+ export async function runTargetSuites(config, targetName, suiteNames) {
446
+ const slug = `${config.packageName}-${targetName}`;
447
+ const lease = await warmupLease(targetName, slug, config);
448
+ if (!lease.ok) {
449
+ const warmupFailure = createLeaseWarmupFailureResult(config, targetName, lease);
450
+ return { ok: false, results: [warmupFailure] };
451
+ }
452
+ const results = [];
453
+ let stopResult;
454
+ let staleCleanupResult;
455
+ try {
456
+ let sync = true;
457
+ for (const suiteName of suiteNames) {
458
+ const result = await runTargetSuite(config, targetName, suiteName, { ...lease, sync });
459
+ results.push(result);
460
+ sync = false;
461
+ if (!result.ok) break;
462
+ }
463
+ } finally {
464
+ stopResult = await stopLease(targetName, lease.leaseId, config);
465
+ staleCleanupResult = await cleanupStaleTargetState(targetName, config);
466
+ }
467
+ if (stopResult) {
468
+ results.push(createLeaseCleanupResult(config, targetName, lease.leaseId, stopResult, staleCleanupResult));
469
+ }
470
+ return { ok: results.every((result) => result.ok), results };
471
+ }
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Purpose: Provide the Crabbox-backed platform smoke CLI for pi-agent-browser-native releases.
4
+ * Responsibilities: Load the project platform smoke config, validate target/suite names, run doctor, and fan out target suites.
5
+ * Scope: Maintainer release verification only; target command rendering and artifact assertions live under scripts/platform-smoke/.
6
+ */
7
+
8
+ import { createRequire } from "node:module";
9
+ import { dirname, resolve } from "node:path";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+ const repoRoot = resolve(__dirname, "..");
15
+ const require = createRequire(import.meta.url);
16
+
17
+ let config;
18
+ try {
19
+ config = require(resolve(repoRoot, "platform-smoke.config.mjs"));
20
+ if (config.default) config = config.default;
21
+ } catch {
22
+ config = null;
23
+ }
24
+
25
+ function printHelp() {
26
+ console.log(`Usage: node scripts/platform-smoke.mjs <command> [options]
27
+
28
+ Commands:
29
+ doctor Validate Crabbox and platform prerequisites
30
+ run --target <names> Run one or more comma-separated targets concurrently
31
+ run --suite <name> Run one suite on all or specified targets
32
+
33
+ Package scripts:
34
+ check:platform-smoke Syntax-check harness scripts and run cheap harness invariant tests
35
+ smoke:platform:ubuntu-image Build the local Ubuntu image used by default
36
+ smoke:platform:all Runs doctor first, then the full macOS/Ubuntu/Windows matrix
37
+
38
+ Targets:
39
+ macos, ubuntu, windows-native
40
+
41
+ Suites:
42
+ platform-build npm ci, npm run verify -- platform-target, npm pack, packed pi install, pi list
43
+ browser-dogfood-smoke model-free native agent_browser smoke with real agent-browser/browser
44
+
45
+ Options:
46
+ --target <names> Comma-separated target names; defaults to configured required targets
47
+ --suite <name> Suite name; defaults to configured required suites
48
+ --help, -h Show this help
49
+
50
+ Examples:
51
+ npm run check:platform-smoke
52
+ npm run smoke:platform:doctor
53
+ npm run smoke:platform:all
54
+ node scripts/platform-smoke.mjs doctor
55
+ node scripts/platform-smoke.mjs run --target macos
56
+ node scripts/platform-smoke.mjs run --target ubuntu --suite platform-build
57
+ node scripts/platform-smoke.mjs run --target macos,ubuntu,windows-native
58
+
59
+ Environment:
60
+ PLATFORM_SMOKE_CRABBOX Optional Crabbox binary override; defaults to crabbox on PATH
61
+ PLATFORM_SMOKE_MAC_HOST macOS SSH host; default localhost
62
+ PLATFORM_SMOKE_MAC_USER macOS SSH user; default $USER
63
+ PLATFORM_SMOKE_MAC_WORK_ROOT macOS Crabbox work root
64
+ PLATFORM_SMOKE_UBUNTU_IMAGE Ubuntu local-container image; default pi-agent-browser-native-platform:node24-agent-browser0.27.1
65
+ PLATFORM_SMOKE_WINDOWS_VM Parallels Windows template VM
66
+ PLATFORM_SMOKE_WINDOWS_SNAPSHOT Parallels snapshot name
67
+ PLATFORM_SMOKE_WINDOWS_USER Windows SSH user
68
+ PLATFORM_SMOKE_WINDOWS_WORK_ROOT Windows work root, for example C:\\crabbox\\pi-agent-browser-native
69
+ PLATFORM_SMOKE_AUTH_ENV Optional comma-separated secret env names to redact/forward for future live suites
70
+ `);
71
+ }
72
+
73
+ export function parseArgs(argv = process.argv.slice(2)) {
74
+ const parsed = { command: null, target: null, suite: null };
75
+ for (let index = 0; index < argv.length; index += 1) {
76
+ const arg = argv[index];
77
+ if (arg === "--help" || arg === "-h") {
78
+ parsed.command = "help";
79
+ return parsed;
80
+ }
81
+ if (arg === "doctor" || arg === "run") {
82
+ if (parsed.command) throw new Error(`multiple commands provided: ${parsed.command}, ${arg}`);
83
+ parsed.command = arg;
84
+ continue;
85
+ }
86
+ if (arg === "--target") {
87
+ const value = argv[index + 1];
88
+ if (!value || value.startsWith("-")) throw new Error("--target requires a value");
89
+ parsed.target = value;
90
+ index += 1;
91
+ continue;
92
+ }
93
+ if (arg === "--suite") {
94
+ const value = argv[index + 1];
95
+ if (!value || value.startsWith("-")) throw new Error("--suite requires a value");
96
+ parsed.suite = value;
97
+ index += 1;
98
+ continue;
99
+ }
100
+ throw new Error(`unknown argument: ${arg}`);
101
+ }
102
+ return parsed;
103
+ }
104
+
105
+ function validateNames(kind, names, allowed) {
106
+ const invalid = names.filter((name) => !allowed.includes(name));
107
+ if (invalid.length > 0) throw new Error(`unknown ${kind}: ${invalid.join(", ")}`);
108
+ }
109
+
110
+ export async function main(argv = process.argv.slice(2)) {
111
+ const args = parseArgs(argv);
112
+ if (!args.command || args.command === "help") {
113
+ printHelp();
114
+ return args.command === "help" ? 0 : 2;
115
+ }
116
+ if (!config) throw new Error("platform-smoke.config.mjs not found or invalid");
117
+
118
+ if (args.command === "doctor") {
119
+ const { runDoctor } = await import("./platform-smoke/doctor.mjs");
120
+ await runDoctor(config);
121
+ return process.exitCode ?? 0;
122
+ }
123
+
124
+ if (args.command === "run") {
125
+ const { runTargetSuite, runTargetSuites } = await import("./platform-smoke/targets.mjs");
126
+ const targets = args.target ? args.target.split(",").map((name) => name.trim()).filter(Boolean) : config.requiredTargets;
127
+ const suites = args.suite ? [args.suite] : config.requiredSuites;
128
+ const supportedTargets = config.supportedTargets ?? config.requiredTargets;
129
+ validateNames("target", targets, supportedTargets);
130
+ validateNames("suite", suites, config.requiredSuites);
131
+ const runs = targets.map(async (targetName) => {
132
+ console.log(`\n=== Target: ${targetName} ===`);
133
+ const result = args.suite
134
+ ? await runTargetSuite(config, targetName, suites[0])
135
+ : await runTargetSuites(config, targetName, suites);
136
+ return { targetName, result };
137
+ });
138
+ const results = await Promise.all(runs);
139
+ const failed = results.filter(({ result }) => !result.ok);
140
+ if (failed.length > 0) {
141
+ console.error(`\nPlatform smoke failed for ${failed.map(({ targetName }) => targetName).join(", ")}. See ${config.artifactRoot}.`);
142
+ return 1;
143
+ }
144
+ console.log(`\nPlatform smoke passed for ${results.map(({ targetName }) => targetName).join(", ")}.`);
145
+ return 0;
146
+ }
147
+
148
+ throw new Error(`unknown command: ${args.command}`);
149
+ }
150
+
151
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
152
+ main().then(
153
+ (exitCode) => {
154
+ process.exitCode = exitCode;
155
+ },
156
+ (error) => {
157
+ console.error(error instanceof Error ? error.message : String(error));
158
+ process.exitCode = 1;
159
+ },
160
+ );
161
+ }