@bitkyc08/opencodex 2.90.0 → 2.91.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 (132) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/{App-DgxkE6Md.js → App-nZ34cR5G.js} +18 -18
  3. package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-TmaHa54u.js → index-DGXS51sp.js} +12 -12
  5. package/gui/dist/assets/{quota-summary-B16x5DZZ.js → quota-summary-DO1Sx5TM.js} +1 -1
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/claude-cli/usage-admission.ts +14 -0
  10. package/src/adapters/openai-responses/passthrough.ts +2 -1
  11. package/src/adapters/openai-responses/request-strips.ts +32 -0
  12. package/src/adapters/openai-responses/transport.ts +6 -0
  13. package/src/chat/inbound.ts +20 -2
  14. package/src/chat/outbound.ts +36 -4
  15. package/src/chat/reasoning-details.ts +137 -0
  16. package/src/cli/capabilities-agents-routing.ts +50 -0
  17. package/src/cli/capabilities-base.ts +15 -2
  18. package/src/cli/capabilities-observe-system.ts +4 -4
  19. package/src/cli/codex-shim-autorestore.ts +5 -0
  20. package/src/cli/dispatch.ts +30 -0
  21. package/src/cli/fallback.ts +127 -0
  22. package/src/cli/help.ts +1 -0
  23. package/src/cli/index.ts +1 -2
  24. package/src/cli/link-apply-restart.ts +72 -0
  25. package/src/cli/link-child.ts +6 -2
  26. package/src/cli/link-cleanup.ts +95 -0
  27. package/src/cli/link-enrollment.ts +4 -0
  28. package/src/cli/link.ts +84 -9
  29. package/src/cli/provider.ts +44 -2
  30. package/src/cli/registry.ts +13 -1
  31. package/src/cli/restart-failure.ts +4 -0
  32. package/src/cli/runtime-api.ts +1 -1
  33. package/src/cli/status-codex-settings.ts +56 -0
  34. package/src/cli/status-fallback.ts +29 -0
  35. package/src/cli/status-install.ts +52 -0
  36. package/src/cli/status.ts +20 -1
  37. package/src/cli/system-restart-client.ts +32 -1
  38. package/src/cli/update-restart-child.ts +4 -3
  39. package/src/cli/update-restart-home.ts +20 -8
  40. package/src/client/connect.ts +82 -6
  41. package/src/client/home-unreachable.ts +16 -0
  42. package/src/client/hub-relay.ts +8 -2
  43. package/src/client/link-home.ts +119 -6
  44. package/src/client/link-parity-reporter.ts +107 -0
  45. package/src/client/link-relay-transport.ts +8 -1
  46. package/src/client/link-relay.ts +27 -3
  47. package/src/client/machine-listener.ts +83 -1
  48. package/src/client/runtime-restart.ts +127 -0
  49. package/src/client/runtime.ts +137 -25
  50. package/src/codex/auth-context.ts +10 -6
  51. package/src/codex/catalog/parsing.ts +4 -0
  52. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  53. package/src/codex/quota-history.ts +7 -0
  54. package/src/codex/quota.ts +10 -0
  55. package/src/codex/subagent-model-fallback.ts +24 -1
  56. package/src/combos/index.ts +1 -0
  57. package/src/combos/resolve.ts +12 -0
  58. package/src/config/diagnostics.ts +5 -0
  59. package/src/config/load-degrade.ts +2 -0
  60. package/src/config/schema/config-schema.ts +3 -0
  61. package/src/config/schema/fallback-policy.ts +40 -0
  62. package/src/generated/compatibility-version.json +205 -97
  63. package/src/lib/authorization-scheme.ts +95 -0
  64. package/src/lib/provider-client-headers.ts +1 -0
  65. package/src/lib/redact.ts +2 -2
  66. package/src/lib/system-restart-contract.ts +24 -0
  67. package/src/lib/test-home-guard.ts +78 -11
  68. package/src/link/child-parity.ts +124 -0
  69. package/src/link/codex-settings.ts +37 -0
  70. package/src/link/compatibility.ts +119 -0
  71. package/src/link/management-routes.ts +2 -0
  72. package/src/link/peer-report.ts +100 -0
  73. package/src/link/remote-platform.ts +78 -0
  74. package/src/link/ssh-argv.ts +19 -0
  75. package/src/link/status-projection.ts +24 -2
  76. package/src/oauth/index.ts +1 -1
  77. package/src/oauth/kimi.ts +2 -0
  78. package/src/oauth/nous.ts +4 -1
  79. package/src/oauth/store.ts +2 -1
  80. package/src/protocols/codecs/chat.ts +3 -3
  81. package/src/protocols/encoders/adapter-events.ts +26 -2
  82. package/src/protocols/encoders/chat.ts +55 -3
  83. package/src/providers/mistral-transport.ts +37 -4
  84. package/src/providers/quota/antigravity.ts +2 -8
  85. package/src/quota-pool/assemble.ts +326 -0
  86. package/src/quota-pool/contract.ts +58 -0
  87. package/src/quota-pool/identity.ts +203 -0
  88. package/src/remote/features.ts +74 -0
  89. package/src/remote/protocol.ts +2 -0
  90. package/src/remote/served-by.ts +57 -0
  91. package/src/responses/parser.ts +7 -0
  92. package/src/responses/reasoning-envelope.ts +12 -0
  93. package/src/routing/fallback-policy.ts +290 -0
  94. package/src/routing/fallback-state.ts +179 -0
  95. package/src/server/audio-client.ts +2 -1
  96. package/src/server/audio-dictation.ts +2 -1
  97. package/src/server/audio-upstream.ts +2 -1
  98. package/src/server/auth-cors.ts +6 -5
  99. package/src/server/background-lifecycle.ts +4 -0
  100. package/src/server/chat-completions.ts +8 -1
  101. package/src/server/gui-session.ts +2 -1
  102. package/src/server/index/link-listener.ts +11 -1
  103. package/src/server/index/link-management.ts +47 -0
  104. package/src/server/index/link-relay-sessions.ts +11 -2
  105. package/src/server/index/optional-listeners.ts +7 -2
  106. package/src/server/index/serve-options.ts +19 -2
  107. package/src/server/live.ts +5 -0
  108. package/src/server/management/context.ts +1 -1
  109. package/src/server/management/fallback-routes.ts +75 -0
  110. package/src/server/management/link-enrollment.ts +21 -3
  111. package/src/server/management/link-route-state.ts +2 -0
  112. package/src/server/management/link-routes.ts +12 -1
  113. package/src/server/management/quota-pool-routes.ts +24 -0
  114. package/src/server/management/route-registry.ts +3 -0
  115. package/src/server/management-api.ts +16 -0
  116. package/src/server/management-auth.ts +2 -1
  117. package/src/server/responses/agent-task-recovery.ts +1 -1
  118. package/src/server/responses/compact.ts +2 -2
  119. package/src/server/responses/core-auth.ts +11 -1
  120. package/src/server/responses/core-combo.ts +72 -1
  121. package/src/server/responses/encrypted-payload.ts +3 -3
  122. package/src/server/responses/main-hard-lock-routing.ts +54 -2
  123. package/src/server/responses/policy-fallback.ts +44 -1
  124. package/src/server/responses/request-prepare.ts +9 -1
  125. package/src/server/restart-replacement.ts +50 -2
  126. package/src/server/ws-bridge.ts +5 -0
  127. package/src/service/cli.ts +25 -2
  128. package/src/service/diagnostics.ts +20 -6
  129. package/src/service/status-json.ts +116 -0
  130. package/src/types/config.ts +7 -0
  131. package/src/types/fallback.ts +38 -0
  132. package/gui/dist/assets/App-dnkJ85te.css +0 -1
