Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 91 additions & 55 deletions bin/ocx.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
*/
import { spawn, spawnSync } from "node:child_process";
import { createSupervisionLatch, inspectDesktopSupervision } from "../src/service/desktop-supervision.mjs";
import { planDesktopCliHandoff, runDesktopCliHandoff } from "../src/lib/desktop-cli-handoff.mjs";
import { desktopCliRecordPath } from "../src/lib/desktop-cli-record.mjs";
import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs";
import { probeProxyLiveness } from "../src/update/proxy-liveness-probe.mjs";
import { decidePostStopUpdate } from "../src/update/stop-decision.mjs";
Expand Down Expand Up @@ -1018,6 +1020,36 @@ if (codexCliUpdateInspection && typeof process.versions.bun === "string") {
process.exit(1);
}

const { launchProof, launchContext, inheritedEnv } = createNodeLaunchContext();
const desktopPlan = planDesktopCliHandoff({ selfPaths: [fileURLToPath(import.meta.url), process.execPath] });
if (desktopPlan.kind === "error") {
if (desktopPlan.issue.startsWith("record-")) {
console.error(`opencodex: OpenCodex Desktop's terminal-command record at ${desktopCliRecordPath()} could not be used (${desktopPlan.issue}). Open OpenCodex Desktop to repair the terminal command, or run with OCX_NO_DESKTOP_HANDOFF=1.`);
} else {
console.error(`opencodex: Desktop CLI handoff refused (${desktopPlan.issue}). Repair the terminal command in OpenCodex Desktop, or run with OCX_NO_DESKTOP_HANDOFF=1.`);
}
process.exit(1);
}
if (desktopPlan.kind === "handoff") {
const result = await runDesktopCliHandoff(desktopPlan, {
argv: process.argv.slice(2), env: inheritedEnv, proof: launchProof, context: launchContext,
});
if (result.kind === "error") {
console.error(`opencodex: Desktop CLI handoff failed (${result.issue}). Repair the terminal command in OpenCodex Desktop, or run with OCX_NO_DESKTOP_HANDOFF=1.`);
process.exit(127);
}
if (result.kind === "exit") {
if (result.signal) {
process.kill(process.pid, result.signal);
} else {
process.exit(result.code);
}
// Signal delivery is asynchronous; never resume package repair or Bun lookup.
await new Promise(() => {});
Comment on lines +1042 to +1048

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle Node-ignored child signals explicitly

When the Desktop CLI terminates from a signal Node ignores by default, notably SIGPIPE when output is piped to a consumer that closes early, process.kill(process.pid, result.signal) does not terminate this launcher. The unresolved top-level await then makes Node exit normally with status 13 and an unsettled top-level await warning rather than mirroring the child's signal. Special-case signals that cannot be re-raised reliably, for example by mapping them to the conventional 128 + signum exit status or using a helper with default signal dispositions.

Useful? React with 👍 / 👎.

}
// Only confirmed target ENOENT reaches the ordinary package path below.
}

