pi-oracle 0.7.22 → 0.7.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.7.24 - 2026-11-02
6
+
7
+ <!-- npm-release:unreleased:start -->
8
+ <!-- npm-release:unreleased:end -->
9
+
10
+ - Raise the Pi floor to 1.5.0 (#74) ([6759f3e](https://github.com/fitchmultz/pi-oracle/commit/6759f3ea0af7c3975842fa4c73ff3797d95ca1f5))
11
+
12
+ ### What's Changed
13
+ * Raise the Pi floor to 1.5.0 by @fitchmultz in https://github.com/fitchmultz/pi-oracle/pull/74
14
+
15
+
16
+ **Full Changelog**: https://github.com/fitchmultz/pi-oracle/compare/v0.7.23...v0.7.24
17
+
18
+ ## 0.7.23 - 2026-11-02
19
+
20
+ <!-- npm-release:unreleased:start -->
21
+ ### Changed
22
+ - update the `basic-ftp` dependency override to the patched stable release
23
+ - publish versioned npm candidates automatically after reviewed changes merge to `main` and pass Pi compatibility checks
24
+ - support target-specific platform preflight for distributed release checks
25
+ - qualify retained Oracle release candidates across hosted macOS, Ubuntu, and native Windows Crabbox targets before publication
26
+
27
+ ### Fixed
28
+ - recognize ChatGPT's current model selector, send button, and completed response state
29
+ <!-- npm-release:unreleased:end -->
30
+
31
+ - Automate qualified Oracle releases (3/3) (#72) ([596d8b7](https://github.com/fitchmultz/pi-oracle/commit/596d8b7c2c0591d80bdb1e8c260bc04b3cf16310))
32
+
33
+ ### What's Changed
34
+ * ci: publish npm releases automatically on merge by @fitchmultz in https://github.com/fitchmultz/pi-oracle/pull/64
35
+ * chore(deps): update dependency basic-ftp to v6.2.3 by @renovate[bot] in https://github.com/fitchmultz/pi-oracle/pull/55
36
+ * Restore Oracle ChatGPT flow and prepare release (1/2) by @fitchmultz in https://github.com/fitchmultz/pi-oracle/pull/68
37
+ * Qualify retained candidates on native SSH targets (2/2) by @fitchmultz in https://github.com/fitchmultz/pi-oracle/pull/69
38
+ * Support target-specific platform preflight (1/3) by @fitchmultz in https://github.com/fitchmultz/pi-oracle/pull/70
39
+ * Qualify retained Oracle packages on hosted platforms (2/3) by @fitchmultz in https://github.com/fitchmultz/pi-oracle/pull/71
40
+ * Automate qualified Oracle releases (3/3) by @fitchmultz in https://github.com/fitchmultz/pi-oracle/pull/72
41
+
42
+
43
+ **Full Changelog**: https://github.com/fitchmultz/pi-oracle/compare/v0.7.22...v0.7.23
44
+
3
45
  ## 0.7.22 - 2026-10-12
4
46
 
5
47
  ### Documentation
package/README.md CHANGED
@@ -6,9 +6,9 @@ pi-oracle sends project files and questions from [Pi](https://github.com/earendi
6
6
 
7
7
  ## Install
8
8
 
9
- Use Node.js 22.19.0 or newer on macOS, Linux, or Windows native. Use a current Pi release and a Chromium-family browser.
9
+ Use a supported Node.js release on macOS, Linux, or Windows native. Use a current Pi release and a Chromium-family browser.
10
10
 
11
- Install `agent-browser` and `tar`. Install `zstd` for ChatGPT jobs. Check the [platform requirements](docs/reference.md#requirements) for profile copies and encrypted cookies.
11
+ Install `agent-browser` and `tar`. Install `zstd` for ChatGPT jobs. Check the [platform requirements](docs/reference.md#requirements) for the Node.js minimum, profile copies, and encrypted cookies.
12
12
 
13
13
  Install Oracle:
14
14
 
@@ -7,7 +7,7 @@ Companion doc:
7
7
  - `docs/ORACLE_RECOVERY_DRILL.md` — safe expired-auth recovery validation drill
8
8
 
9
9
  Compatibility target:
10
- - `pi` 0.80.9+ is the suggested tested floor for current project-trust-aware package/runtime validation
10
+ - `pi` 1.5.0+ is the suggested tested floor for current project-trust-aware package/runtime validation
11
11
  - package metadata keeps pi runtime packages as optional wildcard peers, so this suggested floor is not enforced as a hard npm install requirement
12
12
  - current extension lifecycle only; no backward-compatibility shims for removed `session_switch` / `session_fork` events
13
13
 
@@ -665,6 +665,6 @@ Recent proof points:
665
665
  - expired-auth drill post-repair success: `fa26a2a7-0057-4a21-b3e0-71c1d020facf`
666
666
  - successful multi-artifact completion: `b6b3599c-6b91-4315-adfa-8a83aa5eda9b`
667
667
  - repo-owned sanity harness: `npm run sanity:oracle`
668
- - real installed-extension smoke source of truth: `scripts/oracle-real-smoke.mjs`; required release proof runs packed-install mode (`npm run smoke:real:packed`), asserts Pi 0.80.9, and executes `/oracle-status` through Pi's installed-package loader without provider credentials or an external oracle job; optional slower model-agent submission debugging remains behind `PI_ORACLE_REAL_TEST_MODEL_AGENT=1`; source mode (`npm run smoke:real:source`) is inner-loop/debug only
668
+ - real installed-extension smoke source of truth: `scripts/oracle-real-smoke.mjs`; required release proof runs packed-install mode (`npm run smoke:real:packed`), asserts the pinned development Pi version, and executes `/oracle-status` through Pi's installed-package loader without provider credentials or an external oracle job; optional slower model-agent submission debugging remains behind `PI_ORACLE_REAL_TEST_MODEL_AGENT=1`; source mode (`npm run smoke:real:source`) is inner-loop/debug only
669
669
  - macOS, Ubuntu, and Windows native package/build/runtime smoke source of truth: `docs/platform-smoke.md`; use `npm run verify:oracle` for everyday local iteration, `npm run smoke:platform:doctor` plus a focused target/suite run for platform-sensitive changes, `npm run smoke:platform:all` for doctor-first platform matrix evidence, and `npm run release:check` for the full local-plus-platform release gate
670
670
  - release gate: `npm run release:check`, also used by `prepublishOnly`, combines static verification, fresh loaded-extension ChatGPT preset proof via `npm run release:proof:chatgpt-presets`, and all required Crabbox platform smokes
@@ -4,7 +4,7 @@ The [README](../README.md) is the user entry point. Architecture and lifecycle d
4
4
 
5
5
  ## Host qualification
6
6
 
7
- Required offline qualification targets are the latest stable official Pi and latest maintained fork `main`, resolving version/commit once per workflow run and retaining exact SDK/CLI evidence. Locked development dependencies are reproducible snapshots, not validation targets; the platform-smoke harness covers macOS, Linux, and Windows native with Chromium-family browsers. Pi `0.80.9+` is the suggested tested floor for project-trust-aware package/runtime validation, but pi-bundled runtime packages remain optional wildcard peers so npm peer ranges do not block users from trying newer pi releases.
7
+ Required offline qualification targets are the latest stable official Pi and latest maintained fork `main`, resolving version/commit once per workflow run and retaining exact SDK/CLI evidence. Locked development dependencies are reproducible snapshots, not validation targets; the platform-smoke harness covers macOS, Linux, and Windows native with Chromium-family browsers. Pi `1.5.0+` is the suggested tested floor for project-trust-aware package/runtime validation, but pi-bundled runtime packages remain optional wildcard peers so npm peer ranges do not block users from trying newer pi releases.
8
8
 
9
9
  Package install/use requires Node.js 22.19.0 or newer; platform smoke/release validation expects Node 24+ per `platform-smoke.config.mjs`.
10
10
 
@@ -12,11 +12,11 @@ Production hardening should keep focusing on UI drift detection, auth recovery,
12
12
 
13
13
  ## Automatic npm releases (maintainers)
14
14
 
15
- Follow the [shared release procedure](https://github.com/fitchmultz/.github#automatic-npm-releases): merge a reviewed PR into `main` with an intentional `package.json` version bump and a matching versioned `CHANGELOG.md` section. Automation never bumps versions, overwrites releases, or republishes an existing version. Once configured and enabled, it runs existing offline compatibility checks, qualifies one candidate tarball, then waits for `fitchmultz` approval in the `npm` environment.
15
+ The [shared release procedure](https://github.com/fitchmultz/.github#automatic-npm-releases) creates a reviewed patch-version PR after changes merge to `main`, then qualifies one retained candidate tarball across compatibility checks and hosted macOS, Ubuntu, and native Windows Crabbox suites before publishing the same version on npm and GitHub.
16
16
 
17
- Approve only after checking the exact source commit, version, downloaded candidate tarball and summary against [the existing `release:check` requirements](#verification) and [packed platform proof](platform-smoke.md). Fresh all-preset ChatGPT proof must match that commit/version, satisfy the proof checker's timing and saved-response requirements, and accompany the required doctor-first macOS/Ubuntu/native-Windows evidence. Old-commit proof is not sufficient. Approval attests genuine satisfaction of these gates; it does not create proof or waive `release:check`. Authenticated and paid checks remain outside CI. Automated publication disables npm hooks to publish the checked bytes; manual `npm publish` still runs its existing `prepublishOnly` gate.
17
+ Before releasing runtime or prompt changes, verify [the `release:check` requirements](#verification) and [packed platform proof](platform-smoke.md). Fresh all-preset ChatGPT proof must match `HEAD` or an ancestor commit whose `extensions/` and `prompts/` trees are byte-identical to `HEAD`, satisfy the proof checker's 72-hour timing and saved-response requirements, and accompany the required doctor-first macOS/Ubuntu/native-Windows evidence. Authenticated and paid checks remain outside CI. Automated publication disables npm hooks to publish the checked bytes; manual `npm publish` still runs its existing `prepublishOnly` gate.
18
18
 
19
- Failed/unpublished candidates can retry daily at 12:17 UTC or via manual dispatch of `npm release` on `main`, without another bump. Set repository variable `NPM_RELEASE_ENABLED` to anything other than `true` to stop new release plans; cancel pending runs separately when needed. Workflow validation is not evidence of a completed real OIDC publication.
19
+ Set repository variable `NPM_RELEASE_ENABLED` to anything other than `true` to stop new version and release plans; cancel pending runs separately when needed. Follow the shared procedure to retry an unpublished candidate. Workflow validation is not evidence of a completed real OIDC publication.
20
20
 
21
21
  ## Verification
22
22
 
@@ -37,8 +37,6 @@ npm run verify:oracle
37
37
 
38
38
  `npm test` runs `npm run verify:oracle`, so it is not a separate gate from the final line above.
39
39
 
40
- Known development dependency warning: the official Pi 1.0.0 npm package's `npm-shrinkwrap.json` still pins `brace-expansion` `5.0.9`, which has brace-expansion denial-of-service advisories. npm 11 preserves that vendor-pinned subtree even with a root override; dropping the shrinkwrap to obtain a clean audit is not a fix. The warning predates the Pi 1.0 baseline upgrade and needs an upstream host dependency fix. Dependencies embedded in a prebuilt Pi CLI bundle likewise require a fixed host distribution.
41
-
42
40
  `npm publish` is guarded by `prepublishOnly`, which runs `npm run release:check`. That release gate now blocks unless fresh live ChatGPT preset proof exists for every canonical preset, then requires doctor-first macOS, Ubuntu, and Windows native Crabbox evidence. The required Crabbox runtime suite uses packed-install proof, not source-tree `pi -e` loading.
43
41
 
44
42
  Use the narrowest validation workflow that proves the change:
@@ -66,7 +64,7 @@ node scripts/oracle-chatgpt-preset-proof.mjs template > .artifacts/chatgpt-prese
66
64
  npm run release:proof:chatgpt-presets
67
65
  ```
68
66
 
69
- The proof checker is intentionally part of `release:check`; it fails if the proof is missing, stale, tied to a different package version/git head, references jobs that completed before the current commit, or lacks actual persisted ChatGPT `.tar.zst` job state and response text for any canonical preset.
67
+ The proof checker is intentionally part of `release:check`; it fails if the proof is missing, stale, tied to a commit whose `extensions/` or `prompts/` differ from `HEAD`, references jobs that completed before the proven commit, or lacks actual persisted ChatGPT `.tar.zst` job state and response text for any canonical preset.
70
68
 
71
69
  The real runtime suite defaults to deterministic installed-tool execution so platform proof stays bounded. Provider/model defaults remain `zai/glm-5.2` for doctor/config and for optional model-agent debugging; override with `PI_ORACLE_REAL_TEST_PROVIDER` and `PI_ORACLE_REAL_TEST_MODEL` when needed. For inner-loop source loading only, use `npm run smoke:real:source`; it is not release proof. Set `PI_ORACLE_REAL_TEST_MODEL_AGENT=1` only when debugging the slower model-agent path. The optional second real-agent negative symlink check is opt-in via `PI_ORACLE_REAL_TEST_NEGATIVE_SYMLINK=1`; `npm run sanity:oracle` covers archive/symlink rejection by default without adding another model-agent turn to the platform release gate.
72
70
 
@@ -29,9 +29,10 @@ crabbox providers
29
29
 
30
30
  Target setup:
31
31
 
32
- - macOS: Remote Login enabled; noninteractive `ssh $USER@localhost` works; `node`, `npm`, `git`, `tar`, `rsync`, `zstd`, and `agent-browser` are on the SSH PATH.
32
+ - macOS: Remote Login enabled; noninteractive `ssh $USER@localhost` works; `node`, `npm`, `git`, `tar`, `rsync`, `zstd`, and `agent-browser` are on the SSH PATH. Use `PI_ORACLE_SMOKE_MAC_PORT` or `PLATFORM_SMOKE_MAC_PORT` for a non-default SSH port.
33
33
  - Ubuntu: Docker is running and the configured image (`PI_ORACLE_SMOKE_UBUNTU_IMAGE`, default `pi-oracle-platform-smoke:node24`) has `node`, `npm`, `git`, `tar`, `rsync`, `zstd`, and `agent-browser` on PATH. Build the local image when needed with `docker build -t pi-oracle-platform-smoke:node24 -f scripts/platform-smoke/Dockerfile.ubuntu .`.
34
- - Windows native: Parallels has stopped source VM `pi-extension-windows-template` and the configured power-off snapshot (`crabbox-ready` by default for this repo). The template must have OpenSSH, PowerShell, Git, Node/npm, `tar`, `zstd`, and `agent-browser` on PATH. Do not bake API keys, browser sessions, project checkouts, `.pi` state, artifacts, or secrets into the template.
34
+ - Windows native: by default, Parallels has stopped source VM `pi-extension-windows-template` and the configured power-off snapshot (`crabbox-ready` by default for this repo). The template must have OpenSSH, PowerShell, Git, Node/npm, `tar`, `zstd`, and `agent-browser` on PATH. Do not bake API keys, browser sessions, project checkouts, `.pi` state, artifacts, or secrets into the template.
35
+ - Alternatively, set `PI_ORACLE_SMOKE_WINDOWS_HOST` to a native Windows SSH host, and set `PI_ORACLE_SMOKE_WINDOWS_USER`, `PI_ORACLE_SMOKE_WINDOWS_PORT` (default 22), and `PI_ORACLE_SMOKE_WINDOWS_NATIVE_WORK_ROOT` for that target as needed. Crabbox uses its SSH provider instead of Parallels; the target needs the same tools on its SSH PATH. The `PLATFORM_SMOKE_WINDOWS_*` aliases also work.
35
36
 
36
37
  Real runtime suite auth:
37
38
 
@@ -47,7 +48,7 @@ Use the narrowest workflow that proves the change. Do not run the full platform
47
48
  | Situation | Canonical command(s) | What it proves |
48
49
  | --- | --- | --- |
49
50
  | Everyday local iteration | `npm run verify:oracle` | Syntax, bundle, platform-smoke invariants, type checks, oracle sanity, and package dry-run pass locally. |
50
- | Platform-sensitive change | `npm run smoke:platform:doctor`, then `node scripts/platform-smoke.mjs run --target <target> --suite <suite>` | Target setup is ready and the affected platform/suite works without paying for unrelated targets. |
51
+ | Platform-sensitive change | `node scripts/platform-smoke.mjs doctor --target <target>`, then `node scripts/platform-smoke.mjs run --target <target> --suite <suite>` | Target setup is ready and the affected platform/suite works without paying for unrelated targets. |
51
52
  | Platform matrix proof | `npm run smoke:platform:all` | Doctor-first packed-install proof passes on every required target and suite. |
52
53
  | ChatGPT preset release proof | `npm run release:proof:chatgpt-presets` | Fresh loaded-extension proof exists for every canonical ChatGPT preset. |
53
54
  | Publish/release gate | `npm run release:check` | Local verification (`verify:oracle`) passes, fresh ChatGPT preset proof exists, then the doctor-first platform matrix passes. |
@@ -65,13 +66,19 @@ npm run smoke:platform:all
65
66
  Focused commands:
66
67
 
67
68
  ```bash
68
- npm run smoke:platform:doctor
69
+ node scripts/platform-smoke.mjs doctor --target macos
69
70
  npm run smoke:platform:macos
71
+ node scripts/platform-smoke.mjs doctor --target ubuntu
70
72
  npm run smoke:platform:ubuntu
73
+ node scripts/platform-smoke.mjs doctor --target windows-native
71
74
  npm run smoke:platform:windows-native
72
75
  node scripts/platform-smoke.mjs run --target windows-native --suite real-extension
73
76
  ```
74
77
 
78
+ `doctor --target` checks the selected targets' providers and tools; without it,
79
+ doctor still checks every required target. In split hosted jobs, run a scoped
80
+ doctor on each target before its suites.
81
+
75
82
  Full release gate:
76
83
 
77
84
  ```bash
@@ -80,6 +87,10 @@ npm run release:check
80
87
 
81
88
  `release:check` runs `verify:oracle`, then `release:proof:chatgpt-presets`, then `smoke:platform:all`, matching the release order: cheap harness checks, fresh live ChatGPT preset proof, doctor, full matrix, then artifact review. `prepublishOnly` runs `npm run release:check`.
82
89
 
90
+ For an already prepared release candidate, set `PI_ORACLE_RELEASE_TARBALL` to its local tarball before running the matrix. Set `PI_ORACLE_RELEASE_SHA256` to the prepared candidate's SHA-256 when one is available; the runner checks it before transfer. Each suite still checks that the source packs, but installs the retained candidate. Crabbox transfers a temporary copy and each target verifies its SHA-256 before use; the suite evidence records the verified hash.
91
+
92
+ The reusable [hosted platform qualification](../.github/workflows/release-platform.yml) runs each target on its native GitHub-hosted OS. It verifies the shared candidate receipt and source before and after both suites, and retains the per-target evidence. This is the platform portion of release qualification; the live ChatGPT preset proof is a separate required gate.
93
+
83
94
  ## What `platform-build` proves
84
95
 
85
96
  On each required target, `platform-build`:
package/docs/reference.md CHANGED
@@ -18,7 +18,7 @@ You need:
18
18
 
19
19
  - macOS, Linux, or Windows native
20
20
  - Node.js 22.19.0 or newer for package install/use; platform smoke/release validation currently expects Node 24+ per `platform-smoke.config.mjs`
21
- - Suggested tested floor: `pi` 0.80.9 or newer; older pi versions are not blocked by package metadata but are outside the current validation baseline
21
+ - Suggested tested floor: `pi` 1.5.0 or newer; older pi versions are not blocked by package metadata but are outside the current validation baseline
22
22
  - Google Chrome/Chromium or another Chromium-family browser
23
23
  - ChatGPT or Grok already signed in to the configured local browser profile for the provider you plan to use
24
24
  - `agent-browser` and `tar` available on the machine; `zstd` is also required when submitting ChatGPT `.tar.zst` archives
@@ -26,6 +26,13 @@ You need:
26
26
  - a normal persisted `pi` session, not `pi --no-session`
27
27
  - on Linux, encrypted Chromium cookies may also require `secret-tool` (GNOME/libsecret) or `kwallet-query` + `dbus-send` (KDE), unless a Chrome/Brave safe-storage password override is set for the auth run
28
28
 
29
+ `agent-browser` has its own Node.js requirements, which may be newer than Oracle's package minimum.
30
+ If npm blocks `agent-browser`'s install script, allow it when installing the global package:
31
+
32
+ ```bash
33
+ npm install -g --allow-scripts=agent-browser agent-browser
34
+ ```
35
+
29
36
  ## What a successful run looks like
30
37
 
31
38
  ```text
@@ -16,6 +16,7 @@ export declare function matchesModelFamilyLabel(label: string | undefined, famil
16
16
  export declare function matchesRequestedModelControlLabel(label: string | undefined, selection: OracleUiSelection): boolean;
17
17
  export declare function matchesCompactIntelligenceControlLabel(label: string | undefined): boolean;
18
18
  export declare function matchesCompactIntelligenceOpenerLabel(label: string | undefined): boolean;
19
+ export declare function matchesModelConfigurationOpener(entry: import("./artifact-heuristics.mjs").SnapshotEntry): boolean;
19
20
  export declare function requestedEffortLabel(selection: OracleUiSelection): string | undefined;
20
21
  export declare function effortSelectionVisible(snapshot: string, effortLabel: string | undefined): boolean;
21
22
  export declare function thinkingChipVisible(snapshot: string): boolean;
@@ -522,24 +522,29 @@ export function snapshotHasUsableComposerControls(snapshot) {
522
522
  return hasComposer && hasAddFiles;
523
523
  }
524
524
 
525
+ /**
526
+ * @param {SnapshotEntry} entry
527
+ * @returns {boolean}
528
+ */
529
+ export function matchesModelConfigurationOpener(entry) {
530
+ if (entry.disabled || entry.kind !== "button" || typeof entry.label !== "string") return false;
531
+ const label = normalizeChipLabel(entry.label);
532
+ return label === "Model"
533
+ || label === "Model selector"
534
+ || label === "Select ChatGPT model"
535
+ || matchesCompactIntelligenceOpenerLabel(label)
536
+ || BARE_EFFORT_PATTERN.test(label)
537
+ || ["instant", "thinking", "pro"].some((family) => matchesModelFamilyLabel(label, /** @type {OracleUiModelFamily} */ (family)))
538
+ || THINKING_CHIP_PATTERN.test(label)
539
+ || PRO_CHIP_PATTERN.test(label);
540
+ }
541
+
525
542
  /**
526
543
  * @param {string} snapshot
527
544
  * @returns {boolean}
528
545
  */
529
546
  export function snapshotHasModelOpener(snapshot) {
530
- /** @type {SnapshotEntry[]} */
531
- const entries = parseSnapshotEntries(snapshot);
532
- return entries.some((entry) => {
533
- if (entry.disabled || entry.kind !== "button" || typeof entry.label !== "string") return false;
534
- const label = normalizeChipLabel(entry.label);
535
- return label === "Model"
536
- || label === "Model selector"
537
- || COMPACT_INTELLIGENCE_OPENER_PATTERN.test(label)
538
- || EFFORT_LABELS.has(label)
539
- || ["instant", "thinking", "pro"].some((family) => matchesModelFamilyLabel(label, /** @type {OracleUiModelFamily} */ (family)))
540
- || THINKING_CHIP_PATTERN.test(label)
541
- || PRO_CHIP_PATTERN.test(label);
542
- });
547
+ return parseSnapshotEntries(snapshot).some(matchesModelConfigurationOpener);
543
548
  }
544
549
 
545
550
  /**
@@ -26,7 +26,7 @@ import {
26
26
  CHATGPT_COMPOSER_LABELS,
27
27
  deriveAssistantCompletionSignature,
28
28
  matchesCompactIntelligenceControlLabel,
29
- matchesCompactIntelligenceOpenerLabel,
29
+ matchesModelConfigurationOpener,
30
30
  matchesModelFamilyLabel,
31
31
  matchesRequestedModelControlLabel,
32
32
  requestedEffortLabel,
@@ -58,7 +58,7 @@ const jobPath = `${jobDir}/job.json`;
58
58
  const CHATGPT_LABELS = {
59
59
  composer: "Chat with ChatGPT",
60
60
  addFiles: "Add files and more",
61
- send: "Send prompt",
61
+ send: ["Send prompt", "Send"],
62
62
  close: "Close",
63
63
  autoSwitchToThinking: "Auto-switch to Thinking",
64
64
  configure: "Configure...",
@@ -66,7 +66,7 @@ const CHATGPT_LABELS = {
66
66
  const GROK_LABELS = {
67
67
  composer: "Ask Grok anything",
68
68
  addFiles: "Attach",
69
- send: "Submit",
69
+ send: ["Submit"],
70
70
  modelSelect: "Model select",
71
71
  stop: "Stop model response",
72
72
  };
@@ -916,18 +916,6 @@ function matchesRequestedModelControl(candidate, selection, options = {}) {
916
916
  return matchesRequestedModelControlLabel(candidate.label, selection);
917
917
  }
918
918
 
919
- function matchesModelConfigurationOpener(candidate) {
920
- if (candidate.kind !== "button" || typeof candidate.label !== "string" || candidate.disabled) return false;
921
- const label = String(candidate.label || "");
922
- return candidate.label === "Model"
923
- || candidate.label === "Model selector"
924
- || matchesCompactIntelligenceOpenerLabel(label)
925
- || /^(?:Light|Standard|Extended|Heavy)(?:, click to remove)?$/i.test(label)
926
- || ["instant", "thinking", "pro"].some((family) => matchesModelFamilyLabel(label, /** @type {import("./chatgpt-ui-helpers.d.mts").OracleUiModelFamily} */ (family)))
927
- || /^(?:(?:Light|Standard|Extended|Heavy) )?Thinking(?:, click to remove)?$/i.test(label)
928
- || /^(?:(?:Light|Standard|Extended|Heavy) )?Pro(?:, click to remove)?$/i.test(label);
929
- }
930
-
931
919
  function canUseOpenModelMenuForSelection(snapshot, selection) {
932
920
  if (selection.modelFamily !== "instant" || selection.autoSwitchToThinking === true) return false;
933
921
  return Boolean(findEntry(
@@ -1312,7 +1300,7 @@ async function waitForUploadConfirmed(job, fileLabel, baselineCount) {
1312
1300
  const labels = labelsForJob(job);
1313
1301
  const sendEntry = findEntry(
1314
1302
  snapshot,
1315
- (candidate) => candidate.kind === "button" && candidate.label === labels.send && !candidate.disabled,
1303
+ (candidate) => candidate.kind === "button" && labels.send.includes(candidate.label || "") && !candidate.disabled,
1316
1304
  );
1317
1305
  const fileCount = isGrokJob(job) && snapshot.includes(fileLabel)
1318
1306
  ? baselineCount + 1
@@ -1346,12 +1334,12 @@ async function waitForSendReady(job) {
1346
1334
  const labels = labelsForJob(job);
1347
1335
  const entry = findEntry(
1348
1336
  snapshot,
1349
- (candidate) => candidate.kind === "button" && candidate.label === labels.send && !candidate.disabled,
1337
+ (candidate) => candidate.kind === "button" && labels.send.includes(candidate.label || "") && !candidate.disabled,
1350
1338
  );
1351
1339
  if (entry) return entry;
1352
1340
  await sleep(1000);
1353
1341
  }
1354
- throw new Error(`Timed out waiting for ${labelsForJob(job).send} to become enabled`);
1342
+ throw new Error(`Timed out waiting for ${labelsForJob(job).send.join(" or ")} to become enabled`);
1355
1343
  }
1356
1344
 
1357
1345
  async function activateSendButton(job) {
@@ -1360,12 +1348,12 @@ async function activateSendButton(job) {
1360
1348
  const buttons = Array.from(document.querySelectorAll('button'));
1361
1349
  const button = buttons.find((candidate) => {
1362
1350
  const label = (candidate.getAttribute('aria-label') || candidate.textContent || '').trim();
1363
- return label === labels.send;
1351
+ return labels.send.includes(label);
1364
1352
  });
1365
1353
  if (!button) return { ok: false, reason: 'send button not found' };
1366
1354
  if (button.disabled || button.getAttribute('aria-disabled') === 'true') return { ok: false, reason: 'send button disabled' };
1367
1355
  button.click();
1368
- return { ok: true };
1356
+ return { ok: true, label: (button.getAttribute('aria-label') || button.textContent || '').trim() };
1369
1357
  `));
1370
1358
  return result;
1371
1359
  }
@@ -1380,7 +1368,7 @@ async function sendAcceptanceState(job, baselineAssistantCount) {
1380
1368
  url: urlResult.url,
1381
1369
  urlKnown: urlResult.ok,
1382
1370
  assistantCount: Math.max(baselineAssistantCount, messages.length),
1383
- stopStreaming: isGrokJob(job) ? snapshot.includes(GROK_LABELS.stop) : snapshot.includes("Stop streaming"),
1371
+ stopStreaming: isGrokJob(job) ? snapshot.includes(GROK_LABELS.stop) : snapshot.includes("Stop streaming") || snapshot.includes('button "Stop"'),
1384
1372
  transientErrorText: detectProviderVisibleBlockerText(snapshot) || "",
1385
1373
  };
1386
1374
  }
@@ -1389,12 +1377,13 @@ async function clickSend(job, baselineAssistantCount) {
1389
1377
  await waitForSendReady(job);
1390
1378
  const beforeSend = await sendAcceptanceState(job, baselineAssistantCount);
1391
1379
  const activation = await activateSendButton(job);
1392
- if (!activation?.ok) throw new Error(`Could not activate ${labelsForJob(job).send}: ${activation?.reason || "DOM activation failed"}`);
1393
- await log(`Activated ${labelsForJob(job).send}; waiting for provider acceptance evidence`);
1380
+ const sendLabel = activation?.label || labelsForJob(job).send.join(" or ");
1381
+ if (!activation?.ok) throw new Error(`Could not activate ${sendLabel}: ${activation?.reason || "DOM activation failed"}`);
1382
+ await log(`Activated ${sendLabel}; waiting for provider acceptance evidence`);
1394
1383
  if (await waitForSendAccepted(job, beforeSend, { timeoutMs: 20_000 })) return;
1395
1384
 
1396
1385
  await captureDiagnostics(job, "send-not-accepted");
1397
- throw new Error(`${isGrokJob(job) ? "Grok" : "ChatGPT"} message did not leave the composer after activating ${labelsForJob(job).send}`);
1386
+ throw new Error(`${isGrokJob(job) ? "Grok" : "ChatGPT"} message did not leave the composer after activating ${sendLabel}`);
1398
1387
  }
1399
1388
 
1400
1389
  async function waitForSendAccepted(job, beforeSend, options = {}) {
@@ -1781,7 +1770,7 @@ async function assistantMessages(job) {
1781
1770
  job,
1782
1771
  toJsonScript(`
1783
1772
  const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6,[role="heading"]'))
1784
- .filter((el) => (el.textContent || '').trim() === 'ChatGPT said:');
1773
+ .filter((el) => el.getAttribute('data-conversation-role') === 'assistant' || (el.textContent || '').trim() === 'ChatGPT said:');
1785
1774
  const renderText = (node) => {
1786
1775
  if (!node) return '';
1787
1776
  const clone = node.cloneNode(true);
@@ -1807,7 +1796,10 @@ async function assistantMessages(job) {
1807
1796
  .trim();
1808
1797
  return text;
1809
1798
  };
1810
- const headingMessages = headings.map((heading) => ({ text: renderText(heading.nextElementSibling) }));
1799
+ const headingMessages = headings.map((heading) => ({
1800
+ text: renderText(heading.nextElementSibling),
1801
+ turnComplete: heading.closest('[data-talvt-turn-state]')?.getAttribute('data-talvt-turn-state') === 'complete',
1802
+ }));
1811
1803
  const messageNodes = Array.from(document.querySelectorAll('[data-testid="assistant-message"], [data-message-author-role="assistant"]'));
1812
1804
  const nodeMessages = messageNodes.map((node) => ({ text: renderText(node) }));
1813
1805
  return {
@@ -1817,7 +1809,10 @@ async function assistantMessages(job) {
1817
1809
  );
1818
1810
 
1819
1811
  if (!Array.isArray(result?.messages)) return [];
1820
- return result.messages.map((message) => ({ text: typeof message?.text === "string" ? message.text : "" }));
1812
+ return result.messages.map((message) => ({
1813
+ text: typeof message?.text === "string" ? message.text : "",
1814
+ turnComplete: message?.turnComplete === true,
1815
+ }));
1821
1816
  }
1822
1817
 
1823
1818
  async function grokAssistantMessages(job) {
@@ -1886,7 +1881,7 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
1886
1881
  while (Date.now() < timeoutAt) {
1887
1882
  await heartbeat();
1888
1883
  const [snapshot, body] = await Promise.all([snapshotText(job), pageText(job).catch(() => "")]);
1889
- const hasStopStreaming = isGrokJob(job) ? snapshot.includes(GROK_LABELS.stop) : snapshot.includes("Stop streaming");
1884
+ const hasStopStreaming = isGrokJob(job) ? snapshot.includes(GROK_LABELS.stop) : snapshot.includes("Stop streaming") || snapshot.includes('button "Stop"');
1890
1885
  const hasRetryButton = snapshot.includes('button "Retry"');
1891
1886
  const copyResponseCount = isGrokJob(job) ? (snapshot.match(/button "Copy"/g) || []).length : (snapshot.match(/Copy response/g) || []).length;
1892
1887
  throwIfProviderTransientError(job, snapshot, "waiting for response completion");
@@ -1894,7 +1889,7 @@ async function waitForChatCompletion(job, baselineAssistantCount) {
1894
1889
  const messages = await assistantMessages(job);
1895
1890
  const targetMessage = messages[baselineAssistantCount];
1896
1891
  const targetText = targetMessage?.text || "";
1897
- const hasTargetCopyResponse = copyResponseCount > baselineAssistantCount;
1892
+ const hasTargetCopyResponse = copyResponseCount > baselineAssistantCount || targetMessage?.turnComplete === true;
1898
1893
 
1899
1894
  if (!hasStopStreaming && hasRetryButton && responseFailureText) {
1900
1895
  if (!retriedAfterFailure) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.7.22",
3
+ "version": "0.7.24",
4
4
  "description": "ChatGPT and Grok web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -87,12 +87,11 @@
87
87
  "typebox": "*"
88
88
  },
89
89
  "overrides": {
90
- "basic-ftp": "6.2.1",
91
90
  "protobufjs": "7.6.6"
92
91
  },
93
92
  "devDependencies": {
94
- "@earendil-works/pi-ai": "1.0.0",
95
- "@earendil-works/pi-coding-agent": "1.0.0",
93
+ "@earendil-works/pi-ai": "1.5.0",
94
+ "@earendil-works/pi-coding-agent": "1.5.0",
96
95
  "@types/node": "^22.19.19",
97
96
  "esbuild": "^0.28.0",
98
97
  "tsx": "^4.22.3",
@@ -61,8 +61,25 @@ function currentGitHead() {
61
61
  return git(["rev-parse", "HEAD"]);
62
62
  }
63
63
 
64
- function currentGitHeadCommittedAt() {
65
- return git(["show", "-s", "--format=%cI", "HEAD"]);
64
+ function gitCommitCommittedAt(ref = "HEAD") {
65
+ return git(["show", "-s", "--format=%cI", ref]);
66
+ }
67
+
68
+ function isRuntimeIdenticalAncestor(candidateHead, currentHead) {
69
+ if (typeof candidateHead !== "string" || !/^[0-9a-f]{40}$/i.test(candidateHead)) return false;
70
+ if (candidateHead === currentHead) return true;
71
+ try {
72
+ execFileSync("git", ["merge-base", "--is-ancestor", candidateHead, currentHead], { cwd: REPO_ROOT, stdio: "ignore" });
73
+ execFileSync("git", ["diff", "--exit-code", candidateHead, currentHead, "--", "extensions", "prompts"], { cwd: REPO_ROOT, stdio: "ignore" });
74
+ return true;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ function packageMetadataAtCommit(ref) {
81
+ const pkg = JSON.parse(git(["show", `${ref}:package.json`]));
82
+ return { name: pkg.name, version: pkg.version };
66
83
  }
67
84
 
68
85
  function currentGitStatus() {
@@ -230,8 +247,6 @@ function validateProof(proof, path) {
230
247
  const errors = [];
231
248
  const { name, version } = packageMetadata();
232
249
  const gitHead = currentGitHead();
233
- const gitHeadCommittedAt = currentGitHeadCommittedAt();
234
- const gitHeadCommittedAtMs = Date.parse(gitHeadCommittedAt);
235
250
  const gitStatus = currentGitStatus();
236
251
  const presetRegistry = canonicalPresets();
237
252
  const requiredPresets = Object.keys(presetRegistry);
@@ -246,10 +261,17 @@ function validateProof(proof, path) {
246
261
  return errors;
247
262
  }
248
263
 
264
+ const proofGitHead = typeof proof.gitHead === "string" ? proof.gitHead : "";
265
+ const validProofHead = isRuntimeIdenticalAncestor(proofGitHead, gitHead);
266
+ const expectedProofHead = validProofHead ? proofGitHead : gitHead;
267
+ const expectedVersion = validProofHead ? packageMetadataAtCommit(expectedProofHead).version : version;
268
+ const gitHeadCommittedAt = gitCommitCommittedAt(expectedProofHead);
269
+ const gitHeadCommittedAtMs = Date.parse(gitHeadCommittedAt);
270
+
249
271
  if (proof.schemaVersion !== 1) errors.push("schemaVersion must be 1");
250
272
  if (proof.packageName !== name) errors.push(`packageName must be ${name}`);
251
- if (proof.packageVersion !== version) errors.push(`packageVersion must match package.json version ${version}`);
252
- if (proof.gitHead !== gitHead) errors.push(`gitHead must match current HEAD ${gitHead}`);
273
+ if (proof.packageVersion !== expectedVersion) errors.push(`packageVersion must match package.json version ${expectedVersion}`);
274
+ if (!validProofHead) errors.push(`gitHead must match current HEAD ${gitHead} or a runtime-identical ancestor commit`);
253
275
  if (proof.provider !== "chatgpt") errors.push('provider must be "chatgpt"');
254
276
  if (proof.extensionUnderTest !== "loaded-extension") errors.push('extensionUnderTest must be "loaded-extension"');
255
277
 
@@ -262,7 +284,7 @@ function validateProof(proof, path) {
262
284
  const maxAgeMs = maxAgeHours() * 60 * 60 * 1000;
263
285
  if (ageMs < 0) errors.push("validatedAt must not be in the future");
264
286
  if (ageMs > maxAgeMs) errors.push(`validatedAt is older than ${maxAgeHours()} hours`);
265
- if (proofValidatedAtMs <= gitHeadCommittedAtMs) errors.push("validatedAt must be after current git HEAD commit time");
287
+ if (proofValidatedAtMs <= gitHeadCommittedAtMs) errors.push("validatedAt must be after proven git commit time");
266
288
  }
267
289
 
268
290
  const jobs = proof.jobs;
@@ -277,8 +299,8 @@ function validateProof(proof, path) {
277
299
  canonicalPreset: presetRegistry[preset],
278
300
  proofJob: jobs[preset],
279
301
  packageName: name,
280
- packageVersion: version,
281
- gitHead,
302
+ packageVersion: expectedVersion,
303
+ gitHead: expectedProofHead,
282
304
  gitHeadCommittedAtMs,
283
305
  proofValidatedAtMs,
284
306
  errors,
@@ -15,20 +15,21 @@ const functions = names.flatMap((name) => {
15
15
  return `${worker.slice(start - 6, start) === "async " ? "async " : ""}${worker.slice(start, end)}`;
16
16
  }).join("\n");
17
17
 
18
- function harness({ initial = 3, max = "4", wrongLabel = false, stuck = false, mountingReads = 0 } = {}) {
18
+ function harness({ initial = 3, max = "4", wrongLabel = false, stuck = false, mountingReads = 0, openerLabel } = {}) {
19
19
  const labels = ["Instant", "Medium", "High", "Extra High", "Pro"];
20
20
  let value = initial;
21
21
  let open = false;
22
22
  let verified = false;
23
+ let now = 0;
23
24
  const snapshot = () => open
24
25
  ? '- button "Thinking effort" [expanded=true, ref=e1]\n- menu "Thinking effort" [ref=e2]\n- menuitem "Select model" [ref=e3]\n- menuitem "Power" [ref=e4]'
25
- : `- button "${value === 4 ? "6 Pro" : labels[value]}" [expanded=false, ref=e1]\n- textbox "Ask ChatGPT" [ref=e5]\n- button "Add files and more" [ref=e6]`;
26
+ : `- button "${openerLabel ?? (value === 4 ? "6 Pro" : labels[value])}" [expanded=false, ref=e1]\n- textbox "Ask ChatGPT" [ref=e5]\n- button "Add files and more" [ref=e6]`;
26
27
  const control = {
27
28
  closest: () => null,
28
29
  getAttribute: () => "description",
29
30
  querySelector: () => mountingReads-- > 0 ? null : ({ getAttribute: (name) => ({ "aria-valuemin": "0", "aria-valuemax": max, "aria-valuenow": String(value) })[name] }),
30
31
  };
31
- const context = vm.createContext({ ...ui, parseSnapshotEntries, Date, Error, JSON, Number,
32
+ const context = vm.createContext({ ...ui, parseSnapshotEntries, Date: { now: () => now++ }, Error, JSON, Number,
32
33
  CHATGPT_LABELS: { configure: "Configure..." },
33
34
  MODEL_CONFIGURATION_OPEN_TIMEOUT_MS: 30, MODEL_CONFIGURATION_SETTLE_TIMEOUT_MS: 30,
34
35
  MODEL_CONFIGURATION_CLOSE_RETRY_MS: 1, MODEL_CONFIGURATION_SETTLE_POLL_MS: 0,
@@ -58,19 +59,21 @@ function harness({ initial = 3, max = "4", wrongLabel = false, stuck = false, mo
58
59
  }
59
60
 
60
61
  test("worker configures every preset through the current Power picker and closes it", async () => {
61
- for (const [selection, expected] of [
62
- [{ modelFamily: "instant", autoSwitchToThinking: false }, 0],
63
- [{ modelFamily: "instant", autoSwitchToThinking: true }, 0],
64
- [{ modelFamily: "thinking", effort: "light" }, 1],
65
- [{ modelFamily: "thinking", effort: "standard" }, 1],
66
- [{ modelFamily: "thinking", effort: "extended" }, 2],
67
- [{ modelFamily: "thinking", effort: "heavy" }, 3],
68
- [{ modelFamily: "pro", effort: "standard" }, 4],
69
- [{ modelFamily: "pro", effort: "extended" }, 4],
70
- ]) {
71
- const h = harness({ initial: expected === 4 ? 0 : 4, mountingReads: 2 });
72
- await h.configure(selection);
73
- assert.deepEqual(h.state, { value: expected, open: false, verified: true });
62
+ for (const openerLabel of [undefined, "Select ChatGPT model"]) {
63
+ for (const [selection, expected] of [
64
+ [{ modelFamily: "instant", autoSwitchToThinking: false }, 0],
65
+ [{ modelFamily: "instant", autoSwitchToThinking: true }, 0],
66
+ [{ modelFamily: "thinking", effort: "light" }, 1],
67
+ [{ modelFamily: "thinking", effort: "standard" }, 1],
68
+ [{ modelFamily: "thinking", effort: "extended" }, 2],
69
+ [{ modelFamily: "thinking", effort: "heavy" }, 3],
70
+ [{ modelFamily: "pro", effort: "standard" }, 4],
71
+ [{ modelFamily: "pro", effort: "extended" }, 4],
72
+ ]) {
73
+ const h = harness({ initial: expected === 4 ? 0 : 4, mountingReads: 2, openerLabel });
74
+ await h.configure(selection);
75
+ assert.deepEqual(h.state, { value: expected, open: false, verified: true });
76
+ }
74
77
  }
75
78
  });
76
79
 
@@ -3,7 +3,8 @@
3
3
  // Default mode is packed-install release proof. Source mode is inner-loop/debug only.
4
4
 
5
5
  import { spawn } from "node:child_process";
6
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
6
+ import { createHash } from "node:crypto";
7
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
7
8
  import { mkdtempSync } from "node:fs";
8
9
  import { tmpdir } from "node:os";
9
10
  import { basename, dirname, join, resolve } from "node:path";
@@ -25,6 +26,8 @@ Modes:
25
26
  Environment:
26
27
  PI_HOST_CLI selected Pi manifest bin entry, invoked with this Node executable (default: pi on PATH)
27
28
  PI_COMPAT_EXPECTED_VERSION expected Pi version (default: package development baseline)
29
+ PI_ORACLE_RELEASE_TARBALL retained tarball to install instead of the newly packed archive
30
+ PI_ORACLE_RELEASE_SHA256 expected retained tarball SHA-256, when supplied
28
31
  PI_ORACLE_REAL_TEST_PROVIDER pi provider for the test agent (default: ${DEFAULT_PROVIDER})
29
32
  PI_ORACLE_REAL_TEST_MODEL pi model for the test agent (default: ${DEFAULT_MODEL})
30
33
  PI_ORACLE_REAL_TEST_TIMEOUT_MS per-agent timeout in ms (default: ${DEFAULT_TIMEOUT_MS})
@@ -262,8 +265,16 @@ async function preparePackedProject({ runDir, provider, model, timeoutMs }) {
262
265
  timeoutMs: 120_000,
263
266
  });
264
267
  const tarballName = pack.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1);
265
- const tarballPath = tarballName ? join(packDir, tarballName) : undefined;
268
+ let tarballPath = tarballName ? join(packDir, tarballName) : undefined;
266
269
  if (!tarballPath || !existsSync(tarballPath)) throw new Error(`npm pack did not produce a tarball in ${packDir}`);
270
+ if (env("PI_ORACLE_RELEASE_TARBALL")) {
271
+ tarballPath = join(packDir, "release-candidate.tgz");
272
+ copyFileSync(resolve(env("PI_ORACLE_RELEASE_TARBALL")), tarballPath);
273
+ const hash = createHash("sha256").update(readFileSync(tarballPath)).digest("hex");
274
+ const expected = env("PI_ORACLE_RELEASE_SHA256");
275
+ if (expected && hash !== expected) throw new Error("Retained release candidate checksum changed");
276
+ console.log(`ORACLE_RELEASE_CANDIDATE_SHA256=${hash}`);
277
+ }
267
278
  writeFileSync(join(installDir, "packed-tarball.txt"), `${tarballPath}\n`);
268
279
 
269
280
  await mustRun(installDir, "npm-init", npm, ["init", "-y"], { cwd: piProject, env: process.env, timeoutMs: 60_000 });
@@ -4,6 +4,6 @@ USER root
4
4
  RUN apt-get update \
5
5
  && apt-get install -y --no-install-recommends zstd rsync \
6
6
  && rm -rf /var/lib/apt/lists/*
7
- RUN npm install -g npm@11.21.0 \
8
- && npm install -g agent-browser@0.29.1
7
+ RUN npm install -g npm \
8
+ && npm install -g --allow-scripts=agent-browser agent-browser
9
9
  USER circleci
@@ -58,6 +58,7 @@ export function buildTargetBaseArgs(targetName, config = {}) {
58
58
  switch (targetName) {
59
59
  case "macos": {
60
60
  const host = env("PI_ORACLE_SMOKE_MAC_HOST") || env("PLATFORM_SMOKE_MAC_HOST") || "localhost";
61
+ const port = env("PI_ORACLE_SMOKE_MAC_PORT") || env("PLATFORM_SMOKE_MAC_PORT") || "22";
61
62
  const user = env("PI_ORACLE_SMOKE_MAC_USER") || env("PLATFORM_SMOKE_MAC_USER") || env("USER");
62
63
  const workRoot = env("PI_ORACLE_SMOKE_MAC_WORK_ROOT") || env("PLATFORM_SMOKE_MAC_WORK_ROOT") || `/Users/${env("USER")}/crabbox/${config.packageName ?? "pi-oracle"}`;
63
64
  return [
@@ -65,7 +66,7 @@ export function buildTargetBaseArgs(targetName, config = {}) {
65
66
  "--target", "macos",
66
67
  "--static-host", host,
67
68
  "--static-user", user,
68
- "--static-port", "22",
69
+ "--static-port", port,
69
70
  "--static-work-root", workRoot,
70
71
  ];
71
72
  }
@@ -82,6 +83,19 @@ export function buildTargetBaseArgs(targetName, config = {}) {
82
83
  const snapshot = env("PI_ORACLE_SMOKE_WINDOWS_SNAPSHOT") || env("PLATFORM_SMOKE_WINDOWS_SNAPSHOT") || config.windowsParallels?.snapshot || "crabbox-ready";
83
84
  const user = env("PI_ORACLE_SMOKE_WINDOWS_USER") || env("PLATFORM_SMOKE_WINDOWS_USER") || env("USER");
84
85
  const workRoot = env("PI_ORACLE_SMOKE_WINDOWS_NATIVE_WORK_ROOT") || env("PLATFORM_SMOKE_WINDOWS_WORK_ROOT") || `C:\\crabbox\\${config.packageName ?? "pi-oracle"}`;
86
+ const host = env("PI_ORACLE_SMOKE_WINDOWS_HOST") || env("PLATFORM_SMOKE_WINDOWS_HOST");
87
+ if (host) {
88
+ const port = env("PI_ORACLE_SMOKE_WINDOWS_PORT") || env("PLATFORM_SMOKE_WINDOWS_PORT") || "22";
89
+ return [
90
+ "--provider", "ssh",
91
+ "--target", "windows",
92
+ "--windows-mode", "normal",
93
+ "--static-host", host,
94
+ "--static-user", user,
95
+ "--static-port", port,
96
+ "--static-work-root", workRoot,
97
+ ];
98
+ }
85
99
  return [
86
100
  "--provider", "parallels",
87
101
  "--target", "windows",
@@ -1,8 +1,8 @@
1
1
  // Preflight checks for the pi-oracle Crabbox platform smoke gate.
2
2
 
3
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";
4
+ import { accessSync, constants, mkdirSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { join, relative, resolve } from "node:path";
6
6
  import { buildTargetBaseArgs } from "./crabbox-runner.mjs";
7
7
 
8
8
  let failures = 0;
@@ -131,28 +131,53 @@ function verifyPackageExclusions() {
131
131
  forbidden.length ? fail(`forbidden files in package: ${forbidden.join(", ")}`) : ok("package excludes local env/artifact/debug state");
132
132
  }
133
133
 
134
+ function findForbiddenFiles(dir, depth = 0) {
135
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
136
+ const path = join(dir, entry.name);
137
+ const matches = entry.name === ".env" || entry.name.startsWith(".env.") || entry.name.endsWith(".tgz");
138
+ return [
139
+ ...(matches ? [relative(process.cwd(), path)] : []),
140
+ ...(entry.isDirectory() && depth < 2 ? findForbiddenFiles(path, depth + 1) : []),
141
+ ];
142
+ });
143
+ }
144
+
134
145
  function verifyRepoForbiddenFiles() {
135
- const forbidden = shell("find . -maxdepth 3 \\( -name '.env' -o -name '.env.*' -o -name '*.tgz' \\) -print 2>/dev/null");
136
- forbidden ? fail(`forbidden local secret/package artifact(s): ${forbidden}`) : ok("no .env/.env.* or package tarballs at repo root depth");
146
+ const forbidden = findForbiddenFiles(process.cwd());
147
+ forbidden.length ? fail(`forbidden local secret/package artifact(s): ${forbidden.join(", ")}`) : ok("no .env/.env.* or package tarballs at repo root depth");
137
148
  for (const dir of [".artifacts", ".crabbox", ".debug", ".platform-smoke-runs"]) {
138
- const ignored = shell(`git check-ignore -q ${dir}/probe && echo ignored`);
149
+ const ignored = silent("git", ["check-ignore", "-q", `${dir}/probe`]) !== null;
139
150
  ignored ? ok(`${dir}/ is gitignored`) : fail(`${dir}/ must be gitignored`);
140
151
  }
141
- const trackedForbidden = shell("git ls-files '.env*' '*.tgz' '.artifacts/*' '.crabbox/*' '.debug/*' '.platform-smoke-runs/*'");
152
+ const trackedForbidden = silent("git", ["ls-files", ".env*", "*.tgz", ".artifacts/*", ".crabbox/*", ".debug/*", ".platform-smoke-runs/*"]);
142
153
  trackedForbidden ? fail(`forbidden tracked local state: ${trackedForbidden}`) : ok("no forbidden local state is tracked");
143
154
  verifyPackageExclusions();
144
155
  }
145
156
 
146
157
  export async function runDoctor(config) {
147
158
  failures = 0;
159
+ const targets = config.requiredTargets ?? [];
160
+ const hasMac = targets.includes("macos");
161
+ const hasUbuntu = targets.includes("ubuntu");
162
+ const hasWindows = targets.includes("windows-native");
163
+ const windowsArgs = hasWindows ? buildTargetBaseArgs("windows-native", config) : [];
164
+ const windowsStatic = windowsArgs.includes("--static-host");
148
165
  console.log("\n── Environment ──");
149
166
  const cbox = env("PI_ORACLE_SMOKE_CRABBOX") || env("PLATFORM_SMOKE_CRABBOX") || "crabbox";
150
167
  ok(`Crabbox binary = ${resolveCommand(cbox) ?? cbox}${env("PI_ORACLE_SMOKE_CRABBOX") || env("PLATFORM_SMOKE_CRABBOX") ? " (env override)" : " (PATH)"}`);
151
- ok(`PI_ORACLE_SMOKE_MAC_HOST = ${env("PI_ORACLE_SMOKE_MAC_HOST") || env("PLATFORM_SMOKE_MAC_HOST") || "localhost"}`);
152
- ok(`PI_ORACLE_SMOKE_MAC_USER = ${env("PI_ORACLE_SMOKE_MAC_USER") || env("PLATFORM_SMOKE_MAC_USER") || env("USER")}`);
153
- ok(`PI_ORACLE_SMOKE_UBUNTU_IMAGE = ${env("PI_ORACLE_SMOKE_UBUNTU_IMAGE") || env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config.ubuntuContainerImage || "pi-oracle-platform-smoke:node24"}`);
154
- ok(`PI_ORACLE_SMOKE_WINDOWS_VM = ${windowsVmName(config)}`);
155
- ok(`PI_ORACLE_SMOKE_WINDOWS_SNAPSHOT = ${windowsSnapshotName(config)}`);
168
+ if (hasMac) {
169
+ ok(`PI_ORACLE_SMOKE_MAC_HOST = ${env("PI_ORACLE_SMOKE_MAC_HOST") || env("PLATFORM_SMOKE_MAC_HOST") || "localhost"}`);
170
+ ok(`PI_ORACLE_SMOKE_MAC_PORT = ${env("PI_ORACLE_SMOKE_MAC_PORT") || env("PLATFORM_SMOKE_MAC_PORT") || "22"}`);
171
+ ok(`PI_ORACLE_SMOKE_MAC_USER = ${env("PI_ORACLE_SMOKE_MAC_USER") || env("PLATFORM_SMOKE_MAC_USER") || env("USER")}`);
172
+ }
173
+ if (hasUbuntu) ok(`PI_ORACLE_SMOKE_UBUNTU_IMAGE = ${env("PI_ORACLE_SMOKE_UBUNTU_IMAGE") || env("PLATFORM_SMOKE_UBUNTU_IMAGE") || config.ubuntuContainerImage || "pi-oracle-platform-smoke:node24"}`);
174
+ if (hasWindows && windowsStatic) {
175
+ ok(`Windows SSH host = ${windowsArgs[windowsArgs.indexOf("--static-host") + 1]}`);
176
+ ok(`Windows SSH port = ${windowsArgs[windowsArgs.indexOf("--static-port") + 1]}`);
177
+ } else if (hasWindows) {
178
+ ok(`PI_ORACLE_SMOKE_WINDOWS_VM = ${windowsVmName(config)}`);
179
+ ok(`PI_ORACLE_SMOKE_WINDOWS_SNAPSHOT = ${windowsSnapshotName(config)}`);
180
+ }
156
181
 
157
182
  console.log("\n── Crabbox ──");
158
183
  const resolvedCbox = resolveCommand(cbox);
@@ -166,15 +191,22 @@ export async function runDoctor(config) {
166
191
  compareVersions(versionLine, requiredVersion) >= 0 ? ok(`required version: ${requiredVersion}+`) : fail(`Crabbox version mismatch: need ${requiredVersion}+, got ${versionLine}`);
167
192
  }
168
193
  const providers = silent(cbox, ["providers"], { timeout: 30_000 }) ?? "";
169
- for (const provider of ["ssh", "local-container", "parallels"]) {
194
+ const neededProviders = new Set([
195
+ ...(hasMac || (hasWindows && windowsStatic) ? ["ssh"] : []),
196
+ ...(hasUbuntu ? ["local-container"] : []),
197
+ ...(hasWindows && !windowsStatic ? ["parallels"] : []),
198
+ ]);
199
+ for (const provider of neededProviders) {
170
200
  new RegExp(`^${provider}$`, "m").test(providers) ? ok(`provider available: ${provider}`) : fail(`crabbox providers missing ${provider}`);
171
201
  }
172
- runCrabboxDoctor(cbox, "macOS static SSH", buildTargetBaseArgs("macos", config), 120_000);
173
- runCrabboxDoctor(cbox, "Windows native Parallels", buildTargetBaseArgs("windows-native", config), 180_000);
174
- runCrabboxDoctor(cbox, "local-container", buildTargetBaseArgs("ubuntu", config), 120_000);
202
+ if (hasMac) runCrabboxDoctor(cbox, "macOS static SSH", buildTargetBaseArgs("macos", config), 120_000);
203
+ if (hasWindows) runCrabboxDoctor(cbox, windowsStatic ? "Windows native SSH" : "Windows native Parallels", windowsArgs, 180_000);
204
+ if (hasUbuntu) runCrabboxDoctor(cbox, "local-container", buildTargetBaseArgs("ubuntu", config), 120_000);
175
205
 
176
206
  console.log("\n── Host tools ──");
177
- 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"]]) {
207
+ const hostTools = [["Node", "node --version"], ["npm", "npm --version"], ["git", "git --version"], ["tar", "tar --version"], ["rsync", "rsync --version"]];
208
+ if (hasUbuntu) hostTools.unshift(["Docker", "docker info --format '{{.ServerVersion}}'"]);
209
+ for (const [name, command] of hostTools) {
178
210
  const output = shell(command);
179
211
  output ? ok(`${name}: ${output.split("\n")[0]}`) : fail(`${name} not found or unavailable`);
180
212
  }
@@ -183,7 +215,7 @@ export async function runDoctor(config) {
183
215
  const major = Number(nodeVersion.replace(/^v/, "").split(".")[0]);
184
216
  if (major < (config.nodeValidationMajor ?? 24)) fail(`Node ${nodeVersion}; need ${config.nodeValidationMajor ?? 24}+ for smoke validation`);
185
217
  }
186
- verifyWindowsTemplate(config);
218
+ if (hasWindows && !windowsStatic) verifyWindowsTemplate(config);
187
219
 
188
220
  console.log("\n── Auth environment ──");
189
221
  const requiredAuth = requiredRealSmokeAuthEnv(config);
@@ -193,7 +225,7 @@ export async function runDoctor(config) {
193
225
  }
194
226
 
195
227
  console.log("\n── Target tools ──");
196
- for (const target of config.requiredTargets ?? []) runTargetToolProbe(cbox, target, config);
228
+ for (const target of targets) runTargetToolProbe(cbox, target, config);
197
229
 
198
230
  console.log("\n── Artifact root ──");
199
231
  const artifactRoot = resolve(process.cwd(), config.artifactRoot ?? ".artifacts/platform-smoke");
@@ -217,8 +249,8 @@ export async function runDoctor(config) {
217
249
  console.log(`\n=== Results: ${failures} failure(s) ===`);
218
250
  if (failures > 0) {
219
251
  process.exitCode = 1;
220
- console.log("Fix the failures above before running npm run smoke:platform:all.");
252
+ console.log("Fix the failures above before running the selected platform suites.");
221
253
  } else {
222
- console.log("Ready for npm run smoke:platform:all.");
254
+ console.log(`Ready for platform suites on ${targets.join(", ")}.`);
223
255
  }
224
256
  }
@@ -28,17 +28,23 @@ function testHelpTextIncludesTargetsAndExamples() {
28
28
  }
29
29
 
30
30
  function testTargetSelection() {
31
- const result = runNode(["scripts/platform-smoke.mjs", "run", "--target", "not-a-target", "--suite", "platform-build"]);
32
- assert.notEqual(result.status, 0, "unsupported targets should fail before Crabbox runs");
33
- assert.ifError(result.error);
34
- assert.match(`${result.stdout ?? ""}\n${result.stderr ?? ""}`, /unsupported target: not-a-target/);
31
+ for (const command of ["doctor", "run"]) {
32
+ const result = withEnv({ PI_ORACLE_SMOKE_CRABBOX: "/nonexistent/crabbox" }, () =>
33
+ runNode(["scripts/platform-smoke.mjs", command, "--target", "not-a-target"]));
34
+ assert.notEqual(result.status, 0, `${command}: unsupported targets should fail before Crabbox runs`);
35
+ assert.ifError(result.error);
36
+ assert.match(`${result.stdout ?? ""}\n${result.stderr ?? ""}`, /unsupported target: not-a-target/);
37
+ }
35
38
  }
36
39
 
37
40
  function testEmptyTargetSelectionRejected() {
38
- const result = runNode(["scripts/platform-smoke.mjs", "run", "--target", ",,", "--suite", "platform-build"]);
39
- assert.notEqual(result.status, 0, "an --target value that resolves to zero targets must fail instead of running nothing");
40
- assert.ifError(result.error);
41
- assert.match(`${result.stdout ?? ""}\n${result.stderr ?? ""}`, /--target did not resolve to any targets/);
41
+ for (const command of ["doctor", "run"]) {
42
+ const result = withEnv({ PI_ORACLE_SMOKE_CRABBOX: "/nonexistent/crabbox" }, () =>
43
+ runNode(["scripts/platform-smoke.mjs", command, "--target", ",,"]));
44
+ assert.notEqual(result.status, 0, `${command}: an empty target selection must fail instead of running nothing`);
45
+ assert.ifError(result.error);
46
+ assert.match(`${result.stdout ?? ""}\n${result.stderr ?? ""}`, /--target did not resolve to any targets/);
47
+ }
42
48
  }
43
49
 
44
50
  function testPackedInstallCommandRendering() {
@@ -93,6 +99,8 @@ function testTargetBaseArgsEnvPrecedence() {
93
99
  PLATFORM_SMOKE_UBUNTU_IMAGE: undefined,
94
100
  PI_ORACLE_SMOKE_MAC_WORK_ROOT: undefined,
95
101
  PLATFORM_SMOKE_MAC_WORK_ROOT: undefined,
102
+ PI_ORACLE_SMOKE_MAC_PORT: undefined,
103
+ PLATFORM_SMOKE_MAC_PORT: undefined,
96
104
  PI_ORACLE_SMOKE_WINDOWS_NATIVE_WORK_ROOT: undefined,
97
105
  PLATFORM_SMOKE_WINDOWS_WORK_ROOT: undefined,
98
106
  }, () => {
@@ -122,6 +130,14 @@ function testTargetBaseArgsEnvPrecedence() {
122
130
  args = buildTargetBaseArgs("macos", {});
123
131
  assert(args.includes("/invariants/private-work") && !args.includes("/invariants/shared-work"), "private PI_ORACLE_SMOKE_MAC_WORK_ROOT alias should win over the shared alias");
124
132
 
133
+ assert.equal(args[args.indexOf("--static-port") + 1], "22", "macOS SSH defaults to port 22");
134
+ process.env.PLATFORM_SMOKE_MAC_PORT = "2200";
135
+ args = buildTargetBaseArgs("macos", {});
136
+ assert.equal(args[args.indexOf("--static-port") + 1], "2200", "shared macOS port reaches Crabbox");
137
+ process.env.PI_ORACLE_SMOKE_MAC_PORT = "2201";
138
+ args = buildTargetBaseArgs("macos", {});
139
+ assert.equal(args[args.indexOf("--static-port") + 1], "2201", "project macOS port takes precedence");
140
+
125
141
  process.env.PLATFORM_SMOKE_WINDOWS_WORK_ROOT = "C:\\invariants\\shared-work";
126
142
  args = buildTargetBaseArgs("windows-native", {});
127
143
  assert(args.includes("C:\\invariants\\shared-work"), "shared PLATFORM_SMOKE_WINDOWS_WORK_ROOT override should reach target args");
@@ -1,6 +1,8 @@
1
1
  param(
2
2
  [string]$PackageName = "pi-oracle",
3
- [int]$NodeValidationMajor = 24
3
+ [int]$NodeValidationMajor = 24,
4
+ [string]$CandidatePath = "",
5
+ [string]$CandidateSha256 = ""
4
6
  )
5
7
 
6
8
  $ErrorActionPreference = "Continue"
@@ -77,6 +79,20 @@ Write-Output "PLATFORM_NPM_PACK_EXIT=$PACK_EXIT"
77
79
  if ($PackTarball -and (Test-Path -LiteralPath $PackTarball)) {
78
80
  Move-Item -LiteralPath $PackTarball -Destination (Join-Path $PackDir $PackTarball) -Force
79
81
  }
82
+ if ($CandidatePath) {
83
+ if (-not (Test-Path -LiteralPath $CandidatePath)) {
84
+ Write-Output "Retained release candidate is missing"
85
+ exit 1
86
+ }
87
+ $CandidateHash = (Get-FileHash -LiteralPath $CandidatePath -Algorithm SHA256).Hash.ToLowerInvariant()
88
+ Write-Output "PLATFORM_CANDIDATE_SHA256=$CandidateHash"
89
+ if ($CandidateHash -ne $CandidateSha256.ToLowerInvariant()) {
90
+ Write-Output "Retained release candidate checksum changed"
91
+ exit 1
92
+ }
93
+ Copy-Item -LiteralPath $CandidatePath -Destination (Join-Path $PackDir "release-candidate.tgz") -ErrorAction Stop
94
+ $PackTarball = "release-candidate.tgz"
95
+ }
80
96
  Write-Output "PLATFORM_PACKED_TARBALL=$PackTarball"
81
97
  Set-Content -Path (Join-Path $PackDir "packed-tarball.txt") -Value $PackTarball
82
98
 
@@ -95,6 +95,10 @@ export function buildRealExtensionCommand(targetName = "ubuntu", config = {}) {
95
95
  `$env:PI_ORACLE_REAL_TEST_PROVIDER = ${powershellSingleQuote(provider)}`,
96
96
  `$env:PI_ORACLE_REAL_TEST_MODEL = ${powershellSingleQuote(model)}`,
97
97
  `$env:PI_ORACLE_REAL_TEST_MODEL_AGENT = ${powershellSingleQuote(useModelAgent ? "1" : "")}`,
98
+ ...(config.releaseCandidate ? [
99
+ `$env:PI_ORACLE_RELEASE_TARBALL = Join-Path (Get-Location) ${powershellSingleQuote(config.releaseCandidate.path)}`,
100
+ `$env:PI_ORACLE_RELEASE_SHA256 = ${powershellSingleQuote(config.releaseCandidate.sha256)}`,
101
+ ] : []),
98
102
  "$env:PI_ORACLE_REAL_TEST_TIMEOUT_MS = '360000'",
99
103
  "if (-not (Test-Path -LiteralPath 'node_modules')) { npm.cmd ci; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } }",
100
104
  "npm.cmd run smoke:real:doctor",
@@ -108,15 +112,22 @@ export function buildRealExtensionCommand(targetName = "ubuntu", config = {}) {
108
112
  `export PI_ORACLE_REAL_TEST_PROVIDER=${shellQuote(provider)}`,
109
113
  `export PI_ORACLE_REAL_TEST_MODEL=${shellQuote(model)}`,
110
114
  `export PI_ORACLE_REAL_TEST_MODEL_AGENT=${shellQuote(useModelAgent ? "1" : "")}`,
115
+ ...(config.releaseCandidate ? [
116
+ `export PI_ORACLE_RELEASE_TARBALL="$PWD"/${shellQuote(config.releaseCandidate.path)}`,
117
+ `export PI_ORACLE_RELEASE_SHA256=${shellQuote(config.releaseCandidate.sha256)}`,
118
+ ] : []),
111
119
  'if [ ! -d node_modules ]; then npm ci; fi',
112
120
  "npm run smoke:real:doctor",
113
121
  "npm run smoke:real:packed",
114
122
  ].join("\n");
115
123
  }
116
124
 
117
- export function buildPlatformBuildCommand(targetName = "ubuntu", packageName = "pi-oracle", nodeValidationMajor = 24) {
125
+ export function buildPlatformBuildCommand(targetName = "ubuntu", packageName = "pi-oracle", nodeValidationMajor = 24, releaseCandidate) {
118
126
  if (targetName === "windows-native") {
119
- return `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\\scripts\\platform-smoke\\platform-build-windows.ps1 -PackageName ${packageName} -NodeValidationMajor ${nodeValidationMajor}`;
127
+ const candidate = releaseCandidate
128
+ ? ` -CandidatePath ${powershellSingleQuote(releaseCandidate.path)} -CandidateSha256 ${powershellSingleQuote(releaseCandidate.sha256)}`
129
+ : "";
130
+ return `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\\scripts\\platform-smoke\\platform-build-windows.ps1 -PackageName ${packageName} -NodeValidationMajor ${nodeValidationMajor}${candidate}`;
120
131
  }
121
132
  const lines = [];
122
133
  lines.push("set -o pipefail");
@@ -172,6 +183,12 @@ export function buildPlatformBuildCommand(targetName = "ubuntu", packageName = "
172
183
  lines.push('cat "$PACK_DIR/npm-pack.stderr.txt"');
173
184
  lines.push('echo "PLATFORM_NPM_PACK_EXIT=$PACK_EXIT"');
174
185
  lines.push('if [ -n "$PACK_TARBALL" ] && [ -f "$PACK_TARBALL" ]; then mv "$PACK_TARBALL" "$PACK_DIR/$PACK_TARBALL"; fi');
186
+ if (releaseCandidate) {
187
+ lines.push(`CANDIDATE="$SOURCE_ROOT"/${shellQuote(releaseCandidate.path)}`);
188
+ lines.push('CANDIDATE_SHA256=$(node -e \'const {createHash}=require("node:crypto");const {readFileSync}=require("node:fs");console.log(createHash("sha256").update(readFileSync(process.argv[1])).digest("hex"))\' "$CANDIDATE")');
189
+ lines.push('echo "PLATFORM_CANDIDATE_SHA256=$CANDIDATE_SHA256"');
190
+ lines.push(`if [ "$CANDIDATE_SHA256" = ${shellQuote(releaseCandidate.sha256)} ]; then cp "$CANDIDATE" "$PACK_DIR/release-candidate.tgz" || exit 1; PACK_TARBALL=release-candidate.tgz; else echo "Retained release candidate checksum changed"; exit 1; fi`);
191
+ }
175
192
  lines.push('echo "PLATFORM_PACKED_TARBALL=$PACK_TARBALL"');
176
193
  lines.push('printf "%s\\n" "$PACK_TARBALL" > "$PACK_DIR/packed-tarball.txt"');
177
194
  lines.push("");
@@ -213,7 +230,7 @@ export async function runTargetSuite(config, targetName, suiteName, leaseSession
213
230
  writeFileSync(resolve(suiteDir, "suite.json"), JSON.stringify({ suiteName, writtenAt: new Date().toISOString() }, null, 2));
214
231
 
215
232
  const command = suiteName === "platform-build"
216
- ? buildPlatformBuildCommand(targetName, config.packageName ?? "pi-oracle", config.nodeValidationMajor ?? 24)
233
+ ? buildPlatformBuildCommand(targetName, config.packageName ?? "pi-oracle", config.nodeValidationMajor ?? 24, config.releaseCandidate)
217
234
  : buildRealExtensionCommand(targetName, config);
218
235
  writeCommand(suiteDir, command);
219
236
 
@@ -269,6 +286,8 @@ export async function runTargetSuite(config, targetName, suiteName, leaseSession
269
286
  { id: "platform-dependencies", fn: () => /PLATFORM_DEPS_EXIT=0/.test(stdout) },
270
287
  { id: "platform-verification", fn: () => /PLATFORM_NPM_TEST_EXIT=0/.test(stdout) },
271
288
  { id: "npm-pack", fn: () => /PLATFORM_NPM_PACK_EXIT=0/.test(stdout) && /PLATFORM_PACKED_TARBALL=\S+/.test(stdout) },
289
+ ...(config.releaseCandidate ? [{ id: "release-candidate-sha256",
290
+ fn: () => markerValue(stdout, "PLATFORM_CANDIDATE_SHA256") === config.releaseCandidate.sha256 }] : []),
272
291
  { id: "packed-node-install", fn: () => /PLATFORM_PACKED_NODE_INSTALL_EXIT=0/.test(stdout) },
273
292
  { id: "pi-install", fn: () => /PLATFORM_PI_INSTALL_EXIT=0/.test(stdout) },
274
293
  { id: "pi-list", fn: () => /PLATFORM_PI_LIST_EXIT=0/.test(stdout) && listOutput.includes(packageName) && packageInstallPattern.test(listOutput) },
@@ -289,6 +308,8 @@ export async function runTargetSuite(config, targetName, suiteName, leaseSession
289
308
  { id: "real-smoke-doctor", fn: () => stdout.includes("Oracle real smoke doctor") && stdout.includes(`provider: ${provider}`) },
290
309
  { id: "real-smoke-marker", fn: () => stdout.includes("Oracle real smoke passed:") },
291
310
  { id: "real-smoke-packed-install", fn: () => stdout.includes("mode=packed") && stdout.includes("extension=./node_modules/pi-oracle") },
311
+ ...(config.releaseCandidate ? [{ id: "release-candidate-sha256",
312
+ fn: () => markerValue(stdout, "ORACLE_RELEASE_CANDIDATE_SHA256") === config.releaseCandidate.sha256 }] : []),
292
313
  { id: "real-smoke-no-source-extension", fn: () => !command.includes("extensions/oracle/index.ts") && !/\bpi\s+(?:-e|--extension)\s+extensions\/oracle/.test(command) },
293
314
  { id: "no-secrets", fn: () => violations.length === 0, error: "redaction violations found" },
294
315
  ];
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { createHash } from "node:crypto";
4
+ import { copyFileSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
5
  import { createRequire } from "node:module";
4
- import { dirname, resolve } from "node:path";
6
+ import { dirname, relative, resolve, sep } from "node:path";
5
7
  import { fileURLToPath } from "node:url";
6
8
 
7
9
  const __filename = fileURLToPath(import.meta.url);
@@ -21,7 +23,7 @@ function printHelp() {
21
23
  console.log(`Usage: node scripts/platform-smoke.mjs <command> [options]
22
24
 
23
25
  Commands:
24
- doctor Run mandatory Crabbox, host, target-tool, auth, and package preflight checks
26
+ doctor [--target <names>] Run preflight checks for required or selected targets
25
27
  run --target <names> Run one or more comma-separated targets concurrently
26
28
  run --suite <name> Run one suite on the configured target(s)
27
29
 
@@ -48,14 +50,19 @@ Environment:
48
50
  PLATFORM_SMOKE_CRABBOX Reusable Crabbox binary override (defaults to PATH)
49
51
  PI_ORACLE_SMOKE_CRABBOX Project-specific Crabbox binary override (wins over PLATFORM_SMOKE_CRABBOX)
50
52
  PLATFORM_SMOKE_MAC_HOST macOS SSH host (default: localhost)
53
+ PLATFORM_SMOKE_MAC_PORT macOS SSH port (default: 22)
51
54
  PLATFORM_SMOKE_MAC_USER macOS SSH user (default: $USER)
52
55
  PLATFORM_SMOKE_MAC_WORK_ROOT macOS Crabbox work root
53
56
  PLATFORM_SMOKE_UBUNTU_IMAGE Optional local-container image override
54
57
  PLATFORM_SMOKE_WINDOWS_VM Parallels source VM (default from config)
55
58
  PLATFORM_SMOKE_WINDOWS_SNAPSHOT Parallels snapshot (default from config)
59
+ PLATFORM_SMOKE_WINDOWS_HOST Use a native Windows SSH target instead of Parallels
60
+ PLATFORM_SMOKE_WINDOWS_PORT Windows SSH port (default: 22)
56
61
  PLATFORM_SMOKE_WINDOWS_USER Windows SSH user (default: $USER)
57
62
  PLATFORM_SMOKE_WINDOWS_WORK_ROOT Windows work root
58
63
  PI_ORACLE_SMOKE_* Project-specific aliases for the PLATFORM_SMOKE_* knobs above
64
+ PI_ORACLE_RELEASE_TARBALL Use this retained tarball for the packed platform suites
65
+ PI_ORACLE_RELEASE_SHA256 Expected SHA-256 of the retained tarball, when supplied
59
66
  PI_ORACLE_REAL_TEST_PROVIDER Real smoke provider (default: zai)
60
67
  PI_ORACLE_REAL_TEST_MODEL Real smoke model (default: glm-5.2)
61
68
  ZAI_API_KEY Default real-smoke provider API key for optional model-agent debugging
@@ -104,9 +111,9 @@ function validateSelection(targets, suites) {
104
111
  }
105
112
  }
106
113
 
107
- async function runDoctor() {
114
+ async function runDoctor(targets) {
108
115
  const { runDoctor } = await import("./platform-smoke/doctor.mjs");
109
- await runDoctor(config);
116
+ await runDoctor({ ...config, requiredTargets: targets });
110
117
  }
111
118
 
112
119
  async function runTarget(targetName, suites, singleSuite) {
@@ -124,7 +131,10 @@ async function main() {
124
131
  if (!config) throw new Error("platform-smoke.config.mjs not found or failed to load");
125
132
 
126
133
  if (args.command === "doctor") {
127
- await runDoctor();
134
+ const targets = args.target ? splitCsv(args.target) : config.requiredTargets;
135
+ if (targets.length === 0) throw new Error("--target did not resolve to any targets");
136
+ validateSelection(targets, config.requiredSuites);
137
+ await runDoctor(targets);
128
138
  return;
129
139
  }
130
140
 
@@ -134,13 +144,36 @@ async function main() {
134
144
  if (targets.length === 0) throw new Error("--target did not resolve to any targets");
135
145
  if (suites.length === 0) throw new Error("--suite did not resolve to any suites");
136
146
  validateSelection(targets, suites);
137
- const results = await Promise.all(targets.map(async (target) => {
138
- console.log(`\n=== Target: ${target} ===`);
139
- return { target, result: await runTarget(target, suites, Boolean(args.suite)) };
140
- }));
141
- if (results.some(({ result }) => !result.ok)) {
142
- console.log("\nOne or more platform smoke suites failed. Check .artifacts/platform-smoke/ for details.");
143
- process.exit(1);
147
+ let inputDir;
148
+ try {
149
+ if (process.env.PI_ORACLE_RELEASE_TARBALL) {
150
+ // Crabbox syncs nonignored files, but this repository ignores *.tgz.
151
+ inputDir = mkdtempSync(resolve(repoRoot, ".oracle-release-input-"));
152
+ const candidate = resolve(inputDir, "candidate");
153
+ copyFileSync(resolve(process.env.PI_ORACLE_RELEASE_TARBALL), candidate);
154
+ const sha256 = createHash("sha256").update(readFileSync(candidate)).digest("hex");
155
+ if (process.env.PI_ORACLE_RELEASE_SHA256 && sha256 !== process.env.PI_ORACLE_RELEASE_SHA256) {
156
+ throw new Error("Retained release candidate checksum changed");
157
+ }
158
+ config.releaseCandidate = {
159
+ path: relative(repoRoot, candidate).split(sep).join("/"),
160
+ sha256,
161
+ };
162
+ console.log(`Retained release candidate SHA-256: ${config.releaseCandidate.sha256}`);
163
+ }
164
+ const outcomes = await Promise.allSettled(targets.map(async (target) => {
165
+ console.log(`\n=== Target: ${target} ===`);
166
+ return { target, result: await runTarget(target, suites, Boolean(args.suite)) };
167
+ }));
168
+ const failure = outcomes.find((outcome) => outcome.status === "rejected");
169
+ if (failure) throw failure.reason;
170
+ const results = outcomes.map((outcome) => outcome.value);
171
+ if (results.some(({ result }) => !result.ok)) {
172
+ console.log("\nOne or more platform smoke suites failed. Check .artifacts/platform-smoke/ for details.");
173
+ process.exitCode = 1;
174
+ }
175
+ } finally {
176
+ if (inputDir) rmSync(inputDir, { recursive: true, force: true });
144
177
  }
145
178
  return;
146
179
  }