package/src/cli/link.ts CHANGED
@@ -2,7 +2,11 @@ import { clientLinkStatusIdentity } from "../client/link-status";
2
2
  import { clientLinkTunnelStatus } from "../client/link-tunnel";
3
3
  import { loadConfig } from "../config";
4
4
  import { projectLinkStatus, type ClientLinkIdentity, type LinkStatusConfig, type LinkStatusDto } from "../link/status-projection";
5
+ import { isEpochMs, type LinkChildParity } from "../link/child-parity";
6
+ import { CODEX_SETTINGS_DIGEST_PATTERN } from "../link/codex-settings";
7
+ import type { LinkLocalPeer, LinkPeerStatus } from "../link/compatibility";
5
8
  import { runLinkEnrollment } from "./link-enrollment";
9
+ import { runLinkCleanup, type LinkCleanupDeps } from "./link-cleanup";
6
10
  import { assertSshAlias } from "../link/ssh-argv";
7
11
  import { linkStorePath } from "../link/paths";
8
12
  import { isLinkPort } from "../link/ports";
@@ -31,8 +35,10 @@ export const LINK_USAGE = `Usage:
31
35
  ocx link status [--json]
32
36
  ocx link revoke --link-id <id> [--force --yes] [--json]
33
37
  ocx link reconnect --link-id <id> [--json]
38
+ ocx link cleanup [--link-id <id>] [--json]
34
39
 
35
- Forced revoke skips remote cleanup. Run ocx disconnect on the remote client.`;
40
+ Forced revoke skips remote cleanup. Run ocx disconnect on the remote client.
41
+ Cleanup finds Windows Children and explains how to move them to Hub mode; it changes nothing.`;
36
42
 
37
43
  type LinkState = "connecting" | "connected" | "reconnecting" | "failed" | "idle";
38
44
  type ListenerState = "off" | "listening" | "failed";
@@ -40,7 +46,7 @@ type ListenerState = "off" | "listening" | "failed";
40
46
  export interface LinkStatusPayload {
41
47
  role: "standalone" | "home" | "child";
42
48
  listener: { state: ListenerState; port: number | null };
43
- links: Array<{
49
+ links: Array<LinkChildParity & {
44
50
  id: string;
45
51
  alias: string;
46
52
  direction: LinkDirection;
@@ -48,6 +54,7 @@ export interface LinkStatusPayload {
48
54
  since: string;
49
55
  reason: string | null;
50
56
  tunnelPort: number;
57
+ peer?: LinkPeerStatus;
51
58
  }>;
52
59
  child: null | {
53
60
  alias: string;
@@ -55,6 +62,7 @@ export interface LinkStatusPayload {
55
62
  since: string;
56
63
  reason: string | null;
57
64
  };
65
+ local?: LinkLocalPeer;
58
66
  }
59
67
 
60
68
  export interface LinkIssuePayload {
@@ -64,7 +72,7 @@ export interface LinkIssuePayload {
64
72
  listenerPort: number;
65
73
  }
66
74
 
67
- export interface LinkCliDeps extends RuntimeApiDeps {
75
+ export interface LinkCliDeps extends RuntimeApiDeps, Omit<LinkCleanupDeps, "readStore"> {
68
76
  /** Test seam can shorten, never extend, the whole enrollment deadline. */
69
77
  enrollmentTimeoutMs?: number;
70
78
  choosePort?: () => Promise<number>;
@@ -85,9 +93,14 @@ function isRecord(value: unknown): value is Record<string, unknown> {
85
93
  }
86
94
 
87
95
  function assertExactKeys(value: Record<string, unknown>, keys: readonly string[], label: string): void {
88
- const expected = new Set(keys);
96
+ assertKeys(value, keys, [], label);
97
+ }
98
+
99
+ /** Exact keys plus fields a newer proxy adds; an older proxy omits them. */
100
+ function assertKeys(value: Record<string, unknown>, keys: readonly string[], optional: readonly string[], label: string): void {
101
+ const allowed = new Set([...keys, ...optional]);
89
102
  const actual = Object.keys(value);
90
- if (actual.length !== expected.size || actual.some(key => !expected.has(key))) {
103
+ if (keys.some(key => !(key in value)) || actual.some(key => !allowed.has(key))) {
91
104
  throw new Error(`invalid link API response: ${label} fields`);
92
105
  }
93
106
  }
@@ -104,9 +117,42 @@ function validNullableString(value: unknown): value is string | null {
104
117
  return value === null || typeof value === "string";
105
118
  }
106
119
 
120
+ function validStringList(value: unknown): value is string[] {
121
+ return Array.isArray(value) && value.every(validString);
122
+ }
123
+
124
+ function validateLocal(value: unknown): LinkLocalPeer {
125
+ if (!isRecord(value)) throw new Error("invalid link API response: local");
126
+ assertExactKeys(value, ["version", "relayAuth", "features"], "local");
127
+ if (!validString(value.version) || !validString(value.relayAuth) || !validStringList(value.features)) {
128
+ throw new Error("invalid link API response: local fields");
129
+ }
130
+ return { version: value.version, relayAuth: value.relayAuth, features: [...value.features] };
131
+ }
132
+
133
+ function validatePeer(value: unknown, index: number): LinkPeerStatus {
134
+ const fail = (): never => { throw new Error(`invalid link API response: link ${index} peer`); };
135
+ if (!isRecord(value)) return fail();
136
+ assertExactKeys(value, ["observedAt", "version", "versionRange", "relayAuth", "features", "compatibility", "reasons"], `link ${index} peer`);
137
+ const range = value.versionRange;
138
+ if (!validNullableString(value.observedAt) || !validNullableString(value.version) || !validNullableString(value.relayAuth)
139
+ || (value.features !== null && !validStringList(value.features)) || !validStringList(value.reasons)
140
+ || !["compatible", "degraded", "incompatible", "unknown"].includes(value.compatibility as string)
141
+ || (range !== null && (!isRecord(range) || !validNullableString(range.min) || !validString(range.below)))) fail();
142
+ return {
143
+ observedAt: value.observedAt as string | null,
144
+ version: value.version as string | null,
145
+ versionRange: range === null ? null : { min: (range as Record<string, unknown>).min as string | null, below: (range as Record<string, unknown>).below as string },
146
+ relayAuth: value.relayAuth as string | null,
147
+ features: value.features === null ? null : [...value.features as string[]],
148
+ compatibility: value.compatibility as LinkPeerStatus["compatibility"],
149
+ reasons: [...value.reasons as string[]],
150
+ };
151
+ }
152
+
107
153
  function validateStatus(value: unknown): LinkStatusPayload {
108
154
  if (!isRecord(value)) throw new Error("invalid link API response: status object");
109
- assertExactKeys(value, ["role", "listener", "links", "child"], "status");
155
+ assertKeys(value, ["role", "listener", "links", "child"], ["local"], "status");
110
156
  if (value.role !== "standalone" && value.role !== "home" && value.role !== "child") {
111
157
  throw new Error("invalid link API response: status role");
112
158
  }
@@ -121,7 +167,10 @@ function validateStatus(value: unknown): LinkStatusPayload {
121
167
  if (!Array.isArray(value.links)) throw new Error("invalid link API response: links");
122
168
  const links = value.links.map((candidate, index) => {
123
169
  if (!isRecord(candidate)) throw new Error(`invalid link API response: link ${index}`);
124
- assertExactKeys(candidate, ["id", "alias", "direction", "state", "since", "reason", "tunnelPort"], `link ${index}`);
170
+ // The OCX-6 parity fields are optional on the wire (an older runtime omits them) and always
171
+ // present in this output, null when absent.
172
+ assertKeys(candidate, ["id", "alias", "direction", "state", "since", "reason", "tunnelPort"], [...PARITY_KEYS, "peer"], `link ${index}`);
173
+ const parity = validateParity(candidate, index);
125
174
  if (typeof candidate.id !== "string" || !LINK_ID.test(candidate.id)
126
175
  || !validString(candidate.alias) || (candidate.direction !== "hub-initiated" && candidate.direction !== "client-initiated")
127
176
  || (candidate.state !== "connecting" && candidate.state !== "connected" && candidate.state !== "reconnecting" && candidate.state !== "failed" && candidate.state !== "idle")
@@ -136,6 +185,8 @@ function validateStatus(value: unknown): LinkStatusPayload {
136
185
  since: candidate.since,
137
186
  reason: candidate.reason,
138
187
  tunnelPort: candidate.tunnelPort,
188
+ ...parity,
189
+ ...(candidate.peer !== undefined ? { peer: validatePeer(candidate.peer, index) } : {}),
139
190
  } as LinkStatusPayload["links"][number];
140
191
  });
141
192
  let child: LinkStatusPayload["child"] = null;
@@ -159,6 +210,7 @@ function validateStatus(value: unknown): LinkStatusPayload {
159
210
  listener: { state: value.listener.state, port: value.listener.port },
160
211
  links,
161
212
  child,
213
+ ...(value.local !== undefined ? { local: validateLocal(value.local) } : {}),
162
214
  };
163
215
  }
164
216
 
@@ -258,7 +310,8 @@ async function runStatus(args: string[], deps: LinkCliDeps): Promise<void> {
258
310
  }
259
311
  throw error;
260
312
  }
261
- const result = validateStatus(await linkRequest<unknown>("/api/link/status", {}, { ...deps, baseUrl }));
313
+ // A proxy older than the compatibility report ignores the query and answers the exact K16 shape.
314
+ const result = validateStatus(await linkRequest<unknown>("/api/link/status?compatibility=1", {}, { ...deps, baseUrl }));
262
315
  console.log(JSON.stringify(result));
263
316
  }
264
317
 
@@ -326,6 +379,28 @@ export async function runLinkCommand(rawArgs: string[], deps: LinkCliDeps = {}):
326
379
  else if (command === "status") await runStatus(args, deps);
327
380
  else if (command === "revoke") await runRevoke(args, deps);
328
381
  else if (command === "reconnect") await runReconnect(args, deps);
329
- else throw new CliUsageError("link requires one of: probe, add, port, issue, status, revoke, reconnect", LINK_USAGE);
382
+ else if (command === "cleanup") await runLinkCleanup(args, deps, LINK_USAGE);
383
+ else throw new CliUsageError("link requires one of: probe, add, port, issue, status, revoke, reconnect, cleanup", LINK_USAGE);
330
384
  });
331
385
  }
386
+
387
+ const PARITY_KEYS = ["lastApplyAt", "runtimeStartedAt", "appliedCodexSettingsDigest", "reportedAt"] as const;
388
+
389
+ function validateParity(candidate: Record<string, unknown>, index: number): LinkChildParity {
390
+ const time = (key: "lastApplyAt" | "runtimeStartedAt" | "reportedAt"): number | null => {
391
+ const value = candidate[key];
392
+ if (value === undefined || value === null) return null;
393
+ if (!isEpochMs(value)) throw new Error(`invalid link API response: link ${index} ${key}`);
394
+ return value;
395
+ };
396
+ const digest = candidate.appliedCodexSettingsDigest;
397
+ if (digest !== undefined && digest !== null && (typeof digest !== "string" || !CODEX_SETTINGS_DIGEST_PATTERN.test(digest))) {
398
+ throw new Error(`invalid link API response: link ${index} appliedCodexSettingsDigest`);
399
+ }
400
+ return {
401
+ lastApplyAt: time("lastApplyAt"),
402
+ runtimeStartedAt: time("runtimeStartedAt"),
403
+ appliedCodexSettingsDigest: typeof digest === "string" ? digest : null,
404
+ reportedAt: time("reportedAt"),
405
+ };
406
+ }
@@ -28,6 +28,8 @@ import type { RuntimeApiDeps } from "./runtime-api";
28
28
  import { redactSecretArgs } from "./secret-args";
29
29
  import { projectLocalSyncResult, type LocalSyncResult } from "./local-sync-result";
30
30
  import { providerManagementConfigError } from "../server/auth-cors";
31
+ import { readClientConnectionState, type ClientConnectionState } from "../client/state";
32
+ import { SENSITIVE_KEY_PATTERN } from "../lib/redact";
31
33
 
32
34
  export interface ProviderCommandDeps extends RuntimeApiDeps {
33
35
  syncModels?: typeof syncModelsToCodex;
@@ -102,7 +104,46 @@ function validateAndSave(config: ReturnType<typeof loadConfig>): void {
102
104
  // provider list
103
105
  // ---------------------------------------------------------------------------
104
106
 
105
- function handleList(args: string[]): void {
107
+ /**
108
+ * Does this device's own provider config serve requests? A device linked to a Home — a Remote
109
+ * Link Child or a Hub-mode client — routes through the Home, so its local rows are shadowed.
110
+ * A standalone device and a Hub serve their own rows. An unreadable or inconsistent client
111
+ * state is not a link, the same reading `readLinkChildContext` takes.
112
+ */
113
+ export function localProvidersActive(read: () => ClientConnectionState = readClientConnectionState): boolean {
114
+ try {
115
+ return read().kind !== "connected";
116
+ } catch {
117
+ return true;
118
+ }
119
+ }
120
+
121
+ const SENSITIVE_QUERY_KEY = /key|token|secret|password|passwd|signature|^sig$|auth|credential/i;
122
+
123
+ /**
124
+ * The baseUrl as `provider list` prints it. Validation already rejects embedded credentials;
125
+ * this also covers a hand-edited config, dropping userinfo and masking credential-shaped query
126
+ * values while keeping ordinary parameters such as `api-version`.
127
+ */
128
+ export function providerListBaseUrl(baseUrl: string): string {
129
+ let parsed: URL;
130
+ try { parsed = new URL(baseUrl); } catch { return baseUrl; }
131
+ let changed = false;
132
+ if (parsed.username || parsed.password) {
133
+ parsed.username = "";
134
+ parsed.password = "";
135
+ changed = true;
136
+ }
137
+ for (const key of [...new Set(parsed.searchParams.keys())]) {
138
+ if (SENSITIVE_KEY_PATTERN.test(key) || SENSITIVE_QUERY_KEY.test(key)) {
139
+ parsed.searchParams.set(key, "redacted");
140
+ changed = true;
141
+ }
142
+ }
143
+ return changed ? parsed.toString() : baseUrl;
144
+ }
145
+
146
+ function handleList(args: string[], active: boolean = localProvidersActive()): void {
106
147
  const wantsJson = consumeFlag(args, "--json");
107
148
  const wantsJsonl = consumeFlag(args, "--jsonl");
108
149
  rejectUnknownArgs(args, "Usage: ocx provider list [--json|--jsonl]");
@@ -120,12 +161,13 @@ function handleList(args: string[]): void {
120
161
  return {
121
162
  name,
122
163
  adapter: prov.adapter,
123
- baseUrl: prov.baseUrl,
164
+ baseUrl: providerListBaseUrl(prov.baseUrl),
124
165
  authMode: prov.authMode ?? "key",
125
166
  defaultModel: prov.defaultModel ?? null,
126
167
  isDefault: name === config.defaultProvider,
127
168
  source: registryEntry ? "registry" : "custom",
128
169
  models: prov.models ?? [],
170
+ active,
129
171
  };
130
172
  });
131
173
 
@@ -150,7 +150,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
150
150
  },
151
151
  {
152
152
  name: "link",
153
- usage: "ocx link <port|issue|status|revoke|reconnect>",
153
+ usage: "ocx link <port|issue|status|revoke|reconnect|cleanup>",
154
154
  summary: "Allocate and manage a loopback remote home link.",
155
155
  details: [
156
156
  "Port: ocx link port [--json]",
@@ -158,6 +158,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
158
158
  "Status: ocx link status [--json]",
159
159
  "Revoke: ocx link revoke --link-id <id> [--force --yes] [--json]",
160
160
  "Reconnect: ocx link reconnect --link-id <id> [--json]",
161
+ "Cleanup: ocx link cleanup [--link-id <id>] [--json]",
161
162
  "Issue, status, revoke, and reconnect use the running proxy's loopback management API and admin token.",
162
163
  ],
163
164
  },
@@ -434,6 +435,17 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
434
435
  "Works both online (via live proxy API) and offline (modifies persisted config safely with atomic writes).",
435
436
  ],
436
437
  },
438
+ {
439
+ name: "fallback",
440
+ usage: "ocx fallback <status|off|on|clear|local> [--kind model|provider|all] [--provider <name> | --route <selector> | --device <id|alias>] [--json]",
441
+ summary: "Turn model and provider fallback off or on per scope, and record per-device local fallback.",
442
+ details: [
443
+ "A refused fallback answers fallback_refused with the status of the failure that triggered it, and names the command that allows it.",
444
+ "Precedence: a device set to off always refuses; otherwise route, then provider, then global; unset allows.",
445
+ "Rotation among the accounts or keys of one provider pool is not a fallback and is not governed here.",
446
+ "Live proxy only. On a Remote Link Child, status reads the Home; writes are refused. Run write commands on the Home.",
447
+ ],
448
+ },
437
449
  {
438
450
  name: "agent",
439
451
  usage: "ocx agent <status|injection|effort|subagents|fallback|sidecar> ...",
@@ -16,6 +16,10 @@ export function reportRestartFailure(result: Extract<ProxyRestartResult, { ok: f
16
16
  console.error("The proxy home or lifecycle ownership could not be verified. Run `ocx status`; if a service owns the proxy, use its installation's `ocx service restart`, otherwise use the owning app or supervisor. No changes were made.");
17
17
  } else if (code === "restart_package_tree_unsettled") {
18
18
  console.error(`The proxy's package files are unsettled. Wait for the install to finish, then run \`ocx restart\`. ${INCOMPLETE_INSTALL_RECOVERY}`);
