diff --git a/.github/workflows/catalog-async-contracts.yml b/.github/workflows/catalog-async-contracts.yml new file mode 100644 index 00000000000..c4b0e833373 --- /dev/null +++ b/.github/workflows/catalog-async-contracts.yml @@ -0,0 +1,57 @@ +name: Catalog async contracts + +on: + push: + branches: [dev, main, preview] + paths: + - src/** + - scripts/diagnostics/windows-version-control/** + - .github/actions/setup-project-bun/** + - .github/workflows/catalog-async-contracts.yml + - package.json + - bun.lock + pull_request: + paths: + - src/** + - scripts/diagnostics/windows-version-control/** + - .github/actions/setup-project-bun/** + - .github/workflows/catalog-async-contracts.yml + - package.json + - bun.lock + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: catalog-async-contracts-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + contracts: + name: Catalog async contracts (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + - uses: ./.github/actions/setup-project-bun + with: + role: runtime + - uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master snapshot; the action publishes no release tags, so the toolchain is pinned explicitly + with: + toolchain: 1.99.0 + components: rustfmt, clippy + - name: Install repository dependencies + run: bun install --frozen-lockfile + - name: Check formatting + run: cargo fmt --manifest-path scripts/diagnostics/windows-version-control/Cargo.toml --check + - name: Check Rust contracts + run: cargo clippy --locked --manifest-path scripts/diagnostics/windows-version-control/Cargo.toml --all-targets -- -D warnings + - name: Run Bun-module contracts through the portable Rust fixture + run: cargo test --locked --manifest-path scripts/diagnostics/windows-version-control/Cargo.toml --test async_contracts -- --test-threads=4 diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 4cd3380ae18..6ead0be8613 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -9,6 +9,9 @@ * src/cli/index.ts — only the published npm/pnpm `bin` routes through here.) */ 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"; @@ -34,6 +37,7 @@ import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { isRealBunBinary } from "../src/lib/bun-binary-validator.mjs"; +import { findDesktopCli, findPathBun } from "../src/lib/bun-path-runtime.mjs"; import { npmInvocation } from "../src/update/npm-invocation.mjs"; import { pnpmInvocationForPath, resolvePnpmCommands } from "../src/update/pnpm-invocation.mjs"; import { detectInstallOwnershipFromPath } from "../src/update/install-detection.mjs"; @@ -95,6 +99,15 @@ function currentPackageVersion() { } } +function pinnedBunVersion() { + try { + const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")); + return typeof pkg.dependencies?.bun === "string" ? pkg.dependencies.bun : ""; + } catch { + return ""; + } +} + function updateTag(currentVersion) { // Allowlist the tag: the value is argv-controlled and (on Windows) flows into a // shell-joined spawnSync — never forward arbitrary strings. @@ -323,11 +336,13 @@ function runPackageManagerSelfUpdate(manager) { : JSON.stringify(observation.ownership ? ["owned", observation.ownership.owner, observation.ownership.installId, observation.ownership.consentGeneration] : ["none"]); + const supervisionLatch = createSupervisionLatch(); + const observeSupervision = () => supervisionLatch.observe(inspectDesktopSupervision()); const initialOwnership = readOwnership(); - let runtimePlan = planUpdateRuntimeHandling({ ...initialOwnership, serviceInstalled: serviceWasInstalled }); + let runtimePlan = planUpdateRuntimeHandling({ ...initialOwnership, serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); if (runtimePlan.notice) console.log(runtimePlan.notice); if (!runtimePlan.mayReplacePackage) { - console.error("opencodex: update stopped before tray handoff, runtime stop, or package replacement because runtime ownership is unknown."); + console.error("opencodex: update stopped before tray handoff, runtime stop, or package replacement because runtime authority does not permit it."); process.exit(1); } const trayBeforeUpdate = planWindowsTrayUpdate( @@ -456,6 +471,14 @@ function runPackageManagerSelfUpdate(manager) { : unprivilegedOwnershipMutationEnvironment(process.env); function startProxyDirectly() { + const supervision = observeSupervision(); + const plan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: false, supervision }); + if (!plan.mayStopRuntime) { + console.warn(supervision + ? "OpenCodex Desktop supervises the proxy; no CLI runtime was restored." + : plan.notice); + return false; + } if (!postUpdateLauncherUsable || !existsSync(postUpdateLauncher)) { console.error("opencodex: cannot restart the proxy because the launcher is missing; reinstall opencodex manually."); return false; @@ -488,6 +511,15 @@ function runPackageManagerSelfUpdate(manager) { } function refreshBackgroundServiceOrStartDirect() { + const mayRefresh = () => { + const supervision = observeSupervision(); + const plan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: true, supervision }); + if (!plan.mayRestoreService) console.warn(supervision + ? "OpenCodex Desktop supervises the proxy; CLI recovery was skipped. Use the app's updater (tray → Check for Updates)." + : plan.notice); + return plan.mayRestoreService; + }; + if (!mayRefresh()) return; const prevBake = process.env.OCX_BAKE_PORT; process.env.OCX_BAKE_PORT = String(bakePort); try { @@ -503,6 +535,7 @@ function runPackageManagerSelfUpdate(manager) { // failure would resurrect the elevation prompt this change exists to avoid, and // could re-register a service the user just uninstalled. if (svc.status !== 0 && readServiceInstalledFromStatus(postUpdateLauncher) === false) { + if (!mayRefresh()) return; console.log("No registered service found — installing it instead."); svc = spawnSync(process.execPath, serviceInstallArgs(), { stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), @@ -535,9 +568,12 @@ function runPackageManagerSelfUpdate(manager) { // Re-read rather than reuse the plan from before the package install: the app can // claim the runtime during an update that takes minutes, and the refusal that repair // just returned is indistinguishable from any other failure at this layer. - const nowOwned = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: true }); + const supervision = observeSupervision(); + const nowOwned = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: true, supervision }); if (!nowOwned.mayStopRuntime) { - console.warn(nowOwned.notice ?? "opencodex: the background runtime is owned elsewhere; not starting a second proxy."); + console.warn(supervision + ? "OpenCodex Desktop supervises the proxy; CLI recovery was skipped. Use the app's updater (tray → Check for Updates)." + : nowOwned.notice ?? "opencodex: the background runtime is owned elsewhere; not starting a second proxy."); return; } // Repair normally avoids elevation for a healthy registration, but a stale Windows @@ -574,7 +610,7 @@ function runPackageManagerSelfUpdate(manager) { // Stop authority is decided under the same lease the child joins. A takeover between the // earlier preflight and this boundary therefore blocks stop before it is sent. const lockedOwnership = readOwnership(); - const lockedPlan = planUpdateRuntimeHandling({ ...lockedOwnership, serviceInstalled: serviceWasInstalled }); + const lockedPlan = planUpdateRuntimeHandling({ ...lockedOwnership, serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); if (lockedOwnership.subjectToken !== initialOwnership.subjectToken || !lockedPlan.mayReplacePackage) { releaseUpdateLease(); console.error(lockedPlan.notice @@ -616,6 +652,7 @@ function runPackageManagerSelfUpdate(manager) { liveness, plan: planStoppedRuntimeRecovery({ stopAttempted, + supervision: observeSupervision(), ...recoveryOwnership, sameOwner: ownershipIdentity(recoveryOwnership) === stoppedOwnershipIdentity, liveness, @@ -633,7 +670,9 @@ function runPackageManagerSelfUpdate(manager) { releaseUpdateLease(); ({ liveness: recoveryLiveness, plan: recovery } = planRecovery()); } - if (recovery.reason === "ownership-unknown") { + if (recovery.reason === "desktop-supervised") { + console.log("OpenCodex Desktop supervises the proxy; no CLI runtime was restored."); + } else if (recovery.reason === "ownership-unknown") { console.error(`opencodex: ${reason}; runtime ownership is unknown, so automatic recovery was refused. Run 'ocx status --json' and repair the service-state record before retrying.`); } else if (recovery.reason === "ownership-transferred") { console.log("opencodex: runtime ownership moved to another installation; the stopped CLI runtime was not revived."); @@ -666,6 +705,14 @@ function runPackageManagerSelfUpdate(manager) { process.exit(1); } if (stopNeeded) { + const preStopPlan = planUpdateRuntimeHandling({ + ...readOwnership(), serviceInstalled: serviceWasInstalled, supervision: observeSupervision(), + }); + if (!preStopPlan.mayStopRuntime) { + console.error(preStopPlan.notice); + releaseUpdateLease(); + process.exit(1); + } stopAttempted = true; console.log("⏹ Stopping the running proxy before updating..."); const stopRes = spawnSync(process.execPath, [launcher, "stop"], { @@ -722,7 +769,7 @@ function runPackageManagerSelfUpdate(manager) { } const replacementOwnership = readOwnership(); - const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled }); + const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); const replacementLiveness = currentPackageRuntimeLiveness(); if (replacementOwnership.subjectToken !== initialOwnership.subjectToken || !replacementPlan.mayReplacePackage @@ -829,9 +876,10 @@ function runPackageManagerSelfUpdate(manager) { // path and keeps token restoration coupled to the lease itself. releaseUpdateLease(); } - const postInstallPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); + const postInstallPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled, supervision: observeSupervision() }); if (res.status === 0) { console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`); + if (!postInstallPlan.mayStopRuntime) console.warn("Runtime authority does not permit CLI recovery; no service was refreshed or proxy started."); repairCodexShimIfNeeded(postUpdateLauncher); if (trayBeforeUpdate.refreshAfterReplacement) { const tray = spawnSync(process.execPath, [postUpdateLauncher, ...trayBeforeUpdate.installArgs], { @@ -884,6 +932,8 @@ const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH"; // imported; tests/cli/ocx-launcher-source.test.ts pins the two together. const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE"; const BUN_RUNTIME_PATH_ENV = "OCX_BUN_RUNTIME_PATH"; +/** Total budget for validating a PATH Bun fallback (both probes together). */ +const PATH_BUN_PROBE_BUDGET_MS = 5_000; function findBunBinary(bunDir) { // The bundled `bun` package ships the binary as bin/bun.exe on every platform; @@ -899,6 +949,7 @@ function fail(msg) { const reinstall = installMethod === "pnpm" ? "pnpm add -g --allow-build=bun @bitkyc08/opencodex" : "npm install -g --allow-scripts=bun @bitkyc08/opencodex"; + const desktopCli = findDesktopCli(); console.error( `opencodex: ${msg}\n` + "The bundled Bun runtime could not be prepared. This usually means the\n" + @@ -906,7 +957,8 @@ function fail(msg) { "or pnpm did not approve bun's build) or optional dependencies. Reinstall with:\n" + ` ${reinstall}\n` + "(use sudo if the original install used sudo; without --ignore-scripts\n" + - "and without --omit=optional / optional=false)" + "and without --omit=optional / optional=false)" + + (desktopCli ? `\nAn installed Desktop CLI is available: "${desktopCli}"` : "") ); process.exit(1); } @@ -923,25 +975,32 @@ function resolveBun({ allowInstall = true } = {}) { ); } - let bunDir; + let bunDir = null; try { bunDir = bunBinDir(); - } catch { - fail("the `bun` dependency is not installed."); - } + } catch { /* Missing dependency can still fall back to a validated PATH Bun. */ } - let bin = findBunBinary(bunDir); + let bin = bunDir ? findBunBinary(bunDir) : null; if (bin) return { path: bin, source: "bundled" }; // Lazy fallback: --ignore-scripts (or a failed postinstall) leaves the // ~450-byte placeholder stub. Run the bun package's own installer once. - const installJs = join(bunDir, "install.js"); - if (allowInstall && existsSync(installJs)) { + const installJs = bunDir ? join(bunDir, "install.js") : null; + if (allowInstall && installJs && existsSync(installJs)) { const r = spawnSync(process.execPath, [installJs], { stdio: "inherit" }); if (r.status === 0) bin = findBunBinary(bunDir); } - if (!bin) fail("Bun binary missing after install attempt."); - return { path: bin, source: "bundled" }; + if (bin) return { path: bin, source: "bundled" }; + + // Reached only when the bundled runtime is unusable. The two probes start the candidate Bun + // cold, and on Windows a first run of a copied bun.exe is often held by an on-access scan for + // well over a second, so the total budget is generous rather than interactive-tight. + const pathBun = findPathBun({ pinnedVersion: pinnedBunVersion(), deadlineMs: PATH_BUN_PROBE_BUDGET_MS }); + if (pathBun) { + console.error(`opencodex: using PATH Bun ${pathBun.version}.`); + return { path: pathBun.path, source: "process" }; + } + fail(bunDir ? "Bun binary missing after install attempt." : "the `bun` dependency is not installed."); } // `ocx update --help` prints usage and exits WITHOUT side effects. The Node launcher @@ -961,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(() => {}); + } + // Only confirmed target ENOENT reaches the ordinary package path below. +} + if (process.argv[2] === "update" && installMethod === "mise") { if (installOwnership.owner) { console.error( @@ -1015,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 `/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 `/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 diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 180ea4c168e..d2486a72e5a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.81.0" +version = "2.82.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 95e4134d16b..b2676e5f3ca 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.81.0" +version = "2.82.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/src/cli_command.rs b/desktop/src-tauri/src/cli_command.rs new file mode 100644 index 00000000000..c701fed923b --- /dev/null +++ b/desktop/src-tauri/src/cli_command.rs @@ -0,0 +1,765 @@ +#[cfg(unix)] +use crate::cli_command_posix as posix; +use crate::{ + cli_command_record::{self as record, Bundle, Record, Result, Store}, + cli_command_windows as windows, +}; +use serde::Serialize; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, +}; +use tauri::{AppHandle, Manager}; + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Status { + pub enabled: bool, + pub configured: bool, + pub phase: String, + pub expected_executable: Option, + pub issues: Vec, +} +impl Default for Status { + fn default() -> Self { + Self { + enabled: true, + configured: false, + phase: "unobserved".into(), + expected_executable: None, + issues: Vec::new(), + } + } +} +#[derive(Default)] +pub struct State { + scheduled: AtomicBool, + serial: Mutex<()>, + latest: Mutex, +} +#[derive(Clone, Copy)] +pub enum Action { + Reconcile, + Repair, + Enable(bool), + Remove, +} +pub fn status(app: &AppHandle) -> Status { + app.state::() + .latest + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() +} +fn perform(app: &AppHandle, action: Action) -> Result { + let home = app.path().home_dir().map_err(|_| "home-unavailable")?; + if !home.is_absolute() { + return Err("home-unavailable".into()); + } + record::check(&home, true)?; + let root = home.join(".opencodex-desktop"); + let exists = root.try_exists().map_err(|_| "io-failed")?; + // First run checks the bundle before creating even the record directory (AM-10). + let missing = !exists + || !root + .join("cli.json") + .try_exists() + .map_err(|_| "io-failed")?; + let disabled = matches!(action, Action::Remove | Action::Enable(false)); + let observed = if missing && !disabled { + Some(observe_bundle(app)?) + } else { + None + }; + let initial = if missing { + let mut r = match &observed { + Some(b) => Record::fresh(b.clone()), + None => Record { + version: 1, + owner_id: uuid::Uuid::new_v4().to_string(), + install_id: String::new(), + generation: 1, + enabled: false, + bundle: None, + posix: None, + windows: None, + notify_pending: false, + pending: None, + }, + }; + r.install_id = crate::identity::install_id(app).ok_or("install-id-unavailable")?; + Some(r) + } else { + None + }; + perform_selected( + root, + &home, + action, + initial, + || match observed { + Some(b) => Ok(b), + None => observe_bundle(app), + }, + |enabled| { + app.state::() + .latest + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .enabled = enabled; + }, + ) +} +fn perform_selected( + root: std::path::PathBuf, + home: &std::path::Path, + action: Action, + initial: Option, + observe: impl FnOnce() -> Result, + publish_enabled: impl FnOnce(bool), +) -> Result { + let mut store = Store::open(root, Vec::new())?; + let current = store.read()?; + let enabled = match action { + Action::Remove | Action::Enable(false) => false, + Action::Enable(true) => true, + _ => current + .as_ref() + .or(initial.as_ref()) + .is_some_and(|r| r.enabled), + }; + #[cfg(unix)] + let selected = if enabled { + posix::targets(home)? + } else { + Vec::new() + }; + #[cfg(not(unix))] + let selected: Vec<(String, std::path::PathBuf)> = { + let _ = enabled; + Vec::new() + }; + store.rc_allowed = selected.iter().map(|(_, p)| p.clone()).collect(); + perform_in( + &store, + home, + selected, + action, + initial, + observe, + publish_enabled, + ) +} +fn observe_bundle(app: &AppHandle) -> Result { + let exe = std::env::current_exe().map_err(|_| "bundle-unavailable")?; + let version = app.package_info().version.to_string(); + #[cfg(unix)] + { + posix::stable_bundle(&exe, cfg!(debug_assertions), &version) + } + #[cfg(windows)] + { + windows::stable_bundle(&exe, cfg!(debug_assertions), &version) + } +} +fn perform_in( + store: &Store, + home: &std::path::Path, + selected: Vec<(String, std::path::PathBuf)>, + action: Action, + initial: Option, + observe: impl FnOnce() -> Result, + publish_enabled: impl FnOnce(bool), +) -> Result { + // Initial/persisted records contain ownership history, never evidence of this launch. + let mut r = match store.read()? { + Some(r) => r, + None => initial.ok_or("bundle-unavailable")?, + }; + let explicit_remove = matches!(action, Action::Remove | Action::Enable(false)); + let desired = match action { + Action::Enable(v) => Some(v), + Action::Remove => Some(false), + _ => None, + }; + let install_bundle = if desired == Some(true) || (desired != Some(false) && r.enabled) { + Some(observe()?) + } else { + None + }; + if desired == Some(true) { + r.bundle = install_bundle.clone(); + } + if let Some(enabled) = desired { + if r.enabled != enabled { + r.enabled = enabled; + r.generation += 1; + if let Some(j) = &mut r.pending { + j.next.enabled = enabled; + j.next.generation = r.generation + 1; + } + } + // Off is durable even if journal conflict prevents this cleanup attempt. + store.save(&r)?; + } + publish_enabled(r.enabled); + // A disabled interrupted install rolls back its completed prefix. It never finishes installing. + store.recover(&mut r)?; + // Retry settled/recovery debt before target planning, which may itself fail. + let mut notify_issues = Vec::new(); + if let Err(e) = store.notify_with(&mut r, windows::notify) { + notify_issues.push(e); + } + let remove = explicit_remove || (!r.enabled && matches!(action, Action::Reconcile)); + if !remove && !r.enabled { + return Ok(Status { + enabled: false, + phase: if notify_issues.is_empty() { + "disabled" + } else { + "partial" + } + .into(), + issues: notify_issues, + ..Status::default() + }); + } + let (next, changes, mut issues) = if remove { + #[cfg(unix)] + { + posix::remove_plan(store, &r)? + } + #[cfg(windows)] + { + if let Some(b) = r.bundle.clone() { + windows::plan(&r, b, true)? + } else { + (r.clone(), Vec::new(), Vec::new()) + } + } + } else { + let b = install_bundle.ok_or("bundle-unavailable")?; + #[cfg(unix)] + { + posix::plan(store, &r, b, home, selected)? + } + #[cfg(windows)] + { + let _ = (home, selected); + windows::plan(&r, b, false)? + } + }; + let registry_changed = changes.iter().any(|c| c.kind.starts_with("registry-")); + store.transact( + &mut r, + next, + changes, + if remove { "remove" } else { "install" }, + )?; + issues.extend(notify_issues); + issues.extend(store.journal_issues(&r)); + if registry_changed { + if let Err(e) = store.notify_with(&mut r, windows::notify) { + issues.push(e); + } + } + let configured = r.enabled + && r.bundle.is_some() + && (r.posix.is_some() || r.windows.is_some()) + && issues.is_empty(); + let phase = if !issues.is_empty() { + "partial" + } else if !r.enabled { + "disabled" + } else if configured { + "configured" + } else { + "unobserved" + }; + Ok(Status { + enabled: r.enabled, + configured, + phase: phase.into(), + expected_executable: r.bundle.as_ref().map(|b| b.cli_executable.clone()), + issues, + }) +} +fn run(app: &AppHandle, action: Action) -> Status { + let state = app.state::(); + let _serial = state + .serial + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let result = match perform(app, action) { + Ok(v) => v, + Err(code) => { + crate::logging::log_once("terminal command", &code); + let mut v = status(app); + v.configured = false; + v.phase = if v.enabled { "blocked" } else { "partial" }.into(); + v.issues = if v.enabled { + vec![code] + } else { + vec![code, "cleanup-pending".into()] + }; + v + } + }; + *state + .latest + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = result.clone(); + result +} +async fn execute(app: AppHandle, action: Action) -> Result { + let worker_app = app.clone(); + match tauri::async_runtime::spawn_blocking(move || run(&worker_app, action)).await { + Ok(v) => Ok(v), + Err(_) => { + let mut v = status(&app); + v.configured = false; + v.phase = "blocked".into(); + v.issues = vec!["worker-failed".into()]; + *app.state::() + .latest + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = v.clone(); + Ok(v) + } + } +} +pub fn reconcile_on_launch(app: &AppHandle) { + if app.state::().scheduled.swap(true, Ordering::AcqRel) { + return; + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if execute(app, Action::Reconcile).await.is_err() { + crate::logging::log_once("terminal command", "worker-failed"); + } + }); +} +pub async fn set_enabled(app: AppHandle, enabled: bool) -> Result { + execute(app, Action::Enable(enabled)).await +} +pub async fn install(app: AppHandle) -> Result { + execute(app, Action::Repair).await +} +pub async fn remove(app: AppHandle) -> Result { + execute(app, Action::Remove).await +} +pub fn show_page(app: &AppHandle) { + crate::popup::hide(app); + if let Some(w) = app.get_webview_window("main") { + // Fixed local URL; this works before a proxy exists, like the bundled update page. + let origin = if cfg!(target_os = "windows") { + "http://tauri.localhost/cli.html" + } else { + "tauri://localhost/cli.html" + }; + if tauri::Url::parse(origin) + .ok() + .is_some_and(|url| w.navigate(url).is_ok()) + { + crate::window::show(&w); + } else { + crate::logging::log_once("terminal command", "page-unavailable"); + } + } +} + +#[cfg(test)] +mod bootstrap_tests { + use super::*; + use record::tests::{bundle, Temp}; + #[test] + fn first_off_and_remove_persist_disabled_intent_without_observing_a_bundle() { + for action in [Action::Enable(false), Action::Remove] { + let t = Temp::new(); + let root = t.0.join("record"); + let mut initial = Record::fresh(bundle()); + initial.enabled = false; + initial.bundle = None; + let status = perform_selected( + root.clone(), + &t.0, + action, + Some(initial), + || panic!("disabled-must-not-observe-bundle"), + |_| {}, + ) + .unwrap(); + assert!(!status.enabled); + let store = Store::open(root, vec![]).unwrap(); + let r = store.read().unwrap().unwrap(); + assert!(!r.enabled && r.bundle.is_none()); + } + } +} + +#[cfg(all(test, windows))] +mod windows_tests { + use super::*; + use record::tests::{bundle, Temp}; + #[test] + fn selected_entry_refuses_unstable_bundle_before_registry_access() { + for action in [Action::Reconcile, Action::Repair, Action::Enable(true)] { + for error in ["development-launch", "temporary-bundle"] { + let t = Temp::new(); + let store = t.store(); + store.save(&Record::fresh(bundle())).unwrap(); + let before = std::fs::read(store.root.join("cli.json")).unwrap(); + let root = store.root.clone(); + drop(store); + let calls = std::cell::Cell::new(0); + let result = perform_selected( + root.clone(), + &t.0, + action, + None, + || { + calls.set(calls.get() + 1); + Err(error.into()) + }, + // A regressed observation shortcut must stop before reaching real registry IO. + |_| assert_eq!(calls.get(), 1, "bundle-observation-skipped"), + ); + assert_eq!(calls.get(), 1, "bundle-observation-skipped"); + assert_eq!(result.unwrap_err(), error); + assert!( + std::fs::read(root.join("cli.json")).unwrap() == before, + "unstable-bundle-changed-record" + ); + } + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use record::tests::{bundle, Temp}; + fn selected(t: &Temp) -> Vec<(String, std::path::PathBuf)> { + vec![("zsh".into(), t.0.join(".zshrc"))] + } + fn selected_observation_case(case: &str) { + let t = Temp::new(); + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "cli_command::tests::selected_observation_child", + "--nocapture", + ]) + .env("OCX_CLI_OBSERVATION_TEST", case) + .env("HOME", &t.0) + .env("ZDOTDIR", &t.0) + .env("XDG_CONFIG_HOME", t.0.join(".config")) + .output() + .unwrap(); + assert!(result.status.success(), "selected-observation-failed"); + assert!( + String::from_utf8_lossy(&result.stdout).contains("1 passed"), + "selected-observation-not-executed" + ); + } + #[test] + fn selected_entry_refreshes_bundle_path_version_and_shim() { + for action in ["reconcile", "repair", "enable"] { + selected_observation_case(&format!("update-{action}")); + } + } + #[test] + fn selected_entry_refuses_unstable_bundle_and_preserves_existing_files() { + for action in ["reconcile", "repair", "enable"] { + for error in ["development-launch", "temporary-bundle"] { + selected_observation_case(&format!("{error}-{action}")); + } + } + } + #[test] + fn selected_observation_child() { + let Ok(case) = std::env::var("OCX_CLI_OBSERVATION_TEST") else { + return; + }; + let home = std::path::PathBuf::from(std::env::var_os("HOME").unwrap()); + let rc = home.join(".zshrc"); + let root = home.join("record"); + let store = Store::open(root.clone(), vec![rc.clone()]).unwrap(); + perform_in( + &store, + &home, + vec![("zsh".into(), rc.clone())], + Action::Reconcile, + Some(Record::fresh(bundle())), + || Ok(bundle()), + |_| {}, + ) + .unwrap(); + let snapshots: Vec<_> = [ + root.join("cli.json"), + root.join("bin/ocx"), + root.join("path.sh"), + rc, + ] + .into_iter() + .map(|path| { + let bytes = std::fs::read(&path).unwrap(); + (path, bytes) + }) + .collect(); + drop(store); + let (scenario, action) = case.rsplit_once('-').unwrap(); + let action = match action { + "reconcile" => Action::Reconcile, + "repair" => Action::Repair, + "enable" => Action::Enable(true), + _ => panic!("observation-test-invalid"), + }; + let calls = std::cell::Cell::new(0); + let mut moved = bundle(); + moved.app_executable = home + .join("Moved.app/Contents/MacOS/app") + .to_string_lossy() + .into_owned(); + moved.cli_executable = home + .join("Moved.app/Contents/MacOS/ocx") + .to_string_lossy() + .into_owned(); + moved.version = "2".into(); + let result = perform_selected( + root.clone(), + &home, + action, + None, + || { + calls.set(calls.get() + 1); + if scenario == "update" { + Ok(moved.clone()) + } else { + Err(scenario.into()) + } + }, + |_| {}, + ); + assert_eq!(calls.get(), 1, "bundle-observation-skipped"); + if scenario == "update" { + result.unwrap(); + let store = Store::open(root.clone(), vec![]).unwrap(); + let r = store.read().unwrap().unwrap(); + assert!(r.bundle.as_ref() == Some(&moved), "bundle-not-refreshed"); + let expected = + posix::render_shim(std::path::Path::new(&moved.cli_executable), &r.owner_id) + .unwrap(); + assert!( + std::fs::read(root.join("bin/ocx")).unwrap() == expected.as_bytes(), + "shim-target-not-refreshed" + ); + } else { + assert_eq!(result.unwrap_err(), scenario); + for (path, bytes) in snapshots { + assert!( + std::fs::read(path).unwrap() == bytes, + "unstable-bundle-changed-files" + ); + } + assert!(!home.join(".zlogin").exists()); + assert!(!home.join(".bashrc").exists()); + assert!(!home.join(".config/fish/config.fish").exists()); + } + } + fn disabled_selector_case(action: &str) { + let result = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "cli_command::tests::disabled_selector_child", + "--nocapture", + ]) + .env("OCX_CLI_SELECTOR_TEST", action) + .env("ZDOTDIR", "/invalid:zdotdir") + .output() + .unwrap(); + assert!(result.status.success(), "disabled-selector-failed"); + assert!( + String::from_utf8_lossy(&result.stdout).contains("1 passed"), + "disabled-selector-not-executed" + ); + } + #[test] + fn remove_uses_recorded_targets_with_unrepresentable_zdotdir() { + disabled_selector_case("remove"); + } + #[test] + fn off_uses_recorded_targets_with_unrepresentable_zdotdir() { + disabled_selector_case("off"); + } + #[test] + fn disabled_reconcile_uses_recorded_targets_with_unrepresentable_zdotdir() { + disabled_selector_case("reconcile"); + } + #[test] + fn disabled_selector_child() { + let Ok(action) = std::env::var("OCX_CLI_SELECTOR_TEST") else { + return; + }; + let t = Temp::new(); + assert_eq!(posix::targets(&t.0).unwrap_err(), "path-unrepresentable"); + let store = t.store(); + perform_in( + &store, + &t.0, + selected(&t), + Action::Reconcile, + Some(Record::fresh(bundle())), + || Ok(bundle()), + |_| {}, + ) + .unwrap(); + let action = match action.as_str() { + "remove" => Action::Remove, + "off" => Action::Enable(false), + "reconcile" => { + let mut r = store.read().unwrap().unwrap(); + r.enabled = false; + store.save(&r).unwrap(); + Action::Reconcile + } + _ => panic!("selector-test-invalid"), + }; + let root = store.root.clone(); + drop(store); + let status = perform_selected( + root.clone(), + &t.0, + action, + None, + || panic!("disabled-must-not-observe-bundle"), + |_| {}, + ) + .unwrap(); + assert!(!status.enabled); + assert!(!t.0.join(".zshrc").exists()); + assert!(!root.join("bin/ocx").exists()); + assert!(!root.join("path.sh").exists()); + } + #[test] + fn fresh_stable_bundle_installs_without_npm() { + let t = Temp::new(); + let store = t.store(); + let result = perform_in( + &store, + &t.0, + selected(&t), + Action::Reconcile, + Some(Record::fresh(bundle())), + || Ok(bundle()), + |_| {}, + ) + .unwrap(); + assert!(result.enabled && result.configured); + let r = store.read().unwrap().unwrap(); + assert!(r.bundle.is_some() && r.pending.is_none()); + assert!(store.root.join("bin/ocx").is_file()); + let generation = r.generation; + perform_in( + &store, + &t.0, + selected(&t), + Action::Reconcile, + None, + || Ok(bundle()), + |_| {}, + ) + .unwrap(); + assert_eq!(store.read().unwrap().unwrap().generation, generation); + } + #[test] + fn disabled_record_survives_relaunch_repair_and_tombstone_can_be_enabled() { + let t = Temp::new(); + let store = t.store(); + let mut r = Record::fresh(bundle()); + r.enabled = false; + r.bundle = None; + store.save(&r).unwrap(); + for action in [Action::Reconcile, Action::Repair] { + let result = perform_in( + &store, + &t.0, + selected(&t), + action, + None, + || panic!("off does not observe an install target"), + |_| {}, + ) + .unwrap(); + assert!(!result.enabled); + assert_eq!(result.phase, "disabled"); + assert!(!store.root.join("bin/ocx").exists()); + } + let result = perform_in( + &store, + &t.0, + selected(&t), + Action::Enable(true), + None, + || Ok(bundle()), + |_| {}, + ) + .unwrap(); + assert!(result.configured); + } + #[test] + fn remove_persists_disabled_intent_before_first_cleanup_write() { + let t = Temp::new(); + let store = t.store(); + perform_in( + &store, + &t.0, + selected(&t), + Action::Reconcile, + Some(Record::fresh(bundle())), + || Ok(bundle()), + |_| {}, + ) + .unwrap(); + let shim = store.root.join("bin/ocx"); + let result = perform_in( + &store, + &t.0, + selected(&t), + Action::Remove, + None, + || panic!("remove has no bundle gate"), + |enabled| { + assert!(!enabled); + assert!(!store.read().unwrap().unwrap().enabled); + assert!(shim.exists()); + }, + ) + .unwrap(); + assert!(!result.enabled); + assert_eq!(result.phase, "disabled"); + assert!(!shim.exists()); + } + #[test] + fn unstable_bundle_keeps_existing_record_and_files_unchanged() { + let t = Temp::new(); + let store = t.store(); + let r = Record::fresh(bundle()); + store.save(&r).unwrap(); + let before = std::fs::read(store.root.join("cli.json")).unwrap(); + assert_eq!( + perform_in( + &store, + &t.0, + selected(&t), + Action::Reconcile, + None, + || Err("development-launch".into()), + |_| {} + ) + .unwrap_err(), + "development-launch" + ); + assert_eq!(std::fs::read(store.root.join("cli.json")).unwrap(), before); + } +} diff --git a/desktop/src-tauri/src/cli_command_posix.rs b/desktop/src-tauri/src/cli_command_posix.rs new file mode 100644 index 00000000000..acad8798c87 --- /dev/null +++ b/desktop/src-tauri/src/cli_command_posix.rs @@ -0,0 +1,1142 @@ +use crate::cli_command_record::{ + self as record, Bundle, Change, OwnedFile, Posix, RcFile, Record, Result, Store, +}; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +pub const START: &str = "# >>> OpenCodex Desktop ocx PATH v1 >>>"; +pub const END: &str = "# <<< OpenCodex Desktop ocx PATH v1 <<<"; +const SHIM: &str = r#"#!/bin/sh +# OpenCodex Desktop ocx shim v1 +# owner-id: @OWNER@ +_ocx_cli=@CLI@ +if [ ! -f "$_ocx_cli" ] || [ ! -x "$_ocx_cli" ]; then + printf '%s\n' 'OpenCodex Desktop CLI is unavailable. Repair or remove the terminal command in Desktop.' >&2 + exit 127 +fi +unset OCX_NODE_LAUNCH_CONTEXT OCX_PRE_BUN_ANTHROPIC_ENV +_ocx_proof= +if [ -r /dev/urandom ] && [ -x /usr/bin/od ] && [ -x /usr/bin/awk ]; then + _ocx_proof=$(/usr/bin/od -An -tu1 -N32 /dev/urandom 2>/dev/null | /usr/bin/awk ' + BEGIN { a="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"; n=0; bits=0; count=0; out="" } + { for (i=1; i<=NF; i++) { + if ($i !~ /^[0-9]+$/ || $i < 0 || $i > 255) exit 1 + count++; n=n*256+$i; bits+=8 + while (bits>=6) { bits-=6; q=int(n/(2^bits)); out=out substr(a,q+1,1); n=n-q*(2^bits) } + } } + END { if (count!=32) exit 1; if (bits) out=out substr(a,n*(2^(6-bits))+1,1); print out } + ') || _ocx_proof= +fi +case "$_ocx_proof" in + *[!A-Za-z0-9_-]*) _ocx_proof= ;; +esac +if [ "${#_ocx_proof}" -eq 43 ]; then + _ocx_slots= + _ocx_sep= + if [ -n "${ANTHROPIC_API_KEY-}" ]; then + _ocx_slots='"ANTHROPIC_API_KEY"'; _ocx_sep=, + fi + if [ -n "${ANTHROPIC_AUTH_TOKEN-}" ]; then + _ocx_slots=$_ocx_slots$_ocx_sep'"ANTHROPIC_AUTH_TOKEN"'; _ocx_sep=, + fi + if [ -n "${ANTHROPIC_BASE_URL-}" ]; then + _ocx_slots=$_ocx_slots$_ocx_sep'"ANTHROPIC_BASE_URL"' + fi + OCX_NODE_LAUNCH_CONTEXT='{"version":1,"proof":"'$_ocx_proof'","anthropicEnvSlots":['$_ocx_slots'],"codexCliInspectionEnv":null}' + export OCX_NODE_LAUNCH_CONTEXT + exec "$_ocx_cli" "--ocx-internal-launch-proof=$_ocx_proof" "$@" +fi +exec "$_ocx_cli" "$@" +"#; +const HELPER: &str = r#"# OpenCodex Desktop ocx PATH helper v1 +_ocx_desktop_path_v1() ( + _ocx_bin=@BIN@ + _ocx_new=$_ocx_bin + _ocx_rest=${PATH-} + if [ "${PATH+x}" = x ]; then + while :; do + case "$_ocx_rest" in + *:*) _ocx_part=${_ocx_rest%%:*}; _ocx_rest=${_ocx_rest#*:}; _ocx_last=0 ;; + *) _ocx_part=$_ocx_rest; _ocx_last=1 ;; + esac + if [ "$_ocx_part" != "$_ocx_bin" ]; then _ocx_new=$_ocx_new:$_ocx_part; fi + [ "$_ocx_last" = 1 ] && break + done + fi + printf '%s' "$_ocx_new" +) +PATH=$(_ocx_desktop_path_v1) +export PATH +unset -f _ocx_desktop_path_v1 +"#; +fn text(p: &Path) -> Result { + let s = p.to_str().ok_or("path-not-utf8")?; + if !p.is_absolute() + || p.components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + || s.contains(['\0', '\n', '\r', ':']) + { + return Err("path-unrepresentable".into()); + } + Ok(s.into()) +} +fn quote(p: &Path) -> Result { + Ok(format!("'{}'", text(p)?.replace('\'', "'\"'\"'"))) +} +pub fn render_shim(cli: &Path, owner: &str) -> Result { + // Replace the owner before substituting paths so a literal placeholder in a path stays literal. + Ok(SHIM + .replace("@OWNER@", owner) + .replace("@CLI@", "e(cli)?)) +} +pub fn render_helper(bin: &Path) -> Result { + Ok(HELPER.replace("@BIN@", "e(bin)?)) +} +pub fn block(shell: &str, root: &Path) -> Result { + let body = if shell == "fish" { + format!( + "if status is-interactive\n fish_add_path --path --prepend --move {}\nend\n", + quote(&root.join("bin"))? + ) + } else { + let helper = quote(&root.join("path.sh"))?; + format!("if [ -r {helper} ]; then\n . {helper}\nfi\n") + }; + Ok(format!("{START}\n{body}{END}\n")) +} +pub fn targets(home: &Path) -> Result> { + targets_in( + home, + std::env::var_os("ZDOTDIR").map(PathBuf::from), + std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), + ) +} +fn targets_in( + home: &Path, + zdotdir: Option, + xdg: Option, +) -> Result> { + let z = zdotdir + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| home.into()); + let fish = xdg + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| home.join(".config")) + .join("fish/config.fish"); + let mut out = vec![ + ("zsh".into(), z.join(".zshrc")), + ("zsh".into(), z.join(".zlogin")), + ("bash".into(), home.join(".bashrc")), + ("fish".into(), fish), + ]; + // Exists, including an unsafe symlink: it is the active candidate and must be refused, not skipped. + if let Some(p) = [".bash_profile", ".bash_login", ".profile"] + .into_iter() + .map(|n| home.join(n)) + .find(|p| fs::symlink_metadata(p).is_ok()) + { + out.push(("bash".into(), p)); + } + for (_, p) in &out { + text(p)?; + } + Ok(out) +} +pub fn stable_bundle(exe: &Path, debug: bool, version: &str) -> Result { + if debug { + return Err("development-launch".into()); + } + let raw = text(exe)?; + if raw.starts_with("/Volumes/") + || raw.starts_with("/private/var/folders/") && raw.contains("/AppTranslocation/") + || raw.contains("/tmp/.mount_") + || std::env::var_os("APPIMAGE").is_some() + || std::env::var_os("APPDIR").is_some() + { + return Err("temporary-bundle".into()); + } + let real = fs::canonicalize(exe).map_err(|_| "bundle-unavailable")?; + let s = text(&real)?; + if s.starts_with("/Volumes/") || s.contains("/AppTranslocation/") || s.contains("/tmp/.mount_") + { + return Err("temporary-bundle".into()); + } + let parent = real.parent().ok_or("unpackaged-launch")?; + let (platform, kind, cli) = if cfg!(target_os = "macos") { + if parent.file_name().and_then(|s| s.to_str()) != Some("MacOS") + || parent + .parent() + .and_then(|p| p.file_name()) + .and_then(|s| s.to_str()) + != Some("Contents") + || parent + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.extension()) + .and_then(|s| s.to_str()) + != Some("app") + { + return Err("unpackaged-launch".into()); + } + ("darwin", "macos-app", parent.join("ocx")) + } else { + if parent != Path::new("/usr/bin") { + return Err("unpackaged-launch".into()); + } + ("linux", "linux-deb", PathBuf::from("/usr/bin/ocx")) + }; + let m = fs::symlink_metadata(&cli).map_err(|_| "bundle-cli-missing")?; + if !m.is_file() || m.file_type().is_symlink() { + return Err("bundle-cli-missing".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if m.permissions().mode() & 0o111 == 0 { + return Err("bundle-cli-not-executable".into()); + } + } + Ok(Bundle { + platform: platform.into(), + kind: kind.into(), + app_executable: s, + cli_executable: text(&cli)?, + version: version.into(), + }) +} +// Return exact block range, including its line terminator. Reject malformed or changed markers. +fn range(bytes: &[u8], expected: &str) -> Result>> { + let s = std::str::from_utf8(bytes).map_err(|_| "rc-not-utf8")?; + let begins: Vec<_> = s.match_indices(START).map(|(n, _)| n).collect(); + let ends: Vec<_> = s.match_indices(END).map(|(n, _)| n).collect(); + if s.lines().any(|line| { + (line.contains("OpenCodex Desktop ocx PATH") + && (line.contains(">>>") || line.contains("<<<"))) + && line != START + && line != END + }) { + return Err("rc-markers-invalid".into()); + } + if begins.is_empty() && ends.is_empty() { + return Ok(None); + } + if begins.len() != 1 + || ends.len() != 1 + || ends[0] < begins[0] + || begins[0] > 0 && bytes[begins[0] - 1] != b'\n' + { + return Err("rc-markers-invalid".into()); + } + let finish = ends[0] + END.len(); + let finish = if s[finish..].starts_with("\r\n") { + finish + 2 + } else if s[finish..].starts_with('\n') { + finish + 1 + } else if finish == s.len() { + finish + } else { + return Err("rc-markers-invalid".into()); + }; + let candidate = &s[begins[0]..finish]; + if candidate != expected + && candidate.trim_end_matches(['\r', '\n']) != expected.trim_end_matches(['\r', '\n']) + { + return Err("rc-block-modified".into()); + } + Ok(Some(begins[0]..finish)) +} +fn newline(bytes: &[u8]) -> Result<&'static str> { + let s = std::str::from_utf8(bytes).map_err(|_| "rc-not-utf8")?; + if s.contains('\r') && (!s.contains("\r\n") || s.replace("\r\n", "").contains(['\r', '\n'])) { + return Err("rc-mixed-newlines".into()); + } + Ok(if s.contains("\r\n") { "\r\n" } else { "\n" }) +} +pub fn edit_rc(bytes: &[u8], managed: &str, remove: bool) -> Result> { + let nl = newline(bytes)?; + let expected = managed.replace('\n', nl); + let found = range(bytes, &expected)?; + if let Some(r) = &found { + if !remove && bytes[r.end..].iter().all(u8::is_ascii_whitespace) { + return Ok(bytes.to_vec()); + } + } + let mut out = bytes.to_vec(); + if let Some(r) = found { + out.drain(r); + } + if remove { + return Ok(out); + } + // Outside bytes and trailing whitespace stay in the same order; block is last nonblank content. + if !out.is_empty() && !out.ends_with(b"\n") { + out.extend_from_slice(nl.as_bytes()); + } + out.extend_from_slice(expected.as_bytes()); + Ok(out) +} +fn mode(path: &Path) -> u32 { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::metadata(path) + .map(|m| m.permissions().mode() & 0o777) + .unwrap_or(0o600) + } + #[cfg(not(unix))] + { + let _ = path; + 0o600 + } +} +fn change( + store: &Store, + p: &Path, + before: Option>, + after: Option>, + mode: u32, +) -> Change { + let backup = before.as_ref().map(|_| { + store + .root + .join("backups") + .join(uuid::Uuid::new_v4().to_string()) + .to_string_lossy() + .into_owned() + }); + Change { + kind: "file".into(), + path: p.to_string_lossy().into_owned(), + before, + after, + mode, + backup_path: backup, + } +} +fn ensure_rc_parent(p: &Path, home: &Path) -> Result<()> { + let parent = p.parent().ok_or("unsafe-file")?; + if parent == home.join(".config/fish") { + for directory in [home.join(".config"), home.join(".config/fish")] { + match fs::symlink_metadata(&directory) { + Ok(_) => record::check(&directory, true)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + record::private_dir(&directory)? + } + Err(_) => return Err("io-failed".into()), + } + } + } + record::check(parent, true) +} +pub(crate) fn plan( + store: &Store, + current: &Record, + bundle: Bundle, + home: &Path, + selected: Vec<(String, PathBuf)>, +) -> Result<(Record, Vec, Vec)> { + let mut next = current.clone(); + next.bundle = Some(bundle.clone()); + let mut changes = Vec::new(); + let mut issues = Vec::new(); + let mut owned = Posix { + bin_directory: text(&store.root.join("bin"))?, + files: Vec::new(), + rc_files: Vec::new(), + }; + for (kind, p, rendered, permissions) in [ + ( + "shim", + store.root.join("bin/ocx"), + render_shim(Path::new(&bundle.cli_executable), ¤t.owner_id)?, + 0o700, + ), + ( + "path-helper", + store.root.join("path.sh"), + render_helper(&store.root.join("bin"))?, + 0o600, + ), + ] { + let old = record::read_bytes(&p)?; + let previous = current + .posix + .as_ref() + .and_then(|v| v.files.iter().find(|f| f.path == p.to_string_lossy())); + if let Some(b) = &old { + if previous.is_none_or(|f| record::hash(b) != f.sha256) { + return Err("owned-file-conflict".into()); + } + if mode(&p) != permissions { + return Err("owned-file-permissions".into()); + } + } + if old.as_deref() != Some(rendered.as_bytes()) { + changes.push(change( + store, + &p, + old.clone(), + Some(rendered.as_bytes().to_vec()), + permissions, + )); + } + owned.files.push(OwnedFile { + kind: kind.into(), + path: text(&p)?, + sha256: record::hash(rendered.as_bytes()), + created: previous.map_or(old.is_none(), |f| f.created), + }); + } + if selected.iter().filter(|(s, _)| s == "bash").count() == 1 { + issues.push("login-file-absent".into()); + } + for (shell, p) in &selected { + // Never create an arbitrary external ZDOTDIR/XDG directory tree; absent parents are reported. + let attempt: Result = (|| { + ensure_rc_parent(p, home)?; + record::rc_acl_check(p)?; + let old = record::read_bytes(p)?; + let previous = current + .posix + .as_ref() + .and_then(|v| v.rc_files.iter().find(|f| f.path == p.to_string_lossy())); + let managed = block(shell, &store.root)?; + let nl = newline(old.as_deref().unwrap_or_default())?; + let rendered = managed.replace('\n', nl); + let found = range(old.as_deref().unwrap_or_default(), &rendered).map_err(|e| { + if previous.is_some() && e == "rc-block-modified" { + "owned-block-modified".into() + } else { + e + } + })?; + if found.is_some() + && previous.is_none_or(|f| f.block_sha256 != record::hash(rendered.as_bytes())) + { + return Err(if previous.is_some() { + "owned-block-modified" + } else { + "rc-block-unowned" + } + .into()); + } + let after = edit_rc(old.as_deref().unwrap_or_default(), &managed, false)?; + let mut backup = previous.and_then(|f| f.backup_path.clone()); + if old.as_deref() != Some(after.as_slice()) { + record::rc_acl_check(p)?; + let c = change(store, p, old.clone(), Some(after), mode(p)); + if backup.is_none() { + backup = c.backup_path.clone(); + } + changes.push(c); + } + Ok(RcFile { + shell: shell.clone(), + path: text(p)?, + block_sha256: record::hash(rendered.as_bytes()), + created: previous.map_or(old.is_none(), |f| f.created), + backup_path: backup, + result: "installed".into(), + }) + })(); + match attempt { + Ok(f) => owned.rc_files.push(f), + Err(e) => { + issues.push(e); + // Retain old ownership so a refused file stays recoverable/removable after user repair. + if let Some(f) = current + .posix + .as_ref() + .and_then(|v| v.rc_files.iter().find(|f| f.path == p.to_string_lossy())) + { + owned.rc_files.push(f.clone()); + } + } + } + } + // Ownership is independent of today's ZDOTDIR, XDG_CONFIG_HOME and bash login choice. + if let Some(previous) = ¤t.posix { + for f in &previous.rc_files { + if selected.iter().any(|(_, p)| p == Path::new(&f.path)) { + continue; + } + if let Err(e) = remove_rc(store, f, &mut changes) { + issues.push(e); + owned.rc_files.push(f.clone()); + } + } + } + next.posix = Some(owned); + next.windows = None; + Ok((next, changes, issues)) +} +fn remove_rc(store: &Store, f: &RcFile, changes: &mut Vec) -> Result<()> { + let p = Path::new(&f.path); + record::rc_acl_check(p)?; + let Some(old) = record::read_bytes(p)? else { + return Ok(()); + }; + let managed = block(&f.shell, &store.root)?; + let expected = managed.replace('\n', newline(&old)?); + let found = range(&old, &expected).map_err(|e| { + if e == "rc-block-modified" { + "owned-block-modified".into() + } else { + e + } + })?; + if let Some(r) = found { + if record::hash(&old[r]) != f.block_sha256 { + return Err("owned-block-modified".into()); + } + } else { + return Ok(()); + } + let after = edit_rc(&old, &managed, true)?; + let result = if f.created && after.iter().all(u8::is_ascii_whitespace) { + None + } else { + Some(after) + }; + if result.as_deref() != Some(old.as_slice()) { + record::rc_acl_check(p)?; + changes.push(change(store, p, Some(old), result, mode(p))); + } + Ok(()) +} +pub fn remove_plan(store: &Store, current: &Record) -> Result<(Record, Vec, Vec)> { + let mut next = current.clone(); + let mut changes = Vec::new(); + let mut issues = Vec::new(); + let Some(mut owned) = current.posix.clone() else { + return Ok((next, changes, issues)); + }; + owned.rc_files.retain(|f| { + let attempt = remove_rc(store, f, &mut changes); + if let Err(e) = attempt { + issues.push(e); + true + } else { + false + } + }); + // If a refused rc still references helper/bin, keep both until repair/removal can finish. + if owned.rc_files.is_empty() { + owned.files.retain(|f| { + let attempt: Result<()> = (|| { + let p = Path::new(&f.path); + let Some(old) = record::read_bytes(p)? else { + return Ok(()); + }; + if record::hash(&old) != f.sha256 { + return Err("owned-file-conflict".into()); + } + if mode(p) != if f.kind == "shim" { 0o700 } else { 0o600 } { + return Err("owned-file-permissions".into()); + } + changes.push(change(store, p, Some(old), None, mode(p))); + Ok(()) + })(); + if let Err(e) = attempt { + issues.push(e); + true + } else { + false + } + }); + } + next.posix = (!owned.files.is_empty() || !owned.rc_files.is_empty()).then_some(owned); + Ok((next, changes, issues)) +} +#[cfg(all(test, unix))] +mod tests { + use super::*; + use record::tests::{bundle, Temp}; + #[cfg(target_os = "macos")] + fn rc_acl(path: &Path, add: bool) { + let mut command = std::process::Command::new("/bin/chmod"); + if add { + command.args(["+a", "nobody deny read"]); + } else { + command.arg("-N"); + } + assert!( + command + .arg(path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap() + .success(), + "acl-command-failed" + ); + } + #[cfg(target_os = "macos")] + #[test] + fn rc_acl_refuses_install_reposition_remove_and_keeps_owned_artifacts() { + let t = Temp::new(); + let s = t.store(); + let p = t.0.join(".zshrc"); + record::atomic(&p, None, b"# user\n", 0o600).unwrap(); + rc_acl(&p, true); + let mut r = Record::fresh(bundle()); + let selected = vec![("zsh".into(), p.clone())]; + let (next, changes, issues) = plan(&s, &r, bundle(), &t.0, selected.clone()).unwrap(); + assert_eq!(issues, ["rc-acl-present"]); + assert!(changes.iter().all(|c| c.path != p.to_string_lossy())); + assert_eq!(fs::read(&p).unwrap(), b"# user\n"); + assert!(record::rc_acl_check(&p).is_err()); + s.transact(&mut r, next, changes, "install").unwrap(); + rc_acl(&p, false); + let (next, changes, _) = plan(&s, &r, bundle(), &t.0, selected.clone()).unwrap(); + s.transact(&mut r, next, changes, "install").unwrap(); + let mut bytes = fs::read(&p).unwrap(); + bytes.extend_from_slice(b"# later\n"); + fs::write(&p, &bytes).unwrap(); + rc_acl(&p, true); + let (next, changes, issues) = plan(&s, &r, bundle(), &t.0, selected).unwrap(); + assert_eq!(issues, ["rc-acl-present"]); + assert!(changes.is_empty()); + assert_eq!(next.posix.as_ref().unwrap().rc_files.len(), 1); + r.enabled = false; + s.save(&r).unwrap(); + let (next, changes, issues) = remove_plan(&s, &r).unwrap(); + assert_eq!(issues, ["rc-acl-present"]); + assert!(changes.is_empty()); + s.transact(&mut r, next, changes, "remove").unwrap(); + assert!(r.posix.as_ref().unwrap().rc_files.len() == 1); + assert!(s.root.join("bin/ocx").is_file()); + assert!(s.root.join("path.sh").is_file()); + assert!(fs::read(&p).unwrap() == bytes); + assert!(record::rc_acl_check(&p).is_err()); + rc_acl(&p, false); + let (next, changes, issues) = remove_plan(&s, &r).unwrap(); + assert!(issues.is_empty()); + s.transact(&mut r, next, changes, "remove").unwrap(); + assert_eq!(fs::read(p).unwrap(), b"# user\n# later\n"); + assert!(r.posix.is_none()); + } + fn root() -> PathBuf { + fs::canonicalize(std::env::temp_dir()) + .unwrap() + .join(format!("ocx-posix-{}", uuid::Uuid::new_v4())) + } + #[test] + fn rc_edit_is_idempotent_and_repositions_after_later_npm_prepend() { + let t = root(); + record::private_dir(&t).unwrap(); + let p = t.join(".zshrc"); + let b = block("zsh", &t).unwrap(); + let first = edit_rc(b"export PATH=/npm:$PATH\n", &b, false).unwrap(); + record::atomic(&p, None, &first, 0o600).unwrap(); + assert_eq!(edit_rc(&first, &b, false).unwrap(), first); + let later = [first.as_slice(), b"export PATH=/later:$PATH\n"].concat(); + let repaired = edit_rc(&later, &b, false).unwrap(); + assert!(repaired.ends_with(b.as_bytes())); + assert_eq!( + edit_rc(&repaired, &b, true).unwrap(), + b"export PATH=/npm:$PATH\nexport PATH=/later:$PATH\n" + ); + fs::remove_dir_all(t).unwrap(); + } + #[test] + fn broken_duplicate_nested_or_modified_blocks_are_refused() { + let b = block("bash", Path::new("/example/record")).unwrap(); + for bytes in [ + START.to_owned(), + END.to_owned(), + format!("{b}{b}"), + format!("{START}\n{START}\n{END}\n{END}\n"), + b.replace("if [ -r", "if [ -w"), + ] { + assert!(edit_rc(bytes.as_bytes(), &b, false).is_err()); + assert!(edit_rc(bytes.as_bytes(), &b, true).is_err()); + } + } + #[test] + fn rc_newlines_and_outside_bytes_survive_removal() { + let b = block("zsh", Path::new("/example/record")).unwrap(); + let original = b"# user's config\r\nexport PATH=\"/npm:$PATH\"\r\n"; + let installed = edit_rc(original, &b, false).unwrap(); + assert_eq!(edit_rc(&installed, &b, true).unwrap(), original); + assert!(edit_rc(b"a\r\nb\n", &b, false).is_err()); + assert!(edit_rc(&[0xff], &b, false).is_err()); + } + #[test] + fn quoting_and_invalid_path_contract_is_exact() { + assert_eq!( + quote(Path::new("/example/a'b")).unwrap(), + "'/example/a'\"'\"'b'" + ); + for s in ["relative", "/example/a:b", "/example/a\nb"] { + assert!(quote(Path::new(s)).is_err()); + } + } + #[test] + fn development_and_temporary_launches_are_refused_before_installation() { + assert_eq!( + stable_bundle(Path::new("/example/app"), true, "1").unwrap_err(), + "development-launch" + ); + for p in [ + "/Volumes/DMG/App.app/Contents/MacOS/app", + "/private/var/folders/a/AppTranslocation/b/App.app/Contents/MacOS/app", + "/tmp/.mount_example/usr/bin/app", + ] { + assert_eq!( + stable_bundle(Path::new(p), false, "1").unwrap_err(), + "temporary-bundle" + ); + } + } + #[test] + fn owned_and_desired_rc_paths_reconcile_without_losing_modified_ownership() { + let t = Temp::new(); + let old = t.0.join("old"); + let new = t.0.join("new"); + record::private_dir(&old).unwrap(); + record::private_dir(&new).unwrap(); + let s = Store::open(t.0.join("record"), vec![new.join(".zshrc")]).unwrap(); + let mut r = Record::fresh(bundle()); + let (next, changes, issues) = plan( + &s, + &r, + bundle(), + &t.0, + vec![("zsh".into(), old.join(".zshrc"))], + ) + .unwrap(); + assert!(issues.is_empty()); + s.transact(&mut r, next, changes, "install").unwrap(); + let old_rc = old.join(".zshrc"); + fs::write( + &old_rc, + fs::read_to_string(&old_rc) + .unwrap() + .replace("if [ -r", "if [ -w"), + ) + .unwrap(); + let modified = fs::read(&old_rc).unwrap(); + let (next, changes, issues) = plan( + &s, + &r, + bundle(), + &t.0, + vec![("zsh".into(), new.join(".zshrc"))], + ) + .unwrap(); + assert_eq!(issues, ["owned-block-modified"]); + assert_eq!(next.posix.as_ref().unwrap().rc_files.len(), 2); + s.transact(&mut r, next, changes, "install").unwrap(); + assert_eq!(fs::read(&old_rc).unwrap(), modified); + // Repair the old block, then retire it although today's target selector no longer lists it. + fs::write(&old_rc, block("zsh", &s.root).unwrap()).unwrap(); + let (next, changes, issues) = plan( + &s, + &r, + bundle(), + &t.0, + vec![("zsh".into(), new.join(".zshrc"))], + ) + .unwrap(); + assert!(issues.is_empty()); + s.transact(&mut r, next, changes, "install").unwrap(); + assert!(!old_rc.exists()); + assert_eq!(r.posix.as_ref().unwrap().rc_files.len(), 1); + } + #[test] + fn modified_rc_keeps_helper_until_cleanup_and_preserves_user_content() { + let t = Temp::new(); + let s = t.store(); + let p = t.0.join(".zshrc"); + record::atomic(&p, None, b"# user\n", 0o600).unwrap(); + let mut r = Record::fresh(bundle()); + let (next, changes, _) = + plan(&s, &r, bundle(), &t.0, vec![("zsh".into(), p.clone())]).unwrap(); + s.transact(&mut r, next, changes, "install").unwrap(); + fs::write( + &p, + fs::read_to_string(&p) + .unwrap() + .replace("if [ -r", "if [ -w"), + ) + .unwrap(); + let (_, changes, issues) = remove_plan(&s, &r).unwrap(); + assert_eq!(issues, ["owned-block-modified"]); + assert!(changes.is_empty()); + assert!(s.root.join("path.sh").exists()); + fs::write( + &p, + format!("# user\n{}# later\n", block("zsh", &s.root).unwrap()), + ) + .unwrap(); + r.enabled = false; + s.save(&r).unwrap(); + let (next, changes, issues) = remove_plan(&s, &r).unwrap(); + assert!(issues.is_empty()); + s.transact(&mut r, next, changes, "remove").unwrap(); + assert_eq!(fs::read(p).unwrap(), b"# user\n# later\n"); + assert!(!s.root.join("path.sh").exists()); + assert!(r.posix.is_none()); + } + #[test] + fn unrecorded_block_and_generated_file_are_not_adopted() { + let t = Temp::new(); + let s = t.store(); + let p = t.0.join(".zshrc"); + let r = Record::fresh(bundle()); + record::atomic(&p, None, block("zsh", &s.root).unwrap().as_bytes(), 0o600).unwrap(); + let (next, _, issues) = plan(&s, &r, bundle(), &t.0, vec![("zsh".into(), p)]).unwrap(); + assert_eq!(issues, ["rc-block-unowned"]); + assert!(next.posix.unwrap().rc_files.is_empty()); + record::atomic(&s.root.join("bin/ocx"), None, b"user", 0o700).unwrap(); + assert_eq!( + plan(&s, &r, bundle(), &t.0, vec![]).unwrap_err(), + "owned-file-conflict" + ); + } + #[test] + fn zdotdir_xdg_and_bash_login_selection_are_injected() { + let t = Temp::new(); + let chosen = targets_in(&t.0, Some(t.0.join("zdot")), Some(t.0.join("xdg"))).unwrap(); + assert_eq!(chosen[0].1, t.0.join("zdot/.zshrc")); + assert_eq!(chosen[3].1, t.0.join("xdg/fish/config.fish")); + assert_eq!(chosen.len(), 4); + record::atomic(&t.0.join(".profile"), None, b"# fallback", 0o600).unwrap(); + record::atomic(&t.0.join(".bash_login"), None, b"# active", 0o600).unwrap(); + let chosen = targets_in(&t.0, Some("relative".into()), None).unwrap(); + assert_eq!(chosen[0].1, t.0.join(".zshrc")); + assert_eq!(chosen[4].1, t.0.join(".bash_login")); + } + #[test] + fn path_helper_preserves_empty_entries_and_prepends_once() { + let bin = Path::new("/example/desktop/bin"); + let helper = render_helper(bin).unwrap(); + for (path, expected) in [ + (None, "/example/desktop/bin"), + (Some(""), "/example/desktop/bin:"), + ( + Some(":/npm::/example/desktop/bin:/other:"), + "/example/desktop/bin::/npm::/other:", + ), + ] { + let mut command = std::process::Command::new("/bin/sh"); + let unset = if path.is_none() { "unset PATH\n" } else { "" }; + command + .env_clear() + .arg("-c") + .arg(format!("{unset}{helper}\nprintf '%s' \"$PATH\"")); + if let Some(path) = path { + command.env("PATH", path); + } + let output = command.output().unwrap(); + assert!(output.status.success()); + assert_eq!(String::from_utf8(output.stdout).unwrap(), expected); + } + } + #[test] + fn shim_generates_argv_bound_proof_and_fails_closed_when_bundle_is_missing() { + let t = Temp::new(); + let cli = t.0.join("cli"); + let shim = t.0.join("shim"); + let inspect = b"#!/bin/sh\nprintf '%s\\n' \"$OCX_NODE_LAUNCH_CONTEXT\" \"$@\"\n"; + record::atomic(&cli, None, inspect, 0o700).unwrap(); + record::atomic( + &shim, + None, + render_shim(&cli, "owner").unwrap().as_bytes(), + 0o700, + ) + .unwrap(); + let out = std::process::Command::new(&shim) + .env_clear() + .env("ANTHROPIC_AUTH_TOKEN", "dummy") + .env("ANTHROPIC_API_KEY", "") + .env("OCX_NODE_LAUNCH_CONTEXT", "untrusted") + .arg("status") + .output() + .unwrap(); + assert!(out.status.success()); + let text = String::from_utf8(out.stdout).unwrap(); + let lines: Vec<_> = text.lines().collect(); + let context: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); + let proof = context["proof"].as_str().unwrap(); + assert_eq!(proof.len(), 43); + assert_eq!(lines[1], format!("--ocx-internal-launch-proof={proof}")); + assert_eq!( + context["anthropicEnvSlots"], + serde_json::json!(["ANTHROPIC_AUTH_TOKEN"]) + ); + assert!(context["codexCliInspectionEnv"].is_null()); + assert!(!text.contains("dummy")); + fs::remove_file(cli).unwrap(); + assert_eq!( + std::process::Command::new(&shim) + .env_clear() + .output() + .unwrap() + .status + .code(), + Some(127) + ); + // Exercise unavailable/malformed utility fallback without changing the machine's utilities. + for rendered in [ + render_shim(&t.0.join("cli"), "owner") + .unwrap() + .replace("/usr/bin/od", "/no-such-od"), + render_shim(&t.0.join("cli"), "owner") + .unwrap() + .replace("/usr/bin/awk", "/no-such-awk"), + render_shim(&t.0.join("cli"), "owner") + .unwrap() + .replace("/dev/urandom", "/no-such-urandom"), + render_shim(&t.0.join("cli"), "owner") + .unwrap() + .replace("-N32", "-N1"), + ] { + record::atomic(&t.0.join("cli"), None, inspect, 0o700).unwrap(); + fs::write(&shim, rendered).unwrap(); + let out = std::process::Command::new(&shim) + .env_clear() + .env("OCX_NODE_LAUNCH_CONTEXT", "inherited") + .arg("status") + .output() + .unwrap(); + assert!(out.status.success()); + assert_eq!(out.stdout, b"\nstatus\n"); + fs::remove_file(t.0.join("cli")).unwrap(); + } + } + #[cfg(target_os = "macos")] + #[test] + fn stable_macos_bundle_requires_an_executable_sibling_and_checks_canonical_location() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let t = Temp::new(); + let app = t.0.join("App.app/Contents/MacOS"); + fs::create_dir_all(&app).unwrap(); + let exe = app.join("app"); + let cli = app.join("ocx"); + record::atomic(&exe, None, b"app", 0o700).unwrap(); + assert_eq!( + stable_bundle(&exe, false, "1").unwrap_err(), + "bundle-cli-missing" + ); + record::atomic(&cli, None, b"cli", 0o600).unwrap(); + assert_eq!( + stable_bundle(&exe, false, "1").unwrap_err(), + "bundle-cli-not-executable" + ); + fs::set_permissions(&cli, fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!( + stable_bundle(&exe, false, "1").unwrap().cli_executable, + cli.to_str().unwrap() + ); + let translocated = t.0.join("AppTranslocation"); + record::private_dir(&translocated).unwrap(); + fs::rename(t.0.join("App.app"), translocated.join("App.app")).unwrap(); + symlink(translocated.join("App.app"), t.0.join("App.app")).unwrap(); + assert_eq!( + stable_bundle(&exe, false, "1").unwrap_err(), + "temporary-bundle" + ); + } + #[test] + fn missing_external_rc_parent_is_partial_and_default_fish_parents_are_created() { + let t = Temp::new(); + let s = t.store(); + let r = Record::fresh(bundle()); + let external = t.0.join("missing/config.fish"); + let (_, _, issues) = plan(&s, &r, bundle(), &t.0, vec![("fish".into(), external)]).unwrap(); + assert!(!issues.is_empty() && !t.0.join("missing").exists()); + let (next, changes, issues) = plan( + &s, + &r, + bundle(), + &t.0, + vec![("fish".into(), t.0.join(".config/fish/config.fish"))], + ) + .unwrap(); + assert!(issues.is_empty()); + let mut r = r; + s.transact(&mut r, next, changes, "install").unwrap(); + assert!(t.0.join(".config/fish/config.fish").exists()); + } + #[test] + #[ignore = "explicit wp3 compiled CLI target/output paths only"] + fn render_shim_for_compiled_cli() { + let cli = PathBuf::from(std::env::var_os("OCX_TEST_SHIM_TARGET").expect("target required")); + let output = + PathBuf::from(std::env::var_os("OCX_TEST_SHIM_OUTPUT").expect("output required")); + record::atomic( + &output, + None, + render_shim(&cli, "test-owner").unwrap().as_bytes(), + 0o700, + ) + .unwrap(); + } + #[test] + #[ignore = "explicit real-shell evidence using an isolated temporary HOME"] + fn real_shell_selects_desktop_shim_on_temp_home() { + use std::{io::Write, process::Command}; + + let t = Temp::new(); + let npm = t.0.join("npm-bin"); + record::private_dir(&npm).unwrap(); + record::atomic(&npm.join("ocx"), None, b"#!/bin/sh\necho npm-ocx\n", 0o700).unwrap(); + let app = if cfg!(target_os = "macos") { + t.0.join("Applications/OpenCodex.app/Contents/MacOS") + } else { + t.0.join("usr/bin") + }; + fs::create_dir_all(&app).unwrap(); + let exe = app.join("OpenCodex"); + let cli = app.join("ocx"); + record::atomic(&exe, None, b"#!/bin/sh\nexit 0\n", 0o700).unwrap(); + // The real CLI consumes the shim's private proof argument before dispatch. + record::atomic( + &cli, + None, + b"#!/bin/sh\ncase ${1-} in --ocx-internal-launch-proof=*) shift ;; esac\necho desktop-ocx \"$@\"\n", + 0o700, + ) + .unwrap(); + #[cfg(target_os = "macos")] + let installed_bundle = stable_bundle(&exe, false, "1").unwrap(); + #[cfg(not(target_os = "macos"))] + let installed_bundle = Bundle { + app_executable: text(&exe).unwrap(), + cli_executable: text(&cli).unwrap(), + ..bundle() + }; + + let prepend = format!("export PATH=\"{}:$PATH\"\n", npm.display()); + let bash_profile = format!("{prepend}. \"$HOME/.bashrc\"\n"); + for (name, contents) in [ + (".zshrc", prepend.as_str()), + (".bashrc", prepend.as_str()), + (".bash_profile", bash_profile.as_str()), + ] { + record::atomic(&t.0.join(name), None, contents.as_bytes(), 0o600).unwrap(); + } + // Inject selectors instead of consulting the developer's ZDOTDIR/XDG settings. + let selected = targets_in(&t.0, None, None).unwrap(); + let s = Store::open( + t.0.join("record"), + selected.iter().map(|(_, path)| path.clone()).collect(), + ) + .unwrap(); + let mut r = Record::fresh(installed_bundle.clone()); + let shim = s.root.join("bin/ocx"); + let shells: Vec<_> = ["zsh", "bash"] + .into_iter() + .filter_map(|shell| { + let executable = ["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"] + .into_iter() + .map(|directory| Path::new(directory).join(shell)) + .find(|path| path.is_file()); + if executable.is_none() { + eprintln!("{shell}: skipped (shell unavailable)"); + } + executable.map(|executable| (shell, executable)) + }) + .collect(); + let check_shells = |expected_path: &Path, expected_output: &str| { + for (shell, executable) in &shells { + let mut command = Command::new(executable); + command + .env_clear() + .env("HOME", &t.0) + .env("PATH", "/usr/bin:/bin:/usr/sbin:/sbin") + .env("TERM", "dumb") + .current_dir(&t.0); + if *shell == "bash" { + command.arg("-l"); + } + // System rc files may print to stdout (Ubuntu's /etc/bash.bashrc sudo hint), so only + // the marked result lines count; a failed lookup or CLI run still fails the shell, and + // the end marker makes any extra CLI output line fail the comparison. + let output = command + .args([ + "-i", + "-c", + "p=$(command -v ocx) || exit 11; o=$(ocx hello) || exit 12; \ + printf 'OCX-PATH:%s\\nOCX-OUT:%s:OCX-END\\n' \"$p\" \"$o\"", + ]) + .output() + .expect("cannot start real shell"); + assert!(output.status.success(), "{shell}: command failed"); + let stdout = String::from_utf8(output.stdout).expect("shell stdout is not UTF-8"); + let marked = |tag: &str| { + stdout + .lines() + .filter_map(|line| line.strip_prefix(tag)) + .collect::>() + }; + let (paths, outputs) = (marked("OCX-PATH:"), marked("OCX-OUT:")); + // Keep captured system-rc output and temporary HOME paths out of failure logs. + assert!( + paths.len() == 1 && outputs.len() == 1, + "{shell}: expected exactly one marked path and one marked output line" + ); + assert!( + paths[0] == expected_path.to_str().unwrap(), + "{shell}: command resolution selected the wrong executable" + ); + assert!( + outputs[0] == format!("{expected_output}:OCX-END"), + "{shell}: wrong CLI output" + ); + } + }; + check_shells(&npm.join("ocx"), "npm-ocx"); + for reconcile in [false, true] { + if reconcile { + for name in [".zshrc", ".bashrc"] { + let p = t.0.join(name); + assert!(fs::read_to_string(&p) + .unwrap() + .ends_with(&format!("{END}\n"))); + fs::OpenOptions::new() + .append(true) + .open(&p) + .unwrap() + .write_all(prepend.as_bytes()) + .unwrap(); + assert!(fs::read_to_string(&p).unwrap().ends_with(&prepend)); + } + } + let (next, changes, issues) = + plan(&s, &r, installed_bundle.clone(), &t.0, selected.clone()).unwrap(); + assert!(issues.is_empty(), "installation reported issues"); + s.transact(&mut r, next, changes, "install").unwrap(); + assert!(shim.is_file() && s.root.join("path.sh").is_file()); + for name in [".zshrc", ".bashrc", ".bash_profile"] { + let contents = fs::read_to_string(t.0.join(name)).unwrap(); + assert!(contents.ends_with(&format!("{END}\n"))); + assert_eq!(contents.matches(START).count(), 1); + } + check_shells(&shim, "desktop-ocx hello"); + } + r.enabled = false; + s.save(&r).unwrap(); + let (next, changes, issues) = remove_plan(&s, &r).unwrap(); + assert!(issues.is_empty(), "removal reported issues"); + s.transact(&mut r, next, changes, "remove").unwrap(); + assert!(r.posix.is_none()); + assert!(!shim.exists() && !s.root.join("path.sh").exists()); + for (name, expected) in [ + (".zshrc", prepend.repeat(2)), + (".bashrc", prepend.repeat(2)), + (".bash_profile", bash_profile), + ] { + assert!( + fs::read_to_string(t.0.join(name)).unwrap() == expected, + "user rc contents were changed during removal" + ); + } + check_shells(&npm.join("ocx"), "npm-ocx"); + } +} diff --git a/desktop/src-tauri/src/cli_command_record.rs b/desktop/src-tauri/src/cli_command_record.rs new file mode 100644 index 00000000000..88eac9750b8 --- /dev/null +++ b/desktop/src-tauri/src/cli_command_record.rs @@ -0,0 +1,1839 @@ +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::{Read, Write}, + path::{Path, PathBuf}, +}; +use uuid::Uuid; + +pub type Result = std::result::Result; +pub fn hash(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +fn io(value: std::io::Result) -> Result { + value.map_err(|_| "io-failed".into()) +} +fn digest(s: &str) -> bool { + s.len() == 64 + && s.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} +pub const RECORD_LIMIT: usize = 64 * 1024; +const JOURNAL_LIMIT: usize = 8 * 1024 * 1024; +pub fn platform() -> &'static str { + if cfg!(windows) { + "win32" + } else if cfg!(target_os = "macos") { + "darwin" + } else { + "linux" + } +} +fn absolute_for(s: &str, host: &str) -> bool { + if s.chars().count() > 4096 || s.contains(['\0', '\n', '\r']) { + return false; + } + let absolute = if host == "win32" { + let b = s.as_bytes(); + (b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && matches!(b[2], b'/' | b'\\')) + || s.starts_with("\\\\") + || s.starts_with("//") + } else { + s.starts_with('/') + }; + absolute + && !s + .split(if host == "win32" { + &['/', '\\'][..] + } else { + &['/'][..] + }) + .any(|part| matches!(part, "." | "..")) +} +fn absolute(s: &str) -> bool { + absolute_for(s, platform()) +} +fn bundle_valid(b: &Bundle, host: &str) -> bool { + b.platform == host + && absolute_for(&b.app_executable, host) + && absolute_for(&b.cli_executable, host) + && !b.version.is_empty() + && matches!( + (host, b.kind.as_str()), + ("darwin", "macos-app") | ("win32", "windows-install") | ("linux", "linux-deb") + ) +} +fn fingerprint(bytes: Option<&[u8]>) -> String { + bytes.map(hash).unwrap_or_else(|| "absent".into()) +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Bundle { + pub platform: String, + pub app_executable: String, + pub cli_executable: String, + pub version: String, + pub kind: String, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OwnedFile { + pub kind: String, + pub path: String, + pub sha256: String, + pub created: bool, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RcFile { + pub shell: String, + pub path: String, + pub block_sha256: String, + pub created: bool, + pub backup_path: Option, + pub result: String, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Posix { + pub bin_directory: String, + pub files: Vec, + pub rc_files: Vec, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Windows { + pub key: String, + pub value: String, + pub entry: String, + pub value_type: String, + pub action: String, + pub previous_before: Option, + pub previous_after: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Change { + pub kind: String, + pub path: String, + pub before: Option>, + pub after: Option>, + pub mode: u32, + pub backup_path: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PendingChange { + pub kind: String, + pub path: String, + pub mode: u32, + pub backup_path: Option, + pub journal_file: String, + pub before_sha256: String, + pub after_sha256: String, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Journal { + pub operation: String, + pub changes: Vec, + pub next: Box, +} +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct JournalFile { + version: u32, + kind: String, + path: String, + mode: Option, + before: Option, + after: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Record { + pub version: u32, + pub owner_id: String, + pub install_id: String, + pub generation: u64, + pub enabled: bool, + pub bundle: Option, + pub posix: Option, + pub windows: Option, + #[serde(default)] + pub notify_pending: bool, + pub pending: Option, +} +impl Record { + pub fn fresh(bundle: Bundle) -> Self { + let install_id = Uuid::new_v4().to_string(); + Self { + version: 1, + owner_id: Uuid::new_v4().to_string(), + install_id, + generation: 1, + enabled: true, + bundle: Some(bundle), + posix: None, + windows: None, + notify_pending: false, + pending: None, + } + } +} +// Desired targets authorize new writes; persisted rc ownership survives environment changes. +pub struct Store { + pub root: PathBuf, + pub rc_allowed: Vec, + _lock: fs::File, +} +#[cfg(unix)] +unsafe extern "C" { + fn geteuid() -> u32; + fn flock(fd: i32, op: i32) -> i32; +} +#[cfg(target_os = "macos")] +mod acl { + use super::*; + use std::{ + ffi::{c_void, CString}, + os::unix::ffi::OsStrExt, + }; + const EXTENDED: i32 = 0x100; + unsafe extern "C" { + fn acl_get_file(path: *const i8, kind: i32) -> *mut c_void; + fn acl_get_entry(acl: *mut c_void, entry_id: i32, entry: *mut *mut c_void) -> i32; + fn acl_set_file(path: *const i8, kind: i32, acl: *mut c_void) -> i32; + fn acl_init(count: i32) -> *mut c_void; + fn acl_free(acl: *mut c_void) -> i32; + } + fn name(path: &Path) -> Result { + CString::new(path.as_os_str().as_bytes()).map_err(|_| "private-acl-unsafe".into()) + } + pub fn present(path: &Path) -> Result { + // NULL/ENOENT denotes no ACL only on an existing, non-symlink path. + let m = fs::symlink_metadata(path).map_err(|_| "private-acl-unsafe")?; + if m.file_type().is_symlink() { + return Err("private-acl-unsafe".into()); + } + let path = name(path)?; + let acl = unsafe { acl_get_file(path.as_ptr(), EXTENDED) }; + if acl.is_null() { + return if std::io::Error::last_os_error().raw_os_error() == Some(2) { + Ok(false) + } else { + Err("private-acl-unsafe".into()) + }; + } + let mut entry = std::ptr::null_mut(); + // Darwin returns 0 for an entry and -1/EINVAL for an empty ACL. + let result = unsafe { acl_get_entry(acl, 0, &mut entry) }; + let errno = std::io::Error::last_os_error().raw_os_error(); + let freed = unsafe { acl_free(acl) }; + if freed != 0 { + return Err("private-acl-unsafe".into()); + } + match (result, errno) { + (0, _) => Ok(true), + (-1, Some(22)) => Ok(false), + _ => Err("private-acl-unsafe".into()), + } + } + pub fn clear(path: &Path) -> Result<()> { + let path = name(path)?; + let acl = unsafe { acl_init(0) }; + if acl.is_null() { + return Err("private-acl-unsafe".into()); + } + let result = unsafe { acl_set_file(path.as_ptr(), EXTENDED, acl) }; + let freed = unsafe { acl_free(acl) }; + if result != 0 || freed != 0 { + Err("private-acl-unsafe".into()) + } else { + Ok(()) + } + } +} +#[cfg(target_os = "linux")] +mod acl { + use super::*; + use std::{ + ffi::{c_void, CString}, + os::unix::ffi::OsStrExt, + }; + const NAMES: [&[u8]; 2] = [b"system.posix_acl_access\0", b"system.posix_acl_default\0"]; + unsafe extern "C" { + fn getxattr(path: *const i8, name: *const i8, value: *mut c_void, size: usize) -> isize; + fn removexattr(path: *const i8, name: *const i8) -> i32; + } + fn name(path: &Path) -> Result { + CString::new(path.as_os_str().as_bytes()).map_err(|_| "private-acl-unsafe".into()) + } + pub fn present(path: &Path) -> Result { + let m = fs::symlink_metadata(path).map_err(|_| "private-acl-unsafe")?; + if m.file_type().is_symlink() { + return Err("private-acl-unsafe".into()); + } + let path = name(path)?; + for attr in NAMES { + let n = + unsafe { getxattr(path.as_ptr(), attr.as_ptr().cast(), std::ptr::null_mut(), 0) }; + if n >= 0 { + return Ok(true); + } + if !matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(61 | 95) + ) { + return Err("private-acl-unsafe".into()); + } + } + Ok(false) + } + pub fn clear(path: &Path) -> Result<()> { + let path = name(path)?; + for attr in NAMES { + if unsafe { removexattr(path.as_ptr(), attr.as_ptr().cast()) } != 0 + && !matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(61 | 95) + ) + { + return Err("private-acl-unsafe".into()); + } + } + Ok(()) + } +} +pub fn rc_acl_check(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => return Err("rc-acl-present".into()), + Ok(_) => {} + } + #[cfg(unix)] + if acl::present(path).map_err(|_| "rc-acl-present")? { + return Err("rc-acl-present".into()); + } + Ok(()) +} +fn private_check(path: &Path, directory: bool, mode: u32, root: bool) -> Result<()> { + check(path, directory).map_err(|_| "private-acl-unsafe")?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let m = fs::symlink_metadata(path).map_err(|_| "private-acl-unsafe")?; + if m.mode() & 0o777 != mode || acl::present(path)? { + return Err("private-acl-unsafe".into()); + } + let _ = root; + } + #[cfg(windows)] + { + let _ = mode; + crate::cli_command_windows::private_acl::verify(path, root)?; + } + Ok(()) +} +fn harden_new(path: &Path, directory: bool) -> Result<()> { + #[cfg(unix)] + { + acl::clear(path)?; + if acl::present(path)? { + return Err("private-acl-unsafe".into()); + } + let _ = directory; + } + #[cfg(windows)] + crate::cli_command_windows::private_acl::harden(path, directory)?; + Ok(()) +} +pub fn check(path: &Path, directory: bool) -> Result<()> { + let m = io(fs::symlink_metadata(path))?; + for parent in path.ancestors().skip(1) { + if fs::symlink_metadata(parent) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(true) + { + return Err("unsafe-parent".into()); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if fs::symlink_metadata(parent) + .map(|m| m.file_attributes() & 0x400 != 0) + .unwrap_or(true) + { + return Err("unsafe-parent".into()); + } + } + } + if m.file_type().is_symlink() || (directory && !m.is_dir()) || (!directory && !m.is_file()) { + return Err("unsafe-file".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if m.uid() != unsafe { geteuid() } { + return Err("foreign-owner".into()); + } + if m.mode() & 0o222 == 0 { + return Err("read-only".into()); + } + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if m.file_attributes() & 0x400 != 0 { + return Err("unsafe-file".into()); + } + } + if m.permissions().readonly() { + return Err("read-only".into()); + } + Ok(()) +} +pub fn private_dir(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(_) => private_check(path, true, 0o700, true)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + let mut b = fs::DirBuilder::new(); + b.mode(0o700); + io(b.create(path))?; + } + #[cfg(not(unix))] + io(fs::create_dir(path))?; + harden_new(path, true)?; + } + Err(_) => return Err("io-failed".into()), + } + private_check(path, true, 0o700, true) +} +fn read_limited(path: &Path, limit: usize, reason: &str) -> Result>> { + match fs::symlink_metadata(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err("io-failed".into()), + Ok(m) if m.len() > limit as u64 => return Err(reason.into()), + Ok(_) => check(path, false)?, + } + let mut f = io(fs::File::open(path))?; + let mut out = Vec::new(); + io((&mut f).take(limit as u64 + 1).read_to_end(&mut out))?; + if out.len() > limit { + return Err(reason.into()); + } + Ok(Some(out)) +} +pub fn read_bytes(path: &Path) -> Result>> { + read_limited(path, JOURNAL_LIMIT, "rc-too-large") +} +fn private_bytes(path: &Path, limit: usize, reason: &str) -> Result>> { + let bytes = read_limited(path, limit, reason)?; + if bytes.is_some() { + private_check(path, false, 0o600, false)?; + } + Ok(bytes) +} +fn sync_dir(path: &Path) -> Result<()> { + #[cfg(unix)] + io(fs::File::open(path).and_then(|f| f.sync_all()))?; + #[cfg(not(unix))] + let _ = path; + Ok(()) +} +fn new_file(path: &Path, mode: u32) -> Result { + let mut o = fs::OpenOptions::new(); + o.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(mode); + } + #[cfg(not(unix))] + let _ = mode; + let file = io(o.open(path))?; + harden_new(path, false)?; + Ok(file) +} +#[cfg(windows)] +fn replace(from: &Path, to: &Path) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + #[link(name = "kernel32")] + unsafe extern "system" { + fn MoveFileExW(a: *const u16, b: *const u16, flags: u32) -> i32; + } + let a: Vec = from.as_os_str().encode_wide().chain(Some(0)).collect(); + let b: Vec = to.as_os_str().encode_wide().chain(Some(0)).collect(); + if unsafe { MoveFileExW(a.as_ptr(), b.as_ptr(), 0x1 | 0x8) } == 0 { + Err("rename-failed".into()) + } else { + Ok(()) + } +} +#[cfg(not(windows))] +fn replace(from: &Path, to: &Path) -> Result<()> { + io(fs::rename(from, to)) +} +pub fn atomic(path: &Path, before: Option<&[u8]>, after: &[u8], mode: u32) -> Result<()> { + let parent = path.parent().ok_or("unsafe-file")?; + check(parent, true)?; + rc_acl_check(path)?; + let tmp = parent.join(format!(".ocx-cli-{}", Uuid::new_v4())); + let result = (|| { + let original = fs::symlink_metadata(path).ok(); + let mut f = new_file(&tmp, mode)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + io(f.set_permissions(fs::Permissions::from_mode(mode)))?; + } + io(f.write_all(after))?; + io(f.sync_all())?; + let observed = fs::symlink_metadata(path).ok(); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if original + .as_ref() + .map(|m| (m.dev(), m.ino(), m.mode(), m.mtime(), m.mtime_nsec())) + != observed + .as_ref() + .map(|m| (m.dev(), m.ino(), m.mode(), m.mtime(), m.mtime_nsec())) + { + return Err("concurrent-edit".into()); + } + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if original + .as_ref() + .map(|m| (m.creation_time(), m.last_write_time(), m.file_attributes())) + != observed + .as_ref() + .map(|m| (m.creation_time(), m.last_write_time(), m.file_attributes())) + { + return Err("concurrent-edit".into()); + } + } + rc_acl_check(path)?; + if read_bytes(path)?.as_deref() != before { + return Err("concurrent-edit".into()); + } + replace(&tmp, path)?; + #[cfg(unix)] + io(fs::File::open(parent).and_then(|f| f.sync_all()))?; + Ok(()) + })(); + let _ = fs::remove_file(tmp); + result +} +fn lock(root: &Path) -> Result { + let p = root.join("cli.lock"); + let existed = fs::symlink_metadata(&p).is_ok(); + if fs::symlink_metadata(&p).is_ok() { + check(&p, false)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if io(fs::metadata(&p))?.permissions().mode() & 0o777 != 0o600 { + return Err("lock-permissions".into()); + } + } + } + let mut o = fs::OpenOptions::new(); + o.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(0o600); + } + let f = io(o.open(&p))?; + if !existed { + harden_new(&p, false)?; + } + private_check(&p, false, 0o600, false)?; + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + // LOCK_EX | LOCK_NB: OS releases the lock on close/process death; no stale PID deletion. + if unsafe { flock(f.as_raw_fd(), 2 | 4) } != 0 { + return Err("lock-busy".into()); + } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + #[repr(C)] + struct Overlapped { + internal: usize, + high: usize, + offset: u32, + offset_high: u32, + event: *mut std::ffi::c_void, + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn LockFileEx( + h: *mut std::ffi::c_void, + flags: u32, + reserved: u32, + low: u32, + high: u32, + overlapped: *mut Overlapped, + ) -> i32; + } + let mut v = Overlapped { + internal: 0, + high: 0, + offset: 0, + offset_high: 0, + event: std::ptr::null_mut(), + }; + if unsafe { LockFileEx(f.as_raw_handle(), 1 | 2, 0, 1, 0, &mut v) } == 0 { + return Err("lock-busy".into()); + } + } + Ok(f) +} +impl Store { + pub fn open(root: PathBuf, rc_allowed: Vec) -> Result { + // Inspect every existing private path before creating even the lock file. + if fs::symlink_metadata(&root).is_ok() { + private_check(&root, true, 0o700, true)?; + for (name, directory, mode) in [ + ("bin", true, 0o700), + ("journal", true, 0o700), + ("backups", true, 0o700), + ("cli.json", false, 0o600), + ("cli.lock", false, 0o600), + ("bin/ocx", false, 0o700), + ("path.sh", false, 0o600), + ] { + let path = root.join(name); + match fs::symlink_metadata(&path) { + Ok(_) => private_check(&path, directory, mode, false)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return Err("private-acl-unsafe".into()), + } + if matches!(name, "journal" | "backups") { + for entry in io(fs::read_dir(&path))? { + private_check(&io(entry)?.path(), false, 0o600, false)?; + } + } + } + } + private_dir(&root)?; + let guard = lock(&root)?; + for name in ["bin", "backups", "journal"] { + let path = root.join(name); + if fs::symlink_metadata(&path).is_ok() { + private_check(&path, true, 0o700, false)?; + } else { + private_dir(&path)?; + } + } + let store = Self { + root, + rc_allowed, + _lock: guard, + }; + let record = store.read()?; // A missing record is a validated empty record. + store.clean_unreferenced(record.as_ref())?; + Ok(store) + } + fn owned_path(&self, s: &str, r: &Record, next: &Record) -> bool { + let p = Path::new(s); + absolute(s) + && (p == self.root.join("bin/ocx") + || p == self.root.join("path.sh") + || self.rc_allowed.iter().any(|r| r == p) + || [r, next].iter().any(|r| { + r.posix + .as_ref() + .is_some_and(|v| v.rc_files.iter().any(|f| f.path == s)) + })) + } + fn backup_path(&self, s: &str) -> bool { + absolute(s) && Path::new(s).parent() == Some(self.root.join("backups").as_path()) + } + fn journal_path(&self, s: &str) -> bool { + let p = Path::new(s); + if !absolute(s) + || p.parent() != Some(self.root.join("journal").as_path()) + || p.extension().and_then(|s| s.to_str()) != Some("json") + { + return false; + } + p.file_stem().and_then(|s| s.to_str()).is_some_and(|s| { + let parts: Vec<_> = s.split('-').collect(); + let (g, i) = match parts.as_slice() { + [g, i] => (*g, *i), + [g, tx, i] + if tx.len() == 32 + && tx + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) => + { + (*g, *i) + } + _ => return false, + }; + g.parse::() + .is_ok_and(|n| n > 0 && n <= 9_007_199_254_740_991) + && i.parse::().is_ok_and(|n| n < 32) + }) + } + fn clean_unreferenced(&self, record: Option<&Record>) -> Result<()> { + let referenced: std::collections::HashSet<_> = record + .and_then(|r| r.pending.as_ref()) + .map(|j| { + j.changes + .iter() + .map(|c| Path::new(&c.journal_file)) + .collect() + }) + .unwrap_or_default(); + for entry in io(fs::read_dir(self.root.join("journal")))? { + let entry = io(entry)?; + let path = entry.path(); + if io(entry.file_type())?.is_file() + && self.journal_path(&path.to_string_lossy()) + && !referenced.contains(path.as_path()) + { + io(fs::remove_file(path))?; + } + } + sync_dir(&self.root.join("journal")) + } + pub fn validate(&self, r: &Record, nested: bool) -> Result<()> { + self.validate_for(r, nested, platform()) + } + fn validate_for(&self, r: &Record, nested: bool, host: &str) -> Result<()> { + let invalid = || "record-invalid".to_owned(); + if r.version != 1 + || Uuid::parse_str(&r.owner_id).is_err() + || r.install_id.is_empty() + || r.install_id.len() > 256 + || r.generation == 0 + || r.generation > 9_007_199_254_740_991 + { + return Err(invalid()); + } + if let Some(b) = &r.bundle { + if !bundle_valid(b, host) + || (b.platform == "win32" && r.posix.is_some()) + || (b.platform != "win32" && r.windows.is_some()) + { + return Err(invalid()); + } + } else if r.enabled || r.posix.is_some() || r.windows.is_some() { + return Err(invalid()); + } + if let Some(p) = &r.posix { + if p.bin_directory != self.root.join("bin").to_string_lossy() + || p.files.len() > 2 + || p.rc_files.len() > 16 + { + return Err(invalid()); + } + let mut paths = std::collections::HashSet::new(); + for f in &p.files { + let expected = match f.kind.as_str() { + "shim" => self.root.join("bin/ocx"), + "path-helper" => self.root.join("path.sh"), + _ => return Err(invalid()), + }; + if Path::new(&f.path) != expected || !digest(&f.sha256) || !paths.insert(&f.path) { + return Err(invalid()); + } + } + for f in &p.rc_files { + if !matches!(f.shell.as_str(), "zsh" | "bash" | "fish") + || !absolute(&f.path) + || !paths.insert(&f.path) + || !digest(&f.block_sha256) + || f.result != "installed" + || f.backup_path + .as_deref() + .is_some_and(|s| !self.backup_path(s)) + { + return Err(invalid()); + } + } + } + if let Some(w) = &r.windows { + if w.key != "HKCU\\Environment" + || w.value != "Path" + || !absolute_for(&w.entry, "win32") + || w.entry.contains(';') + || !matches!(w.value_type.as_str(), "REG_SZ" | "REG_EXPAND_SZ") + || !matches!(w.action.as_str(), "inserted" | "moved-existing") + || [&w.previous_before, &w.previous_after].iter().any(|s| { + s.as_ref() + .is_some_and(|s| s.contains([';', '\0', '\r', '\n'])) + }) + { + return Err(invalid()); + } + } + if let Some(j) = &r.pending { + if nested + || !matches!(j.operation.as_str(), "install" | "remove") + || j.changes.len() > 32 + || j.next.pending.is_some() + || r.owner_id != j.next.owner_id + || r.install_id != j.next.install_id + || r.enabled != j.next.enabled + || j.next.generation != r.generation + 1 + { + return Err(invalid()); + } + self.validate_for(&j.next, true, host)?; + let mut paths = std::collections::HashSet::new(); + let mut journals = std::collections::HashSet::new(); + for c in &j.changes { + if !paths.insert(&c.path) + || !journals.insert(&c.journal_file) + || !self.journal_path(&c.journal_file) + || ![&c.before_sha256, &c.after_sha256] + .iter() + .all(|s| *s == "absent" || digest(s)) + || c.backup_path + .as_deref() + .is_some_and(|s| !self.backup_path(s)) + { + return Err(invalid()); + } + match c.kind.as_str() { + "file" if self.owned_path(&c.path, r, &j.next) && c.mode <= 0o777 => {} + "registry-sz" | "registry-expand" + if host == "win32" + && c.path == "HKCU\\Environment\\Path" + && c.mode == 0 + && c.backup_path.is_none() => {} + _ => return Err(invalid()), + } + } + } + Ok(()) + } + pub fn read(&self) -> Result> { + let Some(b) = private_bytes( + &self.root.join("cli.json"), + RECORD_LIMIT, + "record-too-large", + )? + else { + return Ok(None); + }; + let r: Record = serde_json::from_slice(&b).map_err(|_| "record-invalid")?; + self.validate(&r, false)?; + Ok(Some(r)) + } + fn encoded(&self, r: &Record) -> Result> { + self.validate(r, false)?; + let b = serde_json::to_vec(r).map_err(|_| "record-invalid")?; + if b.len() > RECORD_LIMIT { + return Err("record-too-large".into()); + } + Ok(b) + } + pub fn save(&self, r: &Record) -> Result<()> { + let b = self.encoded(r)?; + let p = self.root.join("cli.json"); + let old = private_bytes(&p, RECORD_LIMIT, "record-too-large")?; + if old.as_deref() == Some(b.as_slice()) { + return Ok(()); + } + atomic(&p, old.as_deref(), &b, 0o600) + } + fn prepare( + &self, + current: &Record, + mut next: Record, + changes: &[Change], + op: &str, + ) -> Result { + next.generation = current.generation + 1; + next.pending = None; + let registry_changed = changes.iter().any(|c| c.kind.starts_with("registry-")); + next.notify_pending |= registry_changed || current.notify_pending; + let txid = Uuid::new_v4().simple().to_string(); + let mut refs = Vec::new(); + let mut payloads = Vec::new(); + for (i, c) in changes.iter().enumerate() { + let journal_file = self + .root + .join("journal") + .join(format!("{}-{txid}-{i}.json", next.generation)); + let payload = JournalFile { + version: 1, + kind: c.kind.clone(), + path: c.path.clone(), + mode: (c.kind == "file").then_some(c.mode), + before: c.before.as_ref().map(|b| STANDARD.encode(b)), + after: c.after.as_ref().map(|b| STANDARD.encode(b)), + }; + let bytes = serde_json::to_vec(&payload).map_err(|_| "journal-invalid")?; + if bytes.len() > JOURNAL_LIMIT { + return Err("rc-too-large".into()); + } + refs.push(PendingChange { + kind: c.kind.clone(), + path: c.path.clone(), + mode: c.mode, + backup_path: c.backup_path.clone(), + journal_file: journal_file.to_string_lossy().into_owned(), + before_sha256: fingerprint(c.before.as_deref()), + after_sha256: fingerprint(c.after.as_deref()), + }); + payloads.push((journal_file, bytes)); + } + let mut pending = current.clone(); + pending.notify_pending |= registry_changed; + pending.pending = Some(Journal { + operation: op.into(), + changes: refs, + next: Box::new(next), + }); + self.encoded(&pending)?; // Validate all paths and the 64 KiB bound before creating any journal. + for (path, bytes) in payloads { + let old = private_bytes(&path, JOURNAL_LIMIT, "journal-too-large")?; + if old.as_deref().is_some_and(|old| old != bytes) { + return Err("journal-conflict".into()); + } + if old.is_none() { + atomic(&path, None, &bytes, 0o600)?; + } + } + sync_dir(&self.root.join("journal"))?; + Ok(pending) + } + pub fn transact( + &self, + current: &mut Record, + next: Record, + changes: Vec, + op: &str, + ) -> Result<()> { + if changes.is_empty() && *current == next { + return Ok(()); + } + let pending = self.prepare(current, next, &changes, op)?; + self.save(&pending)?; + *current = pending; + self.recover(current) + } + fn load_change(&self, c: &PendingChange) -> Result { + let bytes = private_bytes( + Path::new(&c.journal_file), + JOURNAL_LIMIT, + "journal-too-large", + )? + .ok_or("journal-missing")?; + let j: JournalFile = serde_json::from_slice(&bytes).map_err(|_| "journal-invalid")?; + let decode = |s: Option| { + s.map(|s| STANDARD.decode(s).map_err(|_| "journal-invalid".to_owned())) + .transpose() + }; + let before = decode(j.before)?; + let after = decode(j.after)?; + if j.version != 1 + || j.kind != c.kind + || j.path != c.path + || j.mode != (c.kind == "file").then_some(c.mode) + || fingerprint(before.as_deref()) != c.before_sha256 + || fingerprint(after.as_deref()) != c.after_sha256 + { + return Err("journal-digest-mismatch".into()); + } + Ok(Change { + kind: c.kind.clone(), + path: c.path.clone(), + before, + after, + mode: c.mode, + backup_path: c.backup_path.clone(), + }) + } + pub fn recover(&self, current: &mut Record) -> Result<()> { + self.recover_with( + current, + crate::cli_command_windows::read_change, + crate::cli_command_windows::apply_change, + ) + } + fn recover_with( + &self, + current: &mut Record, + mut read_registry: impl FnMut(&Change) -> Result>>, + mut write_registry: impl FnMut(&Change) -> Result<()>, + ) -> Result<()> { + self.validate(current, false)?; + let Some(j) = current.pending.clone() else { + return Ok(()); + }; + let wanted = (j.operation == "install") == current.enabled; + if j.changes.iter().any(|c| c.kind.starts_with("registry-")) && !current.notify_pending { + current.notify_pending = true; + self.save(current)?; + } + // State table for BOTH operations: wanted: before -> after, after -> done. + // Cancelled: before -> leave, after -> before (reverse completed prefix). + // Any third state or unverifiable journal -> stop, preserve pending and backups. + let mut changes = j + .changes + .iter() + .map(|c| self.load_change(c)) + .collect::>>()?; + if !wanted { + changes.reverse(); + } + for c in changes { + if c.kind == "file" && !Path::new(&c.path).starts_with(&self.root) { + rc_acl_check(Path::new(&c.path))?; + } + let (before, after) = if wanted { + (&c.before, &c.after) + } else { + (&c.after, &c.before) + }; + let found = if c.kind == "file" { + read_bytes(Path::new(&c.path))? + } else { + read_registry(&c)? + }; + if found == *after { + continue; + } + if found != *before { + return Err("journal-conflict".into()); + } + if wanted { + if let (Some(name), Some(bytes)) = (&c.backup_path, &c.before) { + let p = Path::new(name); + if let Some(old) = private_bytes(p, JOURNAL_LIMIT, "backup-too-large")? { + if old != *bytes { + return Err("backup-conflict".into()); + } + } else { + atomic(p, None, bytes, 0o600)?; + } + } + } + if c.kind == "file" { + let p = Path::new(&c.path); + if !p.starts_with(&self.root) { + rc_acl_check(p)?; + } + if let Some(b) = after { + atomic(p, before.as_deref(), b, c.mode)?; + } else { + if read_bytes(p)? != *before { + return Err("concurrent-edit".into()); + } + io(fs::remove_file(p))?; + sync_dir(p.parent().ok_or("unsafe-file")?)?; + } + } else { + // Old pending records also acquire debt before replay/rollback writes. + if !current.notify_pending { + current.notify_pending = true; + self.save(current)?; + } + let mut step = c.clone(); + step.before = before.clone(); + step.after = after.clone(); + write_registry(&step)?; + } + } + let mut settled = if wanted { *j.next } else { current.clone() }; + settled.pending = None; + settled.notify_pending |= current.notify_pending; + settled.generation = current.generation + 1; + self.save(&settled)?; + *current = settled; + for c in j.changes { + io(fs::remove_file(c.journal_file))?; + } + sync_dir(&self.root.join("journal")) + } + pub fn notify_with( + &self, + current: &mut Record, + broadcast: impl FnOnce() -> Result<()>, + ) -> Result<()> { + if current.notify_pending { + broadcast()?; + let mut settled = current.clone(); + settled.notify_pending = false; + self.save(&settled)?; + *current = settled; + } + Ok(()) + } + pub fn journal_issues(&self, r: &Record) -> Vec { + let referenced: std::collections::HashSet<_> = r + .pending + .as_ref() + .map(|j| j.changes.iter().map(|c| c.journal_file.as_str()).collect()) + .unwrap_or_default(); + let orphan = fs::read_dir(self.root.join("journal")) + .map(|v| { + v.filter_map(|e| e.ok()) + .any(|e| !referenced.contains(e.path().to_string_lossy().as_ref())) + }) + .unwrap_or(true); + if orphan { + vec!["orphan-journal".into()] + } else { + Vec::new() + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + pub(crate) struct Temp(pub PathBuf); + impl Temp { + pub(crate) fn new() -> Self { + let p = fs::canonicalize(std::env::temp_dir()) + .unwrap() + .join(format!("ocx-cli-{}", Uuid::new_v4())); + private_dir(&p).unwrap(); + Self(p) + } + pub(crate) fn store(&self) -> Store { + Store::open(self.0.join("record"), vec![self.0.join(".zshrc")]).unwrap() + } + } + impl Drop for Temp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + pub(crate) fn bundle() -> Bundle { + let (app, cli, kind) = match platform() { + "win32" => ( + "C:\\Desktop\\app.exe", + "C:\\Desktop\\ocx.exe", + "windows-install", + ), + "darwin" => ( + "/Applications/Desktop.app/Contents/MacOS/app", + "/Applications/Desktop.app/Contents/MacOS/ocx", + "macos-app", + ), + _ => ("/usr/bin/app", "/usr/bin/ocx", "linux-deb"), + }; + Bundle { + platform: platform().into(), + kind: kind.into(), + app_executable: app.into(), + cli_executable: cli.into(), + version: "1".into(), + } + } + fn change(p: &Path) -> Change { + Change { + kind: "file".into(), + path: p.to_string_lossy().into_owned(), + before: Some(b"old".to_vec()), + after: Some(b"new".to_vec()), + mode: 0o600, + backup_path: None, + } + } + #[test] + fn shared_record_fixtures_match_host_contract_and_first_pending_shape() { + let t = Temp::new(); + let s = t.store(); + for (json, host, valid) in [ + ( + include_str!("../../../tests/fixtures/desktop-cli-record/notify-pending.json"), + "win32", + true, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/valid-darwin.json"), + "darwin", + true, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/valid-win32.json"), + "win32", + true, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/valid-linux.json"), + "linux", + true, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/disabled-tombstone.json"), + "darwin", + true, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/pending-enabled.json"), + "darwin", + true, + ), + ( + include_str!( + "../../../tests/fixtures/desktop-cli-record/disabled-with-pending.json" + ), + "darwin", + true, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/appimage-kind.json"), + "darwin", + false, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/relative-target.json"), + "darwin", + false, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/dotdot-target.json"), + "darwin", + false, + ), + ( + include_str!("../../../tests/fixtures/desktop-cli-record/first-pending.json"), + "darwin", + true, + ), + ] { + // Windows roots carry backslashes, which must stay escaped inside the JSON text. + let root = s.root.to_str().unwrap().replace('\\', "\\\\"); + let json = json.replace("/fixture/desktop", &root); + let r: Record = serde_json::from_str(&json).unwrap(); + // Path rules follow the real host, so a Windows test run cannot simulate a POSIX host. + // There it checks the real contract instead: any record carrying another platform's + // bundle is refused, and only a bundle-less disabled tombstone stays valid. + if cfg!(windows) && host != "win32" { + let tombstone = !r.enabled && r.bundle.is_none(); + assert_eq!( + s.validate(&r, false).is_ok(), + tombstone, + "{host} fixture on win32" + ); + continue; + } + assert_eq!( + s.validate_for(&r, false, host).is_ok(), + valid, + "{host} fixture: {json}" + ); + if r.enabled && r.bundle.as_ref().unwrap().platform != platform() { + assert!(s.validate(&r, false).is_err()); + } + } + } + #[test] + fn record_roundtrip_validation_and_limits() { + let t = Temp::new(); + let s = t.store(); + let r = Record::fresh(bundle()); + s.save(&r).unwrap(); + assert_eq!(s.read().unwrap(), Some(r.clone())); + let mut bad = r.clone(); + bad.version = 2; + assert!(s.save(&bad).is_err()); + bad = r.clone(); + bad.bundle = None; + assert!(s.save(&bad).is_err()); + bad = r.clone(); + bad.generation = 9_007_199_254_740_992; + assert!(s.save(&bad).is_err()); + bad = r.clone(); + bad.owner_id = "bad".into(); + assert!(s.save(&bad).is_err()); + bad = r.clone(); + bad.bundle.as_mut().unwrap().version = "x".repeat(RECORD_LIMIT); + assert_eq!(s.save(&bad).unwrap_err(), "record-too-large"); + let p = s.root.join("cli.json"); + let old = read_bytes(&p).unwrap().unwrap(); + atomic(&p, Some(&old), &vec![b' '; RECORD_LIMIT + 1], 0o600).unwrap(); + assert_eq!(s.read().unwrap_err(), "record-too-large"); + } + #[test] + fn unsaved_first_transaction_journals_are_cleaned_on_reopen() { + let t = Temp::new(); + let s = t.store(); + let p = t.0.join(".zshrc"); + atomic(&p, None, b"old", 0o600).unwrap(); + let mut r = Record::fresh(bundle()); + let pending = s.prepare(&r, r.clone(), &[change(&p)], "install").unwrap(); + let leftover = pending.pending.unwrap().changes[0].journal_file.clone(); + assert!(Path::new(&leftover).is_file()); + drop(s); + let s = t.store(); + assert!(!Path::new(&leftover).exists()); + let mut c = change(&p); + c.after = Some(b"different".to_vec()); + s.transact(&mut r.clone(), r.clone(), vec![c], "install") + .unwrap(); + r = s.read().unwrap().unwrap(); + assert!(r.pending.is_none()); + assert_eq!(fs::read(p).unwrap(), b"different"); + } + #[test] + fn final_save_before_journal_deletion_is_cleaned_on_reopen() { + let t = Temp::new(); + let s = t.store(); + let p = t.0.join(".zshrc"); + let r = Record::fresh(bundle()); + atomic(&p, None, b"old", 0o600).unwrap(); + let pending = s.prepare(&r, r.clone(), &[change(&p)], "install").unwrap(); + let journal = pending.pending.as_ref().unwrap(); + atomic(&p, Some(b"old"), b"new", 0o600).unwrap(); + s.save(&journal.next).unwrap(); + let leftover = journal.changes[0].journal_file.clone(); + drop(s); + let s = t.store(); + assert!(!Path::new(&leftover).exists()); + assert!(s.read().unwrap().unwrap().pending.is_none()); + assert_eq!(fs::read(p).unwrap(), b"new"); + } + #[test] + fn corrupt_record_preserves_all_journals_on_reopen() { + let t = Temp::new(); + let s = t.store(); + let r = Record::fresh(bundle()); + let pending = s + .prepare(&r, r.clone(), &[change(&t.0.join(".zshrc"))], "install") + .unwrap(); + let leftover = pending.pending.unwrap().changes[0].journal_file.clone(); + let bytes = fs::read(&leftover).unwrap(); + atomic(&s.root.join("cli.json"), None, b"{", 0o600).unwrap(); + drop(s); + assert!(Store::open(t.0.join("record"), vec![]).is_err()); + assert!(fs::read(leftover).unwrap() == bytes); + } + #[test] + fn referenced_legacy_and_txid_journals_survive_cleanup() { + for legacy in [false, true] { + let t = Temp::new(); + let s = t.store(); + let r = Record::fresh(bundle()); + let p = t.0.join(".zshrc"); + atomic(&p, None, b"old", 0o600).unwrap(); + let mut pending = s.prepare(&r, r.clone(), &[change(&p)], "install").unwrap(); + let c = &mut pending.pending.as_mut().unwrap().changes[0]; + if legacy { + let old = s.root.join("journal/2-0.json"); + fs::rename(&c.journal_file, &old).unwrap(); + c.journal_file = old.to_string_lossy().into_owned(); + } + let referenced = c.journal_file.clone(); + s.save(&pending).unwrap(); + atomic(&s.root.join("journal/unrelated.txt"), None, b"keep", 0o600).unwrap(); + drop(s); + let s = t.store(); + assert!(Path::new(&referenced).is_file()); + assert!(s.root.join("journal/unrelated.txt").is_file()); + s.recover(&mut pending).unwrap(); + assert_eq!(fs::read(p).unwrap(), b"new"); + } + } + #[test] + fn broadcast_debt_survives_failure_and_remove_then_retries_once() { + let t = Temp::new(); + let s = t.store(); + let mut r = Record::fresh(bundle()); + r.notify_pending = true; + s.save(&r).unwrap(); + assert_eq!( + s.notify_with(&mut r, || Err("environment-broadcast-failed".into())) + .unwrap_err(), + "environment-broadcast-failed" + ); + assert!(s.read().unwrap().unwrap().notify_pending); + r.enabled = false; + r.windows = None; + s.save(&r).unwrap(); + drop(s); + let s = t.store(); + let mut r = s.read().unwrap().unwrap(); + let count = std::cell::Cell::new(0); + s.notify_with(&mut r, || { + count.set(count.get() + 1); + Ok(()) + }) + .unwrap(); + s.notify_with(&mut r, || panic!("settled debt must not broadcast again")) + .unwrap(); + assert_eq!(count.get(), 1); + assert!(!s.read().unwrap().unwrap().notify_pending); + } + #[cfg(windows)] + #[test] + fn registry_install_remove_recovery_and_rollback_keep_durable_broadcast_debt() { + use std::cell::RefCell; + for scenario in ["install", "remove", "rollback", "crash-after-write"] { + let t = Temp::new(); + let s = t.store(); + let mut current = Record::fresh(bundle()); + let mut next = current.clone(); + let (_, owned) = + crate::cli_command_windows::prepend("old", r"C:\Desktop", "REG_SZ").unwrap(); + if scenario == "remove" { + current.enabled = false; + current.windows = Some(owned); + next = current.clone(); + next.windows = None; + } else { + next.windows = Some(owned); + } + let c = Change { + kind: "registry-sz".into(), + path: "HKCU\\Environment\\Path".into(), + before: Some(b"old".to_vec()), + after: Some(b"new".to_vec()), + mode: 0, + backup_path: None, + }; + current = s + .prepare( + ¤t, + next, + &[c], + if scenario == "remove" { + "remove" + } else { + "install" + }, + ) + .unwrap(); + assert!(current.notify_pending); + s.save(¤t).unwrap(); + let registry = RefCell::new(Some( + if matches!(scenario, "rollback" | "crash-after-write") { + b"new".to_vec() + } else { + b"old".to_vec() + }, + )); + if scenario == "rollback" { + current.enabled = false; + current.pending.as_mut().unwrap().next.enabled = false; + // Exercise legacy debt acquisition in the save that starts rollback. + current.notify_pending = false; + s.save(¤t).unwrap(); + } + s.recover_with( + &mut current, + |_| Ok(registry.borrow().clone()), + |c| { + assert!(s.read().unwrap().unwrap().notify_pending); + *registry.borrow_mut() = c.after.clone(); + Ok(()) + }, + ) + .unwrap(); + assert!(current.pending.is_none() && current.notify_pending); + assert_eq!( + registry.into_inner().unwrap(), + if scenario == "rollback" { + b"old" + } else { + b"new" + } + ); + if scenario == "remove" { + assert!(current.windows.is_none()); + } + assert!(s + .notify_with(&mut current, || Err("environment-broadcast-failed".into())) + .is_err()); + assert!(s.read().unwrap().unwrap().notify_pending); + drop(s); + let s = t.store(); + let mut current = s.read().unwrap().unwrap(); + let count = std::cell::Cell::new(0); + s.notify_with(&mut current, || { + count.set(count.get() + 1); + Ok(()) + }) + .unwrap(); + s.notify_with(&mut current, || panic!("broadcast-must-not-repeat")) + .unwrap(); + assert_eq!(count.get(), 1); + assert!(!s.read().unwrap().unwrap().notify_pending); + } + } + #[cfg(target_os = "macos")] + #[test] + fn rc_acl_refuses_replay_and_rollback_without_losing_pending_ownership() { + for rollback in [false, true] { + let t = Temp::new(); + let s = t.store(); + let p = t.0.join(".zshrc"); + let mut r = Record::fresh(bundle()); + atomic(&p, None, if rollback { b"new" } else { b"old" }, 0o600).unwrap(); + r = s.prepare(&r, r.clone(), &[change(&p)], "install").unwrap(); + if rollback { + r.enabled = false; + r.pending.as_mut().unwrap().next.enabled = false; + } + s.save(&r).unwrap(); + chmod_acl(&p, true); + assert_eq!(s.recover(&mut r).unwrap_err(), "rc-acl-present"); + assert!(s.read().unwrap().unwrap().pending.is_some()); + assert_eq!( + fs::read(&p).unwrap(), + if rollback { b"new" } else { b"old" } + ); + assert!(rc_acl_check(&p).is_err()); + chmod_acl(&p, false); + s.recover(&mut r).unwrap(); + assert_eq!(fs::read(p).unwrap(), if rollback { b"old" } else { b"new" }); + } + } + #[cfg(target_os = "macos")] + pub(crate) fn chmod_acl(path: &Path, add: bool) { + let mut command = std::process::Command::new("/bin/chmod"); + if add { + command.args(["+a", if path.is_dir() { "everyone allow read,readattr,readextattr,readsecurity,file_inherit,directory_inherit" } else { "everyone allow read,readattr,readextattr,readsecurity" }]); + } else { + command.arg("-N"); + } + assert!( + command + .arg(path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap() + .success(), + "acl-command-failed" + ); + } + #[cfg(target_os = "macos")] + #[test] + fn acl_empty_missing_inherited_and_new_store_contract() { + let t = Temp::new(); + assert!(!acl::present(&t.0).unwrap()); + assert!(acl::present(&t.0.join("missing")).is_err()); + chmod_acl(&t.0, true); + let inherited = t.0.join("inherited"); + fs::create_dir(&inherited).unwrap(); + fs::set_permissions(&inherited, { + use std::os::unix::fs::PermissionsExt; + fs::Permissions::from_mode(0o700) + }) + .unwrap(); + assert!(acl::present(&inherited).unwrap()); + assert!(private_dir(&inherited).is_err()); + chmod_acl(&inherited, false); + private_dir(&inherited).unwrap(); + let s = t.store(); + assert!(!acl::present(&s.root).unwrap()); + atomic(&s.root.join("journal/2-0.json"), None, b"test", 0o600).unwrap(); + assert!(!acl::present(&s.root.join("journal/2-0.json")).unwrap()); + let root = s.root.clone(); + drop(s); + Store::open(root, vec![]).unwrap(); + } + #[cfg(target_os = "macos")] + #[test] + fn existing_private_acl_is_refused_before_any_store_write() { + for name in [ + "", + "bin", + "journal", + "backups", + "cli.json", + "cli.lock", + "bin/ocx", + "path.sh", + "journal/2-0.json", + "backups/test", + ] { + let t = Temp::new(); + let s = t.store(); + s.save(&Record::fresh(bundle())).unwrap(); + for (file, mode) in [ + ("bin/ocx", 0o700), + ("path.sh", 0o600), + ("journal/2-0.json", 0o600), + ("backups/test", 0o600), + ] { + atomic(&s.root.join(file), None, b"keep", mode).unwrap(); + } + let record = fs::read(s.root.join("cli.json")).unwrap(); + let root = s.root.clone(); + drop(s); + chmod_acl(&root.join(name), true); + assert!(Store::open(root.clone(), vec![]).is_err()); + assert!(fs::read(root.join("cli.json")).unwrap() == record); + assert!(root.join("journal/2-0.json").is_file()); + chmod_acl(&root.join(name), false); + } + } + #[cfg(target_os = "linux")] + #[test] + fn linux_acl_inheritance_clear_and_existing_child_refusal() { + let t = Temp::new(); + let set = |path: &Path, spec: &str| { + std::process::Command::new("setfacl") + .args(["-m", spec]) + .arg(path) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + }; + let result = set(&t.0, "d:u:65534:rwx"); + if result + .as_ref() + .is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound) + { + eprintln!("acl-test-skipped-setfacl-unavailable"); + return; + } + assert!(result.unwrap().success(), "acl-command-failed"); + let s = t.store(); + assert!(!acl::present(&s.root).unwrap()); + let p = s.root.join("backups/test"); + atomic(&p, None, b"keep", 0o600).unwrap(); + assert!(!acl::present(&p).unwrap()); + assert!(set(&p, "u:65534:r--").unwrap().success()); + // Keep the numeric mode private so this negative specifically proves ACL inspection. + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&p, fs::Permissions::from_mode(0o600)).unwrap(); + } + let root = s.root.clone(); + drop(s); + assert!(Store::open(root, vec![]).is_err()); + } + #[test] + fn corrupt_record_does_not_become_first_run() { + let t = Temp::new(); + let s = t.store(); + atomic(&s.root.join("cli.json"), None, b"{", 0o600).unwrap(); + assert_eq!(s.read().unwrap_err(), "record-invalid"); + } + #[test] + fn external_journal_recovery_state_table_and_idempotence() { + for op in ["install", "remove"] { + for wanted in [false, true] { + for state in ["before", "after", "other"] { + let t = Temp::new(); + let s = t.store(); + let p = t.0.join(".zshrc"); + let mut r = Record::fresh(bundle()); + r.enabled = (op == "install") == wanted; + let c = change(&p); + let bytes = match state { + "before" => b"old", + "after" => b"new", + _ => b"usr", + }; + atomic(&p, None, bytes, 0o600).unwrap(); + r = s.prepare(&r, r.clone(), &[c], op).unwrap(); + s.save(&r).unwrap(); + let result = s.recover(&mut r); + if state == "other" { + assert_eq!(result.unwrap_err(), "journal-conflict"); + assert_eq!(fs::read(&p).unwrap(), b"usr"); + assert!(s.read().unwrap().unwrap().pending.is_some()); + } else { + result.unwrap(); + assert_eq!(fs::read(&p).unwrap(), if wanted { b"new" } else { b"old" }); + assert!(r.pending.is_none()); + s.recover(&mut r).unwrap(); + assert_eq!(fs::read_dir(s.root.join("journal")).unwrap().count(), 0); + } + } + } + } + } + #[test] + fn journal_digest_mismatch_and_unauthorized_paths_are_refused() { + let t = Temp::new(); + let s = t.store(); + let mut r = Record::fresh(bundle()); + assert!(s + .prepare(&r, r.clone(), &[change(&t.0.join("unowned"))], "install") + .is_err()); + r = s + .prepare(&r, r.clone(), &[change(&t.0.join(".zshrc"))], "install") + .unwrap(); + let c = &r.pending.as_ref().unwrap().changes[0]; + let p = Path::new(&c.journal_file); + let old = read_bytes(p).unwrap().unwrap(); + let mut j: JournalFile = serde_json::from_slice(&old).unwrap(); + j.after = Some(STANDARD.encode(b"tampered")); + atomic(p, Some(&old), &serde_json::to_vec(&j).unwrap(), 0o600).unwrap(); + s.save(&r).unwrap(); + assert_eq!(s.recover(&mut r).unwrap_err(), "journal-digest-mismatch"); + assert!(s.read().unwrap().unwrap().pending.is_some()); + } + #[test] + fn oversize_journal_and_change_roster_are_refused_before_save() { + let t = Temp::new(); + let s = t.store(); + let r = Record::fresh(bundle()); + let mut c = change(&t.0.join(".zshrc")); + c.after = Some(vec![b'x'; JOURNAL_LIMIT]); + assert_eq!( + s.prepare(&r, r.clone(), &[c], "install").unwrap_err(), + "rc-too-large" + ); + assert!(s + .prepare( + &r, + r.clone(), + &vec![change(&t.0.join(".zshrc")); 33], + "install" + ) + .is_err()); + assert!(s.read().unwrap().is_none()); + assert_eq!(fs::read_dir(s.root.join("journal")).unwrap().count(), 0); + } + #[test] + fn disabled_pending_install_rolls_back_every_completed_prefix() { + for prefix in 0..=2 { + let t = Temp::new(); + let s = t.store(); + let mut r = Record::fresh(bundle()); + let changes: Vec<_> = [s.root.join("bin/ocx"), s.root.join("path.sh")] + .iter() + .map(|p| { + let mut c = change(p); + c.before = None; + c + }) + .collect(); + r = s.prepare(&r, r.clone(), &changes, "install").unwrap(); + s.save(&r).unwrap(); + for c in changes.iter().take(prefix) { + atomic(Path::new(&c.path), None, c.after.as_ref().unwrap(), c.mode).unwrap(); + } + r.enabled = false; + r.generation += 1; + let j = r.pending.as_mut().unwrap(); + j.next.enabled = false; + j.next.generation = r.generation + 1; + s.save(&r).unwrap(); + let mut reloaded = s.read().unwrap().unwrap(); + s.recover(&mut reloaded).unwrap(); + assert!(!reloaded.enabled && reloaded.pending.is_none()); + for c in changes { + assert!(!Path::new(&c.path).exists()); + } + } + } + #[test] + fn large_rc_bytes_stay_in_external_private_journal_and_backup() { + let t = Temp::new(); + let s = t.store(); + let p = t.0.join(".zshrc"); + let r = Record::fresh(bundle()); + let mut c = change(&p); + c.before = Some(vec![b'x'; 256 * 1024]); + c.backup_path = Some( + s.root + .join("backups/original") + .to_string_lossy() + .into_owned(), + ); + atomic(&p, None, c.before.as_ref().unwrap(), 0o600).unwrap(); + let mut pending = s.prepare(&r, r.clone(), &[c.clone()], "install").unwrap(); + s.save(&pending).unwrap(); + assert!(fs::metadata(s.root.join("cli.json")).unwrap().len() < RECORD_LIMIT as u64); + let json: serde_json::Value = + serde_json::from_slice(&fs::read(s.root.join("cli.json")).unwrap()).unwrap(); + assert!(json["pending"]["changes"][0].get("before").is_none()); + assert!(private_bytes( + Path::new(&pending.pending.as_ref().unwrap().changes[0].journal_file), + JOURNAL_LIMIT, + "limit" + ) + .unwrap() + .is_some()); + s.recover(&mut pending).unwrap(); + assert_eq!(fs::read(c.backup_path.unwrap()).unwrap(), c.before.unwrap()); + } + #[cfg(unix)] + #[test] + fn rc_ownership_limit_and_lexical_path_validation_are_independent_of_desired_targets() { + let t = Temp::new(); + let s = t.store(); + let mut r = Record::fresh(bundle()); + r.posix = Some(Posix { + bin_directory: s.root.join("bin").to_string_lossy().into_owned(), + files: vec![], + rc_files: (0..16) + .map(|i| RcFile { + shell: "zsh".into(), + path: t.0.join(format!("old-{i}")).to_string_lossy().into_owned(), + block_sha256: hash(b"block"), + created: true, + backup_path: None, + result: "installed".into(), + }) + .collect(), + }); + s.validate(&r, false).unwrap(); + let mut extra = r.posix.as_ref().unwrap().rc_files[0].clone(); + extra.path = t.0.join("extra").to_string_lossy().into_owned(); + r.posix.as_mut().unwrap().rc_files.push(extra); + assert!(s.validate(&r, false).is_err()); + r.posix.as_mut().unwrap().rc_files.pop(); + for bad in [ + "relative", + "/example/./ocx", + "/example/../ocx", + "/example/ocx\n", + "/example/ocx\0", + ] { + r.bundle.as_mut().unwrap().cli_executable = bad.into(); + assert!(s.validate(&r, false).is_err()); + } + r.bundle.as_mut().unwrap().cli_executable = format!("/{}", "x".repeat(4096)); + assert!(s.validate(&r, false).is_err()); + } + #[test] + fn lock_and_atomic_write_preserve_concurrent_user_edits() { + let t = Temp::new(); + let s = t.store(); + assert!(Store::open(s.root.clone(), vec![]).is_err()); + drop(s); + let _s = t.store(); + let p = t.0.join("file"); + atomic(&p, None, b"user", 0o600).unwrap(); + assert_eq!( + atomic(&p, Some(b"old"), b"replacement", 0o600).unwrap_err(), + "concurrent-edit" + ); + assert_eq!(fs::read(p).unwrap(), b"user"); + } + #[cfg(unix)] + #[test] + fn symlinks_special_files_readonly_and_unsafe_parents_are_refused() { + use std::os::unix::fs::{symlink, PermissionsExt}; + let t = Temp::new(); + let p = t.0.join("file"); + atomic(&p, None, b"bytes", 0o600).unwrap(); + symlink(&p, t.0.join("link")).unwrap(); + assert!(read_bytes(&t.0.join("link")).is_err()); + assert!(check(&t.0, false).is_err()); + fs::set_permissions(&p, fs::Permissions::from_mode(0o400)).unwrap(); + assert!(read_bytes(&p).is_err()); + symlink(&t.0, t.0.join("parent")).unwrap(); + assert!(atomic(&t.0.join("parent/child"), None, b"x", 0o600).is_err()); + } +} diff --git a/desktop/src-tauri/src/cli_command_windows.rs b/desktop/src-tauri/src/cli_command_windows.rs new file mode 100644 index 00000000000..fcabe70b508 --- /dev/null +++ b/desktop/src-tauri/src/cli_command_windows.rs @@ -0,0 +1,1037 @@ +#[cfg(any(windows, test))] +use crate::cli_command_record::Windows; +#[cfg(windows)] +use crate::cli_command_record::{Bundle, Record}; +use crate::cli_command_record::{Change, Result}; +#[cfg(windows)] +use std::path::Path; +#[cfg(windows)] +pub(crate) mod private_acl { + use super::*; + use std::{ffi::c_void, os::windows::ffi::OsStrExt, ptr}; + type Ptr = *mut c_void; + const UNSAFE: &str = "private-acl-unsafe"; + const ACL_REVISION: u32 = 2; + const FULL_CONTROL: u32 = 0x1f01ff; + const OBJECT_INHERIT_ACE: u32 = 0x1; + const CONTAINER_INHERIT_ACE: u32 = 0x2; + const SE_FILE_OBJECT: u32 = 1; + const DACL_SECURITY_INFORMATION: u32 = 4; + const PROTECTED_DACL_SECURITY_INFORMATION: u32 = 0x80000000; + #[repr(C)] + struct Acl { + revision: u8, + reserved: u8, + size: u16, + count: u16, + reserved2: u16, + } + #[repr(C)] + struct AceHeader { + kind: u8, + flags: u8, + size: u16, + } + #[repr(C)] + struct Sid { + revision: u8, + count: u8, + authority: [u8; 6], + sub: [u32; 2], + } + #[link(name = "advapi32")] + unsafe extern "system" { + fn OpenProcessToken(process: Ptr, access: u32, token: *mut Ptr) -> i32; + fn GetTokenInformation( + token: Ptr, + class: u32, + info: Ptr, + size: u32, + needed: *mut u32, + ) -> i32; + fn CreateWellKnownSid(kind: u32, domain: Ptr, sid: Ptr, size: *mut u32) -> i32; + fn InitializeAcl(acl: *mut Acl, size: u32, revision: u32) -> i32; + fn AddAccessAllowedAceEx( + acl: *mut Acl, + revision: u32, + flags: u32, + mask: u32, + sid: Ptr, + ) -> i32; + fn SetNamedSecurityInfoW( + name: *mut u16, + kind: u32, + info: u32, + owner: Ptr, + group: Ptr, + dacl: *mut Acl, + sacl: *mut Acl, + ) -> u32; + fn GetNamedSecurityInfoW( + name: *const u16, + kind: u32, + info: u32, + owner: *mut Ptr, + group: *mut Ptr, + dacl: *mut *mut Acl, + sacl: *mut *mut Acl, + descriptor: *mut Ptr, + ) -> u32; + fn GetSecurityDescriptorControl( + descriptor: Ptr, + control: *mut u16, + revision: *mut u32, + ) -> i32; + fn GetSecurityDescriptorDacl( + descriptor: Ptr, + present: *mut i32, + dacl: *mut *mut Acl, + defaulted: *mut i32, + ) -> i32; + fn EqualSid(a: Ptr, b: Ptr) -> i32; + fn IsValidSid(sid: Ptr) -> i32; + fn IsValidAcl(acl: *mut Acl) -> i32; + fn GetAce(acl: *mut Acl, index: u32, ace: *mut Ptr) -> i32; + fn GetLengthSid(sid: Ptr) -> u32; + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> Ptr; + fn CloseHandle(handle: Ptr) -> i32; + fn LocalFree(memory: Ptr) -> Ptr; + } + struct Local(Ptr); + impl Drop for Local { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + LocalFree(self.0); + } + } + } + } + struct Token(Ptr); + impl Drop for Token { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } + } + fn user() -> Result> { + let mut token = ptr::null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), 0x8, &mut token) } == 0 { + return Err(UNSAFE.into()); + } + let token = Token(token); + let mut needed = 0; + unsafe { + GetTokenInformation(token.0, 1, ptr::null_mut(), 0, &mut needed); + } + if needed < std::mem::size_of::() as u32 || needed > 65536 { + return Err(UNSAFE.into()); + } + let mut bytes = vec![0usize; (needed as usize).div_ceil(std::mem::size_of::())]; + if unsafe { + GetTokenInformation(token.0, 1, bytes.as_mut_ptr().cast(), needed, &mut needed) + } == 0 + { + return Err(UNSAFE.into()); + } + if unsafe { IsValidSid(bytes[0] as Ptr) } == 0 { + return Err(UNSAFE.into()); + } + Ok(bytes) + } + fn wide(path: &Path) -> Result> { + let out: Vec<_> = path.as_os_str().encode_wide().collect(); + if out.contains(&0) { + return Err(UNSAFE.into()); + } + Ok(out.into_iter().chain(Some(0)).collect()) + } + pub fn verify(path: &Path, root: bool) -> Result<()> { + let user = user()?; + let name = wide(path)?; + let mut owner = ptr::null_mut(); + let mut dacl = ptr::null_mut(); + let mut descriptor = ptr::null_mut(); + let code = unsafe { + GetNamedSecurityInfoW( + name.as_ptr(), + 1, + 1 | 4, + &mut owner, + ptr::null_mut(), + &mut dacl, + ptr::null_mut(), + &mut descriptor, + ) + }; + // Owner and DACL point inside this allocation; only the descriptor is freed. + let descriptor = Local(descriptor); + if code != 0 || descriptor.0.is_null() || owner.is_null() || dacl.is_null() { + return Err(UNSAFE.into()); + } + let mut control = 0; + let mut revision = 0; + let mut present = 0; + let mut defaulted = 0; + if unsafe { GetSecurityDescriptorControl(descriptor.0, &mut control, &mut revision) } == 0 + || unsafe { + GetSecurityDescriptorDacl(descriptor.0, &mut present, &mut dacl, &mut defaulted) + } == 0 + || present == 0 + || dacl.is_null() + || unsafe { IsValidAcl(dacl) } == 0 + || unsafe { IsValidSid(owner) } == 0 + || unsafe { EqualSid(owner, user[0] as Ptr) } == 0 + || root && control & 0x1000 == 0 + { + return Err(UNSAFE.into()); + } + let mut system = Sid { + revision: 1, + count: 1, + authority: [0, 0, 0, 0, 0, 5], + sub: [18, 0], + }; + let mut admins = Sid { + revision: 1, + count: 2, + authority: [0, 0, 0, 0, 0, 5], + sub: [32, 544], + }; + let mut full_control = false; + for i in 0..unsafe { (*dacl).count } as u32 { + let mut ace = ptr::null_mut(); + if unsafe { GetAce(dacl, i, &mut ace) } == 0 || ace.is_null() { + return Err(UNSAFE.into()); + } + let header = unsafe { &*ace.cast::() }; + if header.kind != 0 || header.size < 16 || root && header.flags & 0x10 != 0 { + return Err(UNSAFE.into()); + } + let mask = unsafe { *ace.cast::().add(4).cast::() }; + let sid = unsafe { ace.cast::().add(8).cast::() }; + if unsafe { IsValidSid(sid) } == 0 + || unsafe { GetLengthSid(sid) } + 8 > header.size as u32 + { + return Err(UNSAFE.into()); + } + let is_user = unsafe { EqualSid(sid, user[0] as Ptr) } != 0; + if !is_user + && unsafe { EqualSid(sid, (&mut system as *mut Sid).cast()) } == 0 + && unsafe { EqualSid(sid, (&mut admins as *mut Sid).cast()) } == 0 + { + return Err(UNSAFE.into()); + } + // INHERIT_ONLY_ACE does not grant this object access. + full_control |= is_user + && header.flags & 0x8 == 0 + && (mask & 0x1f01ff == 0x1f01ff || mask & 0x10000000 != 0); + } + if !full_control { + return Err(UNSAFE.into()); + } + Ok(()) + } + fn well_known_sid(kind: u32) -> Result<[u32; 17]> { + // SECURITY_MAX_SID_SIZE is 68 bytes. Caller-owned, DWORD-aligned storage + // needs no LocalFree/FreeSid and remains alive while building the ACL. + let mut sid = [0u32; 17]; + let mut size = std::mem::size_of_val(&sid) as u32; + if unsafe { CreateWellKnownSid(kind, ptr::null_mut(), sid.as_mut_ptr().cast(), &mut size) } + == 0 + { + return Err(UNSAFE.into()); + } + Ok(sid) + } + fn allowed_acl(sids: &[Ptr], flags: u32) -> Result> { + let size = std::mem::size_of::() as u32 + + sids + .iter() + .map(|sid| 8 + unsafe { GetLengthSid(*sid) }) + .sum::(); + let mut buffer = vec![0u32; (size as usize).div_ceil(std::mem::size_of::())]; + let acl = buffer.as_mut_ptr().cast::(); + if unsafe { InitializeAcl(acl, size, ACL_REVISION) } == 0 { + return Err(UNSAFE.into()); + } + for sid in sids { + if unsafe { AddAccessAllowedAceEx(acl, ACL_REVISION, flags, FULL_CONTROL, *sid) } == 0 { + return Err(UNSAFE.into()); + } + } + Ok(buffer) + } + pub fn harden(path: &Path, directory: bool) -> Result<()> { + let user = user()?; + let mut system = well_known_sid(22)?; // WinLocalSystemSid + let mut admins = well_known_sid(26)?; // WinBuiltinAdministratorsSid + let mut acl = allowed_acl( + &[ + user[0] as Ptr, + system.as_mut_ptr().cast(), + admins.as_mut_ptr().cast(), + ], + if directory { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + 0 + }, + )?; + let mut name = wide(path)?; + if unsafe { + SetNamedSecurityInfoW( + name.as_mut_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + ptr::null_mut(), + ptr::null_mut(), + acl.as_mut_ptr().cast(), + ptr::null_mut(), + ) + } != 0 + { + return Err(UNSAFE.into()); + } + verify(path, directory) + } + #[cfg(test)] + mod tests { + use super::*; + use crate::cli_command_record::{self as record, tests::Temp, Store}; + #[link(name = "advapi32")] + unsafe extern "system" { + fn AddAccessAllowedAce(acl: *mut Acl, revision: u32, mask: u32, sid: Ptr) -> i32; + } + #[test] + fn harden_sets_only_three_full_control_aces_with_directory_inheritance() { + let t = Temp::new(); + let root = t.0.join("root"); + record::private_dir(&root).unwrap(); + let file = root.join("file"); + std::fs::write(&file, b"test").unwrap(); + harden(&file, false).unwrap(); + let user = user().unwrap(); + let mut system = well_known_sid(22).unwrap(); + let mut admins = well_known_sid(26).unwrap(); + let sids = [ + user[0] as Ptr, + system.as_mut_ptr().cast(), + admins.as_mut_ptr().cast(), + ]; + for (path, flags) in [(&root, 0x3), (&file, 0)] { + let name = wide(path).unwrap(); + let mut dacl = ptr::null_mut(); + let mut descriptor = ptr::null_mut(); + assert_eq!( + unsafe { + GetNamedSecurityInfoW( + name.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + ptr::null_mut(), + ptr::null_mut(), + &mut dacl, + ptr::null_mut(), + &mut descriptor, + ) + }, + 0 + ); + let descriptor = Local(descriptor); + assert!(!dacl.is_null()); + let mut control = 0; + let mut revision = 0; + assert_ne!( + unsafe { + GetSecurityDescriptorControl(descriptor.0, &mut control, &mut revision) + }, + 0 + ); + assert_ne!(control & 0x1000, 0); + assert_eq!(unsafe { (*dacl).count }, 3); + for (index, sid) in sids.iter().enumerate() { + let mut ace = ptr::null_mut(); + assert_ne!(unsafe { GetAce(dacl, index as u32, &mut ace) }, 0); + let header = unsafe { &*ace.cast::() }; + assert_eq!(header.kind, 0); + assert_eq!(header.flags, flags); + assert_eq!( + unsafe { *ace.cast::().add(4).cast::() }, + FULL_CONTROL + ); + assert_ne!(unsafe { EqualSid(ace.cast::().add(8).cast(), *sid) }, 0); + } + } + } + #[test] + fn hardened_root_and_inherited_child_are_valid_after_store_reopen() { + let t = Temp::new(); + let s = t.store(); + verify(&s.root, true).unwrap(); + let child = s.root.join("backups/child"); + std::fs::write(&child, b"test").unwrap(); + verify(&child, false).unwrap(); + let root = s.root.clone(); + drop(s); + let s = Store::open(root, vec![]).unwrap(); + verify(&s.root, true).unwrap(); + verify(&child, false).unwrap(); + } + #[test] + fn root_without_protection_is_refused_even_without_inherited_aces() { + let t = Temp::new(); + // Replace the temporary parent's whole DACL with one non-inheritable user ACE. + let user = user().unwrap(); + let size = + std::mem::size_of::() as u32 + 8 + unsafe { GetLengthSid(user[0] as Ptr) }; + let mut buffer = vec![0usize; (size as usize).div_ceil(std::mem::size_of::())]; + let parent_acl = buffer.as_mut_ptr().cast::(); + assert_ne!(unsafe { InitializeAcl(parent_acl, size, 2) }, 0); + assert_ne!( + unsafe { AddAccessAllowedAce(parent_acl, 2, 0x1f01ff, user[0] as Ptr) }, + 0 + ); + let mut parent_name = wide(&t.0).unwrap(); + assert_eq!( + unsafe { + SetNamedSecurityInfoW( + parent_name.as_mut_ptr(), + 1, + 4 | 0x80000000, + ptr::null_mut(), + ptr::null_mut(), + parent_acl, + ptr::null_mut(), + ) + }, + 0 + ); + verify(&t.0, true).unwrap(); + let mut dacl = ptr::null_mut(); + let mut descriptor = ptr::null_mut(); + assert_eq!( + unsafe { + GetNamedSecurityInfoW( + parent_name.as_ptr(), + 1, + 4, + ptr::null_mut(), + ptr::null_mut(), + &mut dacl, + ptr::null_mut(), + &mut descriptor, + ) + }, + 0 + ); + let descriptor = Local(descriptor); + assert!(!dacl.is_null()); + assert_eq!(unsafe { (*dacl).count }, 1); + let mut ace = ptr::null_mut(); + assert_ne!(unsafe { GetAce(dacl, 0, &mut ace) }, 0); + assert_eq!(unsafe { (*ace.cast::()).flags } & 0x3, 0); + drop(descriptor); + + let root = t.0.join("root"); + record::private_dir(&root).unwrap(); + let mut name = wide(&root).unwrap(); + let mut dacl = ptr::null_mut(); + let mut descriptor = ptr::null_mut(); + assert_eq!( + unsafe { + GetNamedSecurityInfoW( + name.as_ptr(), + 1, + 4, + ptr::null_mut(), + ptr::null_mut(), + &mut dacl, + ptr::null_mut(), + &mut descriptor, + ) + }, + 0 + ); + let descriptor = Local(descriptor); + assert!(!dacl.is_null()); + // Preserve the explicit, full-control root ACL and only remove its protection. + assert_eq!( + unsafe { + SetNamedSecurityInfoW( + name.as_mut_ptr(), + 1, + 4 | 0x20000000, + ptr::null_mut(), + ptr::null_mut(), + dacl, + ptr::null_mut(), + ) + }, + 0 + ); + drop(descriptor); + let mut checked = ptr::null_mut(); + let mut descriptor = ptr::null_mut(); + assert_eq!( + unsafe { + GetNamedSecurityInfoW( + name.as_ptr(), + 1, + 4, + ptr::null_mut(), + ptr::null_mut(), + &mut checked, + ptr::null_mut(), + &mut descriptor, + ) + }, + 0 + ); + let descriptor = Local(descriptor); + let mut control = 0; + let mut revision = 0; + assert_ne!( + unsafe { GetSecurityDescriptorControl(descriptor.0, &mut control, &mut revision) }, + 0 + ); + assert_eq!(control & 0x1000, 0); + assert!(!checked.is_null()); + for i in 0..unsafe { (*checked).count } as u32 { + let mut ace = ptr::null_mut(); + assert_ne!(unsafe { GetAce(checked, i, &mut ace) }, 0); + assert_eq!(unsafe { (*ace.cast::()).flags } & 0x10, 0); + } + // All other child ACL rules pass; the root fails specifically on SE_DACL_PROTECTED. + verify(&root, false).unwrap(); + assert_eq!(verify(&root, true).unwrap_err(), UNSAFE); + } + #[test] + fn explicit_everyone_ace_is_refused_on_root_and_child() { + let t = Temp::new(); + let s = t.store(); + let child = s.root.join("backups/child"); + std::fs::write(&child, b"test").unwrap(); + for (path, root) in [(&s.root, true), (&child, false)] { + let user = user().unwrap(); + let mut everyone = well_known_sid(1).unwrap(); // WinWorldSid + let mut acl = + allowed_acl(&[user[0] as Ptr, everyone.as_mut_ptr().cast()], 0).unwrap(); + let mut name = wide(path).unwrap(); + assert_eq!( + unsafe { + SetNamedSecurityInfoW( + name.as_mut_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + ptr::null_mut(), + ptr::null_mut(), + acl.as_mut_ptr().cast(), + ptr::null_mut(), + ) + }, + 0 + ); + assert_eq!(verify(path, root).unwrap_err(), UNSAFE); + } + } + #[test] + fn protected_owned_root_with_null_dacl_is_refused() { + let t = Temp::new(); + let root = t.0.join("root"); + record::private_dir(&root).unwrap(); + let mut name = wide(&root).unwrap(); + assert_eq!( + unsafe { + SetNamedSecurityInfoW( + name.as_mut_ptr(), + 1, + 4 | 0x80000000, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ) + }, + 0 + ); + assert_eq!(verify(&root, true).unwrap_err(), UNSAFE); + harden(&root, true).unwrap(); + } + } +} +#[cfg(any(windows, test))] +fn normalized(s: &str) -> String { + s.replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase() +} +#[cfg(any(windows, test))] +fn parts(s: &str) -> Vec { + if s.is_empty() { + Vec::new() + } else { + s.split(';').map(str::to_owned).collect() + } +} +#[cfg(any(windows, test))] +fn index(v: &[String], entry: &str) -> Result> { + let hits: Vec<_> = v + .iter() + .enumerate() + .filter(|(_, s)| normalized(s) == normalized(entry)) + .map(|(i, _)| i) + .collect(); + if hits.len() > 1 { + Err("path-entry-ambiguous".into()) + } else { + Ok(hits.first().copied()) + } +} +#[cfg(any(windows, test))] +fn entry_ok(s: &str) -> bool { + !s.is_empty() && !s.contains([';', '\0', '\r', '\n']) +} +#[cfg(any(windows, test))] +pub fn prepend(raw: &str, entry: &str, ty: &str) -> Result<(String, Windows)> { + if !entry_ok(entry) || !matches!(ty, "REG_SZ" | "REG_EXPAND_SZ") { + return Err("path-invalid".into()); + } + let mut v = parts(raw); + let found = index(&v, entry)?; + let before = found.and_then(|i| i.checked_sub(1)).map(|i| v[i].clone()); + let after = found.and_then(|i| v.get(i + 1)).cloned(); + let actual = found.map(|i| v.remove(i)).unwrap_or_else(|| entry.into()); + v.insert(0, actual); + let result = v.join(";"); + if result.encode_utf16().count() + 1 > 32_767 { + return Err("path-too-long".into()); + } + Ok(( + result, + Windows { + key: "HKCU\\Environment".into(), + value: "Path".into(), + entry: entry.into(), + value_type: ty.into(), + action: if found.is_some() { + "moved-existing" + } else { + "inserted" + } + .into(), + previous_before: before, + previous_after: after, + }, + )) +} +#[cfg(any(windows, test))] +pub fn remove(raw: &str, owned: &Windows) -> Result { + let mut v = parts(raw); + let Some(i) = index(&v, &owned.entry)? else { + return Ok(raw.into()); + }; + if owned.action == "inserted" { + v.remove(i); + return Ok(v.join(";")); + } + let actual = v.remove(i); + let before = owned + .previous_before + .as_deref() + .map(|s| index(&v, s)) + .transpose()? + .flatten(); + let after = owned + .previous_after + .as_deref() + .map(|s| index(&v, s)) + .transpose()? + .flatten(); + let position = match ( + before, + after, + owned.previous_before.is_none(), + owned.previous_after.is_none(), + ) { + (Some(b), Some(a), _, _) if b + 1 == a => a, + (Some(b), None, _, true) if b + 1 == v.len() => v.len(), + (None, Some(0), true, _) => 0, + (None, None, true, true) if v.is_empty() => 0, + _ => return Err("path-restore-ambiguous".into()), + }; + v.insert(position, actual); + Ok(v.join(";")) +} +#[cfg(any(windows, test))] +pub fn replace_owned_entry( + raw: &str, + owned: Option<&Windows>, + entry: &str, + ty: &str, +) -> Result<(String, Windows)> { + if let Some(old) = owned { + if normalized(&old.entry) == normalized(entry) { + if index(&parts(raw), entry)?.is_none() { + return prepend(raw, entry, ty); + } + let (out, _) = prepend(raw, entry, ty)?; + let mut retained = old.clone(); + retained.value_type = ty.into(); + return Ok((out, retained)); + } + prepend(&remove(raw, old)?, entry, ty) + } else { + prepend(raw, entry, ty) + } +} +#[cfg(windows)] +pub fn stable_bundle(exe: &Path, debug: bool, version: &str) -> Result { + if debug { + return Err("development-launch".into()); + } + let real = std::fs::canonicalize(exe).map_err(|_| "bundle-unavailable")?; + let parent = real.parent().ok_or("unpackaged-launch")?; + // current_exe is already absolute. Do not persist canonical Windows \\?\ extended syntax. + let app = exe.to_str().ok_or("path-not-utf8")?; + let cli = exe.parent().ok_or("unpackaged-launch")?.join("ocx.exe"); + let raw = cli.to_str().ok_or("path-not-utf8")?; + let canonical = real + .to_string_lossy() + .to_ascii_lowercase() + .replace('/', "\\"); + let lower = app.to_ascii_lowercase().replace('/', "\\"); + if lower.contains("\\target\\") + || lower.contains("\\temp\\") + || lower.contains("\\tmp\\") + || canonical.contains("\\target\\") + || canonical.contains("\\temp\\") + || canonical.contains("\\tmp\\") + || !std::fs::symlink_metadata(parent.join("ocx.exe")) + .is_ok_and(|m| m.is_file() && !m.file_type().is_symlink()) + { + return Err("unpackaged-launch".into()); + } + if !entry_ok(raw) || !exe.is_absolute() { + return Err("path-invalid".into()); + } + Ok(Bundle { + platform: "win32".into(), + kind: "windows-install".into(), + app_executable: app.into(), + cli_executable: raw.into(), + version: version.into(), + }) +} +#[cfg(windows)] +mod os { + use super::*; + use winreg::{ + enums::{ + HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_SET_VALUE, REG_EXPAND_SZ, REG_SZ, + }, + RegKey, RegValue, + }; + pub fn raw() -> Result> { + let key = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey_with_flags("Environment", KEY_READ) + .map_err(|_| "registry-read-failed")?; + match key.get_raw_value("Path") { + Ok(v) if matches!(v.vtype, REG_SZ | REG_EXPAND_SZ) => Ok(Some(v)), + Ok(_) => Err("path-type-unsupported".into()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err("registry-read-failed".into()), + } + } + pub fn decode(b: &[u8]) -> Result { + if !b.len().is_multiple_of(2) { + return Err("path-invalid-utf16".into()); + } + let mut words: Vec = b + .as_chunks::<2>() + .0 + .iter() + .map(|c| u16::from_le_bytes(*c)) + .collect(); + if words.pop() != Some(0) || words.contains(&0) { + return Err("path-invalid-utf16".into()); + } + String::from_utf16(&words).map_err(|_| "path-invalid-utf16".into()) + } + pub fn encode(s: &str) -> Vec { + s.encode_utf16() + .chain(Some(0)) + .flat_map(u16::to_le_bytes) + .collect() + } + pub fn type_name(v: &RegValue) -> &'static str { + if v.vtype == REG_EXPAND_SZ { + "REG_EXPAND_SZ" + } else { + "REG_SZ" + } + } + pub fn write(c: &Change) -> Result<()> { + let key = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey_with_flags("Environment", KEY_SET_VALUE) + .map_err(|_| "registry-write-failed")?; + match &c.after { + Some(b) => { + decode(b)?; + key.set_raw_value( + "Path", + &RegValue { + bytes: b.clone(), + vtype: if c.kind == "registry-expand" { + REG_EXPAND_SZ + } else { + REG_SZ + }, + }, + ) + .map_err(|_| "registry-write-failed".into()) + } + None => match key.delete_value("Path") { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err("registry-write-failed".into()), + }, + } + } + pub fn machine_conflict() -> Result { + let k = RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey_with_flags( + "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment", + KEY_READ, + ) + .map_err(|_| "machine-path-unobserved")?; + let v = match k.get_raw_value("Path") { + Ok(v) => v, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(_) => return Err("machine-path-unobserved".into()), + }; + let raw = decode(&v.bytes)?; + // Preserve raw text in storage; expansion below is observe-only for conflict detection. + for p in parts(&raw) { + let expanded = expand(&p)?; + for name in ["ocx.exe", "ocx.com", "ocx.cmd", "ocx.bat"] { + if Path::new(expanded.trim_matches('"')).join(name).is_file() { + return Ok(true); + } + } + } + Ok(false) + } + fn expand(s: &str) -> Result { + #[link(name = "kernel32")] + unsafe extern "system" { + fn ExpandEnvironmentStringsW(src: *const u16, dst: *mut u16, size: u32) -> u32; + } + let src: Vec = s.encode_utf16().chain(Some(0)).collect(); + let mut dst = vec![0u16; 32_767]; + let n = + unsafe { ExpandEnvironmentStringsW(src.as_ptr(), dst.as_mut_ptr(), dst.len() as u32) }; + if n == 0 || n as usize > dst.len() { + return Err("machine-path-unobserved".into()); + } + let out = + String::from_utf16(&dst[..n as usize - 1]).map_err(|_| "machine-path-unobserved")?; + if out.contains('%') { + return Err("machine-path-unobserved".into()); + } + Ok(out) + } + pub fn broadcast() -> Result<()> { + #[link(name = "user32")] + unsafe extern "system" { + fn SendMessageTimeoutW( + hwnd: *mut std::ffi::c_void, + msg: u32, + wparam: usize, + lparam: isize, + flags: u32, + timeout: u32, + result: *mut usize, + ) -> isize; + } + let environment: Vec = "Environment".encode_utf16().chain(Some(0)).collect(); + let mut result = 0usize; + // HWND_BROADCAST, WM_SETTINGCHANGE, SMTO_ABORTIFHUNG; bounded per recipient. + if unsafe { + SendMessageTimeoutW( + 0xffffusize as *mut _, + 0x001a, + 0, + environment.as_ptr() as isize, + 0x0002, + 1000, + &mut result, + ) + } == 0 + { + Err("environment-broadcast-failed".into()) + } else { + Ok(()) + } + } +} +#[cfg(windows)] +pub fn read_change(c: &Change) -> Result>> { + let found = os::raw()?; + if let Some(v) = &found { + if (c.kind == "registry-expand") != (os::type_name(v) == "REG_EXPAND_SZ") { + return Err("path-type-changed".into()); + } + } + Ok(found.map(|v| v.bytes)) +} +#[cfg(not(windows))] +pub fn read_change(_: &Change) -> Result>> { + Err("registry-unsupported".into()) +} +#[cfg(windows)] +pub fn apply_change(c: &Change) -> Result<()> { + if read_change(c)? != c.before { + return Err("concurrent-edit".into()); + } + os::write(c) +} +#[cfg(not(windows))] +pub fn apply_change(_: &Change) -> Result<()> { + Err("registry-unsupported".into()) +} +#[cfg(windows)] +pub fn plan( + current: &Record, + bundle: Bundle, + remove_owned: bool, +) -> Result<(Record, Vec, Vec)> { + let old = os::raw()?; + let ty = old.as_ref().map(os::type_name).unwrap_or("REG_EXPAND_SZ"); + let raw = old + .as_ref() + .map(|v| os::decode(&v.bytes)) + .transpose()? + .unwrap_or_default(); + let mut next = current.clone(); + let mut issues = Vec::new(); + let result = if remove_owned { + if let Some(owned) = ¤t.windows { + remove(&raw, owned)? + } else { + return Ok((next, Vec::new(), issues)); + } + } else { + let entry = Path::new(&bundle.cli_executable) + .parent() + .and_then(|p| p.to_str()) + .ok_or("path-invalid")?; + let (s, owned) = replace_owned_entry(&raw, current.windows.as_ref(), entry, ty)?; + next.bundle = Some(bundle); + next.windows = Some(owned); + s + }; + if remove_owned { + next.windows = None; + } + next.posix = None; + let after = if old.is_none() && result.is_empty() { + None + } else { + Some(os::encode(&result)) + }; + let before = old.map(|v| v.bytes); + let changes = if before == after { + Vec::new() + } else { + vec![Change { + kind: if ty == "REG_EXPAND_SZ" { + "registry-expand" + } else { + "registry-sz" + } + .into(), + path: "HKCU\\Environment\\Path".into(), + before, + after, + mode: 0, + backup_path: None, + }] + }; + if !remove_owned { + match os::machine_conflict() { + Ok(true) => issues.push("machine-path-conflict".into()), + Ok(false) => {} + Err(e) => issues.push(e), + } + } + Ok((next, changes, issues)) +} +#[cfg(windows)] +pub fn notify() -> Result<()> { + os::broadcast() +} +#[cfg(not(windows))] +pub fn notify() -> Result<()> { + Ok(()) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn prepend_preserves_raw_expansions_type_and_empty_entries() { + let raw = r"%APPDATA%\npm;;C:\Tools;"; + for ty in ["REG_SZ", "REG_EXPAND_SZ"] { + let (out, owned) = prepend(raw, r"C:\Program Files\OpenCodex", ty).unwrap(); + assert_eq!(out, format!(r"C:\Program Files\OpenCodex;{raw}")); + assert_eq!(owned.value_type, ty); + assert_eq!(owned.action, "inserted"); + assert_eq!(remove(&out, &owned).unwrap(), raw); + } + } + #[test] + fn moved_existing_entry_is_restored_and_same_entry_repair_keeps_ownership() { + let raw = r"A;C:\Desktop;B"; + let (out, owned) = prepend(raw, r"c:/desktop/", "REG_SZ").unwrap(); + assert_eq!(owned.action, "moved-existing"); + let (twice, retained) = + replace_owned_entry(&out, Some(&owned), r"C:\Desktop", "REG_SZ").unwrap(); + assert_eq!(twice, out); + assert_eq!(retained, owned); + assert_eq!(remove(&out, &owned).unwrap(), raw); + } + #[test] + fn removal_preserves_concurrent_unrelated_entries_and_ambiguity() { + let (out, owned) = prepend(r"A;B", r"C:\Desktop", "REG_EXPAND_SZ").unwrap(); + assert_eq!(remove(&format!("{out};NEW"), &owned).unwrap(), "A;B;NEW"); + let (out, moved) = prepend(r"A;C:\Desktop;B", r"C:\Desktop", "REG_SZ").unwrap(); + assert_eq!( + remove(&out.replace("A;B", "A;NEW;B"), &moved).unwrap_err(), + "path-restore-ambiguous" + ); + } + #[test] + fn replacing_owned_entry_removes_only_the_previous_insertion() { + let (out, old) = prepend("A;B", r"C:\Old", "REG_SZ").unwrap(); + let (new, owned) = replace_owned_entry(&out, Some(&old), r"C:\New", "REG_SZ").unwrap(); + assert_eq!(new, r"C:\New;A;B"); + assert_eq!(remove(&new, &owned).unwrap(), "A;B"); + assert_eq!(remove("A;B", &old).unwrap(), "A;B"); + let (_, moved) = prepend(r"A;C:\Old;B", r"C:\Old", "REG_SZ").unwrap(); + let (repaired, inserted) = + replace_owned_entry("A;B", Some(&moved), r"C:\Old", "REG_SZ").unwrap(); + assert_eq!(inserted.action, "inserted"); + assert_eq!(remove(&repaired, &inserted).unwrap(), "A;B"); + } + #[test] + fn duplicate_invalid_and_oversize_entries_are_refused() { + assert!(prepend(r"C:\Desktop;c:/desktop/", r"C:\Desktop", "REG_SZ").is_err()); + assert!(prepend("A", "bad;entry", "REG_SZ").is_err()); + assert!(prepend("A", r"C:\Desktop", "REG_BINARY").is_err()); + assert!(prepend(&"X".repeat(32_767), r"C:\Desktop", "REG_SZ").is_err()); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0dcd286717d..06c1fa29540 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,5 +1,10 @@ mod auth; mod claim; +mod cli_command; +#[cfg(unix)] +mod cli_command_posix; +mod cli_command_record; +mod cli_command_windows; #[cfg(target_os = "macos")] mod companion_query; mod companion_usage; @@ -213,6 +218,43 @@ fn decide_takeover(app: tauri::AppHandle, approved: bool) { } } +#[tauri::command] +fn cli_status( + window: tauri::WebviewWindow, + app: tauri::AppHandle, +) -> Result { + window::require_cli_page(&window)?; + Ok(cli_command::status(&app)) +} + +#[tauri::command] +async fn cli_set_enabled( + window: tauri::WebviewWindow, + app: tauri::AppHandle, + enabled: bool, +) -> Result { + window::require_cli_page(&window)?; + cli_command::set_enabled(app, enabled).await +} + +#[tauri::command] +async fn cli_install( + window: tauri::WebviewWindow, + app: tauri::AppHandle, +) -> Result { + window::require_cli_page(&window)?; + cli_command::install(app).await +} + +#[tauri::command] +async fn cli_remove( + window: tauri::WebviewWindow, + app: tauri::AppHandle, +) -> Result { + window::require_cli_page(&window)?; + cli_command::remove(app).await +} + #[tauri::command] async fn update_status( window: tauri::WebviewWindow, @@ -247,7 +289,7 @@ async fn update_install( #[tauri::command] fn return_to_dashboard(window: tauri::WebviewWindow, app: tauri::AppHandle) -> Result<(), String> { - window::require_update_page(&window)?; + window::require_local_settings_page(&window)?; startup::return_to_dashboard(&app) } @@ -291,6 +333,10 @@ pub fn run() { startup_phases, retry_startup, decide_takeover, + cli_status, + cli_set_enabled, + cli_install, + cli_remove, update_status, update_check, update_install, @@ -298,6 +344,7 @@ pub fn run() { ]) .setup(|app| { app.manage(AppState::new()); + app.manage(cli_command::State::default()); app.manage(updater::PendingUpdate(Mutex::new(None))); app.manage(updater::DesktopUpdateState::new( app.package_info().version.to_string(), diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs index 0a8e5572d7e..8ceb94fbd48 100644 --- a/desktop/src-tauri/src/startup.rs +++ b/desktop/src-tauri/src/startup.rs @@ -1471,6 +1471,7 @@ async fn register(app: &AppHandle, deadline: Instant) -> Registration { // than the state from before first run. let login = first_run::apply_start_at_login_default(app); first_run::adopt_launch_origin_argument(app); + crate::cli_command::reconcile_on_launch(app); // The verdict is published only once an icon actually exists. Announcing a tray and then // failing to install it would hide the window into nothing, which is the exact stranding D6 @@ -1616,7 +1617,9 @@ fn finish(app: &AppHandle, started: Instant, endpoint: ProxyEndpoint) { let mode = startup .as_ref() .map_or(Mode::Launch, |startup| startup.mode()); - if keeps_update_page(mode, crate::window::shows_update_page(&window)) { + if crate::window::shows_cli_page(&window) + || keeps_update_page(mode, crate::window::shows_update_page(&window)) + { return; } if loads_dashboard_on_ready(LaunchOrigin::detect(), visible, requested) { @@ -1655,8 +1658,9 @@ pub fn open_dashboard(app: &AppHandle) { } pub fn return_to_dashboard(app: &AppHandle) -> Result<(), String> { - let startup = app.try_state::().ok_or("dashboard is not ready")?; - let dashboard = startup.ready_dashboard(); + let dashboard = app + .try_state::() + .and_then(|startup| startup.ready_dashboard()); let window = app .get_webview_window("main") .ok_or("dashboard window is unavailable")?; @@ -1669,7 +1673,11 @@ fn return_ready_dashboard( dashboard: Option<&str>, navigate: impl FnOnce(&str) -> bool, ) -> Result<(), String> { - let dashboard = dashboard.ok_or("dashboard is not ready")?; + let dashboard = dashboard.unwrap_or(if cfg!(target_os = "windows") { + "http://tauri.localhost/index.html" + } else { + "tauri://localhost/index.html" + }); if !navigate(dashboard) { return Err("dashboard could not be opened".into()); } @@ -2377,11 +2385,7 @@ mod tests { } #[test] - fn update_page_return_requires_a_ready_dashboard_and_retries_refused_navigation() { - assert_eq!( - return_ready_dashboard(None, |_| true).unwrap_err(), - "dashboard is not ready" - ); + fn settings_page_return_opens_a_ready_dashboard_and_retries_refused_navigation() { assert_eq!( return_ready_dashboard(Some("http://127.0.0.1:10100/#/usage"), |_| false).unwrap_err(), "dashboard could not be opened" @@ -2397,6 +2401,26 @@ mod tests { assert_eq!(visited.as_deref(), Some("http://127.0.0.1:10100/#/usage")); } + #[test] + fn settings_page_return_without_a_ready_dashboard_opens_the_bundled_startup_page() { + assert_eq!( + return_ready_dashboard(None, |_| false).unwrap_err(), + "dashboard could not be opened" + ); + let mut visited = None; + assert!(return_ready_dashboard(None, |url| { + visited = Some(url.to_owned()); + true + }) + .is_ok()); + let expected = if cfg!(target_os = "windows") { + "http://tauri.localhost/index.html" + } else { + "tauri://localhost/index.html" + }; + assert_eq!(visited.as_deref(), Some(expected)); + } + #[test] fn a_login_launch_hides_only_where_there_is_a_tray_to_hide_in() { assert!(!shows_window( diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs index eb06e2dd1dc..e8e76697ec9 100644 --- a/desktop/src-tauri/src/tray.rs +++ b/desktop/src-tauri/src/tray.rs @@ -72,6 +72,13 @@ fn update_pending(app: &AppHandle) -> bool { pub fn install(app: &AppHandle) -> tauri::Result<()> { let open = MenuItem::with_id(app, "open-dashboard", "Open Dashboard", true, None::<&str>)?; let browser = MenuItem::with_id(app, "open-browser", "Open in Browser", true, None::<&str>)?; + let cli_command = MenuItem::with_id( + app, + "terminal-command", + "Terminal command…", + true, + None::<&str>, + )?; let login = CheckMenuItem::with_id( app, "start-at-login", @@ -110,6 +117,7 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { &show_usage, &open, &browser, + &cli_command, &PredefinedMenuItem::separator(app)?, &login, &stop, @@ -188,6 +196,7 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { let anchor = tray_anchor(app); let _ = popup::show(app, endpoint, anchor); } + "terminal-command" => crate::cli_command::show_page(app), "open-dashboard" => { crate::startup::open_dashboard(app); } diff --git a/desktop/src-tauri/src/window.rs b/desktop/src-tauri/src/window.rs index b5a90c8cbf5..5c69ae17fe7 100644 --- a/desktop/src-tauri/src/window.rs +++ b/desktop/src-tauri/src/window.rs @@ -125,6 +125,43 @@ fn is_update_page_url(url: &Url) -> bool { is_app_origin(url) && url.path() == "/update.html" } +fn is_cli_page_url(url: &Url) -> bool { + is_local_settings_page("main", url) && url.path() == "/cli.html" +} + +fn is_local_settings_page(label: &str, url: &Url) -> bool { + label == "main" + && is_app_origin(url) + && matches!(url.path(), "/update.html" | "/cli.html") + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() +} + +pub fn require_cli_page(window: &WebviewWindow) -> Result<(), String> { + if window.label() != "main" { + return Err("CLI page unavailable".into()); + } + let url = window.url().map_err(|_| "CLI page unavailable")?; + if !is_cli_page_url(&url) { + return Err("CLI page unavailable".into()); + } + Ok(()) +} + +pub fn require_local_settings_page(window: &WebviewWindow) -> Result<(), String> { + let url = window.url().map_err(|_| "settings page unavailable")?; + if !is_local_settings_page(window.label(), &url) { + return Err("settings page unavailable".into()); + } + Ok(()) +} + +pub fn shows_cli_page(window: &WebviewWindow) -> bool { + window.label() == "main" && window.url().is_ok_and(|url| is_cli_page_url(&url)) +} + /// Whether the window currently shows the bundled update page. An unreadable URL reads as not. pub fn shows_update_page(window: &WebviewWindow) -> bool { window.url().is_ok_and(|url| is_update_page_url(&url)) @@ -184,13 +221,45 @@ pub fn set_tray_policy(app: &AppHandle, visible: bool) { #[cfg(test)] mod tests { - use super::{is_app_origin, is_update_page_url, opens_in_default_browser, webview_user_agent}; + use super::{ + is_app_origin, is_cli_page_url, is_local_settings_page, is_update_page_url, + opens_in_default_browser, webview_user_agent, + }; use tauri::Url; fn url(value: &str) -> Url { Url::parse(value).expect("a url") } + #[test] + fn cli_and_local_settings_require_main_and_exact_local_urls() { + for origin in ["tauri://localhost", "http://tauri.localhost"] { + for path in ["/update.html", "/cli.html"] { + let value = url(&format!("{origin}{path}")); + assert!(is_local_settings_page("main", &value)); + assert!(!is_local_settings_page("popup", &value)); + assert_eq!(is_cli_page_url(&value), path == "/cli.html"); + } + } + for value in [ + "http://127.0.0.1:10100/cli.html", + "https://tauri.localhost/cli.html", + "tauri://evil/cli.html", + "http://tauri.localhost:1420/cli.html", + "tauri://localhost:1420/cli.html", + "tauri://localhost/cli.html.evil", + "tauri://localhost/index.html", + "tauri://localhost/cli.html?path=x", + "tauri://localhost/cli.html#x", + "tauri://user@localhost/cli.html", + "http://tauri.localhost:1420/update.html", + "https://evil.example/update.html", + ] { + assert!(!is_local_settings_page("main", &url(value)), "{value}"); + assert!(!is_cli_page_url(&url(value)), "{value}"); + } + } + #[test] fn only_web_addresses_are_handed_to_the_default_browser() { for value in [ diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index b8f069d1d01..60b72fcd690 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.81.0", + "version": "2.82.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/desktop/ui/cli.html b/desktop/ui/cli.html new file mode 100644 index 00000000000..524c80fa028 --- /dev/null +++ b/desktop/ui/cli.html @@ -0,0 +1,38 @@ + + + + + + OpenCodex terminal command + + + + +
+

Terminal command

+ +

Reading command configuration…

+

+
    + +

    After enabling or repairing, open a new terminal. Existing shells and aliases can still select another command. Use type -a ocx on POSIX, or Get-Command ocx -All and where.exe ocx on Windows to check.

    +

    Remove the terminal command here before uninstalling Desktop. Removing it keeps your off choice for the next launch.

    +
    + + + +
    +
    + + + diff --git a/desktop/ui/cli.js b/desktop/ui/cli.js new file mode 100644 index 00000000000..fb8194b840b --- /dev/null +++ b/desktop/ui/cli.js @@ -0,0 +1,84 @@ +"use strict"; +const invoke = window.__TAURI__?.core?.invoke; +const nodes = Object.fromEntries(["enabled", "state", "target", "issues", "error", "repair", "remove", "back", "titlebar"] + .map(id => [id, document.getElementById(id)])); +let busy = false; +let latest = null; +let polling = false; +let generation = 0; +let refreshQueued = false; +function controls() { + nodes.enabled.disabled = busy || !invoke; + nodes.repair.disabled = busy || !invoke || !latest?.enabled; + nodes.remove.disabled = busy || !invoke; + nodes.back.disabled = busy || !invoke; +} +function render(s) { + latest = s; + nodes.enabled.checked = Boolean(s.enabled); + nodes.state.textContent = { + unobserved: "Configuration has not been inspected yet.", + configured: "Desktop command configuration is installed. Check selection in a new terminal.", + partial: "Some configuration could not be applied. Check the issues below.", + disabled: "Automatic terminal command configuration is off.", + blocked: "Configuration is blocked. Correct the reported issue and try Repair.", + }[s.phase] || "Configuration state is unavailable."; + nodes.target.textContent = s.expectedExecutable ? "Bundled executable: " + s.expectedExecutable : ""; + nodes.issues.replaceChildren(...(s.issues || []).map(code => { + const li = document.createElement("li"); li.textContent = code; return li; + })); + controls(); +} +function bounded(work) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Desktop did not answer within 30 seconds. Refresh status before retrying.")), 30000); + Promise.resolve(work).then(v => { clearTimeout(timer); resolve(v); }, e => { clearTimeout(timer); reject(e); }); + }); +} +async function refresh() { + if (!invoke || busy || polling) return; + const request = ++generation; + polling = true; + try { + const s = await bounded(invoke("cli_status")); + if (request === generation) render(s); + } catch (e) { + if (request === generation) { nodes.error.textContent = String(e); nodes.error.hidden = false; } + } finally { + polling = false; + if (refreshQueued) { refreshQueued = false; await refresh(); } + } +} +async function action(name, args) { + if (!invoke || busy) return; + const request = ++generation; + busy = true; controls(); nodes.error.hidden = true; + try { + const s = await bounded(args === undefined ? invoke(name) : invoke(name, args)); + if (request === generation && s) render(s); + } catch (e) { + if (request === generation) { nodes.error.textContent = String(e); nodes.error.hidden = false; } + } finally { + busy = false; controls(); + if (polling) refreshQueued = true; + else await refresh(); + } +} +nodes.enabled.addEventListener("change", () => action("cli_set_enabled", { enabled: nodes.enabled.checked })); +nodes.repair.addEventListener("click", () => action("cli_install")); +nodes.remove.addEventListener("click", () => action("cli_remove")); +nodes.back.addEventListener("click", () => action("return_to_dashboard")); +if (invoke && typeof navigator !== "undefined" && /Macintosh/.test(navigator.userAgent)) { + nodes.titlebar.hidden = false; + nodes.titlebar.addEventListener("mousedown", e => { + if (e.button === 0) invoke("plugin:window|start_dragging").catch(() => {}); + }); + nodes.titlebar.addEventListener("dblclick", () => invoke("plugin:window|toggle_maximize").catch(() => {})); +} +if (!invoke) { + nodes.state.textContent = "Open this page from OpenCodex Desktop's Terminal command menu."; + controls(); +} else { + refresh(); + setInterval(refresh, 2000); +} diff --git a/desktop/ui/update.html b/desktop/ui/update.html index 22ca486c13e..a816cf1db22 100644 --- a/desktop/ui/update.html +++ b/desktop/ui/update.html @@ -35,6 +35,7 @@

    OpenCodex update

    Reading update status…

    +
    @@ -46,6 +47,8 @@

    OpenCodex update

    const titlebar = document.querySelector("#titlebar"); const state = document.querySelector("#state"); const error = document.querySelector("#error"); +const manual = document.querySelector("#manual"); +const release = document.querySelector("#release"); const check = document.querySelector("#check"); const install = document.querySelector("#install"); const back = document.querySelector("#back"); @@ -65,9 +68,17 @@

    OpenCodex update

    ); }); } -function showError(cause) { - error.textContent = cause instanceof Error ? cause.message : String(cause); +function showError(cause, installFailed = false) { + const message = cause instanceof Error ? cause.message : String(cause); + const version = installFailed ? latest?.latestVersion : null; + const hint = version + ? " If automatic download is slow or blocked by network, install the package manually from GitHub Releases (v" + version + ")." + : ""; + error.textContent = message + hint; error.hidden = false; + manual.hidden = !version; + release.textContent = version ? "Download v" + version + " from GitHub Releases" : ""; + release.href = version ? "https://github.com/lidge-jun/opencodex/releases/tag/v" + encodeURIComponent(version) : ""; } function render(snapshot) { latest = snapshot; @@ -98,6 +109,7 @@

    OpenCodex update

    check.disabled = true; install.disabled = true; error.hidden = true; + manual.hidden = true; state.textContent = name === "update_check" ? "Checking for updates…" : "Reading update status…"; try { render(await call(name)); } catch (cause) { showError(cause); if (latest) render(latest); } @@ -110,14 +122,16 @@

    OpenCodex update

    check.disabled = true; install.disabled = true; error.hidden = true; + manual.hidden = true; state.textContent = "Installing update…"; try { render(await call("update_install")); } - catch (cause) { showError(cause); try { render(await call("update_status")); } catch (readError) { showError(readError); } } + catch (cause) { showError(cause, true); try { render(await call("update_status")); } catch (readError) { showError(readError, true); } } finally { busy = false; if (latest) render(latest); else check.disabled = false; } }); back.addEventListener("click", async () => { back.disabled = true; error.hidden = true; + manual.hidden = true; try { await call("return_to_dashboard"); } catch (cause) { showError(cause); back.disabled = false; } }); diff --git a/devlog/_fin/260808_metadata_pipeline_dejawcode/020_azure_context_refresh.md b/devlog/_fin/260808_metadata_pipeline_dejawcode/020_azure_context_refresh.md new file mode 100644 index 00000000000..aab0a142b51 --- /dev/null +++ b/devlog/_fin/260808_metadata_pipeline_dejawcode/020_azure_context_refresh.md @@ -0,0 +1,47 @@ +# Azure context metadata refresh — 2026-10-08 + +The public `openai` snapshot had 373,000-token windows for GPT-5.6, its Luna/Sol/Terra +variants, GPT-6 Luna/Sol and GPT-6.1 Sol, while the canonical `openai-apikey` registry +declared 1,050,000. Align those seven snapshot windows with the registry and add the +missing GPT-6 Astra row (1,050,000 context, 128,000 output). + +Public metadata cross-check: `https://openrouter.ai/api/v1/models`, read on 2026-10-08, +reported 1,050,000 context and 128,000 maximum output for the seven named variants +above, including Astra. Astra prices per million tokens were input 10, output 50, +cache read 1 and cache write 12.5. The plain `gpt-5.6` window follows the existing +canonical API registry seed; it was not independently listed by this source. +The `openai-codex` and `openrouter` snapshot bundles retain their separate contracts. + +This is model metadata, not proof of an individual Azure deployment's capacity. The +destination-gated catalog fallback runs only when reported/configured limits are absent. +Regression coverage includes serialized catalog context and compaction, case matching, +unknown aliases, non-Azure destinations, explicit limits and provider caps. + +The same catalog fallback fills missing input modalities and output limits. The public +`https://models.dev/api.json` Azure provider rows were read on 2026-10-08: 96 models +in a 5,362,373-byte catalog. Azure `gpt-6.1-sol` reports text/image/pdf input, +1,050,000 context and 128,000 output; Azure `deepseek-v4-flash` reports text-only +input, 1,000,000 context and 384,000 output. The direct-vendor DeepSeek row differs, +so published Azure fields take precedence over vendor fallback. Codex accepts only +text/image/audio input values; unsupported values such as pdf are filtered. + +Discovery refreshes and caches the public Azure subset for 24 hours, with a two-second +deadline and 16 MiB byte ceiling, without sending provider credentials. Missing or +malformed metadata keeps discovery working; stale data remains usable offline. New +published ids can acquire image metadata without a bundled-data release. Azure inference +capability flags alone do not identify modalities, and arbitrary deployment aliases are +not guessed. Explicit declarations, reported modalities and sidecar behavior remain intact. + +Cache state is isolated by the resolved configuration directory, including snapshots, +in-flight refreshes and failure retry cooldowns. A refresh captures its directory before +awaiting so a root switch cannot redirect its disk write or expose its memory snapshot +to another root. Regression coverage switches A to B and back, including concurrent +refreshes and an offline cooldown. + +Discovery admission carries its metadata directory through refresh, upstream awaits +and subsequent hint projections, including cached rows, retention and combo/custom +rows. The public metadata fetch rejects redirects and retains stale fallback data. + +Azure discovery caches upstream rows before metadata enrichment. Fresh, stale and +failure-cooldown reads project those rows using the admitted configuration root, +so A-to-B-to-A calls can reuse one upstream result while keeping separate metadata. diff --git a/devlog/_fin/261009_n3_test_infra/000_plan.md b/devlog/_fin/261009_n3_test_infra/000_plan.md new file mode 100644 index 00000000000..9ea06449a1b --- /dev/null +++ b/devlog/_fin/261009_n3_test_infra/000_plan.md @@ -0,0 +1,69 @@ +# N3 test infrastructure: client-home isolation and fixture determinism + +Unit for three test-infrastructure defects reported against dev in October 2026. +Each item ships as its own pull request to `dev` so a reviewer can judge the +home-isolation change apart from the two fixture fixes. + +| Doc | Issue | Change | PR branch | +| --- | --- | --- | --- | +| 010 | #6775 | Pin client homes in the test sandbox, resolve the Claude default home at call time, and make the test guard refuse writes to the real Claude config directory | `codex/n3-test-infra` | +| 020 | #6777 | Stop the combo management fixture from reaching provider discovery and keep a timed-out case from running into the next fixture | `codex/n3-combo-rename-fixture` | +| 030 | #6776 | Give the native Codex toggle fixture deterministic service-manager evidence | `codex/n3-codex-toggle-fixture` | + +## Constraints + +- Production behavior stays the same. The only production edits are in 010: + `claudeConfigDir()` reads the home the way Node's `os.homedir()` does at call + time, and two Claude writers call a guard that is inert unless + `OCX_TEST_HOME_GUARD=1`. +- Other `homedir()` call sites (OpenCodex config dir, Codex home, client + integrations) are deliberately out of scope. Moving them to an environment-first + home would change where production reads its own config on Windows hosts whose + `HOME` differs from the profile directory. The sandbox and the guard cover tests + without that risk. +- Workflows under `.github/` are not touched (L7 #6806 owns CI changes). +- Every local test run starts Bun with a temporary `HOME`, `USERPROFILE` and + `CLAUDE_CONFIG_DIR`; a bare `bun test` against the developer home is not used, + because that is the defect in #6775. +- File-size ratchet: no capped file grows past its cap. New tests are registered in + `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. + +## Order + +010, 020 and 030 are independent; each starts from `origin/dev`. 010 goes first +because the other two reproductions are only safe once the sandbox pins the Claude +home. + +## Verification + +Focused files per doc plus `bun run typecheck`, run with a temporary home at +process start. The full suite runs in hosted CI on each PR's exact head. + + +## Outcome + +**DONE**, 2026-10-09. All three items merged into `dev` with squash admin merges, after +exact-head CI concluded `success` and independent gpt-6.1-sol reviews passed: + +| Doc | Issue | PR | Merge commit | Exact-head CI | +| --- | --- | --- | --- | --- | +| 010 | #6775 | #6835 | `7f642e30e8` | all executed jobs passed at `9ac1a95263`, including the nine Windows shards, `npm-global windows-latest` and `keyring windows` | +| 020 | #6777 | #6833 | `6d7e5f48ad` | all executed jobs passed at `0fa7ee87df`; Windows shards and other conditional jobs skipped (not selected for this diff) | +| 030 | #6776 | #6834 | `c4baa30e09` | all executed jobs passed at `c6addbb97e`; Windows shards and other conditional jobs skipped (not selected for this diff) | + +Before merging, the three heads were combined on `dev` `b89bbfb083` (#6835 shares +`layout.json` and `test-layout-expected.json` with changes that landed meanwhile): 180 +focused tests, typecheck and `structure:check` passed. The three issues were closed with +a comment naming the PR and merge commit. + +010 went through four review rounds. The plan's two guarded writers grew to four (agent +sync, gateway cache, served-catalog invalidation, intercept `settings.json`), and the guard +now judges every touched path by where it resolves (directory and file links), resolves its +roots at check time, compares case-insensitively on macOS and Windows, splits containment +on the platform separator, and covers removals with the same comparison while keeping the +checkout-content lift. + +Not done here: other client homes that still default to `os.homedir()` (Claude Desktop +config library, several client-integration writers, Kiro, XDG-based clients) remain +follow-up candidates. Native Windows was exercised only through hosted CI. + diff --git a/devlog/_fin/261009_n3_test_infra/010_6775_client_home_isolation.md b/devlog/_fin/261009_n3_test_infra/010_6775_client_home_isolation.md new file mode 100644 index 00000000000..23d9a52c339 --- /dev/null +++ b/devlog/_fin/261009_n3_test_infra/010_6775_client_home_isolation.md @@ -0,0 +1,195 @@ +# 010 — #6775: keep tests out of the real Claude config directory + +## Defect + +`tests/preload.ts` sandboxes a bare `bun test` by assigning `process.env.HOME` +after Bun has started. Bun's `os.homedir()` keeps the start-time home, so +`claudeConfigDir()` in `src/claude/gateway-cache.ts` resolves the developer's real +`~/.claude` when `CLAUDE_CONFIG_DIR` is unset. Management toggles call +`syncClaudeAgentDefsBestEffort()` (`src/server/management-api.ts`) with no +explicit directory, and `syncClaudeAgentDefs([])` prunes every generated +`ocx-*.md` there. `tests/claude-integration/claude-messages-endpoint.test.ts` +("compatibility survives management toggles…") triggers it. + +Reproduced on `730d898457` with a temporary HOME at process start: a planted +generated `ocx-probe.md` in the fake home is removed by that one test. + +## Change + +### 1. Sandbox pins the Claude and Grok homes — MODIFY `scripts/test.ts` + +`createIsolatedTestEnvironment()` already feeds both the wrapper child env and the +preload. Add, next to `CODEX_HOME`: + +```diff + OPENCODEX_HOME: opencodexHome, + CODEX_HOME: codexHome, ++ // Client homes that default to os.homedir(). Bun keeps the start-time home, so a ++ // preload that only rewrites HOME leaves these pointing at the developer's real ++ // directories. Pin them inside the sandbox; never inherit a live override. ++ CLAUDE_CONFIG_DIR: join(root, ".claude"), ++ GROK_HOME: join(root, ".grok"), +``` + +and capture the real Claude directory for the guard before it is replaced: + +```diff + OCX_REAL_HOME: baseEnv.OCX_REAL_HOME ?? homedir(), ++ OCX_REAL_CLAUDE_CONFIG_DIR: baseEnv.OCX_REAL_CLAUDE_CONFIG_DIR ++ ?? (baseEnv.CLAUDE_CONFIG_DIR?.trim() || join(baseEnv.OCX_REAL_HOME ?? homedir(), ".claude")), +``` + +### 2. Guard refuses the real Claude directory — MODIFY `src/lib/test-home-guard.ts` + +Capture at module load (same moment as `REAL_HOME`), protecting the default +`/.claude` and, when it differs, the developer's own +`CLAUDE_CONFIG_DIR` handed over as `OCX_REAL_CLAUDE_CONFIG_DIR`: + +```ts +const REAL_CLAUDE_DIRS = [...new Set([ + join(REAL_HOME, ".claude"), + process.env.OCX_REAL_CLAUDE_CONFIG_DIR?.trim() || process.env.CLAUDE_CONFIG_DIR?.trim() || join(REAL_HOME, ".claude"), +])]; +const PROTECTED_CLAUDE_TREES = REAL_CLAUDE_DIRS.map(path => ({ + path: canonicalize(path), lexical: resolve(path), label: "the real Claude config directory", +})); + +export function protectedClaudeConfigDirsForTests(): readonly string[]; +/** Throw when an armed test process is about to write or prune inside the real Claude config dir. */ +export function assertNotRealClaudeConfigUnderTest(dir: string): void; +``` + +`assertNotRealClaudeConfigUnderTest` is a no-op unless armed, and refuses when +`dir` (canonical or lexical) equals or sits inside a protected Claude tree. The +Claude trees are appended to `PROTECTED_TREES` so `assertRemovalOutsideProtectedTrees` +refuses removals there too. + +Note on the capture: under `bun run test` the wrapper child already has a +sandboxed `CLAUDE_CONFIG_DIR`, which is why the real value travels in +`OCX_REAL_CLAUDE_CONFIG_DIR`. Under a bare run the preload imports the guard +before it rewrites the environment, so `process.env.CLAUDE_CONFIG_DIR` is still +the developer's own value. + +### 3. Writers call the guard first — MODIFY `src/claude/agents-inject.ts`, `src/claude/gateway-cache.ts` + +```diff + export function syncClaudeAgentDefs(defs, configDir = claudeConfigDir()) { ++ // Outside the best-effort catch: under an armed test process a write or prune of the ++ // real Claude directory must fail loudly, never degrade to "returned null". ++ assertNotRealClaudeConfigUnderTest(configDir); + try { +``` + +Same first line in `writeGatewayModelCache`. Callers that wrap these in their own +best-effort catch still never reach the filesystem. + +### 4. Claude default home follows the current environment — MODIFY `src/claude/gateway-cache.ts` + +```diff + export function claudeConfigDir(): string { + const custom = process.env.CLAUDE_CONFIG_DIR; +- return custom && custom.length > 0 ? custom : join(homedir(), ".claude"); ++ return custom && custom.length > 0 ? custom : join(currentUserHome(), ".claude"); + } ++ ++/** ++ * The home Claude Code itself resolves. Claude Code runs on Node, whose os.homedir() ++ * consults HOME (POSIX) or USERPROFILE (Windows) at call time when it is set; Bun's ++ * caches the start-time value. In production the two agree; under a preload that ++ * rewrites HOME only this one follows the sandbox. Exported as an internal for tests. ++ */ ++export function currentUserHome(env = process.env, platform = process.platform): string { ++ const fromEnv = platform === "win32" ? env.USERPROFILE : env.HOME; ++ return fromEnv !== undefined && fromEnv !== "" ? fromEnv : homedir(); ++} +``` + +### 5. Existing resolver test follows the new contract — MODIFY `tests/claude-integration/claude-gateway-cache.test.ts` + +"claudeConfigDir honors CLAUDE_CONFIG_DIR" deletes `CLAUDE_CONFIG_DIR` and expects +`join(os.homedir(), ".claude")`, Bun's cached start-time home. Under the preload that is the +developer's real home, so the assertion encodes the defect. New fallback expectation: + +```diff +- const { homedir } = require("node:os") as typeof import("node:os"); +- expect(claudeConfigDir()).toBe(join(homedir(), ".claude")); ++ const home = process.platform === "win32" ? process.env.USERPROFILE : process.env.HOME; ++ expect(claudeConfigDir()).toBe(join(home!, ".claude")); +``` + +### 6. The triggering test names its directory — MODIFY `tests/claude-integration/claude-messages-endpoint.test.ts` + +Pass `{ managementApi: { claudeAgentConfigDir: join(testDir, "claude-agents") } }` +to `startServer` in the toggle test (in-place, no line growth; file is at 1993/2000). + +## Regression tests — NEW `tests/claude-integration/claude-config-home-isolation.test.ts` + +1. `createIsolatedTestEnvironment({ CLAUDE_CONFIG_DIR: "/inherited", GROK_HOME: "/inherited" })` + pins both inside `isolated.root` and records `OCX_REAL_CLAUDE_CONFIG_DIR: "/inherited"`. + Fails on dev (inherited values pass through). +2. With `CLAUDE_CONFIG_DIR` unset and `HOME`/`USERPROFILE` reassigned in-process, + `claudeConfigDir()` returns `/.claude`. Fails on dev under Bun. +3. A child Bun process armed with `OCX_TEST_HOME_GUARD=1` and + `OCX_REAL_HOME=` calls `syncClaudeAgentDefs([], /.claude)`: + the call throws the guard error and the planted generated file survives. On dev + the file is pruned. The sentinel is a temp directory, so the real home is never + involved. +4. The preload itself: inside any test process, `process.env.CLAUDE_CONFIG_DIR` + resolves under the sandbox root, not under `OCX_REAL_HOME`. +5. HOME and USERPROFILE disagree: `currentUserHome({HOME:a, USERPROFILE:b}, "linux")` + is `a`, with `"win32"` it is `b`, and an empty variable falls back to `os.homedir()`. +6. Nested isolation keeps the original: `createIsolatedTestEnvironment(isolated.env)` + preserves the outer `OCX_REAL_CLAUDE_CONFIG_DIR`. + +Register the new file in `scripts/test-layout/layout.json` (`explicit`) and +`tests/fixtures/test-layout-expected.json` under `claude-integration`. + +Extend `tests/ci-workflows/test-home-guard.test.ts` only if its roster asserts the +exact protected-tree list (check `protectedRemovalTreesForTests` expectations). + +## Structure docs + +Source areas touched: `scripts/`, `src/claude/`, `src/lib/`, `tests/`. Every doc mapped for +them in `structure/INDEX.md` is reviewed. The contract that changes is the test sandbox, +described by `structure/ops/test-sandbox-cleanup.md` (maps `tests/`). MODIFY it with one +present-tense paragraph: the sandbox pins `CLAUDE_CONFIG_DIR` and `GROK_HOME` inside its +root and hands the real Claude directory to the guard as `OCX_REAL_CLAUDE_CONFIG_DIR`; +`claudeConfigDir()` follows the current platform home variable; the armed guard refuses +Claude agent sync and gateway-cache writes into the real Claude config directory (default and +the developer's own `CLAUDE_CONFIG_DIR`) and refuses removals there; pinned by +`tests/claude-integration/claude-config-home-isolation.test.ts`. The other mapped docs +(`runtime.md`, `clients/claude-desktop.md`, `overview.md`, `ops/docs-and-release.md`, and the +`src/lib/` transport/dashboard docs) describe no behavior this change alters; the PR records +that review. + +## Out of scope, reported in the PR + +The research inventory found other client homes resolved through `homedir()` +(Claude Desktop config library, client integration writers, Kiro, XDG-based +clients). The sandbox now pins Claude and Grok; the rest are follow-up candidates. + +## Verify + +```sh +FH=$(mktemp -d); env -u CLAUDE_CONFIG_DIR -u GROK_HOME HOME="$FH" USERPROFILE="$FH" CLAUDE_CONFIG_DIR="$FH/.claude" \ + bun test tests/claude-integration/claude-config-home-isolation.test.ts tests/ci-workflows/test-home-guard.test.ts \ + tests/claude-integration/claude-messages-endpoint.test.ts tests/claude-integration/claude-agents-inject.test.ts \ + tests/claude-integration/claude-gateway-cache.test.ts tests/test-layout.test.ts tests/test-layout-tooling.test.ts \ + tests/ci-workflows/structure-ssot.test.ts tests/ci-workflows/file-size-ratchet.test.ts +bun run typecheck +bun run structure:check +``` +plus the issue's probe: plant a generated `ocx-probe.md` in `$FH/.claude/agents`, +start Bun with `CLAUDE_CONFIG_DIR` unset, and confirm it survives. + + +## Implementation record + +Merged as PR #6835 (`7f642e30e8`). Beyond this doc, review added: guarded served-catalog +invalidation (`src/claude/intercept/cli-catalog.ts`) and the intercept `settings.json` +writer (`src/claude/intercept/settings.ts`); every touched path, including +`cache/gateway-models.json` and each agent definition's temporary file, is passed to the +guard; roots resolve at check time with case folding on macOS and Windows; removal +protection uses the same comparison and keeps the checkout-content lift. The regression file +has eight cases, all failing on `dev` before the change. + diff --git a/devlog/_fin/261009_n3_test_infra/020_6777_combo_fixture.md b/devlog/_fin/261009_n3_test_infra/020_6777_combo_fixture.md new file mode 100644 index 00000000000..b51339725ca --- /dev/null +++ b/devlog/_fin/261009_n3_test_infra/020_6777_combo_fixture.md @@ -0,0 +1,110 @@ +# 020 — #6777: combo management fixture stays local and serial + +## Defect + +`tests/routing/combo-management-api.test.ts` builds providers `a/b/c` with +synthetic `https://*.example/v1` URLs and live discovery on. The rename route +awaits `syncClaudeAgentDefsBestEffort` → `fetchAllModels` → provider discovery → +`providerOutboundGet` → DNS lookup, which is not bounded by the request's abort +signal. When DNS is slow the case passes Bun's 5 s timeout; Bun then starts the next +case while the first callback is still running, so the first reads the second's +fixture and its `finally` restores the environment under the second (the reported +`new-public`/`stable-public` mismatch and `ENOENT config.json`). + +Hosted CI resolves DNS fast and uses `--timeout 60000`, which hides it. + +The route's await is correct and stays: the test asserts that agent definitions are +migrated before the response returns. + +## Change — MODIFY `tests/routing/combo-management-api.test.ts` only + +1. `baseConfig`: add `liveModels: false` to providers `a`, `b`, `c` (static + catalog path in `src/codex/catalog/provider-models.ts:239`). +2. Serialize the fixture helper and drain it, with a bound, before shared state is + cleared. A drain that exceeds the bound poisons the helper: later owners reject + immediately instead of queueing behind a callback that may still resume and rewrite + the environment. + +```diff +-async function withTempHome(run: (dir: string) => Promise | T): Promise { ++// Bun starts the next case while a timed-out one is still running. Chaining owners ++// keeps one case's environment from being installed or restored under another's. ++let tempHomeSettled: Promise = Promise.resolve(); ++let tempHomeStuck = false; ++let tempHomeDrainMs = 50_000; // lowered only by the poison regression ++const STUCK = "an earlier withTempHome owner never settled; refusing to share its environment"; ++function withTempHome(run: (dir: string) => Promise | T): Promise { ++ if (tempHomeStuck) return Promise.reject(new Error(STUCK)); ++ // Rechecked when the queued owner starts: one queued before poisoning must not run ++ // after a stalled predecessor finally settles. ++ const operation = tempHomeSettled.then(() => { ++ if (tempHomeStuck) throw new Error(STUCK); ++ return runWithTempHome(run); ++ }); ++ tempHomeSettled = operation.then(() => {}, () => {}); ++ return operation; ++} ++ ++async function drainTempHome(): Promise { ++ // Once poisoned, every later drain fails at once instead of waiting out the bound again. ++ if (tempHomeStuck) throw new Error(STUCK); ++ let timer: ReturnType | undefined; ++ const drained = await Promise.race([ ++ tempHomeSettled.then(() => true), ++ new Promise(resolve => { timer = setTimeout(() => resolve(false), tempHomeDrainMs); }), ++ ]); ++ clearTimeout(timer); ++ if (!drained) { ++ tempHomeStuck = true; ++ throw new Error("withTempHome owner still running after the drain bound"); ++ } ++} ++ ++async function runWithTempHome(run: (dir: string) => Promise | T): Promise { +``` + +```diff +-afterEach(() => { ++afterEach(async () => { ++ await drainTempHome(); + clearComboSelectionState(); + clearComboTargetCooldowns(); +-}); ++}, 60_000); +``` + +3. Regression in the rename case without re-indenting it: install + `spyOn(outbound, "providerOutboundGet").mockRejectedValue(...)` at the top of the + case, register its restore in an `afterEach` cleanup list, and assert + `expect(discovery).not.toHaveBeenCalled()` after `withTempHome` resolves. On dev + the spy records the a/b/c discovery calls. +4. Serialization regression at file end: two overlapping `withTempHome` owners; + the first holds a gate and asserts its own `OPENCODEX_HOME` after the second was + requested. On dev the second owner rewrites the environment under the first. + The gate is released in `finally` so a failing assertion cannot strand the queue. +5. Poison regression: with a lowered drain bound, an owner that never settles makes + `drainTempHome()` throw; afterwards (a) a second drain rejects immediately, (b) a new + `withTempHome` rejects immediately, and (c) an owner queued before poisoning rejects + without running its callback when the stalled owner is released. Gate release, + settlement of every owner, and restoration of the bound, the poison flag and the queue + head all happen in `finally`, so a failed assertion cannot leave the file poisoned. + The test-only reset is a small `resetTempHomeQueueForTest()` function in the test file. + +## Verify + +```sh +FH=$(mktemp -d); env -u HTTPS_PROXY -u NODE_EXTRA_CA_CERTS HOME="$FH" USERPROFILE="$FH" CLAUDE_CONFIG_DIR="$FH/.claude" \ + bun test tests/routing/combo-management-api.test.ts +``` + + +## Implementation record + +Implemented as written in PR #6833 (branch `codex/n3-combo-rename-fixture`, commit `0fa7ee87df`), +test-only, in `tests/routing/combo-management-api.test.ts` (1,913 lines, under the ratchet cap, so +no sibling file was needed). Before the change the three regressions fail (rename: 3 discovery +calls; serialization: the first owner observes the second's `OPENCODEX_HOME`; poison: the drain +resolves). After: 41/41 in the file under a fresh startup home; independent review also ran it with +CI's `--isolate --timeout 60000` flags (77/77 across four files) and confirmed the timeout probe +drains the owner before the next case. + diff --git a/devlog/_fin/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md b/devlog/_fin/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md new file mode 100644 index 00000000000..e1dd36a1577 --- /dev/null +++ b/devlog/_fin/261009_n3_test_infra/030_6776_toggle_ownership_fixture.md @@ -0,0 +1,64 @@ +# 030 — #6776: native Codex toggle fixture owns its service-manager evidence + +## Defect + +`tests/codex-integration/native-codex-toggle.test.ts` writes a matching v2 +`service-state.json` into its synthetic `OPENCODEX_HOME`, then enables Codex +through the management route. Enable runs production admission, which calls +`inspectServiceManagerInstallation` (`src/integrations/native/ownership-preflight.ts`). +That probe asks the real service manager: `launchctl print gui//…` on macOS +and `systemctl --user show opencodex-proxy` on Linux, neither of which HOME can +redirect. On a machine with an installed opencodex service the probe returns a +registration that the sandbox plist cannot explain, admission returns +`service-home`, and the round-trip case reads `state: "absent"`. + +Reproduced on `730d898457` by simulating a registered launchd job: 12 pass / 1 fail +with the reported refusal. Hosted runners have no registration, so CI passes. + +The production ownership boundary is correct and unchanged. + +## Change — MODIFY `tests/codex-integration/native-codex-toggle.test.ts` only + +```diff +-import { afterEach, beforeEach, describe, expect, test } from "bun:test"; ++import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; ++import * as serviceManagerProbe from "../../src/service-manager-probe"; ++let restoreServiceManagerProbe = () => {}; + + beforeEach(() => { ++ // The route fixture owns no service, and HOME cannot redirect launchctl/systemctl. ++ // Without this the host's own opencodex registration decides the case. ++ const probe = spyOn(serviceManagerProbe, "inspectServiceManagerInstallation") ++ .mockReturnValue({ kind: "absent" }); ++ restoreServiceManagerProbe = () => probe.mockRestore(); + + afterEach(() => { ++ restoreServiceManagerProbe(); +``` + +The fixture's `service-state.json` is still validated by the real state reader, so +the case keeps exercising the state comparison. + +## Regression + +The existing round-trip case is the regression. Its host dependence is proven by +the scratch reproduction (simulated registered manager: fails on dev, passes with +the spy). A case asserting `inspectServiceManagerInstallation` is called during +enable confirms the stub is on the admission path rather than dead code. + +## Verify + +```sh +FH=$(mktemp -d); env HOME="$FH" USERPROFILE="$FH" CLAUDE_CONFIG_DIR="$FH/.claude" \ + bun test tests/codex-integration/native-codex-toggle.test.ts +``` + + +## Implementation record + +Implemented as written in PR #6834 (branch `codex/n3-codex-toggle-fixture`, commit `c6addbb97e`), +test-only. With a simulated registered service manager the round-trip case fails on dev (12 pass / +1 fail, `service-home` refusal, `state: "absent"`) and passes with the stub (14/14). The new call +assertion fails when the production probe call is bypassed; the spy is restored before fixture +cleanup. + diff --git a/devlog/_fin/261010_cli_path_crossos_probe/000_plan.md b/devlog/_fin/261010_cli_path_crossos_probe/000_plan.md new file mode 100644 index 00000000000..757359a6935 --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/000_plan.md @@ -0,0 +1,62 @@ +# 000 — Cross-OS probe of the desktop-owned CLI path + +## Objective + +Find out whether the desktop-owned CLI path and install work on `dev` behaves on Linux and Windows the way it was +designed and tested on macOS, and turn every gap into a PR to `dev` or an issue. + +The work under test, all squash-merged on `dev` by 2026-10-09: + +| PR | Commit | Surface | +|---|---|---| +| #6802 | 01acdc8874 | `ocx status`/`doctor`/`resolve` treat a Desktop-supervised runtime as the authority (`src/service/desktop-supervision.mjs`) | +| #6807 | 32abcc363d | package launcher falls back to a validated PATH Bun; lifecycle version-skew notice (`bin/ocx.mjs`, `src/lib/bun-path-runtime.mjs`) | +| #6809 | 63d87f6370 | `service`/`update`/`start`/`stop` refuse to compete with a Desktop-supervised runtime | +| #6812 | 3b9fc865fa | Windows launchers preflight the selected Bun before baking it in | +| #6816 | b89bbfb083 | Desktop owns the `ocx` terminal command on PATH (`desktop/src-tauri/src/cli_command*.rs`) | +| #6818 | 3618baac41 | package launchers hand off to the Desktop `ocx`; `status`/`doctor` report PATH selection | + +The macOS proof is recorded in `devlog/_plan/261009_desktop_owned_path_cli/049_outcome.md`. That note leaves four +items to release QA: packaged click-through of the CLI page, fish on a real login, the Linux deb path, and Windows +terminal pickup after a real registry write. It also records that #6818 deliberately does not hand off on Windows +and that `desktop-supervision.mjs` returns `unsupported` on `win32`, so the #6802/#6809 authority and guards are +inert there. + +No release contains #6816 yet: v2.81.0 was published 2026-10-08, before the merge. On Linux the installer probe runs +a Desktop built from `dev` inside a disposable overlay root on lidge. On Windows a second Desktop cannot run beside +the user's live single-instance 2.77.0, so the installer is exercised at module level (`perform_in` with a real +HKCU write) and the packaged launch is recorded as unverified. + +## Hosts (baseline 2026-10-10) + +| Host | OS | Relevant state | +|---|---|---| +| `ssh lidge` | Ubuntu 24.04.4, x86_64, the login user, bash | `open-codex` 2.61.0 deb owns `/usr/bin/ocx`; Desktop not running; no `~/.opencodex-desktop`; no zsh/fish/docker/xvfb; passwordless sudo; cargo 1.95, webkit2gtk-4.1 2.52.6; bun `/usr/local/bin/bun`; GNOME session on seat0 | +| `ssh mini` | Windows 11 build 26200, the interactive user | OpenCodex Desktop 2.77.0 per-machine MSI at `C:\Program Files\OpenCodex`, running in console session 1 with its `ocx` sidecar; npm `ocx` via nvm4w at `C:\nvm4w\nodejs` which is in both Machine and User Path; bun in `~\.bun\bin`; cargo/rustc; Windows Terminal; no Visual Studio found by vswhere | + +## Constraints + +- All repository work happens in `.tmp/lanes/cli-path-crossos` (branch `codex/cli-path-crossos-probe`); the main + checkout is not touched. Fix branches are cut from `origin/dev` in `.tmp/lanes/` inside this worktree. +- Remote hosts: probe in temp directories and throwaway users where possible, never uninstall an existing tool, + back up before any persistent write, and log every mutation with its exact revert command in + `.tmp/host-changes.md` (worktree scratch, summarised in 040). +- Sol subagents do the host lanes and reviews; `create_thread` is not exposed in this session. +- Authorised: PRs to `dev` and issues. Not authorised: merge, release, version bump, deploy, messaging. + +## Work-phase map (dependency order) + +| Phase | Doc | Depends on | Closes with | +|---|---|---|---| +| wp1 | this file + 010/020/030/040 | — | roadmap reviewed | +| wp2 Linux probe | 010 | wp1 | Linux matrix with lidge evidence | +| wp3 Windows probe | 020 | wp1 | Windows matrix with mini evidence | +| wp4 fixes, issues, closeout | 040 (030 is the release-QA checklist both probes fill) | wp2, wp3 | PRs with exact-head CI, issues, reverts | + +wp2 and wp3 touch different hosts and no shared files, so they run in parallel lanes. + +## Outcome vocabulary + +Each probe row ends as **works** (observed on the host), **broken** (observed failure, with the command and output), +**by design** (behaviour matches a documented decision, e.g. Windows `windows-path-only`), or **unverified** (with +the reason it could not be observed). diff --git a/devlog/_fin/261010_cli_path_crossos_probe/001_host_baseline.md b/devlog/_fin/261010_cli_path_crossos_probe/001_host_baseline.md new file mode 100644 index 00000000000..3eb10a93536 --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/001_host_baseline.md @@ -0,0 +1,41 @@ +# 001 — Host baseline and change-log protocol (2026-10-10) + +Captured read-only before any probe mutation. Later rows in 030 compare against this. + +## lidge (Ubuntu 24.04.4, kernel 7.0.0-30, x86_64) + +- `dpkg -l open-codex` → `ii open-codex 2.61.0 amd64 OpenCodex desktop shell`; `dpkg -S /usr/bin/ocx` → `open-codex`; + `ocx --version` → `opencodex 2.61.0`. Desktop not running; `~/.opencodex-desktop` absent. +- Shell `/bin/bash`; `~/.bashrc` and `~/.profile` exist, `~/.bash_profile` and `~/.zshrc` do not; no fish, zsh, + docker or Xvfb. `unshare` and `setpriv` present; passwordless sudo. +- Bun `/usr/local/bin/bun` 1.3.14 (the launcher pins 1.4.2); Node `/usr/bin/node`; cargo/rustc 1.95 under + `~/.cargo/bin` (not on the non-interactive SSH PATH); webkit2gtk-4.1 / javascriptcoregtk-4.1 2.52.6, libsoup-3.0 3.4.4. +- GNOME session for `` on seat0/tty2. + +## mini (Windows 11 build 26200, the interactive user) + +- OpenCodex Desktop 2.77.0, per-machine MSI, `C:\Program Files\OpenCodex\`, running in console session 1 + (`opencodex-desktop` PID 22680, `ocx` sidecar PID 33476); `~\.opencodex-desktop` absent. +- npm `ocx` 2.58.0 via nvm4w: `C:\nvm4w\nodejs\ocx{,.cmd,.ps1}`; `C:\nvm4w\nodejs` is in both Machine Path and + User Path; HKCU Path is `REG_EXPAND_SZ`. +- Bun `%USERPROFILE%\.bun\bin\bun.exe`; cargo/rustc stable `x86_64-pc-windows-msvc` (links despite `vswhere -latest` + listing nothing); Windows Terminal `wt.exe`; PowerShell 7 in Path. + +## Temp clones + +Both hosts carry a clone of `d17a9f2239` with CI's empty sidecar/resource stubs (ci.yml:1567-1573): +`/tmp/ocx-probe-261010/repo` (lidge) and `%TEMP%\ocx-probe-261010\repo` (mini). + +## Change-log protocol + +Every remote mutation is appended to the worktree scratch file `.tmp/host-changes.md` (gitignored) BEFORE it is +made: time (KST), host, exact command, exact revert command, and the check that proves the revert. 040's closeout +copies the final table with each row marked reverted or kept (with reason). Secrets, tokens and request bodies never +enter the log. + +## First evidence + +- lidge `cargo test --manifest-path desktop/src-tauri/Cargo.toml cli_command`: run 1 `45 passed; 1 failed; 2 ignored` + (`final_save_before_journal_deletion_is_cleaned_on_reopen`, `Err("lock-busy")` at `cli_command_record.rs:1114`); + runs 2-4 `46 passed; 0 failed; 2 ignored`; isolated test 5/5 pass. +- mini, same command: `27 passed; 0 failed; 0 ignored`. diff --git a/devlog/_fin/261010_cli_path_crossos_probe/010_linux_probe.md b/devlog/_fin/261010_cli_path_crossos_probe/010_linux_probe.md new file mode 100644 index 00000000000..44bcccc154a --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/010_linux_probe.md @@ -0,0 +1,67 @@ +# 010 — wp2: Linux probe on `ssh lidge` + +Source under test: a temp clone of `d17a9f2239` at `/tmp/ocx-probe-261010/repo` on lidge. Every host mutation is +appended to the worktree's `.tmp/host-changes.md` with its revert before it is made. + +## Code facts the probe relies on (read 2026-10-10) + +- Install kind: `desktop/src-tauri/src/cli_command_posix.rs:147` rejects debug builds (`development-launch`), + AppImage env or `/tmp/.mount_` paths (`temporary-bundle`) and any executable whose canonical parent is not + `/usr/bin` (`unpackaged-launch`); a qualifying launch needs an executable `/usr/bin/ocx` and records `linux-deb`. + dpkg is never consulted. +- Owned paths: `~/.opencodex-desktop/{bin/ocx (0700), path.sh (0600), cli.json, cli.lock, journal/, backups/}`. +- Managed blocks (`# >>> OpenCodex Desktop ocx PATH v1 >>>`): `.bashrc` plus the FIRST existing of + `.bash_profile`/`.bash_login`/`.profile` (none created; absent → `login-file-absent`, partial); `.zshrc` and + `.zlogin` under `ZDOTDIR` or HOME; `${XDG_CONFIG_HOME:-~/.config}/fish/config.fish` wrapped in + `if status is-interactive` (`cli_command_posix.rs:97-132`). +- The ignored `real_shell_selects_desktop_shim_on_temp_home` test covers bash and zsh only, never fish. +- Launcher handoff (`src/lib/desktop-cli-handoff.mjs`): escape hatch and recursion guard `OCX_NO_DESKTOP_HANDOFF=1`; + update/uninstall/remove/internal commands are excluded; record must be 0700 dir / 0600 file owned by the euid. +- PATH-Bun fallback (`src/lib/bun-path-runtime.mjs`) needs Bun ≥ the pinned 1.4.2; lidge's `/usr/local/bin/bun` + is 1.3.14 and must be rejected. + +## Probe steps + +| # | Step | Command sketch | Expected | +|---|---|---|---| +| L1 | Rust unit tests | `cargo test --manifest-path desktop/src-tauri/Cargo.toml cli_command` with CI's empty sidecar/resource stubs (ci.yml:1567-1573), repeated ×5 | all pass (first run: 1 `lock-busy` flake, see F1) | +| L2 | Ignored real-shell test | `... cli_command_posix::tests::real_shell_selects_desktop_shim_on_temp_home -- --ignored --exact --nocapture` | bash passes; zsh skipped (absent) | +| L3 | Build dev deb | `bun install` (root, gui, desktop) → `bun run build:gui` → `bun desktop/scripts/prepare-sidecar.ts --target x86_64-unknown-linux-gnu` → in `desktop/`: `bunx tauri build --ci --bundles deb --config '{"bundle":{"createUpdaterArtifacts":false}}'` | `.deb` under `src-tauri/target/release/bundle/deb/` | +| L4 | Throwaway user | `sudo useradd -m -s /opt/ocx-probe/fish/bin/fish ocxprobe` after unpacking `fish-4.9.3-linux-x86_64.tar.xz` into `/opt/ocx-probe/fish`; give it `.bashrc` and `.profile` copied from `/etc/skel` | user exists, `getent passwd ocxprobe` shows fish | +| L5 | Install dev deb | download v2.61.0 deb for revert first; `sudo apt install ./OpenCodex-*.deb` | `/usr/bin/opencodex-desktop` + `/usr/bin/ocx` from dev | +| L6 | Desktop reconcile as ocxprobe | run `/usr/bin/opencodex-desktop` as ocxprobe under a virtual display (`xvfb-run` installed and logged, or the user's Wayland/X session if Xvfb is refused) long enough for `reconcile_on_launch`, then stop it | `~ocxprobe/.opencodex-desktop/cli.json` enabled, kind `linux-deb`; blocks in `.bashrc`, `.profile`, `.zshrc`, `.zlogin`, `config.fish` | +| L7 | Real login shells | `sudo -iu ocxprobe` (fish login, interactive via `script -qc`), `fish -l -c 'command -v ocx'`, `bash -l -i -c`, `bash -lc`, `env -i ... bash --login`, `ssh ocxprobe@localhost` if keys allow | interactive fish/bash → `~/.opencodex-desktop/bin/ocx`; non-interactive fish → `/usr/bin/ocx` (guard) | +| L8 | Package launcher handoff | as ocxprobe: `npm i -g` of a `npm pack` tarball into `~/.npm-global`, `bun link` from the clone, and a linuxbrew-style `~/.linuxbrew/bin/ocx` symlink; run each `--version`/`status`; repeat with `OCX_NO_DESKTOP_HANDOFF=1` | handoff to the record target; escape hatch runs the package | +| L9 | Diagnostics | `ocx status`, `status --json`, `doctor`, `resolve --json` with and without the Desktop running | `ocx command:` line names Desktop first/not first; supervisor line only with Desktop live | +| L10 | #6809 guards | with dev Desktop running as ocxprobe: package `ocx start`, `stop`, `service install`, `update --check`-style dry paths | refusals naming Desktop | +| L11 | Bun fallback | temp package copy with bundled Bun removed; PATH first = Bun 1.4.2 in a temp dir; then 1.3.14 | 1.4.2: `using PATH Bun` notice; 1.3.14 rejected with Desktop CLI named | +| L12 | Opt-out and revert | Desktop "Remove" / disabled reconcile as ocxprobe; then `sudo userdel -r ocxprobe`, `sudo apt install ./OpenCodex-2.61.0-linux-amd64.deb` (allow downgrade), remove `/opt/ocx-probe`, `/tmp/ocx-probe-261010` | blocks removed except user-modified ones; host back at 2.61.0 | + +## Outcomes + +Each row is recorded in 030's matrix as works / broken / by design / unverified with the command and output tail. + +## Reflection (same architect, Dewey 01a1225a-9e23): MISALIGNED → dispositions + +These replace the matching rows above. + +- **L1/F1:** the first-run `lock-busy` failure stays recorded as evidence. The forked-child cause is a hypothesis. + Before any fix, B reproduces under a loop (`for i in $(seq 30)`), captures `RUST_BACKTRACE=1` and the errno from + `flock`, and identifies the holder with `/proc/locks` plus `ls -l /proc/*/fd` matching the lock inode during a + failure (an instrumented copy of the test may sleep on failure to allow the snapshot). The fix shape is chosen + only from that evidence. +- **L7:** positive fish case is `fish -l -i -c 'command -v ocx'` and an interactive login via `script -qc 'sudo -iu ocxprobe' /dev/null`; + the negative case is `env -i HOME=/home/ocxprobe PATH=/usr/bin:/bin /opt/ocx-probe/fish/bin/fish -l -c 'command -v ocx'`. + Bash: `env -i HOME=/home/ocxprobe PATH=/usr/bin:/bin bash --login -c 'command -v ocx'` and with `-i`. +- **L10:** no live `update`. The guard probe runs `start`, `stop` and `service install` from the package as ocxprobe + only while the dev Desktop runs as ocxprobe with ocxprobe's own `~/.opencodex`, and the focused existing tests + (`tests/update/update-desktop-owner.test.ts`, `tests/service/service-ownership-handover.test.ts`, + `tests/cli/cli-stop-json.test.ts`) run on lidge for `update`. +- **L11:** set `OCX_NO_DESKTOP_HANDOFF=1`; remove the bundled Bun and the installer from the temp package copy; the + 1.3.14 expectation is "rejected, no Desktop CLI named" because `findDesktopCli` returns null off macOS (candidate + finding F4). +- **L8/L12:** npm-global uses `npm_config_prefix=/home/ocxprobe/.npm-global`. Before L5, download the v2.61.0 deb to + `/opt/ocx-probe/revert/`, verify it against its `.sha256`, and record `dpkg-deb -f Package Version`; L12 + restores with `sudo apt install --allow-downgrades /opt/ocx-probe/revert/OpenCodex-2.61.0-linux-amd64.deb`. + +See the audit dispositions at the end of 020_windows_probe.md; items 1 and 5 replace L4, L5, L7 and L12 host steps. diff --git a/devlog/_fin/261010_cli_path_crossos_probe/011_wp2_execution.md b/devlog/_fin/261010_cli_path_crossos_probe/011_wp2_execution.md new file mode 100644 index 00000000000..e957c082d16 --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/011_wp2_execution.md @@ -0,0 +1,101 @@ +# 011 — wp2 execution: three Linux lanes on lidge + +Re-verified against `origin/dev` 93ed1a40b4: the two commits after `d17a9f2239` (#6846, #6848) touch no file under +test, so 010 and its dispositions stand. Architect proposal (Dewey, 01a1225a-9e23) D1-D6 accepted as below. + +## Host hazards found in P + +- lidge runs the user's own npm `ocx` proxy (PID 3280942) on `127.0.0.1:10100`. No probe may reach, stop or replace + it: every Desktop and `ocx` probe runs in a loopback-only network namespace, and each lane re-checks that PID and + the port listener before and after. +- `/`, `/var/tmp`, `/tmp` and `/home` share one ext4 filesystem, so an overlay upper directory on `/var/tmp` overlaps + `lowerdir=/`; upper and work go on a tmpfs mounted inside the private mount namespace (D3). +- 's umask is 002; the PATH-Bun fallback skips group-writable Bun files or directories (D6). + +## Decisions + +| ID | Decision | Disposition | +|---|---|---| +| D1 | Live Desktop checks (L9 with Desktop, L10 refusals) run in lane A, the only place a dev Desktop exists; lane B uses fixture records | accepted | +| D2 | Each lane has its own clone and `BUN_INSTALL`, `BUN_INSTALL_CACHE_DIR`, `npm_config_cache` under `/tmp/ocx-probe-261010/`; only `~/.cargo` is shared (cargo locks it) | accepted | +| D3 | Overlay of `/` with tmpfs upper/work inside `sudo setsid -f unshare --mount --pid --fork --propagation private`, fresh `proc`, rbind `/dev` `/sys`, tmpfs over `/tmp` `/run` `~`; enter with `nsenter -t -m -p -r -w` | accepted | +| D4 | Loopback-only network namespace for every Desktop/`ocx` probe; Desktop as `ocxprobe` under `dbus-run-session -- xvfb-run -a` with `WEBKIT_DISABLE_DMABUF_RENDERER=1`; apt inside the overlay with a `policy-rc.d` returning 101 | accepted | +| D5 | `bun install` with the host Bun, then the repo's `node_modules/bun/bin/bun.exe` (1.4.2) for `build:gui`, `prepare-sidecar` and `x tauri build` | accepted | +| D6 | L11 temp Bun directory and binary `chmod 755` | accepted | + +## Lanes + +| Lane | Rows | Workspace | Returns | +|---|---|---|---| +| A — packaged deb | L3, L5-L7, live L9/L10, L12; 020 audit items 1 and 5 | clone `/tmp/ocx-probe-261010/a`; overlay namespace | namespace PIDs and mounts; `dpkg -s open-codex` inside vs outside; `cli.json`; each rc diff; LOGIN/INTERACTIVE/`command -v ocx` per shell mode; status/doctor/resolve with Desktop live; guard refusal text; Desktop log tail; revert checks | +| B — launcher and diagnostics | L8, L9 without Desktop, L10 focused tests, L11, F4 | clone `/tmp/ocx-probe-261010/b`; temp HOMEs on the real root; probes inside a loopback netns as with `env -i` and a PATH without `~/.local/bin` | `--version` through npm-global (`npm_config_prefix`), bun-link and a linuxbrew-shaped path, with and without `OCX_NO_DESKTOP_HANDOFF=1`; record-state table (missing, disabled, ready, unsafe mode, pending, oversize, target missing, target is self) with status/doctor lines; resolve output; L11 notice and the 1.3.14 failure text; focused test results with the repo Bun | +| C — F1 lock-busy | L1, L2, F1 | existing `/tmp/ocx-probe-261010/repo` | pass/fail counts for 30 parallel and 30 `--test-threads=1` runs with load average; on failure errno and holder process from a `/proc/*/fd` inode scan during an instrumented 3 s sleep; cause verdict; L2 ignored real-shell result | + +## Stop conditions + +- Any lane: the host proxy PID or the `:10100` listener changes, `dpkg -s open-codex` stops reporting 2.61.0, or + `/etc/passwd` or a file in `~` changes → stop and revert. +- A: overlay mount fails → stop and report; Desktop not up in 120 s after one software-rendering retry → row + unverified per audit item 1. +- B: any probe reaches port 10100 → stop. +- C: 60 runs without a failure → "not reproduced", no fix PR for F1 (the first-run failure stays recorded). +- Revert: kill the namespace holder, require `lsns -t mnt` and `findmnt` free of `ocx-probe`, then + `sudo rm -rf /var/tmp/ocx-probe`; lanes B/C remove their `/tmp/ocx-probe-261010/` and any netns they created. + +Every mutation is logged in the worktree `.tmp/host-changes.md` (via the coordinator) before it happens. + +## Reflection (Dewey, same architect): MISALIGNED → dispositions + +1. The namespace holder runs `exec chroot $M sleep infinity`; every later step enters with + `sudo nsenter -t -m -p -r -w`, so the root is the overlay (D3 corrected). +2. Before the holder starts, `sudo mkdir -p /var/tmp/ocx-probe /var/tmp/ocx-probe-evidence` is logged as a host change. + Inside the namespace, `/var/tmp/ocx-probe-evidence` is bind-mounted to `$M/evidence` so lane A's results survive the + tmpfs teardown, and the overlay's dangling `/etc/resolv.conf` symlink is replaced by the host file's contents. +3. The network namespace (`ip netns add ocxprobe261010`, loopback up) is created after apt and the fish download, kept + alive by its own named namespace, and every Desktop, status, doctor, resolve and guard probe runs in that one + namespace (`ip netns exec ocxprobe261010 nsenter ...` or the reverse order, whichever works with the chroot). + Revert: `sudo ip netns delete ocxprobe261010`. +4. Lane B uses the pinned Bun for `bun install` and `build:gui` before `npm pack` (`prepack` → `prepare:package`). +5. Stop condition watches named paths only: `~/.bashrc`, `~/.profile` (sha256 recorded first), `~/.opencodex` (mtime of + its top-level files), absence of `~/.opencodex-desktop`, `~/.local/lib/node_modules/@bitkyc08`; `~/.cargo` is shared + by design. +6. Revert check: record the holder PID and `readlink /proc//ns/mnt` at start; revert requires the PID gone and that + inode absent from `sudo lsns -t mnt` before `sudo rm -rf /var/tmp/ocx-probe`. + +## Audit (independent Sol, 01a1226f-82b1): FAIL → dispositions + +These supersede earlier rows where they conflict. + +1. **Fixed entry and gate.** Lane A creates the netns first on the host (`sudo ip netns add ocxprobe-a`, + `sudo ip -n ocxprobe-a link set lo up`; no other interfaces), and the mount/PID holder is started *inside* it: + `sudo ip netns exec ocxprobe-a setsid -f unshare --mount --pid --fork --propagation private sh -c '; exec chroot $M sleep infinity'`. + Network access for apt and the fish tarball is done before that, by downloading on the host into + `/var/tmp/ocx-probe-evidence/in/` (debs via `apt-get download` of xvfb and its deps, fish tarball, the dev deb) and + installing inside the overlay from those files with `dpkg -i` only. Every later command enters with exactly + `sudo nsenter -t -m -p -n -r -w ` and is preceded by a gate script that aborts unless: + `readlink /proc/self/ns/mnt` and `/proc/self/ns/pid` equal the holder's recorded inodes, `/proc/self/ns/net` + equals `ocxprobe-a`'s inode, `ip -o link` lists only `lo`, and `test -f /evidence/.overlay-marker` (a file written + only into the overlay at setup). Lane B uses its own `ocxprobe-b` netns with the same gate minus the overlay checks. +2. **Deb route.** The dev deb is copied from lane A's host clone to `/var/tmp/ocx-probe-evidence/in/` with its sha256 + recorded, then installed inside the overlay from `/evidence/in/`. Before and after: host `dpkg -s open-codex` + reports 2.61.0 and `sha256sum /usr/bin/ocx` on the real root is unchanged. +3. **Evidence and rollback.** Evidence is copied back with `rsync -a lidge:/var/tmp/ocx-probe-evidence/out/ + /.tmp/evidence/lidge-a/`; then the holder is killed, its mount-ns inode must be absent from `sudo lsns -t mnt`, + `sudo ip netns delete ocxprobe-a`, `sudo ip netns delete ocxprobe-b`, + `sudo rm -rf /var/tmp/ocx-probe /var/tmp/ocx-probe-evidence`, and + `ls -d /var/tmp/ocx-probe* /tmp/ocx-probe-261010` must fail; `ip netns list` must not show either name. + A rerun must first confirm that `/var/tmp/ocx-probe` and `/var/tmp/ocx-probe-evidence` do not exist (or use fresh + `mktemp -d` paths): `mkdir -p` accepts existing directories, and this cleanup removes everything under the fixed paths. +4. **Supervision gate.** Live L9/L10 coverage is claimed only if `resolve --json` (or a direct + `inspectDesktopSupervision()` call) inside the namespace reports `kind: "desktop"` for the Desktop's child; + otherwise those rows are recorded unverified with the inspector's actual result. + +5. **Root proof.** The gate requires `stat -f -c %T /` = `overlayfs` and `test -f /.ocxprobe-overlay-root`, a marker + written to `$M/.ocxprobe-overlay-root` after the overlay mount (it lives only in the tmpfs upper layer); the + `/evidence` marker is dropped. +6. **Per-path ownership and cleanup.** Each lane removes only what it owns, after it finishes, and checks each path + separately with `test ! -e `: + - lane A: `/tmp/ocx-probe-261010/a`, netns `ocxprobe-a`, holder ns, `/var/tmp/ocx-probe`; + - lane B: `/tmp/ocx-probe-261010/b`, netns `ocxprobe-b`; + - lane C: `/tmp/ocx-probe-261010/repo` and `/tmp/ocx-probe-261010/c`; + - coordinator, after all three: `/var/tmp/ocx-probe-evidence` (after rsync) and the now-empty `/tmp/ocx-probe-261010`. diff --git a/devlog/_fin/261010_cli_path_crossos_probe/019_wp2_outcome.md b/devlog/_fin/261010_cli_path_crossos_probe/019_wp2_outcome.md new file mode 100644 index 00000000000..9b8c1d0bdb7 --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/019_wp2_outcome.md @@ -0,0 +1,27 @@ +# 019 — wp2 outcome: Linux on lidge + +Source `d17a9f2239` (package 2.82.0). Three Sol lanes ran in parallel under 011; every host mutation was logged and +reverted. Raw evidence stays in the worktree scratch `.tmp/evidence/lidge-{a,b,c,shared}/` (not committed). + +| Row | Result | Evidence | +|---|---|---| +| L1 Rust `cli_command` tests | **works** — 60/60 runs `46 passed; 0 failed; 2 ignored` (30 parallel, 30 `--test-threads=1`, load 0.04-0.66) | lane C `results.txt` | +| F1 first-run `lock-busy` | **unverified** — not reproduced in 60 runs; the single first-run failure (`final_save_before_journal_deletion_is_cleaned_on_reopen`, 001) stays unexplained; no fix PR per the 011 stop rule | lane C | +| L2 ignored real-shell test | **broken (test)** — exit 101, `bash: expected exactly two stdout lines`. Ubuntu's `/etc/bash.bashrc:44-53` prints the sudo hint to stdout for a sudo-group user without `~/.hushlogin`; the shell itself resolved `/usr/bin/ocx` then `npm-ocx` correctly. Test assumption at `cli_command_posix.rs:1063-1066` → F5 | lane C `l2-real-shell.log`, `bash-diagnostic.log` | +| L3 dev deb build | **works** — pinned Bun 1.4.2 `build:gui` → `prepare-sidecar` → `tauri build --bundles deb` produced `OpenCodex_2.82.0_amd64.deb`, whose sha256 was recorded when it was staged and matched at install | lane A `02_build.sh`, `07_gate_deb.log`, `08_install.log`; the raw build log was deleted with the clone before it was copied, so `02_build_observed_tail.md` is a transcription | +| L5/L6 deb install + reconcile | **works** — inside the overlay `dpkg -i` → 2.82.0 (host stayed 2.61.0); Desktop under `dbus-run-session -- xvfb-run -a` wrote an enabled `cli.json` with a bundle (the `kind` value itself was not printed; on Linux only `posix::stable_bundle` accepting a `/usr/bin` launch produces one, and it records `linux-deb`), `status --json` `cliCommand.configured: true`, shim 0700, `path.sh` 0600, and managed blocks in `.bashrc`, `.profile`, `.zshrc`, `.zlogin`, `.config/fish/config.fish` matching the generated text exactly | lane A `08_install.log`, `10_reconcile_readout.log`, `13b_diagnostics.log`, `15_rc_exact.log` | +| L7 real login shells (release QA: fish) | **works** — real fish login via `script -qec 'su - ocxprobe'`: login 0, interactive 0, `command -v ocx` → `~/.opencodex-desktop/bin/ocx`; `fish -l -i -c` same; clean-PATH `fish -l -c` interactive 1 → `/usr/bin/ocx` (**by design**, `if status is-interactive` guard); bash interactive login and `bash -lc` → shim | lane A `11_fish_matrix.log`, `12_bash_matrix.log` | +| L8 package launcher handoff | **works** — npm-global (`npm_config_prefix`), `bun link` and a linuxbrew-shaped symlink all hand off to the record target with a ready record; `OCX_NO_DESKTOP_HANDOFF=1` runs the package (`opencodex 2.82.0`); unsafe mode, symlinked record, pending and oversize records are refused with explicit errors | lane B `matrix.log` | +| L9 diagnostics | **works** — without Desktop: `status`/`--json` name the npm launcher first, `.cliCommand.desktopFirstOnPath=false` with `path-first-not-desktop`, doctor shows it, `resolve --json` `runtime-absent`; with the dev Desktop live in the overlay: Desktop first on PATH, `startupSource: live`, `resolve --json` `supervisor.kind: "desktop"` | lane B `matrix.log`; lane A `13b_diagnostics.log` | +| L10 refuse-to-compete (#6809) | **works** — with `supervisor.kind=desktop`: `ocx start` refuses, `ocx service install` refuses and leaves supervision intact; `ocx stop` stops the child and warns the Desktop may restart it (**by design**, `src/cli/desktop-runtime-guidance.ts:27`, `tests/cli/cli-stop-json.test.ts:279`; 010's "refuse" expectation was wrong). Focused tests: 517 pass / 4 skip / 0 fail across the 11 files | lane A `14b_guards.log`, `24_service_guard.log`; lane B `tests-final.log` | +| L11 PATH-Bun fallback (#6807) | **works** — bundled Bun and installer removed, Bun 1.4.2 first on PATH: `opencodex: using PATH Bun 1.4.2.` and `opencodex 2.82.0`; with only 1.3.14: `Bun binary missing after install attempt.` (exit 1) | lane B `fallback.log` | +| F4 Linux failure text names no Desktop CLI | **by design today** — `findDesktopCli()` is macOS-only (`src/lib/bun-path-runtime.mjs:80`), so a broken package launcher on a deb host does not point at `/usr/bin/ocx`; naming it safely needs a check that `/usr/bin/ocx` is the deb's binary and not an npm `--prefix /usr` symlink, so it is a follow-up, not a fix here | lane B | +| L12 opt-out | **unverified** — the CLI page's Remove/disable action is not reachable without a GUI operator; cleanup of the probe itself verified | lane A | +| Packaged CLI page click-through | **unverified** — same reason | — | + +Host state after wp2 (coordinator check): `/var/tmp/ocx-probe*`, `/tmp/ocx-probe-261010`, `~/.opencodex-desktop` absent; +no `ocxprobe*` netns; no `ocxprobe` user; `open-codex` 2.61.0; user proxy PID 3280942 still on `127.0.0.1:10100`. + +Process note: the first dispatch of lanes A and B was tagged as reviewers by codexclaw's keyword role inference +(packet text contained "audit"/"verify") and refused to execute; they were redispatched with a `CXC-ROLE: explorer` +header. diff --git a/devlog/_fin/261010_cli_path_crossos_probe/020_windows_probe.md b/devlog/_fin/261010_cli_path_crossos_probe/020_windows_probe.md new file mode 100644 index 00000000000..58f2b12188b --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/020_windows_probe.md @@ -0,0 +1,107 @@ +# 020 — wp3: Windows probe on `ssh mini` + +Source under test: a temp clone of `d17a9f2239` at `%TEMP%\ocx-probe-261010\repo` on mini. The live Desktop 2.77.0 +(per-machine MSI, `C:\Program Files\OpenCodex`, PID 22680, session 1) and nvm4w npm `ocx` 2.58.0 are user tools and +are not reinstalled, stopped or upgraded. + +## Code facts (read 2026-10-10) + +- `cli_command_windows.rs:693` `stable_bundle` rejects debug launches and paths containing `target`, `temp`, `tmp`; + needs a regular non-symlink sibling `ocx.exe`. Any install root qualifies, including Program Files. +- `prepend` (600) puts the install directory first in `HKCU\Environment\Path`, preserving raw text and REG_SZ vs + REG_EXPAND_SZ; `machine_conflict` (805) flags any Machine Path dir holding `ocx.exe/.com/.cmd/.bat`; `broadcast` + (848) sends `WM_SETTINGCHANGE`/`Environment` with a 1 s per-recipient timeout; `notifyPending` persists failure. +- No test writes the real HKCU; the Windows Path tests are pure transforms or use an in-memory registry seam. +- `src/lib/desktop-cli-handoff.mjs:19` returns `windows-path-only`; `src/service/desktop-supervision.mjs:75` returns + `unsupported` for anything but darwin/linux, so #6802 authority and #6809 refusals never engage on Windows. + +## Probe steps + +| # | Step | Expected | +|---|---|---| +| W1 | `cargo test --manifest-path desktop\src-tauri\Cargo.toml cli_command` with CI stubs, ×3 | pass (first run 27/27) | +| W2 | Baseline: `reg query HKCU\Environment /v Path` (type), `reg export HKCU\Environment .reg /y`, `Get-Command ocx -All`, `where ocx` | backup file recorded in host log | +| W3 | Real HKCU write through the real module: a throwaway `#[ignore]` test added only in the temp clone that runs the Windows `plan` → `apply_change` → `notify` path for a staging dir `%USERPROFILE%\ocx-probe-desktop\` (no temp/tmp in path) holding a copy of `C:\Program Files\OpenCodex\ocx.exe` | HKCU Path gains the staging dir first, type preserved; issues include `machine-path-conflict` | +| W4 | New terminal pickup: (a) scheduled task `/IT` in session 1 running `cmd /c "echo %PATH% & where ocx" > out.txt`; (b) `explorer.exe ` launched via an /IT task so Explorer's refreshed environment is used; (c) `wt new-tab --reloadEnvironment` | staging dir present in PATH of each new process; `where ocx` still lists `C:\nvm4w\nodejs\ocx.cmd` first (machine conflict) | +| W5 | Counter-probe without the machine conflict: same harness, but compute selection with a PATH where Machine entries lack ocx (simulated by the module's own selection on a copy, not by editing Machine Path) | Desktop dir would win; documents that the conflict is the only blocker | +| W6 | Revert W3 through the module's removal path, then compare `reg query` with the backup | HKCU Path byte-identical to baseline; otherwise `reg import` after checking for unrelated edits | +| W7 | Diagnostics with new code: from the clone, `bun src\cli\index.ts status --json`, `doctor`, `resolve --json` against the live Desktop runtime (read-only), with and without a hand-made 0600-equivalent `cli.json` record in a temp USERPROFILE | `desktopSupervision: unsupported`; status recommends service actions despite Desktop supervising (gap); `packageHandoff=disabled-on-windows`; `path-first-not-desktop` | +| W8 | #6809 guards in isolation: `OPENCODEX_HOME`, `CODEX_HOME` in temp, free port; start a runtime from the Desktop's `ocx.exe` copy as a fake "Desktop child", then package `start`/`stop` from the clone against that isolated home | no refusal on Windows (gap evidence); never against the live home | +| W9 | #6812 Bun preflight: focused tests `tests/lib/bun-runtime-preflight.test.ts`, `tests/service/service-runtime-preflight.test.ts`, `tests/codex-integration/codex-shim-runtime-preflight.test.ts` on mini; plus `OPENCODEX_BUN_PATH` pointing at a non-Bun exe in temp with an isolated home | tests pass; bogus Bun rejected before baking | +| W10 | Launcher Bun fallback (#6807) on Windows: temp package copy, bundled Bun absent, PATH Bun 1.4.2 vs the user's `~\.bun\bin\bun.exe` version | 1.4.2 accepted with notice | + +Do not run against the live install: package `ocx start`, `stop`, `service install/repair/start/restart/stop`, +`update`, `doctor --reclaim`. + +## Reflection (same architect, Boyle 01a1225a-9ed0): MISALIGNED → dispositions + +These replace the matching rows above. + +- **W3/W6 contract:** the harness proves the real registry write, the broadcast and the persisted debt, so it goes + through the same entry the Desktop uses: `Store::transact` with the Windows plan for a bundle produced by + `stable_bundle` on the staging directory (copied `opencodex-desktop.exe` + sibling `ocx.exe`), with HOME/USERPROFILE + pointed at a temp record root for the store. If `stable_bundle` refuses the staging copy, that refusal is recorded + and the bundle is built by hand for the write probe only. Restoration runs in a `finally`-style guard (Drop) that + calls the removal path; W6 compares raw bytes and value type from `RegQueryValueExW` + (`Get-Item HKCU:\Environment` `.GetValue('Path',$null,'DoNotExpandEnvironmentNames')` + `GetValueKind`) against + the values saved in W2, and falls back to setting the saved raw value and type, never `reg import` of the whole key. +- **W4:** the scheduled task runs a pre-written `probe.cmd` (PATH evaluated at run time, not creation), writing + `%PATH%`, `where ocx` and its own parent PID/image/session (via PowerShell `Get-CimInstance Win32_Process`). + The Explorer variant launches `explorer.exe probe.cmd` from an /IT task and records the parent chain; `wt new-tab + --reloadEnvironment` runs from an /IT task in session 1. SSH `cmd` is reported only as the stale control. +- **W5:** labelled simulated: a pure PATH-order computation over Machine then User entries after the write. +- **W7:** expectations use real fields: `status --json` `startupSource` and `cliCommand`; `resolve --json` + `supervisor.kind="unsupported"` when live. A hand-made record is labelled synthetic. Service-action + recommendations are recorded only if status reports the runtime unhealthy. +- **W8:** no copied sidecar. Evidence is the direct inspector result on win32 (`inspectDesktopSupervision()` → + `unsupported` while the real Desktop child is live) and the focused guard tests on mini; any lifecycle simulation is + isolated from client homes too. F2 separates the supervision gap from the deliberate PATH-only handoff choice. +- **W9:** the shim test is `tests/codex-integration/codex-shim-runtime-preflight.test.ts`. W10 first reads the pinned + Bun policy in `src/lib/bun-path-runtime.mjs:67`. + + +## Audit (independent Sol, 01a12262-abb4): FAIL → dispositions + +These supersede the earlier rows and the reflection dispositions where they conflict. + +1. **Disposable Linux root (replaces L4, L5, L12 host steps).** The dev deb is never installed on lidge's real root. + B builds a private overlay of `/` and does everything that needs `/usr/bin` inside it: + `sudo unshare --mount --pid --fork --propagation private` → `mount -t overlay overlay -o lowerdir=/,upperdir=/var/tmp/ocx-probe/upper,workdir=/var/tmp/ocx-probe/work /var/tmp/ocx-probe/merged` + → mount fresh `proc`, bind `/dev`, `/dev/pts`, `/sys`, a tmpfs `/tmp`, and copy `/etc/resolv.conf` → `chroot`. + Inside: `dpkg -i` the dev deb (replacing 2.61.0 only in the overlay), `apt-get install -y xvfb dbus-x11` if needed, + unpack portable fish 4.9.3 to `/opt/fish`, `useradd -m -s /opt/fish/bin/fish ocxprobe` with `/etc/skel` files. + The host dpkg database, `/usr/bin/ocx`, `/etc/passwd` and real HOMEs are never written. Revert: exit the + namespace (its mounts vanish with it) and `sudo rm -rf /var/tmp/ocx-probe`. Verification of the revert: + `dpkg -s open-codex | grep Version` = 2.61.0, `getent passwd ocxprobe` empty, `mount | grep ocx-probe` empty. + If the overlay cannot run the Desktop (WebKit, dbus, display), the deb row is recorded as unverified with the + error and the install-kind evidence falls back to the overlay's `dpkg -i` plus a direct call of + `stable_bundle` through the Desktop's `ocx` path checks. +2. **Windows integrated launch.** The Desktop uses `tauri-plugin-single-instance` (`desktop/src-tauri/src/lib.rs:298`), + so a second dev Desktop on mini would hand its arguments to the live 2.77.0 instance; launching one is not safe or + meaningful. The Windows matrix therefore states "packaged Desktop launch: unverified (single instance with the + user's live Desktop)" and records module-level evidence: the throwaway harness calls `perform_in` + (`cli_command.rs:165`), the same core `perform` and `reconcile_on_launch` use, with `observe = stable_bundle(, false, )`, a `Store` under a temp USERPROFILE-like root, and `Action::Reconcile`. + Only `reconcile_on_launch`'s Tauri scheduling and `perform`'s home lookup stay outside the harness. +3. **Windows Terminal observation inside the tab.** The /IT task runs + `wt.exe -w new new-tab --reloadEnvironment cmd /c %USERPROFILE%\ocx-probe-desktop\probe.cmd wt`; `probe.cmd` + writes `%PATH%`, `where ocx` and a PowerShell dump of its own process, parent and grandparent (name, PID, + SessionId) to `probe-wt.txt`, then exits. Controls, each with its own output file: `explorer.exe probe.cmd` from + an /IT task (parent must be the logon explorer.exe), a plain /IT task running `probe.cmd` (Task Scheduler builds a + fresh environment), and SSH `cmd` (stale). Before the write, the same four probes run once as the baseline. +4. **Windows revert contract.** Order: (a) W2 saves the raw Path value and kind via + `.GetValue('Path',$null,'DoNotExpandEnvironmentNames')`/`GetValueKind('Path')` to a file, plus `reg export` as a + second copy; (b) after the probes the harness calls `perform_in(..., Action::Remove, ...)`, which saves the record + off before removal (`cli_command.rs:179-201`) and broadcasts; (c) read the value again: identical raw text and + kind → done; otherwise diff it against (a) and, if the only difference is the owned entry, set the saved raw value + with the saved kind and broadcast `WM_SETTINGCHANGE` (PowerShell `SendMessageTimeout` P/Invoke); any other + difference is left for the user and reported. Cleanup list: each scheduled task gets a unique logged name + (`ocx-probe-261010--`) removed with `schtasks /Delete /TN /F`, close no user + windows (each probe exits itself), delete `%USERPROFILE%\ocx-probe-desktop`, the temp record root and + `%TEMP%\ocx-probe-261010`. +5. **Fish real login inside the session.** `printf 'status is-login; and echo LOGIN; status is-interactive; and echo INTERACTIVE; command -v ocx; echo $PATH; exit\n' | script -qec 'su - ocxprobe' /dev/null` + inside the overlay chroot; the same for bash by switching the probe user's shell (`usermod -s /bin/bash`) and + re-running; `fish -l -i -c` stays as a separate control and the clean-PATH `fish -l -c` as the negative control. + +Non-blocking folded: F1's first row is a hypothesis until wp2 evidence (reflection dispositions); F3 depends on the +observed W3/W4 order; F4 depends on L11's failure text. diff --git a/devlog/_fin/261010_cli_path_crossos_probe/021_wp3_execution.md b/devlog/_fin/261010_cli_path_crossos_probe/021_wp3_execution.md new file mode 100644 index 00000000000..1ac5d1429f4 --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/021_wp3_execution.md @@ -0,0 +1,88 @@ +# 021 — wp3 execution: two Windows lanes on mini + +Re-verified against `origin/dev` 93ed1a40b4 (no tested file changed since `d17a9f2239`). Architect proposal (Boyle, +01a1225a-9ed0) D1-D6 accepted. This doc supersedes 020 rows W3, W4, W6, W7, W8 and audit item 2's harness wording; +020's other rows and audit items 3-5 stay in force. + +## Decisions + +| ID | Decision | +|---|---| +| D1 | Harness = three `#[ignore]` tests added only to mini's temp clone `%TEMP%\ocx-probe-261010\repo` in a `#[cfg(all(test, windows))]` module of `desktop/src-tauri/src/cli_command.rs` (private fns are reachable there): `probe_bundle_program_files` calls only `windows::stable_bundle(C:\Program Files\OpenCodex\opencodex-desktop.exe, false, "2.77.0")`; `probe_plan_only` calls `windows::plan(&Record::fresh(bundle), bundle, false)` without `transact` (no write; prints would-be HKCU text and issues); `probe_install` runs `record::check(&home, true)` then `perform_selected(\.opencodex-desktop, &home, Action::Reconcile, Some(fresh record with install_id), \|\| windows::stable_bundle(%USERPROFILE%\ocx-probe-desktop\opencodex-desktop.exe, false, "2.82.0-probe"), \|_\| {})`; `probe_remove` runs `perform_selected(..., Action::Remove, None, ...)`. `` = `%TEMP%\ocx-probe-261010\home` (exists before the call). Each prints its Status as JSON plus the raw HKCU Path text/kind before and after, and its own process SessionId. The staging dir holds copies of the installed `opencodex-desktop.exe` and `ocx.exe`; neither copy is ever executed. Not exercised: Tauri scheduling and home lookup, `identity::install_id` minting, `current_exe`/`package_info`, UI state publication and the CLI page's Repair/Off/Remove, and a packaged launch (single instance). | +| D2 | `probe_install` and `probe_remove` run as the compiled test binary from `/IT` scheduled tasks in session 1 (where Explorer and the real Desktop live), never from SSH; one SSH-launched run of `probe_plan_only` is the no-write control. | +| D3 | Lanes: **W-a** (the only writer: W2 baseline, D1 tests, W4 probes before and after, remove, compare/restore) and **W-b** (read-only against the live runtime plus focused tests and a temp package copy). They share no paths, ports or registry keys. | +| D4 | Live-session limits: the HKCU window (install → remove) stays under 10 minutes; exactly two broadcasts (install, remove); restore runs even after a failure; every task has a unique logged name and is deleted by exact name; each probe window exits itself; Desktop 2.77.0 predates #6816 and cannot read the temp record. | +| D5 | F3 evidence without a write: `probe_plan_only` output (would-be text + `machine-path-conflict`) plus the observed `Get-Command ocx -All` order; W5 is labelled simulated (Machine then User PATH composition). | +| D6 | W6 is module-level removal; UI Remove stays unverified (as Linux L12). The doctor reclaim flag to avoid is `--reclaim-response-temps`. PID 22680 is re-checked at start. | + +## Lane W-a (writer) + +1. `whoami`, `query session`, `Get-Process opencodex-desktop,ocx | select Id,SessionId,Path`; save raw HKCU Path + (`(Get-Item HKCU:\Environment).GetValue('Path',$null,'DoNotExpandEnvironmentNames')`) and `GetValueKind('Path')` to + `%USERPROFILE%\ocx-probe-desktop\path-before.json` plus `reg export HKCU\Environment ...\env-before.reg /y`. +2. Add the four tests to the clone (patch file kept in the lane evidence dir), build with + `cargo test --manifest-path desktop\src-tauri\Cargo.toml --no-run`, locate the test exe; run + `probe_bundle_program_files` and `probe_plan_only` over SSH (`-- --ignored --exact --nocapture`). +3. Write `probe.cmd` (PATH evaluated at run time, `where ocx`, PowerShell dump of self/parent/grandparent name, PID, + SessionId, output to a per-run file, then exit). Baseline the four probes: /IT task `cmd /c probe.cmd`, /IT task + running `explorer.exe probe.cmd`, /IT task `wt.exe -w new new-tab --reloadEnvironment cmd /c probe.cmd`, SSH `cmd`. +4. /IT task runs the test exe `probe_install` (output to file); wait for completion; verify HKCU now starts with the + staging dir and kind unchanged; rerun the four probes. +5. /IT task runs `probe_remove`; compare raw value and kind with step 1; if only the owned entry differs, set the saved + raw value with the saved kind and broadcast; any other difference stops and is reported. +6. Delete tasks by exact name, `%USERPROFILE%\ocx-probe-desktop`, the temp home; keep the clone for W-b until both lanes end. + +Stop: Path differs from baseline before install; harness `Err`, `lock-busy`, `journal-conflict`; a probe window open +after 60 s; any restore difference beyond the owned entry. + +## Lane W-b (read-only) + +1. `Get-Process opencodex-desktop,ocx`, listening port of the sidecar; `bun install --frozen-lockfile` in a second + clone `%TEMP%\ocx-probe-261010\b` (avoid W-a's cargo tree). +2. With the real USERPROFILE: `bun src\cli\index.ts resolve --json`, `status`, `status --json` (`startupSource`, + `cliCommand`), `doctor` (no flags); `node -e` import of `src/service/desktop-supervision.mjs` → + `inspectDesktopSupervision()` result on win32 while the Desktop child is live. +3. Synthetic record diagnostics through a script calling `collectCliPathDiagnostics({home, env, recordRead})` with a + ready win32 record whose CLI is `C:\Program Files\OpenCodex\ocx.exe` (labelled synthetic). +4. Focused tests with the pinned Bun: `tests/lib/bun-runtime-preflight.test.ts`, `tests/service/service-runtime-preflight.test.ts`, + `tests/codex-integration/codex-shim-runtime-preflight.test.ts`, `tests/service/service-desktop-runtime-preflight.test.ts`, + `tests/cli/ocx-launcher-desktop-handoff.test.ts`, `tests/cli/cli-path-diagnostics.test.ts`, `tests/update/update-desktop-owner.test.ts`, + `tests/service/service-ownership-handover.test.ts`, `tests/cli/cli-stop-json.test.ts`, `tests/cli/ocx-launcher-runtime.test.ts` + with HOME/USERPROFILE/OPENCODEX_HOME/CODEX_HOME in temp. +5. W10: temp package copy, bundled Bun and installer removed, Bun 1.4.2 (`bun-windows-x64.zip`) first on PATH → + `using PATH Bun` notice; then with only the user's Bun; capture the failure text and whether it names the Desktop CLI. + +Never run against the live install: `ocx start`, `stop`, `service install/repair/start/restart/stop`, `update`, +`doctor --reclaim-response-temps`. Stop if the Desktop PID or its port changes. + +## Reflection (Boyle): ALIGNED with fixes → dispositions + +1. D1 has four ignored tests (`probe_bundle_program_files`, `probe_plan_only`, `probe_install`, `probe_remove`). +2. Before step 6, W-a copies `path-before.json`, `env-before.reg`, every probe output and the harness outputs to the lane + evidence directory (scp to the coordinator's `.tmp/evidence/mini-a/`). +3. The SSH-versus-session-1 broadcast difference is not observed (two-broadcast limit); it is recorded as unverified and + the harness prints its own SessionId. +4. Task completion: the harness and `probe.cmd` write a `done` marker file last; W-a polls for it and for + `schtasks /Query /TN /V /FO LIST` not Running, with a 120 s timeout that is a stop condition. +5. Step 4 expectations: install Status `phase=partial` with `machine-path-conflict`; `cli.json` `notifyPending=false` + after the broadcast; after `probe_remove`, `enabled=false` and `windows=null`. +6. W-b inspector import: `node --input-type=module -e "import('./src/service/desktop-supervision.mjs').then(async m=>console.log(JSON.stringify(await m.inspectDesktopSupervision())))"`. + +## Audit (independent Sol, 01a12288-5370): FAIL → dispositions + +1. **W-b doctor isolation.** `doctor` runs with `CODEX_HOME` and every other client home set to a temp directory (it + takes a native-main claim that opens SQLite under `CODEX_HOME`, `src/codex/native-main-claim.ts:86`); the real + `USERPROFILE` and `~/.opencodex` stay so it still sees the live runtime. `status`/`resolve` keep the real homes and + their port probes are recorded as observations of the live runtime. +2. **External finally.** W-a wraps steps 4-5 in a PowerShell `try { ... } finally { ... }` driver (kept in the + evidence dir) whose `finally` always: (a) waits for/kills only its own harness task by exact name; (b) runs + `probe_remove` from an /IT task if the record exists (`perform_selected` with `Action::Remove` saves off first and + `recover` rolls back a pending install journal, `cli_command.rs:179-206`); (c) compares raw Path text and kind + with `path-before.json`; (d) if only the owned staging entry differs, writes the saved raw value with the saved + kind and broadcasts once; (e) if anything else differs, leaves the value, preserves the temp record and journals, + and reports. The broadcast limit becomes "install, remove, plus at most one notification retry and one fallback + restoration broadcast". +3. **Exact test selection.** Every invocation is ` cli_command::probe_tests:: --ignored --exact + --nocapture --test-threads=1` (module `probe_tests`), and the driver requires the output line + `test result: ok. 1 passed`, the done marker, and the expected transition (HKCU starts with the staging entry after + install; raw value and kind equal the baseline after remove) before continuing. diff --git a/devlog/_fin/261010_cli_path_crossos_probe/029_wp3_outcome.md b/devlog/_fin/261010_cli_path_crossos_probe/029_wp3_outcome.md new file mode 100644 index 00000000000..ef2cde88f43 --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/029_wp3_outcome.md @@ -0,0 +1,26 @@ +# 029 — wp3 outcome: Windows on mini + +Source `d17a9f2239`. Two Sol lanes under 021: W-a (the only writer, session-1 harness) and W-b (read-only against +the live Desktop 2.77.0 runtime). Raw evidence stays in the worktree scratch `.tmp/evidence/mini-{a,b}/`. + +| Row | Result | Evidence | +|---|---|---| +| W1 Rust `cli_command` tests | **coordinator-reported, raw output unavailable** — `27 passed; 0 failed` on `x86_64-pc-windows-msvc` | recorded in 001; the raw output was not retained | +| Install eligibility | **works** — `stable_bundle(C:\Program Files\OpenCodex\opencodex-desktop.exe)` → `kind: windows-install`, CLI `C:\Program Files\OpenCodex\ocx.exe`; the per-machine MSI qualifies | mini-a `harness-bundle.txt` | +| W3 real HKCU write (module level, `perform_selected` from an /IT task in session 1) | **works** — HKCU `Path` gained the staging directory first, `REG_EXPAND_SZ` kept, persisted `notifyPending:false` after the broadcast; Status `phase: partial` with `machine-path-conflict` (see F7) | mini-a `harness-install.txt`, `state-after.json` | +| W4 new terminal pickup (release QA) | **works** — after the write, a Windows Terminal tab (`wt -w new new-tab --reloadEnvironment`, parent `WindowsTerminal.exe`, session 1), an Explorer-launched `cmd` (parent `explorer.exe`, session 1) and a Task Scheduler `cmd` all carried the staging directory and `where ocx` returned `%USERPROFILE%\ocx-probe-desktop\ocx.exe` **first**, ahead of nvm4w; before the write all three found nvm4w first. The SSH shell (session 0) stayed stale, as expected | mini-a `probe-pre-*.txt`, `probe-post-*.txt` | +| W5 machine conflict | **broken (diagnostic, F7)** — `machine_conflict()` (`cli_command_windows.rs:805`) expands Machine `Path` with the Desktop's own environment and reports `machine-path-conflict` for `%NVM_SYMLINK%` (`C:\nvm4w\nodejs\ocx.cmd`). Fresh logon environments on mini keep `%NVM_HOME%;%NVM_SYMLINK%` literal (Machine `Path` is `REG_EXPAND_SZ` and both variables exist at machine scope), so nvm4w's `ocx` only arrives through the User `Path`, after the Desktop entry. The Desktop would show "partial" while its `ocx` actually wins | mini-a `machine-path.json`, `harness-plan.txt`, `probe-post-wt.txt` | +| W6 removal (module level) | **works** — `Action::Remove` → `enabled:false`, `phase: disabled`, `windows: null`; raw `Path` text and kind equal the baseline byte for byte; HKCU window 0.18 min | mini-a `harness-remove.txt`, `cli-after-remove.json`, `state-final.json` | +| Packaged Desktop launch, Tauri home lookup, install-id minting, CLI page actions | **unverified** — single instance with the user's live 2.77.0 (`lib.rs:298`); SSH-versus-session-1 broadcast difference also unobserved | — | +| W7 handoff | **by design** — package launcher never hands off on Windows (`windows-path-only`); `status` prints `package handoff=disabled on Windows (user Path selects Desktop)`; synthetic ready record → `packageHandoff: disabled-on-windows`, `path-first-not-desktop` | mini-b `live.out`, `synthetic.out` | +| W7 status/doctor/resolve with the live Desktop | **broken (F2)** — `resolve --json` `supervisor: {"kind":"unsupported"}` and `inspectDesktopSupervision()` → `unsupported` while `Win32_Process` shows sidecar 33476 is the child of `opencodex-desktop` 22680, both under `C:\Program Files\OpenCodex`, session 1. `status` tells the user to restart the proxy with `ocx service restart` (version skew 2.82.0 vs 2.77.0), reports `Restart safety: AT RISK` and `run 'ocx service repair'`; `doctor` (isolated `CODEX_HOME`) recommends `ocx service repair` and `ocx service restart` — the advice #6802 suppresses on macOS/Linux | mini-b `live.out`, `doctor.out` | +| W8 refuse-to-compete (#6809) | **not active on Windows (F2)** — by code: `ocx stop` takes the ordinary stop path (no Desktop warning, `desktop-runtime-guidance.ts:27`), `service install` passes `desktop-command-guard.ts:24` and may clear existing managers and the tracked listener, `update` gets no Desktop veto from `runtime-ownership.mjs:45`. Not run live | mini-b report | +| W9 focused tests | **broken (tests, F6)** — 246 pass / 23 skip / 3 fail. `codex-shim-runtime-preflight.test.ts:86`, `:111` and `service-runtime-preflight.test.ts:120` expect the absolute `process.execPath` in generated `.cmd` files, but both generators intentionally render paths under the profile as `%LOCALAPPDATA%\...`/`%USERPROFILE%\...` (`src/lib/win-paths.ts`); any Windows machine whose Bun lives in the default `~\.bun\bin` fails, while CI's tool-cache Bun passes | mini-b `tests.out` | +| W10 PATH-Bun fallback | **works** — Bun 1.4.2 first on PATH: `opencodex: using PATH Bun 1.4.2.`, exit 0; user's 1.3.14 only: `the \`bun\` dependency is not installed.`, exit 1, no Desktop CLI named (F4 also holds on Windows) | mini-b `w10_run.out` | + +Host state after wp3: raw HKCU `Path` and kind equal the baseline; no `ocx-probe-261010-*` tasks; staging directory, +temp homes, lane clone `b` and W-a's source patch removed; Desktop PID 22680 and sidecar 33476 unchanged in session 1; +ports 10100/10200/52599 unchanged. The coordinator removes `%TEMP%\ocx-probe-261010` at closeout. + +Secret handling: W-a's full `reg export HKCU\Environment` contained an authentication-token value; the raw copy was +deleted on mini and locally, and only a PATH-only export is kept. diff --git a/devlog/_fin/261010_cli_path_crossos_probe/030_release_qa_matrix.md b/devlog/_fin/261010_cli_path_crossos_probe/030_release_qa_matrix.md new file mode 100644 index 00000000000..1d318d642fe --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/030_release_qa_matrix.md @@ -0,0 +1,23 @@ +# 030 — Release-QA items and the shared findings matrix + +The three release-QA items left open by `261009_desktop_owned_path_cli/049_outcome.md`, plus the matrix both probes +fill. Rows are filled in B of wp2/wp3 with the command and output tail. + +| Item | Host | Probe | Result | +|---|---|---|---| +| Fish in a real login shell | lidge | 010 L4/L6/L7 (portable fish 4.9.3 as the probe user's login shell) | works (019 L7); non-interactive fish keeps `/usr/bin/ocx` by design | +| Linux .deb path | lidge | 010 L3/L5/L6/L12 | works for build, install kind, reconcile and rc blocks (019); opt-out unverified | +| New Windows terminal after a real registry write | mini | 020 W3/W4/W6 | works (029 W4): Terminal tab with `--reloadEnvironment`, Explorer-launched and Task Scheduler `cmd` select the Desktop `ocx` first; module-level write, packaged launch unverified | +| Packaged click-through of the CLI page | — | not in this unit's reach without a GUI operator; recorded as unverified unless the Linux Xvfb run exposes the page | pending | + +## Matrix + +| Surface | Linux (lidge) | Windows (mini) | +|---|---|---| +| PATH installer (#6816) | L1, L2, L6 (packaged deb in overlay) | W1, W3 (module level; packaged launch unverified) | +| New shell / terminal pickup | L7 | W4 | +| Package launcher handoff (#6818) | L8 | W7 (by design: none) | +| status / doctor / resolve (#6802, #6818) | L9 | W7 | +| refuse-to-compete guards (#6809) | L10 | W8 | +| Bun fallback (#6807) / preflight (#6812) | L11 | W9, W10 | +| Opt-out cleanup | L12 | W6 | diff --git a/devlog/_fin/261010_cli_path_crossos_probe/040_fixes_issues_closeout.md b/devlog/_fin/261010_cli_path_crossos_probe/040_fixes_issues_closeout.md new file mode 100644 index 00000000000..14fd4259949 --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/040_fixes_issues_closeout.md @@ -0,0 +1,43 @@ +# 040 — wp4: fixes, issues and closeout + +Each defect found in wp2/wp3 becomes a PR to `dev` (focused tests on the host where it reproduces, exact-head CI, +independent Sol review) or an issue when it needs a design decision. PR branches are cut from `origin/dev` in +`.tmp/lanes/` inside this worktree. PRs follow `.github/PULL_REQUEST_TEMPLATE.md`; issues use the matching +`.github/ISSUE_TEMPLATE/*.yml` headings. + +## Known before the probes + +| ID | Finding | Evidence | Disposition | +|---|---|---|---| +| F1 | `cli_command_record::tests::final_save_before_journal_deletion_is_cleaned_on_reopen` failed once on Linux with `lock-busy` (1 of 4 full `cli_command` runs on lidge; isolated 5/5 pass). `lock()` (`cli_command_record.rs:536`) takes `flock(LOCK_EX\|LOCK_NB)` once. Hypothesis at the time (never confirmed): a child forked by a parallel test briefly shares the lock's open file description until exec, so a drop-then-reopen sees the lock still held. | lidge cargo log | Superseded after wp2: not reproduced in 60 runs and no lock holder captured, so no PR and no issue (see "After wp2") | +| F2 | Windows has no Desktop-supervision detection: #6802 authority, #6809 refusals and #6818 handoff never engage, so a package `ocx stop`/`service install`/`update` can act against a Desktop-owned runtime | 020 W7/W8 | Issue (feature proposal): Windows process-identity proof for the Desktop child (parent PID + image path via `QueryFullProcessImageNameW`/toolhelp snapshot), equivalent to the Linux `/proc` checks | +| F3 | Machine Path npm (nvm4w, Program Files nodejs) always beats the HKCU entry; Desktop reports `partial` but the npm `ocx` stays selected and, without a handoff, can be far older than the Desktop | 020 W3/W4 | Issue or docs PR depending on W4 evidence | + +Further rows are appended from the probe results. Closeout records PR/issue links, head SHAs, CI run ids, review +verdicts and the host revert table. + +## Reflection dispositions + +- F1 stays a hypothesis until wp2 captures the lock holder (010 dispositions); the PR follows that evidence. +- F2 is about supervision only; the Windows handoff being PATH-only is a separate, deliberate design choice and is + described as such. +- F4 (candidate): on Linux, a package launcher that cannot run names no Desktop CLI because `findDesktopCli` is + macOS-only (`src/lib/bun-path-runtime.mjs:79`), even though the deb ships `/usr/bin/ocx`. + +## After wp2 + +- F1: not reproduced in 60 runs; no PR. Mentioned in the closeout as an unexplained single failure. +- F4: confirmed (`Bun binary missing after install attempt.`); follow-up rather than a fix, see 019. +- F5 (new, fixable): `real_shell_selects_desktop_shim_on_temp_home` requires exactly two stdout lines from + `bash -l -i`, which fails on stock Ubuntu for any sudo-group user because `/etc/bash.bashrc` prints the sudo hint. + PR: emit sentinel-prefixed lines (`printf 'OCX-PATH:%s\nOCX-OUT:%s\n' ...`) and assert on those lines only. + +## After wp3 + +| ID | Disposition | +|---|---| +| F2 | Confirmed live (029 W7/W8). Issue (feature proposal): Windows Desktop-supervision detection from `Win32_Process`-equivalent facts (parent PID, both image paths under the install directory, session, creation time, two agreeing snapshots), so #6802 authority and #6809 refusals apply; PATH-only handoff stays a separate decision. | +| F3 | Superseded by F7: on mini the Desktop `ocx` does win in new terminals. | +| F4 | Holds on Linux and Windows; folded into the F2 issue as a related follow-up (name the Desktop CLI in launcher failure text off macOS). | +| F6 | PR: compute the expected rendered path with `windowsEnvIndirectBatchValue` in the three assertions. | +| F7 | Issue (bug): `machine-path-conflict` false positive; evaluate Machine `Path` as a fresh logon environment does (e.g. `CreateEnvironmentBlock` with `bInherit = FALSE`) instead of `ExpandEnvironmentStringsW` in the Desktop process. | diff --git a/devlog/_fin/261010_cli_path_crossos_probe/041_wp4_execution.md b/devlog/_fin/261010_cli_path_crossos_probe/041_wp4_execution.md new file mode 100644 index 00000000000..4f621db922d --- /dev/null +++ b/devlog/_fin/261010_cli_path_crossos_probe/041_wp4_execution.md @@ -0,0 +1,127 @@ +# 041 — wp4 execution: two fix PRs, two issues, closeout + +Stale check: `origin/dev` 93ed1a40b4; none of the files below changed since `d17a9f2239`. Neither test file is in +`tests/fixtures/file-size-baseline.json`. `gh` is unauthenticated and `git push` has no credential on this Mac, so +branches, commits and PRs are created through the GitHub connector (blob → tree → commit on the `dev` head → +`create_branch` at that commit → `create_pull_request` to `dev`), from local commits made in lane worktrees. + +## PR 1 — F5: real-shell evidence test survives system rc output + +Lane worktree `.tmp/lanes/f5-real-shell-markers` (inside this worktree), branch `codex/real-shell-test-markers` from +`origin/dev`. MODIFY `desktop/src-tauri/src/cli_command_posix.rs` in `real_shell_selects_desktop_shim_on_temp_home`: + +```diff +- let output = command +- .args(["-i", "-c", "command -v ocx; ocx hello"]) ++ // System rc files may print (Ubuntu's /etc/bash.bashrc sudo hint); only marked lines count. ++ let output = command ++ .args([ ++ "-i", ++ "-c", ++ "printf 'OCX-PATH:%s\\n' \"$(command -v ocx)\"; printf 'OCX-OUT:%s\\n' \"$(ocx hello)\"", ++ ]) + .output() + .expect("cannot start real shell"); + assert!(output.status.success(), "{shell}: command failed"); + let stdout = String::from_utf8(output.stdout).expect("shell stdout is not UTF-8"); +- let lines: Vec<_> = stdout.lines().collect(); ++ let marked = |tag: &str| stdout.lines().filter_map(|l| l.strip_prefix(tag)).collect::>(); ++ let (paths, outputs) = (marked("OCX-PATH:"), marked("OCX-OUT:")); + // Keep captured system-rc output and temporary HOME paths out of failure logs. + assert!( +- lines.len() == 2, +- "{shell}: expected exactly two stdout lines" ++ paths.len() == 1 && outputs.len() == 1, ++ "{shell}: expected exactly one marked path and one marked output line" + ); + assert!( +- lines[0] == expected_path.to_str().unwrap(), ++ paths[0] == expected_path.to_str().unwrap(), + "{shell}: command resolution selected the wrong executable" + ); +- assert!(lines[1] == expected_output, "{shell}: wrong CLI output"); ++ assert!(outputs[0] == expected_output, "{shell}: wrong CLI output"); +``` + +(final layout per `cargo fmt`). Verification: on lidge (Ubuntu, sudo-group user, the failing case) and on this Mac +(zsh + bash), `cargo test --manifest-path desktop/src-tauri/Cargo.toml cli_command_posix::tests::real_shell_selects_desktop_shim_on_temp_home -- --ignored --exact --nocapture` +must pass (lidge failed before with `expected exactly two stdout lines`); `cargo fmt --check` and +`cargo clippy --all-targets -- -D warnings` for the desktop crate (CI stubs as in ci.yml:1567-1573); plain +`cargo test ... cli_command` stays green. Activation: lidge's `/etc/bash.bashrc` sudo hint is the scenario; the +test only passes there if the extra stdout line is ignored. + +## PR 2 — F6: Windows preflight assertions follow the env-indirect path rendering + +Lane worktree `.tmp/lanes/f6-preflight-test-paths`, branch `codex/preflight-tests-env-indirect-paths`. + +- MODIFY `tests/codex-integration/codex-shim-runtime-preflight.test.ts`: import + `windowsEnvIndirectBatchValue` from `../../src/lib/win-paths`; add + `const renderedRuntimePath = () => windowsEnvIndirectBatchValue(runtime.path, value => value);` with a one-line + comment (wrappers write profile paths as `%LOCALAPPDATA%`/`%USERPROFILE%` tokens, `src/lib/win-paths.ts`); lines 86 + and 111 change `toContain(runtime.path)` → `toContain(renderedRuntimePath())`. +- MODIFY `tests/service/service-runtime-preflight.test.ts`: same import; line 120 + `toContain(runtime.path)` → `toContain(windowsEnvIndirectBatchValue(runtime.path, value => value))`. + +The rendered value equals `runtime.path` when no indirection variable prefixes it (macOS, Linux, CI's tool-cache Bun), +so the assertions keep their meaning there and become correct for a Bun under the profile. The identity escape is +enough because `process.execPath` contains none of `% ^ "`; a comment says so. + +Verification: on mini (Windows, Bun under `%USERPROFILE%\.bun` — the failing case), `bun test +tests/codex-integration/codex-shim-runtime-preflight.test.ts tests/service/service-runtime-preflight.test.ts` with +isolated homes → 0 fail (was 3 fail); same two files on this Mac → 0 fail; `bun run typecheck`. + +## Issues + +| Issue | Template | Content | +|---|---|---| +| I1 Windows Desktop supervision (F2, F4) | `feature_request.yml` headings (Area, What are you trying to accomplish?, What prevents this today?, What should OpenCodex do?, Example usage or interface, Alternatives or workarounds, Additional context, Checks) | 029 W7/W8 evidence: `supervisor: unsupported` with the Win32 parent/child facts, status/doctor service advice, which #6802/#6809 paths stay inert; proposal: Windows process-identity proof (parent PID, both image paths in the install directory, session, creation time, two agreeing snapshots) feeding `inspectDesktopSupervision`; F4 as a related follow-up (name the Desktop CLI in launcher failure text off macOS: Windows `InstallLocation`, Linux deb `/usr/bin/ocx` only when it is the package's ELF) | +| I2 `machine-path-conflict` false positive (F7) | `bug_report.yml` headings (Client or integration, Area, Summary, Reproduction, Version, Operating system, Provider and model, Logs or error output, Screenshots and supporting files, Redacted configuration, Checks) | 029 W3/W4/W5 evidence; proposed direction: read `PATH` from `CreateEnvironmentBlock(token, NULL, FALSE)` and test whether any entry before the Desktop entry holds `ocx.{exe,com,cmd,bat}` | + +Issue bodies are drafted in scratch (`.tmp/issues/`), contain no account data, and are created with the connector. + +## Closeout (wp4 C/D) + +1. Independent Sol review of both PR diffs (exact head) and of 019/029/040; record the verdict and rounds. +2. Exact-head CI for both PRs observed through the connector; Windows shards do not run on ordinary PR CI, so mini's + run is the Windows evidence for PR 2 (stated in the PR). +3. Remove mini's `%TEMP%\ocx-probe-261010` and any lidge leftovers; final host table in 049. +4. 049_outcome.md: findings matrix summary, PR/issue links, heads, CI runs, reverts. The devlog unit itself is + published as a third, docs-only PR from this branch (`codex/cli-path-crossos-probe`). + +## Architect consultation (Sol 01a1229a-dfe4, proposal written before reading this doc) + +| ID | Proposal | Disposition | +|---|---|---| +| P1 | Sentinel-prefixed result lines; capture each command and keep its failure status; shared bash/zsh syntax; reject `.hushlogin` (changes the tested environment) and stderr redirection (the hint is on stdout) | accepted; the `-c` script becomes `p=$(command -v ocx) \|\| exit 11; o=$(ocx hello) \|\| exit 12; printf 'OCX-PATH:%s\\nOCX-OUT:%s:OCX-END\\n' "$p" "$o"`, so a failing lookup or CLI still fails `status.success()`; final #6851 head `d785fb1b02` also requires `outputs[0] == format!("{expected_output}:OCX-END")`, so an extra CLI output line fails the comparison | +| P2 | Expected value from `windowsEnvIndirectBatchValue`; assert the complete `set "OCX_BUN=..."` line; keep the frozen-runtime and `unprobed.exe` checks | accepted; the three assertions check `set "OCX_BUN="` (the shim and the service script both emit that line), using the test-local `batchValue` escape for the literal suffix (see audit item 1) | +| P3a | Feature issue for Windows supervision with the observed trigger, the proof shape, `unknown` on inconclusive evidence; PATH-only handoff stays separate; F4 related, Linux needs deb-vs-npm-symlink distinction | accepted as I1 | +| P3b | Bug issue for F7; desired behaviour is fresh-logon PATH evaluation; validate `CreateEnvironmentBlock(FALSE)` against real new terminals before choosing it; no speculative PR | accepted as I2 (wording: candidate direction, to be validated) | +| P4 | No issue for F1; keep the observation and capture the lock holder on recurrence | accepted | + +Reflection: the proposal and this plan match on every decision; the only amendments are the two concrete P1/P2 forms +above. + +## Audit (independent Sol, 01a1229c-7b16): NEAR-PASS → dispositions + +1. PR2 expected values use a test-local `batchValue` that applies the generators' own suffix escaping + (`%` → `%%`, `^` → `^^`, drop `"`; `src/codex/shim-templates.ts:170`, `src/service/windows-taskxml.ts:14`) through + `windowsEnvIndirectBatchValue(runtime.path, batchValue)`, and assert the complete `set "OCX_BUN=..."` line. The + frozen-runtime and `unprobed.exe` checks stay. +2. Issues use `### ` sections in form order, with the dropdown value or text under each, and the form's + labels (`bug` / `enhancement`). The forms define no `title:` prefix; titles follow the repository convention + (`[Bug] ...` / `[Feature]: ...`). `enforce-issue-quality` detects the kind from content and validates the body + (`.github/scripts/issue-quality-core.cjs` `detectIssueKind`, `validateIssueBody`), not the submission route. Each + PR body fills Summary, Verification and Checklist from `.github/PULL_REQUEST_TEMPLATE.md` with the host evidence. +3. Closeout step 1 records the actual review verdict and its round count rather than presupposing PASS; PR1's Rust + literal keeps `\\n` and plain `||`. + +## Audit round 2 (01a1229c-7b16): NEAR-PASS, one residual → rebuttal + +The forms define no title prefix; the bug form adds the `bug` label and the feature form `enhancement`. Titles follow +the repository's existing convention (`[Bug] ...`, `[Feature]: ...`, e.g. #6720, #3191). + +Residual: AGENTS.md asks agents to open issues through the template chooser. Issue forms can only be submitted through +the web UI and `gh` is unauthenticated here, so the issues are created through the GitHub connector with the body the +form would render (same `###
    diff --git a/gui/src/components/sidebar-github-row.tsx b/gui/src/components/sidebar-github-row.tsx index 8b5ae752abb..227d9dfa814 100644 --- a/gui/src/components/sidebar-github-row.tsx +++ b/gui/src/components/sidebar-github-row.tsx @@ -1,5 +1,5 @@ /** - * Sidebar footer row: the GitHub link plus two circular satellite buttons. + * Sidebar footer row: the GitHub link plus circular satellite buttons. * * Star: reflects the real state from `gh` rather than always inviting a click. * Already starred renders a filled, non-interactive marker; an unauthenticated @@ -15,7 +15,7 @@ */ import { useState } from "react"; import { useKeyedClientResource } from "../client-resource"; -import { IconDownload, IconGithub, IconStar } from "../icons"; +import { IconDownload, IconGithub, IconStar, IconTerminal } from "../icons"; import { useT } from "../i18n/shared"; import { isDesktopShell, updateBadgeUrl } from "../lib/desktop-shell"; @@ -47,10 +47,13 @@ async function readJson(url: string, signal: AbortSignal): Promise export function SidebarGithubRow({ apiBase, onOpenUpdate, + onOpenTerminalCommand, }: { apiBase: string; /** Opens desktop updates in the app shell, or the package update surface in a browser. */ onOpenUpdate: () => void; + /** Opens the local terminal-command settings in the desktop app shell. */ + onOpenTerminalCommand: () => void; }) { const t = useT(); const [starring, setStarring] = useState(false); @@ -157,6 +160,17 @@ export function SidebarGithubRow({