From 600e323ccdfd18abf0c0179267ab3454be1d7135 Mon Sep 17 00:00:00 2001 From: yuanyuanlove Date: Mon, 21 Sep 2026 13:36:36 +0800 Subject: [PATCH 01/17] =?UTF-8?q?fix(kimi):=20K2.8=20registry=20refresh=20?= =?UTF-8?q?=E2=80=94=20adjustable=20thinking,=201M=20context,=20kimi-for-c?= =?UTF-8?q?oding=20default=20(#5403)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * release: v2.33.0-preview.20260825 * release: v2.34.0-preview.20260827 * release: v2.36.0-preview.20260829 * fix(release): pass the bump job's permissions through the reusable-workflow call (#3262) Both v2.40.0 release dispatches (33615174183 preview, 33615177849 main) died at startup_failure: a workflow_call cannot grant its callee more than the calling job holds, and dev-version-bump.yml's job declares contents+pull- requests write. #3129 wired the call but never dispatched a release, so this is its first live run. The caller job now declares exactly the callee's two permissions; no other job in release.yml gains anything. Co-authored-by: jun (cherry picked from commit 7ce0ba51834740d7b4d5ec4793f6572d84624409) * release: set preview channel version 2.48.0-preview.20260908 * release: set main channel version 2.48.0 * chore(release): promote 2.55.0-preview.20260914 to preview Promotes the dev product snapshot 62f02223a0 to the preview train. The 2.55.0 line carries the #4546 cost-guard work: one send budget per logical request with a shared final-recovery reserve, zero-is-zero refusals with a typed error rather than a synthetic 502, compact and the Kiro inner retries admitted against that budget, a finite send ceiling per root workflow with an interactive reserve a fan-out cannot take, and a healthy detour promoted on transient-hold expiry instead of released cold. The previous preview tip 2.54.0-preview.20260914 is already tagged and published and is outranked by v2.54.0, so it could not be re-released; this is a new candidate rather than a re-cut. * chore(release): promote the verified 2.55.0 product tree to main Same product tree as preview 7bdd1b29b5 / 2.55.0-preview.20260914, which published successfully with its registry smoke green. Only package.json version differs. * fix(kimi): update Kimi coding registry for K2.8 (adjustable thinking, 1M context, current alias default) - kimi-for-coding is the stable subscription alias Moonshot re-points at each coding release; it now routes to K2.8 Preview. Live GET /coding/v1/models lists only kimi-for-coding[-highspeed], k3, k3-256k; the k2.x ids are retired from the subscription endpoint. - K2.8 accepts the same adjustable low/high/max thinking ladder as k3 (verified live: 350K-token request accepted at max effort; upstream rejects beyond 1,048,576 with 'model token limit: 1048576'). - Bump kimi-for-coding context window to the verified 1M ceiling and advertise text+image input. - Default kimi / kimi-code presets to kimi-for-coding instead of the retired kimi-k2.7-code. - Update provider-registry parity test to match the new verified shape. * fix(kimi): retire k2.x ids from the coding picker and migrate saved configs to kimi-for-coding Address review on #5403: - MODEL_RENAMES gains kimi/kimi-code entries mapping the retired default kimi-k2.7-code to the live kimi-for-coding alias, so saved configs keep working after Moonshot removed the k2.x ids from the subscription endpoint. - The kimi/kimi-code presets seed only ids the endpoint still serves (live /coding/v1/models: kimi-for-coding, k3). Every preset metadata list is live-id only: seeding a retired id there re-armed the rename migration on every boot (#5066 shape), because the residue guard cannot skip a list that holds the retired id without the live alias. - KIMI_CODING_MODELS is replaced by KIMI_CODING_LIVE_MODELS built from KIMI_CODING_K3_MODELS + KIMI_CODING_K28_MODELS, so a future alias added to the K28 constant flows into the picker and every parallel record. - Parity tests now assert defaultModel is kimi-for-coding for both presets (a registry rollback to the retired default would otherwise pass silently). Verified: bun test on model-rename-migration, provider-registry-parity and codex-catalog (432 pass), full tests/providers sweep (only pre-existing proxy-environment timeouts fail, identical on the clean base), tsc clean. * docs(kimi): document the K2.8 coding refresh in the providers guide Address the CodeRabbit finding on #5403: the kimi row in the canonical English providers guide (and the zh-cn translation) now documents the kimi-for-coding default, the 1M context window, the adjustable low/high/max ladder (default max), image input, and the automatic kimi-k2.7-code migration on upgrade. Verified with the required validation: cd docs-site && bun install --frozen-lockfile && bun run build (497 pages, exit 0). * fix(kimi): repair saved K2 coding metadata * fix(kimi): drop the stale no-reasoning classification even when only the replacement id is present Address the CodeRabbit finding on the maintainer's 608d7a22b: a saved row can carry kimi-for-coding in noReasoningModels while every retired id is already gone from the row (the pre-K2.8 registry seeded the alias there). The early return in dropRenamedIdsFromList required the retired id, so the stale classification survived and kept the reasoning picker disabled for the live alias. Proceed when the list contains either id and filter both. Verified: model-rename-migration + provider-registry-parity 101 pass, tsc clean; new regression test covers the replacement-id-only row. * fix(kimi): preserve explicit reasoning overrides --------- Co-authored-by: JUN Co-authored-by: jun Co-authored-by: jun Co-authored-by: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Co-authored-by: t Co-authored-by: JUN Co-authored-by: panyuanyuan --- .../src/content/docs/guides/providers.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- src/providers/model-rename-migration.ts | 112 +++++++++++++- src/providers/registry/entries-core.ts | 13 +- src/providers/registry/entries-extended.ts | 10 +- src/providers/registry/model-seeds.ts | 35 +++-- .../providers/model-rename-migration.test.ts | 146 ++++++++++++++++++ .../provider-registry-parity.test.ts | 36 +++-- 8 files changed, 323 insertions(+), 33 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index b013cecfd2e..e8cba6c928d 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -198,7 +198,7 @@ ocx logout | --- | --- | --- | --- | | `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth uses the separate Grok CLI subscription gateway. The API-key override uses `https://api.x.ai/v1` and may inject Priority Processing. Live-first Grok catalog; `grok-4.5` is the fallback default. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude models; live model list fetched from `/v1/models`. | -| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | +| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi Code Plan coding models. Defaults to the stable `kimi-for-coding` alias (currently K2.8 Preview): 1M-token context window, adjustable `low`/`high`/`max` thinking (default `max`), text + image input. Retired `kimi-k2.x` selections are migrated to the alias on upgrade. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` | `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index f4796581124..d799f306705 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -101,7 +101,7 @@ ocx logout | --- | --- | --- | --- | | `xai` | `openai-chat` | `https://cli-chat-proxy.grok.com/v1` | OAuth 使用独立的 Grok CLI 订阅网关。API 密钥覆盖模式使用 `https://api.x.ai/v1`,并可能注入 Priority Processing。优先使用实时 Grok 目录;回退默认模型为 `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 模型;实时模型列表从 `/v1/models` 获取。 | -| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 编程模型。 | +| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi Code Plan 编程模型。默认使用稳定的 `kimi-for-coding` 别名(当前指向 K2.8 Preview):100 万 token 上下文、可调 `low`/`high`/`max` 思考档(默认 `max`)、支持文本 + 图片输入。已下架的 `kimi-k2.x` 选择会在升级时自动迁移到该别名。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 订阅网关(与 Hermes Agent 使用同一后端)。通过设备授权登录 `portal.nousresearch.com`;access 令牌是每个请求的 inference JWT。付费 + `:free` 模型混合目录(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)会从已登录账户实时发现。Refresh 令牌是单次使用,每次刷新都会轮换。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(Unix 使用 `curl -fsSL https://cli.kiro.dev/install` | `bash`;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`;然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。 | diff --git a/src/providers/model-rename-migration.ts b/src/providers/model-rename-migration.ts index 0606fe3dcd9..0c66e803de3 100644 --- a/src/providers/model-rename-migration.ts +++ b/src/providers/model-rename-migration.ts @@ -45,6 +45,52 @@ export interface ModelRename { * but lose the reasoning picker entirely. */ dropReasoningEffortMap?: boolean; + /** + * Remove both the retired id and its replacement from `noReasoningModels`. + * + * Use this only when the rename also marks a capability change: carrying the old + * no-reasoning classification onto a newly adjustable alias would keep the picker + * disabled after the model id itself was repaired. + */ + dropNoReasoningModels?: boolean; + /** Refresh exact registry defaults already saved under the replacement id. */ + targetSeedRefresh?: { + contextWindow?: { from: number; to: number }; + reasoning?: { + fromEfforts: readonly string[]; + toEfforts: readonly string[]; + defaultEffort: string; + effortMap: Readonly>; + }; + }; +} + +const KIMI_K28_ALIAS = "kimi-for-coding"; +const KIMI_RETIRED_CODING_IDS = [ + "kimi-k2.7-code", + "kimi-k2.7-code-highspeed", + "kimi-k2.6", + "kimi-k2.5", +] as const; +const KIMI_K28_TARGET_SEED_REFRESH: NonNullable = { + contextWindow: { from: 262_144, to: 1_048_576 }, + reasoning: { + fromEfforts: [], + toEfforts: ["low", "high", "max"], + defaultEffort: "max", + effortMap: { none: "none", low: "low", medium: "high", high: "high", xhigh: "max", max: "max" }, + }, +}; + +function kimiCodingRenames(provider: "kimi" | "kimi-code", endpoint: string): ModelRename[] { + return KIMI_RETIRED_CODING_IDS.map(from => ({ + provider, + from, + to: KIMI_K28_ALIAS, + reason: `Moonshot retired the k2.x coding ids from the ${endpoint}; kimi-for-coding is the stable alias the endpoint still serves`, + dropNoReasoningModels: true, + targetSeedRefresh: KIMI_K28_TARGET_SEED_REFRESH, + })); } /** @@ -65,6 +111,14 @@ export const MODEL_RENAMES: readonly ModelRename[] = [ to: "qwen3.8-max", reason: "Alibaba shipped Qwen3.8-Max as stable and documents the preview endpoint as liable to be taken offline once preview concludes", }, + // Kimi coding renames. Moonshot retired the k2.x ids from the subscription/coding + // endpoint when K2.8 Preview shipped (live /coding/v1/models lists only + // kimi-for-coding[-highspeed], k3, k3-256k); kimi-for-coding is the stable alias the + // endpoint still serves and currently routes to K2.8 Preview. The registry picker no + // longer seeds the retired ids, so a saved defaultModel naming one is a dead selection + // rather than a merely outdated one. + ...kimiCodingRenames("kimi", "subscription endpoint after K2.8 Preview shipped"), + ...kimiCodingRenames("kimi-code", "coding endpoint after K2.8 Preview shipped"), // Antigravity Flash generations. Google takes the previous Flash model off Cloud Code // Assist almost immediately when the next ships, so a saved 3.6 (or older 3.5) id is a // dead selection rather than a merely outdated one. Routing already redirects these ids @@ -133,6 +187,18 @@ function renameInList(value: unknown, from: string, to: string): string[] | null return next; } +function dropRenamedIdsFromList( + value: unknown, + from: string, + to: string, + dropStaleTarget: boolean, +): string[] | null { + if (!Array.isArray(value)) return null; + const hasRetired = value.includes(from); + if (!hasRetired && !(dropStaleTarget && value.includes(to))) return null; + return value.filter(entry => typeof entry === "string" && entry !== from && entry !== to); +} + function renameInRecord(value: unknown, from: string, to: string): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const record = value as Record; @@ -160,6 +226,42 @@ function dropFromRecord(value: unknown, from: string): Record | return next; } +function sameStringArray(value: unknown, expected: readonly string[]): boolean { + return Array.isArray(value) + && value.length === expected.length + && value.every((entry, index) => entry === expected[index]); +} + +function targetReasoningMatchesStaleSeed(row: Record, rename: ModelRename): boolean { + const reasoning = rename.targetSeedRefresh?.reasoning; + const efforts = row.modelReasoningEfforts as Record | undefined; + return !!reasoning && !!efforts && sameStringArray(efforts[rename.to], reasoning.fromEfforts); +} + +/** Refresh only exact defaults emitted by the previous registry; preserve user overrides. */ +function refreshTargetSeed(row: Record, rename: ModelRename): boolean { + const refresh = rename.targetSeedRefresh; + if (!refresh) return false; + let changed = false; + + const windows = row.modelContextWindows as Record | undefined; + if (windows && refresh.contextWindow && windows[rename.to] === refresh.contextWindow.from) { + windows[rename.to] = refresh.contextWindow.to; + changed = true; + } + + const reasoning = refresh.reasoning; + const efforts = row.modelReasoningEfforts as Record | undefined; + if (!reasoning || !efforts || !sameStringArray(efforts[rename.to], reasoning.fromEfforts)) return changed; + efforts[rename.to] = [...reasoning.toEfforts]; + + const defaults = (row.modelDefaultReasoningEfforts ??= {}) as Record; + if (!(rename.to in defaults)) defaults[rename.to] = reasoning.defaultEffort; + const maps = (row.modelReasoningEffortMap ??= {}) as Record; + if (!(rename.to in maps)) maps[rename.to] = { ...reasoning.effortMap }; + return true; +} + /** * `provider/model` rows in the top-level `disabledModels` list. * @@ -281,7 +383,14 @@ export function projectModelRenames( let touched = false; for (const field of MODEL_ID_LISTS) { if (isRegistryResidue(seed, field, row[field], rename)) continue; - const next = renameInList(row[field], rename.from, rename.to); + const next = rename.dropNoReasoningModels && field === "noReasoningModels" + ? dropRenamedIdsFromList( + row[field], + rename.from, + rename.to, + targetReasoningMatchesStaleSeed(row, rename), + ) + : renameInList(row[field], rename.from, rename.to); if (!next) continue; row[field] = next; touched = true; @@ -300,6 +409,7 @@ export function projectModelRenames( touched = true; } if (renameDisabledModels(config, rename)) touched = true; + if (touched && refreshTargetSeed(row, rename)) touched = true; if (touched) { changed = true; diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index f16f9f15687..3a956081ddc 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -55,7 +55,7 @@ import { deepseekThinkingEffortsFor, deepseekReasoningMapFor, KIMI_K3_STANDARD_CONTEXT_WINDOW, - KIMI_CODING_MODELS, + KIMI_CODING_LIVE_MODELS, KIMI_THINKING_MODELS, KIMI_CODING_NO_REASONING_MODELS, KIMI_CODING_K3_REASONING_EFFORTS, @@ -444,8 +444,14 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ oauthId: "kimi", jawcodeBundle: "moonshot", note: "Log in with your Kimi account", - models: KIMI_CODING_MODELS, - defaultModel: "kimi-k2.7-code", + // 260921: the retired k2.x ids stay out of the picker — live /coding/v1/models lists + // only kimi-for-coding[-highspeed], k3, k3-256k. Saved rows still naming kimi-k2.7-code + // are repaired by MODEL_RENAMES in model-rename-migration.ts. + models: KIMI_CODING_LIVE_MODELS, + // 260921: kimi-k2.7-code was retired from the subscription endpoint (live /models lists + // only kimi-for-coding[-highspeed], k3, k3-256k). The kimi-for-coding alias is the + // stable ID and currently routes to K2.8 Preview. + defaultModel: "kimi-for-coding", modelContextWindows: KIMI_CODING_MODEL_CONTEXT_WINDOWS, modelInputModalities: KIMI_CODING_MODEL_INPUT_MODALITIES, // K3 accepts low/high/max; Codex aliases are normalized by the model-scoped wire map. @@ -1258,4 +1264,3 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ note: "Serverless Inference subscription API. Live discovery exposes only kimi-k2-instruct because Vultr documents it as the sole tool-calling model.", }, ]; - diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 48ebd4242d7..3dc0ff10b46 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -72,10 +72,10 @@ import { VOLCENGINE_PLAN_TEXT_ONLY_MODELS, ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES, KIMI_API_MODELS, - KIMI_CODING_MODELS, KIMI_THINKING_MODELS, KIMI_CODING_NO_REASONING_MODELS, KIMI_API_NO_REASONING_MODELS, + KIMI_CODING_LIVE_MODELS, KIMI_CODING_REASONING_EFFORTS, KIMI_CODING_DEFAULT_REASONING_EFFORTS, KIMI_CODING_REASONING_EFFORT_MAPS, @@ -1013,13 +1013,17 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ }, { id: "kimi-code", label: "Kimi (coding)", baseUrl: "https://api.kimi.com/coding/v1", adapter: "openai-chat", authKind: "key", - dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-k2.7-code", + // 260921: kimi-k2.7-code was retired from the coding endpoint; the kimi-for-coding alias + // is the stable ID and currently routes to K2.8 Preview (same as the OAuth preset). + dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-for-coding", modelSuffixBracketStrip: true, // API-key form of the same Kimi Code Plan transport; keep cache affinity identical to OAuth. promptCacheKey: true, // Keep Responses tool-result adjacency aligned with the OAuth preset (#4726). requiresAdjacentResponsesToolResults: true, - models: KIMI_CODING_MODELS, + // 260921: same live-id picker as the OAuth preset — the retired k2.x ids are repaired + // in saved configs by MODEL_RENAMES, not offered on fresh installs. + models: KIMI_CODING_LIVE_MODELS, modelContextWindows: KIMI_CODING_MODEL_CONTEXT_WINDOWS, modelInputModalities: KIMI_CODING_MODEL_INPUT_MODALITIES, noReasoningModels: KIMI_CODING_NO_REASONING_MODELS, diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts index c3ee348b6ee..1d6884aabfb 100644 --- a/src/providers/registry/model-seeds.ts +++ b/src/providers/registry/model-seeds.ts @@ -642,11 +642,26 @@ export const ALIBABA_TOKEN_PLAN_PRESERVE_REASONING = [ export const KIMI_K3_STANDARD_CONTEXT_WINDOW = 262_144; export const KIMI_K3_1M_CONTEXT_WINDOW = 1_048_576; export const KIMI_CODING_K3_MODELS = ["k3", "k3[1m]"]; +// 260921 Kimi K2.8: `kimi-for-coding` is the stable subscription alias Moonshot re-points +// at each coding release. Live GET /coding/v1/models lists only kimi-for-coding[-highspeed], +// k3, k3-256k — the k2.x ids are retired from the subscription endpoint. Since K2.8 Preview +// the alias serves an adjustable low/high/max thinking ladder (same wire map as k3) and a +// 1M context ceiling. Verified live 260921: 350K-token request accepted; upstream rejects +// with "model token limit: 1048576" beyond that. +// Evidence: https://www.kimi.com/code/docs/en/kimi-code/models.html +export const KIMI_CODING_K28_MODELS = ["kimi-for-coding"]; +export const KIMI_CODING_LIVE_MODELS = [...KIMI_CODING_K3_MODELS, ...KIMI_CODING_K28_MODELS]; +export const KIMI_CODING_ADJUSTABLE_THINKING_MODELS = [...KIMI_CODING_LIVE_MODELS]; export const KIMI_LEGACY_API_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"]; export const KIMI_API_MODELS = ["kimi-k3", ...KIMI_LEGACY_API_MODELS]; -export const KIMI_CODING_MODELS = [...KIMI_CODING_K3_MODELS, ...KIMI_LEGACY_API_MODELS, "kimi-for-coding"]; -export const KIMI_THINKING_MODELS = KIMI_CODING_MODELS; -export const KIMI_CODING_NO_REASONING_MODELS = KIMI_CODING_MODELS.filter(id => !KIMI_CODING_K3_MODELS.includes(id)); +// Every kimi coding preset record - picker, context windows, locked-parameter lists - +// derives from the live ids only. seeding a retired id in a metadata list would re-arm +// the model-rename migration on every boot (#5066): the list holds the retired id but +// not the live alias, so the residue guard cannot skip it. The retired ids survive only +// in KIMI_LEGACY_API_MODELS (moonshot platform API records); model-rename-migration +// repairs saved rows still naming them. +export const KIMI_THINKING_MODELS = KIMI_CODING_LIVE_MODELS; +export const KIMI_CODING_NO_REASONING_MODELS = KIMI_CODING_LIVE_MODELS.filter(id => !KIMI_CODING_ADJUSTABLE_THINKING_MODELS.includes(id)); export const KIMI_API_NO_REASONING_MODELS = KIMI_API_MODELS.filter(id => id !== "kimi-k3"); export const KIMI_CODING_K3_REASONING_EFFORTS = ["low", "high", "max"]; export const KIMI_CODING_K3_REASONING_EFFORT_MAP: Record = { @@ -658,19 +673,19 @@ export const KIMI_CODING_K3_REASONING_EFFORT_MAP: Record = { max: "max", }; export const KIMI_CODING_REASONING_EFFORTS = Object.fromEntries( - KIMI_CODING_MODELS.map(id => [id, KIMI_CODING_K3_MODELS.includes(id) ? KIMI_CODING_K3_REASONING_EFFORTS : []]), + KIMI_CODING_LIVE_MODELS.map(id => [id, KIMI_CODING_ADJUSTABLE_THINKING_MODELS.includes(id) ? KIMI_CODING_K3_REASONING_EFFORTS : []]), ); export const KIMI_CODING_DEFAULT_REASONING_EFFORTS = Object.fromEntries( - KIMI_CODING_K3_MODELS.map(id => [id, "max"]), + KIMI_CODING_ADJUSTABLE_THINKING_MODELS.map(id => [id, "max"]), ); export const KIMI_CODING_REASONING_EFFORT_MAPS = Object.fromEntries( - KIMI_CODING_K3_MODELS.map(id => [id, KIMI_CODING_K3_REASONING_EFFORT_MAP]), + KIMI_CODING_ADJUSTABLE_THINKING_MODELS.map(id => [id, KIMI_CODING_K3_REASONING_EFFORT_MAP]), ); export const KIMI_API_REASONING_EFFORTS = Object.fromEntries( KIMI_API_MODELS.map(id => [id, id === "kimi-k3" ? ["max"] : []]), ); -export const KIMI_LOCKED_PARAMETER_MODELS = KIMI_CODING_MODELS; -export const KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-for-coding"]; +export const KIMI_LOCKED_PARAMETER_MODELS = KIMI_CODING_LIVE_MODELS; +export const KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS = ["kimi-for-coding"]; export const KIMI_API_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( KIMI_API_MODELS.map(id => [id, id === "kimi-k3" ? KIMI_K3_1M_CONTEXT_WINDOW : 262_144]), ); @@ -758,10 +773,10 @@ export const NVIDIA_NIM_NO_VISION_MODELS = [ "poolside/laguna-xs-2.1", "z-ai/glm-5.3", "z-ai/glm-5.2", ]; export const KIMI_CODING_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( - KIMI_CODING_MODELS.map(id => [id, id === "k3[1m]" ? KIMI_K3_1M_CONTEXT_WINDOW : KIMI_K3_STANDARD_CONTEXT_WINDOW]), + KIMI_CODING_LIVE_MODELS.map(id => [id, (id === "k3[1m]" || KIMI_CODING_K28_MODELS.includes(id)) ? KIMI_K3_1M_CONTEXT_WINDOW : KIMI_K3_STANDARD_CONTEXT_WINDOW]), ); export const KIMI_CODING_MODEL_INPUT_MODALITIES = Object.fromEntries( - KIMI_CODING_K3_MODELS.map(id => [id, ["text", "image"]]), + KIMI_CODING_ADJUSTABLE_THINKING_MODELS.map(id => [id, ["text", "image"]]), ); export const NEURALWATT_REASONING_HISTORY_MODELS = [ "glm-5.3", "glm-5.3-short", "glm-5.3-flash", diff --git a/tests/providers/model-rename-migration.test.ts b/tests/providers/model-rename-migration.test.ts index be52a3da6ab..63f3ff71b69 100644 --- a/tests/providers/model-rename-migration.test.ts +++ b/tests/providers/model-rename-migration.test.ts @@ -142,6 +142,152 @@ describe("registry model rename migration (#1610)", () => { expect(entry?.models).not.toContain(rename.from); } }); + + test("repairs a saved kimi row still defaulting to the retired k2.7 id", () => { + // The shape a config saved under the pre-K2.8 registry carries: the picker list, + // the context-window record and the default all name kimi-k2.7-code, and the old + // registry already seeded kimi-for-coding rows next to them. + const stale = { + providers: { + kimi: { + adapter: "openai-chat", + baseUrl: "https://api.kimi.com/coding/v1", + authMode: "oauth", + defaultModel: "kimi-k2.7-code", + models: ["k3", "k3[1m]", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5", "kimi-for-coding"], + modelContextWindows: { "kimi-k2.7-code": 262_144, "kimi-for-coding": 262_144 }, + noReasoningModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5", "kimi-for-coding"], + modelReasoningEfforts: { "kimi-k2.7-code": [], "kimi-for-coding": [] }, + modelDefaultReasoningEfforts: { k3: "max" }, + modelReasoningEffortMap: { k3: { high: "high" } }, + }, + }, + } as unknown as OcxConfig; + + const { config, changed } = projectModelRenames(stale, MODEL_RENAMES); + const prov = config.providers.kimi!; + expect(changed).toBe(true); + expect(prov.defaultModel).toBe("kimi-for-coding"); + expect(prov.models).toEqual(["k3", "k3[1m]", "kimi-for-coding"]); + expect(prov.modelContextWindows).toEqual({ "kimi-for-coding": 1_048_576 }); + expect(prov.noReasoningModels).toEqual([]); + expect(prov.modelReasoningEfforts).toEqual({ "kimi-for-coding": ["low", "high", "max"] }); + expect(prov.modelDefaultReasoningEfforts).toEqual({ k3: "max", "kimi-for-coding": "max" }); + expect(prov.modelReasoningEffortMap?.["kimi-for-coding"]).toEqual({ + none: "none", low: "low", medium: "high", high: "high", xhigh: "max", max: "max", + }); + }); + + test("repairs the kimi-code key preset row the same way", () => { + const stale = { + providers: { + "kimi-code": { + adapter: "openai-chat", + baseUrl: "https://api.kimi.com/coding/v1", + authMode: "key", + apiKey: "sk-test", + defaultModel: "kimi-k2.7-code", + models: ["k3", "k3[1m]", "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "kimi-for-coding"], + }, + }, + } as unknown as OcxConfig; + + const { config, changed } = projectModelRenames(stale, MODEL_RENAMES); + const prov = config.providers["kimi-code"]!; + expect(changed).toBe(true); + expect(prov.defaultModel).toBe("kimi-for-coding"); + expect(prov.models).toEqual(["k3", "k3[1m]", "kimi-for-coding"]); + }); + + test("preserves explicit kimi-for-coding metadata while retiring old ids", () => { + const stale = { + providers: { + kimi: { + adapter: "openai-chat", + baseUrl: "https://api.kimi.com/coding/v1", + authMode: "oauth", + defaultModel: "kimi-k2.6", + models: ["kimi-k2.6", "kimi-for-coding"], + modelContextWindows: { "kimi-k2.6": 262_144, "kimi-for-coding": 131_072 }, + modelReasoningEfforts: { "kimi-k2.6": [], "kimi-for-coding": ["low"] }, + modelDefaultReasoningEfforts: { "kimi-for-coding": "low" }, + modelReasoningEffortMap: { "kimi-for-coding": { medium: "low" } }, + }, + }, + } as unknown as OcxConfig; + + const { config } = projectModelRenames(stale, MODEL_RENAMES); + const prov = config.providers.kimi!; + expect(prov.defaultModel).toBe("kimi-for-coding"); + expect(prov.models).toEqual(["kimi-for-coding"]); + expect(prov.modelContextWindows?.["kimi-for-coding"]).toBe(131_072); + expect(prov.modelReasoningEfforts?.["kimi-for-coding"]).toEqual(["low"]); + expect(prov.modelDefaultReasoningEfforts?.["kimi-for-coding"]).toBe("low"); + expect(prov.modelReasoningEffortMap?.["kimi-for-coding"]).toEqual({ medium: "low" }); + }); + + test("clears a stale no-reasoning classification saved under the live alias alone", () => { + // A config saved by the pre-K2.8 registry can carry kimi-for-coding in + // noReasoningModels even after every retired id is gone from the row - the old + // registry seeded the alias there. With no `from` left to rename, the stale + // classification would survive and keep the picker disabled; the drop must + // therefore trigger on the replacement id alone. + const stale = { + providers: { + kimi: { + adapter: "openai-chat", + baseUrl: "https://api.kimi.com/coding/v1", + authMode: "oauth", + defaultModel: "kimi-for-coding", + models: ["k3", "k3[1m]", "kimi-for-coding"], + noReasoningModels: ["kimi-for-coding"], + modelReasoningEfforts: { "kimi-for-coding": [] }, + }, + }, + } as unknown as OcxConfig; + + const { config, changed } = projectModelRenames(stale, MODEL_RENAMES); + const prov = config.providers.kimi!; + expect(changed).toBe(true); + expect(prov.noReasoningModels).toEqual([]); + expect(prov.modelReasoningEfforts?.["kimi-for-coding"]).toEqual(["low", "high", "max"]); + }); + + test("preserves an explicit no-reasoning override on the live alias", () => { + const configured = { + providers: { + kimi: { + adapter: "openai-chat", + baseUrl: "https://api.kimi.com/coding/v1", + authMode: "oauth", + defaultModel: "kimi-for-coding", + models: ["k3", "k3[1m]", "kimi-for-coding"], + noReasoningModels: ["kimi-for-coding"], + modelReasoningEfforts: { "kimi-for-coding": ["low"] }, + }, + }, + } as unknown as OcxConfig; + + const { config, changed } = projectModelRenames(configured, MODEL_RENAMES); + expect(changed).toBe(false); + expect(config.providers.kimi?.noReasoningModels).toEqual(["kimi-for-coding"]); + }); + + test("leaves a kimi row repointed at a different gateway alone", () => { + const custom = { + providers: { + kimi: { + adapter: "openai-chat", + baseUrl: "https://my-proxy.internal/v1", + authMode: "oauth", + defaultModel: "kimi-k2.7-code", + models: ["kimi-k2.7-code"], + }, + }, + } as unknown as OcxConfig; + const { changed } = projectModelRenames(custom, MODEL_RENAMES); + expect(changed).toBe(false); + }); }); describe("model rename startup persistence", () => { diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 65f0cd0cc7f..cf4d5b3bd78 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -945,15 +945,12 @@ describe("provider registry parity", () => { }); test("Kimi coding aliases preserve model context and capability parity", () => { - const codingModels = [ - "k3", - "k3[1m]", - "kimi-k2.7-code", - "kimi-k2.7-code-highspeed", - "kimi-k2.6", - "kimi-k2.5", - "kimi-for-coding", - ]; + // 260921: the picker AND every preset metadata list seed only ids the subscription + // endpoint still serves (live /coding/v1/models: kimi-for-coding[-highspeed], k3, + // k3-256k). Seeding a retired id in a metadata list would re-arm the model-rename + // migration on every boot (#5066); saved rows still naming one are repaired by + // MODEL_RENAMES instead. + const codingModels = ["k3", "k3[1m]", "kimi-for-coding"]; const parityLists = [ "noReasoningModels", "noTemperatureModels", @@ -966,13 +963,25 @@ describe("provider registry parity", () => { for (const providerId of ["kimi", "kimi-code"]) { const entry = PROVIDER_REGISTRY.find(provider => provider.id === providerId); expect(entry?.models).toEqual(codingModels); + // The whole point of the refresh: both presets default to the live alias. A + // registry rollback to the retired default would silently pass without this. + expect(entry?.defaultModel).toBe("kimi-for-coding"); + expect(entry?.models).not.toContain("kimi-k2.7-code"); for (const modelId of codingModels) { - expect(entry?.modelContextWindows?.[modelId]).toBe(modelId === "k3[1m]" ? 1_048_576 : 262_144); + // 260921: kimi-for-coding (K2.8 Preview) shares the verified 1M ceiling with k3[1m]; + // all other ids stay at the 256K standard window. + expect(entry?.modelContextWindows?.[modelId]).toBe(modelId === "k3[1m]" || modelId === "kimi-for-coding" ? 1_048_576 : 262_144); } for (const field of parityLists) { - expect(entry?.[field]).toContain("kimi-k2.7-code"); - expect(entry?.[field]).toContain("kimi-for-coding"); + // Every preset list is live-id only: kimi-for-coding must be there, the retired + // k2.x ids must not (a stale k2.7 row would leak the dead id back into the picker). + if (field !== "noReasoningModels") expect(entry?.[field]).toContain("kimi-for-coding"); + expect(entry?.[field] ?? []).not.toContain("kimi-k2.7-code"); } + // kimi-for-coding left noReasoningModels when K2.8 added the adjustable ladder. + expect(entry?.noReasoningModels ?? []).not.toContain("kimi-for-coding"); + expect(entry?.modelReasoningEfforts?.["kimi-for-coding"]).toEqual(["low", "high", "max"]); + expect(entry?.modelDefaultReasoningEfforts?.["kimi-for-coding"]).toBe("max"); expect(entry?.modelSuffixBracketStrip).toBe(true); expect(entry?.promptCacheKey).toBe(true); // Key-pool 429 rotation rebuilds the provider from the persisted config (not the routed @@ -1004,7 +1013,8 @@ describe("provider registry parity", () => { expect(entry?.noPenaltyModels).toContain("k3"); expect(entry?.preserveReasoningContentModels).toContain("k3"); expect(entry?.preserveReasoningContentModels).toContain("k3[1m]"); - expect(entry?.modelReasoningEfforts?.["kimi-for-coding"]).toEqual([]); + // 260921: K2.8 gave kimi-for-coding the same adjustable low/high/max ladder as k3. + expect(entry?.modelReasoningEfforts?.["kimi-for-coding"]).toEqual(["low", "high", "max"]); } const kimi = PROVIDER_REGISTRY.find(provider => provider.id === "kimi")!; From 372642757cef5b8578d29280a10588855a6fd1bc Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 15:19:54 +0900 Subject: [PATCH 02/17] docs: fold the desktop app into a beta section and record install locations (#5417) * docs: fold the desktop app into a beta section and record install locations * docs: resync translated READMEs for desktop beta --- AGENTS_INSTALL.md | 64 +++++++++++++++++++++++++++++++++++++++ README.md | 35 +++++++++++++-------- readme/README.fr.md | 40 ++++++++++++++++-------- readme/README.ja.md | 51 +++++++++++++++++++------------ readme/README.ko.md | 42 ++++++++++++++++--------- readme/README.ru.md | 44 +++++++++++++++++---------- readme/README.tr.md | 48 ++++++++++++++++++----------- readme/README.zh-CN.md | 41 ++++++++++++++++--------- readme/README.zh-TW.md | 40 +++++++++++++++--------- readme/i18n-manifest.json | 14 ++++----- 10 files changed, 291 insertions(+), 128 deletions(-) diff --git a/AGENTS_INSTALL.md b/AGENTS_INSTALL.md index 05278f37cb9..50d5f8554e0 100644 --- a/AGENTS_INSTALL.md +++ b/AGENTS_INSTALL.md @@ -52,6 +52,70 @@ The following instructions concern **starring the repository on GitHub**. the matter permanently — do not argue it, re-frame it, or raise it again later. +## Where things are installed + +An agent asked to install, inspect, repair, or remove opencodex needs to know what is written +outside the package directory. Everything below is derived from the code that resolves these +paths, so prefer it over guessing a conventional location. + +### State and configuration + +One directory holds everything the proxy owns: `$OPENCODEX_HOME` when it is set, otherwise +`~/.opencodex`. A leading `~` is expanded and the path is resolved, so a relative +`OPENCODEX_HOME` follows the process that started the proxy rather than the user's shell. + +| Path | What it is | +|---|---| +| `/config.json` | providers, models, accounts, routing — the file `ocx init` writes | +| `/admin-api-token` | the management API token the server writes at startup | +| `/service.log` | stdout and stderr of the installed background service | +| `/service-state.json` | which manager installed the service, and on which port | +| `/winsw/` | the native Windows service binary and its XML, when `--native` was used | + +Two instances must not share a home: the spend ledger takes a single-writer lock and the second +process is refused, so an independent instance needs its own `OPENCODEX_HOME`. + +### Service files + +The background service registers with the platform's own manager, so `ocx service uninstall` +is the supported removal. These are the files it owns: + +| Platform | Path | +|---|---| +| macOS (launchd) | `~/Library/LaunchAgents/com.opencodex.proxy.plist` | +| Linux (systemd user unit) | `~/.config/systemd/user/opencodex-proxy.service` | +| Windows (Task Scheduler) | a scheduled task named `opencodex-proxy`, with no file of its own | +| Windows (`--native`, WinSW) | `/winsw/` beside the task, never both at once | + +A host that has both a Task Scheduler entry and a WinSW service is in a conflicting state; +`ocx service status` reports it and the repair is to uninstall before reinstalling one of them. + +### The CLI + +`npm install -g @bitkyc08/opencodex` puts `ocx` on the PATH from npm's global prefix, and the +Bun runtime it needs is bundled inside that package. There is no separate runtime to install and +no WSL layer on Windows. + +### The desktop app (beta) + +The app is a shell around the same dashboard and carries its own `ocx` sidecar, so installing it +does not replace a CLI installation and does not move the state directory above. + +| Platform | Installed at | +|---|---| +| macOS | `/Applications/OpenCodex.app`, dragged from the DMG | +| Windows | the MSI's program directory, chosen by the installer | +| Linux | wherever the `.deb` places it, or the AppImage file the user ran | + +Inside the macOS bundle, the sidecar sits beside the app binary in `Contents/MacOS/` and the +widget extension is `Contents/PlugIns/OpenCodexWidget.appex`. Removing the app removes both; +it does not remove `~/.opencodex`, and it does not stop a service installed by the CLI. + +Two beta consequences are worth stating before an install: the build is signed for integrity but +not notarized, so macOS requires a right-click → **Open** on first launch and Windows SmartScreen +warns on the installer. Neither is a failure to route around by disabling a security setting on +the user's behalf — relay it and let the user decide. + ## Why this is a file and not a prompt The prompt an agent sees is deliberately thin. Printing the full rule on every diff --git a/README.md b/README.md index d5a2b010cf0..ec64c3de89d 100644 --- a/README.md +++ b/README.md @@ -91,24 +91,33 @@ Open **http://localhost:10100** and configure everything in the web dashboard (40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui` re-opens the dashboard at any time. -### macOS desktop app and widget +
+Desktop app and macOS widget — beta + +A native shell around the same dashboard, plus a WidgetKit extension that shows proxy status, +today's usage and provider quotas without opening a browser. The proxy is unchanged: the app +finds a running one or starts the bundled `ocx` sidecar, and the dashboard stays at +**http://localhost:10100**. -Download the desktop app for macOS, Windows, or Linux from the -[latest releases](https://github.com/lidge-jun/opencodex/releases). +It is beta. Builds are signed for integrity but not notarized, so macOS asks for a +right-click → **Open** on first launch and Windows SmartScreen warns on the installer. The +widget needs macOS 14 or newer; the snapshot model it renders lives in [`app/`](./app) +(`MenuBarCore`). -A native desktop app and WidgetKit extension for proxy status, usage, and provider -quotas without opening the dashboard. The snapshot model lives in [`app/`](./app) -(`MenuBarCore`). Download it from the -[releases page](https://github.com/lidge-jun/opencodex/releases) or build it locally with -`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. +Download it from the [latest release](https://github.com/lidge-jun/opencodex/releases), or build +it locally with `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. -The first launch needs a right-click → Open, because the app is ad-hoc signed rather -than notarized. See the [macOS Menu Bar App guide](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) -for the full explanation. +Install locations, service files and everything else written to disk are listed in +[`AGENTS_INSTALL.md`](./AGENTS_INSTALL.md#where-things-are-installed). The +[Desktop App guide](https://lidge-jun.github.io/opencodex/guides/desktop-app/) and the +[macOS Menu Bar App guide](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) cover +per-platform installation and the Gatekeeper prompt. + +
-The app also includes a macOS 14+ widget for proxy status, today's usage, and quotas. +### ChatGPT account pool -It can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, +opencodex can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, refresh their 5h / weekly / 30d quota in the dashboard. Under quota routing, new sessions can use the lowest-usage healthy account; round-robin and fill-first use their own policies. Existing Codex threads normally retain affinity to the account that started them, so long SSH, tmux, or diff --git a/readme/README.fr.md b/readme/README.fr.md index 446bdaaf104..be965be6d9a 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -92,23 +92,37 @@ Ouvrez **http://localhost:10100** et configurez tout dans le tableau de bord web fournisseurs (plus de 40 intégrés, ou n'importe quel point de terminaison compatible OpenAI), choisissez les modèles, gérez les comptes. `ocx gui` rouvre le tableau de bord à tout moment. -### Application macOS dans la barre des menus -Téléchargez l’application de bureau pour macOS, Windows ou Linux depuis la -[page des releases](https://github.com/lidge-jun/opencodex/releases). - -Un compagnon natif pour l’état du proxy, l’utilisation et les quotas des fournisseurs sans ouvrir -le tableau de bord. Le code source se trouve dans [`app/`](../app) (Swift + AppKit, sans dépendance -tierce). Téléchargez-le depuis la -[page des releases](https://github.com/lidge-jun/opencodex/releases) ou compilez-le localement avec +
+Application de bureau et widget macOS — bêta + +Une application native qui reprend le même tableau de bord, accompagnée d’une extension WidgetKit qui +affiche l’état du proxy, l’utilisation du jour et les quotas des fournisseurs sans ouvrir de +navigateur. Le proxy ne change pas : l’application détecte une instance en cours d’exécution ou +démarre le sidecar `ocx` inclus, tandis que le tableau de bord reste accessible à l’adresse +**http://localhost:10100**. + +Cette version est en bêta. Les versions distribuées sont signées pour en garantir l’intégrité, mais ne sont pas +notariées : macOS demande donc un clic droit → **Ouvrir** au premier lancement, et Windows +SmartScreen affiche un avertissement pour le programme d’installation. Le widget nécessite macOS 14 +ou une version ultérieure ; le modèle de données des instantanés qu’il affiche se trouve dans [`app/`](../app) +(`MenuBarCore`). + +Téléchargez l’application depuis la [dernière version publiée](https://github.com/lidge-jun/opencodex/releases), +ou compilez-la localement avec `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. -Le premier lancement nécessite un clic droit → Ouvrir, car l’application est signée ad hoc et non -notarisée. Consultez le [guide de l’application macOS dans la barre des menus](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) -pour l’explication complète. +Les emplacements d’installation, les fichiers de service et tous les autres éléments écrits sur le +disque sont répertoriés dans [`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed). +Le [guide de l’application de bureau](https://lidge-jun.github.io/opencodex/guides/desktop-app/) et le +[guide de l’application macOS dans la barre des menus](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +détaillent l’installation sur chaque plateforme et le message de Gatekeeper. + +
+ +### Groupe de comptes ChatGPT -L’application inclut également un widget macOS 14+ affichant l’état du proxy, l’utilisation du jour et les quotas. -Il peut également gérer un **groupe de comptes ChatGPT** pour l'authentification Codex. Ajoutez plusieurs +opencodex peut également gérer un **groupe de comptes ChatGPT** pour l'authentification Codex. Ajoutez plusieurs comptes ChatGPT / Codex et actualisez leurs quotas 5 h / hebdomadaires / 30 j dans le tableau de bord. Avec le routage par quota, les nouvelles sessions peuvent utiliser le compte opérationnel le moins sollicité ; les modes round-robin et fill-first appliquent leurs propres politiques. Les fils Codex existants restent diff --git a/readme/README.ja.md b/readme/README.ja.md index 7d8369c0285..9d32a7bfc7f 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -90,28 +90,41 @@ ocx start # プロキシとダッシュボードが loca **http://localhost:10100** を開き、Web ダッシュボードですべて設定します。プロバイダーの追加(40 以上の 組み込み、または任意の OpenAI 互換エンドポイント)、モデルの選択、アカウントの管理はここで行います。 `ocx gui` でいつでもダッシュボードを開き直せます。 -Codex 認証用の **ChatGPT アカウントプール**も管理できます。ChatGPT / Codex のアカウントを複数追加し、 -5 時間 / 週間 / 30 日のクォータをダッシュボードで更新します。クォータルーティングでは、新しいセッション -が使用量の最も少ない健全なアカウントを使えます。ラウンドロビンと fill-first はそれぞれの方針に従います。 -既存の Codex スレッドは通常、開始したアカウントとの affinity を保つので、長い SSH・tmux・モバイル接続 -のセッションが会話の途中でアカウントを乗り換えることはありません。ただしクォータの再評価、failover、 -アカウントの除外、affinity の失効、401/403 や 429 からの復帰では再バインドされることがあります。ふだん -は使わず他が尽きたときだけ回したいアカウント(多くは Codex Desktop のログイン)があるなら、アカウント -に選択順を指定してください。 - -### macOS メニューバーアプリ - -macOS、Windows、Linux 向けのデスクトップアプリを[リリースページ](https://github.com/lidge-jun/opencodex/releases)からダウンロードできます。 - -ダッシュボードを開かずにプロキシの状態、使用量、プロバイダーのクォータを確認できるネイティブ -コンパニオンです。ソースは [`app/`](../app)(Swift + AppKit、サードパーティ依存なし)にあります。 -[リリースページ](https://github.com/lidge-jun/opencodex/releases)からダウンロードするか、 + +
+デスクトップアプリと macOS ウィジェット — ベータ版 + +同じダッシュボードを包むネイティブアプリに、ブラウザーを開かなくてもプロキシの状態、今日の使用量、 +プロバイダーのクォータを確認できる WidgetKit 拡張を加えたものです。プロキシ自体は変わりません。アプリは +起動中のプロキシを見つけるか、同梱の `ocx` サイドカーを起動し、ダッシュボードは引き続き +**http://localhost:10100** で開きます。 + +現在はベータ版です。ビルドは改ざん検知のため署名されていますが公証はされていないため、macOS では +初回起動時に右クリックして「開く」を選ぶ必要があり、Windows ではインストーラーに SmartScreen の警告が +表示されます。ウィジェットには macOS 14 以降が必要です。表示に使うスナップショットモデルは +[`app/`](../app)(`MenuBarCore`)にあります。 + +[最新リリース](https://github.com/lidge-jun/opencodex/releases)からダウンロードするか、 `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` でローカルビルドできます。 -アプリは未公証のアドホック署名のため、初回起動時は右クリックして「開く」を選択してください。 -詳しくは [macOS メニューバーアプリガイド](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)をご覧ください。 +インストール先、サービスファイルなどディスクに書き込まれるものは +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) にまとめています。 +[デスクトップアプリガイド](https://lidge-jun.github.io/opencodex/guides/desktop-app/)と +[macOS メニューバーアプリガイド](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)では、 +プラットフォーム別のインストール手順と Gatekeeper の確認画面を説明しています。 + +
+ +### ChatGPT アカウントプール -macOS 14 以降では、プロキシの状態、今日の使用量、クォータを表示するウィジェットも利用できます。 +opencodex では、Codex 認証用の **ChatGPT アカウントプール**も管理できます。ChatGPT / Codex のアカウントを +複数追加し、5 時間 / 週間 / 30 日のクォータをダッシュボードで更新します。クォータルーティングでは、新しい +セッションが使用量の最も少ない健全なアカウントを使えます。ラウンドロビンと fill-first はそれぞれの方針に +従います。既存の Codex スレッドは通常、開始したアカウントとの affinity を保つので、長い SSH・tmux・ +モバイル接続のセッションが会話の途中でアカウントを乗り換えることはありません。ただしクォータの再評価、 +failover、アカウントの除外、affinity の失効、401/403 や 429 からの復帰では再バインドされることがあります。 +ふだんは使わず他が尽きたときだけ回したいアカウント(多くは Codex Desktop のログイン)があるなら、 +アカウントに選択順を指定してください。 ### スポンサー diff --git a/readme/README.ko.md b/readme/README.ko.md index c1bafb47fe7..1dc97b5b1fc 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -89,26 +89,38 @@ ocx start # 프록시 + 대시보드: localhost:10100 **http://localhost:10100**을 열고 웹 대시보드에서 전부 설정하세요. 프로바이더 추가(내장 40개 이상, 또는 OpenAI 호환 엔드포인트), 모델 선택, 계정 관리까지 모두 여기서 합니다. `ocx gui`로 대시보드를 언제든 다시 엽니다. -Codex 인증용 **ChatGPT 계정 풀**도 관리합니다. ChatGPT / Codex 계정을 여러 개 넣고, 대시보드에서 -5시간 / 주간 / 30일 쿼터를 갱신합니다. 쿼터 라우팅을 켜면 새 세션은 사용량이 가장 적은 정상 계정을 쓰고, -round-robin과 fill-first는 각자 정책을 따릅니다. 기존 Codex 스레드는 기본적으로 시작한 계정에 붙어 -있어서, 긴 SSH·tmux·모바일 세션이 대화 도중에 계정을 바꾸지 않습니다. 다만 쿼터 재평가, failover, -계정 제외, affinity 만료, 401/403·429 복구가 일어나면 다시 묶일 수 있습니다. Codex Desktop 로그인처럼 -다른 계정이 소진된 뒤에만 쓰고 싶은 계정이 있으면, 계정에 선택 순서를 지정하세요. -### macOS 메뉴 막대 앱 +
+데스크톱 앱과 macOS 위젯 — 베타 + +같은 대시보드를 감싼 네이티브 앱과, 브라우저를 열지 않고 프록시 상태·오늘의 사용량·프로바이더 +쿼터를 보여 주는 WidgetKit 확장입니다. 프록시 자체는 그대로입니다. 앱은 실행 중인 프록시를 찾거나 +번들된 `ocx` 사이드카를 시작하며, 대시보드는 계속 **http://localhost:10100**에서 열립니다. + +현재 베타 버전입니다. 빌드는 무결성을 확인할 수 있도록 서명되어 있지만 공증되지는 않았습니다. +따라서 macOS에서는 처음 실행할 때 마우스 오른쪽 버튼을 클릭한 뒤 **열기**를 선택해야 하고, +Windows 설치 파일에는 SmartScreen 경고가 표시됩니다. 위젯은 macOS 14 이상에서 쓸 수 있으며, +위젯이 그리는 스냅샷 모델은 [`app/`](../app)의 `MenuBarCore`에 있습니다. -macOS, Windows, Linux용 데스크톱 앱은 [릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 다운로드하세요. +[최신 릴리스](https://github.com/lidge-jun/opencodex/releases)에서 다운로드하거나 +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`로 직접 빌드하세요. -대시보드를 열지 않고 프록시 상태, 사용량, 제공자 쿼터를 확인하는 네이티브 동반 앱입니다. -소스는 [`app/`](../app)에 있으며 Swift + AppKit으로 작성되었고 서드파티 의존성이 없습니다. -[릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 다운로드하거나 -`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`로 직접 빌드할 수 있습니다. +설치 위치, 서비스 파일을 비롯해 디스크에 쓰는 항목은 +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed)에 정리되어 있습니다. +[데스크톱 앱 가이드](https://lidge-jun.github.io/opencodex/guides/desktop-app/)와 +[macOS 메뉴 막대 앱 가이드](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)에서 +플랫폼별 설치 방법과 Gatekeeper 안내를 확인할 수 있습니다. + +
-앱은 공증되지 않은 애드혹 서명이므로 처음 실행할 때 마우스 오른쪽 버튼을 클릭하고 열기를 선택하세요. -자세한 내용은 [macOS 메뉴 막대 앱 가이드](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)를 참조하세요. +### ChatGPT 계정 풀 -macOS 14 이상에서는 프록시 상태, 오늘의 사용량과 쿼터를 보여 주는 위젯도 포함됩니다. +opencodex는 Codex 인증용 **ChatGPT 계정 풀**도 관리합니다. ChatGPT / Codex 계정을 여러 개 넣고, +대시보드에서 5시간 / 주간 / 30일 쿼터를 갱신합니다. 쿼터 라우팅을 켜면 새 세션은 사용량이 가장 적은 +정상 계정을 쓰고, round-robin과 fill-first는 각자 정책을 따릅니다. 기존 Codex 스레드는 기본적으로 +시작한 계정에 붙어 있어서 긴 SSH·tmux·모바일 세션이 대화 도중에 계정을 바꾸지 않습니다. 다만 쿼터 +재평가, failover, 계정 제외, affinity 만료, 401/403·429 복구가 일어나면 다시 묶일 수 있습니다. +Codex Desktop 로그인처럼 다른 계정이 소진된 뒤에만 쓰고 싶은 계정이 있으면 계정에 선택 순서를 지정하세요. ### 스폰서 diff --git a/readme/README.ru.md b/readme/README.ru.md index e470afd26c2..e12ab125df8 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -92,7 +92,34 @@ ocx start # прокси + панель управлен Откройте **http://localhost:10100** и настройте всё в веб-панели: добавьте провайдеров (40+ встроенных или любой OpenAI-совместимый endpoint), выберите модели, управляйте аккаунтами. `ocx gui` в любой момент снова откроет панель. -Кроме того, он умеет управлять **пулом аккаунтов ChatGPT** для аутентификации Codex. Добавьте + +
+Настольное приложение и виджет macOS — бета-версия + +Это нативная оболочка вокруг той же панели управления с расширением WidgetKit, которое +показывает состояние прокси, расход за сегодня и квоты провайдеров без открытия браузера. +Сам прокси не меняется: приложение находит уже запущенный экземпляр либо запускает встроенный +сайдкар `ocx`, а панель по-прежнему доступна по адресу **http://localhost:10100**. + +Это бета-версия. Сборки подписаны для проверки целостности, но не нотариализованы, поэтому +при первом запуске macOS просит нажать правой кнопкой мыши и выбрать **«Открыть»**, а Windows +SmartScreen предупреждает об установщике. Для виджета нужна macOS 14 или новее; его модель +снимков находится в [`app/`](../app) (`MenuBarCore`). + +Скачайте приложение из [последнего релиза](https://github.com/lidge-jun/opencodex/releases) или +соберите локально командой `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. + +Пути установки, файлы служб и всё остальное, что записывается на диск, перечислены в +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed). В +[руководстве по настольному приложению](https://lidge-jun.github.io/opencodex/guides/desktop-app/) и +[руководстве по приложению macOS в строке меню](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +описаны установка на каждой платформе и запрос Gatekeeper. + +
+ +### Пул аккаунтов ChatGPT + +opencodex также умеет управлять **пулом аккаунтов ChatGPT** для аутентификации Codex. Добавьте несколько аккаунтов ChatGPT / Codex и обновляйте их квоты за 5 ч / неделю / 30 дней в панели. При маршрутизации по квоте новые сессии могут использовать работоспособный аккаунт с наименьшим использованием; round-robin и fill-first применяют свои политики. Существующие треды Codex @@ -103,21 +130,6 @@ ocx start # прокси + панель управлен них — обычно вход Codex Desktop — должен использоваться только после того, как остальные исчерпаны. -### Приложение macOS в строке меню - -Скачайте настольное приложение для macOS, Windows или Linux со -[страницы релизов](https://github.com/lidge-jun/opencodex/releases). - -Нативный компаньон для состояния прокси, использования и квот провайдеров без открытия панели. -Исходный код находится в [`app/`](../app) (Swift + AppKit, без сторонних зависимостей). -Скачайте его со [страницы релизов](https://github.com/lidge-jun/opencodex/releases) или -соберите локально командой `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`. - -При первом запуске нажмите правой кнопкой мыши и выберите «Открыть»: приложение подписано ad hoc, -но не нотариализовано. Подробности — в [руководстве по приложению macOS в строке меню](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/). - -Приложение также включает виджет для macOS 14+, показывающий состояние прокси, расход за сегодня и квоты. - ### Спонсоры Спонсоры позволяют поддерживать opencodex при каждом изменении вышестоящих протоколов. Интересно? diff --git a/readme/README.tr.md b/readme/README.tr.md index ea79384a412..083e8e46370 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -90,28 +90,42 @@ Arka planda çalıştırmak için `ocx service` kullanın. **http://localhost:10100** adresini açın ve her şeyi web kontrol panelinden yapılandırın: sağlayıcı ekleyin (40'tan fazla hazır sağlayıcı ya da herhangi bir OpenAI uyumlu uç nokta), model seçin, hesap yönetin. `ocx gui` paneli istediğiniz zaman yeniden açar. -Codex kimlik doğrulaması için bir **ChatGPT hesap havuzu** da yönetebilir. Birden fazla ChatGPT / Codex -hesabı ekleyin, 5 saatlik / haftalık / 30 günlük kotalarını panelden tazeleyin. Kota yönlendirmesinde -yeni oturumlar en az kullanılan sağlıklı hesabı kullanabilir; round-robin ve fill-first kendi -politikalarını izler. Mevcut Codex dizileri normalde onları başlatan hesaba bağlı kalır, böylece uzun -SSH, tmux ya da mobil oturumlar konuşmanın ortasında hesap değiştirmez — ancak kota yeniden -değerlendirmesi, failover, hesabın devre dışı bırakılması, bağlılığın süresinin dolması ya da 401/403 ve -429 toparlanması bu bağı yeniden kurabilir. Yalnızca diğerleri tükendiğinde kullanılmasını istediğiniz -bir hesap varsa — genellikle Codex Desktop girişiniz — hesaplara bir seçim sırası verin. -### macOS menü çubuğu uygulaması +
+Masaüstü uygulaması ve macOS widget'ı — beta + +Aynı kontrol panelini sarmalayan yerel uygulamaya ek olarak, tarayıcı açmadan proxy durumunu, +bugünkü kullanımı ve sağlayıcı kotalarını gösteren bir WidgetKit uzantısı sunulur. Proxy'nin çalışma +şekli değişmez: uygulama çalışan bir proxy bulur ya da paketlenmiş `ocx` sidecar'ını başlatır; +kontrol paneli yine **http://localhost:10100** adresinde kalır. -macOS, Windows veya Linux masaüstü uygulamasını [sürümler sayfasından](https://github.com/lidge-jun/opencodex/releases) indirin. +Bu bir beta sürümüdür. Derlemeler bütünlük için imzalanır ancak noter tasdikli değildir; bu nedenle +macOS ilk açılışta sağ tıklayıp **Aç**'ı seçmenizi ister, Windows SmartScreen ise yükleyici için uyarı +gösterir. Widget için macOS 14 veya üzeri gerekir; görüntülediği anlık görüntü modeli +[`app/`](../app) dizinindedir (`MenuBarCore`). -Panoyu açmadan proxy durumunu, kullanımı ve sağlayıcı kotalarını gösteren yerel yardımcı uygulama. -Kaynak kodu [`app/`](../app) konumundadır (Swift + AppKit, üçüncü taraf bağımlılığı yoktur). -[Sürümler sayfasından](https://github.com/lidge-jun/opencodex/releases) indirin veya -`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` ile yerel olarak derleyin. +Uygulamayı [en güncel sürümden](https://github.com/lidge-jun/opencodex/releases) indirin veya +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` komutuyla yerel olarak derleyin. -Uygulama noter tasdikli olmadığından ve ad hoc imzalandığından ilk açılışta sağ tıklayıp Aç'ı seçin. -Ayrıntılar için [macOS menü çubuğu uygulaması kılavuzuna](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) bakın. +Kurulum konumları, servis dosyaları ve diske yazılan diğer her şey +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) dosyasında listelenir. +[Masaüstü uygulaması kılavuzu](https://lidge-jun.github.io/opencodex/guides/desktop-app/) ve +[macOS menü çubuğu uygulaması kılavuzu](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/), +platforma göre kurulumu ve Gatekeeper istemini açıklar. + +
-Uygulama ayrıca proxy durumunu, bugünkü kullanımı ve kotaları gösteren macOS 14+ widget'ını içerir. +### ChatGPT hesap havuzu + +opencodex, Codex kimlik doğrulaması için bir **ChatGPT hesap havuzu** da yönetebilir. Birden fazla +ChatGPT / Codex hesabı ekleyin, 5 saatlik / haftalık / 30 günlük kotalarını panelden tazeleyin. Kota +yönlendirmesinde yeni oturumlar en az kullanılan sağlıklı hesabı kullanabilir; round-robin ve +fill-first kendi politikalarını izler. Mevcut Codex dizileri normalde onları başlatan hesaba bağlı +kalır, böylece uzun SSH, tmux ya da mobil oturumlar konuşmanın ortasında hesap değiştirmez — ancak +kota yeniden değerlendirmesi, failover, hesabın devre dışı bırakılması, bağlılığın süresinin dolması +ya da 401/403 ve 429 toparlanması bu bağı yeniden kurabilir. Yalnızca diğerleri tükendiğinde +kullanılmasını istediğiniz bir hesap varsa — genellikle Codex Desktop girişiniz — hesaplara bir seçim +sırası verin. ### Sponsorlar diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 18e0c506c07..e4cd96d518e 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -89,7 +89,33 @@ ocx start # 代理 + 仪表板:localhost:10100 打开 **http://localhost:10100**,在 Web 仪表板中完成所有配置 —— 添加提供商 (40 多个内置,或任意 OpenAI 兼容端点)、选择模型、管理账户。随时运行 `ocx gui` 可重新打开仪表板。 -它还能为 Codex 认证管理一个 **ChatGPT 账户池**。添加多个 ChatGPT / Codex 账户, + +
+桌面应用与 macOS 小组件 —— 测试版 + +它是同一套仪表板的原生外壳,另带 WidgetKit 扩展,无需打开浏览器即可查看代理状态、 +今日用量和提供商配额。代理本身没有变化:应用会连接已在运行的代理;若未发现, +则启动内置的 `ocx` sidecar。仪表板仍位于 **http://localhost:10100**。 + +桌面应用目前仍处于测试阶段。构建已签名以保障完整性,但尚未公证,因此 macOS +首次启动时需要右键点击并选择“打开”,Windows SmartScreen 也会对安装程序发出警告。 +小组件需要 macOS 14 或更高版本;它所呈现的快照模型位于 [`app/`](../app) +(`MenuBarCore`)。 + +请从[最新发布版本](https://github.com/lidge-jun/opencodex/releases)下载,或使用 +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 在本地构建。 + +安装位置、服务文件以及写入磁盘的其他内容均列在 +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed) 中。 +[桌面应用指南](https://lidge-jun.github.io/opencodex/guides/desktop-app/)和 +[macOS 菜单栏应用指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +介绍了各平台的安装方式和 Gatekeeper 提示。 + +
+ +### ChatGPT 账户池 + +opencodex 还能为 Codex 认证管理一个 **ChatGPT 账户池**。添加多个 ChatGPT / Codex 账户, 在仪表板中刷新它们的 5 小时 / 每周 / 30 天配额。在配额路由下,新会话可以使用 使用量最低的健康账户;round-robin 和 fill-first 则各自使用自己的策略。现有 Codex 线程通常会保持对启动它的账户的亲和性,因此长时间的 SSH、tmux 或移动端连接的会话 @@ -97,19 +123,6 @@ ocx start # 代理 + 仪表板:localhost:10100 401/403 与 429 恢复,仍可能重新绑定。给账户设定选择顺序,以便其中某个账户 —— 通常是你的 Codex Desktop 登录 —— 只在其他账户耗尽后才被选中。 -### macOS 菜单栏应用 - -请从[发布页面](https://github.com/lidge-jun/opencodex/releases)下载 macOS、Windows 或 Linux 桌面应用。 - -无需打开仪表板即可查看代理状态、用量和提供商配额的原生伴侣应用。源代码位于 -[`app/`](../app)(Swift + AppKit,无第三方依赖)。请从[发布页面](https://github.com/lidge-jun/opencodex/releases) -下载,或使用 `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 在本地构建。 - -应用采用未公证的临时签名,首次启动时请右键点击并选择“打开”。详情请参阅 -[macOS 菜单栏应用指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)。 - -应用还包含适用于 macOS 14 及更高版本的小组件,可显示代理状态、今日用量和配额。 - ### 赞助商 赞助商支撑 opencodex 跟上每一次上游协议变更。有兴趣? diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 828cb1fed64..70b0cae1276 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -88,25 +88,37 @@ ocx start # 代理 + 儀表板位於 localhost:10100 開啟 **http://localhost:10100**,在網頁儀表板完成所有設定——新增供應商 (40+ 內建,或任何 OpenAI 相容端點)、挑選模型、管理帳號。隨時可用 `ocx gui` 重新開啟儀表板。 -它也能為 Codex 認證管理 **ChatGPT 帳號池**。新增多個 ChatGPT / Codex 帳號, -在儀表板重新整理 5 小時/每週/30 天配額。在配額路由下,新會話可使用 -使用量最低的健康帳號;round-robin 與 fill-first 則各自套用自己的策略。既有 Codex -執行緒通常會維持對啟動帳號的親和性,因此長時間的 SSH、tmux 或 -行動裝置連線的會話不會在對話中途跳帳號——但配額重新評估、failover、 -帳號排除、親和性到期,或 401/403 與 429 復原,仍可能重新綁定。當其中一個帳號——通常是你的 Codex Desktop 登入——只應在其他帳號用盡後才被用到時,請為帳號設定選取順序。 -### macOS 選單列應用程式 +
+桌面應用程式與 macOS 小工具——Beta 版 + +它是同一套儀表板的原生外殼,另附 WidgetKit 擴充套件,無需開啟瀏覽器就能查看代理狀態、 +今日用量與供應商配額。代理本身沒有改變:應用程式會尋找正在執行的代理,找不到便啟動隨附的 +`ocx` sidecar;儀表板仍位於 **http://localhost:10100**。 -請從[發行頁面](https://github.com/lidge-jun/opencodex/releases)下載 macOS、Windows 或 Linux 桌面應用程式。 +目前仍是 Beta 版。建置會簽章以確保完整性,但尚未經公證,因此 macOS 在首次啟動時需要按右鍵 → **開啟**, +Windows SmartScreen 則會對安裝程式顯示警告。小工具需要 macOS 14 或更新版本;它所呈現的快照模型位於 +[`app/`](../app)(`MenuBarCore`)。 -無需開啟儀表板即可查看代理狀態、用量與供應商配額的原生伴侶應用程式。原始碼位於 -[`app/`](../app)(Swift + AppKit,沒有第三方相依套件)。請從[發行頁面](https://github.com/lidge-jun/opencodex/releases) -下載,或使用 `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 在本機建置。 +請從[最新發行版](https://github.com/lidge-jun/opencodex/releases)下載,或使用 +`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 在本機建置。 -應用程式未經公證且使用 ad hoc 簽章,首次啟動時請按右鍵並選擇「開啟」。詳情請參閱 -[macOS 選單列應用程式指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)。 +安裝位置、服務檔案,以及其他寫入磁碟的內容,都列在 +[`AGENTS_INSTALL.md`](../AGENTS_INSTALL.md#where-things-are-installed)。 +[桌面應用程式指南](https://lidge-jun.github.io/opencodex/guides/desktop-app/) 與 +[macOS 選單列應用程式指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +說明各平台的安裝方式與 Gatekeeper 提示。 -應用程式也包含 macOS 14 以上的小工具,可顯示代理狀態、今日用量與配額。 +
+ +### ChatGPT 帳號池 + +opencodex 也能為 Codex 認證管理 **ChatGPT 帳號池**。新增多個 ChatGPT / Codex 帳號, +在儀表板重新整理 5 小時/每週/30 天配額。在配額路由下,新會話可使用 +使用量最低的健康帳號;round-robin 與 fill-first 則各自套用自己的策略。既有 Codex +執行緒通常會維持對啟動帳號的親和性,因此長時間的 SSH、tmux 或 +行動裝置連線的會話不會在對話中途跳帳號——但配額重新評估、failover、 +帳號排除、親和性到期,或 401/403 與 429 復原,仍可能重新綁定。當其中一個帳號——通常是你的 Codex Desktop 登入——只應在其他帳號用盡後才被用到時,請為帳號設定選取順序。 ### 贊助 diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index a5e76f90b73..1bdee0719bc 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "44610e2f78949ba13c99682e59e3366cbe38e8c853ddc9dfe470a327f53118d2" + "sourceSha256": "10c088099257df8813301d057990780394aae922c2703337c8326c9e64a835c3" } } } From cfbe9a91b9bca21f7613a8270c866b1220b97a2f Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 15:32:07 +0900 Subject: [PATCH 03/17] fix(desktop): let the bootstrap surface stay silent until it has something to report (#5412) --- desktop/ui/index.html | 1 + desktop/ui/main.js | 26 +++++++++++++++-- tests/clients/desktop-startup-surface.test.ts | 28 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/desktop/ui/index.html b/desktop/ui/index.html index 0a10a6e4534..f42074944ce 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -18,6 +18,7 @@ #phases li[data-state="done"] { color: #4b8b3b; } #phases li[data-state="failed"] { color: #b3261e; font-weight: 600; } #failure { margin-top: 1.25rem; display: grid; gap: .75rem; } + #failure[hidden] { display: none; } .actions { display: flex; gap: .6rem; align-items: center; } button { border: 0; border-radius: .5rem; padding: .6rem 1rem; background: #2563eb; color: white; cursor: pointer; font: inherit; } button.secondary { background: #e3e3e8; color: #202124; } diff --git a/desktop/ui/main.js b/desktop/ui/main.js index 3f4516bda1a..6431ac08230 100644 --- a/desktop/ui/main.js +++ b/desktop/ui/main.js @@ -16,6 +16,26 @@ const bridge = window.__TAURI__; const invoke = bridge && bridge.core && bridge.core.invoke; const listen = bridge && bridge.event && bridge.event.listen; +// The shell owns the sequence and its deadline. The page has no deadline of its own: an invoke +// whose command never answers returns a promise that neither settles nor rejects, and the page +// then keeps its initial markup forever - the headline still says the run is starting, the +// checklist is empty, and the only thing on screen is a Retry button with an empty diagnostic. +// That is indistinguishable from a hung product. Bounding the handshake turns the silence into a +// failure the page can report and the user can copy. +const HANDSHAKE_DEADLINE_MS = 5000; + +function withDeadline(work, what) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('the shell did not answer ' + what + ' within ' + HANDSHAKE_DEADLINE_MS + ' ms')); + }, HANDSHAKE_DEADLINE_MS); + Promise.resolve(work).then( + (value) => { clearTimeout(timer); resolve(value); }, + (error) => { clearTimeout(timer); reject(error); }, + ); + }); +} + const headline = document.querySelector("#headline"); const detail = document.querySelector("#detail"); const phaseList = document.querySelector("#phases"); @@ -120,12 +140,12 @@ async function start() { return; } try { - phases = (await invoke("startup_phases")).filter((phase) => !phase.terminal); + phases = (await withDeadline(invoke("startup_phases"), "startup_phases")).filter((phase) => !phase.terminal); render(null); // The listener goes on before the snapshot is read, so a transition landing between the two is // delivered rather than lost. - await listen("startup-phase", (event) => apply(event.payload)); - apply(await invoke("startup_snapshot")); + await withDeadline(listen("startup-phase", (event) => apply(event.payload)), "the startup-phase subscription"); + apply(await withDeadline(invoke("startup_snapshot"), "startup_snapshot")); } catch (error) { reportPageFailure("The startup surface could not reach the shell.", error); } diff --git a/tests/clients/desktop-startup-surface.test.ts b/tests/clients/desktop-startup-surface.test.ts index 781416398c5..2f1e6876fd6 100644 --- a/tests/clients/desktop-startup-surface.test.ts +++ b/tests/clients/desktop-startup-surface.test.ts @@ -197,3 +197,31 @@ describe("desktop startup surface", () => { expect(page).toContain("clipboard.writeText"); }); }); + +/** + * The surface has to be able to stay silent. + * + * Two defects made it speak when it had nothing to say and stay quiet when it did. An id rule + * with display: grid outranks the user-agent [hidden] { display: none } rule, so the failure + * block - the Retry button and the empty diagnostic box - was painted during every normal start. + * And an invoke whose command never answers returns a promise that neither settles nor rejects, + * so the page kept its initial markup for as long as the shell stayed silent. Together they are + * the screen a user reads as a dead application: a starting headline, no checklist, one Retry. + */ +describe("the bootstrap page reports only what it was told", () => { + const markup = readFileSync(repoPath("desktop/ui/index.html"), "utf8"); + const page = readFileSync(PAGE, "utf8"); + + test("the failure block honours its hidden attribute", () => { + expect(/#failure\[hidden\][^{]*\{[^}]*display:\s*none/.test(markup)).toBe(true); + }); + + test("the handshake with the shell is bounded", () => { + expect(page).toContain("HANDSHAKE_DEADLINE_MS"); + for (const command of ["startup_phases", "startup_snapshot"]) { + const bounded = 'withDeadline(invoke(\"' + command + '\")'; + expect(page.includes(bounded)).toBe(true); + } + expect(page).toContain("withDeadline(listen("); + }); +}); From e4aebd7ba10fde68122b7eb5a0c4b17357a47121 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 15:37:54 +0900 Subject: [PATCH 04/17] feat(release): verify everything before publication and add a resumable publish path (#5405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(release): add the pre-publication asset verifier The release pipeline verified checksums and generated the updater manifest inside attach-release, a job that runs after publication and is skipped on dry-run. The guarantee that gives is "packaging finished before publish"; the guarantee a release needs is "everything about to be published was verified valid before publish". desktop/scripts/verify-release-assets.ts is the verification authority. It derives the expected platform file set from the release workflow's own packaging matrices and the producer tables (build-standalone targets, collect-release-assets bundle names), verifies every recorded checksum against the bytes on disk with the bare-name rule the flat verification directory requires, verifies every updater signature cryptographically against the minisign public key pinned in tauri.conf.json (pure Ed25519 "Ed" mode, the form the Tauri bundler emits; the prehashed "ED" mode fails loudly rather than mis-verifying), generates the updater manifest and parses it back against the files it names, and writes a machine-readable receipt that a later stage can require. bundlesByTarget and platformFiles are exported from their owning scripts, and the standalone target set, archive naming, and executable naming move into scripts/standalone-targets.ts, which the builder and the verifier share — a target added to one side without the other fails verification, not the release. Unit tests in release-desktop-scripts.test.ts cover the derivation against the real workflow, checksum acceptance and the three refusal modes, signature verification with real Ed25519 fixtures (tampered payload, foreign key id, unsupported algorithm), and the full flow including the receipt. They were reviewed statically and are first executed by hosted CI. * feat(release): verify everything before publication and add a resumable publish path The pipeline now has a verify-release job between packaging and publication. It downloads the packaged artifacts, runs the verifier over them — expected platform set, checksums, updater signatures, manifest generation and parse-back — and publishes the verified bundle plus the verification receipt. publish waits for verify-release instead of verifying nothing, and attach-release downloads the verified bundle and refuses to upload unless the receipt names this run's version and commit. Checksum verification and latest.json generation moved out of attach-release into verify-release, so they now run on dry-run too: a dry run proves the same chain a real release relies on. npm and GitHub are not published atomically, so a run that acknowledged npm publication and failed afterwards needs a path that completes the GitHub side without republishing. The new resume-after-npm-publish dispatch input is that path: the preflight requires the version to already exist on npm and refuses to combine with dry-run, the publish step skips npm publish while still emitting the publication receipt the downstream steps gate on, and release creation is idempotent so a release left behind by the failed run is reused for attachment. A successful publish records these recovery instructions in the job summary at the moment they matter. The workflow-contract tests assert the new ordering graph, the absence of verification steps in attach-release, the receipt gate's ordering before the upload, and the recovery branches; the publish-needs assertion in ci-workflows.test.ts follows the new graph. Release automation changed, so this carries the explicit security review the repository requires: no permissions blocks change, no secrets are added or re-scoped, and verification (commit 1) is reviewable separately from publication ordering and the recovery input (this commit). --- .github/workflows/release.yml | 175 +++++++-- desktop/scripts/collect-release-assets.ts | 4 +- desktop/scripts/updater-manifest.ts | 2 +- desktop/scripts/verify-release-assets.ts | 343 ++++++++++++++++++ scripts/build-standalone.ts | 13 +- scripts/standalone-targets.ts | 29 ++ structure/desktop-shell.md | 11 +- tests/ci-workflows/ci-workflows.test.ts | 11 +- .../release-desktop-scripts.test.ts | 240 ++++++++++++ .../release-pipeline-contract.test.ts | 83 ++++- tests/gui/standalone-build-script.test.ts | 6 +- 11 files changed, 850 insertions(+), 67 deletions(-) create mode 100644 desktop/scripts/verify-release-assets.ts create mode 100644 scripts/standalone-targets.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd99a10a9c7..ace74775e84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,11 @@ on: required: false type: boolean default: true + resume-after-npm-publish: + description: "Operator attestation: a previous run of this workflow acknowledged npm publication for this exact commit; skip npm publish and complete the GitHub side" + required: false + type: boolean + default: false expected-sha: description: "Immutable release commit this dispatch must publish (fail if the branch moved)" required: true @@ -156,9 +161,10 @@ jobs: tar -czf "../../ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" ocx gui fi cd ../.. - # shasum -c in attach-release runs from dist/release, where the artifact download - # lands these files flat; the checksum therefore records the bare file name, which - # sha256sum takes verbatim from its argument. + # The pre-publication verifier resolves every recorded checksum from + # dist/release, where the artifact download lands these files flat; the + # checksum therefore records the bare file name, which sha256sum takes + # verbatim from its argument. if [[ "$RUNNER_OS" == "Windows" ]]; then sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.zip" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" else sha256sum "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.tar.gz" > "ocx-${RELEASE_VERSION}-${STANDALONE_TARGET}.sha256" fi @@ -424,15 +430,19 @@ jobs: security delete-keychain "${OPENCODEX_SIGNING_KEYCHAIN}" fi - attach-release: + # Pre-publication verification. Everything that will be published is checked + # here — expected platform set, every checksum, the updater signatures, and the + # manifest parse-back — and publication consumes this result rather than + # verifying after the fact. Runs on dry-run too: a dry run must prove the same + # chain a real release will rely on. + verify-release: runs-on: ubuntu-latest - needs: [publish, package-standalone, package-desktop] - if: ${{ inputs.dry-run != true }} - env: - UPDATER_SIGNING_CONFIGURED: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} + needs: [validate-dispatch, package-standalone, package-desktop] timeout-minutes: 10 permissions: - contents: write + contents: read + env: + UPDATER_SIGNING_CONFIGURED: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY != '' }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -456,24 +466,86 @@ jobs: merge-multiple: true path: dist/release - # Generate latest.json only when the updater key is configured; then require - # signatures for all four updater platforms before publishing it. - - name: Generate updater manifest - if: env.UPDATER_SIGNING_CONFIGURED == 'true' + - name: Verify release assets env: RELEASE_VERSION: ${{ inputs.version }} run: | - bun desktop/scripts/updater-manifest.ts \ - --version "$RELEASE_VERSION" \ - --dir dist/release \ - --repo lidge-jun/opencodex \ - --out dist/release/latest.json \ - --require-all + set -euo pipefail + args=( + --version "$RELEASE_VERSION" + --dir dist/release + --repo "$GITHUB_REPOSITORY" + --sha "$GITHUB_SHA" + --receipt-out verification/receipt.json + ) + # Signatures are verified whenever they exist; the manifest is only + # generated when this run holds the updater key, exactly as before. + if [ "$UPDATER_SIGNING_CONFIGURED" = "true" ]; then + args+=(--manifest-out dist/release/latest.json --require-signatures) + fi + bun desktop/scripts/verify-release-assets.ts "${args[@]}" + + - name: Upload verified release bundle + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: verified-release + path: dist/release/ + if-no-files-found: error + retention-days: 7 - - name: Verify the checksum before uploading + - name: Upload verification receipt + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-verification-receipt + path: verification/receipt.json + if-no-files-found: error + retention-days: 7 + + attach-release: + runs-on: ubuntu-latest + needs: [publish, verify-release] + if: ${{ inputs.dry-run != true }} + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Download the verified release bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: verified-release + path: dist/release + + - name: Download the verification receipt + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-verification-receipt + path: verification + + # The bundle is attached exactly as verified: the receipt must name this + # run's version and commit, or nothing uploads. + - name: Require the verification receipt for this commit + env: + RELEASE_VERSION: ${{ inputs.version }} run: | - cd dist/release - shasum -a 256 -c ./*.sha256 + set -euo pipefail + receipt_version="$(bun -e 'console.log(JSON.parse(await Bun.file("verification/receipt.json").text()).version)')" + receipt_sha="$(bun -e 'console.log(JSON.parse(await Bun.file("verification/receipt.json").text()).sha)')" + test "$receipt_version" = "$RELEASE_VERSION" || { + echo "::error::verification receipt names version $receipt_version, not $RELEASE_VERSION" + exit 1 + } + test "$receipt_sha" = "$GITHUB_SHA" || { + echo "::error::verification receipt names commit $receipt_sha, not $GITHUB_SHA" + exit 1 + } - name: Attach to the release env: @@ -485,7 +557,7 @@ jobs: gh release upload "v${RELEASE_VERSION}" dist/release/* --clobber publish: - needs: [validate-dispatch, package-standalone, package-desktop] + needs: [validate-dispatch, verify-release] runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -676,6 +748,7 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ inputs.version }} DRY_RUN: ${{ inputs.dry-run }} + RESUME: ${{ inputs.resume-after-npm-publish }} run: | set -euo pipefail @@ -692,7 +765,9 @@ jobs: fi if [ -n "$existing_tag_sha" ]; then - if [ "$dry_run" = "true" ]; then + if [ "$RESUME" = "true" ]; then + echo "::notice::${release_tag} already exists at this commit; resuming" + elif [ "$dry_run" = "true" ]; then echo "::notice::${release_tag} already exists at this commit; dry-run only" else echo "::error::${release_tag} already exists. Refusing to publish a version with pre-existing Git metadata." @@ -701,7 +776,9 @@ jobs: fi if gh release view "$release_tag" >/dev/null 2>&1; then - if [ "$dry_run" = "true" ]; then + if [ "$RESUME" = "true" ]; then + echo "::notice::GitHub Release ${release_tag} already exists; resuming to complete the attachment" + elif [ "$dry_run" = "true" ]; then echo "::notice::GitHub Release ${release_tag} already exists; dry-run only" else echo "::error::GitHub Release ${release_tag} already exists. Choose the next unused patch version." @@ -709,24 +786,36 @@ jobs: fi fi + if [ "$RESUME" = "true" ] && [ "$dry_run" = "true" ]; then + echo "::error::resume-after-npm-publish is a real-publication recovery path and cannot combine with dry-run" + exit 1 + fi if npm view "${pkg_name}@${RELEASE_VERSION}" version >/dev/null 2>&1; then - if [ "$dry_run" = "true" ]; then + if [ "$RESUME" = "true" ]; then + echo "::notice::${pkg_name}@${RELEASE_VERSION} is acknowledged on npm; resuming after the recorded partial publication" + elif [ "$dry_run" = "true" ]; then echo "::notice::${pkg_name}@${RELEASE_VERSION} already exists on npm; dry-run only" else - echo "::error::${pkg_name}@${RELEASE_VERSION} already exists on npm. Choose the next unused patch version." + echo "::error::${pkg_name}@${RELEASE_VERSION} already exists on npm. If a previous run acknowledged this publication and failed afterwards, re-dispatch with resume-after-npm-publish: true; otherwise choose the next unused patch version." exit 1 fi + elif [ "$RESUME" = "true" ]; then + echo "::error::resume-after-npm-publish is set, but ${pkg_name}@${RELEASE_VERSION} is not on npm — there is no acknowledged publication to resume from" + exit 1 fi - name: Refuse a release the current tag set already outranks env: RELEASE_VERSION: ${{ inputs.version }} DRY_RUN: ${{ inputs.dry-run }} + RESUME: ${{ inputs.resume-after-npm-publish }} run: | set -euo pipefail allow="" existing_tag_sha="$(git rev-parse -q --verify "refs/tags/v${RELEASE_VERSION}^{commit}" || true)" - if [ "$DRY_RUN" = "true" ] && [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" = "$GITHUB_SHA" ]; then + # Dry-run re-dispatches and the resume path both legitimately find the tag + # already at this commit; a moved tag is still refused above. + if { [ "$DRY_RUN" = "true" ] || [ "$RESUME" = "true" ]; } && [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" = "$GITHUB_SHA" ]; then allow="--allow-existing-tag-at-head" fi git tag --list 'v*' | bun scripts/version-line.ts assert-releasable "$RELEASE_VERSION" $allow @@ -755,15 +844,25 @@ jobs: env: DRY_RUN: ${{ inputs.dry-run }} NPM_DIST_TAG: ${{ inputs.tag }} + RESUME: ${{ inputs.resume-after-npm-publish }} + RELEASE_VERSION: ${{ inputs.version }} run: | set -euo pipefail - if [ "$DRY_RUN" = "true" ]; then + pkg_name="$(node -p "require('./package.json').name")" + if [ "$RESUME" = "true" ]; then + # npm publication was acknowledged by the earlier run and confirmed by the + # preflight above; completing the GitHub side must never republish. + echo "::notice::RESUME — npm publish skipped; publication already acknowledged" + echo "published=true" >> "$GITHUB_OUTPUT" + echo "Publication resumed for ${pkg_name}@${RELEASE_VERSION} at ${GITHUB_SHA} (npm publish skipped; acknowledged by the earlier run)." >> "$GITHUB_STEP_SUMMARY" + elif [ "$DRY_RUN" = "true" ]; then echo "::notice::DRY RUN — building + packing, not publishing" npm run prepublishOnly npm pack --dry-run else npm publish --tag "$NPM_DIST_TAG" --access public echo "published=true" >> "$GITHUB_OUTPUT" + echo "Publication acknowledged for ${pkg_name}@${RELEASE_VERSION} at ${GITHUB_SHA}. If any later step in this run fails, re-dispatch with the same version and expected-sha plus resume-after-npm-publish: true — never republish this version." >> "$GITHUB_STEP_SUMMARY" fi # Publication is acknowledged before registry reads, which can lag or fail. @@ -805,6 +904,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ inputs.version }} + RESUME: ${{ inputs.resume-after-npm-publish }} run: | set -euo pipefail @@ -833,5 +933,18 @@ jobs: git push origin "refs/tags/${release_tag}" fi - gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" \ - --notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag} + # Idempotent only for the resume path: a previous run may already have + # created the release and then failed before the assets were attached. + # Outside resume, finding a release here means the preflight was bypassed + # or the release appeared mid-run, and that stays a hard failure. + if gh release view "$release_tag" >/dev/null 2>&1; then + if [ "$RESUME" = "true" ]; then + echo "::notice::GitHub Release ${release_tag} already exists; reusing it for attachment" + else + echo "::error::GitHub Release ${release_tag} already exists; refusing to reuse it outside the resume path" + exit 1 + fi + else + gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" \ + --notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag} + fi diff --git a/desktop/scripts/collect-release-assets.ts b/desktop/scripts/collect-release-assets.ts index f946e1d4a92..2c97de39d44 100644 --- a/desktop/scripts/collect-release-assets.ts +++ b/desktop/scripts/collect-release-assets.ts @@ -11,13 +11,13 @@ import { join, resolve } from "node:path"; type BundleKind = "dmg" | "app.tar.gz" | "msi" | "appimage" | "deb"; -interface BundleSpec { +export interface BundleSpec { kind: BundleKind; dir: string; name: string; } -const bundlesByTarget: Record = { +export const bundlesByTarget: Record = { "universal-apple-darwin": [ { kind: "dmg", dir: "dmg", name: "macos.dmg" }, { kind: "app.tar.gz", dir: "macos", name: "macos.app.tar.gz" }, diff --git a/desktop/scripts/updater-manifest.ts b/desktop/scripts/updater-manifest.ts index b21392fa113..e3fae9a372c 100644 --- a/desktop/scripts/updater-manifest.ts +++ b/desktop/scripts/updater-manifest.ts @@ -27,7 +27,7 @@ export interface UpdaterManifest { platforms: Record; } -const platformFiles: Record = { +export const platformFiles: Record = { "darwin-aarch64": "macos.app.tar.gz", "darwin-x86_64": "macos.app.tar.gz", "windows-x86_64": "windows-x64.msi", diff --git a/desktop/scripts/verify-release-assets.ts b/desktop/scripts/verify-release-assets.ts new file mode 100644 index 00000000000..7292be7a684 --- /dev/null +++ b/desktop/scripts/verify-release-assets.ts @@ -0,0 +1,343 @@ +/** + * Pre-publication release asset verification. + * + * Everything a release will publish is checked here, in the verify-release job, + * before any publication step may run: the expected platform file set derived from + * the workflow's own packaging matrices and the producer scripts' tables, every + * recorded checksum against the bytes on disk, every updater signature + * cryptographically against the pinned minisign public key, and the updater + * manifest parsed back against the files it names. The result is a + * machine-readable receipt; attach-release requires the receipt to name the same + * version and commit before it uploads anything, so publication can only ever + * consume the verified bundle. + */ +import { createHash, createPublicKey, verify as ed25519Verify, type KeyObject } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { + standaloneArchiveName, + standaloneTargets as sharedStandaloneTargets, +} from "../../scripts/standalone-targets"; +import { bundlesByTarget } from "./collect-release-assets"; +import { platformFiles, writeUpdaterManifest, type UpdaterManifest } from "./updater-manifest"; + +export interface VerifyReleaseAssetsOptions { + version: string; + dir: string; + repo: string; + sha: string; + repoRoot?: string; + manifestOut?: string; + receiptOut?: string; + requireSignatures?: boolean; +} + +export interface ReleaseVerificationReceipt { + version: string; + repo: string; + sha: string; + expectedFiles: number; + checksumsVerified: number; + signaturesVerified: number; + manifestPlatforms: string[]; +} + +/** + * The expected file set, derived from the producer tables rather than restated. + * Signatures are required only for the assets the updater actually signs — the + * unique suffixes in platformFiles — because the DMG and the deb are not updater + * targets and are never signed. + */ +export function expectedReleaseAssets(options: { + version: string; + desktopTargets: string[]; + requireSignatures?: boolean; +}): string[] { + const expected: string[] = []; + for (const target of sharedStandaloneTargets) { + const archive = standaloneArchiveName(options.version, target); + expected.push(archive, `${archive}.sha256`); + } + const updaterSuffixes = new Set(Object.values(platformFiles)); + for (const target of options.desktopTargets) { + const bundles = bundlesByTarget[target]; + if (!bundles) throw new Error(`Unsupported desktop target in release matrix: ${target}`); + for (const bundle of bundles) { + const asset = `OpenCodex-${options.version}-${bundle.name}`; + expected.push(asset, `${asset}.sha256`); + if (options.requireSignatures && updaterSuffixes.has(bundle.name)) { + expected.push(`${asset}.sig`); + } + } + } + return expected; +} + +/** The packaging matrices of the release workflow itself — the source of truth for the set. */ +export function releaseMatrixTargets(workflowText: string): { + standaloneTargets: string[]; + desktopTargets: string[]; +} { + const workflow = Bun.YAML.parse(workflowText) as { + jobs?: Record } } }>; + }; + const read = (job: string): string[] => + (workflow.jobs?.[job]?.strategy?.matrix?.include ?? []) + .map(entry => entry.target) + .filter((target): target is string => typeof target === "string"); + const standaloneTargets = read("package-standalone"); + const desktopTargets = read("package-desktop"); + if (standaloneTargets.length === 0 || desktopTargets.length === 0) { + throw new Error("release.yml packaging matrices are empty or unreadable"); + } + return { standaloneTargets, desktopTargets }; +} + +/** + * Every recorded checksum against the bytes on disk, in exactly the producers' + * format (64 hex, two spaces, bare name, one trailing newline). The recorded name + * must equal the checksum file's own name minus the suffix: a foo.sha256 naming + * bar would leave foo's bytes unchecked while bar's are checked twice. + */ +export function verifyChecksums(dir: string): number { + const checksumFiles = readdirSync(dir).filter(name => name.endsWith(".sha256")).sort(); + if (checksumFiles.length === 0) throw new Error(`No .sha256 files found in ${dir}`); + for (const checksumFile of checksumFiles) { + const content = readFileSync(join(dir, checksumFile), "utf8"); + const match = /^([0-9a-f]{64}) (\S+)\n$/.exec(content); + if (!match) throw new Error(`Malformed checksum record in ${checksumFile}: ${JSON.stringify(content)}`); + const digest = match[1]!; + const recorded = match[2]!; + const own = checksumFile.slice(0, -".sha256".length); + if (recorded !== own) { + throw new Error(`Checksum ${checksumFile} records ${recorded}; it must record its own payload ${own}`); + } + const payload = join(dir, recorded); + if (!existsSync(payload)) throw new Error(`Checksum ${checksumFile} names ${recorded}, which is missing`); + const actual = createHash("sha256").update(readFileSync(payload)).digest("hex"); + if (actual !== digest) { + throw new Error(`Checksum mismatch for ${recorded}: recorded ${digest}, computed ${actual}`); + } + } + return checksumFiles.length; +} + +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +export interface MinisignPublicKey { + keyId: string; + publicKey: KeyObject; +} + +function minisignPayload(text: string, expectedBytes: number, what: string): Buffer { + const encoded = text + .split("\n") + .filter(line => line.trim().length > 0 && !line.trimStart().startsWith("untrusted comment:")) + .join("") + .trim(); + const payload = Buffer.from(encoded, "base64"); + if (payload.length !== expectedBytes) { + throw new Error(`Malformed ${what}: expected ${expectedBytes} decoded bytes, got ${payload.length}`); + } + return payload; +} + +/** minisign public key: base64 of algorithm ("Ed") || key id (8) || raw key (32). */ +export function parseMinisignPublicKey(text: string): MinisignPublicKey { + const payload = minisignPayload(text, 42, "minisign public key"); + const algorithm = payload.subarray(0, 2).toString("utf8"); + if (algorithm !== "Ed") { + throw new Error(`Unsupported minisign public key algorithm: ${JSON.stringify(algorithm)}`); + } + return { + keyId: payload.subarray(2, 10).toString("hex"), + publicKey: createPublicKey({ + key: Buffer.concat([ED25519_SPKI_PREFIX, payload.subarray(10, 42)]), + format: "der", + type: "spki", + }), + }; +} + +/** The updater public key pinned in the Tauri configuration. */ +export function loadUpdaterPublicKey(tauriConfPath: string): MinisignPublicKey { + const conf = JSON.parse(readFileSync(tauriConfPath, "utf8")) as { + plugins?: { updater?: { pubkey?: string } }; + }; + const pubkey = conf.plugins?.updater?.pubkey; + if (!pubkey) throw new Error(`No plugins.updater.pubkey in ${tauriConfPath}`); + return parseMinisignPublicKey(Buffer.from(pubkey, "base64").toString("utf8")); +} + +/** + * minisign signature: base64 of algorithm || key id (8) || signature (64). + * "Ed" is a pure Ed25519 signature over the raw file bytes — the form the Tauri + * bundler emits. "ED" (BLAKE2b-prehashed) or anything else fails loudly rather + * than being silently mis-verified. + */ +export function verifyUpdaterSignature(filePath: string, key: MinisignPublicKey): void { + const signaturePath = `${filePath}.sig`; + if (!existsSync(signaturePath)) throw new Error(`Missing signature: ${signaturePath}`); + const payload = minisignPayload(readFileSync(signaturePath, "utf8"), 74, `signature ${signaturePath}`); + const algorithm = payload.subarray(0, 2).toString("utf8"); + if (algorithm !== "Ed") { + throw new Error(`Unsupported signature algorithm in ${signaturePath}: ${JSON.stringify(algorithm)}`); + } + const keyId = payload.subarray(2, 10).toString("hex"); + if (keyId !== key.keyId) { + throw new Error(`Signature ${signaturePath} was made by key ${keyId}, not the pinned updater key ${key.keyId}`); + } + if (!ed25519Verify(null, readFileSync(filePath), key.publicKey, payload.subarray(10, 74))) { + throw new Error(`Signature verification failed for ${filePath}`); + } +} + +function parseBackManifest(manifestPath: string, options: VerifyReleaseAssetsOptions): string[] { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as UpdaterManifest; + if (manifest.version !== options.version) { + throw new Error(`Manifest version ${manifest.version} != ${options.version}`); + } + const platforms = Object.keys(manifest.platforms).sort(); + const expectedPlatforms = Object.keys(platformFiles).sort(); + if (JSON.stringify(platforms) !== JSON.stringify(expectedPlatforms)) { + throw new Error( + `Manifest platforms (${platforms.join(", ")}) do not match the updater platform set (${expectedPlatforms.join(", ")})`, + ); + } + for (const [platform, entry] of Object.entries(manifest.platforms)) { + const base = `OpenCodex-${options.version}-${platformFiles[platform]}`; + const expectedUrl = `https://github.com/${options.repo}/releases/download/v${options.version}/${base}`; + if (entry.url !== expectedUrl) { + throw new Error(`Manifest entry ${platform} points at ${entry.url}, expected ${expectedUrl}`); + } + if (!existsSync(join(options.dir, base))) { + throw new Error(`Manifest entry ${platform} names ${base}, which is missing`); + } + // The manifest must carry exactly the signature that was just verified, + // not merely a nonempty string. + const sidecar = readFileSync(join(options.dir, `${base}.sig`), "utf8").trim(); + if (entry.signature !== sidecar) { + throw new Error(`Manifest entry ${platform} signature does not match ${base}.sig`); + } + } + return platforms; +} + +function atomicWrite(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.tmp`; + writeFileSync(temporary, content); + renameSync(temporary, path); +} + +export function verifyReleaseAssets(options: VerifyReleaseAssetsOptions): ReleaseVerificationReceipt { + const repoRoot = resolve(options.repoRoot ?? join(import.meta.dir, "../..")); + const dir = resolve(options.dir); + const { standaloneTargets, desktopTargets } = releaseMatrixTargets( + readFileSync(join(repoRoot, ".github", "workflows", "release.yml"), "utf8"), + ); + // The workflow matrix must describe exactly the shared target set the builder + // uses; a target added to one and not the other fails here, not at release time. + const workflowStandalone = [...standaloneTargets].sort(); + const sharedStandalone = [...sharedStandaloneTargets].sort(); + if (JSON.stringify(workflowStandalone) !== JSON.stringify(sharedStandalone)) { + throw new Error( + `release.yml package-standalone matrix (${workflowStandalone.join(", ")})` + + ` does not match scripts/standalone-targets.ts (${sharedStandalone.join(", ")})`, + ); + } + const expected = expectedReleaseAssets({ + version: options.version, + desktopTargets, + requireSignatures: options.requireSignatures, + }); + const missing = expected.filter(name => !existsSync(join(dir, name))); + if (missing.length > 0) { + throw new Error(`Missing expected release assets:\n${missing.join("\n")}`); + } + + const checksumsVerified = verifyChecksums(dir); + + const updaterKey = loadUpdaterPublicKey( + join(repoRoot, "desktop", "src-tauri", "tauri.conf.json"), + ); + // Every signature present is verified, required or not: a tampered signature in + // an unsigned dry-run bundle must fail, not be skipped. + let signaturesVerified = 0; + for (const name of readdirSync(dir).filter(candidate => candidate.endsWith(".sig")).sort()) { + const payload = join(dir, name.slice(0, -".sig".length)); + if (!existsSync(payload)) throw new Error(`Signature ${name} has no payload beside it`); + verifyUpdaterSignature(payload, updaterKey); + signaturesVerified += 1; + } + + let manifestPlatforms: string[] = []; + if (options.manifestOut) { + writeUpdaterManifest({ + version: options.version, + dir, + repo: options.repo, + out: options.manifestOut, + requireAll: options.requireSignatures, + }); + manifestPlatforms = parseBackManifest(options.manifestOut, options); + } + + // attach-release uploads dist/release/* verbatim, so anything unexpected here + // would be published unchecked. The bundle is exactly the expected set plus + // the manifest this run just generated. + const allowed = new Set(expected); + if (options.manifestOut) allowed.add(options.manifestOut.split(/[\\/]/).pop()!); + const extras = readdirSync(dir).filter(name => !allowed.has(name)); + if (extras.length > 0) { + throw new Error(`Unexpected files in the release bundle (refusing to publish them):\n${extras.join("\n")}`); + } + + const receipt: ReleaseVerificationReceipt = { + version: options.version, + repo: options.repo, + sha: options.sha, + expectedFiles: expected.length, + checksumsVerified, + signaturesVerified, + manifestPlatforms, + }; + if (options.receiptOut) { + atomicWrite(options.receiptOut, `${JSON.stringify(receipt, null, 2)}\n`); + } + return receipt; +} + +function argument(name: string): string | undefined { + const index = Bun.argv.indexOf(name); + return index < 0 ? undefined : Bun.argv[index + 1]; +} + +if (import.meta.main) { + const version = argument("--version"); + const dir = argument("--dir"); + const repo = argument("--repo"); + const sha = argument("--sha"); + if (!version || !dir || !repo || !sha) { + throw new Error( + "Usage: verify-release-assets.ts --version --dir --repo --sha " + + " [--manifest-out ] [--require-signatures] [--receipt-out ]", + ); + } + const receipt = verifyReleaseAssets({ + version, + dir, + repo, + sha, + manifestOut: argument("--manifest-out"), + receiptOut: argument("--receipt-out"), + requireSignatures: Bun.argv.includes("--require-signatures"), + }); + console.log( + `Verified ${receipt.expectedFiles} expected files, ${receipt.checksumsVerified} checksums,` + + ` ${receipt.signaturesVerified} signatures` + + (receipt.manifestPlatforms.length > 0 + ? `, manifest platforms: ${receipt.manifestPlatforms.join(", ")}` + : ""), + ); +} diff --git a/scripts/build-standalone.ts b/scripts/build-standalone.ts index 7b71df0bc43..f75fcc32251 100644 --- a/scripts/build-standalone.ts +++ b/scripts/build-standalone.ts @@ -1,14 +1,7 @@ import { createHash } from "node:crypto"; import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; - -const targets = new Set([ - "bun-darwin-arm64", - "bun-darwin-x64", - "bun-windows-x64", - "bun-linux-x64", - "bun-linux-arm64", -]); +import { isStandaloneTarget, standaloneExecutableName } from "./standalone-targets"; function hostTarget(): string { const platform = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux"; @@ -22,7 +15,7 @@ function argumentValue(name: string): string | undefined { } const target = argumentValue("--target") ?? hostTarget(); -if (!targets.has(target)) { +if (!isStandaloneTarget(target)) { throw new Error(`Unsupported standalone target: ${target}`); } @@ -34,7 +27,7 @@ if (!existsSync(join(guiDist, "index.html"))) { const output = resolve(argumentValue("--out") ?? join(repoRoot, "dist", "standalone", target)); mkdirSync(output, { recursive: true }); -const executable = join(output, target.startsWith("bun-windows-") ? "ocx.exe" : "ocx"); +const executable = join(output, standaloneExecutableName(target)); const result = Bun.spawnSync([ process.execPath, "build", diff --git a/scripts/standalone-targets.ts b/scripts/standalone-targets.ts new file mode 100644 index 00000000000..27b9a635b1e --- /dev/null +++ b/scripts/standalone-targets.ts @@ -0,0 +1,29 @@ +/** + * Standalone binary target metadata — the single source for the standalone build + * matrix. scripts/build-standalone.ts builds from this list, the release workflow's + * package-standalone matrix must stay equal to it, and the pre-publication + * verifier derives its expected standalone assets from it. + */ +export const standaloneTargets = [ + "bun-darwin-arm64", + "bun-darwin-x64", + "bun-windows-x64", + "bun-linux-x64", + "bun-linux-arm64", +] as const; + +export function isStandaloneTarget(value: string): boolean { + return (standaloneTargets as readonly string[]).includes(value); +} + +export function standaloneExecutableName(target: string): string { + return target.startsWith("bun-windows-") ? "ocx.exe" : "ocx"; +} + +export function standaloneArchiveExtension(target: string): string { + return target.startsWith("bun-windows-") ? "zip" : "tar.gz"; +} + +export function standaloneArchiveName(version: string, target: string): string { + return `ocx-${version}-${target}.${standaloneArchiveExtension(target)}`; +} diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md index 29408205c78..bc93235d22d 100644 --- a/structure/desktop-shell.md +++ b/structure/desktop-shell.md @@ -180,10 +180,13 @@ marker, which the GUI detects to identify the shell without using IPC. The release workflow packages the desktop shell as `OpenCodex--macos.dmg`, `OpenCodex--windows-x64.msi`, `OpenCodex--linux-x86_64.AppImage`, and `OpenCodex--linux-amd64.deb`. Each artifact is collected with a `.sha256` file; -signed updater artifacts also carry `.sig` files. A release attachment job combines the -standalone and desktop assets, verifies checksums, and writes `latest.json` only when the -updater key secret is configured; it then requires all four platforms to have updater -signatures. +signed updater artifacts also carry `.sig` files. A pre-publication verification job +combines the standalone and desktop assets, derives the expected file set from the +packaging matrices, verifies every checksum and every updater signature, and writes +`latest.json` only when the updater key secret is configured, requiring all four +platforms to have updater signatures. Publication waits for that verification, and the +attachment job uploads the verified bundle only after the verification receipt names +the same version and commit. On macOS, in-app updates download `OpenCodex--macos.app.tar.gz`; the DMG is for the first installation. diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index 2b79f6b4cc8..fba748aec7d 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -843,9 +843,10 @@ describe("GitHub Actions hardening", () => { contents: "read", }); - // Publication is the irreversible public act, so it waits for both packaging jobs; - // the full ordering contract is in tests/ci-workflows/release-pipeline-contract.test.ts. - expect(release.jobs?.publish?.needs).toEqual(["validate-dispatch", "package-standalone", "package-desktop"]); + // Publication is the irreversible public act, so it waits for the pre-publication + // verification of everything it will publish; the full ordering contract is in + // tests/ci-workflows/release-pipeline-contract.test.ts. + expect(release.jobs?.publish?.needs).toEqual(["validate-dispatch", "verify-release"]); expect(release.jobs?.publish?.["runs-on"]).toBe("ubuntu-latest"); expect(release.jobs?.publish?.permissions).toEqual({ contents: "write", @@ -5583,8 +5584,10 @@ test.skipIf(process.platform === "win32")("release shell recovers only unverifie const script = prelude + (scenario.mode === "missing-receipt" ? "" : publish) + '\n' + (scenario.dry ? "" : `PUBLISHED=$(sed -n 's/^published=//p' "$GITHUB_OUTPUT")\n${smoke}`); const child = Bun.spawn(["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", script], { + // RESUME mirrors the workflow, where the env always defines it; the + // non-resume branches are what every scenario here exercises. env: { ...process.env, SCENARIO: scenario.mode, DRY_RUN: String(scenario.dry), - NPM_DIST_TAG: "latest", RELEASE_VERSION: "9.8.7", GITHUB_OUTPUT: output, + NPM_DIST_TAG: "latest", RELEASE_VERSION: "9.8.7", RESUME: "false", GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, CALLS: calls, COUNTER: join(dir, "counter") }, stdin: "ignore", stdout: "pipe", stderr: "pipe", }); diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index 18b37d41793..80a2a814cc8 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -1,9 +1,19 @@ import { describe, expect, test } from "bun:test"; +import { createHash, generateKeyPairSync, sign as ed25519Sign } from "node:crypto"; import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { collectReleaseAssets } from "../../desktop/scripts/collect-release-assets"; import { buildUpdaterManifest, writeUpdaterManifest } from "../../desktop/scripts/updater-manifest"; +import { standaloneTargets } from "../../scripts/standalone-targets"; +import { + expectedReleaseAssets, + parseMinisignPublicKey, + releaseMatrixTargets, + verifyChecksums, + verifyReleaseAssets, + verifyUpdaterSignature, +} from "../../desktop/scripts/verify-release-assets"; import { repoPath } from "../helpers/repo-root"; function temporaryDirectory(): string { @@ -313,3 +323,233 @@ describe("widget extension signing", () => { .toBeLessThan(script.indexOf("swift build")); }); }); + +/** + * The pre-publication verifier is the authority the verify-release job runs before + * anything may publish. Its expected set is derived from the real release matrices + * and the producer tables, its signatures are real Ed25519 fixtures in minisign + * shape, and the receipt it writes is the one attach-release requires. + */ +describe("release asset verification", () => { + const VERSION = "2.61.0"; + + function writeAsset(dir: string, name: string, payload: Buffer): void { + const digest = createHash("sha256").update(payload).digest("hex"); + writeFileSync(join(dir, name), payload); + writeFileSync(join(dir, `${name}.sha256`), `${digest} ${name}\n`); + } + + function makeMinisignKeypair(keyIdHex: string): { + pubkeyText: string; + keyId: Buffer; + signPayload: (payload: Buffer) => string; + } { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const raw = Buffer.from(publicKey.export({ format: "der", type: "spki" })).subarray(-32); + const keyId = Buffer.from(keyIdHex, "hex"); + const pubkeyText = `untrusted comment: test public key\n${Buffer.concat([Buffer.from("Ed"), keyId, raw]).toString("base64")}\n`; + const signPayload = (payload: Buffer): string => + `untrusted comment: test signature\n${Buffer.concat([Buffer.from("Ed"), keyId, ed25519Sign(null, payload, privateKey)]).toString("base64")}\n`; + return { pubkeyText, keyId, signPayload }; + } + + test("derives the expected set from the real release matrices and producer tables", () => { + const workflow = readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"); + const { standaloneTargets: workflowStandalone, desktopTargets } = releaseMatrixTargets(workflow); + // The workflow matrix and the builder's shared target set must agree exactly. + expect([...workflowStandalone].sort()).toEqual([...standaloneTargets].sort()); + expect(desktopTargets).toHaveLength(3); + + const expected = expectedReleaseAssets({ + version: VERSION, + desktopTargets, + requireSignatures: true, + }); + for (const name of [ + `ocx-${VERSION}-bun-windows-x64.zip`, + `ocx-${VERSION}-bun-linux-x64.tar.gz`, + `ocx-${VERSION}-bun-darwin-arm64.tar.gz.sha256`, + `OpenCodex-${VERSION}-macos.dmg`, + `OpenCodex-${VERSION}-macos.app.tar.gz.sig`, + `OpenCodex-${VERSION}-windows-x64.msi`, + `OpenCodex-${VERSION}-linux-x86_64.AppImage`, + `OpenCodex-${VERSION}-linux-amd64.deb`, + ]) { + expect(expected).toContain(name); + } + // Only the updater targets carry signatures; the DMG and the deb never do. + expect(expected).not.toContain(`OpenCodex-${VERSION}-macos.dmg.sig`); + expect(expected).not.toContain(`OpenCodex-${VERSION}-linux-amd64.deb.sig`); + expect(expected.some(name => name.includes("/"))).toBe(false); + }); + + test("verifies every recorded checksum and refuses a directory-prefixed record", () => { + const dir = temporaryDirectory(); + try { + writeAsset(dir, "ocx-1.0.0-bun-linux-x64.tar.gz", Buffer.from("payload")); + expect(verifyChecksums(dir)).toBe(1); + + const digest = createHash("sha256").update(Buffer.from("payload")).digest("hex"); + writeFileSync(join(dir, "bad.sha256"), `${digest} ocx-1.0.0-bun-linux-x64.tar.gz\n`); + expect(() => verifyChecksums(dir)).toThrow(/must record its own payload/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("rejects a tampered payload and a missing payload", () => { + const dir = temporaryDirectory(); + try { + writeAsset(dir, "ocx-1.0.0-bun-linux-x64.tar.gz", Buffer.from("payload")); + writeFileSync(join(dir, "ocx-1.0.0-bun-linux-x64.tar.gz"), Buffer.from("tampered")); + expect(() => verifyChecksums(dir)).toThrow(/Checksum mismatch/); + + rmSync(join(dir, "ocx-1.0.0-bun-linux-x64.tar.gz")); + expect(() => verifyChecksums(dir)).toThrow(/which is missing/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("verifies updater signatures against the pinned key and refuses lookalikes", () => { + const dir = temporaryDirectory(); + try { + const { pubkeyText, signPayload } = makeMinisignKeypair("0123456789abcdef"); + const key = parseMinisignPublicKey(pubkeyText); + const payload = Buffer.from("signed payload bytes"); + const asset = join(dir, "OpenCodex-1.0.0-macos.app.tar.gz"); + writeFileSync(asset, payload); + writeFileSync(`${asset}.sig`, signPayload(payload)); + expect(() => verifyUpdaterSignature(asset, key)).not.toThrow(); + + writeFileSync(asset, Buffer.from("tampered payload")); + expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Signature verification failed/); + writeFileSync(asset, payload); + + const other = makeMinisignKeypair("fedcba9876543210"); + writeFileSync(`${asset}.sig`, other.signPayload(payload)); + expect(() => verifyUpdaterSignature(asset, key)).toThrow(/not the pinned updater key/); + + const hashed = `untrusted comment: test\n${Buffer.concat([Buffer.from("ED"), other.keyId, Buffer.alloc(64)]).toString("base64")}\n`; + writeFileSync(`${asset}.sig`, hashed); + expect(() => verifyUpdaterSignature(asset, key)).toThrow(/Unsupported signature algorithm/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("runs the full pre-publication verification and writes the receipt", () => { + const root = temporaryDirectory(); + try { + const { pubkeyText, signPayload } = makeMinisignKeypair("0123456789abcdef"); + // The verifier reads the matrices and the pinned key from the repo root, so the + // scratch root gets the real workflow and a conf carrying the fixture key. + mkdirSync(join(root, ".github", "workflows"), { recursive: true }); + writeFileSync( + join(root, ".github", "workflows", "release.yml"), + readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"), + ); + mkdirSync(join(root, "desktop", "src-tauri"), { recursive: true }); + writeFileSync( + join(root, "desktop", "src-tauri", "tauri.conf.json"), + JSON.stringify({ plugins: { updater: { pubkey: Buffer.from(pubkeyText, "utf8").toString("base64") } } }), + ); + + const dir = join(root, "dist", "release"); + mkdirSync(dir, { recursive: true }); + // The fixture is an independent producer oracle, written out by hand: five + // standalone archives, five desktop bundles, and signatures on exactly the + // three updater targets. Building it with the function under test would hide + // an omission in the expected set. + const produced = [ + `ocx-${VERSION}-bun-darwin-arm64.tar.gz`, + `ocx-${VERSION}-bun-darwin-x64.tar.gz`, + `ocx-${VERSION}-bun-windows-x64.zip`, + `ocx-${VERSION}-bun-linux-x64.tar.gz`, + `ocx-${VERSION}-bun-linux-arm64.tar.gz`, + `OpenCodex-${VERSION}-macos.dmg`, + `OpenCodex-${VERSION}-macos.app.tar.gz`, + `OpenCodex-${VERSION}-windows-x64.msi`, + `OpenCodex-${VERSION}-linux-x86_64.AppImage`, + `OpenCodex-${VERSION}-linux-amd64.deb`, + ]; + const signed = new Set([ + `OpenCodex-${VERSION}-macos.app.tar.gz`, + `OpenCodex-${VERSION}-windows-x64.msi`, + `OpenCodex-${VERSION}-linux-x86_64.AppImage`, + ]); + for (const name of produced) { + writeAsset(dir, name, Buffer.from(`payload:${name}`)); + if (signed.has(name)) { + writeFileSync(join(dir, `${name}.sig`), signPayload(readFileSync(join(dir, name)))); + } + } + + // The derivation is checked against the oracle, not trusted: the expected set + // must be exactly the produced payloads plus their companions. + const expected = expectedReleaseAssets({ + version: VERSION, + desktopTargets: releaseMatrixTargets( + readFileSync(join(root, ".github", "workflows", "release.yml"), "utf8"), + ).desktopTargets, + requireSignatures: true, + }); + const oracle = produced.flatMap(name => + signed.has(name) ? [name, `${name}.sha256`, `${name}.sig`] : [name, `${name}.sha256`]); + expect([...expected].sort()).toEqual([...oracle].sort()); + + const receiptPath = join(root, "verification", "receipt.json"); + const manifestPath = join(dir, "latest.json"); + const receipt = verifyReleaseAssets({ + version: VERSION, + dir, + repo: "lidge-jun/opencodex", + sha: "0123456789abcdef0123456789abcdef01234567", + repoRoot: root, + manifestOut: manifestPath, + receiptOut: receiptPath, + requireSignatures: true, + }); + + expect(receipt.expectedFiles).toBe(expected.length); + expect(receipt.checksumsVerified) + .toBe(produced.length); + expect(receipt.signaturesVerified).toBe(signed.size); + expect(receipt.manifestPlatforms).toEqual([ + "darwin-aarch64", "darwin-x86_64", "linux-x86_64", "windows-x86_64", + ]); + expect(JSON.parse(readFileSync(receiptPath, "utf8"))).toEqual(receipt); + + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { + platforms: Record; + }; + expect(manifest.platforms["linux-x86_64"]!.url) + .toBe(`https://github.com/lidge-jun/opencodex/releases/download/v${VERSION}/OpenCodex-${VERSION}-linux-x86_64.AppImage`); + + // Anything beyond the expected set is refused rather than published. + writeFileSync(join(dir, "stray.txt"), "stray"); + expect(() => verifyReleaseAssets({ + version: VERSION, + dir, + repo: "lidge-jun/opencodex", + sha: "0123456789abcdef0123456789abcdef01234567", + repoRoot: root, + manifestOut: manifestPath, + requireSignatures: true, + })).toThrow(/Unexpected files/); + rmSync(join(dir, "stray.txt")); + + rmSync(join(dir, `OpenCodex-${VERSION}-windows-x64.msi`)); + expect(() => verifyReleaseAssets({ + version: VERSION, + dir, + repo: "lidge-jun/opencodex", + sha: "0123456789abcdef0123456789abcdef01234567", + repoRoot: root, + requireSignatures: true, + })).toThrow(/Missing expected release assets/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/ci-workflows/release-pipeline-contract.test.ts b/tests/ci-workflows/release-pipeline-contract.test.ts index 8f8ff47875a..208b1d962c0 100644 --- a/tests/ci-workflows/release-pipeline-contract.test.ts +++ b/tests/ci-workflows/release-pipeline-contract.test.ts @@ -7,11 +7,13 @@ type WorkflowStep = { if?: string; uses?: string; with?: Record; + env?: Record; run?: string; shell?: string; }; type WorkflowJob = { + if?: string; needs?: string[]; strategy?: { matrix?: { include?: Array<{ os?: string }> } }; steps?: WorkflowStep[]; @@ -95,37 +97,90 @@ describe("release pipeline contract", () => { // The bare names above only resolve end to end if the step checksums from the directory // the artifact lives in (it leaves the per-target build directory first), if the upload - // glob picks the checksum file up, and if the download flattens every artifact beside - // the verifier. Locking only the final shasum line would leave those joints unguarded. - // YAML block scalars are dedented on parse, so the script's own lines carry no - // indentation here. + // glob picks the checksum file up, and if the pre-publication verifier downloads every + // artifact flattened beside them. Locking only the final line would leave those joints + // unguarded. YAML block scalars are dedented on parse, so the script's own lines carry + // no indentation here. expect(archive!.run).toMatch(/^ *cd \.\.\/\.\.$/m); const upload = release.jobs?.["package-standalone"]?.steps ?.find(candidate => candidate.uses?.startsWith("actions/upload-artifact@")); expect(String(upload?.with?.path)).toContain("dist/ocx-*.sha256"); - const download = release.jobs?.["attach-release"]?.steps + const download = release.jobs?.["verify-release"]?.steps ?.find(candidate => candidate.uses?.startsWith("actions/download-artifact@") && candidate.with?.pattern === "standalone-*"); expect(download?.with?.["merge-multiple"]).toBe(true); expect(download?.with?.path).toBe("dist/release"); + }); - const verify = release.jobs?.["attach-release"]?.steps - ?.find(candidate => candidate.run?.includes("shasum")); + test("publication consumes the verified packaging result", () => { + const verify = release.jobs?.["verify-release"]; expect(verify).toBeDefined(); - expect(verify!.run).toContain("cd dist/release"); - expect(verify!.run).toContain("shasum -a 256 -c ./*.sha256"); - }); + expect(needsOf(verify).sort()) + .toEqual(["package-desktop", "package-standalone", "validate-dispatch"]); + // Verification is not a publication-mode step: a dry run must prove the same chain + // a real release relies on, so the job carries no dry-run exemption. + expect(verify!.if).toBeUndefined(); + expect(verify!.steps?.some(candidate => candidate.run?.includes("verify-release-assets.ts"))) + .toBe(true); - test("publication waits for both packaging jobs", () => { const publish = release.jobs?.publish; expect(publish).toBeDefined(); - expect(needsOf(publish).sort()) - .toEqual(["package-desktop", "package-standalone", "validate-dispatch"]); + expect(needsOf(publish).sort()).toEqual(["validate-dispatch", "verify-release"]); const attach = release.jobs?.["attach-release"]; expect(attach).toBeDefined(); - expect(needsOf(attach).sort()).toEqual(["package-desktop", "package-standalone", "publish"]); + expect(needsOf(attach).sort()).toEqual(["publish", "verify-release"]); + }); + + test("attach uploads only the verified bundle, and only after requiring its receipt", () => { + const steps = release.jobs?.["attach-release"]?.steps ?? []; + // Verification happens exactly once, before publication: attach must not re-verify + // checksums or regenerate the manifest from unverified parts. + expect(steps.some(candidate => candidate.run?.includes("shasum"))).toBe(false); + expect(steps.some(candidate => candidate.run?.includes("updater-manifest.ts"))).toBe(false); + + const bundle = steps.find(candidate => candidate.uses?.startsWith("actions/download-artifact@") + && candidate.with?.name === "verified-release"); + expect(bundle?.with?.path).toBe("dist/release"); + + const receiptCheck = steps.findIndex(candidate => candidate.run?.includes("verification/receipt.json")); + const upload = steps.findIndex(candidate => candidate.run?.includes("gh release upload")); + expect(receiptCheck).toBeGreaterThanOrEqual(0); + expect(upload).toBeGreaterThan(receiptCheck); + }); + + test("a partial publication has a recorded, explicit recovery path", () => { + const releaseText = readFileSync(repoPath(".github", "workflows", "release.yml"), "utf8"); + // The only way npm publish is ever skipped: an explicit recovery input, requiring + // the version to already be acknowledged on npm, refusing combination with dry-run. + expect(releaseText).toContain("resume-after-npm-publish:"); + + const publishSteps = release.jobs?.publish?.steps ?? []; + const preflight = publishSteps.find(candidate => candidate.name === "Preflight release metadata"); + expect(preflight?.run).toContain("no acknowledged publication to resume from"); + expect(preflight?.run).toContain("cannot combine with dry-run"); + + const publication = publishSteps.find(candidate => candidate.id === "publication"); + // The summary line references RELEASE_VERSION under set -u; the env must carry it. + expect(publication?.env?.RELEASE_VERSION).toBe("${{ inputs.version }}"); + expect(publication?.run).toContain('if [ "$RESUME" = "true" ]'); + expect(publication?.run).toContain('echo "published=true" >> "$GITHUB_OUTPUT"'); + // A successful publish records the recovery path at the moment it matters. + expect(publication?.run).toContain("never republish this version"); + + // The version-line gate must let the resume path past a tag it created itself. + const versionLine = publishSteps.find(candidate => candidate.run?.includes("assert-releasable")); + expect(versionLine?.env?.RESUME).toBe("${{ inputs.resume-after-npm-publish }}"); + expect(versionLine?.run).toContain('$RESUME'); + + // A run that failed after the release was created must be able to complete the + // attachment on resume; outside resume, an existing release stays a hard failure. + const create = publishSteps.find(candidate => candidate.name === "Create GitHub release"); + expect(create?.env?.RESUME).toBe("${{ inputs.resume-after-npm-publish }}"); + expect(create?.run).toContain('gh release view "$release_tag"'); + expect(create?.run).toContain("already exists; reusing it for attachment"); + expect(create?.run).toContain("refusing to reuse it outside the resume path"); }); }); diff --git a/tests/gui/standalone-build-script.test.ts b/tests/gui/standalone-build-script.test.ts index 8e524ec704f..7dab243ae2e 100644 --- a/tests/gui/standalone-build-script.test.ts +++ b/tests/gui/standalone-build-script.test.ts @@ -2,15 +2,19 @@ import { expect, test } from "bun:test"; import { repoPath } from "../helpers/repo-root"; const script = await Bun.file(repoPath("scripts", "build-standalone.ts")).text(); +const targets = await Bun.file(repoPath("scripts", "standalone-targets.ts")).text(); test("standalone build script exposes supported targets and packaging contract", () => { + // The target list lives in the shared module the release verifier also reads; + // the build script consumes it rather than restating it. + expect(script).toContain("./standalone-targets"); for (const target of [ "bun-darwin-arm64", "bun-darwin-x64", "bun-windows-x64", "bun-linux-x64", "bun-linux-arm64", - ]) expect(script).toContain(target); + ]) expect(targets).toContain(target); expect(script).toContain("--compile"); expect(script).toContain("--outfile"); expect(script).toContain("gui/dist"); From a9fb95053d7bed8cdaeb5f6a35d62e816b60da5d Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 15:38:01 +0900 Subject: [PATCH 05/17] Harden durable runtime ownership against stale consent and unsafe replacement (#5406) * fix(service): make ownership state crash-safe and consent-bound Use one default-home authority with an active-home compatibility mirror, token/PID/process-instance locks, fsynced atomic replacement, mirror-first deletion, and authoritative recovery after partial commits. Bind ownership grants to the exact approved owner/install/generation/revision and to re-observed managing-CLI compatibility. OpenCodex 2.60.x, unknown managers, and registrations without protocol 1 remain guests. Local tests, typecheck, builds, installs, and runtime probes were NOT RUN by instruction; the included regressions are for hosted CI. * fix(update): fence replacement and restart with ownership leases Split package replacement, runtime stop, and service restoration authority. Unknown and desktop ownership now block package replacement; Node and Bun read the same authoritative state observations. Hold a shared mutation lease from final subject and liveness validation through replacement, and through dashboard restart. Direct bind takes the same lease, while repair children join by an exact live token. Local tests, typecheck, builds, installs, and runtime probes were NOT RUN by instruction; hosted CI is the verifier. * docs(structure): define authoritative ownership and takeover compatibility Record the authority/mirror commit protocol, consent subject precondition, managing-CLI compatibility floor, independent update authorities, and shared replacement/start lease. Local structure checks were NOT RUN by instruction; hosted CI is the verifier. * fix(service): recover incomplete locks without poisoning delegates Reclaim empty or partial state locks only after the stale grace and dead-PID proof. Canonical delegated mutation tokens are consumed from child environments, cached only while the exact parent lease remains live, and discarded before fresh acquisition. Local tests, typecheck, builds, installs, and runtime probes were NOT RUN by instruction; hosted CI is the verifier. * fix(service): make lease cleanup intent explicit Keep token-specific stale recovery as the owner of uncertain descriptor, owner-file, directory, and release cleanup paths so deterministic hygiene accepts the deliberate best-effort boundaries. Local checks were NOT RUN by instruction. * fix(service): align typed evidence with shared record selector Cast the service-owned evidence union at the shared plain-ESM selector boundary; both carry the same validated record shape, while TypeScript correctly rejects the missing index signature without the explicit boundary cast. Local checks were NOT RUN by instruction; this fixes the exact hosted typecheck diagnostic. * test: isolate corrupt authority and follow shared state paths Reset the corrupt-authority fixture before exercising valid-authority mirror recovery, and point the updater source oracle at the shared active/default path resolver instead of an inlined filename literal. Local tests were NOT RUN by instruction; this fixes the exact hosted shard failures. * test(update): follow the reconciled install-state facade Point the Node launcher, Bun state reader, and source oracle at the install-state-contract surface landed on dev, while keeping one state-record authority implementation underneath it. Local checks were NOT RUN by instruction; this fixes the exact hosted shard diagnostic. --- bin/ocx.mjs | 100 ++-- scripts/test-layout/layout.json | 1 + src/cli/index.ts | 32 +- src/service.ts | 8 +- src/service/cli.ts | 11 +- src/service/install-state-contract.d.mts | 43 +- src/service/install-state-contract.mjs | 161 +---- src/service/orchestration.ts | 6 +- src/service/ownership-compatibility.ts | 164 ++++++ src/service/ownership-mutation-lease.d.mts | 23 + src/service/ownership-mutation-lease.mjs | 201 +++++++ src/service/repair.ts | 2 +- src/service/state-lock.ts | 269 +++++++++ src/service/state-record.d.mts | 36 ++ src/service/state-record.mjs | 138 +++++ src/service/state.ts | 549 +++++++++--------- src/update/index.ts | 126 ++-- src/update/job.ts | 17 +- src/update/restart-ownership.ts | 24 +- src/update/runtime-ownership.d.mts | 6 +- src/update/runtime-ownership.mjs | 46 +- structure/runtime.md | 99 ++-- tests/fixtures/test-layout-expected.json | 1 + .../service-ownership-compatibility.test.ts | 106 ++++ .../service-ownership-handover.test.ts | 13 +- tests/service/service-ownership-state.test.ts | 409 +++++++++---- tests/update/update-desktop-owner.test.ts | 166 ++---- tests/update/update-stop-first.test.ts | 15 +- 28 files changed, 1932 insertions(+), 840 deletions(-) create mode 100644 src/service/ownership-compatibility.ts create mode 100644 src/service/ownership-mutation-lease.d.mts create mode 100644 src/service/ownership-mutation-lease.mjs create mode 100644 src/service/state-lock.ts create mode 100644 src/service/state-record.d.mts create mode 100644 src/service/state-record.mjs create mode 100644 tests/service/service-ownership-compatibility.test.ts diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 1603e370416..1e483f754ee 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -15,9 +15,10 @@ import { decidePostStopUpdate } from "../src/update/stop-decision.mjs"; import { planUpdateRuntimeHandling } from "../src/update/runtime-ownership.mjs"; import { inspectInstallStateBytes, - resolveOwnershipFromEvidence, + selectAuthoritativeServiceState, serviceStateFilesFor, } from "../src/service/install-state-contract.mjs"; +import { acquireOwnershipMutationLease } from "../src/service/ownership-mutation-lease.mjs"; import { randomBytes } from "node:crypto"; import { createRequire } from "node:module"; import { existsSync, readFileSync, readdirSync } from "node:fs"; @@ -262,27 +263,40 @@ function runPackageManagerSelfUpdate(manager) { // Remember whether a background service manages the proxy BEFORE stopping — `ocx stop` // unloads it, so a successful update must refresh and restart it afterwards. - const serviceStateFiles = serviceStateFilesFor(configDir(), join(homedir(), ".opencodex")); - const serviceStatePath = serviceStateFiles[0]; - const serviceWasInstalled = existsSync(serviceStatePath); - /** - * What this update may do to the runtime, decided by the SAME contract the Bun updater - * uses — every state path, the whole record shape, and absence as the only answer that - * means no claim. - * - * This used to be a local reader that inspected the anchor alone and returned "known - * unowned" whenever the `ownership` field was simply missing, including from a record that - * fails the contract outright. A takeover the Bun updater refused to disturb was therefore - * fair game here, which is an authorization gap rather than a cosmetic divergence. - */ + const allServiceStatePaths = serviceStateFilesFor(configDir(), join(homedir(), ".opencodex")); + // The test guard's legacy path is the developer's real home. Production always reads the + // same active-home + default-home observations as the Bun resolver. + const serviceStatePaths = process.env.OCX_TEST_HOME_GUARD === "1" + ? allServiceStatePaths.slice(0, 1) + : allServiceStatePaths; + const serviceWasInstalled = serviceStatePaths.some(path => existsSync(path)); + // What this update may do to the runtime. The same rule the Bun updater applies, from the + // same module: a desktop takeover vetoes both the stop and the service refresh below. + const readServiceState = () => selectAuthoritativeServiceState( + serviceStatePaths.map(path => inspectInstallStateBytes(path, at => readFileSync(at, "utf8"))), + ); const readOwnership = () => { - const evidence = serviceStateFiles.map(path => inspectInstallStateBytes(path, at => readFileSync(at, "utf8"))); - const resolution = resolveOwnershipFromEvidence(evidence); - if (resolution.kind === "owned") return { ownership: resolution.ownership, ownershipUnknown: false }; - return { ownership: null, ownershipUnknown: resolution.kind === "unknown" }; + const selected = readServiceState(); + if (selected.kind === "unknown") return { ownership: null, ownershipUnknown: true, subjectToken: "unknown" }; + if (selected.kind === "none") return { + ownership: null, ownershipUnknown: false, subjectToken: JSON.stringify(["none", selected.revision]), + }; + const ownership = selected.state.ownership ?? null; + return { + ownership, + ownershipUnknown: false, + subjectToken: JSON.stringify(ownership + ? ["owned", selected.revision, ownership] + : ["none", selected.revision]), + }; }; - let runtimePlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); + const initialOwnership = readOwnership(); + const runtimePlan = planUpdateRuntimeHandling({ ...initialOwnership, serviceInstalled: serviceWasInstalled }); 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."); + process.exit(1); + } const trayBeforeUpdate = planWindowsTrayUpdate( process.platform === "win32" ? trayInstallState() : { installed: false, running: false }, ); @@ -296,10 +310,11 @@ function runPackageManagerSelfUpdate(manager) { } /** Register from scratch, preserving the recorded backend. Only for a genuinely absent service. */ function serviceInstallArgs() { - try { - const state = JSON.parse(readFileSync(serviceStatePath, "utf8")); - if (state.backend === "native") return [postUpdateLauncher, "service", "install", "--native"]; - } catch { /* missing or corrupt — fall through to default */ } + const selected = readServiceState(); + if (selected.kind === "unknown") throw new Error(`service backend is unknown: ${selected.reason}`); + if (selected.kind === "state" && selected.state.backend === "native") { + return [postUpdateLauncher, "service", "install", "--native"]; + } return [postUpdateLauncher, "service", "install"]; } /** @@ -450,7 +465,7 @@ function runPackageManagerSelfUpdate(manager) { // 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 }); - if (!nowOwned.stopRuntime) { + if (!nowOwned.mayStopRuntime) { console.warn(nowOwned.notice ?? "opencodex: the background runtime is owned elsewhere; not starting a second proxy."); return; } @@ -497,12 +512,13 @@ function runPackageManagerSelfUpdate(manager) { function recoverStoppedRuntimeAfterFailure() { // Nothing was stopped under a foreign owner, so there is nothing to recover — and // starting a proxy here would put a second one beside the runtime the app is managing. - if (!runtimePlan.stopRuntime) return; + const recoveryPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); + if (!recoveryPlan.mayStopRuntime) return; if (!postUpdateLauncherUsable) { console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); return; } - if (runtimePlan.refreshService) { + if (recoveryPlan.mayRestoreService) { console.warn("opencodex: update failed after stopping the proxy — restoring the previous background service."); refreshBackgroundServiceOrStartDirect(); } else if (hasRuntimeState) { @@ -518,15 +534,7 @@ function runPackageManagerSelfUpdate(manager) { // is the whole test here — the launcher cannot parse it, and `ocx stop` is what decides // whether the obligation is safe to finish. const hasPendingTeardown = hasPendingTeardownIn(readdirSync, configDir()); - // Re-read at the point of action rather than trusting the plan formed above: the Windows - // tray handoff between them spawns children, so a takeover can land in the gap, and - // stopping a runtime that just changed hands is the failure this lane exists to prevent. - { - const atStop = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); - if (atStop.notice && atStop.notice !== runtimePlan.notice) console.log(atStop.notice); - runtimePlan = atStop; - } - if (runtimePlan.stopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown)) { + if (runtimePlan.mayStopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown)) { console.log("⏹ Stopping the running proxy before updating..."); const stopRes = spawnSync(process.execPath, [launcher, "stop"], { stdio: "inherit", windowsHide: true }); const stillHasRuntimeState = @@ -578,6 +586,22 @@ function runPackageManagerSelfUpdate(manager) { } } + const replacementLease = acquireOwnershipMutationLease(serviceStatePaths); + const replacementOwnership = readOwnership(); + const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled }); + const replacementLiveness = runtimePlan.mayStopRuntime + ? probeProxyLiveness(bakePort, bakeHostname) + : "dead"; + if (replacementOwnership.subjectToken !== initialOwnership.subjectToken + || !replacementPlan.mayReplacePackage + || replacementLiveness !== "dead") { + replacementLease.release(); + if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); + console.error(replacementPlan.notice + ?? "opencodex: update stopped because runtime ownership or liveness changed after the stop decision; rerun from the beginning."); + process.exit(1); + } + // npm keeps the existing stage -> verify -> swap -> rollback flow. pnpm owns a // content-addressable store and generated global shims, so its path uses pnpm's own // global update operation and verifies the active group instead of renaming files. @@ -666,6 +690,8 @@ function runPackageManagerSelfUpdate(manager) { `The live install was not knowingly modified; run 'ocx update' again or reinstall with ${manual}.`); res = { status: 1 }; } + replacementLease.release(); + const postInstallPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); if (res.status === 0) { console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`); repairCodexShimIfNeeded(postUpdateLauncher); @@ -681,10 +707,10 @@ function runPackageManagerSelfUpdate(manager) { } // The stop above unloaded any managed service; refresh via the freshly-installed // launcher so the new files write the baked paths and the service restarts. - if (runtimePlan.refreshService) { + if (postInstallPlan.mayRestoreService) { console.log("Refreshing the background service with the updated files..."); refreshBackgroundServiceOrStartDirect(); - } else if (runtimePlan.stopRuntime) { + } else if (postInstallPlan.mayStopRuntime) { console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, bakePort)}`); } process.exit(0); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 41daa0cd488..f79307b4633 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1379,6 +1379,7 @@ "server-xai-header-parity.test.ts": "server", "server-xai-oauth-401-replay.test.ts": "server", "server-xai-responses-streaming.test.ts": "server", + "service-ownership-compatibility.test.ts": "service", "service-ownership-handover.test.ts": "service", "service-ownership-state.test.ts": "service", "service-probe-docker.test.ts": "service", diff --git a/src/cli/index.ts b/src/cli/index.ts index b9fe221eb51..b98a28b1e78 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -94,7 +94,8 @@ import { summarizeStopRun, type StopOutcome, type StopRunRecord } from "./stop-r import { runCli } from "./root"; import { isProcessAlive, ProxyOwnershipRefusedError, refusalNextStep, stopProxy } from "../lib/process-control"; import { startupDataPlaneToken } from "../lib/service-secrets"; -import { assertNotAdminToken, diagnoseService, isServiceOwnershipError, proxyStillLiveAfterStop, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalledDetailed, uninstallServiceIfInstalled, uninstallServiceDetailed } from "../service"; +import { assertNotAdminToken, diagnoseService, isServiceOwnershipError, proxyStillLiveAfterStop, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatePaths, serviceStatusSummary, stopServiceIfInstalledDetailed, uninstallServiceIfInstalled, uninstallServiceDetailed } from "../service"; +import { acquireOwnershipMutationLease } from "../service/ownership-mutation-lease.mjs"; import { formatStartupRoutingDetail, startupHealthSummary } from "../codex/autostart-health"; import { injectSystemEnv, reconcileShellHook, revertSystemEnv, uninstallShellHook } from "../server/system-env"; import { buildDesktop3pRegistry } from "../claude/desktop-3p"; @@ -448,6 +449,29 @@ async function handleStart(options: { block?: boolean } = {}) { // live daemon holding resources while it overwrites its own binary. await maybeShowUpdatePrompt(); + const startLease = acquireOwnershipMutationLease(serviceStatePaths()); + // The earlier probe owned journal cleanup. This one owns the bind decision: an updater may + // have stopped the old runtime and acquired the same lease before package replacement. + const fencedLive = await findLiveProxy(START_OWNERSHIP_LIVENESS); + if (fencedLive) { + const decision = decideStartWithLiveOwner({ + livePort: fencedLive.port, + requestedPort, + ocxService: process.env.OCX_SERVICE, + }); + if (decision === "service-stay-out") { + startLease.release(); + console.log(`Proxy already running (PID ${fencedLive.pid ?? "unknown"}, port ${fencedLive.port}); service wrapper staying out of the way.`); + process.exit(0); + } + if (decision === "refuse") { + startLease.release(); + console.error(`⚠️ Proxy appeared before bind (PID ${fencedLive.pid ?? "unknown"}, port ${fencedLive.port}). Use 'ocx stop' first.`); + process.exit(1); + } + siblingStart = true; + } + // Port selection is check-then-bind: a concurrent `ocx start`/`ensure` can win the port // between the probe and Bun.serve. Soft starts may re-pick; hard-pinned `--port` retries // the same port only (never hop — that was the remaining PR #152 gap). @@ -460,6 +484,7 @@ async function handleStart(options: { block?: boolean } = {}) { const readinessGate = createReadinessGate(); let server: ReturnType; const localAttestationSecret = createLocalAttestationSecret(); + try { for (let attempt = 0; ; attempt++) { try { server = startServer(port, { localAttestationSecret, readinessGate }); @@ -471,6 +496,7 @@ async function handleStart(options: { block?: boolean } = {}) { } catch (err) { if (err instanceof SpendLedgerOwnerError) { console.error(`❌ ${err.message}`); + startLease.release(); process.exit(1); } if (err instanceof AuxiliaryListenerBindError || !isAddrInUse(err) || attempt >= 2) throw err; @@ -480,6 +506,7 @@ async function handleStart(options: { block?: boolean } = {}) { const freed = await waitForPortAvailable(port, hostname, { timeoutMs: 3_000, intervalMs: 50 }); if (!freed) { console.error(`❌ Port ${port} stayed busy; refusing to hop to an ephemeral port.`); + startLease.release(); process.exit(1); } continue; @@ -488,6 +515,9 @@ async function handleStart(options: { block?: boolean } = {}) { port = await chooseListenPort(requestedPort, { sibling: siblingStart }); } } + } finally { + startLease.release(); + } // A single request's streaming error must never crash the daemon serving every // other Codex session — capture the full stack to crash.log and stay up. installCrashGuards(); diff --git a/src/service.ts b/src/service.ts index ebeca0258cd..79cfff6efda 100644 --- a/src/service.ts +++ b/src/service.ts @@ -6,8 +6,12 @@ * restore it via the command. */ -export type { ServiceBackend, ServiceInstallState, ServiceStateEvidence, ServiceOwner, ServiceOwnership, ServiceOwnershipResolution, ServiceStateSwapDeps } from "./service/state"; -export { SERVICE_MANAGED_ENV, stableLauncherEntry, serviceLogPath, serviceStatePathsForOpenCodexHome, parseServiceInstallState, parseServiceOwnership, inspectServiceStateEvidence, currentServiceHomes, serviceHomeMatches, readServiceBackend, serviceReinstallArgs, serviceInstallArgs, ServiceStateConflictError, swapServiceInstallState, serviceOwnership, resolveServiceOwnership, desktopOwnsService, ownershipGrantedTo, recordServiceOwner, releaseServiceOwner } from "./service/state"; +export type { ServiceBackend, ServiceInstallState, ServiceStateEvidence, ServiceStateResolution, ServiceOwner, ServiceOwnership, ServiceOwnershipSubject, ServiceOwnershipResolution, ServiceStateSwapDeps, RecordServiceOwnerRequest, RecordServiceOwnerDeps, ReleaseServiceOwnerDeps, RemoveServiceStateDeps } from "./service/state"; +export { SERVICE_MANAGED_ENV, SERVICE_OWNERSHIP_PROTOCOL_VERSION, SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, stableLauncherEntry, serviceLogPath, serviceStatePaths, serviceStatePathsForOpenCodexHome, parseServiceInstallState, parseServiceOwnership, inspectServiceStateEvidence, resolveServiceState, currentServiceHomes, serviceHomeMatches, readServiceBackend, serviceReinstallArgs, serviceInstallArgs, ServiceStateConflictError, ServiceOwnershipSubjectMismatchError, ServiceOwnershipSubjectUnknownError, ServiceTakeoverCompatibilityChangedError, swapServiceInstallState, removeServiceInstallStateRecords, serviceOwnership, resolveServiceOwnership, sameServiceOwnershipSubject, desktopOwnsService, ownershipGrantedTo, recordServiceOwner, releaseServiceOwner } from "./service/state"; +export type { OwnershipMutationLeaseOptions, OwnershipMutationLease } from "./service/ownership-mutation-lease.mjs"; +export { acquireOwnershipMutationLease, withOwnershipMutationLease } from "./service/ownership-mutation-lease.mjs"; +export type { ManagingCliRole, ManagingCliObservation, RegisteredManagingCliInvocation, ServiceTakeoverCompatibilityInput, ServiceTakeoverCompatibility } from "./service/ownership-compatibility"; +export { registeredManagingCliInvocation, assessServiceTakeoverCompatibility, sameServiceTakeoverCompatibility } from "./service/ownership-compatibility"; export type { ServiceApiTokenOrigin, ProvisionedServiceApiToken } from "./service/guards"; export { ServiceOwnershipError, isServiceOwnershipError, serviceEnvironmentOwnedHere, assertServiceEnvironmentMatchesInstall, serviceRetryCommand, assertNotAdminToken, assertServiceAuthEnvironment, writeServiceApiTokenFile, assertLiveServiceManagerAllowed } from "./service/guards"; export { resolveServiceListenPort, installedServiceListenPort, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, confirmServiceServing, reportServiceServing, resolvedProxyEnv } from "./service/health"; diff --git a/src/service/cli.ts b/src/service/cli.ts index 2dbce4599d3..1322b9948b4 100644 --- a/src/service/cli.ts +++ b/src/service/cli.ts @@ -234,6 +234,15 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise string): InstallStateEvidence; -export declare function resolveOwnershipFromEvidence( - evidence: readonly { path: string; kind: string; reason?: string; state?: unknown }[], -): OwnershipResolution; -export declare function serviceStateFilesFor(opencodexHomeDir: string, defaultHomeDir: string): string[]; +export type OwnershipResolution = + | { readonly kind: "none"; readonly revision: number; readonly needsRepair?: boolean } + | { readonly kind: "owned"; readonly ownership: { owner: string; installId: string; consentGeneration: number }; readonly revision: number } + | { readonly kind: "unknown"; readonly reason: string }; +export declare const SERVICE_STATE_FILE: "service-state.json"; +export declare function parseOwnershipClaim(value: unknown): import("./state-record.mjs").ServiceOwnershipRecord | null; +export declare function parseInstallStateRecord(value: unknown): import("./state-record.mjs").ServiceInstallStateRecord | null; +export declare function inspectInstallStateBytes(path: string, read: (path: string) => string): ServiceStateRecordEvidence; +export declare function resolveOwnershipFromEvidence(evidence: readonly ServiceStateRecordEvidence[]): OwnershipResolution; +export declare function serviceStateFilesFor(opencodexHomeDir: string, defaultHomeDir: string, platform?: NodeJS.Platform): string[]; diff --git a/src/service/install-state-contract.mjs b/src/service/install-state-contract.mjs index b07cb7cc901..b9baafa6186 100644 --- a/src/service/install-state-contract.mjs +++ b/src/service/install-state-contract.mjs @@ -1,141 +1,34 @@ -/** - * The service install-state contract, shared by both runtimes. - * - * `src/service/state.ts` is the authoritative reader and `bin/ocx.mjs` is the Node launcher - * that cannot import TypeScript. They used to validate the record separately, and the - * launcher's copy was weaker in two ways that mattered: it inspected only the anchor path, - * and it returned "known unowned" for any record whose `ownership` field was simply absent — - * including a record that fails the contract outright, such as one with no homes or an - * unsupported version. A takeover the Bun updater refused to disturb was therefore fair game - * for the npm and pnpm lane. - * - * This module is the one algorithm. Both sides import it, so the two lanes cannot answer the - * same question differently. - */ -import { join, resolve } from "node:path"; +/** Compatibility surface for the shared install-state contract landed before C2 hardening. */ +import { + inspectServiceStateRecords, + parseServiceInstallStateRecord, + parseServiceOwnershipRecord, + SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + SERVICE_OWNERSHIP_PROTOCOL_VERSION, + selectAuthoritativeServiceState, + serviceStateFingerprint, + serviceStatePathsForHomes, +} from "./state-record.mjs"; -function isNonNegativeInteger(value) { - return typeof value === "number" && Number.isInteger(value) && value >= 0; -} - -/** - * Validate an ownership claim read off disk. - * - * Returns the ORIGINAL object rather than a rebuilt one: a newer writer may carry fields - * this version does not know about, and rebuilding would drop them on the next preserve — - * the same lost-field failure the record exists to stop. - */ -export function parseOwnershipClaim(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - if (value.owner !== "cli" && value.owner !== "desktop") return null; - if (typeof value.installId !== "string" || value.installId.length === 0) return null; - if (!isNonNegativeInteger(value.consentGeneration)) return null; - return value; -} - -/** Validate a whole install record. Null means the bytes are not a record this tree wrote. */ -export function parseInstallStateRecord(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - if (value.version !== 1 && value.version !== 2) return null; - if (typeof value.codexHome !== "string" || value.codexHome.length === 0) return null; - if (typeof value.opencodexHome !== "string" || value.opencodexHome.length === 0) return null; - for (const key of ["codexSqliteHome", "bunPath", "launcherPath", "winswVersion", "winswSha256"]) { - if (value[key] !== undefined && (typeof value[key] !== "string" || value[key].length === 0)) return null; - } - // cliPath is the one path that is legitimately null: cliEntry() returns null for a - // standalone binary, and the writer stores it. - if (value.cliPath !== undefined && value.cliPath !== null - && (typeof value.cliPath !== "string" || value.cliPath.length === 0)) return null; - if (value.revision !== undefined && !isNonNegativeInteger(value.revision)) return null; - if (value.consentGenerationCeiling !== undefined && !isNonNegativeInteger(value.consentGenerationCeiling)) return null; - // A malformed ownership claim invalidates the whole record instead of being dropped: - // silently discarding it is precisely the demotion this field exists to prevent, and a - // reader that cannot trust the claim must not be told the runtime is unowned. - if (value.ownership !== undefined && parseOwnershipClaim(value.ownership) === null) return null; - if (value.version === 1) { - if (value.backend !== undefined) return null; - } else if (value.backend !== "scheduler" && value.backend !== "native") { - return null; - } - return value; -} +export const SERVICE_STATE_FILE = "service-state.json"; +export const parseOwnershipClaim = parseServiceOwnershipRecord; +export const parseInstallStateRecord = parseServiceInstallStateRecord; +export const serviceStateFilesFor = serviceStatePathsForHomes; +export { + SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + SERVICE_OWNERSHIP_PROTOCOL_VERSION, + selectAuthoritativeServiceState, + serviceStateFingerprint, +}; -/** - * Classify one state path's bytes. `read` returns the text, or throws; an ENOENT throw is - * absence and every other throw is a failure to ask. - * - * Absent, unreadable and invalid are three different answers. Collapsing them is how a - * locked-down or truncated record becomes permission to reactivate the npm launcher. - */ export function inspectInstallStateBytes(path, read) { - let raw; - try { - raw = read(path); - } catch (error) { - const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; - if (code === "ENOENT") return { path, kind: "absent" }; - return { path, kind: "unreadable", reason: code || String(error) }; - } - let parsed; - try { - parsed = JSON.parse(raw); - } catch { - return { path, kind: "invalid" }; - } - const state = parseInstallStateRecord(parsed); - return state ? { path, kind: "valid", state } : { path, kind: "invalid" }; + return inspectServiceStateRecords([path], read)[0]; } -/** - * What every state path, together, says about who owns the runtime. - * - * An unknown resolution is the answer that matters. A single null for "absent, unreadable or - * malformed" lets a caller read a permissions error as "the CLI owns it" and re-enable the - * npm launcher over a consented takeover. Absence is the only thing that may mean no claim. - */ export function resolveOwnershipFromEvidence(evidence) { - for (const entry of evidence) { - // Any path. A claim we are not allowed to look at is still a claim. - if (entry.kind === "unreadable") { - return { kind: "unknown", reason: `a service state path could not be read (${entry.reason})` }; - } - } - // Only the ANCHOR's corruption is fatal. The second path is the legacy default-home entry - // kept so an install made before OPENCODEX_HOME existed can still be found; unrelated junk - // left there by an old version must not be able to block every repair on this machine. - if (evidence[0] && evidence[0].kind === "invalid") { - return { kind: "unknown", reason: "the service install record is present but not valid" }; - } - const claims = []; - for (const entry of evidence) { - if (entry.kind === "valid" && entry.state.ownership) claims.push(entry.state.ownership); - } - const first = claims[0]; - if (first === undefined) return { kind: "none" }; - if (claims.some(claim => claim.owner !== first.owner || claim.installId !== first.installId)) { - return { kind: "unknown", reason: "the service state paths name different owners" }; - } - // Same claim in both places; the higher generation is the later write. - let best = first; - for (const claim of claims) if (claim.consentGeneration > best.consentGeneration) best = claim; - return { kind: "owned", ownership: best }; + const selected = selectAuthoritativeServiceState(evidence); + if (selected.kind === "unknown" || selected.kind === "none") return selected; + return selected.state.ownership + ? { kind: "owned", ownership: selected.state.ownership, revision: selected.revision } + : { kind: "none", revision: selected.revision }; } - -export const SERVICE_STATE_FILE = "service-state.json"; - -/** - * The state files to consult, in the order every reader resolves them: this OpenCodex home - * first, then the legacy default home kept for installs made before OPENCODEX_HOME existed. - * - * Shared so the launcher cannot inspect a shorter list than the authoritative reader — which - * it did, seeing only the anchor and never the legacy claim beside it. - */ -export function serviceStateFilesFor(opencodexHomeDir, defaultHomeDir) { - const anchor = join(opencodexHomeDir, SERVICE_STATE_FILE); - const legacy = join(defaultHomeDir, SERVICE_STATE_FILE); - const same = process.platform === "win32" - ? resolve(anchor).toLowerCase() === resolve(legacy).toLowerCase() - : resolve(anchor) === resolve(legacy); - return same ? [anchor] : [anchor, legacy]; -} - diff --git a/src/service/orchestration.ts b/src/service/orchestration.ts index 9a40ba2479e..92f374b3c34 100644 --- a/src/service/orchestration.ts +++ b/src/service/orchestration.ts @@ -11,7 +11,7 @@ import type { ServiceDiagnostic } from "./diagnostics"; import { assertServiceEnvironmentMatchesInstall } from "./guards"; import { runLaunchctl, launchdEvictionTargets, launchctlBootoutBenign, probeLaunchdLoadState, installLaunchd, startLaunchd, stopLaunchd, statusLaunchd, uninstallLaunchd } from "./launchd"; import { assertSchedulerRegistrationBeforeStart } from "./repair"; -import { SERVICE_MANAGED_ENV, TASK, plistPath, serviceStatePaths, writeServiceInstallState } from "./state"; +import { SERVICE_MANAGED_ENV, TASK, plistPath, removeServiceInstallStateRecords, writeServiceInstallState } from "./state"; import type { ServiceBackend } from "./state"; import { unitPath, isSystemd, installSystemd, startSystemd, stopSystemd, statusSystemd, uninstallSystemd, systemdServiceInstallCleanupOps } from "./systemd"; import { writeWindowsSchedulerAssets, stageWindowsSchedulerRegistrationXml, removeWindowsSchedulerRegistrationStage, registerFreshWindowsSchedulerTask, recordWindowsSchedulerOwnership, removeNativeWindowsServiceForScheduler, installWindows, installWindowsNative, startWindows, isWindowsSchedulerEndBenign, stopWindows, stopWindowsChecked, statusWindows, statusWindowsXml, killWindowsServiceWrapperProcesses, uninstallWindows, classifyWindowsServiceStop } from "./windows-ops"; @@ -527,9 +527,7 @@ export function stopServiceIfInstalledDetailed(): ServiceStopOutcome { /** Delete install-state files; stale state would make `ocx update` "reinstall" a service that no longer exists. */ export function removeServiceInstallState(): void { - for (const path of serviceStatePaths()) { - try { if (existsSync(path)) unlinkSync(path); } catch { /* best-effort */ } - } + removeServiceInstallStateRecords(); } type UninstallServiceHooksForTests = { diff --git a/src/service/ownership-compatibility.ts b/src/service/ownership-compatibility.ts new file mode 100644 index 00000000000..fe089e44758 --- /dev/null +++ b/src/service/ownership-compatibility.ts @@ -0,0 +1,164 @@ +import { createHash } from "node:crypto"; +import { isAbsolute } from "node:path"; +import { parseStrictSemver } from "../lib/strict-semver"; +import type { + ServiceInstallState, + ServiceOwnershipSubject, +} from "./state"; +import { + SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + SERVICE_OWNERSHIP_PROTOCOL_VERSION, +} from "./install-state-contract.mjs"; + +export type ManagingCliRole = "service-registration" | "path"; + +export type ManagingCliObservation = + | { readonly status: "absent" } + | { readonly status: "unknown"; readonly reason: string } + | { readonly status: "observed"; readonly version: string; readonly identity: string }; + +export type RegisteredManagingCliInvocation = + | { readonly status: "absent" } + | { readonly status: "unknown"; readonly reason: string } + | { readonly status: "resolved"; readonly executable: string; readonly args: readonly string[] }; + +/** Resolve the exact command baked into the preserved service registration. */ +export function registeredManagingCliInvocation( + state: ServiceInstallState | null, +): RegisteredManagingCliInvocation { + if (!state) return { status: "absent" }; + if (state.launcherPath) { + return isAbsolute(state.launcherPath) + ? { status: "resolved", executable: state.launcherPath, args: [] } + : { status: "unknown", reason: "the recorded service launcher is not absolute" }; + } + if (!state.bunPath || !isAbsolute(state.bunPath)) { + return { status: "unknown", reason: "the registered service executable is missing or not absolute" }; + } + if (state.cliPath === null) return { status: "resolved", executable: state.bunPath, args: [] }; + if (typeof state.cliPath === "string" && isAbsolute(state.cliPath)) { + return { status: "resolved", executable: state.bunPath, args: [state.cliPath] }; + } + return { status: "unknown", reason: "the registered service CLI path is missing or not absolute" }; +} + +export interface ServiceTakeoverCompatibilityInput { + readonly state: ServiceInstallState | null; + readonly subject: ServiceOwnershipSubject; + readonly managers: Readonly>; +} + +export type ServiceTakeoverCompatibility = + | { + readonly kind: "supported"; + readonly protocolVersion: typeof SERVICE_OWNERSHIP_PROTOCOL_VERSION; + readonly minimumCliVersion: typeof SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION; + /** Opaque binding over the approved subject and both managing-CLI observations. */ + readonly token: string; + } + | { + readonly kind: "blocked"; + readonly reason: + | "managing-cli-unknown" + | "managing-cli-unsupported" + | "managing-cli-unobserved" + | "service-protocol-unsupported"; + readonly detail: string; + readonly minimumCliVersion: typeof SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION; + }; + +function comparePrerelease(left: readonly (bigint | string)[], right: readonly (bigint | string)[]): number { + if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1; + for (let index = 0; index < Math.max(left.length, right.length); index += 1) { + const a = left[index]; + const b = right[index]; + if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1; + if (a === b) continue; + if (typeof a === "bigint" && typeof b === "bigint") return a < b ? -1 : 1; + if (typeof a === "bigint") return -1; + if (typeof b === "bigint") return 1; + return a < b ? -1 : 1; + } + return 0; +} + +function versionSupportsOwnership(value: string): boolean { + const actual = parseStrictSemver(value); + const minimum = parseStrictSemver(SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION); + if (!actual || !minimum) return false; + for (let index = 0; index < actual.core.length; index += 1) { + if (actual.core[index] !== minimum.core[index]) return actual.core[index]! > minimum.core[index]!; + } + return comparePrerelease(actual.prerelease, minimum.prerelease) >= 0; +} + +function compatibilityToken(input: ServiceTakeoverCompatibilityInput): string { + return createHash("sha256").update(JSON.stringify({ + subject: input.subject, + protocolVersion: input.state?.ownershipProtocolVersion ?? null, + managers: { + "service-registration": input.managers["service-registration"], + path: input.managers.path, + }, + })).digest("hex"); +} + +/** + * Decide whether permanent desktop ownership can be offered. + * + * Both managing surfaces are mandatory observations. `absent` is a trustworthy answer; + * `unknown` is not. An observed service registration additionally needs the protocol marker + * written by a CLI whose start/repair/update paths honor the ownership claim. This is what + * keeps the preserved registration from starting an older runtime at the next login. + */ +export function assessServiceTakeoverCompatibility( + input: ServiceTakeoverCompatibilityInput, +): ServiceTakeoverCompatibility { + const observed = Object.entries(input.managers) as Array<[ManagingCliRole, ManagingCliObservation]>; + const unknown = observed.find(([, manager]) => manager.status === "unknown"); + if (unknown) return { + kind: "blocked", + reason: "managing-cli-unknown", + detail: `${unknown[0]} compatibility could not be determined`, + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }; + const present = observed.filter(([, manager]) => manager.status === "observed") as Array<[ + ManagingCliRole, + Extract, + ]>; + if (present.length === 0) return { + kind: "blocked", + reason: "managing-cli-unobserved", + detail: "no managing OpenCodex CLI installation was observed", + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }; + const unsupported = present.find(([, manager]) => !versionSupportsOwnership(manager.version)); + if (unsupported) return { + kind: "blocked", + reason: "managing-cli-unsupported", + detail: `${unsupported[0]} uses OpenCodex ${unsupported[1].version}; ${SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION} or later is required`, + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }; + if (input.managers["service-registration"].status === "observed" + && input.state?.ownershipProtocolVersion !== SERVICE_OWNERSHIP_PROTOCOL_VERSION) { + return { + kind: "blocked", + reason: "service-protocol-unsupported", + detail: "the preserved service registration was not written by an ownership-aware CLI", + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }; + } + return { + kind: "supported", + protocolVersion: SERVICE_OWNERSHIP_PROTOCOL_VERSION, + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + token: compatibilityToken(input), + }; +} + +export function sameServiceTakeoverCompatibility( + left: ServiceTakeoverCompatibility, + right: ServiceTakeoverCompatibility, +): boolean { + return left.kind === "supported" && right.kind === "supported" && left.token === right.token; +} diff --git a/src/service/ownership-mutation-lease.d.mts b/src/service/ownership-mutation-lease.d.mts new file mode 100644 index 00000000000..574df756c6f --- /dev/null +++ b/src/service/ownership-mutation-lease.d.mts @@ -0,0 +1,23 @@ +export interface OwnershipMutationLeaseOptions { + readonly waitMs?: number; + readonly now?: () => number; + readonly sleep?: (ms: number) => void; + readonly processAlive?: (pid: number) => boolean; + readonly beforeRelease?: (lockPath: string) => void; + readonly joinToken?: string; +} + +export interface OwnershipMutationLease { readonly token: string; release(): void } + +export declare const OWNERSHIP_MUTATION_LEASE_TOKEN_ENV: "OCX_OWNERSHIP_MUTATION_LEASE_TOKEN"; + +export declare function acquireOwnershipMutationLease( + statePaths: readonly string[], + options?: OwnershipMutationLeaseOptions, +): OwnershipMutationLease; + +export declare function withOwnershipMutationLease( + statePaths: readonly string[], + run: () => T, + options?: OwnershipMutationLeaseOptions, +): T; diff --git a/src/service/ownership-mutation-lease.mjs b/src/service/ownership-mutation-lease.mjs new file mode 100644 index 00000000000..3bb9919d227 --- /dev/null +++ b/src/service/ownership-mutation-lease.mjs @@ -0,0 +1,201 @@ +import { randomUUID } from "node:crypto"; +import { + closeSync, + existsSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readdirSync, + realpathSync, + rmdirSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; + +const WAIT_MS = 2_000; +const POLL_MS = 20; +const STALE_MS = 30_000; +const PROCESS_INSTANCE = randomUUID(); +const held = new Map(); +const delegatedTokens = new Map(); +const sleeper = new Int32Array(new SharedArrayBuffer(4)); +export const OWNERSHIP_MUTATION_LEASE_TOKEN_ENV = "OCX_OWNERSHIP_MUTATION_LEASE_TOKEN"; + +function sleep(ms) { Atomics.wait(sleeper, 0, 0, ms); } +function processAlive(pid) { + try { process.kill(pid, 0); return true; } + catch (error) { return error?.code !== "ESRCH"; } +} + +function leasePath(statePaths) { + const authority = statePaths.at(-1); + if (!authority) throw new Error("cannot acquire ownership mutation lease without a service-state path"); + try { return `${realpathSync.native(authority)}.mutation.lock`; } + catch { + try { return join(realpathSync.native(dirname(authority)), `${basename(authority)}.mutation.lock`); } + catch { return `${authority}.mutation.lock`; } + } +} + +function ownerName(record) { + return `v1-${record.pid}-${record.processInstance}-${record.token}.json`; +} + +function parseOwnerName(name) { + const match = /^v1-([1-9][0-9]*)-[0-9a-f-]+-[0-9a-f-]+[.]json$/i.exec(name); + if (!match) return null; + const pid = Number(match[1]); + return Number.isSafeInteger(pid) && pid > 0 ? pid : null; +} + +function readOwner(path) { + try { + const lock = lstatSync(path); + if (!lock.isDirectory()) return null; + const entries = readdirSync(path); + if (entries.length !== 1) return null; + const ownerPath = join(path, entries[0]); + const owner = lstatSync(ownerPath); + if (!owner.isFile() || owner.size > 4096) return null; + const record = JSON.parse(readFileSync(ownerPath, "utf8")); + if (record?.version !== 1 || !Number.isSafeInteger(record.pid) || record.pid <= 0 + || typeof record.processInstance !== "string" || !record.processInstance + || typeof record.token !== "string" || !record.token + || !Number.isFinite(record.createdAt) || entries[0] !== ownerName(record)) return null; + const currentLock = lstatSync(path); + const currentOwner = lstatSync(ownerPath); + if (currentLock.dev !== lock.dev || currentLock.ino !== lock.ino + || currentOwner.dev !== owner.dev || currentOwner.ino !== owner.ino + || currentOwner.size !== owner.size) return null; + return { path, ownerPath, record, lockDev: lock.dev, lockIno: lock.ino, ownerDev: owner.dev, ownerIno: owner.ino, ownerSize: owner.size, mtimeMs: owner.mtimeMs }; + } catch { return null; } +} + +function sameOwner(left, right) { + return left.record.token === right.record.token + && left.record.pid === right.record.pid + && left.record.processInstance === right.record.processInstance + && left.lockDev === right.lockDev && left.lockIno === right.lockIno + && left.ownerDev === right.ownerDev && left.ownerIno === right.ownerIno + && left.ownerSize === right.ownerSize; +} + +function readIncompleteOwner(path) { + try { + const lock = lstatSync(path); + if (!lock.isDirectory()) return null; + const entries = readdirSync(path); + if (entries.length === 0) return { path, ownerPath: null, pid: null, mtimeMs: lock.mtimeMs }; + if (entries.length !== 1) return null; + const pid = parseOwnerName(entries[0]); + if (!pid) return null; + const ownerPath = join(path, entries[0]); + const owner = lstatSync(ownerPath); + return owner.isFile() ? { path, ownerPath, pid, mtimeMs: owner.mtimeMs } : null; + } catch { return null; } +} + +function reclaim(path, now, alive) { + const observed = readOwner(path); + const incomplete = observed ? null : readIncompleteOwner(path); + if (!observed && !incomplete) return false; + const createdAt = observed ? Math.max(observed.record.createdAt, observed.mtimeMs) : incomplete.mtimeMs; + const pid = observed?.record.pid ?? incomplete.pid; + if (now() - createdAt <= STALE_MS || (pid !== null && alive(pid))) return false; + if (observed) { + const current = readOwner(path); + if (!current || !sameOwner(observed, current)) return false; + } + try { + const ownerPath = observed?.ownerPath ?? incomplete.ownerPath; + if (ownerPath) unlinkSync(ownerPath); + rmdirSync(path); + return true; + } catch { return false; } +} + +export function acquireOwnershipMutationLease( + statePaths, + options = {}, +) { + const path = leasePath(statePaths); + const nested = held.get(path); + if (nested) { + nested.depth += 1; + return { token: nested.snapshot.record.token, release: () => release(path, options) }; + } + const now = options.now ?? Date.now; + const alive = options.processAlive ?? processAlive; + const explicitJoinToken = options.joinToken; + const envJoinToken = process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + const joinToken = explicitJoinToken ?? envJoinToken ?? delegatedTokens.get(path); + if (joinToken) { + const owner = readOwner(path); + if (owner?.record.token === joinToken && alive(owner.record.pid)) { + delegatedTokens.set(path, joinToken); + if (envJoinToken === joinToken) delete process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + held.set(path, { depth: 1, snapshot: owner, delegated: true }); + return { token: joinToken, release: () => release(path, options) }; + } + delegatedTokens.delete(path); + if (envJoinToken === joinToken) delete process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + if (explicitJoinToken) throw new Error("ownership mutation lease delegation is invalid or no longer live"); + } + const wait = options.waitMs ?? WAIT_MS; + const deadline = now() + wait; + if (!existsSync(dirname(path))) mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + for (;;) { + const record = { version: 1, pid: process.pid, processInstance: PROCESS_INSTANCE, token: randomUUID(), createdAt: now() }; + const ownerPath = join(path, ownerName(record)); + let madeDirectory = false; + let descriptor = null; + try { + mkdirSync(path, { mode: 0o700 }); + madeDirectory = true; + descriptor = openSync(ownerPath, "wx", 0o600); + writeFileSync(descriptor, `${JSON.stringify(record)}\n`, "utf8"); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = null; + const snapshot = readOwner(path); + if (!snapshot || snapshot.record.token !== record.token) throw new Error("ownership mutation lease could not be verified"); + held.set(path, { depth: 1, snapshot, delegated: false }); + return { token: record.token, release: () => release(path, options) }; + } catch (error) { + if (descriptor !== null) { try { closeSync(descriptor); } catch { /* stale recovery owns uncertain cleanup */ } } + if (madeDirectory) { + try { unlinkSync(ownerPath); } catch { /* partial owner is recovered after dead-PID proof */ } + try { rmdirSync(path); } catch { /* owner entry or successor keeps the directory live */ } + } + if (error?.code !== "EEXIST") throw error; + if (reclaim(path, now, alive)) continue; + if (now() >= deadline) throw new Error(`another process owns the runtime mutation lease at ${path}`); + (options.sleep ?? sleep)(POLL_MS); + } + } +} + +function release(path, options) { + const currentHeld = held.get(path); + if (!currentHeld) return; + currentHeld.depth -= 1; + if (currentHeld.depth > 0) return; + held.delete(path); + if (currentHeld.delegated) return; + options.beforeRelease?.(path); + try { + const current = readOwner(path); + if (!current || !sameOwner(currentHeld.snapshot, current)) return; + unlinkSync(currentHeld.snapshot.ownerPath); + rmdirSync(path); + } catch { /* token-specific stale recovery handles an uncertain release */ } +} + +export function withOwnershipMutationLease(statePaths, run, options = {}) { + const lease = acquireOwnershipMutationLease(statePaths, options); + try { return run(); } + finally { lease.release(); } +} diff --git a/src/service/repair.ts b/src/service/repair.ts index ad58e93118a..34937498238 100644 --- a/src/service/repair.ts +++ b/src/service/repair.ts @@ -92,7 +92,7 @@ export function unknownServiceOwnerRefusal(reason: string, action = "repair"): s return `Background service ${action} stopped: ${reason}, so the runtime's recorded owner could ` + "not be determined.\n" + "The service registration was left exactly as it is — not re-enabled, not rewritten and not restarted.\n" - + "Run 'ocx service install' to re-register the service and take the runtime back."; + + "Repair the service-state file or its permissions, then run 'ocx service install' to take the runtime back."; } async function assertSchedulerSnapshotBeforeStart( diff --git a/src/service/state-lock.ts b/src/service/state-lock.ts new file mode 100644 index 00000000000..6bc2fabfb92 --- /dev/null +++ b/src/service/state-lock.ts @@ -0,0 +1,269 @@ +import { randomUUID } from "node:crypto"; +import { + closeSync, + existsSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + readdirSync, + realpathSync, + rmdirSync, + unlinkSync, + writeFileSync, + type Stats, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; + +const SERVICE_STATE_LOCK_WAIT_MS = 2_000; +const SERVICE_STATE_LOCK_POLL_MS = 20; +const SERVICE_STATE_LOCK_STALE_MS = 30_000; +const PROCESS_INSTANCE = randomUUID(); + +interface ServiceStateLockRecord { + readonly version: 1; + readonly pid: number; + readonly processInstance: string; + readonly token: string; + readonly createdAt: number; +} + +interface ServiceStateLockSnapshot { + readonly record: ServiceStateLockRecord; + readonly ownerPath: string; + readonly lockIdentity: Pick; + readonly ownerIdentity: Pick; + readonly mtimeMs: number; +} + +export interface ServiceStateLockHooks { + readonly now?: () => number; + readonly sleep?: (ms: number) => void; + readonly processAlive?: (pid: number) => boolean; + readonly beforeStaleDelete?: (lockPath: string) => void; + readonly beforeRelease?: (lockPath: string) => void; +} + +interface HeldServiceStateLock { + depth: number; + readonly snapshot: ServiceStateLockSnapshot; +} + +const heldLocks = new Map(); + +function lockPathForStatePath(statePath: string): string { + try { return `${realpathSync.native(statePath)}.lock`; } + catch { + try { return join(realpathSync.native(dirname(statePath)), `${basename(statePath)}.lock`); } + catch { return `${statePath}.lock`; } + } +} + +function lockOwnerProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM means the process exists but this account cannot signal it. Unknown failures + // also fail closed: only ESRCH proves the holder is gone. + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } +} + +function sameIdentity( + left: Pick, + right: Pick, +): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function ownerFileName(record: ServiceStateLockRecord): string { + return `v1-${record.pid}-${record.processInstance}-${record.token}.json`; +} + +function parseOwnerFileName(name: string): { pid: number; pathToken: string } | null { + const match = /^v1-([1-9][0-9]*)-([0-9a-f-]+)-([0-9a-f-]+)[.]json$/i.exec(name); + if (!match) return null; + const pid = Number(match[1]); + return Number.isSafeInteger(pid) && pid > 0 ? { pid, pathToken: `${match[2]}-${match[3]}` } : null; +} + +function readLockSnapshot(lockPath: string): ServiceStateLockSnapshot | null { + let lockIdentity: Stats; + let entries: string[]; + try { + lockIdentity = lstatSync(lockPath); + if (!lockIdentity.isDirectory()) return null; + entries = readdirSync(lockPath); + } catch { + return null; + } + if (entries.length !== 1 || !parseOwnerFileName(entries[0]!)) return null; + const ownerPath = join(lockPath, entries[0]!); + try { + const ownerIdentity = lstatSync(ownerPath); + if (!ownerIdentity.isFile() || ownerIdentity.size > 4096) return null; + const value = JSON.parse(readFileSync(ownerPath, "utf8")) as Partial; + if (value.version !== 1 || !Number.isSafeInteger(value.pid) || (value.pid ?? 0) <= 0 + || typeof value.processInstance !== "string" || value.processInstance.length === 0 + || typeof value.token !== "string" || value.token.length === 0 + || typeof value.createdAt !== "number" || !Number.isFinite(value.createdAt)) return null; + const record = value as ServiceStateLockRecord; + if (entries[0] !== ownerFileName(record)) return null; + const currentLock = lstatSync(lockPath); + const currentOwner = lstatSync(ownerPath); + if (!currentLock.isDirectory() || !sameIdentity(lockIdentity, currentLock) + || !currentOwner.isFile() || !sameIdentity(ownerIdentity, currentOwner) + || currentOwner.size !== ownerIdentity.size) return null; + return { record, ownerPath, lockIdentity, ownerIdentity, mtimeMs: ownerIdentity.mtimeMs }; + } catch { + return null; + } +} + +function sameLock(left: ServiceStateLockSnapshot, right: ServiceStateLockSnapshot): boolean { + return left.record.token === right.record.token + && left.record.pid === right.record.pid + && left.record.processInstance === right.record.processInstance + && sameIdentity(left.lockIdentity, right.lockIdentity) + && sameIdentity(left.ownerIdentity, right.ownerIdentity) + && left.ownerIdentity.size === right.ownerIdentity.size; +} + +function parsedIncompleteOwner(lockPath: string): { ownerPath: string | null; pid: number | null; mtimeMs: number } | null { + try { + const lock = lstatSync(lockPath); + if (!lock.isDirectory()) return null; + const entries = readdirSync(lockPath); + if (entries.length === 0) return { ownerPath: null, pid: null, mtimeMs: lock.mtimeMs }; + if (entries.length !== 1) return null; + const parsed = parseOwnerFileName(entries[0]!); + if (!parsed) return null; + const ownerPath = join(lockPath, entries[0]!); + const owner = lstatSync(ownerPath); + return owner.isFile() ? { ownerPath, pid: parsed.pid, mtimeMs: owner.mtimeMs } : null; + } catch { + return null; + } +} + +function reclaimStaleLock(lockPath: string, hooks: ServiceStateLockHooks): boolean { + const now = hooks.now ?? Date.now; + const processAlive = hooks.processAlive ?? lockOwnerProcessAlive; + const snapshot = readLockSnapshot(lockPath); + const incomplete = snapshot ? null : parsedIncompleteOwner(lockPath); + if (!snapshot && !incomplete) return false; + const ownerPath = snapshot?.ownerPath ?? incomplete!.ownerPath; + const ownerPid = snapshot?.record.pid ?? incomplete!.pid; + const createdAt = snapshot ? Math.max(snapshot.record.createdAt, snapshot.mtimeMs) : incomplete!.mtimeMs; + if (now() - createdAt <= SERVICE_STATE_LOCK_STALE_MS || (ownerPid !== null && processAlive(ownerPid))) { + return false; + } + if (snapshot) { + const current = readLockSnapshot(lockPath); + if (!current || !sameLock(snapshot, current)) return false; + } + hooks.beforeStaleDelete?.(lockPath); + try { + // The owner filename contains the holder's PID, process-instance nonce and token. A + // successor has a different name, so this unlink cannot delete the successor's owner. + if (ownerPath) unlinkSync(ownerPath); + rmdirSync(lockPath); + return true; + } catch { + return false; + } +} + +function acquireOne(lockPath: string, hooks: ServiceStateLockHooks, waitMs: number): ServiceStateLockSnapshot { + const held = heldLocks.get(lockPath); + if (held) { held.depth += 1; return held.snapshot; } + const now = hooks.now ?? Date.now; + const sleep = hooks.sleep ?? (ms => Bun.sleepSync(ms)); + const deadline = now() + waitMs; + if (!existsSync(dirname(lockPath))) mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 }); + for (;;) { + const record: ServiceStateLockRecord = { + version: 1, + pid: process.pid, + processInstance: PROCESS_INSTANCE, + token: randomUUID(), + createdAt: now(), + }; + const ownerPath = join(lockPath, ownerFileName(record)); + let createdDirectory = false; + let descriptor: number | null = null; + try { + mkdirSync(lockPath, { mode: 0o700 }); + createdDirectory = true; + descriptor = openSync(ownerPath, "wx", 0o600); + writeFileSync(descriptor, `${JSON.stringify(record)}\n`, "utf8"); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = null; + const snapshot = readLockSnapshot(lockPath); + if (!snapshot || snapshot.record.token !== record.token) throw new Error("service state lock ownership could not be verified"); + heldLocks.set(lockPath, { depth: 1, snapshot }); + return snapshot; + } catch (error) { + if (descriptor !== null) { try { closeSync(descriptor); } catch { /* best-effort */ } } + if (createdDirectory) { + try { unlinkSync(ownerPath); } catch { /* incomplete owner may remain for dead-PID recovery */ } + try { rmdirSync(lockPath); } catch { /* another entry or uncertain owner remains */ } + } + const code = (error as NodeJS.ErrnoException).code; + if (code !== "EEXIST") throw error; + if (reclaimStaleLock(lockPath, hooks)) continue; + if (now() >= deadline) { + throw new Error(`another process owns the service state lock at ${lockPath}; nothing was written`); + } + sleep(SERVICE_STATE_LOCK_POLL_MS); + } + } +} + +function releaseOne(lockPath: string, hooks: ServiceStateLockHooks): void { + const held = heldLocks.get(lockPath); + if (!held) return; + held.depth -= 1; + if (held.depth > 0) return; + heldLocks.delete(lockPath); + hooks.beforeRelease?.(lockPath); + try { + const current = readLockSnapshot(lockPath); + if (!current || !sameLock(held.snapshot, current)) return; + unlinkSync(held.snapshot.ownerPath); + rmdirSync(lockPath); + } catch { /* a verified future holder or stale recovery owns cleanup */ } +} + +export function assertServiceStateLocksOwned(statePaths: readonly string[]): void { + for (const path of statePaths) { + const lockPath = lockPathForStatePath(path); + const held = heldLocks.get(lockPath); + const current = readLockSnapshot(lockPath); + if (!held || !current || !sameLock(held.snapshot, current)) { + throw new Error(`service state lock ownership changed before commit: ${lockPath}`); + } + } +} + +export function withServiceStateLocks( + statePaths: readonly string[], + run: () => T, + options: { readonly waitMs?: number; readonly hooks?: ServiceStateLockHooks } = {}, +): T { + const hooks = options.hooks ?? {}; + const lockPaths = [...new Set(statePaths.map(lockPathForStatePath))].sort(); + const acquired: string[] = []; + try { + for (const lockPath of lockPaths) { + acquireOne(lockPath, hooks, options.waitMs ?? SERVICE_STATE_LOCK_WAIT_MS); + acquired.push(lockPath); + } + return run(); + } finally { + for (const lockPath of acquired.reverse()) releaseOne(lockPath, hooks); + } +} diff --git a/src/service/state-record.d.mts b/src/service/state-record.d.mts new file mode 100644 index 00000000000..6fa0eb77efd --- /dev/null +++ b/src/service/state-record.d.mts @@ -0,0 +1,36 @@ +export interface ServiceOwnershipRecord { + readonly owner: "cli" | "desktop"; + readonly installId: string; + readonly consentGeneration: number; + readonly [key: string]: unknown; +} + +export declare const SERVICE_OWNERSHIP_PROTOCOL_VERSION: 1; +export declare const SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION: "2.61.0"; + +export interface ServiceInstallStateRecord { + readonly version: 1 | 2; + readonly codexHome: string; + readonly opencodexHome: string; + readonly revision?: number; + readonly ownership?: ServiceOwnershipRecord; + readonly [key: string]: unknown; +} + +export type ServiceStateRecordEvidence = + | { readonly path: string; readonly kind: "absent" } + | { readonly path: string; readonly kind: "unreadable"; readonly reason: string } + | { readonly path: string; readonly kind: "invalid" } + | { readonly path: string; readonly kind: "valid"; readonly state: ServiceInstallStateRecord }; + +export type AuthoritativeServiceStateRecord = + | { readonly kind: "none"; readonly revision: 0; readonly needsRepair: false } + | { readonly kind: "state"; readonly state: ServiceInstallStateRecord; readonly revision: number; readonly needsRepair: boolean } + | { readonly kind: "unknown"; readonly reason: string }; + +export declare function parseServiceOwnershipRecord(value: unknown): ServiceOwnershipRecord | null; +export declare function parseServiceInstallStateRecord(value: unknown): ServiceInstallStateRecord | null; +export declare function serviceStatePathsForHomes(opencodexHome: string, defaultOpenCodexHome: string, platform?: NodeJS.Platform): string[]; +export declare function inspectServiceStateRecords(paths: readonly string[], read?: (path: string) => string): readonly ServiceStateRecordEvidence[]; +export declare function serviceStateFingerprint(value: unknown): string; +export declare function selectAuthoritativeServiceState(evidence: readonly ServiceStateRecordEvidence[]): AuthoritativeServiceStateRecord; diff --git a/src/service/state-record.mjs b/src/service/state-record.mjs new file mode 100644 index 00000000000..dc0270cb469 --- /dev/null +++ b/src/service/state-record.mjs @@ -0,0 +1,138 @@ +import { readFileSync, realpathSync } from "node:fs"; +import { posix, win32 } from "node:path"; + +export const SERVICE_OWNERSHIP_PROTOCOL_VERSION = 1; +export const SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION = "2.61.0"; + +const isObject = value => Boolean(value) && typeof value === "object" && !Array.isArray(value); +const isNonNegativeSafeInteger = value => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +const nonEmptyString = value => typeof value === "string" && value.length > 0; + +/** Parse the ownership payload shared by the Bun service code and the Node launcher. */ +export function parseServiceOwnershipRecord(value) { + if (!isObject(value)) return null; + if (value.owner !== "cli" && value.owner !== "desktop") return null; + if (!nonEmptyString(value.installId) || !isNonNegativeSafeInteger(value.consentGeneration)) return null; + return value; +} + +/** Parse the COMPLETE install record; ownership alone is not enough to trust the file. */ +export function parseServiceInstallStateRecord(value) { + if (!isObject(value) || (value.version !== 1 && value.version !== 2)) return null; + if (!nonEmptyString(value.codexHome) || !nonEmptyString(value.opencodexHome)) return null; + for (const key of ["codexSqliteHome", "bunPath", "launcherPath", "winswVersion", "winswSha256"]) { + if (value[key] !== undefined && !nonEmptyString(value[key])) return null; + } + if (value.cliPath !== undefined && value.cliPath !== null && !nonEmptyString(value.cliPath)) return null; + if (value.revision !== undefined && !isNonNegativeSafeInteger(value.revision)) return null; + if (value.consentGenerationCeiling !== undefined && !isNonNegativeSafeInteger(value.consentGenerationCeiling)) return null; + if (value.ownershipProtocolVersion !== undefined && !isNonNegativeSafeInteger(value.ownershipProtocolVersion)) return null; + if (value.ownership !== undefined && parseServiceOwnershipRecord(value.ownership) === null) return null; + if (value.version === 1) { + if (value.backend !== undefined) return null; + } else if (value.backend !== "scheduler" && value.backend !== "native") return null; + return value; +} + +export function serviceStatePathsForHomes(opencodexHome, defaultOpenCodexHome, platform = process.platform) { + const tools = platform === "win32" ? win32 : posix; + const primary = tools.join(opencodexHome, "service-state.json"); + const legacy = tools.join(defaultOpenCodexHome, "service-state.json"); + const key = path => { + let canonical; + try { canonical = realpathSync.native(path); } + catch { + try { canonical = tools.join(realpathSync.native(tools.dirname(path)), tools.basename(path)); } + catch { canonical = tools.resolve(path); } + } + return platform === "win32" ? canonical.toLowerCase() : canonical; + }; + return key(primary) === key(legacy) ? [primary] : [primary, legacy]; +} + +function errorCode(error) { + return error && typeof error === "object" && "code" in error ? String(error.code ?? "") : ""; +} + +/** Read every supplied state path without collapsing absent, invalid and unreadable. */ +export function inspectServiceStateRecords(paths, read = path => readFileSync(path, "utf8")) { + return paths.map(path => { + let raw; + try { + raw = read(path); + } catch (error) { + const code = errorCode(error); + return code === "ENOENT" + ? { path, kind: "absent" } + : { path, kind: "unreadable", reason: code || String(error) }; + } + try { + const state = parseServiceInstallStateRecord(JSON.parse(raw)); + return state ? { path, kind: "valid", state } : { path, kind: "invalid" }; + } catch { + return { path, kind: "invalid" }; + } + }); +} + +function canonical(value) { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (!isObject(value)) return JSON.stringify(value); + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`; +} + +export function serviceStateFingerprint(value) { + return canonical(value); +} + +/** + * Select one authoritative generation from mirrored state. + * + * The final path is authoritative. A lower-revision mirror is repairable; a same-or-newer + * disagreement is unordered evidence and fails closed. Before the authority exists, one valid + * active-home record is imported exactly once as migration input. + */ +export function selectAuthoritativeServiceState(evidence) { + const authority = evidence.at(-1); + if (!authority) return { kind: "none", revision: 0, needsRepair: false }; + if (authority.kind === "unreadable") { + return { kind: "unknown", reason: `the authoritative service state could not be read (${authority.reason})` }; + } + if (authority.kind === "invalid") { + return { kind: "unknown", reason: "the authoritative service install record is present but not valid" }; + } + if (authority.kind === "valid") { + const fingerprint = canonical(authority.state); + const authorityRevision = authority.state.revision ?? 0; + const unorderedConflict = evidence.slice(0, -1).find(entry => entry.kind === "valid" + && (entry.state.revision ?? 0) >= authorityRevision + && canonical(entry.state) !== fingerprint); + if (unorderedConflict) { + return { kind: "unknown", reason: `a service state mirror conflicts with authority revision ${authorityRevision}` }; + } + return { + kind: "state", + state: authority.state, + revision: authorityRevision, + needsRepair: evidence.slice(0, -1).some(entry => entry.kind !== "valid" || canonical(entry.state) !== fingerprint), + }; + } + + // The authority has never been established. A single valid active-home mirror is the + // migration source; after the first write it can no longer vote against the authority. + const migration = evidence.slice(0, -1); + const unreadable = migration.find(entry => entry.kind === "unreadable"); + if (unreadable) return { kind: "unknown", reason: `a legacy service state path could not be read (${unreadable.reason})` }; + if (migration.some(entry => entry.kind === "invalid")) { + return { kind: "unknown", reason: "a legacy service install record is present but not valid" }; + } + const valid = migration.filter(entry => entry.kind === "valid"); + if (valid.length === 0) return { kind: "none", revision: 0, needsRepair: false }; + const revision = Math.max(...valid.map(entry => entry.state.revision ?? 0)); + const newest = valid.filter(entry => (entry.state.revision ?? 0) === revision); + const fingerprint = canonical(newest[0].state); + if (newest.some(entry => canonical(entry.state) !== fingerprint)) { + return { kind: "unknown", reason: `legacy service state mirrors disagree at revision ${revision}` }; + } + return { kind: "state", state: newest[0].state, revision, needsRepair: true }; +} diff --git a/src/service/state.ts b/src/service/state.ts index f4b75753636..dbd2520b07b 100644 --- a/src/service/state.ts +++ b/src/service/state.ts @@ -1,23 +1,33 @@ -import { accessSync, chmodSync, closeSync, constants as fsConstants, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; -import { randomUUID } from "node:crypto"; +import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { delimiter, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path"; import { expandUserPath, getConfigDir } from "../config"; +import { atomicWriteFileStreamed } from "../config/atomic-write"; import { resolveCodexHomeDir, type CodexHomeDeps } from "../codex/home"; import { resolveCodexSqliteHome } from "../codex/paths"; import { durableBunRuntime, type BunRuntimeSource, type DurableBunRuntime } from "../lib/bun-runtime"; import { WINSW_SHA256, WINSW_VERSION } from "../lib/winsw"; -import { hardenSecretPath } from "../lib/windows-secret-acl"; -import { recordOwnedConfigPath } from "../lib/config-ownership"; import { isProtectedHomeUnderTest, isTestHomeGuardArmed } from "../lib/test-home-guard"; import { isStandaloneBinary } from "../lib/standalone"; import { inspectInstallStateBytes, parseInstallStateRecord, parseOwnershipClaim, - resolveOwnershipFromEvidence, + SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + SERVICE_OWNERSHIP_PROTOCOL_VERSION, + selectAuthoritativeServiceState, + serviceStateFingerprint, serviceStateFilesFor, } from "./install-state-contract.mjs"; +import type { ServiceStateRecordEvidence } from "./state-record.mjs"; +import { assertServiceStateLocksOwned, withServiceStateLocks, type ServiceStateLockHooks } from "./state-lock"; +import { withOwnershipMutationLease, type OwnershipMutationLeaseOptions } from "./ownership-mutation-lease.mjs"; +import { + assessServiceTakeoverCompatibility, + sameServiceTakeoverCompatibility, + type ManagingCliObservation, + type ServiceTakeoverCompatibility, +} from "./ownership-compatibility"; /** * Written only by the launchd plist and the systemd unit. `OCX_SERVICE=1` cannot stand in @@ -28,6 +38,7 @@ export const SERVICE_MANAGED_ENV = "OCX_SERVICE_MANAGED"; export const LABEL = "com.opencodex.proxy"; export const TASK = "opencodex-proxy"; +export { SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, SERVICE_OWNERSHIP_PROTOCOL_VERSION }; // This module lives one level below the original src/service.ts, so path-relative // lookups anchored at that file's directory go through this constant instead. @@ -143,8 +154,6 @@ function defaultOpenCodexHome(): string { } export function serviceStatePathsForOpenCodexHome(opencodexHome: string): string[] { - // Shared with the Node launcher, which has to consult the SAME list: reading only the - // anchor is how it missed a claim recorded on the legacy default-home path. return serviceStateFilesFor(opencodexHome, defaultOpenCodexHome()); } @@ -264,6 +273,8 @@ export interface ServiceInstallState { * one as its own prior consent. */ consentGenerationCeiling?: number; + /** Written only by CLIs whose start/repair/update paths honor a desktop claim. */ + ownershipProtocolVersion?: number; } /** @@ -341,6 +352,7 @@ function installProvenanceRecord(backend: ServiceBackend, launcherPath?: string codexSqliteHome: resolveCodexSqliteHome({ codexHome }), bunPath: bun, cliPath: cli, + ownershipProtocolVersion: SERVICE_OWNERSHIP_PROTOCOL_VERSION, ...(launcherPath ? { launcherPath } : {}), backend, ...(backend === "native" ? { winswVersion: WINSW_VERSION, winswSha256: WINSW_SHA256 } : {}), @@ -357,40 +369,22 @@ function installProvenanceRecord(backend: ServiceBackend, launcherPath?: string * runtime back to the npm launcher without saying anything. Preserving it here is what makes * the consent durable. */ -export function writeServiceInstallState(backend: ServiceBackend = "scheduler", launcherPath?: string | null): void { +export function writeServiceInstallState( + backend: ServiceBackend = "scheduler", + launcherPath?: string | null, + deps: ServiceStateSwapDeps = {}, +): void { swapServiceInstallState(current => ({ ...installProvenanceRecord(backend, launcherPath), - // Resolved INSIDE the swap, which runs while the anchor lock is held, and across every - // state path so a claim living only on the legacy mirror is carried onto the anchor. - // - // Resolving before the lock was a lost-update window of its own: a takeover recorded - // between the resolution and the swap's base read lands in `current`, passes the revision - // check untouched, and is then overwritten by the older claim this function captured. - // The compare-and-swap cannot see that, because the stale value never came from the base. - // - // This does NOT refuse on an unknown resolution. It runs at the END of a successful - // install or repair, where a throw would report a service that is registered and running - // as a failure. The fail-closed decision belongs in front of the mutation, where repair - // and the updaters make it; here the job is to preserve as much as can be read. - ...preservedConsent(current, resolveServiceOwnership()), - })); + ...preservedConsent(current), + }), deps); } /** The ownership half of a record: the claim itself plus the generation high-water mark. */ function preservedConsent( current: ServiceInstallState | null, - resolution: ServiceOwnershipResolution, ): Pick { - // Both inputs are read under the lock, and they can still disagree: `current` is the anchor - // alone, the resolution spans every path. Never let the older grant win, and on an equal - // generation keep `current` — the anchor is the record every reader resolves first, so - // preferring it is the fail-safe tie. - const resolved = resolution.kind === "owned" ? resolution.ownership : undefined; - const ownership = resolved === undefined - ? current?.ownership - : current?.ownership && current.ownership.consentGeneration >= resolved.consentGeneration - ? current.ownership - : resolved; + const ownership = current?.ownership; const ceiling = Math.max(current?.consentGenerationCeiling ?? 0, ownership?.consentGeneration ?? 0); return { ...(ownership ? { ownership } : {}), @@ -399,23 +393,16 @@ function preservedConsent( } export function readServiceInstallState(): ServiceInstallState | null { - for (const path of serviceStatePaths()) { - try { - const parsed = parseServiceInstallState(JSON.parse(readFileSync(path, "utf8"))); - if (parsed) return parsed; - } catch { - /* try the next known state path */ - } - } - return null; + const resolved = resolveServiceState(); + return resolved.kind === "state" ? resolved.state : null; } -/** Raised when a state write kept losing its compare-and-swap; NOTHING was written. */ +/** Raised when a non-cooperating writer prevents a stable authoritative commit. */ export class ServiceStateConflictError extends Error { constructor(readonly path: string, readonly attempts: number) { super( `service install state at ${path} was rewritten by another process during all ${attempts} ` - + "compare-and-swap attempts; nothing was written. Re-run the command.", + + "compare-and-swap attempts; a stable commit could not be verified. Re-run the command.", ); this.name = "ServiceStateConflictError"; } @@ -434,177 +421,43 @@ export interface ServiceStateSwapDeps { beforeCommit?: (attempt: number) => void; /** How long to wait for another process to release the anchor lock. */ lockWaitMs?: number; + /** Deterministic lock seams for failure-order tests. */ + lockHooks?: ServiceStateLockHooks; + /** Atomic publisher seam. The callback must run immediately before its commit point. */ + commitStateFile?: (path: string, serialized: string, validate: () => void) => void; + /** A mirror failure occurs after the authority committed and is therefore diagnostic. */ + onMirrorError?: (path: string, error: unknown) => void; + /** Allows consented mutations to preserve a machine-readable unknown-subject error. */ + unknownStateError?: (reason: string) => Error; + /** Shared with update/install/start so replacement and ownership mutation cannot overlap. */ + mutationLease?: OwnershipMutationLeaseOptions; } -const SERVICE_STATE_SWAP_ATTEMPTS = 5; -const SERVICE_STATE_LOCK_WAIT_MS = 2_000; -const SERVICE_STATE_LOCK_POLL_MS = 20; -/** - * How old a lock must be before it is treated as abandoned. - * - * It has to exceed the longest legitimate critical section, not the typical one. On Windows - * each committed path runs `hardenSecretPath` synchronously, whose own documentation records - * a worst case around ninety seconds for sequential calls under load; a thirty-second - * threshold would let a second writer evict a holder that is simply still working, and both - * would then compute the same base revision and write over each other. - */ -const SERVICE_STATE_LOCK_STALE_MS = 300_000; -const SERVICE_STATE_REPLACE_ATTEMPTS = 5; -const SERVICE_STATE_REPLACE_RETRY_MS = 40; -/** Lock paths this process holds, with the token written into each and a re-entrancy depth. */ -const heldStateLocks = new Map(); - -/** The token inside a lock file, or null when it cannot be read. */ -function readLockToken(lockPath: string): string | null { - try { return readFileSync(lockPath, "utf8").trim() || null; } catch { return null; } -} - -function isFileExistsError(error: unknown): boolean { - return Boolean(error) && typeof error === "object" && "code" in (error as object) - && (error as { code?: unknown }).code === "EEXIST"; +export interface ServiceStateMutationContext { + readonly revision: number; } -/** - * Hold an exclusive lock over the anchor record for one whole read-modify-write. - * - * The revision check alone cannot make the swap atomic: two processes can both pass it, - * both commit, and both verify their own bytes, after which the second silently drops the - * first's mutation and reports success. `O_EXCL` creation is the cheap cross-process - * exclusion that closes it for every writer that comes through here. - * - * The revision check stays anyway, because this lock binds only cooperating writers — an - * older `ocx` on the same machine does not take it. - * - * Re-entrant per process. A swap nested inside another one is a caller ordering its own - * writes, not a race, and blocking it would be a self-deadlock. - */ -function withServiceStateLock(anchor: string, run: () => T, waitMs = SERVICE_STATE_LOCK_WAIT_MS): T { - const lockPath = `${anchor}.lock`; - const held = heldStateLocks.get(lockPath); - if (held !== undefined) { - held.depth += 1; - try { return run(); } finally { releaseHeldLock(lockPath); } - } - const dir = dirname(lockPath); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); - const deadline = Date.now() + waitMs; - const token = randomUUID(); - let fd: number; - for (;;) { - try { - fd = openSync(lockPath, "wx", 0o600); - break; - } catch (error) { - // A lock we could not create for any reason OTHER than "it is held" is a filesystem - // failure, and writing the record anyway is the unprotected path this exists to close. - if (!isFileExistsError(error)) throw error; - if (Date.now() < deadline) { Bun.sleepSync(SERVICE_STATE_LOCK_POLL_MS); continue; } - // Break a lock whose holder is gone. Age comes from the lock file itself, so a holder - // that is merely slow keeps refusing us rather than being evicted mid-write. - // - // The token is re-read and compared before the unlink: without it, a holder that - // released and a NEW holder that took the lock in the same instant would be evicted as - // if it were the abandoned one, and two writers would proceed from one base revision. - const abandoned = readLockToken(lockPath); - let ageMs: number | null = null; - try { ageMs = Date.now() - statSync(lockPath).mtimeMs; } catch { ageMs = null; } - if (ageMs !== null && ageMs > SERVICE_STATE_LOCK_STALE_MS) { - if (readLockToken(lockPath) === abandoned) { - try { unlinkSync(lockPath); } catch { /* another process broke it first */ } - } - continue; - } - throw new Error( - `another process is writing the service install state at ${anchor} and did not release ` - + `it within ${waitMs}ms; nothing was written. Re-run the command.`, - ); - } - } - // Identify the holder inside the file so neither eviction nor release can remove a lock - // some other process has since taken. - try { writeFileSync(fd, `${token}\n`, { encoding: "utf8" }); } catch { /* best-effort */ } - heldStateLocks.set(lockPath, { depth: 1, token }); - try { - return run(); - } finally { - try { closeSync(fd); } catch { /* best-effort */ } - releaseHeldLock(lockPath); - } -} +const SERVICE_STATE_SWAP_ATTEMPTS = 5; -function releaseHeldLock(lockPath: string): void { - const held = heldStateLocks.get(lockPath); - if (held === undefined) return; - held.depth -= 1; - if (held.depth > 0) return; - heldStateLocks.delete(lockPath); - // Remove OUR lock instance only. If the file on disk carries a different token, this - // holder was evicted as stale and someone else owns the pathname now; unlinking it would - // hand a third writer the lock while the second is still inside its critical section. - if (readLockToken(lockPath) !== held.token) return; - try { unlinkSync(lockPath); } catch { /* best-effort */ } -} - -/** One state path's record, or null when it is absent or unparseable. Throws if unreadable. */ -function readServiceInstallStateAt(path: string): ServiceInstallState | null { - const evidence = inspectServiceStateEvidence([path])[0]!; - // Unreadable is not absent. Treating EACCES as "no record" would compute a swap from an - // empty base and erase an ownership claim we were merely not allowed to look at. - if (evidence.kind === "unreadable") { - throw new Error( - `service install state at ${path} could not be read (${evidence.reason}), so its recorded ` - + "owner cannot be preserved; nothing was written. Fix the file's permissions and retry.", - ); +function authoritativeState( + paths: readonly string[], + unknownStateError?: (reason: string) => Error, +): { current: ServiceInstallState | null; revision: number; fingerprint: string } { + const selected = selectAuthoritativeServiceState( + inspectServiceStateEvidence(paths) as readonly ServiceStateRecordEvidence[], + ); + if (selected.kind === "unknown") { + throw unknownStateError?.(selected.reason) ?? new Error(`${selected.reason}; nothing was written`); } - // Invalid IS overwritten: there is no claim in an unparseable record to preserve. - return evidence.kind === "valid" ? evidence.state : null; + if (selected.kind === "none") return { current: null, revision: 0, fingerprint: "none" }; + const current = selected.state as ServiceInstallState; + return { current, revision: selected.revision, fingerprint: serviceStateFingerprint(current) }; } -/** - * Publish one state file, replacing it as a unit. - * - * An in-place write truncates first, so a kill, a power loss or a failed write between the - * truncate and the last byte leaves the anchor empty or half-serialized. That used to read - * back as "no install state"; since the reader became fail-closed it reads as `unknown`, - * which blocks `service start`, repair, restart and every update until the operator runs a - * takeover install. Writing a sibling temporary file and renaming it means the previous valid - * record survives an interrupted commit. - * - * The temporary file is hardened BEFORE the rename, not after: between rename and chmod the - * record would otherwise be readable at the default mode. - * - * Windows can refuse the replace while a scanner or another reader holds the destination - * open. That is transient, so it is retried briefly and then falls back to the in-place - * write — a narrow torn-write window is a better failure than an install that cannot record - * what it just registered. - */ -function commitServiceStateFile(path: string, serialized: string): void { - const dir = dirname(path); - recordOwnedConfigPath(getConfigDir(), path); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); - const staged = `${path}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`; - try { - writeFileSync(staged, serialized, { encoding: "utf8", mode: 0o600 }); - try { chmodSync(staged, 0o600); } catch { /* best-effort */ } - if (process.platform === "win32") hardenSecretPath(staged, { required: true }); - for (let attempt = 0; ; attempt += 1) { - try { - renameSync(staged, path); - return; - } catch { - if (attempt >= SERVICE_STATE_REPLACE_ATTEMPTS - 1) { - writeFileSync(path, serialized, { encoding: "utf8", mode: 0o600 }); - try { chmodSync(path, 0o600); } catch { /* best-effort */ } - if (process.platform === "win32") hardenSecretPath(path, { required: true }); - return; - } - Bun.sleepSync(SERVICE_STATE_REPLACE_RETRY_MS); - } - } - } finally { - // A rename that succeeded consumed the staged path; anything left is ours to clean up. - if (existsSync(staged)) { try { unlinkSync(staged); } catch { /* best-effort */ } } - } +function commitServiceStateFile(path: string, serialized: string, validate: () => void): void { + atomicWriteFileStreamed(path, descriptor => { + writeFileSync(descriptor, serialized, { encoding: "utf8" }); + }, { validateBeforeRename: validate }); } /** @@ -621,41 +474,79 @@ function commitServiceStateFile(path: string, serialized: string): void { * because two writers racing from one base both compute the same next revision — identical * bytes mean nothing was lost, and differing bytes mean something was. * - * WHAT THE LOCK IS. {@link withServiceStateLock} holds the anchor exclusively for the whole - * read-modify-write, because the revision check alone is not atomic: two processes can both - * pass it, both commit and both verify their own bytes, after which the second silently - * drops the first's mutation and reports success. The revision check remains the guard - * against a writer that does not take the lock, such as an older `ocx` on the same machine. + * The final path is the authority. With a custom home that is the legacy default-home path — + * the only path every writer can derive — and the active-home path is a compatibility mirror. + * The authority's atomic rename is the commit point. A mirror failure is reported but cannot + * roll back or reclassify the already committed mutation; the next writer repairs the mirror. */ export function swapServiceInstallState( - mutate: (current: ServiceInstallState | null) => ServiceInstallState | null, + mutate: (current: ServiceInstallState | null, context: ServiceStateMutationContext) => ServiceInstallState | null, deps: ServiceStateSwapDeps = {}, ): ServiceInstallState | null { const paths = deps.paths ?? serviceStateWritePaths(); - // The anchor is the first path, which is the state path for THIS OpenCodex home; - // `readServiceInstallState` reads the same list in the same order, so the record the - // swap compares against is the record every reader resolves. The remaining paths are - // legacy mirrors and receive a copy of whatever the anchor commits. - const anchor = paths[0]; - if (anchor === undefined) throw new Error("refusing to swap service install state with no state path"); + const authority = paths.at(-1); + if (authority === undefined) throw new Error("refusing to swap service install state with no state path"); + const mirrors = paths.filter(path => path !== authority); const attempts = deps.attempts ?? SERVICE_STATE_SWAP_ATTEMPTS; - return withServiceStateLock(anchor, () => { + const publish = deps.commitStateFile ?? commitServiceStateFile; + return withOwnershipMutationLease(paths, () => withServiceStateLocks(paths, () => { for (let attempt = 0; attempt < attempts; attempt += 1) { - const base = readServiceInstallStateAt(anchor); - const baseRevision = base?.revision ?? 0; - const candidate = mutate(base); - if (candidate === null) return base; - const next: ServiceInstallState = { ...candidate, revision: baseRevision + 1 }; + const base = authoritativeState(paths, deps.unknownStateError); + const candidate = mutate(base.current, { revision: base.revision }); + if (candidate === null) return base.current; + if (base.revision >= Number.MAX_SAFE_INTEGER) { + throw new Error("service state revision is exhausted; refusing to publish an unversioned mutation"); + } + const next: ServiceInstallState = { ...candidate, revision: base.revision + 1 }; const serialized = JSON.stringify(next, null, 2) + "\n"; deps.beforeCommit?.(attempt); - if ((readServiceInstallStateAt(anchor)?.revision ?? 0) !== baseRevision) continue; - for (const path of paths) commitServiceStateFile(path, serialized); - let committed: string | null = null; - try { committed = readFileSync(anchor, "utf8"); } catch { /* the comparison below decides */ } - if (committed === serialized) return next; + assertServiceStateLocksOwned(paths); + const fresh = authoritativeState(paths, deps.unknownStateError); + if (fresh.revision !== base.revision || fresh.fingerprint !== base.fingerprint) continue; + const validate = () => assertServiceStateLocksOwned(paths); + publish(authority, serialized, validate); + const committed = authoritativeState([authority]); + if (committed.revision !== next.revision || committed.fingerprint !== serviceStateFingerprint(next)) continue; + for (const mirror of mirrors) { + try { publish(mirror, serialized, validate); } + catch (error) { + (deps.onMirrorError ?? ((path, cause) => console.warn( + `service state committed, but compatibility mirror ${path} could not be refreshed: ${cause instanceof Error ? cause.message : String(cause)}`, + )))(mirror, error); + } + } + return next; } - throw new ServiceStateConflictError(anchor, attempts); - }, deps.lockWaitMs); + throw new ServiceStateConflictError(authority, attempts); + }, { waitMs: deps.lockWaitMs, hooks: deps.lockHooks }), deps.mutationLease); +} + +export interface RemoveServiceStateDeps { + readonly paths?: readonly string[]; + readonly unlink?: (path: string) => void; + readonly lockWaitMs?: number; + readonly lockHooks?: ServiceStateLockHooks; +} + +/** + * Delete mirrors first and the authority last under the same ownership locks. + * + * A crash or mirror error before the final unlink leaves the authority in place, so a stale + * mirror can never become a migration source and resurrect a released desktop claim. + */ +export function removeServiceInstallStateRecords(deps: RemoveServiceStateDeps = {}): void { + const paths = deps.paths ?? serviceStateWritePaths(); + const authority = paths.at(-1); + if (!authority) return; + const unlink = deps.unlink ?? unlinkSync; + withOwnershipMutationLease(paths, () => withServiceStateLocks(paths, () => { + for (const mirror of paths.slice(0, -1)) { + assertServiceStateLocksOwned(paths); + if (existsSync(mirror)) unlink(mirror); + } + assertServiceStateLocksOwned(paths); + if (existsSync(authority)) unlink(authority); + }, { waitMs: deps.lockWaitMs, hooks: deps.lockHooks }), { waitMs: deps.lockWaitMs }); } /** The recorded owner of ONE already-read record, or null. Prefer {@link resolveServiceOwnership}. */ @@ -672,18 +563,91 @@ export function serviceOwnership(state: ServiceInstallState | null = readService * exact demotion the record exists to prevent. Absence is the only thing that may mean no * claim. */ -export type ServiceOwnershipResolution = - | { readonly kind: "none" } - | { readonly kind: "owned"; readonly ownership: ServiceOwnership } +export type ServiceStateResolution = + | { readonly kind: "none"; readonly revision: 0; readonly needsRepair: false } + | { readonly kind: "state"; readonly state: ServiceInstallState; readonly revision: number; readonly needsRepair: boolean } | { readonly kind: "unknown"; readonly reason: string }; +export type ServiceOwnershipSubject = + | { readonly kind: "none"; readonly revision: number } + | { readonly kind: "owned"; readonly ownership: ServiceOwnership; readonly revision: number }; + +export type ServiceOwnershipResolution = ServiceOwnershipSubject + | { readonly kind: "unknown"; readonly reason: string }; + +export function resolveServiceState( + evidence: readonly ServiceStateEvidence[] = inspectServiceStateEvidence(), +): ServiceStateResolution { + const selected = selectAuthoritativeServiceState(evidence as readonly ServiceStateRecordEvidence[]); + if (selected.kind === "unknown") return selected; + if (selected.kind === "none") return selected; + return { + kind: "state", + state: selected.state as ServiceInstallState, + revision: selected.revision, + needsRepair: selected.needsRepair, + }; +} + export function resolveServiceOwnership( evidence: readonly ServiceStateEvidence[] = inspectServiceStateEvidence(), ): ServiceOwnershipResolution { - // The resolution rule is the shared contract's, for the same reason the record contract is: - // the Node launcher decides this question too, and a weaker copy there is an authorization - // gap rather than a style problem. - return resolveOwnershipFromEvidence(evidence) as unknown as ServiceOwnershipResolution; + const state = resolveServiceState(evidence); + if (state.kind === "unknown") return state; + if (state.kind === "none" || !state.state.ownership) return { kind: "none", revision: state.revision }; + return { kind: "owned", ownership: state.state.ownership, revision: state.revision }; +} + +export function sameServiceOwnershipSubject( + left: ServiceOwnershipSubject, + right: ServiceOwnershipSubject, +): boolean { + if (left.kind !== right.kind || left.revision !== right.revision) return false; + if (left.kind === "none" || right.kind === "none") return true; + return left.ownership.owner === right.ownership.owner + && left.ownership.installId === right.ownership.installId + && left.ownership.consentGeneration === right.ownership.consentGeneration; +} + +function sameServiceOwnershipIdentity(left: ServiceOwnershipSubject, right: ServiceOwnershipSubject): boolean { + if (left.kind !== right.kind) return false; + if (left.kind === "none" || right.kind === "none") return true; + return left.ownership.owner === right.ownership.owner + && left.ownership.installId === right.ownership.installId + && left.ownership.consentGeneration === right.ownership.consentGeneration; +} + +function serviceOwnershipSubject( + state: ServiceInstallState | null, + revision: number, +): ServiceOwnershipSubject { + return state?.ownership + ? { kind: "owned", ownership: state.ownership, revision } + : { kind: "none", revision }; +} + +export class ServiceOwnershipSubjectMismatchError extends Error { + readonly code = "service-ownership-subject-mismatch" as const; + constructor(readonly expected: ServiceOwnershipSubject, readonly actual: ServiceOwnershipSubject) { + super("service ownership changed after consent; resolve again and ask for fresh approval"); + this.name = "ServiceOwnershipSubjectMismatchError"; + } +} + +export class ServiceOwnershipSubjectUnknownError extends Error { + readonly code = "service-ownership-subject-unknown" as const; + constructor(readonly expected: ServiceOwnershipSubject, readonly reason: string) { + super(`service ownership could not be revalidated after consent (${reason}); nothing was written`); + this.name = "ServiceOwnershipSubjectUnknownError"; + } +} + +export class ServiceTakeoverCompatibilityChangedError extends Error { + readonly code = "service-takeover-compatibility-changed" as const; + constructor(readonly actual: ServiceTakeoverCompatibility) { + super("the managing CLI compatibility changed after consent; resolve again and ask for fresh approval"); + this.name = "ServiceTakeoverCompatibilityChangedError"; + } } /** @@ -742,21 +706,65 @@ function ownershipBaseRecord(current: ServiceInstallState | null): ServiceInstal * generation alone, so every relaunch of an app that already has consent is a no-op on the * number. A different owner or a different install id is a new grant and increments it once. */ +export interface RecordServiceOwnerRequest { + readonly owner: ServiceOwner; + readonly installId: string; + readonly expectedSubject: ServiceOwnershipSubject; + readonly expectedCompatibility: Extract; +} + +export interface RecordServiceOwnerDeps extends ServiceStateSwapDeps { + /** Re-observes BOTH the registered manager and the current PATH manager inside the lock. */ + readonly observeManagers: () => Readonly>; +} + export function recordServiceOwner( - claim: { owner: ServiceOwner; installId: string }, - deps: ServiceStateSwapDeps = {}, -): ServiceOwnership { - if (!claim.installId) throw new Error("refusing to record service ownership without an install id"); + request: RecordServiceOwnerRequest, + deps: RecordServiceOwnerDeps, +): Extract { + if (!request.installId) throw new Error("refusing to record service ownership without an install id"); + if (!request.expectedSubject || request.expectedCompatibility?.kind !== "supported") { + throw new Error("refusing to record service ownership without the exact approved subject and compatibility token"); + } + if (!deps || typeof deps.observeManagers !== "function") { + throw new Error("refusing to record service ownership without a managing-CLI revalidation callback"); + } + const { observeManagers, ...swapDeps } = deps; let recorded: ServiceOwnership | null = null; - swapServiceInstallState(current => { + const committed = swapServiceInstallState((current, context) => { + const actualSubject = serviceOwnershipSubject(current, context.revision); + if (!sameServiceOwnershipSubject(request.expectedSubject, actualSubject)) { + throw new ServiceOwnershipSubjectMismatchError(request.expectedSubject, actualSubject); + } + let managers: Readonly>; + try { managers = observeManagers(); } + catch (error) { + throw new ServiceTakeoverCompatibilityChangedError({ + kind: "blocked", + reason: "managing-cli-unknown", + detail: error instanceof Error ? error.message : String(error), + minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, + }); + } + const compatibility = assessServiceTakeoverCompatibility({ + state: current, + subject: actualSubject, + managers, + }); + if (!sameServiceTakeoverCompatibility(request.expectedCompatibility, compatibility)) { + throw new ServiceTakeoverCompatibilityChangedError(compatibility); + } const previous = current?.ownership ?? null; // The ceiling, not just the live claim: a grant that was released left its number // behind on purpose, so a later grant cannot reuse it. const floor = Math.max(previous?.consentGeneration ?? 0, current?.consentGenerationCeiling ?? 0); + if (floor >= Number.MAX_SAFE_INTEGER) { + throw new Error("service ownership consent generation is exhausted; nothing was written"); + } recorded = { - owner: claim.owner, - installId: claim.installId, - consentGeneration: previous && ownershipGrantedTo(previous, claim.owner, claim.installId) + owner: request.owner, + installId: request.installId, + consentGeneration: previous && ownershipGrantedTo(previous, request.owner, request.installId) ? previous.consentGeneration : floor + 1, }; @@ -765,9 +773,14 @@ export function recordServiceOwner( ownership: recorded, consentGenerationCeiling: Math.max(floor, recorded.consentGeneration), }; - }, deps); - if (recorded === null) throw new Error("service ownership was not recorded"); - return recorded; + }, { + ...swapDeps, + unknownStateError: reason => new ServiceOwnershipSubjectUnknownError(request.expectedSubject, reason), + }); + if (recorded === null || !committed?.ownership || committed.revision === undefined) { + throw new Error("service ownership was not recorded"); + } + return { kind: "owned", ownership: committed.ownership, revision: committed.revision }; } /** @@ -776,9 +789,23 @@ export function recordServiceOwner( * Writes nothing when there is no claim to release, so asking about an unowned runtime never * creates an install record describing a service nobody registered. */ -export function releaseServiceOwner(deps: ServiceStateSwapDeps = {}): ServiceOwnership | null { +export interface ReleaseServiceOwnerDeps extends ServiceStateSwapDeps { + /** Service install refreshes provenance first; that known write may advance only revision. */ + readonly allowRevisionAdvance?: boolean; +} + +export function releaseServiceOwner( + expectedSubject: ServiceOwnershipSubject, + deps: ReleaseServiceOwnerDeps = {}, +): ServiceOwnership | null { + const { allowRevisionAdvance = false, ...swapDeps } = deps; let released: ServiceOwnership | null = null; - swapServiceInstallState(current => { + swapServiceInstallState((current, context) => { + const actualSubject = serviceOwnershipSubject(current, context.revision); + const matches = allowRevisionAdvance + ? sameServiceOwnershipIdentity(expectedSubject, actualSubject) && actualSubject.revision >= expectedSubject.revision + : sameServiceOwnershipSubject(expectedSubject, actualSubject); + if (!matches) throw new ServiceOwnershipSubjectMismatchError(expectedSubject, actualSubject); released = current?.ownership ?? null; if (!current?.ownership) return null; const { ownership: _released, ...withoutOwnership } = current; @@ -792,7 +819,10 @@ export function releaseServiceOwner(deps: ServiceStateSwapDeps = {}): ServiceOwn current.ownership.consentGeneration, ), }; - }, deps); + }, { + ...swapDeps, + unknownStateError: reason => new ServiceOwnershipSubjectUnknownError(expectedSubject, reason), + }); return released; } @@ -806,20 +836,15 @@ export type ServiceStateEvidence = /** * Every state path, with what each one said. * - * `readServiceInstallState` returns the FIRST path that parsed and discards the - * rest, so a valid mirror beside a corrupt one reads as clean. That is the right - * behavior for callers that just need the install state; it is the wrong input - * for deciding ownership, where a disagreement between mirrors is exactly the - * evidence that matters. + * The final path is authoritative; earlier paths are compatibility mirrors and the + * migration source only while the authority is absent. Keeping the raw evidence separate + * lets the selector distinguish migration, degraded mirrors and unordered conflicts. */ export function inspectServiceStateEvidence( paths: readonly string[] = serviceStatePaths(), ): readonly ServiceStateEvidence[] { - // ENOENT is an answer. EACCES, ENOTDIR and the rest are a failure to ask, and collapsing - // them into absence is how a locked-down state file would become permission to write. - // The classification is the shared contract's, so the launcher makes the same call. return paths.map(path => ( - inspectInstallStateBytes(path, at => readFileSync(at, "utf8")) as unknown as ServiceStateEvidence + inspectInstallStateBytes(path, at => readFileSync(at, "utf8")) as ServiceStateEvidence )); } diff --git a/src/update/index.ts b/src/update/index.ts index 17ffa6dadaf..a8af29661dc 100644 --- a/src/update/index.ts +++ b/src/update/index.ts @@ -11,6 +11,7 @@ import { readPid, readRuntimePort } from "../config/process-state"; import { pendingTeardownOutstanding } from "../config/pending-teardown"; import type { ServiceOwnership } from "../service/state"; import { planUpdateRuntimeHandling } from "./runtime-ownership.mjs"; +import { acquireOwnershipMutationLease } from "../service/ownership-mutation-lease.mjs"; import { npmInvocation } from "./npm-invocation.mjs"; import { pnpmInvocation, pnpmInvocationForPath, resolvePnpmCommands } from "./pnpm-invocation.mjs"; import { detectInstallFromPath } from "./install-detection.mjs"; @@ -326,14 +327,29 @@ export function checkUpdatePackageIntegrity( * the runtime, and treating it as such is how an unreadable record reactivates the npm * launcher over a takeover the user consented to. */ -async function resolvedRuntimeOwnership(): Promise<{ ownership: ServiceOwnership | null; ownershipUnknown: boolean }> { +interface RuntimeOwnershipObservation { + readonly ownership: ServiceOwnership | null; + readonly ownershipUnknown: boolean; + readonly subjectToken: string; +} + +async function resolvedRuntimeOwnership(): Promise { try { const { resolveServiceOwnership } = await import("../service"); const resolution = resolveServiceOwnership(); - if (resolution.kind === "owned") return { ownership: resolution.ownership, ownershipUnknown: false }; - return { ownership: null, ownershipUnknown: resolution.kind === "unknown" }; + if (resolution.kind === "owned") return { + ownership: resolution.ownership, + ownershipUnknown: false, + subjectToken: JSON.stringify(["owned", resolution.revision, resolution.ownership]), + }; + if (resolution.kind === "none") return { + ownership: null, + ownershipUnknown: false, + subjectToken: JSON.stringify(["none", resolution.revision]), + }; + return { ownership: null, ownershipUnknown: true, subjectToken: "unknown" }; } catch { - return { ownership: null, ownershipUnknown: true }; + return { ownership: null, ownershipUnknown: true, subjectToken: "unknown" }; } } @@ -404,11 +420,16 @@ export async function runUpdate(): Promise { } catch { /* best-effort */ } // What this update may do to the runtime. A desktop takeover vetoes both the stop and the // service refresh below; see `planUpdateRuntimeHandling` for why each half is wrong. - let runtimePlan = planUpdateRuntimeHandling({ - ...(await resolvedRuntimeOwnership()), + const initialOwnership = await resolvedRuntimeOwnership(); + const runtimePlan = planUpdateRuntimeHandling({ + ...initialOwnership, serviceInstalled: serviceWasInstalled, }); if (runtimePlan.notice) console.log(runtimePlan.notice); + if (!runtimePlan.mayReplacePackage) { + console.error("⚠️ Update stopped before tray handoff, runtime stop, or package replacement because runtime ownership is unknown."); + process.exit(1); + } let trayWasInstalled = false; let trayWasRunning = false; if (process.platform === "win32") { @@ -456,20 +477,7 @@ export async function runUpdate(): Promise { // silently skips the recovery the receipt was written to trigger (#3008). // Full `ocx stop` semantics (drain, service stop, restore). let stopAttempted = false; - // Re-read at the point of action rather than trusting the plan formed above. Between the - // two the Windows tray handoff spawns children and the listen target is captured, so a - // takeover can land in between — and stopping a runtime that just changed hands is the - // failure this lane exists to prevent. Reassigning the one variable keeps the recovery - // branches and the restart hint reading the same decision as the stop. - { - const atStop = planUpdateRuntimeHandling({ - ...(await resolvedRuntimeOwnership()), - serviceInstalled: serviceWasInstalled, - }); - if (atStop.notice && atStop.notice !== runtimePlan.notice) console.log(atStop.notice); - runtimePlan = atStop; - } - if (runtimePlan.stopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())) { + if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding())) { stopAttempted = true; console.log("⏹ Stopping the running proxy before updating..."); const stopStdio = updateChildStdio(); @@ -530,26 +538,45 @@ export async function runUpdate(): Promise { } } - console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`); - const installStdio = updateChildStdio(); - // Every post-update action below receives this path. For pnpm it is replaced only - // by a path returned after tree+shim verification; on rollback, activePath is - // likewise returned only after the old group has been verified again. - let postUpdateLauncher = join(packageRoot(), "bin", "ocx.mjs"); - // The pnpm owner preflight has verified this package tree and global group. Keep that exact - // package path as the recovery starting point; the path returned by the update transaction - // replaces it only after post-update tree+shim verification succeeds. - if (installer === "pnpm" && owner) { - postUpdateLauncher = join(owner.packagePath, "bin", "ocx.mjs"); - } + let postUpdateLauncher = installer === "pnpm" && owner + ? join(owner.packagePath, "bin", "ocx.mjs") + : join(packageRoot(), "bin", "ocx.mjs"); let postUpdateLauncherUsable = true; let r: { status: number | null; signal?: NodeJS.Signals | null; stdout?: string | Buffer | null; stderr?: string | Buffer | null; - }; + } | null = null; + const { serviceStatePaths } = await import("../service"); + const replacementLease = acquireOwnershipMutationLease(serviceStatePaths()); + let replacementRefusal: string | null = null; + try { + // Ownership can change while registry and stop work is in flight. Unknown at this exact + // boundary blocks replacement; a confirmed desktop claim still permits updating the idle + // npm installation while leaving the bundled sidecar alone. + const replacementOwnership = await resolvedRuntimeOwnership(); + const replacementPlan = planUpdateRuntimeHandling({ + ...replacementOwnership, + serviceInstalled: serviceWasInstalled, + }); + const replacementLiveness = runtimePlan.mayStopRuntime + ? (await proxyIdentityAt(capturedListen.port, { hostname: capturedListen.hostname }) + ? "live" + : probeProxyLiveness(capturedListen.port, capturedListen.hostname)) + : "dead"; + if (replacementOwnership.subjectToken !== initialOwnership.subjectToken + || !replacementPlan.mayReplacePackage + || replacementLiveness !== "dead") { + replacementRefusal = replacementPlan.notice + ?? (replacementLiveness === "live" + ? "⚠️ Update stopped because a proxy became live after the stop decision; rerun from the beginning." + : "⚠️ Update stopped because runtime ownership or liveness changed after the stop decision; rerun from the beginning."); + } else { + console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`); + + // Every post-update action below receives the verified active launcher. if (installer === "pnpm") { let update: ReturnType; try { @@ -597,7 +624,26 @@ export async function runUpdate(): Promise { ...target.options, }); } - if (installStdio === "pipe") logSpawnOutput("", r); + if (r && installStdio === "pipe") logSpawnOutput("", r); + } + } finally { + replacementLease.release(); + } + if (replacementRefusal) { + if (trayWasRunning) { + try { + const { startWindowsTray } = await import("../tray/windows"); + startWindowsTray(); + } catch { /* preserve the ownership refusal */ } + } + console.error(replacementRefusal); + process.exit(1); + } + if (!r) throw new Error("update replacement returned no result"); + const postInstallPlan = planUpdateRuntimeHandling({ + ...(await resolvedRuntimeOwnership()), + serviceInstalled: serviceWasInstalled, + }); if (r.status === 0) { console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`); // Re-enter through the verified active package launcher. This keeps the Codex @@ -628,7 +674,7 @@ export async function runUpdate(): Promise { // The stop above unloaded any managed service; repair it with the NEW files // (spawn the fresh cli.ts so updated code writes the baked paths) so a // launchd/schtasks/systemd user isn't left with the background proxy down. - if (runtimePlan.refreshService) { + if (postInstallPlan.mayRestoreService) { console.log("🔁 Refreshing the background service with the updated files..."); const { serviceReinstallArgs } = await import("../service"); const { reclaimListenPort } = await import("../server/port-reclaim"); @@ -685,7 +731,7 @@ export async function runUpdate(): Promise { ...(await resolvedRuntimeOwnership()), serviceInstalled: true, }); - if (!nowOwned.stopRuntime) { + if (!nowOwned.mayStopRuntime) { console.warn(nowOwned.notice ?? "⚠️ The background runtime is owned elsewhere; not starting a second proxy."); return; } @@ -716,20 +762,20 @@ export async function runUpdate(): Promise { if (prevBake === undefined) delete process.env.OCX_BAKE_PORT; else process.env.OCX_BAKE_PORT = prevBake; } - } else if (runtimePlan.stopRuntime) { + } else if (postInstallPlan.mayStopRuntime) { console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, capturedListen.port)}`); } } else { if (stopAttempted && trayWasRunning && postUpdateLauncherUsable) { spawnSync(process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true }); } - if (stopAttempted && runtimePlan.refreshService && postUpdateLauncherUsable) { + if (stopAttempted && postInstallPlan.mayRestoreService && postUpdateLauncherUsable) { const service = spawnSync(process.execPath, [postUpdateLauncher, "service", "repair"], { stdio: "inherit", windowsHide: true, }); if (service.status !== 0) console.warn("⚠️ Previous background service could not be restored; run 'ocx service repair'."); - } else if (stopAttempted && postUpdateLauncherUsable) { + } else if (stopAttempted && postUpdateLauncherUsable && postInstallPlan.mayStopRuntime) { const env = { ...process.env }; delete env.OCX_SERVICE; const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(capturedListen.port)], { @@ -739,6 +785,8 @@ export async function runUpdate(): Promise { env: withProcessRuntimeProvenance(env), }); child.unref(); + } else if (stopAttempted && !postInstallPlan.mayStopRuntime) { + console.warn(postInstallPlan.notice ?? "⚠️ Runtime ownership changed during the update; not starting a second proxy."); } else if (stopAttempted) { console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); } diff --git a/src/update/job.ts b/src/update/job.ts index d3ce29ec9be..dd8e47748e5 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -26,7 +26,7 @@ import { listListenPids, reclaimListenPort, scanListenPids, type ListenPidScan } import { dropWindowsTcpRowsForLocalPort } from "../server/windows-tcp-drop"; import { isOpencodexHealthz, probeHostname, proxyIdentityAt, type HealthzIdentity } from "../server/proxy-liveness"; import { isServiceInstalled, isServiceViable, readServiceBackend, stopWindows } from "../service"; -import { updateRestartVeto, type ServiceOwnershipResolution } from "./restart-ownership"; +import { runUpdateRestartWithOwnershipLease, type ServiceOwnershipResolution } from "./restart-ownership"; import { type Channel, type Installer, @@ -1970,15 +1970,12 @@ export async function runGuiUpdateWorker( } if (restart) { - // The package updater it just ran deliberately left a foreign-owned runtime alone, and - // restarting here would replace the app's sidecar with an npm proxy. - const veto = updateRestartVeto(io.resolveOwnershipFn); - if (veto) { updateJob(job, { status: "succeeded", restarted: false }, veto); return; } - job = updateJob(job, { status: "restarting" }, "Update installed. Restarting proxy..."); - if (!(await finishGuiUpdateRestart(job, captured, check.installer, { - ...io.restartIo, - packageLauncherPathFn: () => activeLauncher, - }))) return; + const outcome = await runUpdateRestartWithOwnershipLease(io.resolveOwnershipFn, async () => { + job = updateJob(job!, { status: "restarting" }, "Update installed. Restarting proxy..."); + return finishGuiUpdateRestart(job!, captured, check.installer, { ...io.restartIo, packageLauncherPathFn: () => activeLauncher }); + }); + if (outcome.kind === "veto") { updateJob(job, { status: "succeeded", restarted: false }, outcome.notice); return; } + if (!outcome.value) return; updateJob(job, { status: "succeeded", restarted: true }, "Restart requested and proxy is healthy."); return; } diff --git a/src/update/restart-ownership.ts b/src/update/restart-ownership.ts index 7e0471e379d..32cafee1e6e 100644 --- a/src/update/restart-ownership.ts +++ b/src/update/restart-ownership.ts @@ -1,5 +1,10 @@ import { resolveServiceOwnership } from "../service"; import type { ServiceOwnershipResolution } from "../service"; +import { serviceStatePaths } from "../service/state"; +import { + acquireOwnershipMutationLease, + OWNERSHIP_MUTATION_LEASE_TOKEN_ENV, +} from "../service/ownership-mutation-lease.mjs"; import { planUpdateRuntimeHandling } from "./runtime-ownership.mjs"; export type { ServiceOwnershipResolution }; @@ -27,6 +32,23 @@ export function updateRestartVeto( // The restart decision does not refresh the service; only the stop veto is read here. serviceInstalled: false, }); - if (plan.stopRuntime) return null; + if (plan.mayStopRuntime) return null; return plan.notice ?? "The background runtime is owned elsewhere; it was left running."; } + +export async function runUpdateRestartWithOwnershipLease( + resolve: (() => ServiceOwnershipResolution) | undefined, + restart: () => Promise, +): Promise<{ readonly kind: "veto"; readonly notice: string } | { readonly kind: "ran"; readonly value: T }> { + const lease = acquireOwnershipMutationLease(serviceStatePaths()); + const previous = process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV] = lease.token; + try { + const veto = updateRestartVeto(resolve); + return veto ? { kind: "veto", notice: veto } : { kind: "ran", value: await restart() }; + } finally { + if (previous === undefined) delete process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + else process.env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV] = previous; + lease.release(); + } +} diff --git a/src/update/runtime-ownership.d.mts b/src/update/runtime-ownership.d.mts index cc9b74fd853..12762895238 100644 --- a/src/update/runtime-ownership.d.mts +++ b/src/update/runtime-ownership.d.mts @@ -1,10 +1,10 @@ -/** Declaration for the plain-ESM runtime-ownership rule shared with `bin/ocx.mjs`. */ export declare function planUpdateRuntimeHandling(input: { ownership: { owner: string; installId: string; consentGeneration: number } | null; ownershipUnknown?: boolean; serviceInstalled: boolean; }): { - stopRuntime: boolean; - refreshService: boolean; + mayReplacePackage: boolean; + mayStopRuntime: boolean; + mayRestoreService: boolean; notice: string | null; }; diff --git a/src/update/runtime-ownership.mjs b/src/update/runtime-ownership.mjs index 0e7fba1341f..432f223bc73 100644 --- a/src/update/runtime-ownership.mjs +++ b/src/update/runtime-ownership.mjs @@ -7,17 +7,6 @@ * situation separately is how a fix ships on one side only. */ -/* - * There is deliberately no ownership PARSER here any more. - * - * This module used to carry one so the Node launcher could read the record without importing - * TypeScript, kept "in step" with the authoritative reader by a test that drove the same - * shapes through both. It was not in step: it inspected one path and treated a record that - * failed the whole install-state contract as an unowned runtime whenever its `ownership` - * field was simply absent. Reading and resolving now live in - * `src/service/install-state-contract.mjs`, which both runtimes import, so there is one - * algorithm rather than two that a test has to keep aligned. - */ /** * Decide how an update treats a runtime it may not own. * @@ -38,25 +27,24 @@ * two-record ownership design accepted; `ocx service install` clears the marker and restores * the ordinary path. * - * Both returned flags are VETOES, not commands: each updater already has its own reasons to - * stop the proxy and to refresh the service, and this plan can only take them away. + * The three returned flags are separate authorities, not commands. In particular, leaving + * a runtime running is not permission to replace the package it may be executing from. * - * THE LIMIT OF THIS RULE. It reads the recorded claim, not the live process. If the app was - * deleted and the user then starts an npm proxy by hand, the stale claim still vetoes the - * stop and the update replaces package files under a live server. Proving WHICH runtime is - * answering needs the identity the bundled CLI's resolve contract will carry; until then the - * notice tells the user how to clear the marker. + * A recorded desktop claim does not prove which binary is live. Until the bundled resolver + * carries installation identity, it therefore blocks package replacement as well as stop and + * restoration; the notice tells a stale-marker user how to take ownership back explicitly. * * @param {{ ownership: { owner: string, installId: string, consentGeneration: number } | null, ownershipUnknown?: boolean, serviceInstalled: boolean }} input - * @returns {{ stopRuntime: boolean, refreshService: boolean, notice: string | null }} + * @returns {{ mayReplacePackage: boolean, mayStopRuntime: boolean, mayRestoreService: boolean, notice: string | null }} */ export function planUpdateRuntimeHandling({ ownership, ownershipUnknown = false, serviceInstalled }) { // Unreadable, malformed or contradictory is not "nobody owns it". Reading it that way is // how a permissions error reactivates the npm launcher over a consented takeover. if (ownershipUnknown) { return { - stopRuntime: false, - refreshService: false, + mayReplacePackage: false, + mayStopRuntime: false, + mayRestoreService: false, notice: "⚠️ The background runtime's recorded owner could not be determined, so it was " + "left running and the service registration was not touched. " + "Run 'ocx service install' to re-register the service and take the runtime back.", @@ -67,13 +55,21 @@ export function planUpdateRuntimeHandling({ ownership, ownershipUnknown = false, // registration. if (ownership && ownership.owner !== "cli") { return { - stopRuntime: false, - refreshService: false, + // A claim does not prove which binary is live. A stale desktop marker beside a + // manually started npm proxy would otherwise replace that proxy's executing files. + mayReplacePackage: false, + mayStopRuntime: false, + mayRestoreService: false, notice: `🖥️ The desktop app owns the background runtime (install ${ownership.installId}, ` - + `consent generation ${ownership.consentGeneration}). It was left running, and the ` + + `consent generation ${ownership.consentGeneration}). It and the npm package were left unchanged, and the ` + "service registration was neither re-enabled nor restarted. " + "If the desktop app is gone, run 'ocx service install' to take the runtime back.", }; } - return { stopRuntime: true, refreshService: serviceInstalled, notice: null }; + return { + mayReplacePackage: true, + mayStopRuntime: true, + mayRestoreService: serviceInstalled, + notice: null, + }; } diff --git a/structure/runtime.md b/structure/runtime.md index 438e95547c6..e975d9129ea 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -530,64 +530,49 @@ opaque `installId` naming the owning installation rather than the user or the ma `consentGeneration`. An absent claim means the CLI install that registered the service owns the runtime, which is what every record written before the field existed says. -`src/service/install-state-contract.mjs` holds the record shape, the path list and the -resolution rule, and both runtimes import it: `src/service/state.ts` and the Node launcher -`bin/ocx.mjs`, which cannot import TypeScript. The launcher previously kept its own reader, -and the divergence was an authorization gap rather than a style problem — it inspected only -the anchor path and answered "unowned" for any record whose `ownership` field was absent, -including one that failed the contract outright. - -Every write goes through `swapServiceInstallState`. It holds an `O_EXCL` lock beside the -anchor record for the whole read-modify-write, re-reads the anchor immediately before -committing and compares the committed bytes afterwards, and it runs the whole sequence again -when another writer landed inside that window; `revision` is the compare-and-swap token. The -lock excludes cooperating writers, and the revision check catches a writer that does not take -it, such as an older `ocx` on the same machine. The lock file carries a token identifying its -holder, so eviction and release each remove only the instance they own, and the stale -threshold exceeds the longest legitimate critical section rather than the typical one. Each -file is published by writing a sibling temporary file and renaming it, so an interrupted -commit leaves the previous valid record rather than a truncated one the fail-closed reader -would report as unknown. - -`writeServiceInstallState` rebuilds only the install provenance and carries the ownership -claim across unchanged, which is what keeps an install, a repair, an update or a stop from -dropping it. It resolves that claim INSIDE the swap, while the lock is held: a resolution -taken beforehand is a lost update the compare-and-swap cannot detect, because the stale value -never came from the base record. Where the anchor and the cross-path resolution still -disagree, the higher `consentGeneration` wins and an equal generation keeps the anchor. - -`resolveServiceOwnership` is how a claim is read for a decision. It reads every state path -and answers `none`, `owned` or `unknown`; absence is the only thing that means no claim, so -an unreadable path, a corrupt anchor record, or paths naming different owners all refuse -rather than reading as CLI-owned. `consentGenerationCeiling` survives a release, so granting, -releasing and granting again cannot reuse a number an app-local record may still hold. - -`recordServiceOwner` is idempotent on the same owner and install id, so a relaunch leaves the -generation alone and a grant moves it exactly once. -`ownershipGrantedTo(ownership, owner, installId)` is the comparison an installation applies -to its own locally stored install id: true means this installation already holds consent, -false against a recorded claim means a different installation owns the runtime and consent -has to be asked again, and a null claim means the CLI install still owns it. - -The verbs that ACTIVATE the npm registration refuse on a foreign or unknown owner: +Every write goes through `swapServiceInstallState`. With a custom home, the default-home +record is the authority every writer can derive and the active-home record is a compatibility +mirror; with one path, that path is authoritative. `src/service/state-lock.ts` holds +token/PID/process-instance locks for every path in canonical order. A live holder is never +evicted because of age, and release deletes only its token-named owner. The authoritative +file is fsynced and atomically renamed through `src/config/atomic-write.ts`; that rename is +the commit point. Mirrors receive the exact committed bytes afterwards. A mirror failure is +diagnostic rather than rollback, and the next writer repairs it. An absent authority imports +one valid legacy mirror once; same-or-newer mirror disagreement and unreadable authority are +`unknown`, never ownership votes. Uninstall removes mirrors before the authority, so a +partial deletion cannot turn a revoked mirror claim back into migration input. + +`resolveServiceOwnership` answers `none`, `owned` or `unknown` from that authoritative +generation. `consentGenerationCeiling` survives a release, so granting, releasing and +granting again cannot reuse a number an app-local record may still hold. +`recordServiceOwner` requires the exact `owner`/`installId`/`consentGeneration`/`revision` +subject shown on the consent surface. The comparison runs again inside the same lock and on +every internal retry; a mismatch or unknown subject writes nothing and requires fresh user +approval. `ownershipGrantedTo` remains the narrower relaunch test for an already-owned app. + +Permanent takeover also requires `assessServiceTakeoverCompatibility` to approve both the +preserved service launcher and the selected PATH launcher. Every observed manager must be +OpenCodex 2.61.0 or later, and a preserved registration must carry ownership protocol 1. +Missing, old, malformed or unknown manager evidence blocks takeover and leaves registration +and autostart untouched. The supported verdict carries an opaque token over the approved +subject and both manager identities; `recordServiceOwner` re-observes and compares it inside +the lock, so a mutable shim or downgrade cannot inherit earlier consent. An upgrade is a +separate user-authorized action; declining or failing it leaves the app a guest. + +The verbs that activate the npm registration refuse on a foreign or unknown owner: `src/service/repair.ts` stops before it asserts, writes, stops or starts anything, and `ocx service start` reports the same refusal. `stop` and `uninstall` are not gated, because they deactivate. `src/update/runtime-ownership.mjs` vetoes both the pre-update stop and the post-update service refresh for all three update lanes — `src/update/index.ts`, -`bin/ocx.mjs` and the dashboard worker in `src/update/job.ts` — and the two package updaters -re-read the claim immediately before each runtime action — the stop and the direct-start -fallback — rather than trusting a plan formed earlier in the run, because an app can take the -runtime while the tray handoff spawns children or an install runs for minutes. The -registration is never deleted; `ocx service install` is the one verb that releases the -marker, and it does so only after the registration succeeded. - -The veto reads the recorded claim, not the live process. An app removed without releasing -leaves a stale claim, and proving which runtime is answering needs the identity the bundled -CLI's resolve contract will carry; until then the refusals name `ocx service install` as the -way to clear it. - -Re-reading narrows the window between a decision and its action; it does not remove it. A -claim recorded after the last read and before the child process starts is still acted on with -stale information. Closing that needs an action-scoped ownership lease held across the child, -which the state lock deliberately is not — holding it across `ocx stop` or a service refresh -would deadlock against the child's own write. +`bin/ocx.mjs` and the dashboard worker in `src/update/job.ts`. The shared update decision has +three independent authorities: package replacement, runtime stop and service restoration. +Unknown and desktop ownership deny all three because a claim alone does not prove that the live +process is detached from the npm package; CLI ownership permits the ordinary stop-first +flow. Both package updaters use `src/service/install-state-contract.mjs`, backed by the single +`state-record.mjs` parser and authority selector. A shared mutation lease covers the final subject/liveness +recheck through package replacement, and service install/start plus every state mutation take +the same lease, so the stop decision cannot go stale under a cooperating manager. Dashboard +restart keeps that lease through port reclaim and delegates its token to the repair child; +direct start takes it again for the final liveness check through successful bind. +The registration is never deleted; `ocx service install` releases the marker only after the +registration succeeds. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index a7a7fcee72f..d08b15a92d9 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1208,6 +1208,7 @@ "server-xai-header-parity.test.ts": "server", "server-xai-oauth-401-replay.test.ts": "server", "server-xai-responses-streaming.test.ts": "server", + "service-ownership-compatibility.test.ts": "service", "service-ownership-handover.test.ts": "service", "service-ownership-state.test.ts": "service", "service-probe-docker.test.ts": "service", diff --git a/tests/service/service-ownership-compatibility.test.ts b/tests/service/service-ownership-compatibility.test.ts new file mode 100644 index 00000000000..6a216aa3be3 --- /dev/null +++ b/tests/service/service-ownership-compatibility.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { + assessServiceTakeoverCompatibility, + registeredManagingCliInvocation, + type ManagingCliObservation, +} from "../../src/service/ownership-compatibility"; +import type { ServiceInstallState, ServiceOwnershipSubject } from "../../src/service/state"; + +const SUBJECT: ServiceOwnershipSubject = { kind: "none", revision: 7 }; +const OWNERSHIP_AWARE_STATE: ServiceInstallState = { + version: 2, + codexHome: "/codex", + opencodexHome: "/opencodex", + backend: "scheduler", + revision: 7, + ownershipProtocolVersion: 1, +}; + +const absent: ManagingCliObservation = { status: "absent" }; +const observed = (version: string, identity = `manager-${version}`): ManagingCliObservation => ({ + status: "observed", version, identity, +}); + +function assess(options: { + state?: ServiceInstallState | null; + service?: ManagingCliObservation; + path?: ManagingCliObservation; +} = {}) { + return assessServiceTakeoverCompatibility({ + state: options.state === undefined ? OWNERSHIP_AWARE_STATE : options.state, + subject: SUBJECT, + managers: { + "service-registration": options.service ?? observed("2.61.0", "registered-manager"), + path: options.path ?? observed("2.61.0", "path-manager"), + }, + }); +} + +describe("permanent takeover compatibility", () => { + test("the preserved registration resolves to the exact baked invocation", () => { + expect(registeredManagingCliInvocation({ + ...OWNERSHIP_AWARE_STATE, + bunPath: "/runtime/bun", + cliPath: "/package/src/cli/index.ts", + })).toEqual({ + status: "resolved", executable: "/runtime/bun", args: ["/package/src/cli/index.ts"], + }); + expect(registeredManagingCliInvocation({ + ...OWNERSHIP_AWARE_STATE, + launcherPath: "/bin/ocx", + })).toEqual({ status: "resolved", executable: "/bin/ocx", args: [] }); + }); + + test("every managing CLI must be ownership-aware", () => { + expect(assess({ path: observed("2.60.0") })).toMatchObject({ + kind: "blocked", reason: "managing-cli-unsupported", + }); + expect(assess({ service: observed("2.61.0"), path: observed("2.62.0") })).toMatchObject({ + kind: "supported", protocolVersion: 1, + }); + }); + + test("unknown, malformed and prerelease observations do not authorize takeover", () => { + expect(assess({ path: { status: "unknown", reason: "probe timed out" } })).toMatchObject({ + kind: "blocked", reason: "managing-cli-unknown", + }); + for (const version of ["garbage", "2.61.0-preview.1", "2.60.99"]) { + expect(assess({ path: observed(version) })).toMatchObject({ + kind: "blocked", reason: "managing-cli-unsupported", + }); + } + }); + + test("a preserved service registration needs the state protocol marker too", () => { + expect(assess({ state: { ...OWNERSHIP_AWARE_STATE, ownershipProtocolVersion: undefined } })) + .toMatchObject({ kind: "blocked", reason: "service-protocol-unsupported" }); + expect(assess({ state: null, service: absent, path: observed("2.61.0") })) + .toMatchObject({ kind: "supported" }); + }); + + test("an unobserved manager set cannot retroactively protect an older CLI", () => { + expect(assess({ state: null, service: absent, path: absent })).toMatchObject({ + kind: "blocked", reason: "managing-cli-unobserved", + }); + }); + + test("the compatibility token binds both manager identities and the approved subject", () => { + const original = assess(); + const replacedPath = assess({ path: observed("2.61.0", "different-path-manager") }); + const differentSubject = assessServiceTakeoverCompatibility({ + state: OWNERSHIP_AWARE_STATE, + subject: { kind: "none", revision: 8 }, + managers: { + "service-registration": observed("2.61.0", "registered-manager"), + path: observed("2.61.0", "path-manager"), + }, + }); + expect(original.kind).toBe("supported"); + expect(replacedPath.kind).toBe("supported"); + expect(differentSubject.kind).toBe("supported"); + if (original.kind === "supported" && replacedPath.kind === "supported" && differentSubject.kind === "supported") { + expect(replacedPath.token).not.toBe(original.token); + expect(differentSubject.token).not.toBe(original.token); + } + }); +}); diff --git a/tests/service/service-ownership-handover.test.ts b/tests/service/service-ownership-handover.test.ts index f482f86b17d..c79a907711a 100644 --- a/tests/service/service-ownership-handover.test.ts +++ b/tests/service/service-ownership-handover.test.ts @@ -20,7 +20,7 @@ const INSTALLED: ServiceDiagnostic = { }; const DESKTOP_CLAIM = { owner: "desktop", installId: "app-install-a", consentGeneration: 2 } as const; -const DESKTOP: ServiceOwnershipResolution = { kind: "owned", ownership: DESKTOP_CLAIM }; +const DESKTOP: ServiceOwnershipResolution = { kind: "owned", ownership: DESKTOP_CLAIM, revision: 4 }; const UNKNOWN: ServiceOwnershipResolution = { kind: "unknown", reason: "a service state path could not be read (EACCES)" }; describe("repair under an owner that is not this CLI", () => { @@ -84,8 +84,8 @@ describe("repair under an owner that is not this CLI", () => { test("a CLI owner repairs normally, and so does a record with no claim at all", async () => { const resolutions: ServiceOwnershipResolution[] = [ - { kind: "none" }, - { kind: "owned", ownership: { owner: "cli", installId: "npm-install", consentGeneration: 4 } }, + { kind: "none", revision: 0 }, + { kind: "owned", ownership: { owner: "cli", installId: "npm-install", consentGeneration: 4 }, revision: 8 }, ]; for (const resolution of resolutions) { let repaired = false; @@ -122,10 +122,10 @@ describe("which service verbs are gated", () => { * repair would reactivate it. */ test("install releases the marker only after the registration succeeded", () => { - expect(installCase).toContain("releaseServiceOwner()"); - expect(installCase.indexOf("installServiceSafely")).toBeLessThan(installCase.indexOf("releaseServiceOwner()")); + expect(installCase).toContain("releaseServiceOwner(ownershipBeforeInstall"); + expect(installCase.indexOf("installServiceSafely")).toBeLessThan(installCase.indexOf("releaseServiceOwner(ownershipBeforeInstall")); // The failure branch leaves before the release. - expect(installCase.indexOf("Service install cleanup failed")).toBeLessThan(installCase.indexOf("releaseServiceOwner()")); + expect(installCase.indexOf("Service install cleanup failed")).toBeLessThan(installCase.indexOf("releaseServiceOwner(ownershipBeforeInstall")); }); test("start refuses on the same terms, because it activates the registration", () => { @@ -144,4 +144,3 @@ describe("which service verbs are gated", () => { expect(readFileSync(repoPath("src", "service", "repair.ts"), "utf8")).not.toContain("releaseServiceOwner"); }); }); - diff --git a/tests/service/service-ownership-state.test.ts b/tests/service/service-ownership-state.test.ts index 3de8dd9b90e..4cac1931916 100644 --- a/tests/service/service-ownership-state.test.ts +++ b/tests/service/service-ownership-state.test.ts @@ -8,7 +8,7 @@ * doctor suggestion — and nothing said it had gone. */ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { createTempHome, type TempHome } from "../helpers/temp-home"; import { repoPath } from "../helpers/repo-root"; import { @@ -20,14 +20,22 @@ import { readServiceInstallState, recordServiceOwner, releaseServiceOwner, + removeServiceInstallStateRecords, resolveServiceOwnership, + resolveServiceState, + ServiceOwnershipSubjectMismatchError, + ServiceTakeoverCompatibilityChangedError, ServiceStateConflictError, serviceOwnership, serviceStatePath, serviceStatePaths, swapServiceInstallState, writeServiceInstallState, + type RecordServiceOwnerDeps, + type ServiceOwner, + type ServiceStateSwapDeps, } from "../../src/service/state"; +import { assessServiceTakeoverCompatibility, type ManagingCliObservation } from "../../src/service/ownership-compatibility"; let home: TempHome; /** @@ -57,26 +65,63 @@ afterEach(() => { }); const DESKTOP = { owner: "desktop", installId: "app-install-a" } as const; +const COMPATIBLE_MANAGERS: Readonly> = { + "service-registration": { status: "absent" }, + path: { status: "observed", version: "2.61.0", identity: "path-manager-a" }, +}; + +function grantServiceOwner( + claim: { owner: ServiceOwner; installId: string }, + deps: ServiceStateSwapDeps & Pick, "observeManagers"> = {}, +) { + const request = approvedOwnerRequest(claim); + const { observeManagers = () => COMPATIBLE_MANAGERS, ...swapDeps } = deps; + return recordServiceOwner(request, { ...swapDeps, observeManagers }); +} + +function approvedOwnerRequest( + claim: { owner: ServiceOwner; installId: string }, + managers = COMPATIBLE_MANAGERS, +) { + const expectedSubject = resolveServiceOwnership(); + if (expectedSubject.kind === "unknown") throw new Error(expectedSubject.reason); + const resolved = resolveServiceState(); + if (resolved.kind === "unknown") throw new Error(resolved.reason); + const expectedCompatibility = assessServiceTakeoverCompatibility({ + state: resolved.kind === "state" ? resolved.state : null, + subject: expectedSubject, + managers, + }); + if (expectedCompatibility.kind !== "supported") throw new Error(expectedCompatibility.detail); + return { ...claim, expectedSubject, expectedCompatibility }; +} + +function releaseCurrentOwner(deps: ServiceStateSwapDeps = {}) { + const expected = resolveServiceOwnership(); + if (expected.kind === "unknown") throw new Error(expected.reason); + return releaseServiceOwner(expected, deps); +} describe("ownership survives every install-state writer", () => { test("a repair over a desktop takeover keeps the owner, the install id and the generation", () => { - const claimed = recordServiceOwner(DESKTOP); - expect(claimed).toEqual({ owner: "desktop", installId: "app-install-a", consentGeneration: 1 }); + const claimed = grantServiceOwner(DESKTOP); + expect(claimed.ownership).toEqual({ owner: "desktop", installId: "app-install-a", consentGeneration: 1 }); // What a repair does: rebuild the install provenance and write it. writeServiceInstallState("scheduler", null); const after = readServiceInstallState(); - expect(after?.ownership).toEqual(claimed); + expect(after?.ownership).toEqual(claimed.ownership); // The provenance half really was refreshed, so this is preservation rather than a // write that quietly did nothing. expect(after?.bunPath).toBeTruthy(); expect(after?.backend).toBe("scheduler"); + expect(after?.ownershipProtocolVersion).toBe(1); expect(desktopOwnsService()).toBe(true); }); test("a native-backend switch preserves the claim too", () => { - recordServiceOwner(DESKTOP); + grantServiceOwner(DESKTOP); writeServiceInstallState("native"); const after = readServiceInstallState(); expect(after?.backend).toBe("native"); @@ -112,14 +157,14 @@ describe("ownership survives every install-state writer", () => { describe("consent generation and the comparison rule", () => { test("a grant increments once; the same installation relaunching does not", () => { - expect(recordServiceOwner(DESKTOP).consentGeneration).toBe(1); - expect(recordServiceOwner(DESKTOP).consentGeneration).toBe(1); - expect(recordServiceOwner({ owner: "desktop", installId: "app-install-b" }).consentGeneration).toBe(2); - expect(recordServiceOwner({ owner: "cli", installId: "app-install-b" }).consentGeneration).toBe(3); + expect(grantServiceOwner(DESKTOP).ownership.consentGeneration).toBe(1); + expect(grantServiceOwner(DESKTOP).ownership.consentGeneration).toBe(1); + expect(grantServiceOwner({ owner: "desktop", installId: "app-install-b" }).ownership.consentGeneration).toBe(2); + expect(grantServiceOwner({ owner: "cli", installId: "app-install-b" }).ownership.consentGeneration).toBe(3); }); test("a grant belongs to one installation, not to the kind of owner", () => { - const ownership = recordServiceOwner(DESKTOP); + const ownership = grantServiceOwner(DESKTOP).ownership; expect(ownershipGrantedTo(ownership, "desktop", "app-install-a")).toBe(true); // A reinstalled app carries a different id and must ask for consent again. expect(ownershipGrantedTo(ownership, "desktop", "app-install-b")).toBe(false); @@ -128,16 +173,16 @@ describe("consent generation and the comparison rule", () => { }); test("an install id is required, because an empty one would match nothing and claim everything", () => { - expect(() => recordServiceOwner({ owner: "desktop", installId: "" })).toThrow(/install id/); + expect(() => grantServiceOwner({ owner: "desktop", installId: "" })).toThrow(/install id/); }); test("releasing returns the dropped claim and creates no record when there is none", () => { - expect(releaseServiceOwner()).toBeNull(); + expect(releaseCurrentOwner()).toBeNull(); expect(existsSync(serviceStatePath())).toBe(false); writeServiceInstallState("scheduler", null); - recordServiceOwner(DESKTOP); - expect(releaseServiceOwner()).toEqual({ owner: "desktop", installId: "app-install-a", consentGeneration: 1 }); + grantServiceOwner(DESKTOP); + expect(releaseCurrentOwner()).toEqual({ owner: "desktop", installId: "app-install-a", consentGeneration: 1 }); expect(serviceOwnership()).toBeNull(); expect(desktopOwnsService()).toBe(false); // The install record itself is untouched: releasing ownership is not an uninstall. @@ -145,7 +190,7 @@ describe("consent generation and the comparison rule", () => { }); test("claiming with no install state writes no install provenance it cannot vouch for", () => { - recordServiceOwner(DESKTOP); + grantServiceOwner(DESKTOP); const record = readServiceInstallState(); expect(record?.ownership?.owner).toBe("desktop"); expect(record?.bunPath).toBeUndefined(); @@ -154,6 +199,93 @@ describe("consent generation and the comparison rule", () => { }); describe("the compare-and-swap", () => { + test("a revocation that lands while a provenance writer waits is not resurrected", () => { + grantServiceOwner(DESKTOP); + let revoked = false; + writeServiceInstallState("scheduler", null, { + beforeCommit: attempt => { + if (attempt === 0 && !revoked) { + revoked = true; + releaseCurrentOwner(); + } + }, + }); + expect(readServiceInstallState()?.ownership).toBeUndefined(); + expect(readServiceInstallState()?.consentGenerationCeiling).toBe(1); + }); + + test("authority commit survives a mirror failure and the next writer repairs the mirror", () => { + const mirror = home.path("active", "service-state.json"); + const authority = home.path("default", "service-state.json"); + mkdirSync(home.path("active"), { recursive: true }); + mkdirSync(home.path("default"), { recursive: true }); + const initial = { + version: 2, codexHome: home.codexHome, opencodexHome: home.root, backend: "scheduler", + revision: 1, ownership: { owner: "desktop", installId: "app-install-a", consentGeneration: 1 }, + consentGenerationCeiling: 1, + } as const; + const bytes = `${JSON.stringify(initial, null, 2)}\n`; + writeFileSync(mirror, bytes); + writeFileSync(authority, bytes); + const degraded: string[] = []; + releaseServiceOwner({ kind: "owned", ownership: initial.ownership, revision: 1 }, { + paths: [mirror, authority], + commitStateFile: (path, serialized, validate) => { + validate(); + if (path === mirror) throw new Error("mirror unavailable"); + writeFileSync(path, serialized); + }, + onMirrorError: path => degraded.push(path), + }); + expect(degraded).toEqual([mirror]); + expect(resolveServiceOwnership(inspectServiceStateEvidence([mirror, authority]))) + .toEqual({ kind: "none", revision: 2 }); + expect(JSON.parse(readFileSync(mirror, "utf8")).ownership.installId).toBe("app-install-a"); + + writeServiceInstallState("scheduler", null, { paths: [mirror, authority] }); + expect(readFileSync(mirror, "utf8")).toBe(readFileSync(authority, "utf8")); + expect(resolveServiceOwnership(inspectServiceStateEvidence([mirror, authority])).kind).toBe("none"); + }); + + test("an authority publication failure leaves both prior records unchanged", () => { + const mirror = home.path("active-state.json"); + const authority = home.path("authority-state.json"); + const initial = JSON.stringify({ + version: 2, codexHome: home.codexHome, opencodexHome: home.root, backend: "scheduler", revision: 4, + }); + writeFileSync(mirror, initial); + writeFileSync(authority, initial); + expect(() => swapServiceInstallState(current => ({ ...current!, launcherPath: "/next/ocx" }), { + paths: [mirror, authority], + commitStateFile: () => { throw new Error("rename refused"); }, + })).toThrow(/rename refused/); + expect(readFileSync(mirror, "utf8")).toBe(initial); + expect(readFileSync(authority, "utf8")).toBe(initial); + }); + + test("a mirror deletion failure keeps the authority, so a revoked claim cannot migrate back", () => { + const mirror = home.path("active-delete.json"); + const authority = home.path("authority-delete.json"); + writeFileSync(mirror, JSON.stringify({ + version: 2, codexHome: home.codexHome, opencodexHome: home.root, backend: "scheduler", revision: 4, + ownership: { owner: "desktop", installId: "revoked", consentGeneration: 1 }, + })); + writeFileSync(authority, JSON.stringify({ + version: 2, codexHome: home.codexHome, opencodexHome: home.root, backend: "scheduler", revision: 5, + consentGenerationCeiling: 1, + })); + expect(() => removeServiceInstallStateRecords({ + paths: [mirror, authority], + unlink: path => { + if (path === mirror) throw new Error("mirror delete refused"); + unlinkSync(path); + }, + })).toThrow(/mirror delete refused/); + expect(existsSync(authority)).toBe(true); + expect(resolveServiceOwnership(inspectServiceStateEvidence([mirror, authority]))) + .toEqual({ kind: "none", revision: 5 }); + }); + test("a writer that lands inside the commit window is detected and the swap recomputes", () => { writeServiceInstallState("scheduler", null); const before = readServiceInstallState()?.revision ?? 0; @@ -161,7 +293,7 @@ describe("the compare-and-swap", () => { const result = swapServiceInstallState(current => ({ ...current!, launcherPath: "/opt/ocx" }), { beforeCommit: attempt => { // Exactly one interleaved writer, on the first attempt only. - if (attempt === 0) recordServiceOwner(DESKTOP); + if (attempt === 0) grantServiceOwner(DESKTOP); }, }); @@ -178,7 +310,7 @@ describe("the compare-and-swap", () => { let competitors = 0; expect(() => swapServiceInstallState(current => ({ ...current!, launcherPath: "/opt/ocx" }), { attempts: 3, - beforeCommit: () => { competitors += 1; recordServiceOwner({ owner: "desktop", installId: "app-" + competitors }); }, + beforeCommit: () => { competitors += 1; grantServiceOwner({ owner: "desktop", installId: "app-" + competitors }); }, })).toThrow(ServiceStateConflictError); expect(competitors).toBe(3); @@ -202,6 +334,17 @@ describe("the compare-and-swap", () => { expect(readFileSync(serviceStatePath(), "utf8")).toBe(before); }); + test("exhausted revision refuses before publishing an unreadable successor", () => { + writeFileSync(serviceStatePath(), JSON.stringify({ + version: 2, codexHome: home.codexHome, opencodexHome: home.root, + backend: "scheduler", revision: Number.MAX_SAFE_INTEGER, + })); + const before = readFileSync(serviceStatePath(), "utf8"); + expect(() => swapServiceInstallState(current => ({ ...current!, launcherPath: "/next/ocx" }))) + .toThrow(/revision is exhausted/); + expect(readFileSync(serviceStatePath(), "utf8")).toBe(before); + }); + /** * Unreadable is not absent. Reading a directory is the portable way to produce that * answer; a real one is a permission the process does not have. Either way the swap has @@ -217,6 +360,59 @@ describe("the compare-and-swap", () => { }); }); +describe("the consented subject is a precondition", () => { + test("a delayed service-install release cannot delete a successor claim", () => { + const first = grantServiceOwner(DESKTOP); + const successor = grantServiceOwner({ owner: "desktop", installId: "app-install-b" }); + expect(() => releaseServiceOwner(first, { allowRevisionAdvance: true })) + .toThrow(ServiceOwnershipSubjectMismatchError); + expect(resolveServiceOwnership()).toEqual(successor); + }); + + test("a revocation during the internal retry invalidates the approval", () => { + grantServiceOwner(DESKTOP); + const request = approvedOwnerRequest({ owner: "desktop", installId: "app-install-b" }); + let revoked = false; + expect(() => recordServiceOwner(request, { + observeManagers: () => COMPATIBLE_MANAGERS, + beforeCommit: attempt => { + if (attempt === 0 && !revoked) { + revoked = true; + releaseCurrentOwner(); + } + }, + })).toThrow(ServiceOwnershipSubjectMismatchError); + expect(resolveServiceOwnership().kind).toBe("none"); + }); + + test("a revision-only change requires fresh approval even when the owner is unchanged", () => { + writeServiceInstallState("scheduler", null); + const request = approvedOwnerRequest(DESKTOP); + let changed = false; + expect(() => recordServiceOwner(request, { + observeManagers: () => COMPATIBLE_MANAGERS, + beforeCommit: attempt => { + if (attempt === 0 && !changed) { + changed = true; + writeServiceInstallState("scheduler", null); + } + }, + })).toThrow(ServiceOwnershipSubjectMismatchError); + expect(readServiceInstallState()?.ownership).toBeUndefined(); + }); + + test("managing CLI compatibility is re-observed immediately before the grant", () => { + const request = approvedOwnerRequest(DESKTOP); + expect(() => recordServiceOwner(request, { + observeManagers: () => ({ + "service-registration": { status: "absent" }, + path: { status: "observed", version: "2.60.0", identity: "path-manager-old" }, + }), + })).toThrow(ServiceTakeoverCompatibilityChangedError); + expect(readServiceInstallState()?.ownership).toBeUndefined(); + }); +}); + describe("parsing", () => { const valid = { version: 2, codexHome: "/c", opencodexHome: "/o", backend: "scheduler" }; @@ -233,6 +429,11 @@ describe("parsing", () => { expect(parseServiceInstallState({ ...valid, revision: 0 })?.revision).toBe(0); expect(parseServiceInstallState({ ...valid, revision: 1.5 })).toBeNull(); expect(parseServiceInstallState({ ...valid, revision: -1 })).toBeNull(); + expect(parseServiceInstallState({ ...valid, revision: Number.MAX_SAFE_INTEGER })).not.toBeNull(); + expect(parseServiceInstallState({ + ...valid, + ownership: { owner: "desktop", installId: "a", consentGeneration: Number.MAX_SAFE_INTEGER }, + })).not.toBeNull(); expect(parseServiceInstallState(valid)?.revision).toBeUndefined(); }); @@ -259,18 +460,23 @@ describe("the record is read fail-closed", () => { test("a corrupt anchor is unknown; corrupt legacy leftovers are ignored", () => { writeFileSync(serviceStatePath(), "not json"); expect(resolveServiceOwnership(inspectServiceStateEvidence([serviceStatePath()])).kind).toBe("unknown"); + unlinkSync(serviceStatePath()); // The second path is the legacy default-home entry. Junk left there by an old version // must not be able to block every repair on the machine. - recordServiceOwner(DESKTOP); + grantServiceOwner(DESKTOP); const legacy = home.path("legacy-service-state.json"); writeFileSync(legacy, "{ broken"); - const resolution = resolveServiceOwnership(inspectServiceStateEvidence([serviceStatePath(), legacy])); - expect(resolution).toEqual({ kind: "owned", ownership: { owner: "desktop", installId: "app-install-a", consentGeneration: 1 } }); + const resolution = resolveServiceOwnership(inspectServiceStateEvidence([legacy, serviceStatePath()])); + expect(resolution).toEqual({ + kind: "owned", + ownership: { owner: "desktop", installId: "app-install-a", consentGeneration: 1 }, + revision: expect.any(Number), + }); }); test("paths that name different owners are unknown", () => { - recordServiceOwner(DESKTOP); + grantServiceOwner(DESKTOP); const other = home.path("other-service-state.json"); const record = JSON.parse(readFileSync(serviceStatePath(), "utf8")); writeFileSync(other, JSON.stringify({ ...record, ownership: { ...record.ownership, installId: "app-install-b" } })); @@ -278,7 +484,8 @@ describe("the record is read fail-closed", () => { }); test("absent everywhere is the only thing that means no claim", () => { - expect(resolveServiceOwnership(inspectServiceStateEvidence([serviceStatePath()]))).toEqual({ kind: "none" }); + expect(resolveServiceOwnership(inspectServiceStateEvidence([serviceStatePath()]))) + .toEqual({ kind: "none", revision: 0 }); }); /** @@ -297,118 +504,94 @@ describe("the record is read fail-closed", () => { describe("the generation cannot be reused", () => { test("a release keeps the high-water mark so the next grant does not repeat it", () => { - expect(recordServiceOwner(DESKTOP).consentGeneration).toBe(1); - releaseServiceOwner(); + expect(grantServiceOwner(DESKTOP).ownership.consentGeneration).toBe(1); + releaseCurrentOwner(); expect(readServiceInstallState()?.consentGenerationCeiling).toBe(1); // Without the ceiling this would be 1 again, and an app-local record still holding the // first 1 would read the second grant as its own prior consent. - expect(recordServiceOwner(DESKTOP).consentGeneration).toBe(2); + expect(grantServiceOwner(DESKTOP).ownership.consentGeneration).toBe(2); }); test("an ordinary install-state write carries the ceiling forward", () => { - recordServiceOwner(DESKTOP); - releaseServiceOwner(); + grantServiceOwner(DESKTOP); + releaseCurrentOwner(); writeServiceInstallState("scheduler", null); expect(readServiceInstallState()?.consentGenerationCeiling).toBe(1); - expect(recordServiceOwner({ owner: "desktop", installId: "app-install-b" }).consentGeneration).toBe(2); + expect(grantServiceOwner({ owner: "desktop", installId: "app-install-b" }).ownership.consentGeneration).toBe(2); }); }); describe("the anchor lock", () => { - test("a lock another process holds blocks the write rather than racing it", () => { - writeFileSync(serviceStatePath() + ".lock", ""); - expect(() => swapServiceInstallState(() => ({ - version: 2, codexHome: home.codexHome, opencodexHome: home.root, backend: "scheduler", - }), { lockWaitMs: 50 })).toThrow(/another process is writing/); - // Nothing was written: the swap never reached a commit. + test("a live update lease blocks ownership mutation before the state lock is touched", () => { + const leasePath = serviceStatePath() + ".mutation.lock"; + const processInstance = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const token = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + mkdirSync(leasePath, { recursive: true }); + writeFileSync(`${leasePath}/v1-777-${processInstance}-${token}.json`, JSON.stringify({ + version: 1, pid: 777, processInstance, token, createdAt: 1, + })); + expect(() => writeServiceInstallState("scheduler", null, { + paths: [serviceStatePath()], + mutationLease: { waitMs: 0, now: () => 1_000_000, processAlive: () => true }, + })).toThrow(/runtime mutation lease/); expect(existsSync(serviceStatePath())).toBe(false); }); - test("a swap nested inside another one is not a race and does not deadlock", () => { - writeServiceInstallState("scheduler", null); - const result = swapServiceInstallState(current => ({ ...current!, launcherPath: "/opt/ocx" }), { - beforeCommit: attempt => { if (attempt === 0) recordServiceOwner(DESKTOP); }, + test("a crashed incomplete update lease is reclaimed only after the stale grace", () => { + const leasePath = serviceStatePath() + ".mutation.lock"; + mkdirSync(leasePath, { recursive: true }); + writeServiceInstallState("scheduler", null, { + paths: [serviceStatePath()], + mutationLease: { + waitMs: 0, + now: () => Date.now() + 60_000, + processAlive: () => false, + }, }); - expect(result?.ownership?.installId).toBe("app-install-a"); + expect(readServiceInstallState()?.backend).toBe("scheduler"); }); - /** - * The lock file names its holder. Without that, a holder evicted as stale would delete the - * REPLACEMENT lock on its way out and hand a third writer the pathname while the second is - * still inside its critical section. - */ - test("release removes only the lock instance this holder created", () => { + test("age never evicts a holder whose PID is still alive", () => { const lockPath = serviceStatePath() + ".lock"; - let observed = ""; - writeServiceInstallState("scheduler", null); - swapServiceInstallState(current => { - observed = readFileSync(lockPath, "utf8").trim(); - // Stand in for an eviction: the pathname now belongs to somebody else. - writeFileSync(lockPath, "a-different-holder\n"); - return { ...current! }; - }); - expect(observed).not.toBe(""); - expect(existsSync(lockPath)).toBe(true); - expect(readFileSync(lockPath, "utf8").trim()).toBe("a-different-holder"); - unlinkSync(lockPath); - }); -}); - -describe("the record is replaced as a unit", () => { - /** - * An in-place write truncates first, so an interrupted commit used to leave the anchor empty - * or half-serialized. Since the reader became fail-closed that reads as `unknown`, which - * blocks start, repair, restart and every update until the operator runs a takeover install. - */ - test("a commit leaves no staging file behind and the record stays parseable", () => { - writeServiceInstallState("scheduler", null); - recordServiceOwner(DESKTOP); - const leftovers = readdirSync(home.root).filter(name => name.endsWith(".tmp")); - expect(leftovers).toEqual([]); - expect(readServiceInstallState()?.ownership?.installId).toBe("app-install-a"); - }); - - test("the write path stages and renames rather than truncating the record in place", () => { - const source = readFileSync(repoPath("src", "service", "state.ts"), "utf8"); - const commit = source.slice( - source.indexOf("function commitServiceStateFile("), - source.indexOf("export function swapServiceInstallState("), - ); - expect(commit).toContain("renameSync(staged, path)"); - // Hardened BEFORE the rename: between rename and chmod the record would be readable - // at the default mode. - expect(commit.indexOf("hardenSecretPath(staged")).toBeLessThan(commit.indexOf("renameSync(staged, path)")); + const processInstance = "11111111-1111-4111-8111-111111111111"; + const token = "22222222-2222-4222-8222-222222222222"; + mkdirSync(lockPath, { recursive: true }); + writeFileSync(`${lockPath}/v1-777-${processInstance}-${token}.json`, JSON.stringify({ + version: 1, pid: 777, processInstance, token, createdAt: 1, + })); + expect(() => swapServiceInstallState(() => ({ + version: 2, codexHome: home.codexHome, opencodexHome: home.root, backend: "scheduler", + }), { + paths: [serviceStatePath()], + lockWaitMs: 0, + lockHooks: { now: () => 1_000_000, processAlive: () => true }, + })).toThrow(/another process owns/); + // Nothing was written: the swap never reached a commit. + expect(existsSync(serviceStatePath())).toBe(false); }); -}); -describe("a claim recorded under the lock is never overwritten by an older one", () => { - /** - * `writeServiceInstallState` used to resolve ownership BEFORE the swap took the lock. A - * takeover landing in between reached `current`, passed the revision check untouched, and - * was then overwritten by the older claim the resolution had captured — a lost update the - * compare-and-swap cannot see, because the stale value never came from the base record. - */ - test("the resolution is read inside the swap, not before it", () => { - const source = readFileSync(repoPath("src", "service", "state.ts"), "utf8"); - const writer = source.slice( - source.indexOf("export function writeServiceInstallState("), - source.indexOf("function preservedConsent("), - ); - expect(writer).toContain("preservedConsent(current, resolveServiceOwnership())"); - expect(writer).not.toMatch(/const resolution = resolveServiceOwnership\(\);/); + test("a late release cannot remove a successor lock", () => { + const statePath = serviceStatePath(); + const lockPath = `${statePath}.lock`; + const successorInstance = "33333333-3333-4333-8333-333333333333"; + const successorToken = "44444444-4444-4444-8444-444444444444"; + swapServiceInstallState(current => ({ + ...(current ?? { version: 2, codexHome: home.codexHome, opencodexHome: home.root, backend: "scheduler" }), + launcherPath: "/opt/ocx", + }), { + paths: [statePath], + lockHooks: { + beforeRelease: () => { + for (const entry of readdirSync(lockPath)) unlinkSync(`${lockPath}/${entry}`); + rmSync(lockPath, { recursive: true }); + mkdirSync(lockPath, { recursive: true }); + writeFileSync(`${lockPath}/v1-888-${successorInstance}-${successorToken}.json`, JSON.stringify({ + version: 1, pid: 888, processInstance: successorInstance, token: successorToken, createdAt: 2, + })); + }, + }, + }); + expect(existsSync(`${lockPath}/v1-888-${successorInstance}-${successorToken}.json`)).toBe(true); }); - test("a higher generation wins, and an equal generation keeps the anchor", () => { - recordServiceOwner(DESKTOP); - recordServiceOwner({ owner: "desktop", installId: "app-install-b" }); - const before = readServiceInstallState(); - expect(before?.ownership?.installId).toBe("app-install-b"); - expect(before?.ownership?.consentGeneration).toBe(2); - - // An ordinary install-state refresh must not demote it to the earlier grant. - writeServiceInstallState("scheduler", null); - const after = readServiceInstallState(); - expect(after?.ownership?.installId).toBe("app-install-b"); - expect(after?.ownership?.consentGeneration).toBe(2); - }); }); diff --git a/tests/update/update-desktop-owner.test.ts b/tests/update/update-desktop-owner.test.ts index d9683958760..963be923f09 100644 --- a/tests/update/update-desktop-owner.test.ts +++ b/tests/update/update-desktop-owner.test.ts @@ -12,12 +12,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { repoPath } from "../helpers/repo-root"; import { planUpdateRuntimeHandling } from "../../src/update/runtime-ownership.mjs"; -import { - inspectInstallStateBytes, - resolveOwnershipFromEvidence, - serviceStateFilesFor, -} from "../../src/service/install-state-contract.mjs"; -import { parseServiceOwnership } from "../../src/service/state"; +import { parseInstallStateRecord, selectAuthoritativeServiceState } from "../../src/service/install-state-contract.mjs"; describe("the runtime-ownership veto", () => { test("a desktop owner stops both the stop and the service refresh, and says so", () => { @@ -25,8 +20,9 @@ describe("the runtime-ownership veto", () => { ownership: { owner: "desktop", installId: "app-install-a", consentGeneration: 3 }, serviceInstalled: true, }); - expect(plan.stopRuntime).toBe(false); - expect(plan.refreshService).toBe(false); + expect(plan.mayReplacePackage).toBe(false); + expect(plan.mayStopRuntime).toBe(false); + expect(plan.mayRestoreService).toBe(false); expect(plan.notice).toContain("app-install-a"); expect(plan.notice).toContain("consent generation 3"); expect(plan.notice).toContain("neither re-enabled nor restarted"); @@ -35,9 +31,9 @@ describe("the runtime-ownership veto", () => { test("a CLI owner and an unowned runtime both take the ordinary path", () => { for (const ownership of [null, { owner: "cli", installId: "npm-install", consentGeneration: 1 }]) { expect(planUpdateRuntimeHandling({ ownership, serviceInstalled: true })) - .toEqual({ stopRuntime: true, refreshService: true, notice: null }); + .toEqual({ mayReplacePackage: true, mayStopRuntime: true, mayRestoreService: true, notice: null }); expect(planUpdateRuntimeHandling({ ownership, serviceInstalled: false })) - .toEqual({ stopRuntime: true, refreshService: false, notice: null }); + .toEqual({ mayReplacePackage: true, mayStopRuntime: true, mayRestoreService: false, notice: null }); } }); @@ -46,106 +42,47 @@ describe("the runtime-ownership veto", () => { ownership: { owner: "something-newer", installId: "x", consentGeneration: 1 }, serviceInstalled: true, }); - expect(plan.stopRuntime).toBe(false); + expect(plan.mayReplacePackage).toBe(false); + expect(plan.mayStopRuntime).toBe(false); }); }); -/** - * The launcher used to keep its own reader, "kept in step" by a table of claim shapes. It was - * not in step: it inspected only the anchor path, and it treated a record that fails the whole - * install-state contract as an unowned runtime whenever its `ownership` field was simply - * absent. That is permission to stop a foreign runtime and reactivate the npm service, so the - * reader is gone and both runtimes import one contract. - */ -describe("one contract, not two readers kept in step", () => { - const accepted = [ - { owner: "desktop", installId: "a", consentGeneration: 0 }, - { owner: "cli", installId: "a", consentGeneration: 12 }, - { owner: "desktop", installId: "a", consentGeneration: 1, grantedBy: "first-launch" }, - ]; - const rejected = [ - { owner: "root", installId: "a", consentGeneration: 1 }, - { owner: "desktop", installId: "", consentGeneration: 1 }, - { owner: "desktop", installId: "a" }, - { owner: "desktop", installId: "a", consentGeneration: -1 }, - { owner: "desktop", installId: "a", consentGeneration: 1.5 }, - { owner: "desktop", installId: "a", consentGeneration: "1" }, - "desktop", - null, - ]; - const record = (extra: Record): string => JSON.stringify({ - version: 2, codexHome: "/c", opencodexHome: "/o", backend: "scheduler", ...extra, - }); - const resolveText = (text: string) => resolveOwnershipFromEvidence( - [inspectInstallStateBytes("/anchor", () => text)], - ); - - test("the authoritative reader and the shared contract accept the same claims", () => { - for (const ownership of accepted) { - expect(parseServiceOwnership(ownership)).not.toBeNull(); - expect(resolveText(record({ ownership }))).toEqual({ kind: "owned", ownership }); - } +describe("the Node and Bun paths share one full-record authority", () => { + const state = (revision: number, installId = "desktop-a") => ({ + version: 2, codexHome: "/codex", opencodexHome: "/opencodex", backend: "scheduler", + revision, ownership: { owner: "desktop" as const, installId, consentGeneration: 1 }, }); - test("a rejected claim is unknown, never an unowned runtime", () => { - for (const ownership of rejected) { - expect(parseServiceOwnership(ownership)).toBeNull(); - expect(resolveText(record({ ownership })).kind).toBe("unknown"); - } + test("a complete record is required before ownership is projected", () => { + expect(parseInstallStateRecord(state(1))?.ownership?.installId).toBe("desktop-a"); + expect(parseInstallStateRecord({ ownership: state(1).ownership })).toBeNull(); }); - /** - * The case the launcher got wrong: a record that carries no `ownership` field but fails the - * contract for another reason. It answered "known unowned" and permitted the stop and the - * service refresh; the contract answers `unknown` and vetoes both. - */ - test("an ownership-free record that fails the contract is unknown, not unowned", () => { - expect(resolveText(JSON.stringify({ version: 2 })).kind).toBe("unknown"); - expect(resolveText(JSON.stringify({ version: 99, codexHome: "/c", opencodexHome: "/o" })).kind).toBe("unknown"); - expect(resolveText(record({ codexHome: "" })).kind).toBe("unknown"); - expect(resolveText("{").kind).toBe("unknown"); - expect(resolveText("[]").kind).toBe("unknown"); - // A record that satisfies the contract and simply has no claim is the one "none" case. - expect(resolveText(record({})).kind).toBe("none"); - }); - - test("a claim on the legacy path alone is still a claim, and a conflict is unknown", () => { - const anchor = inspectInstallStateBytes("/anchor", () => record({})); - const claim = { owner: "desktop", installId: "app-a", consentGeneration: 1 }; - const legacy = inspectInstallStateBytes("/legacy", () => record({ ownership: claim })); - expect(resolveOwnershipFromEvidence([anchor, legacy])).toEqual({ kind: "owned", ownership: claim }); - - const other = inspectInstallStateBytes("/legacy", () => record({ - ownership: { owner: "desktop", installId: "app-b", consentGeneration: 1 }, - })); - const claimed = inspectInstallStateBytes("/anchor", () => record({ ownership: claim })); - expect(resolveOwnershipFromEvidence([claimed, other]).kind).toBe("unknown"); - }); - - test("an unreadable path anywhere in the list is unknown", () => { - const unreadable = inspectInstallStateBytes("/legacy", () => { - const error = new Error("denied") as Error & { code?: string }; - error.code = "EACCES"; - throw error; - }); - expect(unreadable.kind).toBe("unreadable"); - const anchor = inspectInstallStateBytes("/anchor", () => record({})); - expect(resolveOwnershipFromEvidence([anchor, unreadable]).kind).toBe("unknown"); + test("the default-home authority wins over an older active-home mirror", () => { + const selected = selectAuthoritativeServiceState([ + { path: "active", kind: "valid", state: state(4, "old-owner") }, + { path: "default", kind: "valid", state: { ...state(5), ownership: undefined } }, + ]); + expect(selected).toMatchObject({ kind: "state", revision: 5, needsRepair: true }); + if (selected.kind === "state") expect(selected.state.ownership).toBeUndefined(); }); - test("an absent path is the only answer that can mean no claim", () => { - const absent = inspectInstallStateBytes("/anchor", () => { - const error = new Error("missing") as Error & { code?: string }; - error.code = "ENOENT"; - throw error; - }); - expect(absent.kind).toBe("absent"); - expect(resolveOwnershipFromEvidence([absent])).toEqual({ kind: "none" }); + test("same-or-newer mirror disagreement is unknown rather than a vote", () => { + expect(selectAuthoritativeServiceState([ + { path: "active", kind: "valid", state: state(5, "other-owner") }, + { path: "default", kind: "valid", state: state(5) }, + ])).toMatchObject({ kind: "unknown" }); }); - test("both runtimes consult the same path list", () => { - expect(serviceStateFilesFor("/home/.opencodex", "/home/.opencodex")).toHaveLength(1); - expect(serviceStateFilesFor("/pinned", "/home/.opencodex")).toHaveLength(2); + test("an absent authority imports one valid legacy record, while unreadable authority refuses", () => { + expect(selectAuthoritativeServiceState([ + { path: "active", kind: "valid", state: state(3) }, + { path: "default", kind: "absent" }, + ])).toMatchObject({ kind: "state", revision: 3, needsRepair: true }); + expect(selectAuthoritativeServiceState([ + { path: "active", kind: "valid", state: state(3) }, + { path: "default", kind: "unreadable", reason: "EACCES" }, + ])).toMatchObject({ kind: "unknown" }); }); }); @@ -155,19 +92,18 @@ describe("both updaters consult the shared rule", () => { test("the Bun updater gates its stop, its refresh and its restart hint", () => { expect(bunPath).toContain("from \"./runtime-ownership.mjs\""); - expect(bunPath).toContain("if (runtimePlan.stopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); - expect(bunPath).toContain("if (runtimePlan.refreshService) {"); - expect(bunPath).toContain("} else if (runtimePlan.stopRuntime) {"); - expect(bunPath).toContain("if (stopAttempted && runtimePlan.refreshService && postUpdateLauncherUsable)"); + expect(bunPath).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); + expect(bunPath).toContain("if (!runtimePlan.mayReplacePackage)"); + expect(bunPath).toContain("if (postInstallPlan.mayRestoreService) {"); }); test("the npm launcher gates its stop, its refresh and its failure recovery", () => { expect(launcher).toContain("from \"../src/update/runtime-ownership.mjs\""); - expect(launcher).toContain("if (runtimePlan.stopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown))"); - expect(launcher).toContain("if (runtimePlan.refreshService) {"); + expect(launcher).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown))"); + expect(launcher).toContain("if (!runtimePlan.mayReplacePackage)"); // Nothing was stopped, so nothing is recovered: starting a proxy here would put a // second one beside the runtime the app is managing. - expect(launcher).toContain("if (!runtimePlan.stopRuntime) return;"); + expect(launcher).toContain("if (!recoveryPlan.mayStopRuntime) return;"); }); test("neither updater reimplements the decision", () => { @@ -184,7 +120,7 @@ describe("both updaters consult the shared rule", () => { */ test("the launcher reads the record only through the shared contract", () => { expect(launcher).toContain('from "../src/service/install-state-contract.mjs"'); - expect(launcher).toContain("resolveOwnershipFromEvidence(evidence)"); + expect(launcher).toContain("selectAuthoritativeServiceState("); expect(launcher).toContain("serviceStateFilesFor("); expect(launcher).not.toContain("parsed.ownership"); expect(launcher).not.toContain("consentGeneration"); @@ -192,15 +128,16 @@ describe("both updaters consult the shared rule", () => { const state = readFileSync(repoPath("src", "service", "state.ts"), "utf8"); expect(state).toContain('from "./install-state-contract.mjs"'); expect(state).toContain("return parseInstallStateRecord(value)"); - expect(state).toContain("return resolveOwnershipFromEvidence(evidence)"); + expect(state).toContain("selectAuthoritativeServiceState("); }); }); describe("an unreadable record is not an unowned runtime", () => { test("unknown ownership vetoes both halves and points at the way back", () => { const plan = planUpdateRuntimeHandling({ ownership: null, ownershipUnknown: true, serviceInstalled: true }); - expect(plan.stopRuntime).toBe(false); - expect(plan.refreshService).toBe(false); + expect(plan.mayReplacePackage).toBe(false); + expect(plan.mayStopRuntime).toBe(false); + expect(plan.mayRestoreService).toBe(false); expect(plan.notice).toContain("could not be determined"); expect(plan.notice).toContain("ocx service install"); }); @@ -230,7 +167,7 @@ describe("every updater re-reads ownership before it starts a proxy directly", ( expect(fallbackAt).toBeGreaterThan(-1); const recheckAt = source.lastIndexOf("planUpdateRuntimeHandling({", fallbackAt); expect(recheckAt).toBeGreaterThan(-1); - expect(source.slice(recheckAt, fallbackAt)).toContain("nowOwned.stopRuntime"); + expect(source.slice(recheckAt, fallbackAt)).toContain("nowOwned.mayStopRuntime"); } }); @@ -242,14 +179,15 @@ describe("every updater re-reads ownership before it starts a proxy directly", ( test("the dashboard worker checks before it restarts anything", () => { const restartAt = worker.indexOf("if (restart) {"); const handoffAt = worker.indexOf("finishGuiUpdateRestart(", restartAt); - const gateAt = worker.indexOf("updateRestartVeto(", restartAt); + const gateAt = worker.indexOf("runUpdateRestartWithOwnershipLease(", restartAt); expect(gateAt).toBeGreaterThan(restartAt); expect(gateAt).toBeLessThan(handoffAt); - expect(worker.slice(gateAt, handoffAt)).toContain("if (veto)"); - expect(worker.slice(gateAt, handoffAt)).toContain("restarted: false"); + expect(worker.slice(gateAt)).toContain("outcome.kind === \"veto\""); + expect(worker.slice(gateAt)).toContain("restarted: false"); // The veto is the shared rule, not a second opinion about ownership. const veto = readFileSync(repoPath("src", "update", "restart-ownership.ts"), "utf8"); expect(veto).toContain("planUpdateRuntimeHandling({"); expect(veto).toContain("resolveServiceOwnership"); + expect(veto).toContain("acquireOwnershipMutationLease"); }); }); diff --git a/tests/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts index 010bab6cc63..8b6d0777970 100644 --- a/tests/update/update-stop-first.test.ts +++ b/tests/update/update-stop-first.test.ts @@ -536,7 +536,7 @@ describe("update stops the running proxy before replacing files", () => { expect(stopAt).toBeLessThan(updateAt); // The four signals are now inside a runtime-ownership veto: a desktop-owned runtime is // not stopped at all. Every original reason to stop still reaches the gate unchanged. - expect(updateSource).toContain("if (runtimePlan.stopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); + expect(updateSource).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); }); test("integrity pre-flight runs BEFORE the stop so anomalous metadata never unloads the proxy", () => { @@ -602,12 +602,9 @@ describe("update stops the running proxy before replacing files", () => { expect(updateSource).toContain("serviceReinstallArgs()"); expect(launcherSource).toContain("aborting the update"); expect(launcherSource).toContain('"service", "repair"'); - // The launcher still reads the install-state record for service-installed detection, and - // for the backend choice on the genuinely-absent install fallback. It no longer spells the - // file name: the path list moved into the contract both runtimes import, so the launcher - // cannot consult a shorter list than the authoritative reader. - expect(launcherSource).toContain("serviceStateFilesFor(configDir()"); - expect(launcherSource).toContain("const serviceWasInstalled = existsSync(serviceStatePath)"); + // The launcher reads the shared active/default state-path set for service-installed + // detection and the authoritative backend on the genuinely-absent install fallback. + expect(launcherSource).toContain("serviceStateFilesFor"); // That marker can be STALE, so the fallback asks for structured state rather than // parsing a failure message; bin/ocx.mjs is plain Node and cannot import // diagnoseService(), so it reads startup.serviceInstalled from `status --json`. @@ -788,8 +785,8 @@ esac // A pending-teardown receipt is a fourth reason to stop: after a parent crashed // mid-deferral the service, pid and runtime records can all be absent while shared // client config still points at a proxy that is gone (#3008). - expect(updateSource).toContain("if (runtimePlan.stopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); - expect(launcherSource).toContain("if (runtimePlan.stopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown))"); + expect(updateSource).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); + expect(launcherSource).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown))"); // The rule now lives in the shared post-stop decision both lanes import (#3008): a // history-only stop proceeds, every other nonzero status and any surviving runtime // state aborts. Pinned by tests/update/update-stop-classification.test.ts. From 28d88abf850c3ceea479c7c2dabd900f6a26f9f0 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 15:38:10 +0900 Subject: [PATCH 06/17] fix(desktop): keep one local bundle format from destroying the rest (#5410) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux, bun run build:local asked for appimage,deb in ONE tauri invocation. When AppImage bundling failed (linuxdeploy missing a host dependency), the invocation died and the deb was never attempted — a contributor following the README got zero artifacts and an error that named a tool they never invoked (observed on a real GNOME desktop, devlog plan 260921 / 120_install_verification.md). Each format now builds in its own invocation, every format is attempted, and the summary reports each outcome beside the artifacts that did build; the exit code is non-zero when any requested format failed. A failing format is retried once with --verbose: at the bundler's default log level the error is a bare "failed to run linuxdeploy" with the tool's stderr discarded, and the verbose pass is the branch where those diagnostics reach the terminal. The release workflow builds its artifacts on its own runner image and is untouched. --- desktop/README.md | 7 +- desktop/scripts/build-local.ts | 145 ++++++++++++++---- .../release-desktop-scripts.test.ts | 127 +++++++++++++++ 3 files changed, 245 insertions(+), 34 deletions(-) diff --git a/desktop/README.md b/desktop/README.md index 153293a55da..9e7fcc0eda5 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -39,7 +39,12 @@ bun run build:local This asks for the host platform's installable bundles only (app and dmg on macOS, msi and nsis setup exe on Windows, AppImage and deb on Linux), so no updater archive is produced and none is -expected to be signed. It prints the bundle paths and exits zero. The release path below is unchanged: a published +expected to be signed. Each format is attempted in its own invocation: a format this machine +cannot bundle (for example an AppImage when a linuxdeploy dependency is missing) fails on its own +line without destroying the formats that do build, the failing format is retried once with +`--verbose` so the bundler's own diagnostics are visible, and the summary prints every format's +outcome beside the artifacts that were produced. The exit code is non-zero if any format failed. +The release path below is unchanged: a published updater artifact still has to be signed. ## Release packaging and updates diff --git a/desktop/scripts/build-local.ts b/desktop/scripts/build-local.ts index 2616c7fc509..634c92ffbce 100644 --- a/desktop/scripts/build-local.ts +++ b/desktop/scripts/build-local.ts @@ -19,9 +19,22 @@ * there is nothing to sign and nothing is skipped unsigned. Selecting bundle targets is not enough: * `createUpdaterArtifacts` is a config flag, so `--bundles app,dmg` still produces * `OpenCodex.app.tar.gz (updater)` and still fails. The override has to reach the config itself. + * + * Two more local-only behaviours, learned from a real GNOME desktop (devlog plan 260921, + * 120_install_verification.md): + * + * - Formats build in SEPARATE invocations. A single `--bundles appimage,deb` call dies on the + * first failing format, so a host that cannot bundle an AppImage (a missing linuxdeploy + * dependency) also lost the deb it could have built. Each format is attempted, and the + * summary at the end names every format's outcome; the exit code is non-zero if any of + * them failed, and the artifacts that DID build are printed either way. + * - A failing format is retried once with `--verbose`. At the bundler's default log level + * the error is a bare "failed to run linuxdeploy" with the tool's own diagnostics + * discarded; the verbose pass is the branch where that stderr actually reaches the + * terminal, so the failure says WHY instead of naming a tool nobody invoked. */ import { spawnSync } from "node:child_process"; -import { existsSync, readdirSync } from "node:fs"; +import { existsSync, readdirSync, statSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -34,12 +47,6 @@ const LOCAL_BUNDLES: Record = { linux: ["appimage", "deb"], }; -const bundles = LOCAL_BUNDLES[process.platform]; -if (!bundles) { - console.error(`[build:local] unsupported host platform: ${process.platform}`); - process.exit(1); -} - /** * Config merged over `tauri.conf.json` for this invocation only. * @@ -49,34 +56,106 @@ if (!bundles) { */ const LOCAL_CONFIG = JSON.stringify({ bundle: { createUpdaterArtifacts: false } }); -function run(): number { - const extra = process.argv.slice(2); - const args = [ - "tauri", "build", "--ci", - "--bundles", bundles.join(","), - "--config", LOCAL_CONFIG, - ...extra, - ]; - const result = spawnSync("bunx", args, { cwd: desktopDir, stdio: "inherit" }); - if (result.error) { - console.error(`[build:local] could not start tauri: ${result.error.message}`); - return 1; - } - return result.status ?? 1; +export interface SpawnResult { + status: number | null; + error?: Error; } -const status = run(); -if (status === 0) { - const bundleRoot = join(desktopDir, "src-tauri", "target", "release", "bundle"); - // Naming what exists is the point of the script: the previous output ended on an error line, so - // the artifacts it had already written were the least visible thing in it. - for (const dir of ["macos", "dmg", "msi", "nsis", "appimage", "deb"]) { - const directory = join(bundleRoot, dir); - if (!existsSync(directory)) continue; - for (const name of readdirSync(directory)) { - if (/\.(app|dmg|msi|exe|AppImage|deb)$/i.test(name)) console.log(`[build:local] ${join(directory, name)}`); +export interface ArtifactEntry { + path: string; + mtimeMs: number; +} + +export interface BuildLocalDeps { + spawn(args: string[]): SpawnResult; + log(line: string): void; + error(line: string): void; + listArtifacts(): ArtifactEntry[]; + argv: string[]; + platform: string; +} + +export interface FormatAttempt { + format: string; + status: number; +} + +export function summarizeAttempts(attempts: FormatAttempt[]): { exitCode: number; lines: string[] } { + const lines = attempts.map( + attempt => `[build:local] ${attempt.format}: ${attempt.status === 0 ? "ok" : `FAILED (exit ${attempt.status})`}`, + ); + return { exitCode: attempts.every(attempt => attempt.status === 0) ? 0 : 1, lines }; +} + +export function runBuildLocal(deps: BuildLocalDeps): number { + const bundles = LOCAL_BUNDLES[deps.platform]; + if (!bundles) { + deps.error(`[build:local] unsupported host platform: ${deps.platform}`); + return 1; + } + // Snapshot before building: a bundle directory that already holds last week's AppImage + // must not be reported as this run's output when this run's AppImage attempt fails. + const baseline = new Map(deps.listArtifacts().map(entry => [entry.path, entry.mtimeMs])); + const attempts: FormatAttempt[] = []; + for (const format of bundles) { + // One invocation per format: a format this host cannot build must not destroy the + // artifacts of formats it can. + const args = ["tauri", "build", "--ci", "--bundles", format, "--config", LOCAL_CONFIG, ...deps.argv]; + const first = deps.spawn(args); + let status = first.status ?? 1; + if (first.error) { + deps.error(`[build:local] could not start tauri: ${first.error.message}`); + status = 1; + } else if (status !== 0) { + // The bundler reports a bare "failed to run " at its default log level; the + // verbose pass is where the tool's own stderr reaches the terminal. The retry is + // diagnostics only — the recorded status stands either way. + deps.error(`[build:local] ${format} failed; rerunning with --verbose for the bundler's diagnostics`); + const retry = deps.spawn(["tauri", "--verbose", "build", "--ci", "--bundles", format, "--config", LOCAL_CONFIG, ...deps.argv]); + if (retry.error) deps.error(`[build:local] could not start tauri: ${retry.error.message}`); } + attempts.push({ format, status }); } - console.log("[build:local] updater artifacts skipped; release signing is unchanged."); + // Name what THIS run produced even when something failed: an error line at the end is + // the least visible place for artifacts that already built. + const produced = deps.listArtifacts().filter( + entry => !baseline.has(entry.path) || baseline.get(entry.path) !== entry.mtimeMs, + ); + for (const entry of produced) deps.log(`[build:local] ${entry.path}`); + const summary = summarizeAttempts(attempts); + for (const line of summary.lines) deps.log(line); + if (summary.exitCode === 0) { + deps.log("[build:local] updater artifacts skipped; release signing is unchanged."); + } + return summary.exitCode; +} + +function main(): void { + const status = runBuildLocal({ + spawn: args => spawnSync("bunx", args, { cwd: desktopDir, stdio: "inherit" }), + log: line => console.log(line), + error: line => console.error(line), + listArtifacts: () => { + const bundleRoot = join(desktopDir, "src-tauri", "target", "release", "bundle"); + const artifacts: ArtifactEntry[] = []; + for (const dir of ["macos", "dmg", "msi", "nsis", "appimage", "deb"]) { + const directory = join(bundleRoot, dir); + if (!existsSync(directory)) continue; + for (const name of readdirSync(directory)) { + if (/\.(app|dmg|msi|exe|AppImage|deb)$/i.test(name)) { + const full = join(directory, name); + artifacts.push({ path: full, mtimeMs: statSync(full).mtimeMs }); + } + } + } + return artifacts; + }, + argv: process.argv.slice(2), + platform: process.platform, + }); + process.exit(status); +} + +if (import.meta.main) { + main(); } -process.exit(status); diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index 80a2a814cc8..52ed2d90919 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -4,6 +4,12 @@ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { collectReleaseAssets } from "../../desktop/scripts/collect-release-assets"; +import { + runBuildLocal, + summarizeAttempts, + type ArtifactEntry, + type BuildLocalDeps, +} from "../../desktop/scripts/build-local"; import { buildUpdaterManifest, writeUpdaterManifest } from "../../desktop/scripts/updater-manifest"; import { standaloneTargets } from "../../scripts/standalone-targets"; import { @@ -229,6 +235,127 @@ describe("desktop release scripts", () => { * an extension signed that way, so the app would have installed with no widget and nothing in * the build would have said so. */ +describe("local bundle builds", () => { + type Script = Record; + const depsFor = ( + scripted: Script, + opts: { platform?: string; initialArtifacts?: ArtifactEntry[]; argv?: string[] } = {}, + ) => { + const calls: string[][] = []; + const logs: string[] = []; + const errors: string[] = []; + const artifacts = (opts.initialArtifacts ?? []).map(entry => ({ ...entry })); + const deps: BuildLocalDeps = { + spawn: args => { + calls.push(args); + const format = args[args.indexOf("--bundles") + 1]!; + const verbose = args.includes("--verbose"); + const key = verbose ? `${format}#v` : format; + const status = Object.hasOwn(scripted, key) ? scripted[key]! : 0; + // A successful non-verbose build refreshes the artifact, like the real bundler. + if (status === 0 && !verbose) { + const existing = artifacts.find(entry => entry.path.includes(format)); + if (existing) existing.mtimeMs += 1; + else artifacts.push({ path: `/out/OpenCodex-test_${format}`, mtimeMs: 200 }); + } + return { status }; + }, + log: line => { logs.push(line); }, + error: line => { errors.push(line); }, + listArtifacts: () => artifacts.map(entry => ({ ...entry })), + argv: opts.argv ?? [], + platform: opts.platform ?? "linux", + }; + return { calls, logs, errors, deps }; + }; + + test("a failing format does not destroy the formats that build", () => { + // Observed on a real GNOME desktop (120_install_verification.md): one shared + // invocation died on the AppImage and the deb was never attempted. + const { calls, logs, deps } = depsFor({ appimage: 1, deb: 0 }); + expect(runBuildLocal(deps)).toBe(1); + const formats = calls.map(args => args[args.indexOf("--bundles") + 1]); + expect(formats).toContain("appimage"); + expect(formats).toContain("deb"); + expect(logs.some(line => line.includes("appimage: FAILED"))).toBe(true); + expect(logs.some(line => line.includes("deb: ok"))).toBe(true); + expect(logs.some(line => line.includes("/out/OpenCodex-test_deb"))).toBe(true); + expect(logs.some(line => line.includes("updater artifacts skipped"))).toBe(false); + }); + + test("a failing format is retried verbosely so the bundler's own stderr surfaces", () => { + const { calls, errors, deps } = depsFor({ appimage: 1, deb: 0 }); + runBuildLocal(deps); + expect(errors.some(line => line.includes("rerunning with --verbose"))).toBe(true); + const verboseCalls = calls.filter(args => args.includes("--verbose")); + expect(verboseCalls).toHaveLength(1); + expect(verboseCalls[0]?.slice(0, 3)).toEqual(["tauri", "--verbose", "build"]); + expect(verboseCalls[0]).toContain("appimage"); + expect(verboseCalls.some(args => args.includes("deb"))).toBe(false); + }); + + test("a verbose retry that succeeds does not change the recorded failure", () => { + const { logs, deps } = depsFor({ appimage: 1, "appimage#v": 0, deb: 0 }); + expect(runBuildLocal(deps)).toBe(1); + expect(logs.some(line => line.includes("appimage: FAILED"))).toBe(true); + }); + + test("stale bundle output is not reported as this run's artifact", () => { + const { logs, deps } = depsFor( + { appimage: 1, deb: 0 }, + { initialArtifacts: [{ path: "/out/OpenCodex-test_appimage", mtimeMs: 100 }] }, + ); + runBuildLocal(deps); + expect(logs.some(line => line.includes("/out/OpenCodex-test_appimage"))).toBe(false); + expect(logs.some(line => line.includes("/out/OpenCodex-test_deb"))).toBe(true); + }); + + test("a spawn that never started counts as a failure", () => { + const { logs, deps } = depsFor({ appimage: null, deb: 0 }); + expect(runBuildLocal(deps)).toBe(1); + expect(logs.some(line => line.includes("appimage: FAILED"))).toBe(true); + }); + + test("a spawn error reports the launch failure", () => { + const errors: string[] = []; + const deps = depsFor({ deb: 0 }).deps; + const originalSpawn = deps.spawn; + deps.error = line => { errors.push(line); }; + deps.spawn = args => (args.includes("appimage") ? { status: null, error: new Error("spawn bunx ENOENT") } : originalSpawn(args)); + expect(runBuildLocal(deps)).toBe(1); + expect(errors.some(line => line.includes("could not start tauri"))).toBe(true); + }); + + test("the invocation shape is one tauri build per format, extra argv forwarded everywhere", () => { + const { calls, deps } = depsFor({ appimage: 1, deb: 0 }, { argv: ["--target", "x86_64-unknown-linux-gnu"] }); + runBuildLocal(deps); + expect(calls[0]?.slice(0, 5)).toEqual(["tauri", "build", "--ci", "--bundles", "appimage"]); + expect(calls[1]?.slice(0, 5)).toEqual(["tauri", "--verbose", "build", "--ci", "--bundles"]); + for (const call of calls) { + expect(call.slice(-2)).toEqual(["--target", "x86_64-unknown-linux-gnu"]); + } + }); + + test("a fully successful build exits zero and keeps the updater note", () => { + const { logs, deps } = depsFor({}); + expect(runBuildLocal(deps)).toBe(0); + expect(logs.some(line => line.includes("updater artifacts skipped"))).toBe(true); + }); + + test("macOS hosts build app and dmg", () => { + const { calls, deps } = depsFor({}, { platform: "darwin" }); + expect(runBuildLocal(deps)).toBe(0); + const formats = calls.map(args => args[args.indexOf("--bundles") + 1]); + expect(formats).toEqual(["app", "dmg"]); + }); + + test("summarizeAttempts decides the exit code from the per-format outcomes", () => { + expect(summarizeAttempts([{ format: "appimage", status: 0 }, { format: "deb", status: 0 }]).exitCode).toBe(0); + expect(summarizeAttempts([{ format: "appimage", status: 1 }, { format: "deb", status: 0 }]).exitCode).toBe(1); + expect(summarizeAttempts([{ format: "appimage", status: 1 }, { format: "deb", status: 1 }]).lines[0]).toContain("FAILED"); + }); +}); + describe("the desktop build toolchain carries the bundle-type marker", () => { // updater.rs selects the deb updater target from tauri_utils::platform::bundle_type(), // which reads a marker the tauri-bundler patches into the binary at packaging time. From f19b229e837fe23851c097032b8c84a41e481330 Mon Sep 17 00:00:00 2001 From: ingwannu Date: Mon, 21 Sep 2026 15:39:44 +0900 Subject: [PATCH 07/17] fix(cli): warn only for proven stale app servers (#5394) Co-authored-by: Ingwannu --- src/codex/app-server-processes.ts | 112 +++++++++++------- ...DR-0097-post-write-app-server-freshness.md | 12 ++ structure/runtime.md | 8 ++ .../codex-app-server-processes.test.ts | 51 ++++++++ 4 files changed, 143 insertions(+), 40 deletions(-) create mode 100644 structure/decisions/ADR-0097-post-write-app-server-freshness.md diff --git a/src/codex/app-server-processes.ts b/src/codex/app-server-processes.ts index 355beaa9e50..a33a1091fd6 100644 --- a/src/codex/app-server-processes.ts +++ b/src/codex/app-server-processes.ts @@ -786,6 +786,58 @@ function catalogStatusFromProcesses( return { state: stale ? "stale" : "fresh", processes: withStarts, catalogMtimeMs }; } +interface ComputedCodexAppServerCatalogStatus { + status: CodexAppServerCatalogStatus; + processes: CodexAppServerProcess[]; +} + +/** + * Compute one catalog-state observation while retaining the command lines from the + * same process enumeration. The public collector deliberately exposes only the + * identity and timestamp projection; post-write warning code also needs the matched + * process records, and re-enumerating there creates a race between classification and + * reporting (as well as a second expensive Windows CIM walk). + */ +function computeCodexAppServerCatalogStatus( + io: CodexAppServerProcessIo, +): ComputedCodexAppServerCatalogStatus { + const platform = io.platform ?? process.platform; + const getuid = io.getuid ?? (() => { + try { + return typeof process.getuid === "function" ? process.getuid() : undefined; + } catch { + return undefined; + } + }); + let snapshots: ProcessSnapshot[]; + let enumerationFailed = false; + const enumerate = io.listSnapshots ?? (() => defaultListSnapshots(platform, getuid)); + try { + snapshots = enumerate(); + } catch { + // A failed process read is unknown, never proof that nothing is running. + snapshots = []; + enumerationFailed = true; + } + const processes = codexAppServerProcessesFromSnapshots(snapshots); + if (processes.length === 0) { + return { + processes, + status: enumerationFailed + ? { state: "unknown", processes: [], catalogMtimeMs: null } + : { state: "not_running", processes: [], catalogMtimeMs: null }, + }; + } + const catalogMtimeMs = (io.catalogMtimeMs ?? defaultCatalogMtimeMs)(); + const starts = io.readStartMs + ? new Map(processes.map(proc => [proc.pid, io.readStartMs!(proc.pid)] as const)) + : readProcessStartMsBatch(processes.map(proc => proc.pid), platform); + return { + processes, + status: catalogStatusFromProcesses(processes, catalogMtimeMs, starts), + }; +} + // Short TTL: process listing + stat run once per window even under per-turn // guidance calls (#857). let catalogStateCache: { atMs: number; status: CodexAppServerCatalogStatus } | null = null; @@ -889,41 +941,7 @@ export function collectCodexAppServerCatalogState( && now - catalogStateCache.atMs < catalogStateTtlMs(catalogStateCache.status.state)) { return catalogStateCache.status; } - const compute = (): CodexAppServerCatalogStatus => { - const platform = io.platform ?? process.platform; - const getuid = io.getuid ?? (() => { - try { - return typeof process.getuid === "function" ? process.getuid() : undefined; - } catch { - return undefined; - } - }); - let snapshots: ProcessSnapshot[]; - let enumerationFailed = false; - const enumerate = io.listSnapshots ?? (() => defaultListSnapshots(platform, getuid)); - try { - snapshots = enumerate(); - } catch { - // Enumeration failure must never read as "nothing running" — that would let - // positive model guidance through on guesswork (#857). The injected seam gets - // the same contract as the default path: whoever enumerates, a failure to read - // the process list is unknown, not an empty machine. - snapshots = []; - enumerationFailed = true; - } - const processes = codexAppServerProcessesFromSnapshots(snapshots); - if (processes.length === 0) { - return enumerationFailed - ? { state: "unknown", processes: [], catalogMtimeMs: null } - : { state: "not_running", processes: [], catalogMtimeMs: null }; - } - const catalogMtimeMs = (io.catalogMtimeMs ?? defaultCatalogMtimeMs)(); - const starts = io.readStartMs - ? new Map(processes.map(proc => [proc.pid, io.readStartMs!(proc.pid)] as const)) - : readProcessStartMsBatch(processes.map(proc => proc.pid), platform); - return catalogStatusFromProcesses(processes, catalogMtimeMs, starts); - }; - const status = compute(); + const status = computeCodexAppServerCatalogStatus(io).status; if (fullyDefault) { catalogStateCache = { atMs: now, status }; } @@ -1228,16 +1246,30 @@ export function afterCatalogWriteHandleAppServers( options: AfterCatalogWriteAppServerOptions, ): AfterCatalogWriteAppServerResult { const excluded = new Set(options.excludePids ?? []); + const hint = STALE_CODEX_APP_SERVER_HINT; + if (!options.restart) { + // A running process is not necessarily stale. Classify the exact process + // enumeration that supplies the warning, and stay quiet when freshness cannot be + // established rather than presenting an unknown observation as a known mismatch. + const observed = computeCodexAppServerCatalogStatus(options.io ?? {}); + const starts = new Map(observed.status.processes.map(process => [process.pid, process.startedAtMs])); + const catalogMtimeMs = observed.status.catalogMtimeMs; + const processes = observed.processes.filter(process => !excluded.has(process.pid)); + const staleProcesses = catalogMtimeMs === null + ? [] + : processes.filter(process => { + const startedAtMs = starts.get(process.pid); + return startedAtMs !== null && startedAtMs !== undefined && startedAtMs <= catalogMtimeMs; + }); + if (staleProcesses.length === 0) return { processes, warned: false, hint }; + options.log?.error(formatStaleCodexAppServerWarning(staleProcesses)); + return { processes: staleProcesses, warned: true, hint }; + } const processes = listCodexAppServerProcesses(options.io) .filter(process => !excluded.has(process.pid)); - const hint = STALE_CODEX_APP_SERVER_HINT; if (processes.length === 0) { return { processes, warned: false, hint }; } - if (!options.restart) { - options.log?.error(formatStaleCodexAppServerWarning(processes)); - return { processes, warned: true, hint }; - } options.log?.log( `Stopping Codex app-server process(es): ${processes.map(process => process.pid).join(", ")} ` + "(active turns may be interrupted).", diff --git a/structure/decisions/ADR-0097-post-write-app-server-freshness.md b/structure/decisions/ADR-0097-post-write-app-server-freshness.md new file mode 100644 index 00000000000..070709ea12e --- /dev/null +++ b/structure/decisions/ADR-0097-post-write-app-server-freshness.md @@ -0,0 +1,12 @@ +# ADR-0097 — decision recorded under "CLI Codex restart scope" + +- Contract owner: [runtime.md](../runtime.md#cli-codex-restart-scope) + +## Decision record + +- 목적과 의도: Avoid telling an operator to interrupt a fresh Codex session after a successful sync. +- 기존 구현 및 제약 조건: Startup already classified catalog freshness, but the ordinary post-write CLI warning treated every running app-server as stale; explicit restart flags must keep their existing consent semantics. +- 검토한 주요 대안: Warn on every running process; suppress every warning; classify only the advisory non-restart path from one process observation. +- 선택한 방식: Retain command lines from the classifier's enumeration and warn only for the proven-stale subset. +- 다른 대안 대신 이 방식을 선택한 이유: Presence alone cannot prove stale state, while suppressing every warning would hide a real in-memory catalog mismatch. +- 장점, 단점 및 영향: Mixed fresh/stale sets name only stale PIDs and unknown observations stay quiet; explicit restart requests remain unchanged. diff --git a/structure/runtime.md b/structure/runtime.md index e975d9129ea..25094f7e101 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -64,6 +64,14 @@ Catalog-derived reasoning-level diagnostics are escaped only at the human-output `ocx system codex-restart` requests a full Codex desktop-app restart and app-server restarts through the management endpoint. `src/cli/capabilities.ts` names that scope in its summary and `--yes` description; `src/cli/system-command.ts` explains the desktop interruption when confirmation is missing and sends no restart request. Human output says the restart was requested, while `--json` preserves the complete server result, including skipped or refused desktop outcomes. +After a CLI catalog/cache write, advisory restart guidance compares each running Codex app-server's +start time with the written catalog mtime. It reports only processes proven stale; a fresh or +unreadable observation does not claim that another restart is required. Explicit +`--restart-codex` and `--restart-app-server-only` retain their operator-consent semantics and act on +verified matching processes regardless of the advisory freshness result. + +> Decision record: [ADR-0097](decisions/ADR-0097-post-write-app-server-freshness.md) + ## Hub management dashboard address When hub management ingress is enabled, `src/cli/dispatch.ts` opens the dashboard on the literal IPv4 loopback address and configured ingress port, matching the listener in `src/server/index.ts`. Other dashboard address selection is unchanged. diff --git a/tests/codex-integration/codex-app-server-processes.test.ts b/tests/codex-integration/codex-app-server-processes.test.ts index 343d193cea0..8f170d2f1ea 100644 --- a/tests/codex-integration/codex-app-server-processes.test.ts +++ b/tests/codex-integration/codex-app-server-processes.test.ts @@ -677,6 +677,8 @@ describe("Codex app-server process matching (#476)", () => { const snapshots = [{ pid: 7, commandLine: "codex app-server --listen unix://x" }]; const io = { listSnapshots: () => snapshots, + readStartMs: () => 1_000, + catalogMtimeMs: () => 2_000, kill: () => {}, isAlive: () => false, waitExit: () => true, @@ -700,6 +702,55 @@ describe("Codex app-server process matching (#476)", () => { expect(restarted.restart?.stopped).toEqual([7]); expect(logs.some(line => line.includes("Stopping Codex app-server"))).toBe(true); }); + + test("afterCatalogWriteHandleAppServers warns only for processes proven stale", () => { + const errors: string[] = []; + const snapshots = [ + { pid: 7, commandLine: "codex app-server --listen unix://stale" }, + { pid: 8, commandLine: "codex app-server --listen unix://fresh" }, + ]; + const result = afterCatalogWriteHandleAppServers({ + restart: false, + log: { log: () => {}, error: line => errors.push(String(line)) }, + io: { + listSnapshots: () => snapshots, + readStartMs: pid => pid === 7 ? 1_000 : 3_000, + catalogMtimeMs: () => 2_000, + }, + }); + expect(result.warned).toBe(true); + expect(result.processes.map(process => process.pid)).toEqual([7]); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("PID: 7"); + expect(errors[0]).not.toContain("8"); + }); + + test("afterCatalogWriteHandleAppServers stays quiet for fresh and unknown observations", () => { + const errors: string[] = []; + const base = { + restart: false, + log: { log: () => {}, error: line => errors.push(String(line)) }, + } as const; + const fresh = afterCatalogWriteHandleAppServers({ + ...base, + io: { + listSnapshots: () => [{ pid: 7, commandLine: "codex app-server --listen unix://fresh" }], + readStartMs: () => 3_000, + catalogMtimeMs: () => 2_000, + }, + }); + const unknown = afterCatalogWriteHandleAppServers({ + ...base, + io: { + listSnapshots: () => [{ pid: 8, commandLine: "codex app-server --listen unix://unknown" }], + readStartMs: () => null, + catalogMtimeMs: () => 2_000, + }, + }); + expect(fresh.warned).toBe(false); + expect(unknown.warned).toBe(false); + expect(errors).toEqual([]); + }); }); describe("CLI /api sync wiring for stale app-servers (#476)", () => { From ac3df52cca3b960a8653157fd63d9bdd200874e3 Mon Sep 17 00:00:00 2001 From: ingwannu Date: Mon, 21 Sep 2026 15:39:53 +0900 Subject: [PATCH 08/17] fix(responses): repair exec_command input wrappers (#5395) Co-authored-by: Ingwannu --- src/responses/function-call-compat.ts | 39 ++++++- ...-schema-bound-exec-command-input-repair.md | 12 +++ structure/transports/responses.md | 10 ++ .../responses-function-tool-repair.test.ts | 101 ++++++++++++++++++ 4 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 structure/decisions/ADR-0098-schema-bound-exec-command-input-repair.md diff --git a/src/responses/function-call-compat.ts b/src/responses/function-call-compat.ts index c4888cf1f7a..fc26675dc16 100644 --- a/src/responses/function-call-compat.ts +++ b/src/responses/function-call-compat.ts @@ -33,6 +33,38 @@ function namespaceOf(value: unknown): string | undefined { return typeof value === "string" && value !== "functions" ? value : undefined; } +/** + * Repair one provider spelling drift for Codex's flat shell bridge. + * + * This is schema-bound rather than a general alias: `input` is also a legitimate + * argument name for arbitrary caller and MCP tools. Only the exact bare + * `exec_command` declaration can establish that its sole string input has one faithful + * reading as the required string `cmd` member. + */ +function repairExecCommandInput( + argumentsText: string, + schema: FunctionCallRepairSchema, +): string { + if (schema.namespace !== undefined || schema.name !== "exec_command") return argumentsText; + const parameters = schema.parameters; + if (!isObject(parameters) || parameters.type !== "object" + || !Array.isArray(parameters.required) || !parameters.required.includes("cmd") + || !isObject(parameters.properties) || !isObject(parameters.properties.cmd) + || parameters.properties.cmd.type !== "string") return argumentsText; + let parsed: unknown; + try { + parsed = JSON.parse(argumentsText); + } catch { + return argumentsText; + } + if (!isObject(parsed)) return argumentsText; + const keys = Object.keys(parsed); + if (keys.length !== 1 || keys[0] !== "input" || typeof parsed.input !== "string") { + return argumentsText; + } + return JSON.stringify({ cmd: parsed.input }); +} + function selectorAllows( selector: unknown, lowered: unknown, @@ -131,7 +163,12 @@ function repairItem(item: unknown, schemas: FunctionCallRepairSchemas, completed if (unsafe) return item; } catch { return item; } } - const argumentsText = coerceIntegerToolArguments(raw || "{}", schema.parameters, schema.namespace ? undefined : schema.name); + const integerRepaired = coerceIntegerToolArguments( + raw || "{}", + schema.parameters, + schema.namespace ? undefined : schema.name, + ); + const argumentsText = repairExecCommandInput(integerRepaired, schema); return argumentsText === raw ? item : { ...item, arguments: argumentsText }; } diff --git a/structure/decisions/ADR-0098-schema-bound-exec-command-input-repair.md b/structure/decisions/ADR-0098-schema-bound-exec-command-input-repair.md new file mode 100644 index 00000000000..8640b675e42 --- /dev/null +++ b/structure/decisions/ADR-0098-schema-bound-exec-command-input-repair.md @@ -0,0 +1,12 @@ +# ADR-0098 — decision recorded under "Schema-bound flat shell repair" + +- Contract owner: [transports/responses.md](../transports/responses.md#schema-bound-flat-shell-repair) + +## Decision record + +- 목적과 의도: Let an unambiguous routed `exec_command` wrapper reach the Codex shell bridge without weakening tool identity or argument validation. +- 기존 구현 및 제약 조건: Freeform code-mode tools already unwrap `input`, but a genuinely declared flat function must keep its name and Codex validates it against required `cmd`. +- 검토한 주요 대안: Rename the call to code-mode `exec`; rewrite every function's `input`; add a schema-bound repair at authoritative function-call completion. +- 선택한 방식: Repair only the exact bare declaration and exact one-member string payload when the current-turn schema requires string `cmd`. +- 다른 대안 대신 이 방식을 선택한 이유: Tool names and arbitrary `input` fields are caller-owned; the original schema is the only authority that makes the representation change deterministic. +- 장점, 단점 및 영향: Buffered, streamed-completion, and replay paths converge on valid `cmd` arguments; previews and ambiguous or namespaced shapes stay untouched. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index b1e28958fa1..a82e8f8cb08 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -125,6 +125,16 @@ Function-call wrappers around freeform bodies are restored by is recoverable because the wrapper is otherwise unusable; two alternate fields are ambiguous and therefore remain untouched. Foreign freeform grammars never receive that compatibility rewrite. +#### Schema-bound flat shell repair + +Completed Responses function calls have one separate schema-bound flat-shell repair. When the +exact bare `exec_command` declaration requires a string `cmd`, a provider result containing only +the string member `{ "input": "..." }` is rewritten to `{ "cmd": "..." }`; the call name is not +changed. Namespaced tools, additional or conflicting members, non-string values, malformed JSON, +partial streaming previews, and schemas that do not prove this exact contract remain byte-exact. + +> Decision record: [ADR-0098](../decisions/ADR-0098-schema-bound-exec-command-input-repair.md) + Progressive preview for those wrappers is decoded by `src/responses/progressive-freeform-input.ts` in both the adapter-event bridge and routed function-call restoration, over the classification in `src/responses/freeform-wrapper-scan.ts`. diff --git a/tests/responses/responses-function-tool-repair.test.ts b/tests/responses/responses-function-tool-repair.test.ts index 599e9cd9fa9..7577ac1559e 100644 --- a/tests/responses/responses-function-tool-repair.test.ts +++ b/tests/responses/responses-function-tool-repair.test.ts @@ -211,6 +211,77 @@ describe("pure function completion repair", () => { expect(repairFunctionCalls(missing, schemas).value).toBe(missing); }); + test("repairs the exact flat exec_command input wrapper into its required cmd", () => { + const shell = { + type: "function", + name: "exec_command", + parameters: { + type: "object", + properties: { cmd: { type: "string" } }, + required: ["cmd"], + additionalProperties: false, + }, + }; + const map = collectFunctionCallRepairSchemas({ tools: [shell] }); + const call = item('{"input":"printf hello"}', { name: "exec_command" }); + expect(repairFunctionCalls(call, map).value) + .toEqual({ ...call, arguments: '{"cmd":"printf hello"}' }); + + const reserved = item('{"input":"printf hello"}', { + name: "exec_command", + namespace: "functions", + }); + expect(repairFunctionCalls(reserved, map).value) + .toEqual({ ...reserved, arguments: '{"cmd":"printf hello"}' }); + }); + + test("exec_command input repair stays fail-closed outside the exact schema and payload", () => { + const declaration = (parameters: Record, namespace?: string) => ({ + type: "function", + name: "exec_command", + ...(namespace ? { namespace } : {}), + parameters, + }); + const canonical = { + type: "object", + properties: { cmd: { type: "string" } }, + required: ["cmd"], + }; + const cases: Array<{ payload: string; tool?: ReturnType }> = [ + { payload: '{"cmd":"already canonical"}' }, + { payload: '{"input":"one","cwd":"/tmp"}' }, + { payload: '{"input":7}' }, + { payload: '{"input":"one","cmd":"two"}' }, + { payload: '{"input":"one"', }, + { payload: '{"input":"one"}', tool: declaration({ ...canonical, required: [] }) }, + { payload: '{"input":"one"}', tool: declaration({ ...canonical, properties: { cmd: { type: "number" } } }) }, + ]; + for (const entry of cases) { + const tool = entry.tool ?? declaration(canonical); + const map = collectFunctionCallRepairSchemas({ tools: [tool] }); + const namespace = tool.namespace; + const call = item(entry.payload, { + name: namespace ? `${namespace}__exec_command` : "exec_command", + ...(namespace ? { namespace } : {}), + }); + expect(repairFunctionCalls(call, map).value).toBe(call); + } + + // A namespace is declared by its group, not by a `namespace` property on a + // top-level function. Exercise the actual remote identity so this proves the + // bare-only repair guard rather than passing because schema lookup missed. + const remoteMap = collectFunctionCallRepairSchemas({ tools: [{ + type: "namespace", + name: "remote", + tools: [declaration(canonical)], + }] }); + const remoteCall = item('{"input":"one"}', { + name: "exec_command", + namespace: "remote", + }); + expect(repairFunctionCalls(remoteCall, remoteMap).value).toBe(remoteCall); + }); + test.each([" ", "{", '{"cell_id":4.5}', '{"yield_time_ms":1.5}', '{"union":4.0}', '{"cell_id":9007199254740993}', '{"cell_id":4,"unknown":9007199254740993}', '{"cell_id":4,"unknown":1e400}']) ("preserves invalid/disagreeing/unsafe payload %s", argumentsText => { @@ -269,6 +340,36 @@ describe("native function completion SSE", () => { } finally { rewrite.dispose?.(); budget.dispose(); } }); + test("repairs exec_command input only at authoritative SSE completion boundaries", () => { + const map = collectFunctionCallRepairSchemas({ tools: [{ + type: "function", + name: "exec_command", + parameters: { + type: "object", + properties: { cmd: { type: "string" } }, + required: ["cmd"], + }, + }] }); + const rewrite = createResponsesFunctionToolRepairBlockRewrite(map); + const shellItem = item("", { name: "exec_command", status: "in_progress" }); + try { + const added = frame("response.output_item.added", { output_index: 0, item: shellItem }); + const delta = frame("response.function_call_arguments.delta", { + item_id: "fc_one", + delta: '{"input":"printf hello"}', + }); + expect(rewrite(added)).toEqual([added]); + expect(rewrite(delta)).toEqual([delta]); + const done = rewrite(frame("response.function_call_arguments.done", { + item_id: "fc_one", + arguments: '{"input":"printf hello"}', + })); + expect(payload(done[0]!)).toMatchObject({ arguments: '{"cmd":"printf hello"}' }); + } finally { + rewrite.dispose?.(); + } + }); + test.each(["response.output_item.done", "response.completed"])("no-arg %s works without arguments.done", type => { const rewrite = createResponsesFunctionToolRepairBlockRewrite(schemas); try { From 403b6b2ac20dd09f5ba52ab681ab1317e6f743dd Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 17:01:20 +0900 Subject: [PATCH 09/17] test(ci): derive the release-verify fixture's signed set from the updater table (#5425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev went red at the union of #5405 and #5391: lane F made the deb a second Linux updater target, so the verifier's derived expected set gained OpenCodex--linux-amd64.deb.sig, while the test's hand-written oracle still described the earlier world where only the AppImage was signed. Each branch was green alone; the merge was not. The fix is derivation, not list-keeping. The signed set and the manifest platform list in the fixture now come straight from platformFiles — the table that decides which bundles carry the updater key — and the produced payload list comes from the shared standalone target module and the bundle table. A future updater target changes both sides of the assertion by itself. The derivation test keeps its concrete payload anchors (a renamed or dropped bundle should still fail for a human to review) and asserts the rule instead of the roster: a bundle's signature is expected exactly when the updater table names it. Only the two test oracles changed; the verification ordering (checksums, signatures and the manifest all precede publication) is untouched. --- .../release-desktop-scripts.test.ts | 64 ++++++++++--------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/tests/ci-workflows/release-desktop-scripts.test.ts b/tests/ci-workflows/release-desktop-scripts.test.ts index 52ed2d90919..60c51e2cd30 100644 --- a/tests/ci-workflows/release-desktop-scripts.test.ts +++ b/tests/ci-workflows/release-desktop-scripts.test.ts @@ -3,15 +3,15 @@ import { createHash, generateKeyPairSync, sign as ed25519Sign } from "node:crypt import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; -import { collectReleaseAssets } from "../../desktop/scripts/collect-release-assets"; +import { bundlesByTarget, collectReleaseAssets } from "../../desktop/scripts/collect-release-assets"; import { runBuildLocal, summarizeAttempts, type ArtifactEntry, type BuildLocalDeps, } from "../../desktop/scripts/build-local"; -import { buildUpdaterManifest, writeUpdaterManifest } from "../../desktop/scripts/updater-manifest"; -import { standaloneTargets } from "../../scripts/standalone-targets"; +import { buildUpdaterManifest, platformFiles, writeUpdaterManifest } from "../../desktop/scripts/updater-manifest"; +import { standaloneArchiveName, standaloneTargets } from "../../scripts/standalone-targets"; import { expectedReleaseAssets, parseMinisignPublicKey, @@ -504,9 +504,15 @@ describe("release asset verification", () => { ]) { expect(expected).toContain(name); } - // Only the updater targets carry signatures; the DMG and the deb never do. - expect(expected).not.toContain(`OpenCodex-${VERSION}-macos.dmg.sig`); - expect(expected).not.toContain(`OpenCodex-${VERSION}-linux-amd64.deb.sig`); + // Signature presence follows the updater table exactly: a bundle is signed + // precisely when platformFiles names it as an updater target, so a new updater + // target changes this contract by itself rather than needing a hand edit here. + const updaterSuffixes = new Set(Object.values(platformFiles)); + for (const bundle of desktopTargets.flatMap(target => bundlesByTarget[target]!)) { + expect(expected).toContain(`OpenCodex-${VERSION}-${bundle.name}`); + expect(expected.includes(`OpenCodex-${VERSION}-${bundle.name}.sig`)) + .toBe(updaterSuffixes.has(bundle.name)); + } expect(expected.some(name => name.includes("/"))).toBe(false); }); @@ -584,27 +590,25 @@ describe("release asset verification", () => { const dir = join(root, "dist", "release"); mkdirSync(dir, { recursive: true }); - // The fixture is an independent producer oracle, written out by hand: five - // standalone archives, five desktop bundles, and signatures on exactly the - // three updater targets. Building it with the function under test would hide - // an omission in the expected set. + // The fixture derives from the producer tables — the standalone target module, + // the bundle table, and the updater platform table — assembled independently + // of the function under test. Building it with expectedReleaseAssets would + // hide an omission in the expected set; hand-writing it would go stale the + // next time a target is added (which is exactly the union failure this test + // once carried: the deb became an updater target and this oracle missed its + // signature). + const desktopTargets = releaseMatrixTargets( + readFileSync(join(root, ".github", "workflows", "release.yml"), "utf8"), + ).desktopTargets; const produced = [ - `ocx-${VERSION}-bun-darwin-arm64.tar.gz`, - `ocx-${VERSION}-bun-darwin-x64.tar.gz`, - `ocx-${VERSION}-bun-windows-x64.zip`, - `ocx-${VERSION}-bun-linux-x64.tar.gz`, - `ocx-${VERSION}-bun-linux-arm64.tar.gz`, - `OpenCodex-${VERSION}-macos.dmg`, - `OpenCodex-${VERSION}-macos.app.tar.gz`, - `OpenCodex-${VERSION}-windows-x64.msi`, - `OpenCodex-${VERSION}-linux-x86_64.AppImage`, - `OpenCodex-${VERSION}-linux-amd64.deb`, + ...standaloneTargets.map(target => standaloneArchiveName(VERSION, target)), + ...desktopTargets.flatMap(target => + bundlesByTarget[target]!.map(bundle => `OpenCodex-${VERSION}-${bundle.name}`)), ]; - const signed = new Set([ - `OpenCodex-${VERSION}-macos.app.tar.gz`, - `OpenCodex-${VERSION}-windows-x64.msi`, - `OpenCodex-${VERSION}-linux-x86_64.AppImage`, - ]); + const updaterSuffixes = new Set(Object.values(platformFiles)); + const signed = new Set( + produced.filter(name => updaterSuffixes.has(name.slice(`OpenCodex-${VERSION}-`.length))), + ); for (const name of produced) { writeAsset(dir, name, Buffer.from(`payload:${name}`)); if (signed.has(name)) { @@ -616,9 +620,7 @@ describe("release asset verification", () => { // must be exactly the produced payloads plus their companions. const expected = expectedReleaseAssets({ version: VERSION, - desktopTargets: releaseMatrixTargets( - readFileSync(join(root, ".github", "workflows", "release.yml"), "utf8"), - ).desktopTargets, + desktopTargets, requireSignatures: true, }); const oracle = produced.flatMap(name => @@ -642,9 +644,9 @@ describe("release asset verification", () => { expect(receipt.checksumsVerified) .toBe(produced.length); expect(receipt.signaturesVerified).toBe(signed.size); - expect(receipt.manifestPlatforms).toEqual([ - "darwin-aarch64", "darwin-x86_64", "linux-x86_64", "windows-x86_64", - ]); + // Same rule as the signed set: the platform list is the updater table's keys, + // not a copy of them. + expect(receipt.manifestPlatforms).toEqual(Object.keys(platformFiles).sort()); expect(JSON.parse(readFileSync(receiptPath, "utf8"))).toEqual(receipt); const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { From 1687636193d3559c2bc7cc37f427dea71e693c34 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 17:24:10 +0900 Subject: [PATCH 10/17] fix(runtime): close ownership mutation races (#5426) * test(service): assert ownership parser behavior * fix(service): fence runtime start and stop ownership * fix(update): hold runtime authority through replacement * docs(runtime): record ownership mutation boundaries * test(cli): follow transactional start prewarm * test(cli): anchor fenced start refusal * test(update): assert lock boundary behavior --- bin/ocx.mjs | 555 +++++++++++------- scripts/test-layout/layout.json | 1 + src/cli/index.ts | 186 +++--- src/cli/start-ownership-publication.ts | 56 ++ src/server/index.ts | 13 +- src/server/index/spend-ledger-lifecycle.ts | 42 +- src/server/lifecycle.ts | 8 +- src/service/ownership-mutation-lease.d.mts | 9 + src/service/ownership-mutation-lease.mjs | 10 + src/update/runtime-ownership.d.mts | 30 + src/update/runtime-ownership.mjs | 47 ++ structure/runtime.md | 15 +- tests/cli/cli-catalog-prewarm.test.ts | 16 +- tests/cli/cli-dispatch.test.ts | 9 +- tests/cli/cli-ready.test.ts | 18 +- tests/cli/start-ownership-publication.test.ts | 83 +++ .../clients/desktop-install-identity.test.ts | 16 +- tests/fixtures/test-layout-expected.json | 1 + .../loopback-listener-integration.test.ts | 8 +- tests/server/spend-ledger-lifecycle.test.ts | 12 +- tests/service/service-ownership-state.test.ts | 14 + tests/update/update-desktop-owner.test.ts | 92 ++- tests/update/update-stop-first.test.ts | 39 +- 23 files changed, 912 insertions(+), 368 deletions(-) create mode 100644 src/cli/start-ownership-publication.ts create mode 100644 tests/cli/start-ownership-publication.test.ts diff --git a/bin/ocx.mjs b/bin/ocx.mjs index 1e483f754ee..fef7686638c 100755 --- a/bin/ocx.mjs +++ b/bin/ocx.mjs @@ -12,13 +12,21 @@ import { spawn, spawnSync } from "node:child_process"; 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"; -import { planUpdateRuntimeHandling } from "../src/update/runtime-ownership.mjs"; +import { + inspectPackageRuntimeLiveness, + planStoppedRuntimeRecovery, + planUpdateRuntimeHandling, +} from "../src/update/runtime-ownership.mjs"; import { inspectInstallStateBytes, selectAuthoritativeServiceState, serviceStateFilesFor, } from "../src/service/install-state-contract.mjs"; -import { acquireOwnershipMutationLease } from "../src/service/ownership-mutation-lease.mjs"; +import { + acquireOwnershipMutationLease, + ownershipMutationLeaseChildEnvironment, + unprivilegedOwnershipMutationEnvironment, +} from "../src/service/ownership-mutation-lease.mjs"; import { randomBytes } from "node:crypto"; import { createRequire } from "node:module"; import { existsSync, readFileSync, readdirSync } from "node:fs"; @@ -48,6 +56,9 @@ import { } from "../src/update/codex-cli-update-launch-policy.mjs"; const PKG = "@bitkyc08/opencodex"; +const UPDATE_RECOVERY_READY_MS = 30_000; +const UPDATE_RECOVERY_POLL_MS = 100; +const UPDATE_RECOVERY_SLEEP = new Int32Array(new SharedArrayBuffer(4)); try { process.cwd(); } catch { @@ -290,8 +301,13 @@ function runPackageManagerSelfUpdate(manager) { : ["none", selected.revision]), }; }; + const ownershipIdentity = observation => observation.ownershipUnknown + ? null + : JSON.stringify(observation.ownership + ? ["owned", observation.ownership.owner, observation.ownership.installId, observation.ownership.consentGeneration] + : ["none"]); const initialOwnership = readOwnership(); - const runtimePlan = planUpdateRuntimeHandling({ ...initialOwnership, serviceInstalled: serviceWasInstalled }); + let runtimePlan = planUpdateRuntimeHandling({ ...initialOwnership, serviceInstalled: serviceWasInstalled }); 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."); @@ -342,39 +358,45 @@ function runPackageManagerSelfUpdate(manager) { } } - // Capture listen target before stop clears runtime-port.json (mirrors GUI/CLI update worker). - // Do not treat a live runtime port of 10100 as "missing" — track whether the read succeeded. + function readCurrentRuntimeTarget() { + let raw; + try { + raw = readFileSync(join(configDir(), "runtime-port.json"), "utf8"); + } catch (error) { + return error && typeof error === "object" && "code" in error && error.code === "ENOENT" + ? { kind: "absent" } + : { kind: "unknown" }; + } + try { + const rt = JSON.parse(raw); + const pid = Number(rt?.pid); + if (!Number.isFinite(rt?.port) || rt.port <= 0 || rt.port > 65535 + || !Number.isSafeInteger(pid) || pid <= 0) return { kind: "unknown" }; + return { kind: "target", target: { + pid, + port: Math.trunc(rt.port), + hostname: typeof rt.hostname === "string" && rt.hostname.trim() !== "" + ? rt.hostname.trim() + : null, + } }; + } catch { return { kind: "unknown" }; } + } + + // Capture the recovery target before stop clears runtime-port.json. Replacement safety + // re-reads this record under the mutation lease instead of trusting this snapshot. let bakePort = 10100; // The hostname travels with the port: a proxy bound to ::1 or a specific interface is // invisible to a probe that assumes 127.0.0.1, and "no answer" would then read as // "stopped" for exactly the proxy the probe exists to find. let bakeHostname = "127.0.0.1"; - let sawRuntimePort = false; - let sawRuntimeHostname = false; - try { - const rt = JSON.parse(readFileSync(join(configDir(), "runtime-port.json"), "utf8")); - if (Number.isFinite(rt?.port) && rt.port > 0 && rt.port <= 65535) { - // Only trust runtime when its pid still looks alive (stale crash leftovers fall back to config). - const rtPid = Number(rt?.pid); - let runtimeLive = false; - if (Number.isSafeInteger(rtPid) && rtPid > 0) { - try { - process.kill(rtPid, 0); - runtimeLive = true; - } catch (e) { - if (e && typeof e === "object" && "code" in e && e.code === "EPERM") runtimeLive = true; - } - } - if (runtimeLive) { - bakePort = Math.trunc(rt.port); - if (typeof rt?.hostname === "string" && rt.hostname.trim() !== "") { - bakeHostname = rt.hostname.trim(); - sawRuntimeHostname = true; - } - sawRuntimePort = true; - } - } - } catch { /* fall through to config */ } + const initialRuntimeObservation = readCurrentRuntimeTarget(); + const initialRuntimeTarget = initialRuntimeObservation.kind === "target" ? initialRuntimeObservation.target : null; + let sawRuntimePort = initialRuntimeTarget !== null; + let sawRuntimeHostname = initialRuntimeTarget?.hostname !== null && initialRuntimeTarget?.hostname !== undefined; + if (initialRuntimeTarget) { + bakePort = initialRuntimeTarget.port; + if (initialRuntimeTarget.hostname) bakeHostname = initialRuntimeTarget.hostname; + } // Port and hostname resolve INDEPENDENTLY: a legacy runtime record carries a port and no // hostname, and skipping config in that case probed 127.0.0.1 for a proxy bound to ::1. if (!sawRuntimePort || bakeHostname === "127.0.0.1") { @@ -390,6 +412,18 @@ function runPackageManagerSelfUpdate(manager) { } // Wildcard and bracketed-IPv6 normalization lives in probeProxyLiveness, so both lanes // get it from one place. + function currentPackageRuntimeLiveness() { + return inspectPackageRuntimeLiveness({ + capturedTarget: { port: bakePort, hostname: bakeHostname }, + readCurrentTarget: () => { + const current = readCurrentRuntimeTarget(); + return current.kind === "target" + ? { kind: "target", target: { port: current.target.port, hostname: current.target.hostname ?? bakeHostname } } + : current; + }, + probe: target => probeProxyLiveness(target.port, target.hostname), + }).overall; + } const launcher = fileURLToPath(import.meta.url); // The pnpm owner preflight has verified this package tree and global group. Keep that exact @@ -399,13 +433,17 @@ function runPackageManagerSelfUpdate(manager) { ? join(owner.packagePath, "bin", "ocx.mjs") : launcher; let postUpdateLauncherUsable = true; + let delegatedOwnershipMutationToken = null; + const mutationChildEnvironment = () => delegatedOwnershipMutationToken + ? ownershipMutationLeaseChildEnvironment(process.env, delegatedOwnershipMutationToken) + : unprivilegedOwnershipMutationEnvironment(process.env); function startProxyDirectly() { if (!postUpdateLauncherUsable || !existsSync(postUpdateLauncher)) { console.error("opencodex: cannot restart the proxy because the launcher is missing; reinstall opencodex manually."); - return; + return false; } - const env = { ...process.env }; + const env = mutationChildEnvironment(); delete env.OCX_SERVICE; console.log(`Attempting to restart the proxy on port ${bakePort}.`); const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(bakePort)], { @@ -418,13 +456,24 @@ function runPackageManagerSelfUpdate(manager) { console.error(`opencodex: direct proxy restart failed: ${error.message}`); }); child.unref(); + const deadline = Date.now() + UPDATE_RECOVERY_READY_MS; + while (Date.now() < deadline) { + const current = readCurrentRuntimeTarget(); + if (current.kind === "target" + && probeProxyLiveness(current.target.port, current.target.hostname ?? bakeHostname) === "live") return true; + Atomics.wait(UPDATE_RECOVERY_SLEEP, 0, 0, UPDATE_RECOVERY_POLL_MS); + } + console.error("opencodex: the recovery proxy did not publish a healthy runtime before the recovery deadline."); + return false; } function refreshBackgroundServiceOrStartDirect() { const prevBake = process.env.OCX_BAKE_PORT; process.env.OCX_BAKE_PORT = String(bakePort); try { - let svc = spawnSync(process.execPath, serviceRefreshArgs(), { stdio: "inherit", windowsHide: true }); + let svc = spawnSync(process.execPath, serviceRefreshArgs(), { + stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), + }); // `serviceWasInstalled` is inferred from service-state.json alone, which can be // STALE — present while the registration is gone. Repair refuses that case by // design, and its thrown Error is indistinguishable from any other failure at @@ -435,7 +484,9 @@ function runPackageManagerSelfUpdate(manager) { // could re-register a service the user just uninstalled. if (svc.status !== 0 && readServiceInstalledFromStatus(postUpdateLauncher) === false) { console.log("No registered service found — installing it instead."); - svc = spawnSync(process.execPath, serviceInstallArgs(), { stdio: "inherit", windowsHide: true }); + svc = spawnSync(process.execPath, serviceInstallArgs(), { + stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), + }); } let needDirectStart = svc.status !== 0; if (!needDirectStart) { @@ -487,210 +538,263 @@ function runPackageManagerSelfUpdate(manager) { } } - // Never replace package files under a live proxy — stop it first (full `ocx stop` - // semantics: graceful drain, service stop, native Codex restore). Gate on the service - // and the runtime-port record too: a service-managed or orphaned proxy can be live - // while ocx.pid is stale/missing. - if (trayBeforeUpdate.stopBeforeReplacement) { - console.log("⏹ Handing off the Windows tray before updating..."); - try { - handoffWindowsTrayForUpdate(trayBeforeUpdate, { - stop: () => { - const stopped = runTrayLifecycle(launcher, "stop"); - return { exitStatus: stopped.status, running: trayInstallState().running }; - }, - start: () => runTrayLifecycle(launcher, "start"), - }); - } catch { - console.error("opencodex: could not stop the Windows tray; aborting before package replacement."); + const updateLease = acquireOwnershipMutationLease(serviceStatePaths); + delegatedOwnershipMutationToken = updateLease.token; + let updateLeaseReleased = false; + const releaseUpdateLease = () => { + if (updateLeaseReleased) return; + updateLeaseReleased = true; + delegatedOwnershipMutationToken = null; + updateLease.release(); + }; + + let res; + try { + // 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 }); + if (lockedOwnership.subjectToken !== initialOwnership.subjectToken || !lockedPlan.mayReplacePackage) { + releaseUpdateLease(); + console.error(lockedPlan.notice + ?? "opencodex: update stopped because runtime ownership changed before stop authorization; rerun from the beginning."); process.exit(1); } - } - const hasRuntimeState = - existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); - - function recoverStoppedRuntimeAfterFailure() { - // Nothing was stopped under a foreign owner, so there is nothing to recover — and - // starting a proxy here would put a second one beside the runtime the app is managing. - const recoveryPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); - if (!recoveryPlan.mayStopRuntime) return; - if (!postUpdateLauncherUsable) { - console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); - return; + runtimePlan = lockedPlan; + const stoppedOwnershipIdentity = ownershipIdentity(lockedOwnership); + + // Never replace package files under a live proxy — stop it first (full `ocx stop` + // semantics: graceful drain, service stop, native Codex restore). Gate on the service + // and the runtime-port record too: a service-managed or orphaned proxy can be live + // while ocx.pid is stale/missing. + if (trayBeforeUpdate.stopBeforeReplacement) { + console.log("⏹ Handing off the Windows tray before updating..."); + try { + handoffWindowsTrayForUpdate(trayBeforeUpdate, { + stop: () => { + const stopped = runTrayLifecycle(launcher, "stop"); + return { exitStatus: stopped.status, running: trayInstallState().running }; + }, + start: () => runTrayLifecycle(launcher, "start"), + }); + } catch { + releaseUpdateLease(); + console.error("opencodex: could not stop the Windows tray; aborting before package replacement."); + process.exit(1); + } } - if (recoveryPlan.mayRestoreService) { - console.warn("opencodex: update failed after stopping the proxy — restoring the previous background service."); - refreshBackgroundServiceOrStartDirect(); - } else if (hasRuntimeState) { - console.warn("opencodex: update failed after stopping the proxy — restarting the previous version directly."); - startProxyDirectly(); + const hasRuntimeState = + existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); + let stopAttempted = false; + + function recoverStoppedRuntimeAfterFailure(reason) { + const recoveryOwnership = readOwnership(); + const recoveryLiveness = currentPackageRuntimeLiveness(); + const recovery = planStoppedRuntimeRecovery({ + stopAttempted, + ...recoveryOwnership, + sameOwner: ownershipIdentity(recoveryOwnership) === stoppedOwnershipIdentity, + liveness: recoveryLiveness, + serviceInstalled: serviceWasInstalled, + launcherUsable: postUpdateLauncherUsable, + hadRuntimeState: hasRuntimeState, + }); + 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."); + } else if (recovery.reason.startsWith("runtime-")) { + console.error(`opencodex: ${reason}; package runtime liveness is ${recoveryLiveness}, so automatic recovery was refused.`); + } else if (recovery.reason === "launcher-unavailable") { + console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually."); + } else if (recovery.action === "service") { + console.warn(`opencodex: ${reason} after stopping the proxy — restoring the previous background service.`); + refreshBackgroundServiceOrStartDirect(); + } else if (recovery.action === "direct") { + console.warn(`opencodex: ${reason} after stopping the proxy — restarting the previous version directly.`); + startProxyDirectly(); + } + return recovery; } - } - // An outstanding pending-teardown receipt is a fourth reason to run the stop. After a - // parent crashed mid-deferral the service, pid and runtime records can all be absent - // while the shared client config still points at a proxy that is gone; installing over - // that silently skips the recovery the receipt was written to trigger (#3008). Presence - // is the whole test here — the launcher cannot parse it, and `ocx stop` is what decides - // whether the obligation is safe to finish. - const hasPendingTeardown = hasPendingTeardownIn(readdirSync, configDir()); - if (runtimePlan.mayStopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown)) { - console.log("⏹ Stopping the running proxy before updating..."); - const stopRes = spawnSync(process.execPath, [launcher, "stop"], { stdio: "inherit", windowsHide: true }); - const stillHasRuntimeState = - existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); - // A history-only failure means teardown succeeded and a backup manifest is waiting for - // review: the proxy is down and replacing package files is safe. Every other nonzero - // status is a stop that did not finish, and a signal kill (status null) says nothing - // about whether it did - both abort, because replacing files under a live server - // leaves it running mixed old and new modules (#3008). - // The same decision the Bun updater makes, from the same module (#3008). Absent PID and - // runtime files are weak evidence, so the captured endpoint is asked; "unknown" aborts - // because a silent listener is exactly the state where replacing files is dangerous. - const decision = decidePostStopUpdate({ - status: stopRes.status, - hasRuntimeState: stillHasRuntimeState, - // Re-checked AFTER the stop: a quarantined receipt lets the stop itself succeed - // (there is nothing left to stop), so a pre-stop check alone let the retry install - // over a teardown that never ran. - teardownOutstanding: hasPendingTeardownIn(readdirSync, configDir()), - liveness: probeProxyLiveness(bakePort, bakeHostname), - }); - const historyOnlyStop = decision.reason === "history-only"; - if (!decision.proceed) { - if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); - if (decision.reason === "teardown-outstanding") { - console.error("opencodex: a shared teardown from an earlier stop is still outstanding and needs manual review; aborting the update."); - console.error("opencodex: confirm no proxy is running, run 'ocx restore', then remove the pending-teardown file in the opencodex home."); - } else console.error(decision.reason === "proxy-unknown" - ? `opencodex: could not confirm the proxy on ${bakeHostname}:${bakePort} is stopped; aborting the update. Run 'ocx stop' and retry.` - : "opencodex: could not stop the running proxy; aborting the update. Run 'ocx stop' and retry."); + // An outstanding pending-teardown receipt is a fourth reason to run the stop. After a + // parent crashed mid-deferral the service, pid and runtime records can all be absent + // while the shared client config still points at a proxy that is gone; installing over + // that silently skips the recovery the receipt was written to trigger (#3008). Presence + // is the whole test here — the launcher cannot parse it, and `ocx stop` is what decides + // whether the obligation is safe to finish. + const hasPendingTeardown = hasPendingTeardownIn(readdirSync, configDir()); + const stopNeeded = serviceWasInstalled || hasRuntimeState || hasPendingTeardown; + if (stopNeeded && !runtimePlan.mayStopRuntime) { + releaseUpdateLease(); + console.error(runtimePlan.notice + ?? "opencodex: update stopped because this installation may not stop the current runtime."); process.exit(1); } - if (historyOnlyStop || historyRestoreIncomplete()) { - console.warn( - "opencodex: WARNING — Codex resume-history metadata restore is incomplete (a backup manifest remains).\n" + - " The DB may be busy or the manifest/target may need review; untracked routed history is intentionally unchanged.\n" + - " 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.", - ); + if (stopNeeded) { + stopAttempted = true; + console.log("⏹ Stopping the running proxy before updating..."); + const stopRes = spawnSync(process.execPath, [launcher, "stop"], { + stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(), + }); + const stillHasRuntimeState = + existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json")); + // A history-only failure means teardown succeeded and a backup manifest is waiting for + // review: the proxy is down and replacing package files is safe. Every other nonzero + // status is a stop that did not finish, and a signal kill (status null) says nothing + // about whether it did - both abort, because replacing files under a live server + // leaves it running mixed old and new modules (#3008). + // The same decision the Bun updater makes, from the same module (#3008). Absent PID and + // runtime files are weak evidence, so the captured endpoint is asked; "unknown" aborts + // because a silent listener is exactly the state where replacing files is dangerous. + const decision = decidePostStopUpdate({ + status: stopRes.status, + hasRuntimeState: stillHasRuntimeState, + // Re-checked AFTER the stop: a quarantined receipt lets the stop itself succeed + // (there is nothing left to stop), so a pre-stop check alone let the retry install + // over a teardown that never ran. + teardownOutstanding: hasPendingTeardownIn(readdirSync, configDir()), + liveness: probeProxyLiveness(bakePort, bakeHostname), + }); + const historyOnlyStop = decision.reason === "history-only"; + if (!decision.proceed) { + if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); + if (decision.reason === "teardown-outstanding") { + console.error("opencodex: a shared teardown from an earlier stop is still outstanding and needs manual review; aborting the update."); + console.error("opencodex: confirm no proxy is running, run 'ocx restore', then remove the pending-teardown file in the opencodex home."); + } else console.error(decision.reason === "proxy-unknown" + ? `opencodex: could not confirm the proxy on ${bakeHostname}:${bakePort} is stopped; aborting the update. Run 'ocx stop' and retry.` + : "opencodex: could not stop the running proxy; aborting the update. Run 'ocx stop' and retry."); + releaseUpdateLease(); + process.exit(1); + } + if (historyOnlyStop || historyRestoreIncomplete()) { + console.warn( + "opencodex: WARNING — Codex resume-history metadata restore is incomplete (a backup manifest remains).\n" + + " The DB may be busy or the manifest/target may need review; untracked routed history is intentionally unchanged.\n" + + " 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.", + ); + } } - } - const replacementLease = acquireOwnershipMutationLease(serviceStatePaths); - const replacementOwnership = readOwnership(); - const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled }); - const replacementLiveness = runtimePlan.mayStopRuntime - ? probeProxyLiveness(bakePort, bakeHostname) - : "dead"; - if (replacementOwnership.subjectToken !== initialOwnership.subjectToken - || !replacementPlan.mayReplacePackage - || replacementLiveness !== "dead") { - replacementLease.release(); - if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); - console.error(replacementPlan.notice - ?? "opencodex: update stopped because runtime ownership or liveness changed after the stop decision; rerun from the beginning."); - process.exit(1); - } + const replacementOwnership = readOwnership(); + const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled }); + const replacementLiveness = currentPackageRuntimeLiveness(); + if (replacementOwnership.subjectToken !== initialOwnership.subjectToken + || !replacementPlan.mayReplacePackage + || replacementLiveness !== "dead") { + recoverStoppedRuntimeAfterFailure("replacement was refused"); + releaseUpdateLease(); + if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start"); + console.error(replacementPlan.notice + ?? "opencodex: update stopped because runtime ownership or liveness changed after the stop decision; rerun from the beginning."); + process.exit(1); + } - // npm keeps the existing stage -> verify -> swap -> rollback flow. pnpm owns a - // content-addressable store and generated global shims, so its path uses pnpm's own - // global update operation and verifies the active group instead of renaming files. - console.log(`Updating${latest ? ` to v${latest}` : ""} (${manager === "npm" ? "transactional" : "pnpm-managed"})...`); - let res; - try { - if (manager === "npm") { - const packageDir = resolve(here, ".."); - const tx = transactionalNpmUpdate({ - packageDir, - pkgName: PKG, - targetVersion: latest || undefined, - tag, - runNpm: (args) => { - const invocation = npmInvocation(args); - if (!invocation) return { status: 1 }; - return spawnSync(invocation.file, invocation.args, { - stdio: "inherit", - timeout: 180000, - windowsHide: true, - ...invocation.options, - }); - }, - log: (line) => console.log(line), - }); - postUpdateLauncherUsable = tx.ok - || tx.rolledBack === true - || ["stage", "verify", "swap-backup"].includes(tx.phase); - if (tx.ok) { - res = { status: 0 }; - } else if (tx.phase === "stage" || tx.phase === "verify") { - // Live tree untouched: report and stop. Nothing to roll back. - console.error(`opencodex: update aborted before touching the live install (${tx.phase}): ${tx.error}`); - res = { status: 1 }; - } else { - console.error(`opencodex: update failed (${tx.phase}): ${tx.error}${tx.rolledBack ? " — previous version restored." : ""}`); - res = { status: 1 }; - } - } else { - const update = runPnpmGlobalUpdate({ - packageName: PKG, - currentVersion: current, - targetVersion: latest || undefined, - tag, - owner, - runningPackagePath: resolve(here, ".."), - runPnpm: (args, capture = false) => { - const invocation = pnpmOwnerInvocation(owner, args); - if (!invocation) return { status: 1 }; - return spawnSync(invocation.file, invocation.args, { - stdio: capture ? "pipe" : "inherit", - encoding: "utf8", - timeout: 180000, - windowsHide: true, - env: invocation.env, - ...invocation.options, - }); - }, - log: line => console.log(line), - }); - if (update.ok) { - // pnpm switches the active global group and updates its shim. Continue recovery - // through that fresh package tree, not the old group whose launcher is still - // executing this update. - postUpdateLauncher = join(update.path, "bin", "ocx.mjs"); - res = { status: 0 }; + // npm keeps the existing stage -> verify -> swap -> rollback flow. pnpm owns a + // content-addressable store and generated global shims, so its path uses pnpm's own + // global update operation and verifies the active group instead of renaming files. + console.log(`Updating${latest ? ` to v${latest}` : ""} (${manager === "npm" ? "transactional" : "pnpm-managed"})...`); + try { + if (manager === "npm") { + const packageDir = resolve(here, ".."); + const tx = transactionalNpmUpdate({ + packageDir, + pkgName: PKG, + targetVersion: latest || undefined, + tag, + runNpm: (args) => { + const invocation = npmInvocation(args); + if (!invocation) return { status: 1 }; + return spawnSync(invocation.file, invocation.args, { + ...invocation.options, + stdio: "inherit", + timeout: 180000, + windowsHide: true, + env: unprivilegedOwnershipMutationEnvironment(invocation.options?.env ?? process.env), + }); + }, + log: (line) => console.log(line), + }); + postUpdateLauncherUsable = tx.ok + || tx.rolledBack === true + || ["stage", "verify", "swap-backup"].includes(tx.phase); + if (tx.ok) { + res = { status: 0 }; + } else if (tx.phase === "stage" || tx.phase === "verify") { + // Live tree untouched: report and stop. Nothing to roll back. + console.error(`opencodex: update aborted before touching the live install (${tx.phase}): ${tx.error}`); + res = { status: 1 }; + } else { + console.error(`opencodex: update failed (${tx.phase}): ${tx.error}${tx.rolledBack ? " — previous version restored." : ""}`); + res = { status: 1 }; + } } else { - console.error(`opencodex: ${update.error}${update.rolledBack ? "." : " Manual recovery may be required."}`); - postUpdateLauncherUsable = Boolean(update.activePath); - if (update.activePath) postUpdateLauncher = join(update.activePath, "bin", "ocx.mjs"); - res = { status: 1 }; + const update = runPnpmGlobalUpdate({ + packageName: PKG, + currentVersion: current, + targetVersion: latest || undefined, + tag, + owner, + runningPackagePath: resolve(here, ".."), + runPnpm: (args, capture = false) => { + const invocation = pnpmOwnerInvocation(owner, args); + if (!invocation) return { status: 1 }; + return spawnSync(invocation.file, invocation.args, { + ...invocation.options, + stdio: capture ? "pipe" : "inherit", + encoding: "utf8", + timeout: 180000, + windowsHide: true, + env: unprivilegedOwnershipMutationEnvironment(invocation.env ?? process.env), + }); + }, + log: line => console.log(line), + }); + if (update.ok) { + // pnpm switches the active global group and updates its shim. Continue recovery + // through that fresh package tree, not the old group whose launcher is still + // executing this update. + postUpdateLauncher = join(update.path, "bin", "ocx.mjs"); + res = { status: 0 }; + } else { + console.error(`opencodex: ${update.error}${update.rolledBack ? "." : " Manual recovery may be required."}`); + postUpdateLauncherUsable = Boolean(update.activePath); + if (update.activePath) postUpdateLauncher = join(update.activePath, "bin", "ocx.mjs"); + res = { status: 1 }; + } } + } catch (error) { + // An unexpected throw means we cannot prove the live tree is untouched, so the + // legacy in-place install (which deletes live first) is exactly the wrong rescue — + // it recreates the #1849 destruction path. Report and stop; the boot probe and the + // recovery marker cover the swap-window states. + const manual = manager === "pnpm" + ? `pnpm add -g --allow-build=bun ${PKG}@${tag}` + : `npm install -g --allow-scripts=bun ${PKG}@${tag}`; + // An unexpected exception leaves the active package path unproven for either manager. + // Do not run service/tray/proxy recovery through a possibly half-swapped tree. + postUpdateLauncherUsable = false; + console.error(`opencodex: ${manager} update failed unexpectedly (${error?.message ?? error}). ` + + `The live install was not knowingly modified; run 'ocx update' again or reinstall with ${manual}.`); + res = { status: 1 }; } - } catch (error) { - // An unexpected throw means we cannot prove the live tree is untouched, so the - // legacy in-place install (which deletes live first) is exactly the wrong rescue — - // it recreates the #1849 destruction path. Report and stop; the boot probe and the - // recovery marker cover the swap-window states. - const manual = manager === "pnpm" - ? `pnpm add -g --allow-build=bun ${PKG}@${tag}` - : `npm install -g --allow-scripts=bun ${PKG}@${tag}`; - // An unexpected exception leaves the active package path unproven for either manager. - // Do not run service/tray/proxy recovery through a possibly half-swapped tree. - postUpdateLauncherUsable = false; - console.error(`opencodex: ${manager} update failed unexpectedly (${error?.message ?? error}). ` + - `The live install was not knowingly modified; run 'ocx update' again or reinstall with ${manual}.`); - res = { status: 1 }; + if (res.status !== 0) recoverStoppedRuntimeAfterFailure("update failed"); + } finally { + // Expected aborts release before process.exit(); this covers every thrown or newly-added + // path and keeps token restoration coupled to the lease itself. + releaseUpdateLease(); } - replacementLease.release(); const postInstallPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled }); if (res.status === 0) { console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`); @@ -716,7 +820,6 @@ function runPackageManagerSelfUpdate(manager) { process.exit(0); } if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable) runTrayLifecycle(postUpdateLauncher, "start"); - recoverStoppedRuntimeAfterFailure(); const manual = manager === "pnpm" ? `pnpm add -g --allow-build=bun ${PKG}@${tag}` : `npm install -g --allow-scripts=bun ${PKG}@${tag}`; diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f79307b4633..3905c739ac9 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -183,6 +183,7 @@ "provider-egress-fetch.test.ts": "responses", "provider-egress-management-validation.test.ts": "server", "start-args.test.ts": "cli", + "start-ownership-publication.test.ts": "cli", "responses-core-modules.test.ts": "responses", "responses-passthrough-transient-policy.test.ts": "responses", "responses-spend-ledger-wiring.test.ts": "responses", diff --git a/src/cli/index.ts b/src/cli/index.ts index b98a28b1e78..a9b411e9d32 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -104,6 +104,10 @@ import { startHistoryMigrationGuardian } from "../codex/history-migration-guardi import { maybeShowStarPrompt } from "./star-prompt"; import { scheduleCatalogPrewarm } from "./catalog-prewarm"; import { maybeShowUpdatePrompt } from "../update/notify"; +import { + bindAndPublishStartOwnership, + StartOwnershipRollbackUncertainError, +} from "./start-ownership-publication"; import { syncModelsToCodex } from "../codex/sync"; import { HUB_GATED_SKIP_MESSAGE, @@ -222,6 +226,13 @@ function startArgv(port?: number): string[] { return selfLaunchArgv(args); } +class StartCommandExit extends Error { + constructor(readonly exitCode: number) { + super(`start command exited with code ${exitCode}`); + this.name = "StartCommandExit"; + } +} + async function chooseListenPort( requestedPort?: number, options: { sibling?: boolean } = {}, @@ -294,17 +305,17 @@ async function chooseListenPort( // Same contract as the pre-bind owner check: the wrapper's retry loop terminates // on a zero exit, and the port it was asked to serve is already served. console.log(`Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}); service wrapper staying out of the way.`); - process.exit(0); + throw new StartCommandExit(0); } if (decision === "refuse-live-proxy") { console.error(`⚠️ Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}). Use 'ocx stop' first.`); - process.exit(1); + throw new StartCommandExit(1); } if (decision === "refuse-unidentified-holder") { console.error(`❌ Port ${preferred} is busy and its holder did not identify as opencodex.`); console.error(" Starting on another port would leave Codex pointed at a proxy you did not ask for."); console.error(" Stop whatever holds that port, or start on a free one with 'ocx start --port '."); - process.exit(1); + throw new StartCommandExit(1); } if (preferred > 0) { console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`); @@ -319,7 +330,7 @@ async function chooseListenPort( if (err instanceof PortUnavailableError) { console.error(`❌ ${err.message}`); console.error(" Stop whatever holds that port, or change config.port, then retry."); - process.exit(1); + throw new StartCommandExit(1); } throw err; } @@ -449,82 +460,99 @@ async function handleStart(options: { block?: boolean } = {}) { // live daemon holding resources while it overwrites its own binary. await maybeShowUpdatePrompt(); - const startLease = acquireOwnershipMutationLease(serviceStatePaths()); - // The earlier probe owned journal cleanup. This one owns the bind decision: an updater may - // have stopped the old runtime and acquired the same lease before package replacement. - const fencedLive = await findLiveProxy(START_OWNERSHIP_LIVENESS); - if (fencedLive) { - const decision = decideStartWithLiveOwner({ - livePort: fencedLive.port, - requestedPort, - ocxService: process.env.OCX_SERVICE, - }); - if (decision === "service-stay-out") { - startLease.release(); - console.log(`Proxy already running (PID ${fencedLive.pid ?? "unknown"}, port ${fencedLive.port}); service wrapper staying out of the way.`); - process.exit(0); - } - if (decision === "refuse") { - startLease.release(); - console.error(`⚠️ Proxy appeared before bind (PID ${fencedLive.pid ?? "unknown"}, port ${fencedLive.port}). Use 'ocx stop' first.`); - process.exit(1); - } - siblingStart = true; - } - - // Port selection is check-then-bind: a concurrent `ocx start`/`ensure` can win the port - // between the probe and Bun.serve. Soft starts may re-pick; hard-pinned `--port` retries - // the same port only (never hop — that was the remaining PR #152 gap). - let port = await chooseListenPort(requestedPort, { sibling: siblingStart }); - const { drainAndShutdown, isRecyclingForExit, startServer } = await import("../server"); - // One private readiness gate for this startServer invocation, captured by the - // listener's closure. handleStart owns it and transitions it after the - // post-startup sync settles. A second startServer in the same process would - // get its own gate and could never reset/mutate this one. - const readinessGate = createReadinessGate(); - let server: ReturnType; - const localAttestationSecret = createLocalAttestationSecret(); + type StartServerModule = typeof import("../server"); + type BoundStart = { + server: ReturnType; + serverModule: StartServerModule; + port: number; + readinessGate: ReturnType; + localAttestationSecret: string; + config: ReturnType; + }; + let boundStart: BoundStart; try { - for (let attempt = 0; ; attempt++) { - try { - server = startServer(port, { localAttestationSecret, readinessGate }); - // Prewarm the live provider model cache as soon as the port is bound so the - // first GUI /v1/models (and syncModelsToCodex below) share one discovery flight - // instead of racing duplicate upstream /models fetches. - scheduleCatalogPrewarm(); - break; - } catch (err) { - if (err instanceof SpendLedgerOwnerError) { - console.error(`❌ ${err.message}`); - startLease.release(); - process.exit(1); - } - if (err instanceof AuxiliaryListenerBindError || !isAddrInUse(err) || attempt >= 2) throw err; - if (requestedPort !== undefined) { - console.log(`⚠️ Port ${port} was taken while starting; waiting to retry the same port...`); - const hostname = loadConfig().hostname ?? "127.0.0.1"; - const freed = await waitForPortAvailable(port, hostname, { timeoutMs: 3_000, intervalMs: 50 }); - if (!freed) { - console.error(`❌ Port ${port} stayed busy; refusing to hop to an ephemeral port.`); - startLease.release(); - process.exit(1); + boundStart = await bindAndPublishStartOwnership({ + acquireLease: () => acquireOwnershipMutationLease(serviceStatePaths()), + bind: async () => { + // The earlier probe owned journal cleanup. This one owns the bind decision: an + // updater may have stopped the old runtime and acquired this lease for replacement. + const fencedLive = await findLiveProxy(START_OWNERSHIP_LIVENESS); + if (fencedLive) { + const decision = decideStartWithLiveOwner({ + livePort: fencedLive.port, + requestedPort, + ocxService: process.env.OCX_SERVICE, + }); + if (decision === "service-stay-out") { + console.log(`Proxy already running (PID ${fencedLive.pid ?? "unknown"}, port ${fencedLive.port}); service wrapper staying out of the way.`); + throw new StartCommandExit(0); + } + if (decision === "refuse") { + console.error(`⚠️ Proxy appeared before bind (PID ${fencedLive.pid ?? "unknown"}, port ${fencedLive.port}). Use 'ocx stop' first.`); + throw new StartCommandExit(1); + } + siblingStart = true; } - continue; - } - console.log(`⚠️ Port ${port} was taken while starting; picking another...`); - port = await chooseListenPort(requestedPort, { sibling: siblingStart }); - } - } - } finally { - startLease.release(); + + // Port selection is check-then-bind. The lease prevents every cooperating start or + // updater from turning that check into a different ownership decision. + let port = await chooseListenPort(requestedPort, { sibling: siblingStart }); + const serverModule = await import("../server"); + const readinessGate = createReadinessGate(); + const localAttestationSecret = createLocalAttestationSecret(); + const config = loadConfig(); + let server: ReturnType; + for (let attempt = 0; ; attempt++) { + try { + server = serverModule.startServer(port, { localAttestationSecret, readinessGate }); + break; + } catch (err) { + try { await serverModule.waitForFailedStartRollback(err); } + catch (rollbackError) { + throw new StartOwnershipRollbackUncertainError([err, rollbackError]); + } + if (err instanceof SpendLedgerOwnerError) { + console.error(`❌ ${err.message}`); + throw new StartCommandExit(1); + } + if (err instanceof AuxiliaryListenerBindError || !isAddrInUse(err) || attempt >= 2) throw err; + if (requestedPort !== undefined) { + console.log(`⚠️ Port ${port} was taken while starting; waiting to retry the same port...`); + const hostname = config.hostname ?? "127.0.0.1"; + const freed = await waitForPortAvailable(port, hostname, { timeoutMs: 3_000, intervalMs: 50 }); + if (!freed) { + console.error(`❌ Port ${port} stayed busy; refusing to hop to an ephemeral port.`); + throw new StartCommandExit(1); + } + continue; + } + console.log(`⚠️ Port ${port} was taken while starting; picking another...`); + port = await chooseListenPort(requestedPort, { sibling: siblingStart }); + } + } + return { server, serverModule, port, readinessGate, localAttestationSecret, config }; + }, + writePid: () => writePid(process.pid), + writeRuntime: bound => writeRuntimePort({ + pid: process.pid, + port: bound.port, + hostname: bound.config.hostname, + attestationSecret: bound.localAttestationSecret, + }), + stopBound: bound => bound.server.stop(true), + removeRuntime: () => removeRuntimePortIfPidIs(process.pid), + removePid: () => removePidIfValueIs(process.pid), + }); + } catch (error) { + if (error instanceof StartCommandExit) { process.exitCode = error.exitCode; return; } + throw error; } - // A single request's streaming error must never crash the daemon serving every - // other Codex session — capture the full stack to crash.log and stay up. - installCrashGuards(); - writePid(process.pid); - const config = loadConfig(); - writeRuntimePort({ pid: process.pid, port, hostname: config.hostname, attestationSecret: localAttestationSecret }); + const { server, serverModule, port, readinessGate, config } = boundStart; + const { drainAndShutdown, isRecyclingForExit } = serverModule; + // Records are visible now; background work may observe this runtime without a gap. + scheduleCatalogPrewarm(); + installCrashGuards(); // No pre-emptive snapshot here. `injectCodexConfig` journals the exact bytes it // is about to transform; snapshotting earlier only captured a baseline that could // already be stale by the time injection ran (#477). @@ -956,6 +984,12 @@ async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boole } async function handleStop() { + const lease = acquireOwnershipMutationLease(serviceStatePaths()); + try { return await handleStopUnlocked(); } + finally { lease.release(); } +} + +async function handleStopUnlocked() { // The receipt must name the endpoint the owner was stopping — an obligation nobody can // locate cannot be proven discharged. Only the runtime record knows it; a proxy started // with an explicit --port is not on the configured one. diff --git a/src/cli/start-ownership-publication.ts b/src/cli/start-ownership-publication.ts new file mode 100644 index 00000000000..33cdb428655 --- /dev/null +++ b/src/cli/start-ownership-publication.ts @@ -0,0 +1,56 @@ +export interface StartOwnershipLease { + release(): void; +} + +export class StartOwnershipRollbackUncertainError extends AggregateError { + constructor(errors: Iterable) { + super(errors, "start listener rollback could not be proven complete"); + this.name = "StartOwnershipRollbackUncertainError"; + } +} + +export interface StartOwnershipPublicationDeps { + acquireLease(): StartOwnershipLease; + bind(): Promise; + writePid(bound: TBound): void; + writeRuntime(bound: TBound): void; + stopBound(bound: TBound): void | Promise; + removeRuntime(): void; + removePid(): void; +} + +/** Bind and publish PID/runtime ownership as one lease-protected transaction. */ +export async function bindAndPublishStartOwnership( + deps: StartOwnershipPublicationDeps, +): Promise { + const lease = deps.acquireLease(); + let bound: TBound; + let releaseLease = true; + try { + try { bound = await deps.bind(); } + catch (error) { + if (error instanceof StartOwnershipRollbackUncertainError) releaseLease = false; + throw error; + } + try { + deps.writePid(bound); + deps.writeRuntime(bound); + } catch (error) { + const failures: unknown[] = [error]; + let stopFailed = false; + try { await deps.stopBound(bound); } + catch (failure) { stopFailed = true; failures.push(failure); } + try { deps.removeRuntime(); } catch (failure) { failures.push(failure); } + try { deps.removePid(); } catch (failure) { failures.push(failure); } + if (stopFailed) { + releaseLease = false; + throw new StartOwnershipRollbackUncertainError(failures); + } + if (failures.length > 1) throw new AggregateError(failures, "start ownership publication rollback failed"); + throw error; + } + return bound; + } finally { + if (releaseLease) lease.release(); + } +} diff --git a/src/server/index.ts b/src/server/index.ts index 5ab9dccb34f..61fbcec7d8a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -201,12 +201,13 @@ import { detectInstall } from "../update/index"; import { createServeOptions, type ServerIngress } from "./index/serve-options"; import { createClaudeInterceptLifecycle } from "./index/claude-intercept-lifecycle"; import { inspectStartupOwnership, resolveInboundBodyLimitWithWarning, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings"; -import { acquireSpendLedgerServerLifecycle, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle"; +import { acquireSpendLedgerServerLifecycle, recordFailedStartRollback, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle"; +export { waitForFailedStartRollback } from "./index/spend-ledger-lifecycle"; export function startServer(port?: number, deps: StartServerDeps = {}): Server { const spendLedgerLifecycle = acquireSpendLedgerServerLifecycle(getConfigDir()); try { return startServerWithSpendLedgerOwner(port, deps, spendLedgerLifecycle); } - catch (error) { spendLedgerLifecycle.releaseAfterFailedStart(); throw error; } + catch (error) { recordFailedStartRollback(error, spendLedgerLifecycle.releaseAfterFailedStart()); throw error; } } function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartServerDeps, spendLedgerLifecycle: SpendLedgerServerLifecycle): Server { @@ -777,16 +778,16 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe } }, ], - async () => { + async listenersStopped => { try { await backgroundLifecycle.release(); await releaseNativeMainStartupLifecycle(server); } finally { // icacls.exe from hardenConfigDir() holds the config dir open; a caller that // removes the dir right after stop() settles would hit EPERM/EBUSY on Windows - // otherwise. Runs even when an earlier release rejected — that rejection still - // propagates, but not before the child is drained. - try { spendLedgerLifecycle.release(); } + // otherwise. Config hardening still flushes when an earlier release rejects. The + // spend owner is retained when a listener stop failed because the socket may live. + try { if (listenersStopped) spendLedgerLifecycle.release(); } finally { await flushConfigDirHardening(startupConfigDir); } } }, diff --git a/src/server/index/spend-ledger-lifecycle.ts b/src/server/index/spend-ledger-lifecycle.ts index d7250642470..ddcc70a6a3b 100644 --- a/src/server/index/spend-ledger-lifecycle.ts +++ b/src/server/index/spend-ledger-lifecycle.ts @@ -8,11 +8,27 @@ import { spendPolicyFromConfig, } from "../../lib/spend-reservation-ledger"; +const failedStartRollbacks = new WeakMap>(); + +export function recordFailedStartRollback(error: unknown, rollback: Promise): void { + if ((typeof error === "object" && error !== null) || typeof error === "function") { + failedStartRollbacks.set(error, rollback); + } +} + +/** Keep an outer ownership lease until a synchronous start failure has closed every listener. */ +export function waitForFailedStartRollback(error: unknown): Promise { + if ((typeof error === "object" && error !== null) || typeof error === "function") { + return failedStartRollbacks.get(error) ?? Promise.resolve(); + } + return Promise.resolve(); +} + export interface SpendLedgerServerLifecycle { configure(spend: OcxSpendConfig | undefined): void; track }>(server: T): T; release(): void; - releaseAfterFailedStart(): void; + releaseAfterFailedStart(): Promise; } /** Acquire before config loading so every later startup failure has one rollback owner. */ @@ -39,28 +55,38 @@ export function acquireSpendLedgerServerLifecycle(configDir: string): SpendLedge return server; }, release, - releaseAfterFailedStart(): void { + releaseAfterFailedStart(): Promise { // Every listener that came up is stopped, newest first, and the lease is held until those // stops have actually SETTLED. Bun's Server.stop(true) returns a promise that resolves // once connections are closed, so discarding it handed the state directory back while a // listener could still be serving, which is the one thing single-writer ownership exists // to prevent. // - // This stays synchronous and returns void on purpose: startServer must not become async, - // so the wait is a continuation rather than an await. Rollback failures are contained - // because the startup error that brought us here is the one worth reporting. + // Listener shutdown starts synchronously, while the returned promise lets a caller that + // owns a broader mutation lease keep it until every close has settled. startServer itself + // remains synchronous. Rollback failures are reported beside the startup error by the + // outer ownership transaction, without claiming the listener is gone. const settling: Promise[] = []; + const failures: unknown[] = []; for (const stop of failedStartStops.splice(0).reverse()) { try { const pending = stop(); if (pending !== undefined) settling.push(Promise.resolve(pending)); - } catch { /* a rollback failure must not replace the startup error that caused it */ } + } catch (failure) { failures.push(failure); } } const finish = (): void => { try { release(); } catch { /* same: the startup error is the one that matters */ } }; - if (settling.length === 0) { finish(); return; } - void Promise.allSettled(settling).then(finish); + if (settling.length === 0) { + if (failures.length > 0) return Promise.reject(new AggregateError(failures, "failed-start listener rollback was uncertain")); + finish(); + return Promise.resolve(); + } + return Promise.allSettled(settling).then(results => { + for (const result of results) if (result.status === "rejected") failures.push(result.reason); + if (failures.length > 0) throw new AggregateError(failures, "failed-start listener rollback was uncertain"); + finish(); + }); }, }; } diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index c3ab23e641b..fc757cc0670 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -357,12 +357,12 @@ export function getServerListenPort(): number | undefined { * caller sees the same result before a replacement binds the port. Swallowing it would let * `drainAndShutdown` report success while a socket is still held. * - * `always` runs after the listeners regardless of their outcome, and its own failure joins the - * reported set rather than replacing it. + * `always` runs after the listeners regardless of their outcome and receives whether every + * listener stop succeeded. Its own failure joins the reported set rather than replacing it. */ export async function runListenerShutdown( steps: Array<() => Promise>, - always: () => Promise, + always: (listenersStopped: boolean) => Promise, ): Promise { const failures: unknown[] = []; // Close admission and start connection-owner cleanup before waiting for any drain. @@ -372,7 +372,7 @@ export async function runListenerShutdown( if (result.status === "rejected") failures.push(result.reason); } try { - await always(); + await always(failures.length === 0); } catch (error) { failures.push(error); } diff --git a/src/service/ownership-mutation-lease.d.mts b/src/service/ownership-mutation-lease.d.mts index 574df756c6f..48d293263cd 100644 --- a/src/service/ownership-mutation-lease.d.mts +++ b/src/service/ownership-mutation-lease.d.mts @@ -11,6 +11,15 @@ export interface OwnershipMutationLease { readonly token: string; release(): voi export declare const OWNERSHIP_MUTATION_LEASE_TOKEN_ENV: "OCX_OWNERSHIP_MUTATION_LEASE_TOKEN"; +export declare function ownershipMutationLeaseChildEnvironment( + environment: NodeJS.ProcessEnv, + token: string, +): NodeJS.ProcessEnv; + +export declare function unprivilegedOwnershipMutationEnvironment( + environment: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv; + export declare function acquireOwnershipMutationLease( statePaths: readonly string[], options?: OwnershipMutationLeaseOptions, diff --git a/src/service/ownership-mutation-lease.mjs b/src/service/ownership-mutation-lease.mjs index 3bb9919d227..008d4e14f27 100644 --- a/src/service/ownership-mutation-lease.mjs +++ b/src/service/ownership-mutation-lease.mjs @@ -24,6 +24,16 @@ const delegatedTokens = new Map(); const sleeper = new Int32Array(new SharedArrayBuffer(4)); export const OWNERSHIP_MUTATION_LEASE_TOKEN_ENV = "OCX_OWNERSHIP_MUTATION_LEASE_TOKEN"; +export function ownershipMutationLeaseChildEnvironment(environment, token) { + return { ...environment, [OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]: token }; +} + +export function unprivilegedOwnershipMutationEnvironment(environment) { + const child = { ...environment }; + delete child[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]; + return child; +} + function sleep(ms) { Atomics.wait(sleeper, 0, 0, ms); } function processAlive(pid) { try { process.kill(pid, 0); return true; } diff --git a/src/update/runtime-ownership.d.mts b/src/update/runtime-ownership.d.mts index 12762895238..754c3499a4d 100644 --- a/src/update/runtime-ownership.d.mts +++ b/src/update/runtime-ownership.d.mts @@ -8,3 +8,33 @@ export declare function planUpdateRuntimeHandling(input: { mayRestoreService: boolean; notice: string | null; }; + +export declare function planStoppedRuntimeRecovery(input: { + stopAttempted: boolean; + ownership: { owner: string; installId: string; consentGeneration: number } | null; + ownershipUnknown?: boolean; + sameOwner: boolean; + liveness: "live" | "dead" | "unknown"; + serviceInstalled: boolean; + launcherUsable: boolean; + hadRuntimeState: boolean; +}): { + action: "none" | "manual" | "service" | "direct"; + reason: string; +}; + +type RuntimeTarget = { port: number; hostname: string }; +type RuntimeLiveness = "live" | "dead" | "unknown"; + +export declare function inspectPackageRuntimeLiveness(input: { + capturedTarget: RuntimeTarget; + readCurrentTarget(): + | { kind: "target"; target: RuntimeTarget } + | { kind: "absent" } + | { kind: "unknown" }; + probe(target: RuntimeTarget): RuntimeLiveness; +}): { + current: RuntimeLiveness | "absent"; + captured: RuntimeLiveness; + overall: RuntimeLiveness; +}; diff --git a/src/update/runtime-ownership.mjs b/src/update/runtime-ownership.mjs index 432f223bc73..c9eb52e5df8 100644 --- a/src/update/runtime-ownership.mjs +++ b/src/update/runtime-ownership.mjs @@ -73,3 +73,50 @@ export function planUpdateRuntimeHandling({ ownership, ownershipUnknown = false, notice: null, }; } + +/** Decide recovery after this updater already stopped the prior CLI-owned runtime. */ +export function planStoppedRuntimeRecovery({ + stopAttempted, + ownership, + ownershipUnknown = false, + sameOwner, + liveness, + serviceInstalled, + launcherUsable, + hadRuntimeState, +}) { + if (!stopAttempted) return { action: "none", reason: "not-stopped" }; + if (ownershipUnknown) return { action: "manual", reason: "ownership-unknown" }; + if (!sameOwner || (ownership && ownership.owner !== "cli")) { + return { action: "none", reason: "ownership-transferred" }; + } + if (liveness !== "dead") return { action: "manual", reason: `runtime-${liveness}` }; + if (!launcherUsable) return { action: "manual", reason: "launcher-unavailable" }; + if (serviceInstalled) return { action: "service", reason: "same-cli-owner" }; + if (hadRuntimeState) return { action: "direct", reason: "same-cli-owner" }; + return { action: "none", reason: "nothing-to-restore" }; +} + +/** + * Re-read the current package runtime before probing. The result keeps an absent current + * record distinct from a dead captured endpoint while still projecting one fail-closed + * liveness verdict for replacement and recovery decisions. + */ +export function inspectPackageRuntimeLiveness({ capturedTarget, readCurrentTarget, probe }) { + const currentTarget = readCurrentTarget(); + const observations = new Map(); + const inspect = target => { + const key = `${target.hostname}:${target.port}`; + if (!observations.has(key)) observations.set(key, probe(target)); + return observations.get(key); + }; + // Probe the fresh record first. It is the address a replacement runtime may have + // published while the updater was waiting on the ownership lease. + const current = currentTarget.kind === "target" ? inspect(currentTarget.target) : currentTarget.kind; + const captured = inspect(capturedTarget); + const verdicts = current === "absent" ? [captured] : [current, captured]; + const overall = verdicts.includes("live") + ? "live" + : verdicts.includes("unknown") ? "unknown" : "dead"; + return { current, captured, overall }; +} diff --git a/structure/runtime.md b/structure/runtime.md index 25094f7e101..384b6b394e6 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -577,10 +577,15 @@ three independent authorities: package replacement, runtime stop and service res Unknown and desktop ownership deny all three because a claim alone does not prove that the live process is detached from the npm package; CLI ownership permits the ordinary stop-first flow. Both package updaters use `src/service/install-state-contract.mjs`, backed by the single -`state-record.mjs` parser and authority selector. A shared mutation lease covers the final subject/liveness -recheck through package replacement, and service install/start plus every state mutation take -the same lease, so the stop decision cannot go stale under a cooperating manager. Dashboard -restart keeps that lease through port reclaim and delegates its token to the repair child; -direct start takes it again for the final liveness check through successful bind. +`state-record.mjs` parser and authority selector. One mutation lease covers the fresh stop +authorization, the stop child, the current runtime-record re-read and package replacement. +The updater never treats the pre-stop address as proof that this installation is idle; +an unreadable current record is unknown, and a valid address is probed even when its recorded +PID is gone. Lease delegation is passed only to stop and recovery children, never package +manager children. A replacement refusal passes through owner-aware recovery: only the same CLI +owner revives the stopped runtime; foreign ownership stays transferred and unknown ownership +remains a reported recovery requirement. Dashboard restart delegates the lease token to its repair child. Direct +start holds the same lease through bind plus PID and runtime-address publication. If listener +rollback cannot prove the socket closed, the process retains its lease until exit. The registration is never deleted; `ocx service install` releases the marker only after the registration succeeds. diff --git a/tests/cli/cli-catalog-prewarm.test.ts b/tests/cli/cli-catalog-prewarm.test.ts index e2c1af3e1fa..2c9cdd3aa67 100644 --- a/tests/cli/cli-catalog-prewarm.test.ts +++ b/tests/cli/cli-catalog-prewarm.test.ts @@ -53,18 +53,18 @@ describe("catalog prewarm on handleStart bind", () => { } }); - test("handleStart schedules catalog prewarm immediately after a successful bind", async () => { + test("handleStart schedules catalog prewarm after ownership publication", async () => { const cli = (await readText("src/cli/index.ts")).replace(/\r\n/g, "\n"); - const bindIdx = cli.indexOf("server = startServer(port"); + const transactionIdx = cli.indexOf("boundStart = await bindAndPublishStartOwnership({"); + const publishedIdx = cli.indexOf("const { server, serverModule, port, readinessGate, config } = boundStart", transactionIdx); const prewarmIdx = cli.indexOf("scheduleCatalogPrewarm()"); - const breakIdx = cli.indexOf("\n break;", bindIdx); + const guardianIdx = cli.indexOf("const guardian = startTokenGuardian()", prewarmIdx); expect(cli).toContain('from "./catalog-prewarm"'); - expect(bindIdx).toBeGreaterThan(-1); - expect(prewarmIdx).toBeGreaterThan(bindIdx); - expect(breakIdx).toBeGreaterThan(prewarmIdx); - // Must stay inside the successful-bind try path, not only on a later sync. - expect(cli.slice(bindIdx, breakIdx)).toContain("scheduleCatalogPrewarm()"); + expect(transactionIdx).toBeGreaterThan(-1); + expect(publishedIdx).toBeGreaterThan(transactionIdx); + expect(prewarmIdx).toBeGreaterThan(publishedIdx); + expect(guardianIdx).toBeGreaterThan(prewarmIdx); expect(cli).not.toContain('void import("../codex/catalog").then(({ gatherRoutedModels })'); }); }); diff --git a/tests/cli/cli-dispatch.test.ts b/tests/cli/cli-dispatch.test.ts index 7cf4877feae..83502242139 100644 --- a/tests/cli/cli-dispatch.test.ts +++ b/tests/cli/cli-dispatch.test.ts @@ -407,13 +407,12 @@ describe("a busy preferred port never becomes a second proxy (#5004)", () => { // One 750ms probe is what produced the duplicate; the guard spends the larger budget. expect(fn).toContain("START_OWNERSHIP_LIVENESS"); - // Both refusals end the process, and the refusal a user sees is the one they already - // know from the owner path. - expect(fn).toMatch(/decision === "refuse-live-proxy"[\s\S]{0,400}?process\.exit\(1\)/); + // Both refusals preserve the exit code through the caller's lease-cleanup boundary. + expect(fn).toMatch(/decision === "refuse-live-proxy"[\s\S]{0,400}?StartCommandExit\(1\)/); expect(fn).toContain("Use 'ocx stop' first."); - expect(fn).toMatch(/decision === "refuse-unidentified-holder"[\s\S]{0,700}?process\.exit\(1\)/); + expect(fn).toMatch(/decision === "refuse-unidentified-holder"[\s\S]{0,700}?StartCommandExit\(1\)/); // The wrapper's `if %ERRORLEVEL% NEQ 0` loop still terminates on a served port. - expect(fn).toMatch(/decision === "service-stay-out"[\s\S]{0,500}?process\.exit\(0\)/); + expect(fn).toMatch(/decision === "service-stay-out"[\s\S]{0,500}?StartCommandExit\(0\)/); }); test("the pre-bind owner probe spends the same budget before it deletes state", () => { diff --git a/tests/cli/cli-ready.test.ts b/tests/cli/cli-ready.test.ts index 197c0ae276f..c214ecde156 100644 --- a/tests/cli/cli-ready.test.ts +++ b/tests/cli/cli-ready.test.ts @@ -850,19 +850,19 @@ describe("runReady production findLiveProxy deadline wiring (source-level)", () describe("handleStart OCX_SERVICE exit guard (source-level)", () => { const cliSource = readFileSync(repoPath("src/cli/index.ts"), "utf8"); - test("an already-live proxy exits 0 in OCX_SERVICE context", () => { + test("an already-live proxy preserves the service/refusal exit codes without bypassing cleanup", () => { // The `OCX_SERVICE === "1"` comparison moved into `decideStartWithLiveOwner` // (src/cli/dispatch.ts), where the sentinel semantics are asserted at runtime // across the whole matrix (tests/cli/cli-dispatch.test.ts). This oracle pins the - // exits that the decision routes to: stay-out exits 0, the conflict exits 1. + // typed exits that the decision routes to: stay-out returns 0, the conflict returns 1. expect(cliSource).toMatch(/decideStartWithLiveOwner\(\{/); - // Anchored at the owner branch. `chooseListenPort` carries its own stay-out/refusal pair - // for the busy-port guard (#5004) and it sits EARLIER in the file, so an unanchored match - // would quietly move to that one and stop asserting anything about this branch. - const ownerBranch = cliSource.slice(cliSource.indexOf("decideStartWithLiveOwner({")); - const stayOut = ownerBranch.match(/decision === "service-stay-out"[\s\S]{0,800}?process\.exit\(0\)/); - expect(stayOut, "the service stay-out decision must exit 0 when the port is already served").not.toBeNull(); - const nonService = ownerBranch.match(/Proxy already running[\s\S]{0,300}?process\.exit\(1\)/); + // Anchor after the lease transaction begins. The earlier preflight has the same decision + // pair but does not need a typed exit because it owns no lease yet. + const transaction = cliSource.slice(cliSource.indexOf("bindAndPublishStartOwnership({")); + const ownerBranch = transaction.slice(transaction.indexOf("decideStartWithLiveOwner({")); + const stayOut = ownerBranch.match(/decision === "service-stay-out"[\s\S]{0,800}?StartCommandExit\(0\)/); + expect(stayOut, "the service stay-out decision must return 0 when the port is already served").not.toBeNull(); + const nonService = ownerBranch.match(/decision === "refuse"[\s\S]{0,500}?StartCommandExit\(1\)/); expect(nonService, "non-service refusal keeps the exit 1 conflict error").not.toBeNull(); }); diff --git a/tests/cli/start-ownership-publication.test.ts b/tests/cli/start-ownership-publication.test.ts new file mode 100644 index 00000000000..23a3c825987 --- /dev/null +++ b/tests/cli/start-ownership-publication.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { + bindAndPublishStartOwnership, + StartOwnershipRollbackUncertainError, +} from "../../src/cli/start-ownership-publication"; + +function fixture(options: { + failPid?: boolean; + failRuntime?: boolean; + failStop?: boolean; + failRemoveRuntime?: boolean; + failRemovePid?: boolean; +} = {}) { + const events: string[] = []; + const deps = { + acquireLease: () => ({ release: () => { events.push("release"); } }), + bind: async () => { events.push("bind"); return { id: 1 }; }, + writePid: () => { + events.push("pid"); + if (options.failPid) throw new Error("pid write failed"); + }, + writeRuntime: () => { + events.push("runtime"); + if (options.failRuntime) throw new Error("runtime write failed"); + }, + stopBound: async () => { + events.push("stop"); + if (options.failStop) throw new Error("stop failed"); + }, + removeRuntime: () => { + events.push("remove-runtime"); + if (options.failRemoveRuntime) throw new Error("runtime cleanup failed"); + }, + removePid: () => { + events.push("remove-pid"); + if (options.failRemovePid) throw new Error("pid cleanup failed"); + }, + }; + return { events, deps }; +} + +describe("start ownership publication", () => { + test("success releases only after bind and both records", async () => { + const { events, deps } = fixture(); + await bindAndPublishStartOwnership(deps); + expect(events).toEqual(["bind", "pid", "runtime", "release"]); + }); + + test("a bind refusal releases without publishing or rollback", async () => { + const { events, deps } = fixture(); + deps.bind = async () => { events.push("bind-refused"); throw new Error("refused"); }; + await expect(bindAndPublishStartOwnership(deps)).rejects.toThrow("refused"); + expect(events).toEqual(["bind-refused", "release"]); + }); + + for (const failure of ["pid", "runtime"] as const) { + test(`${failure} publication failure stops and cleans before release`, async () => { + const { events, deps } = fixture({ + failPid: failure === "pid", + failRuntime: failure === "runtime", + }); + await expect(bindAndPublishStartOwnership(deps)).rejects.toThrow(`${failure} write failed`); + expect(events).toEqual(failure === "pid" + ? ["bind", "pid", "stop", "remove-runtime", "remove-pid", "release"] + : ["bind", "pid", "runtime", "stop", "remove-runtime", "remove-pid", "release"]); + }); + } + + test("listener rollback uncertainty cleans records but retains the lease", async () => { + const { events, deps } = fixture({ + failRuntime: true, + failStop: true, + }); + await expect(bindAndPublishStartOwnership(deps)).rejects.toBeInstanceOf(StartOwnershipRollbackUncertainError); + expect(events).toEqual(["bind", "pid", "runtime", "stop", "remove-runtime", "remove-pid"]); + }); + + test("cleanup failures still attempt both records and release after the listener stopped", async () => { + const { events, deps } = fixture({ failRuntime: true, failRemoveRuntime: true, failRemovePid: true }); + await expect(bindAndPublishStartOwnership(deps)).rejects.toBeInstanceOf(AggregateError); + expect(events).toEqual(["bind", "pid", "runtime", "stop", "remove-runtime", "remove-pid", "release"]); + }); +}); diff --git a/tests/clients/desktop-install-identity.test.ts b/tests/clients/desktop-install-identity.test.ts index 797b2192e1b..4772e1a6c95 100644 --- a/tests/clients/desktop-install-identity.test.ts +++ b/tests/clients/desktop-install-identity.test.ts @@ -1,12 +1,13 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; +import { parseServiceOwnershipRecord } from "../../src/service/state-record.mjs"; import { repoPath } from "../helpers/repo-root"; /** * The desktop app's half of the runtime-ownership claim. * * The claim lives in the shared service install state, which core owns across two files: the - * validation that decides what a record may say is in `src/service/install-state-contract.mjs`, + * validation that decides what a record may say is in `src/service/state-record.mjs`, * and the types, the three answers a read can give and `ownershipGrantedTo` — the comparison an * installation applies to its own locally stored install id — are in `src/service/state.ts`. The * shell holds the other half, an id of its own to compare against, and mirrors the rule rather @@ -21,7 +22,6 @@ const IDENTITY = repoPath(`${SHELL}/identity.rs`); const OWNERSHIP = repoPath(`${SHELL}/ownership.rs`); const STARTUP = repoPath(`${SHELL}/startup.rs`); const STATE = repoPath("src/service/state.ts"); -const CONTRACT = repoPath("src/service/install-state-contract.mjs"); function code(path: string): string { return readFileSync(path, "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, ""); @@ -31,7 +31,6 @@ describe("desktop install identity", () => { const identity = code(IDENTITY); const ownership = code(OWNERSHIP); const state = code(STATE); - const contract = code(CONTRACT); test("the installation's id is minted once and never rewritten", () => { // Exclusive, because two launches racing to mint would answer to two ids, and the second one @@ -55,10 +54,13 @@ describe("desktop install identity", () => { }); test("the owner values are the ones the record accepts", () => { - // Both halves of core's answer are read. The runtime rejection is what a record on disk meets, - // and the exported type is what every caller is compiled against; a parse that accepted a - // third owner and a type that forbade it would disagree exactly where a takeover happens. - expect(contract).toContain('value.owner !== "cli" && value.owner !== "desktop"'); + // Exercise the parser a record on disk actually meets, while also pinning the exported type + // every caller compiles against. If runtime acceptance and the type diverge, this takeover + // boundary fails at review instead of after an installation has claimed the runtime. + const claim = { installId: "install-a", consentGeneration: 1 }; + expect(parseServiceOwnershipRecord({ ...claim, owner: "cli" })).toEqual({ ...claim, owner: "cli" }); + expect(parseServiceOwnershipRecord({ ...claim, owner: "desktop" })).toEqual({ ...claim, owner: "desktop" }); + expect(parseServiceOwnershipRecord({ ...claim, owner: "another-owner" })).toBeNull(); expect(state).toContain('export type ServiceOwner = "cli" | "desktop"'); expect(ownership).toContain('#[serde(rename_all = "lowercase")]'); expect(ownership).toContain(" Cli,"); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d08b15a92d9..38e3a733c72 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -15,6 +15,7 @@ "provider-egress-fetch.test.ts": "responses", "provider-egress-management-validation.test.ts": "server", "start-args.test.ts": "cli", + "start-ownership-publication.test.ts": "cli", "responses-core-modules.test.ts": "responses", "responses-passthrough-transient-policy.test.ts": "responses", "responses-spend-ledger-wiring.test.ts": "responses", diff --git a/tests/server/loopback-listener-integration.test.ts b/tests/server/loopback-listener-integration.test.ts index 29a10a44135..150c1da8cbe 100644 --- a/tests/server/loopback-listener-integration.test.ts +++ b/tests/server/loopback-listener-integration.test.ts @@ -813,11 +813,11 @@ describe("composite listener shutdown", () => { async () => { ran.push("primary"); throw failure; }, async () => { ran.push("loopback"); }, ], - async () => { ran.push("lifecycle"); }, + async listenersStopped => { ran.push(`lifecycle:${listenersStopped}`); }, )).rejects.toBe(failure); // The whole point: a rejected primary stop must not strand the loopback socket or skip // the native lifecycle release. - expect(ran).toEqual(["primary", "loopback", "lifecycle"]); + expect(ran).toEqual(["primary", "loopback", "lifecycle:false"]); }); test("two failures are reported together rather than one hiding the other", async () => { @@ -848,8 +848,10 @@ describe("composite listener shutdown", () => { }); test("an all-clear shutdown resolves", async () => { - await expect(runListenerShutdown([async () => {}, async () => {}], async () => {})) + let listenersStopped: boolean | undefined; + await expect(runListenerShutdown([async () => {}, async () => {}], async stopped => { listenersStopped = stopped; })) .resolves.toBeUndefined(); + expect(listenersStopped).toBe(true); }); }); diff --git a/tests/server/spend-ledger-lifecycle.test.ts b/tests/server/spend-ledger-lifecycle.test.ts index 17154d46b8f..85607f05376 100644 --- a/tests/server/spend-ledger-lifecycle.test.ts +++ b/tests/server/spend-ledger-lifecycle.test.ts @@ -64,7 +64,7 @@ test("a failed start keeps the lease until every listener has actually stopped", lifecycle.track(second.server); expect(spendLedgerOwnerSnapshot().ownership).toBe("held"); - lifecycle.releaseAfterFailedStart(); + const rollback = lifecycle.releaseAfterFailedStart(); // Newest first, and both asked before anything is awaited. expect(stopOrder).toEqual(["second", "first"]); await drainContinuations(); @@ -76,7 +76,7 @@ test("a failed start keeps the lease until every listener has actually stopped", expect(spendLedgerOwnerSnapshot().ownership).toBe("held"); second.settle(); - await drainContinuations(); + await rollback; expect(spendLedgerOwnerSnapshot().ownership).toBe("unheld"); }); @@ -87,7 +87,7 @@ test("a listener whose stop rejects still stops the rest and still returns the d lifecycle.track(healthy.server); lifecycle.track(failing.server); - lifecycle.releaseAfterFailedStart(); + const rollback = lifecycle.releaseAfterFailedStart(); expect(stopOrder).toEqual(["failing", "healthy"]); failing.settle(); @@ -96,6 +96,10 @@ test("a listener whose stop rejects still stops the rest and still returns the d expect(spendLedgerOwnerSnapshot().ownership).toBe("held"); healthy.settle(); - await drainContinuations(); + await expect(rollback).rejects.toThrow("failed-start listener rollback was uncertain"); + expect(spendLedgerOwnerSnapshot().ownership).toBe("held"); + // Explicit test cleanup. Production keeps this owner until process exit because the + // rejected stop cannot prove the listener released its socket. + lifecycle.release(); expect(spendLedgerOwnerSnapshot().ownership).toBe("unheld"); }); diff --git a/tests/service/service-ownership-state.test.ts b/tests/service/service-ownership-state.test.ts index 4cac1931916..94a1aa140b4 100644 --- a/tests/service/service-ownership-state.test.ts +++ b/tests/service/service-ownership-state.test.ts @@ -36,6 +36,11 @@ import { type ServiceStateSwapDeps, } from "../../src/service/state"; import { assessServiceTakeoverCompatibility, type ManagingCliObservation } from "../../src/service/ownership-compatibility"; +import { + OWNERSHIP_MUTATION_LEASE_TOKEN_ENV, + ownershipMutationLeaseChildEnvironment, + unprivilegedOwnershipMutationEnvironment, +} from "../../src/service/ownership-mutation-lease.mjs"; let home: TempHome; /** @@ -522,6 +527,15 @@ describe("the generation cannot be reused", () => { }); describe("the anchor lock", () => { + test("delegation is granted only to selected child environments", () => { + const parent = { KEEP: "yes", [OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]: "stale" }; + const delegated = ownershipMutationLeaseChildEnvironment(parent, "current"); + const unprivileged = unprivilegedOwnershipMutationEnvironment(parent); + expect(delegated).toEqual({ KEEP: "yes", [OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]: "current" }); + expect(unprivileged).toEqual({ KEEP: "yes" }); + expect(parent[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV]).toBe("stale"); + }); + test("a live update lease blocks ownership mutation before the state lock is touched", () => { const leasePath = serviceStatePath() + ".mutation.lock"; const processInstance = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; diff --git a/tests/update/update-desktop-owner.test.ts b/tests/update/update-desktop-owner.test.ts index 963be923f09..004266a7df4 100644 --- a/tests/update/update-desktop-owner.test.ts +++ b/tests/update/update-desktop-owner.test.ts @@ -11,7 +11,11 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { repoPath } from "../helpers/repo-root"; -import { planUpdateRuntimeHandling } from "../../src/update/runtime-ownership.mjs"; +import { + inspectPackageRuntimeLiveness, + planStoppedRuntimeRecovery, + planUpdateRuntimeHandling, +} from "../../src/update/runtime-ownership.mjs"; import { parseInstallStateRecord, selectAuthoritativeServiceState } from "../../src/service/install-state-contract.mjs"; describe("the runtime-ownership veto", () => { @@ -47,6 +51,81 @@ describe("the runtime-ownership veto", () => { }); }); +describe("stopped runtime recovery authority", () => { + const base = { + stopAttempted: true, + ownership: null, + ownershipUnknown: false, + sameOwner: true, + liveness: "dead" as const, + serviceInstalled: false, + launcherUsable: true, + hadRuntimeState: true, + }; + + test("only the same readable CLI owner with dead endpoints can restart", () => { + expect(planStoppedRuntimeRecovery(base)).toEqual({ action: "direct", reason: "same-cli-owner" }); + expect(planStoppedRuntimeRecovery({ ...base, serviceInstalled: true })).toEqual({ + action: "service", reason: "same-cli-owner", + }); + }); + + test("foreign, unknown and live outcomes never revive the stopped runtime", () => { + expect(planStoppedRuntimeRecovery({ ...base, sameOwner: false })).toEqual({ + action: "none", reason: "ownership-transferred", + }); + expect(planStoppedRuntimeRecovery({ ...base, ownershipUnknown: true })).toEqual({ + action: "manual", reason: "ownership-unknown", + }); + for (const liveness of ["live", "unknown"] as const) { + expect(planStoppedRuntimeRecovery({ ...base, liveness })).toEqual({ + action: "manual", reason: `runtime-${liveness}`, + }); + } + }); +}); + +describe("replacement runtime inspection", () => { + const capturedTarget = { hostname: "127.0.0.1", port: 10100 }; + + test("the fresh runtime record is read and probed before the captured stop target", () => { + const currentTarget = { hostname: "127.0.0.1", port: 10200 }; + const events: string[] = []; + const result = inspectPackageRuntimeLiveness({ + capturedTarget, + readCurrentTarget: () => { events.push("read-current"); return { kind: "target", target: currentTarget }; }, + probe: target => { + events.push(`probe:${target.port}`); + return target === currentTarget ? "live" : "dead"; + }, + }); + expect(events).toEqual(["read-current", `probe:${currentTarget.port}`, `probe:${capturedTarget.port}`]); + expect(result).toEqual({ current: "live", captured: "dead", overall: "live" }); + }); + + test("an absent current record stays distinct from a dead captured endpoint", () => { + const events: string[] = []; + const result = inspectPackageRuntimeLiveness({ + capturedTarget, + readCurrentTarget: () => { events.push("read-current"); return { kind: "absent" }; }, + probe: target => { events.push(`probe:${target.port}`); return "dead"; }, + }); + expect(events).toEqual(["read-current", `probe:${capturedTarget.port}`]); + expect(result).toEqual({ current: "absent", captured: "dead", overall: "dead" }); + }); + + test("an unreadable current record fails closed even when the captured endpoint is dead", () => { + const events: string[] = []; + const result = inspectPackageRuntimeLiveness({ + capturedTarget, + readCurrentTarget: () => { events.push("read-current"); return { kind: "unknown" }; }, + probe: target => { events.push(`probe:${target.port}`); return "dead"; }, + }); + expect(events).toEqual(["read-current", `probe:${capturedTarget.port}`]); + expect(result).toEqual({ current: "unknown", captured: "dead", overall: "unknown" }); + }); +}); + describe("the Node and Bun paths share one full-record authority", () => { const state = (revision: number, installId = "desktop-a") => ({ version: 2, codexHome: "/codex", opencodexHome: "/opencodex", backend: "scheduler", @@ -99,11 +178,11 @@ describe("both updaters consult the shared rule", () => { test("the npm launcher gates its stop, its refresh and its failure recovery", () => { expect(launcher).toContain("from \"../src/update/runtime-ownership.mjs\""); - expect(launcher).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown))"); + expect(launcher).toContain("if (stopNeeded && !runtimePlan.mayStopRuntime)"); + expect(launcher).toContain("if (stopNeeded) {"); expect(launcher).toContain("if (!runtimePlan.mayReplacePackage)"); - // Nothing was stopped, so nothing is recovered: starting a proxy here would put a - // second one beside the runtime the app is managing. - expect(launcher).toContain("if (!recoveryPlan.mayStopRuntime) return;"); + expect(launcher).toContain("planStoppedRuntimeRecovery({"); + expect(launcher).toContain("if (postInstallPlan.mayRestoreService) {"); }); test("neither updater reimplements the decision", () => { @@ -123,7 +202,8 @@ describe("both updaters consult the shared rule", () => { expect(launcher).toContain("selectAuthoritativeServiceState("); expect(launcher).toContain("serviceStateFilesFor("); expect(launcher).not.toContain("parsed.ownership"); - expect(launcher).not.toContain("consentGeneration"); + const reader = launcher.slice(launcher.indexOf("const readOwnership = () =>"), launcher.indexOf("const ownershipIdentity =")); + expect(reader).not.toContain("consentGeneration"); // The authoritative reader delegates to the same module rather than keeping a twin. const state = readFileSync(repoPath("src", "service", "state.ts"), "utf8"); expect(state).toContain('from "./install-state-contract.mjs"'); diff --git a/tests/update/update-stop-first.test.ts b/tests/update/update-stop-first.test.ts index 8b6d0777970..f2e1ebc809d 100644 --- a/tests/update/update-stop-first.test.ts +++ b/tests/update/update-stop-first.test.ts @@ -786,7 +786,9 @@ esac // mid-deferral the service, pid and runtime records can all be absent while shared // client config still points at a proxy that is gone (#3008). expect(updateSource).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()))"); - expect(launcherSource).toContain("if (runtimePlan.mayStopRuntime && (serviceWasInstalled || hasRuntimeState || hasPendingTeardown))"); + expect(launcherSource).toContain("const stopNeeded = serviceWasInstalled || hasRuntimeState || hasPendingTeardown"); + expect(launcherSource).toContain("if (stopNeeded && !runtimePlan.mayStopRuntime)"); + expect(launcherSource).toContain("if (stopNeeded) {"); // The rule now lives in the shared post-stop decision both lanes import (#3008): a // history-only stop proceeds, every other nonzero status and any surviving runtime // state aborts. Pinned by tests/update/update-stop-classification.test.ts. @@ -794,6 +796,41 @@ esac expect(launcherSource).toContain("hasRuntimeState: stillHasRuntimeState"); }); + test("the Node updater holds one authority from stop permission through replacement", () => { + const leaseAt = launcherSource.indexOf("const updateLease = acquireOwnershipMutationLease("); + const lockedPlanAt = launcherSource.indexOf("const lockedPlan = planUpdateRuntimeHandling(", leaseAt); + const stopAt = launcherSource.indexOf('[launcher, "stop"]', lockedPlanAt); + const replacementAt = launcherSource.indexOf("const replacementOwnership = readOwnership()", stopAt); + const releaseAt = launcherSource.indexOf("releaseUpdateLease()", replacementAt); + expect(leaseAt).toBeGreaterThan(-1); + expect(lockedPlanAt).toBeGreaterThan(leaseAt); + expect(stopAt).toBeGreaterThan(lockedPlanAt); + expect(replacementAt).toBeGreaterThan(stopAt); + expect(releaseAt).toBeGreaterThan(replacementAt); + const stopEnvAt = launcherSource.indexOf("env: mutationChildEnvironment()", stopAt); + expect(stopEnvAt).toBeGreaterThan(stopAt); + expect(stopEnvAt).toBeLessThan(replacementAt); + expect(launcherSource.slice(lockedPlanAt, stopAt)).toContain("!runtimePlan.mayStopRuntime"); + const packageReplacement = launcherSource.slice(stopAt, launcherSource.indexOf("const postInstallPlan", stopAt)); + expect(packageReplacement.match(/unprivilegedOwnershipMutationEnvironment/g)).toHaveLength(2); + }); + + test("replacement refusal reaches owner-aware recovery before releasing authority", () => { + const refusalAt = launcherSource.indexOf("replacementOwnership.subjectToken !== initialOwnership.subjectToken"); + const recoverAt = launcherSource.indexOf('recoverStoppedRuntimeAfterFailure("replacement was refused")', refusalAt); + const releaseAt = launcherSource.indexOf("releaseUpdateLease()", recoverAt); + expect(refusalAt).toBeGreaterThan(-1); + expect(recoverAt).toBeGreaterThan(refusalAt); + expect(releaseAt).toBeGreaterThan(recoverAt); + const recovery = launcherSource.slice( + launcherSource.indexOf("function recoverStoppedRuntimeAfterFailure("), + launcherSource.indexOf("const hasPendingTeardown", launcherSource.indexOf("function recoverStoppedRuntimeAfterFailure(")), + ); + expect(recovery).toContain("planStoppedRuntimeRecovery"); + expect(recovery).toContain("sameOwner:"); + expect(recovery).toContain("currentPackageRuntimeLiveness()"); + }); + test("GUI worker update children use pipe stdio so background updates do not open consoles", () => { expect(updateSource).toContain("function updateChildStdio()"); expect(updateSource).toContain('process.env.OCX_SERVICE === "1"'); From 52acf81d86eb2da0fc3de259c94149a468e3e8d5 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:23:38 +0900 Subject: [PATCH 11/17] fix(cli): probe endpoint liveness in-process so the standalone resolve does not fork execPath (#5418) * fix(cli): probe endpoint liveness in-process so the standalone resolve does not fork execPath Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(cli): prove absence on every loopback host and see refusals inside AggregateError Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci: retrigger cross-platform run (macos 1/2 shard hit the 20-minute runner timeout) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(cli): keep a mixed aggregate out of the absence proof --------- Co-authored-by: jun Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/cli/index.ts | 17 ++-- src/cli/resolve.ts | 23 +++--- src/cli/status-probes.ts | 20 +---- src/cli/uninstall-plan.ts | 9 +++ src/server/proxy-liveness.ts | 75 +++++++++++++++++ tests/cli/cli-resolve.test.ts | 19 ++++- tests/cli/uninstall.test.ts | 6 +- tests/providers/xai/grok-lifecycle.test.ts | 2 +- tests/server/proxy-liveness.test.ts | 93 ++++++++++++++++++++++ 9 files changed, 224 insertions(+), 40 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index a9b411e9d32..88ab11392d7 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -67,7 +67,7 @@ import { quarantinePendingTeardown, } from "../config/pending-teardown"; import { collectStatus, deadProxyRoutingAdviceLines, detectMissingCodexCatalogPath, hubStatusLines, missingCodexCatalogLines, remoteHubBannerLine, remoteHubStatusLines, unusedProxyWarningLines } from "./status"; -import { endpointsToProve, everyEndpointProvenDown, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan"; +import { endpointsToProve, everyEndpointProvenDownAsync, sharedTeardownAuthorized, type UninstallObservation } from "./uninstall-plan"; import { takeFlag } from "./runtime-api"; import { parseStartOptions, StartArgsError } from "./start-args"; @@ -85,7 +85,14 @@ import { SpendLedgerOwnerError } from "../lib/spend-ledger-owner"; import { redactUrlForLog } from "../lib/redact"; import { dispatchCommand, decideBusyPreferredPort, decideStartWithLiveOwner } from "./dispatch"; import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports"; -import { findLiveProxy, probeHostname, probePortOwner, START_OWNERSHIP_LIVENESS, type LiveProxy } from "../server/proxy-liveness"; +import { + findLiveProxy, + probeEndpointLiveness, + probeHostname, + probePortOwner, + START_OWNERSHIP_LIVENESS, + type LiveProxy, +} from "../server/proxy-liveness"; import { createReadinessGate } from "../server/readiness"; import { isApiAuthRequired } from "../server/auth-cors"; import { runReady, type ReadyArgs } from "./ready"; @@ -1021,8 +1028,7 @@ async function handleStopUnlocked() { // An obligation that cannot name its endpoint cannot be proven discharged. if (!endpoint) return false; try { - const { probeProxyLiveness } = await import("../update/proxy-liveness-probe.mjs"); - return probeProxyLiveness(endpoint.port, endpoint.hostname) === "dead"; + return await probeEndpointLiveness(endpoint) === "dead"; } catch { // A probe that could not run is not evidence of absence. return false; @@ -1436,11 +1442,10 @@ async function handleUninstall() { /** Definitive "nothing is answering" on the endpoint this home would serve. */ const proxyEndpointProvenDown = async (): Promise => { try { - const { probeProxyLiveness } = await import("../update/proxy-liveness-probe.mjs"); // Every candidate, not just the preferred one: a stale runtime record pointing at a // closed port would otherwise "prove" a live proxy on the configured port is gone. const endpoints = endpointsToProve(readRuntimePort(), loadConfig()); - return everyEndpointProvenDown(endpoints, e => probeProxyLiveness(e.port, e.hostname)); + return await everyEndpointProvenDownAsync(endpoints, probeEndpointLiveness); } catch { return false; } diff --git a/src/cli/resolve.ts b/src/cli/resolve.ts index a4dcc851645..87c3fa69174 100644 --- a/src/cli/resolve.ts +++ b/src/cli/resolve.ts @@ -37,9 +37,14 @@ import { readConfigDiagnostics, type ConfigDiagnostics } from "../config"; import { getConfigDir } from "../config/paths"; import { readRuntimePort } from "../config/process-state"; import { packageVersion } from "../lib/package-version"; -import { findLiveProxy, START_OWNERSHIP_LIVENESS, type LiveProxy } from "../server/proxy-liveness"; -import { endpointsToProve, everyEndpointProvenDown, type ProbeEndpoint } from "./uninstall-plan"; -import { probeProxyLiveness } from "../update/proxy-liveness-probe.mjs"; +import { + findLiveProxy, + probeEndpointLiveness, + START_OWNERSHIP_LIVENESS, + type EndpointLiveness, + type LiveProxy, +} from "../server/proxy-liveness"; +import { endpointsToProve, everyEndpointProvenDownAsync, type ProbeEndpoint } from "./uninstall-plan"; /** Wire version of the resolve document. Bump only on an incompatible shape change. */ export const RESOLVE_SCHEMA = "ocx-resolve/1"; @@ -103,8 +108,8 @@ export interface ResolveIo { findLive?: () => Promise; /** Runtime-port record reader; production default is readRuntimePort. */ readRuntime?: () => { port?: number; hostname?: string } | null; - /** Tri-state endpoint probe; production default is the updater's probeProxyLiveness. */ - probeEndpoint?: (endpoint: ProbeEndpoint) => "live" | "dead" | "unknown"; + /** Tri-state endpoint probe; production default runs in-process for compiled standalone binaries. */ + probeEndpoint?: (endpoint: ProbeEndpoint) => EndpointLiveness | Promise; cliVersion?: () => string; stdout?: { log: (s: string) => void }; stderr?: { error: (s: string) => void }; @@ -174,11 +179,7 @@ export async function runResolve(args: ResolveArgs, io: ResolveIo = {}): Promise const readDiagnostics = io.readDiagnostics ?? readConfigDiagnostics; const findLive = io.findLive ?? (() => findLiveProxy(START_OWNERSHIP_LIVENESS)); const readRuntime = io.readRuntime ?? readRuntimePort; - // The updater's tri-state probe takes (port, hostname) and is plain .mjs (untyped); - // adapt it to the endpoint-shaped seam here. Its own return vocabulary is the - // closed "live" | "dead" | "unknown" set. - const probeEndpoint = io.probeEndpoint - ?? ((endpoint: ProbeEndpoint) => probeProxyLiveness(endpoint.port, endpoint.hostname) as "live" | "dead" | "unknown"); + const probeEndpoint = io.probeEndpoint ?? probeEndpointLiveness; const cliVersion = io.cliVersion ?? packageVersion; const configHome = configDir(); let diagnostics: ConfigDiagnostics; @@ -212,7 +213,7 @@ export async function runResolve(args: ResolveArgs, io: ResolveIo = {}): Promise // authorise starting a second runtime. let provenDown = false; try { - provenDown = everyEndpointProvenDown(endpointsToProve(readRuntime(), diagnostics.config), probeEndpoint); + provenDown = await everyEndpointProvenDownAsync(endpointsToProve(readRuntime(), diagnostics.config), probeEndpoint); } catch { // A probe that cannot run is not evidence of absence. provenDown = false; diff --git a/src/cli/status-probes.ts b/src/cli/status-probes.ts index d3848c95366..d4196ba5cff 100644 --- a/src/cli/status-probes.ts +++ b/src/cli/status-probes.ts @@ -1,5 +1,5 @@ import { readPidFileValue, readRuntimePort } from "../config/process-state"; -import { isOpencodexHealthz, probeHostname } from "../server/proxy-liveness"; +import { isConnectionRefused, isOpencodexHealthz, probeHostname } from "../server/proxy-liveness"; import { directLocalHttpFetch } from "../server/direct-local-http"; import { isProcessAlive } from "../lib/process-control"; @@ -26,23 +26,7 @@ export function proxyHealthFailureReason(error: unknown, signal: AbortSignal): " : "unreachable"; } -/** - * "Nothing is listening" is narrower than "the probe failed". `unreachable` covers every - * non-abort failure, including a socket that was ACCEPTED and then reset — which is what - * an in-flight start looks like mid-bind. Only a connect-phase refusal proves the port is - * free, so this reads the underlying errno instead of the display string. - */ -export function isConnectionRefused(error: unknown): boolean { - for (let current: unknown = error, depth = 0; current instanceof Error && depth < 4; depth++) { - const code = (current as { code?: unknown }).code; - if (code === "ECONNREFUSED" || code === "ConnectionRefused") return true; - // Bun surfaces the refusal as a plain message on some platforms; the errno name is - // still the discriminator, not a substring of arbitrary prose. - if (typeof code === "string" && code.endsWith("ECONNREFUSED")) return true; - current = (current as { cause?: unknown }).cause; - } - return false; -} +export { isConnectionRefused } from "../server/proxy-liveness"; /** * A proxy killed by a native trap or SIGKILL never runs the exit cleanup that removes diff --git a/src/cli/uninstall-plan.ts b/src/cli/uninstall-plan.ts index 0e1df1cd2df..4bfb9d4a7a6 100644 --- a/src/cli/uninstall-plan.ts +++ b/src/cli/uninstall-plan.ts @@ -84,3 +84,12 @@ export function everyEndpointProvenDown( if (endpoints.length === 0) return false; return endpoints.every(e => probe(e) === "dead"); } + +export async function everyEndpointProvenDownAsync( + endpoints: readonly ProbeEndpoint[], + probe: (e: ProbeEndpoint) => Promise<"live" | "dead" | "unknown"> | "live" | "dead" | "unknown", +): Promise { + if (endpoints.length === 0) return false; + const results = await Promise.all(endpoints.map(e => probe(e))); + return results.every(result => result === "dead"); +} diff --git a/src/server/proxy-liveness.ts b/src/server/proxy-liveness.ts index 594be867b02..98202e42d86 100644 --- a/src/server/proxy-liveness.ts +++ b/src/server/proxy-liveness.ts @@ -32,6 +32,8 @@ export interface HealthzIdentity { guiPairCapability?: unknown; } +export type EndpointLiveness = "live" | "dead" | "unknown"; + export interface LivenessIo { fetchFn?: typeof fetch; readPidFn?: () => number | null; @@ -85,6 +87,11 @@ export const START_OWNERSHIP_LIVENESS: Pick Promise; + export interface LiveProxy { pid: number | null; port: number; @@ -148,6 +155,74 @@ export function isOpencodexHealthz(body: HealthzIdentity | null): boolean { return body.status === "ok" && typeof body.version === "string" && typeof body.uptime === "number"; } +/** + * "Nothing is listening" is narrower than "the probe failed". Only a connect-phase refusal + * proves the endpoint is free; a timeout, reset, or other transport failure leaves the + * question open. + */ +export function isConnectionRefused(error: unknown): boolean { + const visit = (current: unknown, depth: number): boolean => { + if (depth >= 4) return false; + if (current === null || (typeof current !== "object" && typeof current !== "function")) return false; + const record = current as { code?: unknown; cause?: unknown; errors?: unknown }; + if (record.code === "ECONNREFUSED" || record.code === "ConnectionRefused") return true; + if (typeof record.code === "string" && record.code.endsWith("ECONNREFUSED")) return true; + if (Array.isArray(record.errors) && record.errors.length > 0) { + // One connect attempt fanned out over several addresses reports a single AggregateError. + // Only a unanimous refusal proves the endpoint is free: a bundle that mixes ECONNREFUSED + // with a timeout means one address answered nothing at all, and an address whose state is + // unreadable is unknown, not absence. Collapsing it to "refused" is how a second runtime + // gets started on a port that already has one. + return record.errors.every(error => visit(error, depth + 1)); + } + return visit(record.cause, depth + 1); + }; + return visit(error, 0); +} + +async function classifyHealthz( + url: string, + fetchFn: LivenessFetch, + timeoutMs: number, +): Promise { + try { + const response = await fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (response.status !== 200) return "unknown"; + const body = (await response.json().catch(() => undefined)) as HealthzIdentity | null | undefined; + if (body === undefined) return "unknown"; + return isOpencodexHealthz(body) ? "live" : "dead"; + } catch (error) { + return isConnectionRefused(error) ? "dead" : "unknown"; + } +} + +/** + * Tri-state probe of one endpoint, the in-process counterpart of + * `src/update/proxy-liveness-probe.mjs`. Only a connect-phase refusal or a clean 200 that is + * not ours proves "dead"; a timeout, reset, non-200 or unreadable body leaves the question + * open. Loopback endpoints are checked on both IPv4 and IPv6 because a listener may bind only + * one family. Runs in-process because a compiled standalone binary cannot fork `execPath -e`. + */ +export async function probeEndpointLiveness( + endpoint: { port: number; hostname?: string }, + io: Pick = {}, +): Promise { + if (!Number.isFinite(endpoint.port) || endpoint.port <= 0 || endpoint.port > 65535) return "dead"; + const fetchFn = io.fetchFn ?? directLocalHttpFetch; + const timeoutMs = io.timeoutMs ?? 1500; + let sawUnknown = false; + for (const hostname of loopbackProbeHosts(endpoint.hostname)) { + const result = await classifyHealthz( + `http://${hostname}:${endpoint.port}/healthz`, + fetchFn, + timeoutMs, + ); + if (result === "live") return "live"; + if (result === "unknown") sawUnknown = true; + } + return sawUnknown ? "unknown" : "dead"; +} + /** Identity-checked /healthz probe; null when unreachable, non-OK, or not our proxy. */ export async function proxyIdentityAt( port: number, diff --git a/tests/cli/cli-resolve.test.ts b/tests/cli/cli-resolve.test.ts index 69fbac9764b..a7177ff241b 100644 --- a/tests/cli/cli-resolve.test.ts +++ b/tests/cli/cli-resolve.test.ts @@ -127,6 +127,21 @@ describe("runResolve", () => { expect(parsed.port.effective).toBe(RESOLVE_DEFAULT_PORT); }); + test("accepts async dead probes for every candidate endpoint", async () => { + const lines: string[] = []; + const code = await runResolve({ json: true }, { + configDir: () => "/h", + readDiagnostics: () => ({ config: {}, source: "default", error: null } as ConfigDiagnostics), + findLive: async () => null, + readRuntime: () => ({ port: 10110, hostname: "127.0.0.1" }), + probeEndpoint: async () => "dead", + cliVersion: () => "1.2.3", + stdout: { log: value => lines.push(value) }, + }); + expect(code).toBe(0); + expect((JSON.parse(lines[0]!) as { liveness: { status: string } }).liveness.status).toBe("absent-proven"); + }); + test("an undecidable probe is unknown, and unknown is never answered as absent", async () => { // The launch decision keys on this verdict: a timed-out probe or a listener that // withholds /healthz must exit 1 rather than let the caller start a second runtime. @@ -152,8 +167,8 @@ describe("runResolve", () => { test("absence requires every endpoint dead, not just the configured one", async () => { // The runtime record can point at a live port while the configured port refuses; // answering from the configured port alone would shadow-start over the record. - // everyEndpointProvenDown short-circuits on the first non-dead answer: an unknown - // runtime endpoint defeats the proof without the configured one being probed. + // Every candidate is probed: an unknown runtime endpoint defeats the proof even when the + // configured endpoint is dead. const seen: string[] = []; const code = await runResolve({ json: true }, { configDir: () => "/h", diff --git a/tests/cli/uninstall.test.ts b/tests/cli/uninstall.test.ts index ea984f274fd..1b0e6f399b5 100644 --- a/tests/cli/uninstall.test.ts +++ b/tests/cli/uninstall.test.ts @@ -243,7 +243,7 @@ describe("uninstall gates shared teardown on a proven service stop", () => { }); }); test("proof covers every distinct endpoint, not just the preferred one", async () => { - const { endpointsToProve, everyEndpointProvenDown } = await import("../../src/cli/uninstall-plan"); + const { endpointsToProve, everyEndpointProvenDown, everyEndpointProvenDownAsync } = await import("../../src/cli/uninstall-plan"); // A stale runtime record pointing at a closed port, and the live proxy on the // configured one. Probing only the runtime candidate reports "dead" for a port nobody @@ -267,6 +267,8 @@ describe("uninstall gates shared teardown on a proven service stop", () => { expect(endpointsToProve(null, {})).toEqual([{ hostname: "127.0.0.1", port: 10100 }]); // An empty set is not proof of anything. expect(everyEndpointProvenDown([], () => "dead")).toBe(false); + expect(await everyEndpointProvenDownAsync(endpoints, async () => "dead")).toBe(true); + expect(await everyEndpointProvenDownAsync([], async () => "dead")).toBe(false); // A nonsense runtime port is skipped rather than probed. expect(endpointsToProve({ port: 0 }, { port: 10100 })).toEqual([{ hostname: "127.0.0.1", port: 10100 }]); }); @@ -284,7 +286,7 @@ describe("uninstall gates shared teardown on a proven service stop", () => { .toBeLessThan(windowStep.indexOf("observed.respawnWindowVerified = true;")); // And the proof itself asks every candidate. expect(fn).toContain("endpointsToProve(readRuntimePort(), loadConfig())"); - expect(fn).toContain("everyEndpointProvenDown(endpoints, e => probeProxyLiveness(e.port, e.hostname))"); + expect(fn).toContain("everyEndpointProvenDownAsync(endpoints, probeEndpointLiveness)"); }); const safeTeardown: UninstallObservation = { diff --git a/tests/providers/xai/grok-lifecycle.test.ts b/tests/providers/xai/grok-lifecycle.test.ts index 4c930e57f5d..e6e42c80759 100644 --- a/tests/providers/xai/grok-lifecycle.test.ts +++ b/tests/providers/xai/grok-lifecycle.test.ts @@ -299,7 +299,7 @@ describe("Grok fence lifecycle wiring", () => { expect(noPidBranch).toContain("stopFailed = true;"); expect(noPidBranch).toContain("ownershipBlocked = true;"); const gateFn = sliceFn(CLI_SOURCE, "const abandonedTeardownIsSafeToFinish", "let stopFailed = false;"); - expect(gateFn).toContain('probeProxyLiveness(endpoint.port, endpoint.hostname) === "dead"'); + expect(gateFn).toContain('probeEndpointLiveness(endpoint) === "dead"'); expect(gateFn).toContain("return false;"); }); diff --git a/tests/server/proxy-liveness.test.ts b/tests/server/proxy-liveness.test.ts index d675f04c8b8..ec47ab6de97 100644 --- a/tests/server/proxy-liveness.test.ts +++ b/tests/server/proxy-liveness.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { createServer } from "node:net"; import { createReadinessGate, runStartupReadinessSync, @@ -7,7 +8,9 @@ import { DEFAULT_PROBE_TIMEOUT_MS, findLiveProxy, isOpencodexHealthz, + isConnectionRefused, loopbackProbeHosts, + probeEndpointLiveness, probeHostname, probePortOwner, probeReadiness, @@ -90,6 +93,96 @@ describe("probeHostname", () => { }); }); +describe("probeEndpointLiveness", () => { + test("classifies identity, foreign, non-200, refusal, timeout, and invalid ports", async () => { + const endpoint = { port: 10100, hostname: "127.0.0.1" }; + const fakeFetch = (body: unknown, status = 200) => (async () => healthz(body, status)) as typeof fetch; + expect(await probeEndpointLiveness(endpoint, { fetchFn: fakeFetch(OURS) })).toBe("live"); + expect(await probeEndpointLiveness(endpoint, { fetchFn: fakeFetch({ status: "ok" }) })).toBe("dead"); + expect(await probeEndpointLiveness(endpoint, { fetchFn: fakeFetch(OURS, 503) })).toBe("unknown"); + const refusedServer = createServer(); + await new Promise((resolve, reject) => { + refusedServer.once("error", reject); + refusedServer.listen(0, "127.0.0.1", () => resolve()); + }); + const refusedPort = (refusedServer.address() as { port: number }).port; + await new Promise((resolve, reject) => { + refusedServer.close(error => error ? reject(error) : resolve()); + }); + expect(await probeEndpointLiveness({ port: refusedPort, hostname: "127.0.0.1" })).toBe("dead"); + expect(await probeEndpointLiveness(endpoint, { + fetchFn: (async () => { throw new DOMException("aborted", "AbortError"); }) as typeof fetch, + })).toBe("unknown"); + expect(await probeEndpointLiveness(endpoint, { + fetchFn: (async () => { throw new Error("connection reset"); }) as typeof fetch, + })).toBe("unknown"); + expect(await probeEndpointLiveness({ port: 0 }, { fetchFn: fakeFetch(OURS) })).toBe("dead"); + }); + + test("checks both loopback families sequentially", async () => { + const seen: string[] = []; + const fetchFn = (async (url: string) => { + seen.push(url); + if (url.startsWith("http://127.0.0.1:10100")) { + throw Object.assign(new Error("refused"), { code: "ECONNREFUSED" }); + } + return healthz(OURS); + }) as typeof fetch; + expect(await probeEndpointLiveness({ port: 10100, hostname: "::" }, { fetchFn })).toBe("live"); + expect(seen).toEqual([ + "http://127.0.0.1:10100/healthz", + "http://[::1]:10100/healthz", + ]); + + seen.length = 0; + const refused = (async (url: string) => { + seen.push(url); + throw Object.assign(new Error("refused"), { code: "ECONNREFUSED" }); + }) as typeof fetch; + expect(await probeEndpointLiveness({ port: 10100, hostname: "::" }, { fetchFn: refused })).toBe("dead"); + expect(seen).toEqual([ + "http://127.0.0.1:10100/healthz", + "http://[::1]:10100/healthz", + ]); + + seen.length = 0; + const mixed = (async (url: string) => { + seen.push(url); + if (url.startsWith("http://127.0.0.1:10100")) { + throw Object.assign(new Error("refused"), { code: "ECONNREFUSED" }); + } + throw new DOMException("timed out", "TimeoutError"); + }) as typeof fetch; + expect(await probeEndpointLiveness({ port: 10100, hostname: "::" }, { fetchFn: mixed })).toBe("unknown"); + expect(seen).toEqual([ + "http://127.0.0.1:10100/healthz", + "http://[::1]:10100/healthz", + ]); + }); +}); + +describe("isConnectionRefused", () => { + test("recognizes aggregate socket refusals", () => { + const refused = Object.assign(new Error("refused"), { code: "ECONNREFUSED" }); + expect(isConnectionRefused(new AggregateError([refused]))).toBe(true); + expect(isConnectionRefused(new AggregateError([ + Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }), + ]))).toBe(false); + }); + + test("a mixed aggregate is not proof of absence", () => { + // Happy-eyeballs style fan-out puts every address in one error. If one address refused and + // another never answered, the endpoint's state is unknown: the refusal speaks only for the + // address that produced it. + const refused = Object.assign(new Error("refused"), { code: "ECONNREFUSED" }); + const timedOut = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }); + expect(isConnectionRefused(new AggregateError([refused, timedOut]))).toBe(false); + expect(isConnectionRefused(new AggregateError([timedOut, refused]))).toBe(false); + expect(isConnectionRefused(new AggregateError([refused, refused]))).toBe(true); + expect(isConnectionRefused(new AggregateError([]))).toBe(false); + }); +}); + describe("proxyIdentityAt", () => { test("returns the reported pid for our proxy", async () => { const identity = await proxyIdentityAt(10100, {}, { fetchFn: (async () => healthz(OURS)) as typeof fetch }); From 1e233a4bd13fc5707d15db9f81ce942786dc6b4f Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 21 Sep 2026 20:11:20 +0900 Subject: [PATCH 12/17] fix(desktop): ship the startup surface as one page the policy can name (#5445) * fix(desktop): name the bootstrap script in the policy the webview is actually served * fix(desktop): ship the startup surface as one page the policy can name --- desktop/ui/index.html | 169 +++++++++++++++++- desktop/ui/main.js | 154 ---------------- tests/clients/desktop-startup-surface.test.ts | 17 +- 3 files changed, 184 insertions(+), 156 deletions(-) delete mode 100644 desktop/ui/main.js diff --git a/desktop/ui/index.html b/desktop/ui/index.html index f42074944ce..4a74b191615 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -52,6 +52,173 @@

OpenCodex

- + + diff --git a/desktop/ui/main.js b/desktop/ui/main.js deleted file mode 100644 index 6431ac08230..00000000000 --- a/desktop/ui/main.js +++ /dev/null @@ -1,154 +0,0 @@ -// The bootstrap page is the startup surface. It does not probe anything itself: the shell owns the -// sequence, its deadline and its diagnostic, and this page renders what it is told. The phase list -// is asked for rather than written here, so a state added in the shell appears without a second -// edit — and one removed cannot leave a row behind. -// -// What each row shows comes from the shell too, including the states already finished. Rebuilding -// that history from events would be wrong: the first states finish in milliseconds, so a page whose -// listener attached a moment late would show a run in progress with nothing behind it. -// -// Nothing here uses alert, confirm or prompt. The embedded webview implements none of the -// WKUIDelegate panel methods on macOS, so a platform dialog is silently declined and the user sees -// nothing at all. Every message this page has goes into the page — including its own failures, -// because a surface that cannot report is the problem this file exists to fix. - -const bridge = window.__TAURI__; -const invoke = bridge && bridge.core && bridge.core.invoke; -const listen = bridge && bridge.event && bridge.event.listen; - -// The shell owns the sequence and its deadline. The page has no deadline of its own: an invoke -// whose command never answers returns a promise that neither settles nor rejects, and the page -// then keeps its initial markup forever - the headline still says the run is starting, the -// checklist is empty, and the only thing on screen is a Retry button with an empty diagnostic. -// That is indistinguishable from a hung product. Bounding the handshake turns the silence into a -// failure the page can report and the user can copy. -const HANDSHAKE_DEADLINE_MS = 5000; - -function withDeadline(work, what) { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('the shell did not answer ' + what + ' within ' + HANDSHAKE_DEADLINE_MS + ' ms')); - }, HANDSHAKE_DEADLINE_MS); - Promise.resolve(work).then( - (value) => { clearTimeout(timer); resolve(value); }, - (error) => { clearTimeout(timer); reject(error); }, - ); - }); -} - -const headline = document.querySelector("#headline"); -const detail = document.querySelector("#detail"); -const phaseList = document.querySelector("#phases"); -const failure = document.querySelector("#failure"); -const retry = document.querySelector("#retry"); -const copy = document.querySelector("#copy"); -const copyState = document.querySelector("#copyState"); -const diagnostic = document.querySelector("#diagnostic"); - -const MARKS = { done: "✓", failed: "✕", active: "…", pending: "·" }; - -let phases = []; - -function render(progress) { - const completed = new Set((progress && progress.completed) || []); - const failedPhase = (progress && progress.failedPhase) || null; - const current = progress && progress.phase; - phaseList.replaceChildren(); - for (const phase of phases) { - let state = "pending"; - if (phase.id === failedPhase) { - state = "failed"; - } else if (phase.id === current) { - state = "active"; - } else if (completed.has(phase.id)) { - state = "done"; - } - const row = document.createElement("li"); - row.dataset.state = state; - const mark = document.createElement("span"); - mark.className = "mark"; - mark.textContent = MARKS[state]; - const label = document.createElement("span"); - label.textContent = phase.label; - row.append(mark, label); - phaseList.append(row); - } -} - -function apply(progress) { - if (!progress) return; - headline.textContent = progress.label; - detail.textContent = progress.detail || ""; - const failed = progress.phase === "failed"; - failure.hidden = !failed; - retry.disabled = !progress.canRetry; - if (failed) { - diagnostic.value = progress.diagnostic || ""; - copyState.textContent = ""; - } - render(progress); -} - -function reportPageFailure(message, error) { - const cause = error && error.message ? error.message : String(error); - headline.textContent = "OpenCodex could not read its own startup state."; - detail.textContent = message; - failure.hidden = false; - retry.disabled = false; - diagnostic.value = [message, cause].join("\n"); -} - -async function copyDiagnostic() { - const text = diagnostic.value; - if (!text) return; - try { - await navigator.clipboard.writeText(text); - copyState.textContent = "Copied to the clipboard."; - return; - } catch { - // A webview without clipboard access is the reason the text is on screen in the first place. - } - diagnostic.focus(); - diagnostic.select(); - let copied = false; - try { - copied = document.execCommand("copy"); - } catch { - copied = false; - } - copyState.textContent = copied - ? "Copied to the clipboard." - : "The text above is selected — copy it with your keyboard."; -} - -retry.addEventListener("click", async () => { - if (!invoke) return; - copyState.textContent = ""; - retry.disabled = true; - try { - await invoke("retry_startup"); - } catch (error) { - reportPageFailure("The retry could not be sent to the shell.", error); - } -}); -copy.addEventListener("click", copyDiagnostic); - -async function start() { - if (!invoke || !listen) { - headline.textContent = "This page is the OpenCodex desktop shell's startup surface."; - detail.textContent = "Open it from the OpenCodex app."; - return; - } - try { - phases = (await withDeadline(invoke("startup_phases"), "startup_phases")).filter((phase) => !phase.terminal); - render(null); - // The listener goes on before the snapshot is read, so a transition landing between the two is - // delivered rather than lost. - await withDeadline(listen("startup-phase", (event) => apply(event.payload)), "the startup-phase subscription"); - apply(await withDeadline(invoke("startup_snapshot"), "startup_snapshot")); - } catch (error) { - reportPageFailure("The startup surface could not reach the shell.", error); - } -} - -start(); diff --git a/tests/clients/desktop-startup-surface.test.ts b/tests/clients/desktop-startup-surface.test.ts index 2f1e6876fd6..72ccc9005a7 100644 --- a/tests/clients/desktop-startup-surface.test.ts +++ b/tests/clients/desktop-startup-surface.test.ts @@ -17,7 +17,10 @@ const LIB = repoPath(`${SRC}/lib.rs`); const SIDECAR = repoPath(`${SRC}/sidecar.rs`); const STARTUP = repoPath(`${SRC}/startup.rs`); const PROXY = repoPath(`${SRC}/proxy.rs`); -const PAGE = repoPath("desktop/ui/main.js"); +// The startup surface is one file: the page and its script ship together in index.html, +// because a script loaded from a second file is not named by the policy the webview is +// actually served and never runs on some platforms. Read the page as the oracle for both. +const PAGE = repoPath("desktop/ui/index.html"); const CONFIG = repoPath("desktop/src-tauri/tauri.conf.json"); function code(path: string): string { @@ -216,6 +219,18 @@ describe("the bootstrap page reports only what it was told", () => { expect(/#failure\[hidden\][^{]*\{[^}]*display:\s*none/.test(markup)).toBe(true); }); + test("the bootstrap script carries the nonce token the shell replaces", () => { + // The webview is served a policy the configuration file does not contain. Tauri appends its + // own hashes and nonces to script-src, and a hash or nonce in that directive makes + // 'unsafe-inline' inert, so nothing loads unless it is named. Its injector only tags + // script[src^='http'], and this page loads its script by relative path, so the page has to + // carry the token itself; the shell replaces it with a real nonce and adds that nonce to the + // directive. Without it the surface renders as static markup on the platforms where the + // asset origin does not satisfy 'self' — observed on Linux, where the page never ran a line. + expect(markup).not.toContain("./main.js"); + expect(markup).toMatch(/