19
+ } else if (code === "restart_supervised_service") {
20
+ console.error("Restart not performed; the running client runtime was kept. Its background service owns it: run `ocx service restart`.");
21
+ } else if (code === "restart_supervised_desktop") {
22
+ console.error("Restart not performed; the running client runtime was kept. The OpenCodex desktop app owns it: restart it from the app.");
19
23
  } else {
20
24
  console.error("Proxy restart request could not be confirmed; no fallback stop/start was attempted. Run `ocx status` to inspect ownership and runtime state.");
21
25
  }
@@ -202,7 +202,7 @@ export async function runtimeRequest<T = unknown>(
202
202
  if (!linkChildReadable(method, path)) {
203
203
  throw new RuntimeApiError(linkChildRefusal(link, `${method} ${pathname}`), 503, null, "link_child_management_unavailable");
204
204
  }
205
- const { status, body } = await readLinkHomeJson(link, path, deps.linkHome);
205
+ const { status, body } = await readLinkHomeJson(link, path, deps.linkHome, init.signal ? { signal: init.signal } : {});
206
206
  if (status < 200 || status >= 300) throw new RuntimeApiError(responseMessage(body, status), status, body);
207
207
  return body as T;
208
208
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The OCX-6 fields of `ocx status --json`: which Codex settings this machine applied, when, and
3
+ * when its client runtime started.
4
+ *
5
+ * - `appliedCodexSettingsDigest` (any device): the digest of the values this machine applies. On a
6
+ * Remote Link Child that is its apply record (the Home's values its last sync wrote); on a Home or
7
+ * standalone install, its own projection, which is also exactly what it serves its Children. Null
8
+ * on a hub client and on a Child that has no apply record yet.
9
+ * - `lastApplyAt` (Child): epoch ms of the last successful apply of a different Home settings
10
+ * document; null elsewhere.
11
+ * - `runtimeStartedAt` (Child): epoch ms the live client runtime reports as its start; null when no
12
+ * client runtime answers.
13
+ */
14
+ import type { LiveProxy } from "../server/proxy-liveness";
15
+ import type { OcxConfig } from "../types";
16
+
17
+ export interface CliAppliedCodexSettingsStatus {
18
+ appliedCodexSettingsDigest: string | null;
19
+ lastApplyAt: number | null;
20
+ runtimeStartedAt: number | null;
21
+ }
22
+
23
+ export interface AppliedCodexSettingsStatusDeps {
24
+ readStartedAt?: (live: LiveProxy) => Promise<number | null>;
25
+ }
26
+
27
+ export async function collectAppliedCodexSettingsStatus(
28
+ config: OcxConfig,
29
+ live: LiveProxy | null,
30
+ deps: AppliedCodexSettingsStatusDeps = {},
31
+ ): Promise<CliAppliedCodexSettingsStatus> {
32
+ const { codexSettingsDigest, projectHomeCodexSettings } = await import("../link/codex-settings");
33
+ if (config.runtimeRole !== "client") {
34
+ return {
35
+ appliedCodexSettingsDigest: codexSettingsDigest(projectHomeCodexSettings(config, "0.0.0").settings),
36
+ lastApplyAt: null,
37
+ runtimeStartedAt: null,
38
+ };
39
+ }
40
+ const linkId = config.client?.transport === "link" ? config.client.link?.linkId : undefined;
41
+ if (!linkId) return { appliedCodexSettingsDigest: null, lastApplyAt: null, runtimeStartedAt: null };
42
+ const { readHomeCodexSettingsRecord } = await import("../client/link-home");
43
+ const record = readHomeCodexSettingsRecord(linkId);
44
+ const appliedAt = record ? Date.parse(record.appliedAt) : Number.NaN;
45
+ let runtimeStartedAt: number | null = null;
46
+ if (live?.role === "client") {
47
+ const read = deps.readStartedAt ?? (async (target: LiveProxy) =>
48
+ (await import("./link-apply-restart")).readClientRuntimeStartedAt(target));
49
+ runtimeStartedAt = await read(live);
50
+ }
51
+ return {
52
+ appliedCodexSettingsDigest: record ? codexSettingsDigest(record.document.settings) : null,
53
+ lastApplyAt: Number.isFinite(appliedAt) ? appliedAt : null,
54
+ runtimeStartedAt,
55
+ };
56
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `ocx status --json` `fallback` (OCX-11). The live proxy's `GET /api/fallback` when it answers
3
+ * (on a Remote Link Child that read is the Home's, over the link); otherwise the policy from this
4
+ * machine's config with no events, marked `source: "local"`. Never fails the status report.
5
+ */
6
+ import type { OcxConfig } from "../types";
7
+ import { buildFallbackState, type FallbackStateView } from "../routing/fallback-state";
8
+ import { runtimeRequest, type RuntimeApiDeps } from "./runtime-api";
9
+
10
+ export type StatusFallback = (FallbackStateView | ReturnType<typeof buildFallbackState>) & { source: "live" | "home" | "local" };
11
+
12
+ const STATUS_FALLBACK_TIMEOUT_MS = 2_000;
13
+
14
+ export async function collectStatusFallback(
15
+ config: Pick<OcxConfig, "fallback">,
16
+ options: { live: boolean; linkChild: boolean; deps?: RuntimeApiDeps },
17
+ ): Promise<StatusFallback> {
18
+ if (options.live) {
19
+ try {
20
+ const state = await runtimeRequest<FallbackStateView>("/api/fallback", {
21
+ signal: AbortSignal.timeout(STATUS_FALLBACK_TIMEOUT_MS),
22
+ }, options.deps ?? {});
23
+ if (state && typeof state === "object" && state.schema === "ocx.fallback/1") {
24
+ return { ...state, source: options.linkChild ? "home" : "live" };
25
+ }
26
+ } catch { /* an older or unreachable proxy: report the local policy instead */ }
27
+ }
28
+ return { ...buildFallbackState(options.linkChild ? {} : config), source: "local" };
29
+ }
@@ -0,0 +1,52 @@
1
+ import { detectInstallOwnership, type InstallOwnership } from "../update";
2
+
3
+ /** How this ocx is installed, from the updater's own ownership detector. */
4
+ export type InstallMethod = "bun" | "npm" | "pnpm" | "mise" | "source" | "standalone" | "unknown";
5
+
6
+ /**
7
+ * `ocx status --json` `install`. Additive within `schemaVersion: 1`.
8
+ *
9
+ * The verdict is the one `ocx update` acts on (`src/update/install-detection.mjs`), read
10
+ * from the running package's own location — PATH is never consulted, because a machine can
11
+ * carry several package managers at once. `evidence` names what decided it without adding
12
+ * a path: the package layout, mise's backend record (tool alias and backend only), the
13
+ * compiled standalone binary, or nothing when detection itself failed.
14
+ */
15
+ export type InstallDetectionStatus = {
16
+ method: InstallMethod;
17
+ evidence: {
18
+ basis: "package-layout" | "mise-backend-record" | "standalone-binary" | "unavailable";
19
+ mise?: { tool: string; backend: string };
20
+ error?: "metadata_unreadable" | "metadata_inconsistent" | "detection_failed";
21
+ };
22
+ };
23
+
24
+ export function installDetectionStatus(
25
+ detect: () => InstallOwnership = detectInstallOwnership,
26
+ ): InstallDetectionStatus {
27
+ let ownership: InstallOwnership;
28
+ try {
29
+ ownership = detect();
30
+ } catch {
31
+ return { method: "unknown", evidence: { basis: "unavailable", error: "detection_failed" } };
32
+ }
33
+ switch (ownership.installer) {
34
+ case "standalone":
35
+ return { method: "standalone", evidence: { basis: "standalone-binary" } };
36
+ case "mise":
37
+ if (ownership.owner) {
38
+ return {
39
+ method: "mise",
40
+ evidence: { basis: "mise-backend-record", mise: { tool: ownership.owner.tool, backend: ownership.owner.backend } },
41
+ };
42
+ }
43
+ return { method: "mise", evidence: { basis: "mise-backend-record", error: ownership.error ?? "metadata_unreadable" } };
44
+ case "bun":
45
+ case "npm":
46
+ case "pnpm":
47
+ case "source":
48
+ return { method: ownership.installer, evidence: { basis: "package-layout" } };
49
+ default:
50
+ return { method: "unknown", evidence: { basis: "unavailable", error: "detection_failed" } };
51
+ }
52
+ }
package/src/cli/status.ts CHANGED
@@ -36,6 +36,8 @@ export type { ListenTarget } from "./status-probes";
36
36
  import { checkProxyHealth, probeUncleanExitState, type ListenTarget } from "./status-probes";
37
37
  import { LOCAL_MANAGEMENT_READ_PATHS } from "../lib/local-management-capability";
38
38
  import { fetchBoundLocalManagementRead } from "../server/local-management-read-client";
39
+ import { collectAppliedCodexSettingsStatus, type CliAppliedCodexSettingsStatus } from "./status-codex-settings";
40
+ import { installDetectionStatus, type InstallDetectionStatus } from "./status-install";
39
41
 
40
42
  /**
41
43
  * The state of the data-plane admission secret the SERVICE will use. State only -- never the value.
@@ -118,6 +120,8 @@ export type CliRemoteHubStatus = {
118
120
 
119
121
  export type CliStatusJson = {
120
122
  mainAccountHardLock?: CodexMainAccountPolicyHealth;
123
+ /** OCX-11 fallback state; see src/cli/status-fallback.ts. */
124
+ fallback?: import("./status-fallback").StatusFallback;
121
125
  schemaVersion: 1;
122
126
  /**
123
127
  * This machine's topology role, named rather than inferred (#4236).
@@ -229,8 +233,10 @@ export type CliStatusJson = {
229
233
  */
230
234
  versionSkew: VersionSkew;
231
235
  cliCommand: CliPathDiagnostics;
236
+ /** Installer ownership from the updater's detector (OCX-10); additive, `schemaVersion` stays 1. */
237
+ install: InstallDetectionStatus;
232
238
  startupSource: "live" | "local" | "local-supervision-override";
233
- };
239
+ } & CliAppliedCodexSettingsStatus;
234
240
 
235
241
  export type CliStatusView = {
236
242
  json: CliStatusJson;
@@ -853,12 +859,22 @@ export async function collectStatus(options: { mainAccountPolicy?: boolean } = {
853
859
  const mainAccountHardLock = options.mainAccountPolicy && live
854
860
  ? (await (await import("../oauth/health")).fetchCodexHealthFromLiveProxy(undefined, async () => live)).mainAccountHardLock
855
861
  : undefined;
862
+ // Same `--json`-only gate as the hard-lock read above: the human report stays one probe.
863
+ const fallback = options.mainAccountPolicy
864
+ ? await (await import("./status-fallback")).collectStatusFallback(config, {
865
+ live: Boolean(live), linkChild: config.client?.transport === "link",
866
+ deps: { findLiveProxy: async () => live },
867
+ })
868
+ : undefined;
856
869
  const pidFile = readPid();
857
870
  // Preserve an authoritative null from orphan/legacy liveness — do not restore pidFile.
858
871
  const pid = resolveStatusPid(live, pidFile);
859
872
  // No extra request: findLiveProxy's identity probe already parsed and validated the
860
873
  // healthz body, so the version came back with the liveness result.
861
874
  const versionSkew = computeVersionSkew(packageVersion(), live?.version);
875
+ // OCX-6: what this machine applied and, on a Child, when its runtime started. Additive and
876
+ // always present (null when unknown), so `schemaVersion` stays 1.
877
+ const appliedCodexSettings = await collectAppliedCodexSettingsStatus(config, live);
862
878
  const listen = live
863
879
  ? {
864
880
  port: live.port,
@@ -1005,6 +1021,7 @@ export async function collectStatus(options: { mainAccountPolicy?: boolean } = {
1005
1021
  healthLabel: formatCliStatusHealthLabel(health.label, cliCommand, remoteHub.connected),
1006
1022
  json: {
1007
1023
  ...(mainAccountHardLock ? { mainAccountHardLock } : {}),
1024
+ ...(fallback ? { fallback } : {}),
1008
1025
  schemaVersion: 1,
1009
1026
  runtimeRole: config.runtimeRole ?? "standalone",
1010
1027
  proxy: {
@@ -1067,7 +1084,9 @@ export async function collectStatus(options: { mainAccountPolicy?: boolean } = {
1067
1084
  // print it under the wrong heading (#2701).
1068
1085
  versionSkew,
1069
1086
  cliCommand,
1087
+ install: installDetectionStatus(),
1070
1088
  startupSource,
1089
+ ...appliedCodexSettings,
1071
1090
  },
1072
1091
  };
1073
1092
  }
@@ -1,4 +1,5 @@
1
1
  import { readRuntimePort } from "../config/process-state";
2
+ import { readBoundedResponseBytes } from "../lib/bounded-body";
2
3
  import {
3
4
  LOCAL_ATTESTATION_CHALLENGE_HEADER,
4
5
  LOCAL_ATTESTATION_PROOF_HEADER,
@@ -6,6 +7,8 @@ import {
6
7
  verifyLocalAttestationProof,
7
8
  } from "../lib/local-management-attestation";
8
9
  import {
10
+ CLIENT_RESTART_REQUEST_MIN_REMAINING_MS,
11
+ RESTART_SUPERVISED_ERROR,
9
12
  SYSTEM_RESTART_CAPABILITY_HEADER,
10
13
  SYSTEM_RESTART_CAPABILITY_VERSION,
11
14
  SYSTEM_RESTART_EXPECTED_PID_HEADER,
@@ -182,6 +185,13 @@ export async function requestBoundSystemRestart(
182
185
  );
183
186
  if (!capability) return rejected("restart_capability_unavailable");
184
187
 
188
+ // A connected client runtime keeps serving when its replacement fails, but its rollback takes
189
+ // up to CLIENT_RESTART_BUDGET_MS. Ask only while that still ends before this CLI stops observing,
190
+ // or the recovery start below could race the rollback for the port (OCX-6).
191
+ if (body.role === "client" && deadlineAt - now() < CLIENT_RESTART_REQUEST_MIN_REMAINING_MS) {
192
+ return rejected("restart_deadline_expired");
193
+ }
194
+
185
195
  const requestBudget = remaining(deadlineAt, now, SYSTEM_RESTART_REQUEST_TIMEOUT_MS);
186
196
  if (requestBudget <= 0) return rejected("restart_deadline_expired");
187
197
  try {
@@ -194,10 +204,31 @@ export async function requestBoundSystemRestart(
194
204
  },
195
205
  signal: AbortSignal.timeout(requestBudget),
196
206
  });
197
- return response.ok ? { accepted: true } : rejected(`restart_request_http_${response.status}`);
207
+ if (response.ok) return { accepted: true };
208
+ if (response.status === 409) {
209
+ const supervisor = await supervisedRefusal(response);
210
+ if (supervisor) return rejected(`${RESTART_SUPERVISED_ERROR}_${supervisor}`);
211
+ }
212
+ return rejected(`restart_request_http_${response.status}`);
198
213
  } catch {
199
214
  // The server may have accepted the restart before the response connection failed.
200
215
  // The coordinator observes the original PID for replacement instead of replaying.
201
216
  return uncertain("restart_request_outcome_unknown");
202
217
  }
203
218
  }
219
+
220
+ /**
221
+ * A client runtime under the desktop app or a service answers 409 `restart_supervised` and keeps
222
+ * serving (OCX-6). Read that small body and return the validated supervisor, or null.
223
+ */
224
+ async function supervisedRefusal(response: Response): Promise<"service" | "desktop" | null> {
225
+ try {
226
+ const read = await readBoundedResponseBytes(response, { maxBytes: 1024, signal: AbortSignal.timeout(SYSTEM_RESTART_REQUEST_TIMEOUT_MS) });
227
+ if (read.oversized) return null;
228
+ const body = JSON.parse(new TextDecoder().decode(read.bytes)) as { error?: { code?: unknown; supervisor?: unknown } } | null;
229
+ if (body?.error?.code !== RESTART_SUPERVISED_ERROR) return null;
230
+ return body.error.supervisor === "service" || body.error.supervisor === "desktop" ? body.error.supervisor : null;
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
@@ -20,10 +20,11 @@ function parseMarker(raw: string): UpdateRestartChildMarker {
20
20
  || typeof value.home.serviceRecord.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.home.serviceRecord.digest)) throw new Error("update_restart_child_marker_invalid");
21
21
  for (const directory of [value.home.config, value.home.codex]) {
22
22
  // Windows reports 64-bit NTFS file ids (MFT sequence in the high 16 bits) as doubles above
23
- // 2^53. Parent and child read them through the same stat API, so any finite integer is a
24
- // faithful identity token; assertUpdateRestartHome still compares path, dev and ino exactly.
23
+ // 2^53, where neighbouring ids collapse. dev and ino may be large finite integers; fileId
24
+ // carries the exact "dev:ino" that assertUpdateRestartHome actually relies on.
25
25
  if (!directory || typeof directory.path !== "string" || !directory.path
26
- || !Number.isInteger(directory.dev) || !Number.isInteger(directory.ino)) {
26
+ || !Number.isInteger(directory.dev) || !Number.isInteger(directory.ino)
27
+ || typeof directory.fileId !== "string" || !/^\d{1,20}:\d{1,20}$/.test(directory.fileId)) {
27
28
  throw new Error("update_restart_child_marker_invalid");
28
29
  }
29
30
  }