@bitkyc08/opencodex 2.77.0 → 2.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-BtfG5id-.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
  5. package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  15. package/src/adapters/devin/trajectory.ts +49 -0
  16. package/src/adapters/devin.ts +13 -0
  17. package/src/adapters/ollama-native.ts +35 -19
  18. package/src/claude/intercept/client-hello.ts +103 -0
  19. package/src/claude/intercept/model-bindings.ts +3 -2
  20. package/src/claude/intercept/picker-listener.ts +279 -66
  21. package/src/cli/access-audio-input.ts +84 -0
  22. package/src/cli/access-audio-live.ts +99 -0
  23. package/src/cli/access-audio.ts +123 -0
  24. package/src/cli/access-data-client.ts +127 -0
  25. package/src/cli/access-data-plane.ts +104 -0
  26. package/src/cli/access-data-response.ts +76 -0
  27. package/src/cli/access.ts +65 -2
  28. package/src/cli/account-api.ts +30 -6
  29. package/src/cli/account-auth.ts +111 -42
  30. package/src/cli/account-key-quota.ts +118 -0
  31. package/src/cli/account-main.ts +11 -4
  32. package/src/cli/account-next-actions.ts +63 -0
  33. package/src/cli/account-policy-dto.ts +68 -0
  34. package/src/cli/account-policy.ts +167 -0
  35. package/src/cli/account-target.ts +8 -0
  36. package/src/cli/account.ts +34 -8
  37. package/src/cli/agent-runtime-settings.ts +130 -0
  38. package/src/cli/agent-settings.ts +101 -0
  39. package/src/cli/agent.ts +19 -2
  40. package/src/cli/alias.ts +7 -2
  41. package/src/cli/aside-profiles.ts +2 -2
  42. package/src/cli/capabilities-access-remote.ts +147 -0
  43. package/src/cli/capabilities-accounts.ts +195 -0
  44. package/src/cli/capabilities-agents-routing.ts +414 -0
  45. package/src/cli/capabilities-base.ts +783 -0
  46. package/src/cli/capabilities-command.ts +1 -0
  47. package/src/cli/capabilities-integrations.ts +264 -0
  48. package/src/cli/capabilities-lab.ts +214 -0
  49. package/src/cli/capabilities-observe-system.ts +662 -0
  50. package/src/cli/capabilities-provider-models.ts +302 -0
  51. package/src/cli/capabilities.ts +30 -1199
  52. package/src/cli/capability-types.ts +54 -0
  53. package/src/cli/catalog-command-result.ts +57 -0
  54. package/src/cli/chatgpt-command.ts +25 -2
  55. package/src/cli/claude-agent-startup-sync.ts +17 -0
  56. package/src/cli/claude-desktop-profile.ts +100 -0
  57. package/src/cli/claude-desktop.ts +39 -6
  58. package/src/cli/codex-shim-autorestore.ts +2 -1
  59. package/src/cli/codex-shim-readiness.ts +23 -3
  60. package/src/cli/combo-input.ts +88 -0
  61. package/src/cli/combo-result.ts +52 -0
  62. package/src/cli/combo-stats.ts +123 -0
  63. package/src/cli/combo.ts +37 -14
  64. package/src/cli/companion-timeline.ts +142 -0
  65. package/src/cli/companion-usage.ts +146 -0
  66. package/src/cli/companion.ts +12 -3
  67. package/src/cli/config-command.ts +24 -8
  68. package/src/cli/dispatch.ts +52 -79
  69. package/src/cli/doctor.ts +22 -4
  70. package/src/cli/help-catalog.ts +31 -14
  71. package/src/cli/help-recovery.ts +2 -2
  72. package/src/cli/help.ts +25 -11
  73. package/src/cli/index.ts +38 -27
  74. package/src/cli/injection-follow.ts +35 -0
  75. package/src/cli/inspect.ts +5 -0
  76. package/src/cli/integration-aside-sync.ts +69 -0
  77. package/src/cli/integration-cursor.ts +106 -0
  78. package/src/cli/integration-input.ts +60 -0
  79. package/src/cli/integration-journal.ts +49 -0
  80. package/src/cli/integration-plan-dto.ts +156 -0
  81. package/src/cli/integration-preview.ts +215 -0
  82. package/src/cli/integrations.ts +24 -18
  83. package/src/cli/json-input.ts +134 -0
  84. package/src/cli/link.ts +32 -4
  85. package/src/cli/local-sync-result.ts +41 -0
  86. package/src/cli/log-follow.ts +84 -0
  87. package/src/cli/log-view-filter.ts +146 -0
  88. package/src/cli/logout-command.ts +44 -0
  89. package/src/cli/model-picker-ordering.ts +137 -0
  90. package/src/cli/models-custom-input.ts +107 -0
  91. package/src/cli/models-custom-runtime.ts +112 -0
  92. package/src/cli/models-order.ts +194 -0
  93. package/src/cli/models-runtime-subcommands.ts +2 -0
  94. package/src/cli/models-runtime.ts +7 -0
  95. package/src/cli/models.ts +66 -81
  96. package/src/cli/observe-stream.ts +142 -0
  97. package/src/cli/observe.ts +75 -34
  98. package/src/cli/provider-batch.ts +77 -0
  99. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  100. package/src/cli/provider-result.ts +124 -0
  101. package/src/cli/provider-runtime.ts +15 -12
  102. package/src/cli/provider-settings.ts +143 -0
  103. package/src/cli/provider.ts +112 -34
  104. package/src/cli/registry.ts +62 -20
  105. package/src/cli/remote-workspace-hub.ts +83 -0
  106. package/src/cli/remote-workspace.ts +6 -1
  107. package/src/cli/restart-failure.ts +27 -0
  108. package/src/cli/root.ts +12 -0
  109. package/src/cli/route-policy-write.ts +137 -0
  110. package/src/cli/route-policy.ts +8 -0
  111. package/src/cli/runtime-api.ts +56 -79
  112. package/src/cli/secret-args.ts +149 -0
  113. package/src/cli/settings-result.ts +30 -0
  114. package/src/cli/storage.ts +77 -7
  115. package/src/cli/system-command.ts +60 -2
  116. package/src/cli/system-restart-client.ts +20 -3
  117. package/src/cli/system-settings-parity.ts +83 -0
  118. package/src/cli/update-restart-candidate.ts +21 -0
  119. package/src/cli/update-restart-child.ts +81 -0
  120. package/src/cli/update-restart-home.ts +38 -0
  121. package/src/cli/update-restart-transport.ts +193 -0
  122. package/src/cli/update-restart.ts +260 -0
  123. package/src/cli/usage-model-search.ts +58 -0
  124. package/src/cli/usage-report.ts +13 -5
  125. package/src/cli/v2-input.ts +66 -0
  126. package/src/cli/v2-local-output.ts +37 -0
  127. package/src/cli/v2-runtime.ts +90 -0
  128. package/src/cli/v2.ts +102 -144
  129. package/src/clients/config-export/dsh.ts +21 -0
  130. package/src/clients/config-export.ts +7 -1
  131. package/src/codex/catalog/build-entries.ts +1 -1
  132. package/src/codex/catalog/bundled.ts +9 -2
  133. package/src/codex/catalog/heal-observation.ts +66 -0
  134. package/src/codex/catalog/publication-observer.ts +19 -0
  135. package/src/codex/catalog/remote.ts +13 -15
  136. package/src/codex/catalog/restore.ts +19 -6
  137. package/src/codex/catalog/retained-sync.ts +58 -10
  138. package/src/codex/catalog/routed-removal.ts +116 -0
  139. package/src/codex/catalog/write-audit-contract.ts +21 -0
  140. package/src/codex/catalog/write-audit.ts +171 -0
  141. package/src/codex/catalog-self-heal.ts +284 -0
  142. package/src/codex/catalog-write-serialization.ts +71 -7
  143. package/src/codex/codex-home-owner.ts +124 -0
  144. package/src/codex/convergence.ts +55 -15
  145. package/src/codex/features.ts +8 -1
  146. package/src/codex/inject/restore.ts +46 -4
  147. package/src/codex/inject.ts +11 -1
  148. package/src/codex/internal/catalog-writer.ts +120 -13
  149. package/src/codex/journal.ts +49 -13
  150. package/src/codex/management-convergence.ts +3 -0
  151. package/src/codex/quota-types.ts +3 -0
  152. package/src/codex/quota.ts +13 -3
  153. package/src/codex/refresh.ts +20 -9
  154. package/src/codex/runtime.ts +7 -1
  155. package/src/codex/shim-diagnostics.ts +80 -0
  156. package/src/codex/shim-fingerprint.ts +7 -2
  157. package/src/codex/shim-migration.ts +153 -0
  158. package/src/codex/shim-overlay.ts +509 -0
  159. package/src/codex/shim-path-resolution.ts +14 -2
  160. package/src/codex/shim-probe.ts +4 -3
  161. package/src/codex/shim-state-file.ts +93 -7
  162. package/src/codex/shim.ts +15 -68
  163. package/src/codex/sync.ts +24 -4
  164. package/src/combos/failover.ts +19 -0
  165. package/src/combos/index.ts +1 -0
  166. package/src/combos/resolve.ts +12 -0
  167. package/src/config/atomic-write.ts +13 -8
  168. package/src/config/diagnostics.ts +19 -1
  169. package/src/config/load-degrade.ts +3 -0
  170. package/src/config/schema/chatgpt-desktop.ts +22 -0
  171. package/src/config/schema/config-schema.ts +32 -14
  172. package/src/config/schema/leaf-validators.ts +3 -2
  173. package/src/generated/compatibility-version.json +445 -129
  174. package/src/integrations/merge.ts +13 -5
  175. package/src/integrations/mutation-plan.ts +46 -3
  176. package/src/integrations/omp-yaml-source.ts +181 -1
  177. package/src/integrations/ownership-policy.ts +66 -0
  178. package/src/integrations/registry.ts +50 -1
  179. package/src/integrations/state.ts +46 -21
  180. package/src/integrations/target.ts +25 -2
  181. package/src/integrations/writer.ts +89 -31
  182. package/src/lib/package-tree-integrity.ts +42 -1
  183. package/src/lib/proxy-env.ts +3 -0
  184. package/src/lib/test-home-guard.ts +2 -1
  185. package/src/oauth/anthropic-account-refusal.ts +19 -8
  186. package/src/oauth/anthropic-routing.ts +17 -12
  187. package/src/oauth/anthropic.ts +27 -2
  188. package/src/oauth/index.ts +6 -8
  189. package/src/oauth/pool-settings-capability.ts +9 -2
  190. package/src/protocols/plan-snapshot.ts +1 -1
  191. package/src/protocols/settings.ts +28 -4
  192. package/src/server/caller-session-identity.ts +32 -0
  193. package/src/server/claude-messages.ts +11 -4
  194. package/src/server/index/serve-options.ts +12 -7
  195. package/src/server/index.ts +1 -1
  196. package/src/server/management/agent-settings-routes.ts +6 -5
  197. package/src/server/management/context.ts +8 -1
  198. package/src/server/management/cursor-integration-routes.ts +3 -3
  199. package/src/server/management/integration-routes.ts +5 -5
  200. package/src/server/management/model-routes.ts +3 -3
  201. package/src/server/management/native-integration-routes.ts +2 -2
  202. package/src/server/management/oauth-account-routes.ts +96 -30
  203. package/src/server/management/route-registry.ts +25 -24
  204. package/src/server/messages-native-eligibility.ts +3 -6
  205. package/src/server/messages-native-oauth.ts +82 -35
  206. package/src/server/messages-native.ts +78 -16
  207. package/src/server/responses/adapter-dispatch.ts +53 -28
  208. package/src/server/responses/core-auth.ts +10 -4
  209. package/src/server/responses/core-combo.ts +46 -6
  210. package/src/service/cli.ts +6 -1
  211. package/src/service/ownership-mutation-lease.d.mts +26 -0
  212. package/src/service/ownership-mutation-lease.mjs +76 -1
  213. package/src/types/config.ts +2 -0
  214. package/src/types/provider.ts +10 -10
  215. package/src/update/index.ts +2 -1
  216. package/src/update/update-failure-guidance.d.mts +6 -0
  217. package/src/update/update-failure-guidance.mjs +30 -5
  218. package/gui/dist/assets/App-COdEeqSj.css +0 -1
  219. package/gui/dist/assets/App-DEpCJEP_.js +0 -54
  220. package/gui/dist/assets/index-Cd7ittMv.css +0 -1
  221. package/gui/dist/assets/index-DwJKFJm5.js +0 -94
  222. package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