if (process.argv[2] === "update" && installMethod === "mise") {
if (installOwnership.owner) {
console.error(
Expand Down Expand Up @@ -1072,64 +1104,68 @@ const bun = bunRuntime.path;
// billing and prevents it from redirecting the subscriber's OAuth bearer.
// Disabling Bun's dotenv wholesale with --no-env-file is NOT an option: config
// interpolation and provider settings legitimately read the project environment.
const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"]
.filter(name => typeof process.env[name] === "string" && process.env[name] !== "");
// A configured CODEX_CLI_PATH may legitimately be cwd-relative (`./tools/codex`), which the
// ordinary runtime resolver accepts. Inspection only trusts absolute local paths, so capture
// the absolute form here, in the launcher, while the original cwd is still authoritative;
// resolving it later would silently reinterpret it against a different working directory.
//
// A bare command with no separator (`codex`) is NOT a relative path: the runtime resolver
// deliberately hands those to executable lookup along PATH. Rewriting it to `<cwd>/codex`
// would make the inspector treat it as an explicit path and stop searching PATH entirely.
const configuredCodexCliPath = typeof process.env.CODEX_CLI_PATH === "string" && process.env.CODEX_CLI_PATH !== ""
? process.env.CODEX_CLI_PATH
: null;
const preBunCodexCliPath = configuredCodexCliPath !== null
&& (configuredCodexCliPath.includes("/") || configuredCodexCliPath.includes("\\") || /^[A-Za-z]:/.test(configuredCodexCliPath))
? resolve(configuredCodexCliPath)
: configuredCodexCliPath;
const preBunPath = typeof process.env.PATH === "string" ? process.env.PATH : null;
const preBunPathExt = typeof process.env.PATHEXT === "string" ? process.env.PATHEXT : null;
const preBunCodexCliManagerRoots = Object.fromEntries(
CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS.flatMap(name => {
const value = process.env[name];
return typeof value === "string" && value !== "" ? [[name, value]] : [];
}),
);
const launchProof = randomBytes(32).toString("base64url");
const launchContext = JSON.stringify({
version: 1,
proof: launchProof,
anthropicEnvSlots: preBunAnthropicSlots,
codexCliInspectionEnv: codexCliUpdateInspection ? {
codexCliPath: preBunCodexCliPath,
path: preBunPath,
pathExt: preBunPathExt,
managerRoots: preBunCodexCliManagerRoots,
configDir: configDir(),
} : null,
});
// The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as
// proof-bound values, and `inspectCodexCliInstall` reads them from that snapshot rather than
// from the live environment. Inheriting them again would spend the 32,767-character Windows
// environment block twice, so a large-but-valid shell environment could stop the Bun child
// from spawning and fail the command before it reports anything. Drop the duplicates for the
// one-shot inspection launch only; every other launch inherits the environment unchanged.
// Windows environment names are case-insensitive, but this spread produces an ordinary
// case-sensitive object, and a real Windows environment commonly spells the variable `Path`.
// Deleting only the canonical upper-case spelling would silently leave that copy behind and
// reintroduce the duplication this block exists to prevent, so match on the lowercase form.
const inheritedEnv = { ...process.env };
if (codexCliUpdateInspection) {
const snapshotted = new Set(
["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase()),
function createNodeLaunchContext() {
const preBunAnthropicSlots = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"]
.filter(name => typeof process.env[name] === "string" && process.env[name] !== "");
// A configured CODEX_CLI_PATH may legitimately be cwd-relative (`./tools/codex`), which the
// ordinary runtime resolver accepts. Inspection only trusts absolute local paths, so capture
// the absolute form here, in the launcher, while the original cwd is still authoritative;
// resolving it later would silently reinterpret it against a different working directory.
//
// A bare command with no separator (`codex`) is NOT a relative path: the runtime resolver
// deliberately hands those to executable lookup along PATH. Rewriting it to `<cwd>/codex`
// would make the inspector treat it as an explicit path and stop searching PATH entirely.
const configuredCodexCliPath = typeof process.env.CODEX_CLI_PATH === "string" && process.env.CODEX_CLI_PATH !== ""
? process.env.CODEX_CLI_PATH
: null;
const preBunCodexCliPath = configuredCodexCliPath !== null
&& (configuredCodexCliPath.includes("/") || configuredCodexCliPath.includes("\\") || /^[A-Za-z]:/.test(configuredCodexCliPath))
? resolve(configuredCodexCliPath)
: configuredCodexCliPath;
const preBunPath = typeof process.env.PATH === "string" ? process.env.PATH : null;
const preBunPathExt = typeof process.env.PATHEXT === "string" ? process.env.PATHEXT : null;
const preBunCodexCliManagerRoots = Object.fromEntries(
CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS.flatMap(name => {
const value = process.env[name];
return typeof value === "string" && value !== "" ? [[name, value]] : [];
}),
);
for (const name of Object.keys(inheritedEnv)) {
if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name];
const launchProof = randomBytes(32).toString("base64url");
const launchContext = JSON.stringify({
version: 1,
proof: launchProof,
anthropicEnvSlots: preBunAnthropicSlots,
codexCliInspectionEnv: codexCliUpdateInspection ? {
codexCliPath: preBunCodexCliPath,
path: preBunPath,
pathExt: preBunPathExt,
managerRoots: preBunCodexCliManagerRoots,
configDir: configDir(),
} : null,
});
// The inspection snapshot above already carries PATH, PATHEXT, and the manager-root slots as
// proof-bound values, and `inspectCodexCliInstall` reads them from that snapshot rather than
// from the live environment. Inheriting them again would spend the 32,767-character Windows
// environment block twice, so a large-but-valid shell environment could stop the Bun child
// from spawning and fail the command before it reports anything. Drop the duplicates for the
// one-shot inspection launch only; every other launch inherits the environment unchanged.
// Windows environment names are case-insensitive, but this spread produces an ordinary
// case-sensitive object, and a real Windows environment commonly spells the variable `Path`.
// Deleting only the canonical upper-case spelling would silently leave that copy behind and
// reintroduce the duplication this block exists to prevent, so match on the lowercase form.
const inheritedEnv = { ...process.env };
if (codexCliUpdateInspection) {
const snapshotted = new Set(
["PATH", "PATHEXT", ...CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS].map(name => name.toLowerCase()),
);
for (const name of Object.keys(inheritedEnv)) {
if (snapshotted.has(name.toLowerCase())) delete inheritedEnv[name];
}
}
return { launchProof, launchContext, inheritedEnv };
}
const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2)], {

const child = spawn(bun, [cliPath, `${NODE_LAUNCH_PROOF_PREFIX}${launchProof}`, ...process.argv.slice(2).filter(arg => !arg.startsWith(NODE_LAUNCH_PROOF_PREFIX))], {
stdio: "inherit",
// A headless Windows parent (Task Scheduler, dashboard restart, shortcut) has no
// console to inherit. Without this flag Windows allocates a visible console for
Expand Down
64 changes: 64 additions & 0 deletions devlog/_plan/261009_desktop_owned_path_cli/021_wp2_execution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# 021 — wp2 execution plan (re-verified)

wp2 implements 020 as amended by 000 (AM-1, AM-2) and 003 (AM-2 corrected, AM-5, AM-8). It runs on branch `codex/desktop-cli-handoff`, stacked on PR 1 (#6816, `codex/desktop-owned-path-cli` at bcbd0363be). This document and 003 win over 020 where they differ.

## Stale check

| 020 anchor | Current state on `origin/dev` 71c4ae3c8e + PR 1 | Action |
|---|---|---|
| `bin/ocx.mjs` update-help, `codexCliUpdateInspection`, mise/npm update branches, `resolveBun`, launch proof creation | Unchanged (1120 lines). #6807 (head 178f19a6f8) and #6809 (head f22a8b1ba5) are open and rebased on `dev`; both edit this file | Insert the handoff at the semantic point (after the inspection block, before the mise/npm update branch); rebase over #6807/#6809 when they land, anchoring on `const codexCliUpdateInspection` and the following mise `if` |
| `src/cli/status.ts` | #6802 merged (+70 lines: `selectStatusStartupHealth` returns `{ startup, startupSource }`, live supervision) | Add `cliCommand` as a separate additive field computed at `collectStatus` entry; do not touch `startupSource`/`serviceSummary` |
| `src/cli/doctor.ts` | #6802 changed restart-safety source and advice | New "ocx command selection" section after Paths, before response-state sections |
| `structure/runtime.md` | 600 lines (cap); #6802 changed 1 line | Edit the existing entrypoint row with zero line growth |
| `structure/ops/service-and-sidecars.md` | 541 → 541 (#6802 changed 4 lines) | +2 lines as planned |
| `src/service/managing-cli.ts`, `src/cli/launcher-context.ts` | Unchanged | Apply as planned |
| Record schema (wp1 actual) | Rust `Record`/`Bundle` use `camelCase` with `deny_unknown_fields`; fixtures in `tests/fixtures/desktop-cli-record/` (10 files, including `first-pending.json`) | TS reader consumes only `version`, `enabled`, `bundle.platform`, `bundle.kind`, `bundle.cliExecutable`, `pending`; tolerates other fields (the reader is not the writer) |

## Amendments applied in wp2

- **AM-5:** the reader validates `bundle.kind` against the platform pair (`darwin`/`macos-app`, `win32`/`windows-install`, `linux`/`linux-deb`), refuses `.`/`..` segments, NUL, newline and paths over 4096 characters, and is tested against every file in `tests/fixtures/desktop-cli-record/` with the same expectations as the Rust test. `enabled:false` wins over `pending` (no target); enabled + pending is `record-pending`. Diagnostics report `cleanup-pending` for a disabled record with a pending journal.
- **AM-1:** reader bound stays 64 KiB, matching the writer cap.
- **AM-2 (corrected):** the launcher error for `record-invalid`, `record-too-large`, `record-unreadable` and `record-pending` reads: "opencodex: OpenCodex Desktop's terminal-command record at <path> could not be used (<issue>). Open OpenCodex Desktop to repair the terminal command, or run with OCX_NO_DESKTOP_HANDOFF=1." No advice to delete the record.
- **AM-8:** after `spawn` fails with ENOENT, `lstat` the target; only a second ENOENT resumes the package path, otherwise print a fixed error and exit 127. Test: a target script whose interpreter is missing.

## B worker split

| Worker | Files |
|---|---|
| W4 handoff | NEW `src/lib/desktop-cli-record.mjs` + `.d.mts`, NEW `src/lib/desktop-cli-handoff.mjs` + `.d.mts`, MODIFY `bin/ocx.mjs` (hook + proof factory reuse), MODIFY `src/service/managing-cli.ts` (`OCX_NO_DESKTOP_HANDOFF=1` on internal probes), NEW `tests/cli/ocx-launcher-desktop-handoff.test.ts` |
| W5 diagnostics and docs | NEW `src/cli/cli-path-diagnostics.ts`, MODIFY `src/cli/status.ts`, `src/cli/doctor.ts`, NEW `tests/cli/cli-path-diagnostics.test.ts`, MODIFY `structure/runtime.md` (zero growth), `structure/cli-management.md`, `structure/ops/service-and-sidecars.md`, `docs-site/src/content/docs/getting-started/installation.md`, `docs-site/src/content/docs/reference/cli.md` |
| main | registrations of both new tests in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`; integration |

W5 imports the record reader from W4's `src/lib/desktop-cli-record.mjs` using the export names in 020 (`readDesktopCliRecord`, `desktopCliRecordPath`, `DESKTOP_CLI_RECORD_MAX_BYTES`, type `DesktopCliRecordRead`).

## C checks

| Command | Reads the change? |
|---|---|
| `bun test tests/cli/ocx-launcher-desktop-handoff.test.ts tests/cli/cli-path-diagnostics.test.ts` | Yes, both new files and their imports |
| `bun test tests/cli/ocx-launcher-runtime.test.ts tests/cli/ocx-launcher-source.test.ts tests/service/managing-cli.test.ts tests/cli/cli-status-json.test.ts tests/codex-integration/doctor.test.ts` | Yes, existing launcher/probe/status/doctor contracts around the hooks |
| `bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts` | Yes, registrations |
| `node --check bin/ocx.mjs`, `bun run typecheck`, `structure:check`, `privacy:scan`, file-size ratchet | Yes after staging |
| Manual: the dev-checkout `bin/ocx.mjs` run with an isolated `HOME` holding a valid record pointing at a fake executable prints the fake's output; with `OCX_NO_DESKTOP_HANDOFF=1` it does not | Yes, real launcher process |

The A11 compiled check (AM-13) runs in wp3 against PR 1's shim, together with the security review of both PRs.


## Reflection (same architect, 01a11f35): ALIGNED

Folded:

- **Reader result type, fixed before B.** `DesktopCliRecordRead` is `{ state: "missing", path } | { state: "disabled", path, cleanupPending: boolean } | { state: "ready", path, record: { platform, kind, cliExecutable } } | { state: "invalid" | "unreadable", path, issue }`. W4 writes it in `.d.mts`; W5 consumes `cleanupPending` for the `cleanup-pending` diagnostic.
- **Fixture expectations map validity to consumption state**, not pass/fail: `valid-*` on its own host → `ready` (other hosts → `invalid`/`record-invalid`); `disabled-tombstone` → `disabled`, `cleanupPending:false`; `disabled-with-pending` → `disabled`, `cleanupPending:true`; `pending-enabled` and `first-pending` → `invalid`/`record-pending` (valid for Rust recovery, refused for handoff); `appimage-kind`, `relative-target`, `dotdot-target` → `invalid`/`record-invalid`.
- **Path rules match Rust exactly:** length counted in Unicode code points (`[...s].length`), Windows absolute means drive-rooted (`C:\\` or `C:/`) or UNC (`\\\\server\\share`) only; root-relative `\\ocx.exe` and drive-relative `C:ocx.exe` are refused. Extra boundary cases in the TS test: 4096 vs 4097 code points with an astral character, root-relative, drive-relative, UNC.
- **Manual smoke checks both sides:** with the record, `ocx --version` prints the fake target's marker; with `OCX_NO_DESKTOP_HANDOFF=1`, `ocx --version` prints the package version from `package.json` and exits 0.


## Audit (independent, 01a11f6b): NEAR-PASS

Folded:

- Every consumer uses the flattened reader result: `record.cliExecutable`, `record.platform`, `record.kind` (020's `record.bundle.cliExecutable` examples are superseded) in the handoff, diagnostics and test helpers; the real-launcher smoke is required in C because typecheck does not read `.mjs` bodies.
- Windows path rule restated to match Rust exactly: absolute means a drive root (`C:\\` / `C:/`) or a `\\\\` or `//` prefix; the server/share parts are not checked. TS boundary tests include `//server/share/ocx.exe` (accepted), `\\\\server` (accepted, same as Rust), `\\ocx.exe` (refused), `C:ocx.exe` (refused).
- After #6807/#6809 land, PR 2's rebase rereads `structure/runtime.md` (600-line cap), `structure/ops/service-and-sidecars.md`, `structure/cli-management.md` and the installation page, and reruns the launcher/managing-cli tests.

Loading
Loading