pi-oracle 0.7.4 → 0.7.5

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.
Files changed (29) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +51 -17
  3. package/docs/ORACLE_DESIGN.md +12 -5
  4. package/docs/platform-smoke.md +153 -0
  5. package/extensions/oracle/lib/config.ts +53 -27
  6. package/extensions/oracle/lib/jobs.ts +9 -5
  7. package/extensions/oracle/lib/runtime.ts +107 -32
  8. package/extensions/oracle/lib/tools.ts +138 -12
  9. package/extensions/oracle/shared/browser-profile-helpers.d.mts +59 -0
  10. package/extensions/oracle/shared/browser-profile-helpers.mjs +395 -0
  11. package/extensions/oracle/shared/process-helpers.mjs +12 -1
  12. package/extensions/oracle/shared/state-coordination-helpers.mjs +8 -2
  13. package/extensions/oracle/worker/auth-bootstrap.mjs +39 -10
  14. package/extensions/oracle/worker/chatgpt-ui-helpers.d.mts +2 -0
  15. package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +157 -1
  16. package/extensions/oracle/worker/chromium-cookie-source.mjs +2 -1
  17. package/extensions/oracle/worker/run-job.mjs +107 -25
  18. package/package.json +27 -6
  19. package/platform-smoke.config.mjs +59 -0
  20. package/scripts/oracle-real-smoke.mjs +497 -0
  21. package/scripts/platform-smoke/Dockerfile.ubuntu +8 -0
  22. package/scripts/platform-smoke/artifacts.mjs +87 -0
  23. package/scripts/platform-smoke/assertions.mjs +34 -0
  24. package/scripts/platform-smoke/crabbox-runner.mjs +135 -0
  25. package/scripts/platform-smoke/doctor.mjs +239 -0
  26. package/scripts/platform-smoke/invariants.mjs +108 -0
  27. package/scripts/platform-smoke/platform-build-windows.ps1 +168 -0
  28. package/scripts/platform-smoke/targets.mjs +434 -0
  29. package/scripts/platform-smoke.mjs +149 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Thin Crabbox CLI wrapper for pi-oracle's Ubuntu platform smoke target.