@@ -26,6 +26,8 @@ import {
26
26
  import type { ProxyRestartRequestOutcome } from "./tray-proxy";
27
27
  import { packageVersion } from "./help";
28
28
  import { computeVersionSkew } from "./version-skew";
29
+ import { readUpdateRestartHome } from "./update-restart-home";
30
+ import { UpdateRestartRequired } from "./update-restart-candidate";
29
31
 
30
32
  export const SYSTEM_RESTART_REQUEST_TIMEOUT_MS = 5_000;
31
33
  export const SYSTEM_RESTART_ATTESTATION_TIMEOUT_MS = 4_000;
@@ -38,6 +40,7 @@ export interface BoundSystemRestartDeps {
38
40
  now?: () => number;
39
41
  /** Invoking CLI version for the skew guard; defaults to this bundle's package version. */
40
42
  cliVersion?: string;
43
+ readUpdateHome?: typeof readUpdateRestartHome;
41
44
  }
42
45
 
43
46
  function rejected(code: string): ProxyRestartRequestOutcome {
@@ -90,6 +93,8 @@ export async function requestBoundSystemRestart(
90
93
  return rejected("restart_target_runtime_mismatch");
91
94
  }
92
95
 
96
+ let updateHome: ReturnType<typeof readUpdateRestartHome> | undefined;
97
+ try { updateHome = (deps.readUpdateHome ?? readUpdateRestartHome)(); } catch { /* normal in-place restart remains available */ }
93
98
  const attestationBudget = remaining(deadlineAt, now, SYSTEM_RESTART_ATTESTATION_TIMEOUT_MS);
94
99
  if (attestationBudget <= 0) return rejected("restart_deadline_expired");
95
100
 
@@ -143,9 +148,9 @@ export async function requestBoundSystemRestart(
143
148
  const proxyVersion = fenced
144
149
  ? body.installedVersion as string
145
150
  : typeof body.version === "string" ? body.version : undefined;
146
- if (computeVersionSkew(deps.cliVersion ?? ownCliVersion(), proxyVersion).skewed) {
147
- return rejected("restart_version_skew");
148
- }
151
+ const cliVersion = deps.cliVersion ?? ownCliVersion();
152
+ const skew = computeVersionSkew(cliVersion, proxyVersion);
153
+ if (skew.skewed && skew.relation !== "cli-newer") return rejected("restart_version_skew");
149
154
 
150
155
  let observed: LiveProxy | null;
151
156
  try {
@@ -154,6 +159,18 @@ export async function requestBoundSystemRestart(
154
159
  return rejected("restart_target_recheck_failed");
155
160
  }
156
161
  if (!sameRestartTarget(target, observed)) return rejected("restart_target_changed");
162
+ if (skew.relation === "cli-newer") {
163
+ if (!updateHome) return rejected("update_restart_home_unverified");
164
+ const current = readRuntime(target.pid);
165
+ if (!current || current.pid !== runtime.pid || current.port !== runtime.port
166
+ || current.hostname !== runtime.hostname || current.attestationSecret !== runtime.attestationSecret) {
167
+ return rejected("restart_target_changed");
168
+ }
169
+ return { accepted: false, uncertain: false, error: new UpdateRestartRequired({
170
+ target: { ...target, pid: target.pid, version: proxyVersion },
171
+ runtime: { ...runtime, attestationSecret: runtime.attestationSecret }, cliVersion, home: updateHome,
172
+ }) };
173
+ }
157
174
 
158
175
  const capability = createSystemRestartCapability(
159
176
  runtime.attestationSecret,
@@ -0,0 +1,83 @@
1
+ import { runCatalogAction } from "./catalog-command-result";
2
+ import { serializeManagementJson } from "./json-input";
3
+ import { printSettingsResult, projectSettingsApply } from "./settings-result";
4
+ import { CliUsageError, runtimeBaseUrl, runtimeRequest, takeFlag, takeOptionWithSyntax, type RuntimeApiDeps } from "./runtime-api";
5
+
6
+ export const SYSTEM_PARITY_OPTIONS = ["--show-codex-credits", "--account-picker", "--main-account-hard-lock", "--ultra-fast-tier", "--fast-rows"] as const;
7
+ const BOOLEAN_FIELDS: Readonly<Record<string, string>> = {
8
+ "--show-codex-credits": "showCodexCredits", "--account-picker": "codexAccountPickerEnabled",
9
+ "--main-account-hard-lock": "codexMainAccountHardLock", "--ultra-fast-tier": "ultraFastTier", "--fast-rows": "fastRows",
10
+ "--auto-start": "codexAutoStart", "--desktop-authless": "codexDesktopAuthless", "--client-compaction": "codexClientCompaction",
11
+ };
12
+ function record(value: unknown): value is Record<string, unknown> {
13
+ return value !== null && typeof value === "object" && !Array.isArray(value);
14
+ }
15
+ function boolean(raw: string, option: string): boolean {
16
+ if (["on", "true", "yes", "1", "enabled"].includes(raw.toLowerCase())) return true;
17
+ if (["off", "false", "no", "0", "disabled"].includes(raw.toLowerCase())) return false;
18
+ throw new CliUsageError(`${option} must be on or off`);
19
+ }
20
+ function desktopState(value: unknown): Record<string, unknown> | null {
21
+ if (!record(value) || typeof value.stored !== "boolean" || (value.effective !== null && typeof value.effective !== "boolean")
22
+ || (value.inertReason !== undefined && value.inertReason !== "client_role" && value.inertReason !== "non_loopback_bind_requires_admission_token")) return null;
23
+ return { stored: value.stored, effective: value.effective, ...(value.inertReason ? { inertReason: value.inertReason } : {}) };
24
+ }
25
+
26
+ /** New-option writes; the original no-new-option settings output remains compatible. */
27
+ export async function handleSystemSettingsParity(argsInput: string[], deps: RuntimeApiDeps = {}): Promise<number> {
28
+ return runCatalogAction(async () => {
29
+ const args = [...argsInput], wantsJson = takeFlag(args, "--json");
30
+ const body: Record<string, unknown> = {};
31
+ for (const [option, field] of Object.entries(BOOLEAN_FIELDS)) {
32
+ const value = takeOptionWithSyntax(args, option)?.value;
33
+ if (value !== undefined) body[field] = boolean(value, option);
34
+ }
35
+ const mode = takeOptionWithSyntax(args, "--stream-mode")?.value;
36
+ if (mode !== undefined) {
37
+ if (!["auto", "legacy-tee", "eager-relay"].includes(mode)) throw new CliUsageError("Invalid --stream-mode");
38
+ body.streamMode = mode;
39
+ }
40
+ if (args.length || !Object.keys(body).length) throw new CliUsageError("Provide supported system setting options and optional --json");
41
+ const pinned = { ...deps, baseUrl: await runtimeBaseUrl(deps) };
42
+ const response = await runtimeRequest("/api/settings", { method: "PUT", redirect: "error", body: serializeManagementJson(body) }, pinned);
43
+ if (!record(response) || response.ok !== true) throw new Error("Invalid settings receipt");
44
+ const observed: Record<string, unknown> = {};
45
+ const unverified: string[] = [];
46
+ for (const [key, requested] of Object.entries(body)) {
47
+ if (key === "ultraFastTier") continue;
48
+ const actual = response[key];
49
+ if (typeof actual === typeof requested && (key !== "streamMode" || ["auto", "legacy-tee", "eager-relay"].includes(actual as string))) observed[key] = actual;
50
+ if (actual !== requested) unverified.push(key);
51
+ }
52
+ if (Object.hasOwn(body, "ultraFastTier")) {
53
+ try {
54
+ const readBack = await runtimeRequest("/api/settings", { redirect: "error" }, pinned);
55
+ if (record(readBack) && typeof readBack.ultraFastTier === "boolean") observed.ultraFastTier = readBack.ultraFastTier;
56
+ } catch { /* Acceptance already happened; retain safe unknown verification, not raw error. */ }
57
+ if (observed.ultraFastTier !== body.ultraFastTier) unverified.push("ultraFastTier");
58
+ }
59
+ const data: Record<string, unknown> = { ok: true, settings: observed };
60
+ let nativePending = false;
61
+ if (Object.hasOwn(body, "codexDesktopAuthless") || Object.hasOwn(body, "codexClientCompaction")) {
62
+ const switches = record(response.codexDesktopSwitches) ? response.codexDesktopSwitches : {};
63
+ const report: Record<string, unknown> = {};
64
+ for (const key of ["codexDesktopAuthless", "codexClientCompaction"]) {
65
+ if (!Object.hasOwn(body, key)) continue;
66
+ const state = desktopState(switches[key]);
67
+ if (state) report[key] = state; else unverified.push(`${key}.effective`);
68
+ }
69
+ const apply = projectSettingsApply(switches.apply);
70
+ if (apply) {
71
+ report.apply = apply;
72
+ nativePending = !apply.applied && apply.reason !== "not_requested";
73
+ } else unverified.push("desktopApply");
74
+ data.desktop = report;
75
+ }
76
+ if (unverified.length) { data.verification = "unverified"; data.unverifiedFields = unverified; }
77
+ const lines = ["System settings accepted.", ...Object.entries(observed).map(([key, value]) => `${key}: ${value}`)];
78
+ if (unverified.length) lines.push(`Read-back is unverified for: ${unverified.join(", ")}.`);
79
+ if (nativePending) lines.push("Native client application is deferred or refused; the settings remain saved.");
80
+ const pendingCode = printSettingsResult(data, response.catalogRefreshPending, wantsJson, lines);
81
+ return unverified.length || nativePending ? 1 : pendingCode;
82
+ });
83
+ }
@@ -0,0 +1,21 @@
1
+ import type { RuntimePortState } from "../config/process-state";
2
+ import type { LiveProxy } from "../server/proxy-liveness";
3
+
4
+ import type { UpdateRestartHome } from "./update-restart-home";
5
+
6
+ export interface UpdateRestartCandidate {
7
+ home: UpdateRestartHome;
8
+ target: LiveProxy & { pid: number };
9
+ runtime: RuntimePortState & { attestationSecret: string };
10
+ cliVersion: string;
11
+ }
12
+
13
+ /** Private in-process evidence; diagnostics and JSON never expose the proof key. */
14
+ export class UpdateRestartRequired extends Error {
15
+ #candidate: UpdateRestartCandidate;
16
+ constructor(candidate: UpdateRestartCandidate) {
17
+ super("restart_version_skew_cli_newer");
18
+ this.#candidate = candidate;
19
+ }
20
+ candidate(): UpdateRestartCandidate { return this.#candidate; }
21
+ }
@@ -0,0 +1,81 @@
1
+ import { existsSync } from "node:fs";
2
+ import { getRuntimePortPath, readRuntimePort } from "../config/process-state";
3
+ import { packageVersion } from "../lib/package-version";
4
+ import { parseStrictSemver } from "../lib/strict-semver";
5
+ import { acquireOwnershipMutationLease, OWNERSHIP_MUTATION_LEASE_TOKEN_ENV } from "../service/ownership-mutation-lease.mjs";
6
+ import { serviceStatePaths } from "../service/state";
7
+ import { assertUpdateRestartConfiguration, assertUpdateRestartHome, type UpdateRestartHome } from "./update-restart-home";
8
+
9
+ export const UPDATE_RESTART_CHILD_ENV = "OCX_UPDATE_RESTART_CHILD";
10
+ export interface UpdateRestartChildMarker { home: UpdateRestartHome; version: string; port: number; hostname: string; deadlineAt: number }
11
+ export interface UpdateRestartChildGuard { check(port?: number, hostname?: string): void; complete(): void }
12
+
13
+ function parseMarker(raw: string): UpdateRestartChildMarker {
14
+ if (raw.length > 8192) throw new Error("update_restart_child_marker_invalid");
15
+ const value = JSON.parse(raw) as UpdateRestartChildMarker;
16
+ if (!value || !Number.isSafeInteger(value.deadlineAt) || !Number.isInteger(value.port)
17
+ || typeof value.hostname !== "string" || value.port < 1 || value.port > 65535 || typeof value.version !== "string"
18
+ || value.version === "0.0.0" || !parseStrictSemver(value.version) || !value.home
19
+ || !Number.isSafeInteger(value.home.revision)) throw new Error("update_restart_child_marker_invalid");
20
+ for (const directory of [value.home.config, value.home.codex]) {
21
+ if (!directory || typeof directory.path !== "string" || !directory.path
22
+ || !Number.isSafeInteger(directory.dev) || !Number.isSafeInteger(directory.ino)) {
23
+ throw new Error("update_restart_child_marker_invalid");
24
+ }
25
+ }
26
+ return value;
27
+ }
28
+
29
+ export interface UpdateRestartChildIo {
30
+ env?: NodeJS.ProcessEnv;
31
+ now?: () => number;
32
+ version?: () => string;
33
+ checkHome?: (home: UpdateRestartHome) => void;
34
+ acquire?: (waitMs: number) => { release(): void };
35
+ onExit?: (release: () => void) => void;
36
+ checkState?: (marker: UpdateRestartChildMarker) => void;
37
+ armDeadline?: (remainingMs: number) => () => void;
38
+ }
39
+
40
+ /** Admit the replacement before CLI preflight; custody belongs to this child PID. */
41
+ export function admitUpdateRestartChild(argv: string[], io: UpdateRestartChildIo = {}): UpdateRestartChildGuard | undefined {
42
+ const env = io.env ?? process.env;
43
+ const raw = env[UPDATE_RESTART_CHILD_ENV];
44
+ if (raw === undefined) return undefined;
45
+ delete env[UPDATE_RESTART_CHILD_ENV];
46
+ delete env[OWNERSHIP_MUTATION_LEASE_TOKEN_ENV];
47
+ const marker = parseMarker(raw);
48
+ if (argv.length !== 3 || argv[0] !== "start" || argv[1] !== "--port" || argv[2] !== String(marker.port)) {
49
+ throw new Error("update_restart_child_command_changed");
50
+ }
51
+ const now = io.now ?? Date.now;
52
+ const check = (port = marker.port, hostname = marker.hostname) => {
53
+ if (now() >= marker.deadlineAt) throw new Error("update_restart_deadline_expired");
54
+ const bindHost = (host: string) => !host.trim() || /^localhost\.?$/i.test(host.trim()) ? "127.0.0.1" : host.trim();
55
+ if (port !== marker.port || bindHost(hostname) !== bindHost(marker.hostname) || (io.version ?? packageVersion)() !== marker.version) throw new Error("update_restart_child_identity_changed");
56
+ (io.checkHome ?? assertUpdateRestartHome)(marker.home);
57
+ (io.checkState ?? (expected => {
58
+ assertUpdateRestartConfiguration(expected.hostname);
59
+ const current = readRuntimePort();
60
+ if (existsSync(getRuntimePortPath()) && (!current || current.pid !== process.pid
61
+ || current.port !== expected.port || current.siblingOfPort !== undefined)) {
62
+ throw new Error("update_restart_competing_runtime");
63
+ }
64
+ }))(marker);
65
+ };
66
+ check();
67
+ const lease = (io.acquire ?? (waitMs => acquireOwnershipMutationLease(serviceStatePaths(), { waitMs })))(Math.min(2000, marker.deadlineAt - now()));
68
+ let released = false;
69
+ let cancelDeadline: (() => void) | undefined;
70
+ const release = () => { if (!released) { released = true; cancelDeadline?.(); lease.release(); } };
71
+ try {
72
+ check();
73
+ cancelDeadline = (io.armDeadline ?? (remainingMs => {
74
+ const timer = setTimeout(() => process.exit(1), remainingMs);
75
+ timer.unref();
76
+ return () => clearTimeout(timer);
77
+ }))(marker.deadlineAt - now());
78
+ (io.onExit ?? (callback => { process.once("exit", callback); }))(release);
79
+ return { check, complete: () => { check(); release(); } };
80
+ } catch (error) { release(); throw error; }
81
+ }
@@ -0,0 +1,38 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { readClientConnectionState } from "../client/state";
3
+ import { readConfigDiagnostics } from "../config/diagnostics";
4
+ import { getConfigDir } from "../config/paths";
5
+ import { currentCodexHome, resolveServiceOwnership, resolveServiceState } from "../service/state";
6
+
7
+ export interface UpdateRestartHome {
8
+ config: { path: string; dev: number; ino: number };
9
+ codex: { path: string; dev: number; ino: number };
10
+ revision: number;
11
+ }
12
+
13
+ function physicalDirectory(path: string): UpdateRestartHome["config"] {
14
+ const canonical = realpathSync.native(path);
15
+ const stat = statSync(canonical);
16
+ if (!stat.isDirectory()) throw new Error("update_restart_home_unverified");
17
+ return { path: canonical, dev: stat.dev, ino: stat.ino };
18
+ }
19
+
20
+ /** Only an unclaimed standalone home is eligible for this narrow update path. */
21
+ export function readUpdateRestartHome(): UpdateRestartHome {
22
+ const state = resolveServiceState();
23
+ const owner = resolveServiceOwnership();
24
+ if (state.kind !== "none" || owner.kind !== "none") throw new Error("update_restart_owner_unverified");
25
+ return { config: physicalDirectory(getConfigDir()), codex: physicalDirectory(currentCodexHome()), revision: owner.revision };
26
+ }
27
+
28
+ export function assertUpdateRestartHome(expected: UpdateRestartHome): void {
29
+ const current = readUpdateRestartHome();
30
+ if (JSON.stringify(current) !== JSON.stringify(expected)) throw new Error("update_restart_home_changed");
31
+ }
32
+
33
+ /** Read-only eligibility shared by parent pre-stop and child admission. */
34
+ export function assertUpdateRestartConfiguration(hostname: string): void {
35
+ const diagnostics = readConfigDiagnostics();
36
+ if (readClientConnectionState().kind !== "disconnected" || diagnostics.error
37
+ || (diagnostics.config.hostname ?? "") !== hostname) throw new Error("update_restart_configuration_changed");
38
+ }
@@ -0,0 +1,193 @@
1
+ import { Agent, request, type ClientRequest, type IncomingMessage } from "node:http";
2
+ import { connect, type Socket } from "node:net";
3
+ import type { Duplex } from "node:stream";
4
+ import {
5
+ LOCAL_ATTESTATION_CHALLENGE_HEADER,
6
+ LOCAL_ATTESTATION_PROOF_HEADER,
7
+ createLocalAttestationChallenge,
8
+ isLocalAttestationSecret,
9
+ verifyLocalAttestationProof,
10
+ } from "../lib/local-management-attestation";
11
+ import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../lib/system-restart-contract";
12
+ import { isOpencodexHealthz, probeHostname, type LiveProxy } from "../server/proxy-liveness";
13
+ import { computeVersionSkew } from "./version-skew";
14
+
15
+ const FAILURE = "update_restart_stop_failed";
16
+ const MAX_RESPONSE_BYTES = 64 * 1024;
17
+
18
+ /** This agent owns exactly one direct TCP connection, including on Bun's HTTP layer. */
19
+ class AttestedAgent extends Agent {
20
+ socket?: Socket;
21
+ private connected = false;
22
+
23
+ constructor(private readonly hostname: string, private readonly port: number) {
24
+ super({ keepAlive: true, maxSockets: 1, maxTotalSockets: 1, maxFreeSockets: 1 });
25
+ }
26
+
27
+ override createConnection(
28
+ _options: unknown,
29
+ callback?: (error: Error | null, stream: Duplex) => void,
30
+ ): Socket | undefined {
31
+ if (this.connected) {
32
+ // Returning no socket with an error also refuses queued reconnect attempts.
33
+ callback?.(new Error(FAILURE), this.socket!);
34
+ return undefined;
35
+ }
36
+ this.connected = true;
37
+ this.socket = connect({ host: this.hostname, port: this.port, autoSelectFamily: false });
38
+ return this.socket;
39
+ }
40
+ }
41
+
42
+ function checkDeadline(deadlineAt: number): void {
43
+ if (!Number.isFinite(deadlineAt) || Date.now() >= deadlineAt) throw new Error(FAILURE);
44
+ }
45
+
46
+ function readJson(response: IncomingMessage): Promise<Record<string, unknown>> {
47
+ return new Promise((resolve, reject) => {
48
+ const chunks: Buffer[] = [];
49
+ let size = 0;
50
+ const fail = () => { reject(new Error(FAILURE)); response.destroy(); };
51
+ response.on("error", fail);
52
+ response.on("aborted", fail);
53
+ response.on("data", (chunk: Buffer) => {
54
+ size += chunk.length;
55
+ if (size > MAX_RESPONSE_BYTES) { fail(); return; }
56
+ chunks.push(chunk);
57
+ });
58
+ response.on("end", () => {
59
+ try {
60
+ const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
61
+ if (!response.complete || value === null || typeof value !== "object" || Array.isArray(value)) {
62
+ throw new Error(FAILURE);
63
+ }
64
+ resolve(value as Record<string, unknown>);
65
+ } catch { reject(new Error(FAILURE)); }
66
+ });
67
+ });
68
+ }
69
+
70
+ type AttestedTarget = { target: LiveProxy & { pid: number }; secret: string; deadlineAt: number };
71
+ type JsonReply = { response: IncomingMessage; body: Record<string, unknown> };
72
+
73
+ class AttestedSession {
74
+ readonly agent: AttestedAgent;
75
+ readonly challenge = createLocalAttestationChallenge();
76
+ private readonly hostname: string;
77
+ private readonly timer: ReturnType<typeof setTimeout>;
78
+ private current?: ClientRequest;
79
+
80
+ constructor(private readonly options: AttestedTarget) {
81
+ const { target, secret, deadlineAt } = options;
82
+ checkDeadline(deadlineAt);
83
+ if (target.source !== "runtime" || target.role !== undefined || target.packageTreeFenced
84
+ || !Number.isSafeInteger(target.pid) || target.pid <= 0
85
+ || !Number.isInteger(target.port) || target.port < 1 || target.port > 65535
86
+ || !isLocalAttestationSecret(secret)) throw new Error(FAILURE);
87
+ const host = probeHostname(target.hostname);
88
+ this.hostname = host.startsWith("[") ? host.slice(1, -1) : host;
89
+ this.agent = new AttestedAgent(this.hostname, target.port);
90
+ this.timer = setTimeout(() => {
91
+ this.current?.destroy(new Error(FAILURE));
92
+ this.agent.socket?.destroy(new Error(FAILURE));
93
+ }, Math.min(deadlineAt - Date.now(), 2_147_483_647));
94
+ }
95
+
96
+ exchange(adminToken?: string): Promise<JsonReply> {
97
+ const stop = adminToken !== undefined;
98
+ return new Promise((resolve, reject) => {
99
+ const req = request({
100
+ hostname: this.hostname, port: this.options.target.port, agent: this.agent,
101
+ method: stop ? "POST" : "GET", path: stop ? "/api/stop" : "/healthz",
102
+ maxHeaderSize: MAX_RESPONSE_BYTES,
103
+ headers: stop ? { "content-length": "0" } : { [LOCAL_ATTESTATION_CHALLENGE_HEADER]: this.challenge },
104
+ });
105
+ this.current = req;
106
+ req.on("error", reject);
107
+ req.on("response", response => {
108
+ readJson(response).then(body => resolve({ response, body }), reject);
109
+ });
110
+ req.once("socket", (socket: Socket) => {
111
+ try {
112
+ checkDeadline(this.options.deadlineAt);
113
+ if (socket !== this.agent.socket || socket.destroyed || !socket.writable
114
+ || (stop && (socket.connecting || socket.readableEnded || socket.writableEnded))) {
115
+ throw new Error(FAILURE);
116
+ }
117
+ // No end/flushHeaders/write, or credential header, before this socket check.
118
+ if (adminToken !== undefined) req.setHeader("X-OpenCodex-API-Key", adminToken);
119
+ req.end();
120
+ } catch { req.destroy(new Error(FAILURE)); }
121
+ });
122
+ });
123
+ }
124
+
125
+ validateHealth(health: JsonReply): void {
126
+ const { target, secret, deadlineAt } = this.options;
127
+ const proof = health.response.headers[LOCAL_ATTESTATION_PROOF_HEADER];
128
+ checkDeadline(deadlineAt);
129
+ if (health.response.statusCode !== 200 || !isOpencodexHealthz(health.body)
130
+ || health.body.status !== "ok" || health.body.role !== undefined
131
+ || health.body.pid !== target.pid || health.body.port !== target.port
132
+ || !verifyLocalAttestationProof(secret, this.challenge, target.pid, target.port, typeof proof === "string" ? proof : null)) {
133
+ throw new Error(FAILURE);
134
+ }
135
+ }
136
+
137
+ close(): void {
138
+ clearTimeout(this.timer);
139
+ this.current?.destroy();
140
+ this.agent.destroy();
141
+ this.agent.socket?.destroy();
142
+ }
143
+ }
144
+
145
+ /** Proof and credential delivery must remain on one established channel; never retry. */
146
+ export async function stopAttestedUpdateTarget(options: AttestedTarget & {
147
+ cliVersion: string;
148
+ beforeStop: () => void;
149
+ adminToken: string;
150
+ }): Promise<void> {
151
+ let session: AttestedSession | undefined;
152
+ try {
153
+ const { cliVersion, deadlineAt, beforeStop, adminToken } = options;
154
+ if (!adminToken) throw new Error(FAILURE);
155
+ session = new AttestedSession(options);
156
+ const health = await session.exchange();
157
+ session.validateHealth(health);
158
+ if (health.body.restartCapability !== SYSTEM_RESTART_CAPABILITY_VERSION
159
+ || computeVersionSkew(cliVersion, typeof health.body.version === "string" ? health.body.version : undefined).relation !== "cli-newer"
160
+ || /\bclose\b/i.test(health.response.headers.connection ?? "")) throw new Error(FAILURE);
161
+ checkDeadline(deadlineAt);
162
+ beforeStop();
163
+ // Synchronous candidate revalidation and POST enqueue: no intervening await.
164
+ const stopped = await session.exchange(adminToken);
165
+ checkDeadline(deadlineAt);
166
+ if (stopped.response.statusCode !== 200 || stopped.body.success !== true
167
+ || stopped.body.sharedTeardown !== "performed") throw new Error(FAILURE);
168
+ } catch {
169
+ // Never expose parser/network/callback errors (which may contain credentials).
170
+ throw new Error(FAILURE);
171
+ } finally {
172
+ session?.close();
173
+ }
174
+ }
175
+
176
+ /** Observe only the caller's exact newly started runtime; ordinary liveness is not proof. */
177
+ export async function observeAttestedUpdateReplacement(options: AttestedTarget & {
178
+ version: string;
179
+ }): Promise<LiveProxy> {
180
+ let session: AttestedSession | undefined;
181
+ try {
182
+ session = new AttestedSession(options);
183
+ const health = await session.exchange();
184
+ session.validateHealth(health);
185
+ if (health.body.version !== options.version) throw new Error(FAILURE);
186
+ return { pid: options.target.pid, port: options.target.port, hostname: options.target.hostname,
187
+ source: "runtime", version: options.version };
188
+ } catch {
189
+ throw new Error("update_restart_replacement_observation_failed");
190
+ } finally {
191
+ session?.close();
192
+ }
193
+ }