diff --git a/bin/ocx.mjs b/bin/ocx.mjs index ef3aa80cd8..7b3a54eaa2 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -522,6 +522,16 @@ function runPackageManagerSelfUpdate(manager) { " After the update: close the Codex app, run 'ocx doctor', then run 'ocx stop' once to retry.", ); } + if (decision.reason === "history-deferred") { + // The reported #4718 path is this lane. Nothing was restored, so this is a different + // sentence from the manifest warning above: an operator told "history metadata is + // incomplete" would assume config and catalog already came back. + console.warn( + "opencodex: WARNING — the shared teardown was refused by the Codex history preflight and restored nothing.\n" + + " Config, catalog, history and provenance were preserved, and the teardown receipt was kept.\n" + + " The proxy is down, so the update continues; close the Codex app and run 'ocx stop' once afterwards to finish the restore.", + ); + } } // npm keeps the existing stage -> verify -> swap -> rollback flow. pnpm owns a diff --git a/src/cli/index.ts b/src/cli/index.ts index 9d37cb5c21..9df96b3fad 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -15,7 +15,7 @@ try { } import { currentExternalCodexModelProvider, restoreNativeCodex, restoreNativeCodexAsync, shouldInjectApiAuthHeader } from "../codex/inject"; import { stripGrokConfig } from "../grok/inject"; -import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../update/stop-contract.mjs"; +import { STOP_HISTORY_DEFERRED_EXIT_CODE, STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../update/stop-contract.mjs"; import { describeHistoryJobFailure, resolveCodexHistoryJobTarget, @@ -46,6 +46,7 @@ import { isPendingTeardownAbandoned, listPendingTeardowns, pendingTeardownPathFor, + pendingTeardownsAreExactly, quarantinePendingTeardown, } from "../config/pending-teardown"; import { collectStatus, hubStatusLines, remoteHubBannerLine, remoteHubStatusLines, unusedProxyWarningLines } from "./status"; @@ -785,9 +786,16 @@ async function handleRestartStartWhenStopped(): Promise { * * The distinction exists because `ocx update` must proceed for the first and abort for the * second, and it can only see an exit code (#3008). + * + * `historyDeferred` is the third kind (#4718). The Codex history preflight refuses BEFORE + * the config half runs, so nothing was restored at all: config, catalog, history and + * provenance are untouched and the client is still routed at the proxy that just stopped. + * Like `historyOnly` the proxy is genuinely down, so an update may replace package files. + * Unlike `historyOnly` the obligation was not performed, so the receipt must survive. */ -async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boolean; other: boolean }> { +async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boolean; historyDeferred: boolean; other: boolean }> { let historyOnly = false; + let historyDeferred = false; let other = false; try { const result = await restoreNativeCodexAsync(); @@ -798,7 +806,16 @@ async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boole // not — a client reads those, so their failure is a real teardown failure. const artifacts = result.artifacts; const configOrCatalogFailed = artifacts.config.state === "failed" || artifacts.catalog.state === "failed"; - if (!configOrCatalogFailed && artifacts.history.state === "failed") historyOnly = true; + // A preflight refusal reports every artifact as `skipped` because none of them were + // attempted. Reading the states alone cannot tell that apart from an ownership + // refusal, so the structured reason carries it and the states are still required to + // agree — a refusal that somehow reports a failed artifact is not this case. + const preflightRefused = result.historyPreflightRefusal !== undefined + && artifacts.config.state === "skipped" + && artifacts.catalog.state === "skipped" + && artifacts.history.state === "skipped"; + if (preflightRefused) historyDeferred = true; + else if (!configOrCatalogFailed && artifacts.history.state === "failed") historyOnly = true; else other = true; console.error(`⚠️ ${result.message}`); } @@ -816,7 +833,7 @@ async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boole other = true; console.error(`⚠️ Grok config restore failed: ${error instanceof Error ? error.message : String(error)}`); } - return { historyOnly, other }; + return { historyOnly, historyDeferred, other }; } async function handleStop() { @@ -860,6 +877,11 @@ async function handleStop() { }; let stopFailed = false; let historyOnlyFailure = false; + /** + * Obligations this run deliberately kept because the Codex history preflight refused + * before restoring anything (#4718). Non-null selects the deferred exit code. + */ + let historyDeferredNonces: string[] | null = null; // Only Task Scheduler respawns after a successful stop (#764), so only it earns the // restart-window wait; launchd, systemd and WinSW are down when they say so. let schedulerCanRespawn = false; @@ -1138,6 +1160,7 @@ async function handleStop() { } const restore = await restoreSharedClientStateAfterStop(); if (restore.other) stopFailed = true; + else if (restore.historyDeferred) historyDeferredNonces = teardownNonce ? [teardownNonce, ...recoveredNonces] : recoveredNonces; else if (restore.historyOnly) historyOnlyFailure = true; // The obligation is discharged whether or not history metadata finalized: config and // catalog are what a client reads, and `restore.other` already fails the stop. @@ -1145,7 +1168,16 @@ async function handleStop() { // Each nonce names its own file, so a clear can only ever remove the obligation it // names — never one a concurrent stop wrote. Both this run's claim and every inherited // receipt it proved discharged are released together. - if (!restore.other) { + // + // A history-preflight refusal is the exception: it restored nothing, so there is + // nothing to discharge. Clearing here would drop a real obligation on the floor and + // leave the client config pointing at a proxy that is gone, with nothing on disk + // saying so — which is the whole failure the receipt exists to prevent (#4718). + if (restore.historyDeferred) { + console.error(" The shared teardown was refused before it changed anything, so it is still owed."); + console.error(" Its receipt is preserved; run 'ocx stop' again once Codex is closed to retry the restore."); + } + if (!restore.other && !restore.historyDeferred) { const discharged = teardownNonce ? [teardownNonce, ...recoveredNonces] : recoveredNonces; for (const nonce of discharged) { // A receipt that survives its discharge re-triggers recovery forever, so a failed @@ -1185,6 +1217,17 @@ async function handleStop() { // still wins: it is the stronger signal. if (stopFailed) process.exitCode = 1; else if (historyOnlyFailure) process.exitCode = STOP_HISTORY_INCOMPLETE_EXIT_CODE; + // The deferred code says "the only obligations left are the ones I just decided to + // keep". It is read across a process boundary by an updater that will replace package + // files on the strength of it, so this run has to be able to prove the claim: if any + // other obligation is sitting in the home — quarantined, or a concurrent stop's — the + // claim is false and the ordinary failure code is the honest answer. That is also the + // behaviour before #4718, so the fallback loses nothing that used to work. + else if (historyDeferredNonces) { + process.exitCode = pendingTeardownsAreExactly(historyDeferredNonces) + ? STOP_HISTORY_DEFERRED_EXIT_CODE + : 1; + } return !stopFailed; } diff --git a/src/codex/inject/restore.ts b/src/codex/inject/restore.ts index 15282ea771..5e273173ec 100644 --- a/src/codex/inject/restore.ts +++ b/src/codex/inject/restore.ts @@ -89,6 +89,18 @@ export interface CodexNativeRestoreResult { success: boolean; message: string; externalProvider?: string; + /** + * Set when the restore refused at the Codex history preflight (#4718). + * + * The preflight runs before the config half, so a refusal leaves config, catalog, + * history and provenance exactly as they were. That is a different outcome from a + * restore that ran and failed, and callers that decide whether an obligation was + * discharged need to tell them apart. Reading the artifact states alone cannot: a + * refusal reports every artifact as `skipped`, which is also what an ownership refusal + * and a desired-state skip report. Matching the human-readable message instead would + * make a safety decision depend on prose. + */ + historyPreflightRefusal?: string; artifacts: { config: CodexRestoreConfigResult; catalog: CodexRestoreCatalogResult; @@ -216,6 +228,21 @@ function failedConfigRestoreEnvelope(config: CodexRestoreConfigResult): CodexNat return result; } +/** + * The history preflight refused, so nothing was attempted at all (#4718). + * + * The message is unchanged from what this path has always printed; the structured reason + * is added beside it so a caller can act on the refusal without reading the prose. + */ +function historyPreflightRefusalEnvelope(historyError: string): CodexNativeRestoreResult { + const result = skippedRestoreEnvelope( + false, + `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`, + ); + result.historyPreflightRefusal = historyError; + return result; +} + /** The config/profile half of a native restore, reported as one artifact. */ function restoreCodexConfigInline(kind = "sync"): CodexRestoreConfigResult { const preImages = captureCodexPreImages(); @@ -342,7 +369,7 @@ async function restoreNativeCodexAsyncImpl( } const historyError = preflightCodexHistoryInjection(false, false); - if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`); + if (historyError) return historyPreflightRefusalEnvelope(historyError); const eligibility = codexWriteCoordinationEligibility({ coordinatorPath: () => @@ -490,7 +517,7 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD return desiredEnabledRestoreSkip(); } const historyError = preflightCodexHistoryInjection(false, false); - if (historyError) return skippedRestoreEnvelope(false, `Native restore refused: ${historyError}. Config, catalog, history and provenance were preserved.`); + if (historyError) return historyPreflightRefusalEnvelope(historyError); // Captured before the config half: a successful journal restore DELETES the journal, and // restoring the config can drop `model_catalog_json`. Either one would hide the routed // catalog we actually wrote (#1798). diff --git a/src/config/pending-teardown.ts b/src/config/pending-teardown.ts index bfab1cf757..9b1f5e97a2 100644 --- a/src/config/pending-teardown.ts +++ b/src/config/pending-teardown.ts @@ -202,6 +202,37 @@ export function pendingTeardownOutstanding(): boolean { } } +/** + * Are the outstanding obligations EXACTLY the ones this stop chose to keep? + * + * `ocx stop` can preserve its own obligations deliberately — the Codex history preflight + * refuses before anything is restored, so the receipt has to survive for a later stop + * (#4718). That is safe for an update to continue past, because the stop knows those + * receipts describe a proxy it just proved down. + * + * Nothing else is. A quarantined receipt is waiting on a human, and a receipt belonging + * to a live owner means another stop is in flight; letting either ride along would turn + * "we deliberately kept ours" into "we ignored everyone's". So membership is the test, + * not a count of ours: an unrecognized obligation of any kind answers false and the + * caller falls back to the ordinary failure code. + * + * Quarantined names are included in the scan on purpose. They do not correspond to any + * nonce this run preserved, so their presence always answers false. + */ +export function pendingTeardownsAreExactly(nonces: readonly string[]): boolean { + const expected = new Set(nonces.map(nonce => `${PREFIX}${nonce}${SUFFIX}`)); + let names: string[]; + try { + names = readdirSync(getConfigDir()); + } catch (error) { + // A home that does not exist holds nothing, which matches only an empty expectation. + // Any other scan failure may be hiding an obligation and must not answer "exactly". + return (error as NodeJS.ErrnoException).code === "ENOENT" && expected.size === 0; + } + const found = names.filter(isAnyTeardownObligationFileName); + return found.length === expected.size && found.every(name => expected.has(name)); +} + /** Paths of quarantined obligations awaiting a human. */ export function listQuarantinedTeardowns(): string[] { try { diff --git a/src/lib/windows-elevation.ts b/src/lib/windows-elevation.ts index b2d02b8123..171545276c 100644 --- a/src/lib/windows-elevation.ts +++ b/src/lib/windows-elevation.ts @@ -250,6 +250,21 @@ export const OCX_ELEVATED_PROTOCOL_FAILED = 13; /** Windows ERROR_CANCELLED — reserved for UAC denial; never emitted by the elevated script. */ export const OCX_ELEVATED_UAC_CANCELLED = 1223; +/** + * The elevated process could not read a staged payload (#4692). + * + * `hardenSecretPath` grants the staging account and strips inheritance, so a split-token + * elevation of the same user reads the file and an elevation answered with a DIFFERENT + * administrator's credentials does not. The elevated side cannot explain that itself: it + * runs hidden, so its stderr goes nowhere and only the exit code survives the boundary. + * Without a code of its own the operator would be told "exit code 1" for a cause that + * names its own remedy — the same undiagnosable failure this change set exists to remove. + * + * Deliberately outside OCX_ELEVATED_PROTOCOL_CODES: that list is the create-and-run + * transaction's alphabet, and this code belongs to the registration path. + */ +export const OCX_ELEVATED_STAGING_UNREADABLE = 14; + export const OCX_ELEVATED_PROTOCOL_CODES = [ OCX_ELEVATED_SUCCESS, OCX_ELEVATED_CREATE_FAILED, @@ -645,36 +660,83 @@ export function runWindowsElevated(file: string, args: string[]): Promise