3
+ */
4
+
5
+ import { spawn } from "node:child_process";
6
+
7
+ const CRABBOX_BIN = process.env.PI_ORACLE_SMOKE_CRABBOX || "crabbox";
8
+
9
+ function env(name) {
10
+ return process.env[name] ?? "";
11
+ }
12
+
13
+ export function execCrabbox(args, opts = {}) {
14
+ return new Promise((resolve) => {
15
+ const child = spawn(CRABBOX_BIN, args, {
16
+ stdio: ["ignore", "pipe", "pipe"],
17
+ env: { ...process.env, CRABBOX_SYNC_GIT_SEED: "false", ...opts.env },
18
+ ...opts.spawnOpts,
19
+ });
20
+ const stdoutChunks = [];
21
+ const stderrChunks = [];
22
+ let timeout;
23
+ let killTimeout;
24
+ if (opts.timeout && opts.timeout > 0) {
25
+ timeout = setTimeout(() => {
26
+ stderrChunks.push(Buffer.from(`\n[platform-smoke] crabbox command timed out after ${opts.timeout}ms\n`));
27
+ try { child.kill("SIGTERM"); } catch {}
28
+ killTimeout = setTimeout(() => {
29
+ try { child.kill("SIGKILL"); } catch {}
30
+ }, 10_000);
31
+ }, opts.timeout);
32
+ }
33
+ child.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
34
+ child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
35
+ child.on("error", (error) => {
36
+ if (timeout) clearTimeout(timeout);
37
+ if (killTimeout) clearTimeout(killTimeout);
38
+ resolve({
39
+ stdout: Buffer.concat(stdoutChunks).toString(),
40
+ stderr: `${Buffer.concat(stderrChunks).toString()}\n${error.message}`.trim(),
41
+ code: 1,
42
+ signal: null,
43
+ });
44
+ });
45
+ child.on("close", (code, signal) => {
46
+ if (timeout) clearTimeout(timeout);
47
+ if (killTimeout) clearTimeout(killTimeout);
48
+ resolve({
49
+ stdout: Buffer.concat(stdoutChunks).toString(),
50
+ stderr: Buffer.concat(stderrChunks).toString(),
51
+ code: code ?? (signal ? 1 : 0),
52
+ signal,
53
+ });
54
+ });
55
+ });
56
+ }
57
+
58
+ export function buildTargetBaseArgs(targetName, config = {}) {
59
+ switch (targetName) {
60
+ case "macos": {
61
+ const host = env("PI_ORACLE_SMOKE_MAC_HOST") || "localhost";
62
+ const user = env("PI_ORACLE_SMOKE_MAC_USER") || env("USER");
63
+ const workRoot = env("PI_ORACLE_SMOKE_MAC_WORK_ROOT") || `/Users/${env("USER")}/crabbox/pi-oracle`;
64
+ return [
65
+ "--provider", "ssh",
66
+ "--target", "macos",
67
+ "--static-host", host,
68
+ "--static-user", user,
69
+ "--static-port", "22",
70
+ "--static-work-root", workRoot,
71
+ ];
72
+ }
73
+ case "ubuntu": {
74
+ const image = env("PI_ORACLE_SMOKE_UBUNTU_IMAGE") || env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config.ubuntuContainerImage || "pi-oracle-platform-smoke:node24";
75
+ return [
76
+ "--provider", "local-container",
77
+ "--target", "linux",
78
+ "--local-container-image", image,
79
+ ];
80
+ }
81
+ case "windows-native": {
82
+ const vm = env("PI_ORACLE_SMOKE_WINDOWS_VM") || env("PLATFORM_SMOKE_WINDOWS_VM") || config.windowsParallels?.sourceVm || "pi-extension-windows-template";
83
+ const snapshot = env("PI_ORACLE_SMOKE_WINDOWS_SNAPSHOT") || env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || config.windowsParallels?.snapshot || "crabbox-ready";
84
+ const user = env("PI_ORACLE_SMOKE_WINDOWS_USER") || env("USER");
85
+ const workRoot = env("PI_ORACLE_SMOKE_WINDOWS_NATIVE_WORK_ROOT") || env("PLATFORM_SMOKE_WINDOWS_WORK_ROOT") || `C:\\crabbox\\${config.packageName ?? "pi-oracle"}`;
86
+ return [
87
+ "--provider", "parallels",
88
+ "--target", "windows",
89
+ "--windows-mode", "normal",
90
+ "--parallels-source", vm,
91
+ "--parallels-source-snapshot", snapshot,
92
+ "--parallels-user", user,
93
+ "--parallels-work-root", workRoot,
94
+ ];
95
+ }
96
+ default:
97
+ throw new Error(`unknown target: ${targetName}`);
98
+ }
99
+ }
100
+
101
+ function parseLeaseId(output) {
102
+ return output.match(/\bleased\s+(\S+)/)?.[1]
103
+ ?? output.match(/\blease=(\S+)/)?.[1]
104
+ ?? null;
105
+ }
106
+
107
+ export async function warmupLease(config, targetName, slug) {
108
+ const args = ["warmup", ...buildTargetBaseArgs(targetName, config), "--slug", slug, "--keep"];
109
+ if (targetName === "macos") args.push("--reclaim");
110
+ console.log(` [crabbox] ${args.join(" ")}`);
111
+ const result = await execCrabbox(args, { timeout: 300_000 });
112
+ return {
113
+ ok: result.code === 0,
114
+ ...result,
115
+ leaseId: parseLeaseId(result.stdout) ?? parseLeaseId(result.stderr) ?? slug,
116
+ };
117
+ }
118
+
119
+ export async function runOnLease(config, targetName, leaseId, command, opts = {}) {
120
+ const args = ["run", ...buildTargetBaseArgs(targetName, config), "--id", leaseId];
121
+ if (targetName === "macos") args.push("--reclaim");
122
+ for (const name of opts.allowEnvNames ?? []) args.push("--allow-env", name);
123
+ if (opts.sync === false) args.push("--no-sync");
124
+ else args.push("--fresh-sync");
125
+ if (opts.shell) args.push("--shell", command);
126
+ else args.push("--", ...(Array.isArray(command) ? command : command.split(" ")));
127
+ console.log(` [crabbox] run ${targetName} ${opts.sync === false ? "--no-sync" : "--fresh-sync"} ...`);
128
+ return execCrabbox(args, { timeout: opts.timeout ?? 900_000, env: opts.env });
129
+ }
130
+
131
+ export async function stopLease(config, targetName, leaseId) {
132
+ const args = ["stop", ...buildTargetBaseArgs(targetName, config), "--id", leaseId];
133
+ console.log(` [crabbox] ${args.join(" ")}`);
134
+ return execCrabbox(args, { timeout: 60_000 });
135
+ }
@@ -0,0 +1,239 @@
1
+ // Preflight checks for the pi-oracle Crabbox platform smoke gate.
2
+
3
+ import { execFileSync, execSync } from "node:child_process";
4
+ import { accessSync, constants, existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+
7
+ let failures = 0;
8
+
9
+ function ok(label) { console.log(` ✓ ${label}`); }
10
+ function warn(label) { console.log(` ⚠ ${label}`); }
11
+ function fail(label) { console.error(` ✗ ${label}`); failures += 1; }
12
+ function env(name) { return process.env[name] ?? ""; }
13
+ function truthy(value) { return /^(1|true|yes|on)$/i.test(String(value ?? "")); }
14
+
15
+ function silent(command, args, opts = {}) {
16
+ try { return execFileSync(command, args, { timeout: 20_000, stdio: "pipe", ...opts }).toString().trim(); }
17
+ catch { return null; }
18
+ }
19
+
20
+ function shell(command, opts = {}) {
21
+ try { return execSync(command, { timeout: 20_000, stdio: "pipe", ...opts }).toString().trim(); }
22
+ catch { return null; }
23
+ }
24
+
25
+ function resolveCommand(command) {
26
+ if (!command) return null;
27
+ if (command.includes("/") || command.includes("\\")) return command;
28
+ return shell(`command -v ${command}`) ?? command;
29
+ }
30
+
31
+ function compareVersions(actual, required) {
32
+ const a = String(actual).split(".").map((part) => Number.parseInt(part, 10));
33
+ const b = String(required).split(".").map((part) => Number.parseInt(part, 10));
34
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
35
+ const av = Number.isFinite(a[i]) ? a[i] : 0;
36
+ const bv = Number.isFinite(b[i]) ? b[i] : 0;
37
+ if (av > bv) return 1;
38
+ if (av < bv) return -1;
39
+ }
40
+ return 0;
41
+ }
42
+
43
+ function realSmokeProvider(config) {
44
+ return process.env.PI_ORACLE_REAL_TEST_PROVIDER || config.realSmoke?.defaultProvider || "zai";
45
+ }
46
+
47
+ function windowsVmName(config) {
48
+ return env("PI_ORACLE_SMOKE_WINDOWS_VM") || env("PLATFORM_SMOKE_WINDOWS_VM") || config.windowsParallels?.sourceVm || "pi-extension-windows-template";
49
+ }
50
+
51
+ function windowsSnapshotName(config) {
52
+ return env("PI_ORACLE_SMOKE_WINDOWS_SNAPSHOT") || env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || config.windowsParallels?.snapshot || "crabbox-ready";
53
+ }
54
+
55
+ function requiredRealSmokeAuthEnv(config) {
56
+ if (!(config.requiredSuites ?? []).includes("real-extension")) return [];
57
+ if (!truthy(process.env.PI_ORACLE_REAL_TEST_MODEL_AGENT)) return [];
58
+ const provider = realSmokeProvider(config);
59
+ return config.realSmoke?.authEnvByProvider?.[provider] ?? [];
60
+ }
61
+
62
+ function parseJson(text) {
63
+ try { return JSON.parse(text); } catch { return undefined; }
64
+ }
65
+
66
+ function targetBaseArgs(targetName, config) {
67
+ if (targetName === "macos") {
68
+ const host = env("PI_ORACLE_SMOKE_MAC_HOST") || env("PLATFORM_SMOKE_MAC_HOST") || "localhost";
69
+ const user = env("PI_ORACLE_SMOKE_MAC_USER") || env("PLATFORM_SMOKE_MAC_USER") || env("USER");
70
+ const workRoot = env("PI_ORACLE_SMOKE_MAC_WORK_ROOT") || env("PLATFORM_SMOKE_MAC_WORK_ROOT") || `/Users/${env("USER")}/crabbox/${config.packageName}`;
71
+ return ["--provider", "ssh", "--target", "macos", "--static-host", host, "--static-user", user, "--static-port", "22", "--static-work-root", workRoot];
72
+ }
73
+ if (targetName === "ubuntu") {
74
+ const image = env("PI_ORACLE_SMOKE_UBUNTU_IMAGE") || env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config.ubuntuContainerImage || "pi-oracle-platform-smoke:node24";
75
+ return ["--provider", "local-container", "--target", "linux", "--local-container-image", image];
76
+ }
77
+ const vm = windowsVmName(config);
78
+ const snapshot = windowsSnapshotName(config);
79
+ const user = env("PI_ORACLE_SMOKE_WINDOWS_USER") || env("PLATFORM_SMOKE_WINDOWS_USER") || env("USER");
80
+ const workRoot = env("PI_ORACLE_SMOKE_WINDOWS_NATIVE_WORK_ROOT") || env("PLATFORM_SMOKE_WINDOWS_WORK_ROOT") || `C:\\crabbox\\${config.packageName}`;
81
+ return ["--provider", "parallels", "--target", "windows", "--windows-mode", "normal", "--parallels-source", vm, "--parallels-source-snapshot", snapshot, "--parallels-user", user, "--parallels-work-root", workRoot];
82
+ }
83
+
84
+ function runCrabboxDoctor(cbox, label, args, timeout = 120_000) {
85
+ const output = silent(cbox, ["doctor", ...args, "--json"], { env: { ...process.env, CRABBOX_SYNC_GIT_SEED: "false" }, timeout });
86
+ if (!output) {
87
+ fail(`crabbox doctor ${label} failed`);
88
+ return;
89
+ }
90
+ const parsed = parseJson(output);
91
+ if (!parsed) fail(`could not parse ${label} Crabbox doctor JSON`);
92
+ else if (parsed.ok) ok(`${label} provider OK`);
93
+ else fail(`${label} doctor failed: ${parsed.error ?? "not ok"}`);
94
+ }
95
+
96
+ function runTargetToolProbe(cbox, targetName, config) {
97
+ const required = targetName === "windows-native"
98
+ ? ["node", "npm", "git", "tar", "zstd", "agent-browser"]
99
+ : ["node", "npm", "git", "tar", "rsync", "zstd", "agent-browser"];
100
+ const command = targetName === "windows-native"
101
+ ? `cmd.exe /c "where node && where npm && where git && where tar && where zstd && where agent-browser && zstd --version && agent-browser --version && echo tools-ok"`
102
+ : `missing=""; for tool in ${required.join(" ")}; do command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"; done; if [ -n "$missing" ]; then echo "missing=$missing"; exit 1; fi; zstd --version >/dev/null && agent-browser --version >/dev/null && echo tools-ok`;
103
+ const baseArgs = targetBaseArgs(targetName, config);
104
+ if (targetName === "macos") baseArgs.push("--reclaim");
105
+ const result = silent(cbox, ["run", ...baseArgs, "--no-sync", "--shell", command], {
106
+ env: { ...process.env, CRABBOX_SYNC_GIT_SEED: "false" },
107
+ timeout: targetName === "windows-native" ? 300_000 : 180_000,
108
+ });
109
+ result?.includes("tools-ok") ? ok(`${targetName} required target tools OK`) : fail(`${targetName} required target tools missing or probe failed${result ? `: ${result.split(/\r?\n/).slice(-3).join(" | ")}` : ""}`);
110
+ }
111
+
112
+ function verifyWindowsTemplate(config) {
113
+ const vm = windowsVmName(config);
114
+ const snapshot = windowsSnapshotName(config);
115
+ const prlctl = resolveCommand("prlctl");
116
+ if (!prlctl) {
117
+ fail("prlctl not found on host PATH");
118
+ return;
119
+ }
120
+ ok(`prlctl: ${prlctl}`);
121
+ const list = silent("prlctl", ["list", "--all", "-j"], { timeout: 30_000 });
122
+ const vms = parseJson(list);
123
+ const vmRecord = Array.isArray(vms) ? vms.find((entry) => entry.name === vm || entry.ID === vm) : undefined;
124
+ if (!vmRecord) fail(`Windows source VM not found: ${vm}`);
125
+ else if (vmRecord.status !== "stopped") fail(`Windows source VM ${vm} must be stopped; status=${vmRecord.status}`);
126
+ else ok(`Windows source VM stopped: ${vm}`);
127
+
128
+ const snapshotsText = silent("prlctl", ["snapshot-list", vm, "-j"], { timeout: 30_000 });
129
+ const snapshots = parseJson(snapshotsText);
130
+ const snapshotRecord = snapshots ? Object.values(snapshots).find((entry) => entry?.name === snapshot) : undefined;
131
+ if (!snapshotRecord) fail(`Windows snapshot not found: ${snapshot}`);
132
+ else if (snapshotRecord.state !== "poweroff") fail(`Windows snapshot ${snapshot} must be poweroff; state=${snapshotRecord.state}`);
133
+ else ok(`Windows snapshot poweroff/forkable: ${snapshot}`);
134
+ }
135
+
136
+ function verifyPackageExclusions() {
137
+ const forbiddenPatterns = [/^\.env(?:\.|$)/, /^\.artifacts(?:\/|$)/, /^\.crabbox(?:\/|$)/, /^\.debug(?:\/|$)/, /^\.platform-smoke-runs(?:\/|$)/, /\.tgz$/];
138
+ const output = shell("npm pack --dry-run --json", { timeout: 120_000 });
139
+ const parsed = parseJson(output);
140
+ if (!Array.isArray(parsed) || !parsed[0]?.files) {
141
+ warn("could not inspect npm pack file list");
142
+ return;
143
+ }
144
+ const files = parsed[0].files.map((file) => file.path);
145
+ const forbidden = files.filter((file) => forbiddenPatterns.some((pattern) => pattern.test(file)));
146
+ forbidden.length ? fail(`forbidden files in package: ${forbidden.join(", ")}`) : ok("package excludes local env/artifact/debug state");
147
+ }
148
+
149
+ function verifyRepoForbiddenFiles() {
150
+ const forbidden = shell("find . -maxdepth 3 \\( -name '.env' -o -name '.env.*' -o -name '*.tgz' \\) -print 2>/dev/null");
151
+ forbidden ? fail(`forbidden local secret/package artifact(s): ${forbidden}`) : ok("no .env/.env.* or package tarballs at repo root depth");
152
+ for (const dir of [".artifacts", ".crabbox", ".debug", ".platform-smoke-runs"]) {
153
+ const ignored = shell(`git check-ignore -q ${dir}/probe && echo ignored`);
154
+ ignored ? ok(`${dir}/ is gitignored`) : fail(`${dir}/ must be gitignored`);
155
+ }
156
+ const trackedForbidden = shell("git ls-files '.env*' '*.tgz' '.artifacts/*' '.crabbox/*' '.debug/*' '.platform-smoke-runs/*'");
157
+ trackedForbidden ? fail(`forbidden tracked local state: ${trackedForbidden}`) : ok("no forbidden local state is tracked");
158
+ verifyPackageExclusions();
159
+ }
160
+
161
+ export async function runDoctor(config) {
162
+ failures = 0;
163
+ console.log("\n── Environment ──");
164
+ const cbox = env("PI_ORACLE_SMOKE_CRABBOX") || env("PLATFORM_SMOKE_CRABBOX") || "crabbox";
165
+ ok(`Crabbox binary = ${resolveCommand(cbox) ?? cbox}${env("PI_ORACLE_SMOKE_CRABBOX") || env("PLATFORM_SMOKE_CRABBOX") ? " (env override)" : " (PATH)"}`);
166
+ ok(`PI_ORACLE_SMOKE_MAC_HOST = ${env("PI_ORACLE_SMOKE_MAC_HOST") || env("PLATFORM_SMOKE_MAC_HOST") || "localhost"}`);
167
+ ok(`PI_ORACLE_SMOKE_MAC_USER = ${env("PI_ORACLE_SMOKE_MAC_USER") || env("PLATFORM_SMOKE_MAC_USER") || env("USER")}`);
168
+ ok(`PI_ORACLE_SMOKE_UBUNTU_IMAGE = ${env("PI_ORACLE_SMOKE_UBUNTU_IMAGE") || env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config.ubuntuContainerImage || "pi-oracle-platform-smoke:node24"}`);
169
+ ok(`PI_ORACLE_SMOKE_WINDOWS_VM = ${windowsVmName(config)}`);
170
+ ok(`PI_ORACLE_SMOKE_WINDOWS_SNAPSHOT = ${windowsSnapshotName(config)}`);
171
+
172
+ console.log("\n── Crabbox ──");
173
+ const resolvedCbox = resolveCommand(cbox);
174
+ try { accessSync(resolvedCbox ?? cbox, constants.X_OK); ok(`binary executable: ${resolvedCbox ?? cbox}`); }
175
+ catch { fail(`${resolvedCbox ?? cbox} is not executable`); }
176
+ const version = silent(cbox, ["--version"]);
177
+ const versionLine = version?.split("\n")[0] ?? "";
178
+ version ? ok(`version: ${versionLine}`) : fail("could not read Crabbox version");
179
+ const requiredVersion = config.requiredCrabbox?.minVersion ?? config.requiredCrabbox?.version;
180
+ if (requiredVersion && versionLine) {
181
+ compareVersions(versionLine, requiredVersion) >= 0 ? ok(`required version: ${requiredVersion}+`) : fail(`Crabbox version mismatch: need ${requiredVersion}+, got ${versionLine}`);
182
+ }
183
+ const providers = silent(cbox, ["providers"], { timeout: 30_000 }) ?? "";
184
+ for (const provider of ["ssh", "local-container", "parallels"]) {
185
+ new RegExp(`^${provider}$`, "m").test(providers) ? ok(`provider available: ${provider}`) : fail(`crabbox providers missing ${provider}`);
186
+ }
187
+ runCrabboxDoctor(cbox, "macOS static SSH", targetBaseArgs("macos", config), 120_000);
188
+ runCrabboxDoctor(cbox, "Windows native Parallels", targetBaseArgs("windows-native", config), 180_000);
189
+ runCrabboxDoctor(cbox, "local-container", targetBaseArgs("ubuntu", config), 120_000);
190
+
191
+ console.log("\n── Host tools ──");
192
+ for (const [name, command] of [["Docker", "docker info --format '{{.ServerVersion}}'"], ["Node", "node --version"], ["npm", "npm --version"], ["git", "git --version"], ["tar", "tar --version"], ["rsync", "rsync --version"]]) {
193
+ const output = shell(command);
194
+ output ? ok(`${name}: ${output.split("\n")[0]}`) : fail(`${name} not found or unavailable`);
195
+ }
196
+ const nodeVersion = shell("node --version");
197
+ if (nodeVersion) {
198
+ const major = Number(nodeVersion.replace(/^v/, "").split(".")[0]);
199
+ if (major < (config.nodeValidationMajor ?? 24)) fail(`Node ${nodeVersion}; need ${config.nodeValidationMajor ?? 24}+ for smoke validation`);
200
+ }
201
+ verifyWindowsTemplate(config);
202
+
203
+ console.log("\n── Auth environment ──");
204
+ const requiredAuth = requiredRealSmokeAuthEnv(config);
205
+ if (requiredAuth.length === 0) ok("no real-smoke auth env required by configured suites");
206
+ for (const name of requiredAuth) {
207
+ env(name) ? ok(`${name} = (present, redacted)`) : fail(`${name} missing for required real-extension suite`);
208
+ }
209
+
210
+ console.log("\n── Target tools ──");
211
+ for (const target of config.requiredTargets ?? []) runTargetToolProbe(cbox, target, config);
212
+
213
+ console.log("\n── Artifact root ──");
214
+ const artifactRoot = resolve(process.cwd(), config.artifactRoot ?? ".artifacts/platform-smoke");
215
+ try {
216
+ mkdirSync(artifactRoot, { recursive: true });
217
+ const probe = resolve(artifactRoot, ".doctor-write-test");
218
+ writeFileSync(probe, "ok");
219
+ unlinkSync(probe);
220
+ ok(`writable: ${artifactRoot}`);
221
+ } catch (error) {
222
+ fail(`cannot write artifact root: ${error instanceof Error ? error.message : String(error)}`);
223
+ }
224
+
225
+ console.log("\n── Git/package hygiene ──");
226
+ const branch = shell("git branch --show-current");
227
+ branch ? ok(`branch: ${branch}`) : warn("could not determine branch");
228
+ const status = shell("git status --short");
229
+ status ? warn(`${status.split(/\r?\n/).filter(Boolean).length} uncommitted change(s) under test`) : ok("clean worktree");
230
+ verifyRepoForbiddenFiles();
231
+
232
+ console.log(`\n=== Results: ${failures} failure(s) ===`);
233
+ if (failures > 0) {
234
+ process.exitCode = 1;
235
+ console.log("Fix the failures above before running npm run smoke:platform:all.");
236
+ } else {
237
+ console.log("Ready for npm run smoke:platform:all.");
238
+ }
239
+ }
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ // Cheap invariant tests for the platform-smoke harness.
3
+
4
+ import assert from "node:assert/strict";
5
+ import { spawnSync } from "node:child_process";
6
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import config from "../../platform-smoke.config.mjs";
11
+ import { writeManifest } from "./artifacts.mjs";
12
+ import { buildPlatformBuildCommand, buildRealExtensionCommand } from "./targets.mjs";
13
+
14
+ const repoRoot = fileURLToPath(new URL("../..", import.meta.url));
15
+
16
+ function runNode(args) {
17
+ return spawnSync(process.execPath, args, { cwd: repoRoot, encoding: "utf8" });
18
+ }
19
+
20
+ function testTargetSelection() {
21
+ const result = runNode(["scripts/platform-smoke.mjs", "run", "--target", "not-a-target", "--suite", "platform-build"]);
22
+ assert.notEqual(result.status, 0, "unsupported targets should fail before Crabbox runs");
23
+ assert.ifError(result.error);
24
+ assert.match(`${result.stdout ?? ""}\n${result.stderr ?? ""}`, /unsupported target: not-a-target/);
25
+ }
26
+
27
+ function testPackedInstallCommandRendering() {
28
+ const command = buildPlatformBuildCommand("ubuntu", config.packageName, config.nodeValidationMajor);
29
+ assert.match(command, /verify:oracle:platform/, "platform-build should run the platform-focused verification gate");
30
+ assert.doesNotMatch(command, /npm test/, "platform-build should not run the full local iteration gate on every target");
31
+ assert.match(command, /npm pack --silent/, "platform-build should pack the package");
32
+ assert.match(command, /npm install --no-save/, "platform-build should install the packed tarball");
33
+ assert.match(command, /install -l \.\/node_modules\/pi-oracle/, "platform-build should install through pi's package path");
34
+ assert.doesNotMatch(command, /\bpi\s+(?:-e|--extension)\s+\./, "release proof must not use pi -e/--extension source shortcuts");
35
+ }
36
+
37
+ function testRealExtensionPackedInstallRendering() {
38
+ const command = buildRealExtensionCommand("ubuntu", config);
39
+ assert.match(command, /smoke:real:packed/, "required real-extension suite should run packed real smoke");
40
+ assert.doesNotMatch(command, /smoke:real:source|extensions\/oracle\/index\.ts|\bpi\s+-e\b/, "required real-extension suite must not use source extension loading");
41
+ }
42
+
43
+ function testManifestFailure() {
44
+ const dir = mkdtempSync(join(tmpdir(), "pi-oracle-manifest-test-"));
45
+ try {
46
+ writeFileSync(join(dir, "present.txt"), "ok\n");
47
+ const manifest = writeManifest(dir, ["present.txt", "missing.txt"]);
48
+ assert.deepEqual(manifest.missing, ["missing.txt"], "missing manifest entries should be reported");
49
+ assert(manifest.expected.includes("artifact-manifest.json"), "manifest should require itself as an artifact");
50
+ } finally {
51
+ rmSync(dir, { recursive: true, force: true });
52
+ }
53
+ }
54
+
55
+ function testCleanupFailureInvariant() {
56
+ const source = readFileSync(new URL("./targets.mjs", import.meta.url), "utf8");
57
+ assert.match(source, /recordStopResultOnSuite/, "multi-suite runs should record stop evidence on suite artifacts");
58
+ assert.match(source, /lease-stop/, "cleanup results should be asserted as lease-stop");
59
+ assert.match(source, /stop exit/, "cleanup failure should surface a stop exit error");
60
+ }
61
+
62
+ function testPackageExclusion() {
63
+ const result = spawnSync("npm", ["pack", "--dry-run", "--json"], { cwd: repoRoot, encoding: "utf8", shell: process.platform === "win32" });
64
+ assert.equal(result.status, 0, `npm pack dry-run failed: ${result.stderr}`);
65
+ const files = JSON.parse(result.stdout)[0].files.map((file) => file.path);
66
+ for (const forbidden of [".artifacts/", ".crabbox/", ".debug/", ".platform-smoke-runs/", ".env", "context.md"]) {
67
+ assert(!files.some((file) => file === forbidden || file.startsWith(forbidden)), `package should exclude ${forbidden}`);
68
+ }
69
+ }
70
+
71
+ function testSourceSmokeExplicitlyDebugOnly() {
72
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
73
+ assert.equal(pkg.scripts["smoke:real"], "npm run smoke:real:packed", "default real smoke should be packed-release proof");
74
+ assert.match(pkg.scripts["smoke:real:source"], /--mode source/, "source real smoke should be explicitly named");
75
+ }
76
+
77
+ function testCanonicalWorkflowConfig() {
78
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
79
+ assert.deepEqual(config.workflows?.everyday?.commands, ["npm run verify:oracle"], "everyday workflow should use the local verification gate");
80
+ assert(config.workflows?.platformSensitive?.commands?.includes("npm run smoke:platform:doctor"), "platform-sensitive workflow should start with doctor");
81
+ assert(config.workflows?.platformSensitive?.commands?.some((command) => command.includes("--target <target> --suite <suite>")), "platform-sensitive workflow should document focused target/suite runs");
82
+ assert.deepEqual(config.workflows?.release?.commands, ["npm run smoke:platform:all"], "release workflow should use the full platform matrix");
83
+ assert.equal(pkg.scripts["smoke:platform:all"], "npm run smoke:platform:doctor && node scripts/platform-smoke.mjs run --target macos,ubuntu,windows-native", "full platform smoke should remain doctor-first and cover all required targets");
84
+ assert.match(pkg.scripts["release:check"], /npm run verify:oracle && npm run smoke:platform:all/, "release check should combine local verification and full platform smoke");
85
+ }
86
+
87
+ function testRealSmokeExpensiveAgentPathsAreOptIn() {
88
+ const source = readFileSync(new URL("../oracle-real-smoke.mjs", import.meta.url), "utf8");
89
+ assert.match(source, /runDirectOracleSubmit/, "default real smoke should use deterministic installed-tool execution");
90
+ assert.match(source, /PI_ORACLE_REAL_TEST_MODEL_AGENT/, "real smoke should expose the optional model-agent toggle");
91
+ const targetsSource = readFileSync(new URL("./targets.mjs", import.meta.url), "utf8");
92
+ assert.match(targetsSource, /PI_ORACLE_REAL_TEST_MODEL_AGENT/, "platform real-extension command should forward the optional model-agent toggle");
93
+ assert.match(targetsSource, /realSmokeUsesModelAgent\(\) \? config\.realSmoke\?\.authEnvByProvider/, "platform smoke should allow provider auth env only for the optional model-agent path");
94
+ assert.match(source, /truthy\(env\("PI_ORACLE_REAL_TEST_MODEL_AGENT"\)\)/, "model-agent real smoke path should be opt-in");
95
+ assert.match(source, /PI_ORACLE_REAL_TEST_NEGATIVE_SYMLINK/, "real smoke should expose the optional negative symlink toggle");
96
+ assert.match(source, /truthy\(env\("PI_ORACLE_REAL_TEST_NEGATIVE_SYMLINK"\)\)/, "negative symlink real-agent check should be opt-in");
97
+ }
98
+
99
+ testTargetSelection();
100
+ testPackedInstallCommandRendering();
101
+ testRealExtensionPackedInstallRendering();
102
+ testManifestFailure();
103
+ testCleanupFailureInvariant();
104
+ testPackageExclusion();
105
+ testSourceSmokeExplicitlyDebugOnly();
106
+ testCanonicalWorkflowConfig();
107
+ testRealSmokeExpensiveAgentPathsAreOptIn();
108
+ console.log("platform-smoke invariant checks passed");
@@ -0,0 +1,168 @@
1
+ param(
2
+ [string]$PackageName = "pi-oracle",
3
+ [int]$NodeValidationMajor = 24
4
+ )
5
+
6
+ $ErrorActionPreference = "Continue"
7
+
8
+ function Exit-CodeFromLastCommand {
9
+ if ($null -ne $global:LASTEXITCODE) { return [int]$global:LASTEXITCODE }
10
+ if ($?) { return 0 }
11
+ return 1
12
+ }
13
+
14
+ function Write-SectionFile {
15
+ param([string]$Name, [string]$Path)
16
+ Write-Output "--- $Name START ---"
17
+ if (Test-Path -LiteralPath $Path) {
18
+ Get-Content -LiteralPath $Path -ErrorAction SilentlyContinue
19
+ }
20
+ Write-Output "--- $Name END ---"
21
+ }
22
+
23
+ Write-Output "Starting pi-oracle platform-build in $(Get-Location) at $(Get-Date -Format o)"
24
+ $SourceRoot = (Get-Location).Path
25
+ $RunRoot = Join-Path $SourceRoot (Join-Path ".platform-smoke-runs" ("platform-build-" + (Get-Date -Format "yyyyMMddTHHmmssZ") + "-" + $PID))
26
+ $PackDir = Join-Path $RunRoot "pack"
27
+ $TestWorkspace = Join-Path $RunRoot "test-workspace"
28
+ $PiProject = Join-Path $RunRoot "pi-project"
29
+ New-Item -ItemType Directory -Force -Path $PackDir, $TestWorkspace, $PiProject | Out-Null
30
+ Write-Output "PLATFORM_RUN_ROOT=$RunRoot"
31
+ Write-Output "PLATFORM_TEST_WORKSPACE=$TestWorkspace"
32
+ Write-Output "PLATFORM_PI_PROJECT=$PiProject"
33
+
34
+ $NodeVersion = (& node.exe --version).Trim()
35
+ $NodeMajor = [int](($NodeVersion -replace '^v', '').Split('.')[0])
36
+ if ($NodeMajor -ge $NodeValidationMajor) { $NODE_VERSION_EXIT = 0 } else { $NODE_VERSION_EXIT = 1 }
37
+ Write-Output "PLATFORM_NODE_VERSION=$NodeVersion"
38
+ Write-Output "PLATFORM_NODE_VERSION_EXIT=$NODE_VERSION_EXIT"
39
+
40
+ Write-Output "=== npm ci ==="
41
+ & npm.cmd ci *>&1
42
+ $CI_EXIT = Exit-CodeFromLastCommand
43
+ Write-Output "PLATFORM_NPM_CI_EXIT=$CI_EXIT"
44
+
45
+ Write-Output "=== platform dependencies ==="
46
+ $DEPS_EXIT = 0
47
+ $ZstdCommand = Get-Command zstd -ErrorAction SilentlyContinue
48
+ if ($ZstdCommand) { $ZSTD_INSTALL_EXIT = 0 } else { Write-Output "zstd missing on Windows target PATH; update pi-extension-windows-template/crabbox-ready"; $ZSTD_INSTALL_EXIT = 1 }
49
+ if ($ZSTD_INSTALL_EXIT -ne 0) { $DEPS_EXIT = 1 }
50
+ $AgentBrowserCommand = Get-Command agent-browser -ErrorAction SilentlyContinue
51
+ if (-not $AgentBrowserCommand) { $AgentBrowserCommand = Get-Command agent-browser.cmd -ErrorAction SilentlyContinue }
52
+ if ($AgentBrowserCommand) { $AGENT_BROWSER_INSTALL_EXIT = 0; $AgentBrowserPath = $AgentBrowserCommand.Source } else { Write-Output "agent-browser missing on Windows target PATH; update pi-extension-windows-template/crabbox-ready"; $AGENT_BROWSER_INSTALL_EXIT = 1; $AgentBrowserPath = "" }
53
+ if ($AGENT_BROWSER_INSTALL_EXIT -ne 0) { $DEPS_EXIT = 1 }
54
+ Write-Output "PLATFORM_ZSTD_INSTALL_EXIT=$ZSTD_INSTALL_EXIT"
55
+ Write-Output "PLATFORM_AGENT_BROWSER_INSTALL_EXIT=$AGENT_BROWSER_INSTALL_EXIT"
56
+ Write-Output "PLATFORM_DEPS_EXIT=$DEPS_EXIT"
57
+ if ($ZstdCommand) { Write-Output $ZstdCommand.Source }
58
+ if ($AgentBrowserPath) { Write-Output $AgentBrowserPath }
59
+
60
+ Write-Output "=== platform verification ==="
61
+ $PreviousAgentBrowserPath = $env:AGENT_BROWSER_PATH
62
+ $PreviousSanityProgress = $env:PI_ORACLE_SANITY_PROGRESS
63
+ $env:AGENT_BROWSER_PATH = $AgentBrowserPath
64
+ $env:PI_ORACLE_SANITY_PROGRESS = "1"
65
+ & npm.cmd run verify:oracle:platform *>&1
66
+ $TEST_EXIT = Exit-CodeFromLastCommand
67
+ if ($null -eq $PreviousAgentBrowserPath) { Remove-Item Env:\AGENT_BROWSER_PATH -ErrorAction SilentlyContinue } else { $env:AGENT_BROWSER_PATH = $PreviousAgentBrowserPath }
68
+ if ($null -eq $PreviousSanityProgress) { Remove-Item Env:\PI_ORACLE_SANITY_PROGRESS -ErrorAction SilentlyContinue } else { $env:PI_ORACLE_SANITY_PROGRESS = $PreviousSanityProgress }
69
+ Write-Output "PLATFORM_NPM_TEST_EXIT=$TEST_EXIT"
70
+
71
+ Write-Output "=== npm pack ==="
72
+ $NpmPackErr = Join-Path $PackDir "npm-pack.stderr.txt"
73
+ $PackOutput = @(& npm.cmd pack --silent 2> $NpmPackErr)
74
+ $PACK_EXIT = Exit-CodeFromLastCommand
75
+ Get-Content -LiteralPath $NpmPackErr -ErrorAction SilentlyContinue
76
+ $PackTarball = ($PackOutput | Select-Object -First 1)
77
+ if ($PackTarball) { $PackTarball = $PackTarball.Trim() }
78
+ Write-Output "PLATFORM_NPM_PACK_EXIT=$PACK_EXIT"
79
+ if ($PackTarball -and (Test-Path -LiteralPath $PackTarball)) {
80
+ Move-Item -LiteralPath $PackTarball -Destination (Join-Path $PackDir $PackTarball) -Force
81
+ }
82
+ Write-Output "PLATFORM_PACKED_TARBALL=$PackTarball"
83
+ Set-Content -Path (Join-Path $PackDir "packed-tarball.txt") -Value $PackTarball
84
+
85
+ Write-Output "=== fixture workspace ==="
86
+ Copy-Item -LiteralPath package.json, README.md -Destination $TestWorkspace -ErrorAction SilentlyContinue
87
+ Copy-Item -LiteralPath extensions, prompts, docs -Destination $TestWorkspace -Recurse -ErrorAction SilentlyContinue
88
+ if ((Test-Path -LiteralPath (Join-Path $TestWorkspace "package.json")) -and (Test-Path -LiteralPath (Join-Path $TestWorkspace "README.md")) -and (Test-Path -LiteralPath (Join-Path $TestWorkspace "extensions")) -and (Test-Path -LiteralPath (Join-Path $TestWorkspace "prompts")) -and (Test-Path -LiteralPath (Join-Path $TestWorkspace "docs"))) {
89
+ $FIXTURE_EXIT = 0
90
+ } else {
91
+ $FIXTURE_EXIT = 1
92
+ }
93
+ Write-Output "PLATFORM_FIXTURE_EXIT=$FIXTURE_EXIT"
94
+
95
+ $PiCli = Join-Path (Get-Location) "node_modules\.bin\pi.cmd"
96
+ if (-not (Test-Path -LiteralPath $PiCli)) { $PiCli = Join-Path (Get-Location) "node_modules\.bin\pi" }
97
+ if (-not (Test-Path -LiteralPath $PiCli)) {
98
+ $Command = Get-Command pi -ErrorAction SilentlyContinue
99
+ $PiCli = $Command.Source
100
+ }
101
+ Write-Output "PLATFORM_PI_CLI=$PiCli"
102
+
103
+ $PackedNodeInstallOut = Join-Path $PackDir "packed-node-install.stdout.txt"
104
+ $PackedNodeInstallErr = Join-Path $PackDir "packed-node-install.stderr.txt"
105
+ $PiInstallOut = Join-Path $PackDir "pi-install.stdout.txt"
106
+ $PiInstallErr = Join-Path $PackDir "pi-install.stderr.txt"
107
+ $PiListOut = Join-Path $PackDir "pi-list.stdout.txt"
108
+ $PiListErr = Join-Path $PackDir "pi-list.stderr.txt"
109
+
110
+ Write-Output "=== pi install packed package ==="
111
+ $TarballPath = Join-Path $PackDir $PackTarball
112
+ if ($PackTarball -and $PiCli -and (Test-Path -LiteralPath $TarballPath)) {
113
+ Push-Location $PiProject
114
+ & npm.cmd init -y 1> $PackedNodeInstallOut 2> $PackedNodeInstallErr
115
+ $NPM_INIT_EXIT = Exit-CodeFromLastCommand
116
+ if ($NPM_INIT_EXIT -eq 0) {
117
+ & npm.cmd install --no-save $TarballPath 1>> $PackedNodeInstallOut 2>> $PackedNodeInstallErr
118
+ $PACKED_NODE_INSTALL_EXIT = Exit-CodeFromLastCommand
119
+ } else {
120
+ $PACKED_NODE_INSTALL_EXIT = $NPM_INIT_EXIT
121
+ }
122
+ if ($PACKED_NODE_INSTALL_EXIT -eq 0) {
123
+ $PreviousPiOffline = $env:PI_OFFLINE
124
+ $env:PI_OFFLINE = "1"
125
+ & $PiCli install -l (Join-Path ".\node_modules" $PackageName) 1> $PiInstallOut 2> $PiInstallErr
126
+ $PI_INSTALL_EXIT = Exit-CodeFromLastCommand
127
+ if ($null -eq $PreviousPiOffline) { Remove-Item Env:\PI_OFFLINE -ErrorAction SilentlyContinue } else { $env:PI_OFFLINE = $PreviousPiOffline }
128
+ } else {
129
+ Set-Content -LiteralPath $PiInstallErr -Value "packed npm install failed"
130
+ $PI_INSTALL_EXIT = 1
131
+ }
132
+ Pop-Location
133
+ } else {
134
+ Set-Content -LiteralPath $PackedNodeInstallErr -Value "missing pi cli or tarball"
135
+ Set-Content -LiteralPath $PiInstallErr -Value "missing pi cli or tarball"
136
+ $PACKED_NODE_INSTALL_EXIT = 1
137
+ $PI_INSTALL_EXIT = 1
138
+ }
139
+ Write-Output "PLATFORM_PACKED_NODE_INSTALL_EXIT=$PACKED_NODE_INSTALL_EXIT"
140
+ Write-SectionFile "PACKED_NODE_INSTALL_STDOUT" $PackedNodeInstallOut
141
+ Write-SectionFile "PACKED_NODE_INSTALL_STDERR" $PackedNodeInstallErr
142
+ Write-Output "PLATFORM_PI_INSTALL_EXIT=$PI_INSTALL_EXIT"
143
+ Write-SectionFile "PI_INSTALL_STDOUT" $PiInstallOut
144
+ Write-SectionFile "PI_INSTALL_STDERR" $PiInstallErr
145
+
146
+ Write-Output "=== pi list ==="
147
+ if ($PiCli) {
148
+ Push-Location $PiProject
149
+ $PreviousPiOffline = $env:PI_OFFLINE
150
+ $env:PI_OFFLINE = "1"
151
+ & $PiCli list 1> $PiListOut 2> $PiListErr
152
+ $PI_LIST_EXIT = Exit-CodeFromLastCommand
153
+ if ($null -eq $PreviousPiOffline) { Remove-Item Env:\PI_OFFLINE -ErrorAction SilentlyContinue } else { $env:PI_OFFLINE = $PreviousPiOffline }
154
+ Pop-Location
155
+ } else {
156
+ Set-Content -LiteralPath $PiListErr -Value "missing pi cli"
157
+ $PI_LIST_EXIT = 1
158
+ }
159
+ Write-Output "PLATFORM_PI_LIST_EXIT=$PI_LIST_EXIT"
160
+ Write-SectionFile "PI_LIST_STDOUT" $PiListOut
161
+ Write-SectionFile "PI_LIST_STDERR" $PiListErr
162
+
163
+ Write-Output "node=$NODE_VERSION_EXIT ci=$CI_EXIT deps=$DEPS_EXIT test=$TEST_EXIT pack=$PACK_EXIT fixture=$FIXTURE_EXIT packedNodeInstall=$PACKED_NODE_INSTALL_EXIT install=$PI_INSTALL_EXIT list=$PI_LIST_EXIT"
164
+ if ($NODE_VERSION_EXIT -ne 0 -or $CI_EXIT -ne 0 -or $DEPS_EXIT -ne 0 -or $TEST_EXIT -ne 0 -or $PACK_EXIT -ne 0 -or $FIXTURE_EXIT -ne 0 -or $PACKED_NODE_INSTALL_EXIT -ne 0 -or $PI_INSTALL_EXIT -ne 0 -or $PI_LIST_EXIT -ne 0) {
165
+ Write-Output "PLATFORM_BUILD_FAILED: node=$NODE_VERSION_EXIT ci=$CI_EXIT deps=$DEPS_EXIT test=$TEST_EXIT pack=$PACK_EXIT fixture=$FIXTURE_EXIT packedNodeInstall=$PACKED_NODE_INSTALL_EXIT install=$PI_INSTALL_EXIT list=$PI_LIST_EXIT"
166
+ exit 1
167
+ }
168
+ Write-Output "PLATFORM_BUILD_OK"