@bitkyc08/opencodex 2.43.0 → 2.44.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 (138) hide show
  1. package/README.md +25 -17
  2. package/gui/dist/assets/index-B7_K1Hsj.js +115 -0
  3. package/gui/dist/assets/index-ltx3L-WS.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/command-code.ts +23 -2
  7. package/src/adapters/cursor/native-exec.ts +63 -11
  8. package/src/adapters/cursor/tool-definitions.ts +1 -1
  9. package/src/adapters/cursor/tool-schemas.ts +59 -0
  10. package/src/adapters/kiro.ts +54 -8
  11. package/src/adapters/openai-chat.ts +23 -6
  12. package/src/adapters/openai-responses.ts +3 -1
  13. package/src/adapters/opencode-go.ts +35 -0
  14. package/src/claude/agents-inject.ts +12 -5
  15. package/src/claude/desktop-3p-library.ts +89 -0
  16. package/src/claude/desktop-3p.ts +166 -95
  17. package/src/claude/desktop-discovery-inputs.ts +44 -0
  18. package/src/claude/desktop-profile.ts +1 -1
  19. package/src/claude/desktop-remote-store-artifact.ts +185 -0
  20. package/src/claude/desktop-remote-store-io.ts +91 -0
  21. package/src/claude/desktop-remote-store-state.ts +127 -0
  22. package/src/claude/desktop-remote-store.ts +269 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound-records.ts +7 -0
  25. package/src/claude/inbound.ts +1 -1
  26. package/src/claude/model-info.ts +16 -3
  27. package/src/cli/account-api.ts +7 -1
  28. package/src/cli/aside-profiles.ts +17 -0
  29. package/src/cli/capabilities.ts +39 -0
  30. package/src/cli/claude-agent-startup-sync.ts +3 -1
  31. package/src/cli/claude-desktop.ts +127 -18
  32. package/src/cli/connect.ts +27 -14
  33. package/src/cli/dispatch.ts +30 -16
  34. package/src/cli/export-command.ts +3 -1
  35. package/src/cli/index.ts +28 -16
  36. package/src/cli/integrations.ts +46 -12
  37. package/src/cli/opencode.ts +11 -4
  38. package/src/cli/runtime-api.ts +7 -2
  39. package/src/cli/uninstall-client-state.ts +78 -0
  40. package/src/client/connect.ts +476 -223
  41. package/src/client/hub-client.ts +81 -0
  42. package/src/client/hub-relay.ts +38 -6
  43. package/src/client/lifecycle-lock.ts +129 -0
  44. package/src/client/state.ts +71 -42
  45. package/src/clients/aside-profiles.ts +224 -0
  46. package/src/codex/account-lifecycle.ts +5 -9
  47. package/src/codex/auth-api.ts +50 -4
  48. package/src/codex/catalog/metadata.ts +0 -2
  49. package/src/codex/catalog/provider-fetch.ts +7 -5
  50. package/src/codex/catalog/sync.ts +88 -24
  51. package/src/codex/convergence.ts +2 -0
  52. package/src/codex/inject.ts +45 -14
  53. package/src/codex/journal.ts +6 -6
  54. package/src/codex/quota-refresh-outcome.ts +27 -0
  55. package/src/codex/shim.ts +7 -1
  56. package/src/combos/resolve.ts +10 -7
  57. package/src/config.ts +18 -5
  58. package/src/generated/compatibility-version.json +209 -105
  59. package/src/images/loop.ts +5 -2
  60. package/src/integrations/aside-profile-context.ts +270 -0
  61. package/src/integrations/aside-profile-journal.ts +229 -0
  62. package/src/integrations/aside-profiles.ts +176 -0
  63. package/src/integrations/catalog-refresh.ts +40 -0
  64. package/src/integrations/config-io.ts +16 -1
  65. package/src/integrations/owned-refresh.ts +14 -2
  66. package/src/integrations/state.ts +3 -1
  67. package/src/integrations/writer.ts +3 -1
  68. package/src/lab/fabric/producer-isolate.ts +84 -46
  69. package/src/lib/account-selection-events.ts +32 -0
  70. package/src/lib/errors.ts +9 -1
  71. package/src/lib/provider-outbound.ts +2 -45
  72. package/src/lib/proxy-env.ts +67 -0
  73. package/src/oauth/account-quota-rank.ts +9 -1
  74. package/src/oauth/anthropic-routing.ts +91 -23
  75. package/src/oauth/generic-account-failover.ts +15 -53
  76. package/src/oauth/index.ts +67 -68
  77. package/src/oauth/pool-settings-capability.ts +2 -1
  78. package/src/oauth/store.ts +91 -7
  79. package/src/oauth/types.ts +8 -0
  80. package/src/providers/api-key-selection.ts +110 -0
  81. package/src/providers/api-keys.ts +38 -31
  82. package/src/providers/context-cap.ts +27 -3
  83. package/src/providers/key-failover.ts +27 -17
  84. package/src/providers/provider-id-rewrite.ts +10 -8
  85. package/src/providers/registry.ts +1 -0
  86. package/src/responses/custom-tool-compat.ts +10 -0
  87. package/src/responses/function-call-compat.ts +173 -0
  88. package/src/responses/namespace-tool-compat.ts +27 -4
  89. package/src/responses/parser.ts +8 -0
  90. package/src/responses/task-input.ts +36 -0
  91. package/src/responses/tool-name-aliases.ts +79 -0
  92. package/src/router.ts +2 -0
  93. package/src/routing/capability.ts +10 -3
  94. package/src/routing/compatibility/assemble.ts +16 -1
  95. package/src/routing/evaluator.ts +5 -1
  96. package/src/server/auth-cors.ts +3 -0
  97. package/src/server/chat-native.ts +44 -9
  98. package/src/server/claude-messages.ts +71 -36
  99. package/src/server/grok-responses-snapshot-repair.ts +333 -0
  100. package/src/server/index.ts +26 -10
  101. package/src/server/management/account-selection-stream.ts +70 -0
  102. package/src/server/management/aside-profile-routes.ts +231 -0
  103. package/src/server/management/config-routes.ts +36 -41
  104. package/src/server/management/context.ts +3 -0
  105. package/src/server/management/integration-routes.ts +61 -3
  106. package/src/server/management/logs-usage-routes.ts +1 -0
  107. package/src/server/management/model-routes.ts +20 -9
  108. package/src/server/management/model-rows.ts +10 -2
  109. package/src/server/management/oauth-account-routes.ts +7 -0
  110. package/src/server/management/provider-routes.ts +10 -8
  111. package/src/server/management/route-registry.ts +20 -2
  112. package/src/server/management-auth.ts +16 -0
  113. package/src/server/relay-eager.ts +17 -5
  114. package/src/server/relay.ts +43 -4
  115. package/src/server/request-log.ts +29 -1
  116. package/src/server/responses/agent-task-recovery-cache.ts +11 -0
  117. package/src/server/responses/agent-task-recovery.ts +24 -4
  118. package/src/server/responses/codex-ws-pool.ts +4 -4
  119. package/src/server/responses/codex-ws-session.ts +2 -2
  120. package/src/server/responses/collaboration.ts +2 -1
  121. package/src/server/responses/combo-stream-preflight.ts +26 -7
  122. package/src/server/responses/core.ts +668 -151
  123. package/src/server/responses/fetch-helpers.ts +6 -1
  124. package/src/server/responses/ws-upstream.ts +8 -3
  125. package/src/server/responses-custom-tool-repair.ts +29 -9
  126. package/src/server/responses-function-tool-repair.ts +183 -0
  127. package/src/server/responses-snapshot-codec.ts +14 -0
  128. package/src/server/responses-snapshot-repair.ts +1 -13
  129. package/src/server/responses-undeclared-tool-guard.ts +1 -68
  130. package/src/server/sse-payload-rewrite.ts +3 -1
  131. package/src/service.ts +6 -2
  132. package/src/types/config.ts +23 -16
  133. package/src/types/provider.ts +14 -3
  134. package/src/types/request.ts +2 -0
  135. package/src/usage/log.ts +9 -0
  136. package/src/web-search/loop.ts +5 -2
  137. package/gui/dist/assets/index-DS1NE4Jn.css +0 -1
  138. package/gui/dist/assets/index-Djowl68T.js +0 -112
@@ -1,6 +1,8 @@
1
1
  import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download";
2
2
  import { readBoundedResponseBytes } from "../lib/bounded-body";
3
3
  import { clearableDeadline } from "../lib/abort";
4
+ import type { Desktop3pModelEntry } from "../claude/desktop-3p";
5
+ import { assertDesktop3pModelsValid } from "../claude/desktop-3p-guard";
4
6
 
5
7
  /**
6
8
  * A pairing grant may cross loopback or authenticated HTTPS, and nothing else.
@@ -30,6 +32,8 @@ import {
30
32
  const READY_BODY_LIMIT = 64 * 1024;
31
33
  const MANAGEMENT_BODY_LIMIT = 128 * 1024;
32
34
  const DEFAULT_TIMEOUT_MS = 5_000;
35
+ const DESKTOP_SNAPSHOT_MAX_BYTES = 1024 * 1024;
36
+ const DESKTOP_SNAPSHOT_MAX_ENTRIES = 2000;
33
37
 
34
38
  export type OneTimeConnectCredential =
35
39
  | { kind: "admin"; value: Uint8Array }
@@ -97,6 +101,7 @@ async function fetchBounded(
97
101
  });
98
102
  headerDeadline?.clear();
99
103
  if (response.status >= 300 && response.status < 400 && response.status !== 304) {
104
+ try { await response.body?.cancel(); } catch { /* best effort */ }
100
105
  throw new HubClientError("redirect_refused", "Hub request redirect was refused", response.status);
101
106
  }
102
107
  return response;
@@ -115,6 +120,7 @@ async function boundedText(
115
120
  ): Promise<string> {
116
121
  const declared = Number(response.headers.get("content-length") ?? "0");
117
122
  if (Number.isFinite(declared) && declared > maxBytes) {
123
+ try { await response.body?.cancel(); } catch { /* best effort */ }
118
124
  throw new HubClientError("body_too_large", "Hub response exceeded the allowed size", response.status);
119
125
  }
120
126
  const result = await readBoundedResponseBytes(response, {
@@ -465,6 +471,81 @@ export async function downloadClientCatalog(
465
471
  return { kind: "fresh", body, ...(keyId ? { keyId } : {}) };
466
472
  }
467
473
 
474
+ function desktopSnapshotModels(value: unknown): Desktop3pModelEntry[] {
475
+ const invalid = () => new HubClientError("desktop_snapshot_invalid", "Hub Desktop model snapshot was invalid");
476
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid();
477
+ const raw = value as Record<string, unknown>;
478
+ if (raw.version !== 1) {
479
+ throw new HubClientError("desktop_snapshot_unsupported", "Hub Desktop model snapshot format is unsupported");
480
+ }
481
+ if (!Array.isArray(raw.models) || raw.models.length > DESKTOP_SNAPSHOT_MAX_ENTRIES) throw invalid();
482
+ const models: Desktop3pModelEntry[] = raw.models.map((row: unknown) => {
483
+ if (!row || typeof row !== "object" || Array.isArray(row)) throw invalid();
484
+ const entry = row as Record<string, unknown>;
485
+ const family = entry.anthropicFamilyTier;
486
+ if (typeof entry.name !== "string" || typeof entry.labelOverride !== "string"
487
+ || (family !== "opus" && family !== "fable" && family !== "sonnet" && family !== "haiku")
488
+ || (Object.hasOwn(entry, "isFamilyDefault") && typeof entry.isFamilyDefault !== "boolean")
489
+ || (Object.hasOwn(entry, "supports1m") && entry.supports1m !== true)
490
+ || (Object.hasOwn(entry, "prefer1m") && entry.prefer1m !== true)) throw invalid();
491
+ return {
492
+ name: entry.name,
493
+ labelOverride: entry.labelOverride,
494
+ anthropicFamilyTier: family,
495
+ ...(typeof entry.isFamilyDefault === "boolean" ? { isFamilyDefault: entry.isFamilyDefault } : {}),
496
+ ...(entry.supports1m === true ? { supports1m: true as const } : {}),
497
+ ...(entry.prefer1m === true ? { prefer1m: true as const } : {}),
498
+ };
499
+ });
500
+ try { assertDesktop3pModelsValid(models); } catch { throw invalid(); }
501
+ return models;
502
+ }
503
+
504
+ export async function downloadDesktop3pModels(
505
+ serverUrl: string,
506
+ admissionToken: string,
507
+ options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
508
+ ): Promise<{ version: 1; models: Desktop3pModelEntry[] }> {
509
+ const origin = normalizeHubOrigin(serverUrl);
510
+ if (!isPairingTransportPermitted(origin)) {
511
+ throw new HubClientError("insecure_http_refused", "Desktop model snapshots require HTTPS or loopback HTTP");
512
+ }
513
+ try {
514
+ // Keep fetchBounded's total request deadline active through body consumption;
515
+ // continuous progress must not extend a small Desktop snapshot download indefinitely.
516
+ const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/v1/models?ids=desktop&format=desktop-config`, {
517
+ method: "GET",
518
+ headers: new Headers({
519
+ Accept: "application/json",
520
+ "anthropic-version": "2023-06-01",
521
+ "x-opencodex-api-key": admissionToken,
522
+ }),
523
+ }, options.timeoutMs);
524
+ if (!response.ok || response.status === 304) {
525
+ try { await response.body?.cancel(); } catch { /* best effort */ }
526
+ throw new HubClientError(`desktop_snapshot_http_${response.status}`, "Hub Desktop model snapshot request failed", response.status);
527
+ }
528
+ if (!jsonCompatibleContentType(response)) {
529
+ try { await response.body?.cancel(); } catch { /* best effort */ }
530
+ throw new HubClientError("desktop_snapshot_invalid", "Hub Desktop model snapshot was invalid");
531
+ }
532
+ const body = await boundedText(response, DESKTOP_SNAPSHOT_MAX_BYTES, {
533
+ inactivityTimeoutMs: safeTimeout(options.timeoutMs),
534
+ });
535
+ return { version: 1, models: desktopSnapshotModels(parseJson(body, "desktop_snapshot_invalid")) };
536
+ } catch (error) {
537
+ // Existing low-level errors can carry a cause containing remote JSON or fetch details.
538
+ // Expose only the fixed category/message, never that cause or a remote field value.
539
+ if (error instanceof HubClientError) {
540
+ const message = error.code === "desktop_snapshot_invalid" ? "Hub Desktop model snapshot was invalid"
541
+ : error.code === "desktop_snapshot_unsupported" ? "Hub Desktop model snapshot format is unsupported"
542
+ : "Hub Desktop model snapshot request failed";
543
+ throw new HubClientError(error.code, message, error.status);
544
+ }
545
+ throw new HubClientError("unreachable", "Hub Desktop model snapshot request did not complete");
546
+ }
547
+ }
548
+
468
549
  export async function probeClientKeyId(
469
550
  serverUrl: string,
470
551
  admissionToken: string,
@@ -164,6 +164,7 @@ function boundedRelayResponseStream(
164
164
  body: ReadableStream<Uint8Array>,
165
165
  limit: number,
166
166
  signal: AbortSignal,
167
+ cleanup: () => void,
167
168
  ): ReadableStream<Uint8Array> {
168
169
  const reader = body.getReader();
169
170
  let bytes = 0;
@@ -172,6 +173,7 @@ function boundedRelayResponseStream(
172
173
  if (finished) return;
173
174
  finished = true;
174
175
  signal.removeEventListener("abort", onAbort);
176
+ cleanup();
175
177
  try { reader.releaseLock(); } catch { /* a pending read may still own it */ }
176
178
  };
177
179
  const onAbort = () => {
@@ -245,9 +247,19 @@ export async function relayHubManagementRequest(
245
247
  ? Math.min(Math.floor(deps.timeoutMs), 120_000)
246
248
  : HUB_RELAY_DEFAULT_TIMEOUT_MS;
247
249
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
248
- const signal = req.signal
249
- ? AbortSignal.any([req.signal, timeoutSignal])
250
- : timeoutSignal;
250
+ const relayAbort = new AbortController();
251
+ const signal = relayAbort.signal;
252
+ const stopDeadline = () => timeoutSignal.removeEventListener("abort", onTimeout);
253
+ const cleanup = () => {
254
+ stopDeadline();
255
+ req.signal.removeEventListener("abort", onClientAbort);
256
+ };
257
+ const onTimeout = () => { relayAbort.abort(timeoutSignal.reason); cleanup(); };
258
+ const onClientAbort = () => { relayAbort.abort(req.signal.reason); cleanup(); };
259
+ timeoutSignal.addEventListener("abort", onTimeout, { once: true });
260
+ req.signal.addEventListener("abort", onClientAbort, { once: true });
261
+ if (req.signal.aborted) onClientAbort();
262
+ else if (timeoutSignal.aborted) onTimeout();
251
263
  let upstream: Response;
252
264
  try {
253
265
  upstream = await (deps.fetchImpl ?? fetch)(destination, {
@@ -258,9 +270,16 @@ export async function relayHubManagementRequest(
258
270
  signal,
259
271
  });
260
272
  } catch {
273
+ cleanup();
274
+ return relayError(502, "hub relay unavailable");
275
+ }
276
+ if (signal.aborted) {
277
+ cleanup();
278
+ try { await upstream.body?.cancel(); } catch { /* best effort */ }
261
279
  return relayError(502, "hub relay unavailable");
262
280
  }
263
281
  if (upstream.status >= 300 && upstream.status < 400) {
282
+ cleanup();
264
283
  try { await upstream.body?.cancel(); } catch { /* best effort */ }
265
284
  return relayError(502, "hub relay redirect refused");
266
285
  }
@@ -268,18 +287,31 @@ export async function relayHubManagementRequest(
268
287
  const responseConnectionNamed = new Set((upstream.headers.get("connection") ?? "").split(",").map(value => value.trim().toLowerCase()).filter(Boolean));
269
288
  const responseHeaders = filteredHeaders(upstream.headers, RESPONSE_HEADERS, responseConnectionNamed);
270
289
  if (!headersWithinLimit(responseHeaders)) {
290
+ cleanup();
271
291
  try { await upstream.body?.cancel(); } catch { /* best effort */ }
272
292
  return relayError(502, "hub relay response headers too large");
273
293
  }
274
294
  const declaredResponseLength = upstream.headers.get("content-length");
275
295
  if (declaredResponseLength !== null && (!/^\d+$/.test(declaredResponseLength)
276
296
  || Number(declaredResponseLength) > HUB_RELAY_RESPONSE_BODY_MAX_BYTES)) {
297
+ cleanup();
277
298
  try { await upstream.body?.cancel(); } catch { /* best effort */ }
278
299
  return relayError(502, "hub relay response body too large");
279
300
  }
280
- const responseBody = method === "HEAD" || !upstream.body
281
- ? null
282
- : boundedRelayResponseStream(upstream.body, HUB_RELAY_RESPONSE_BODY_MAX_BYTES, signal);
301
+ // Only this known, successfully established SSE endpoint outlives the handshake.
302
+ // Its body remains byte-bounded and connected to the browser's abort signal.
303
+ if (method === "GET" && destination.pathname === "/api/accounts/events" && !destination.search
304
+ && upstream.status === 200
305
+ && responseHeaders.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === "text/event-stream") {
306
+ stopDeadline();
307
+ }
308
+ let responseBody: ReadableStream<Uint8Array> | null = null;
309
+ if (method === "HEAD" || !upstream.body) {
310
+ cleanup();
311
+ try { await upstream.body?.cancel(); } catch { /* best effort */ }
312
+ } else {
313
+ responseBody = boundedRelayResponseStream(upstream.body, HUB_RELAY_RESPONSE_BODY_MAX_BYTES, signal, cleanup);
314
+ }
283
315
  return new Response(responseBody, {
284
316
  status: upstream.status,
285
317
  statusText: upstream.statusText,
@@ -0,0 +1,129 @@
1
+ /** Client/Desktop lifecycle exclusion. Lock order: N -> L -> C; never acquire N inside L. */
2
+ import { Database } from "bun:sqlite";
3
+ import { chmodSync, closeSync, lstatSync, mkdirSync, openSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { resolveEffectiveUserIdentity, resolveEffectiveUserRuntimeRoot } from "../codex/user-identity";
6
+ import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
7
+
8
+ declare const lifecycleLease: unique symbol;
9
+ export interface ClientLifecycleHeld { readonly [lifecycleLease]: true }
10
+ export interface ClientLifecycleLockDeps { lockPath?: string }
11
+
12
+ const activeLeases = new WeakSet<object>();
13
+
14
+ class ClientLifecycleError extends Error {
15
+ constructor(readonly code: string, options?: ErrorOptions) {
16
+ super(code, options);
17
+ this.name = "ClientLifecycleError";
18
+ }
19
+ }
20
+
21
+ /** A type assertion, copied property or expired callback cannot manufacture exclusion. */
22
+ export function assertClientLifecycleHeld(held: ClientLifecycleHeld): void {
23
+ if (!activeLeases.has(held)) throw new ClientLifecycleError("client_lifecycle_lease_invalid");
24
+ }
25
+
26
+ function errorCode(error: unknown): unknown {
27
+ return error !== null && typeof error === "object" && "code" in error ? error.code : undefined;
28
+ }
29
+
30
+ function assertPath(path: string, directory: boolean): void {
31
+ const stat = lstatSync(path);
32
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1)) {
33
+ throw new ClientLifecycleError("client_lifecycle_path_unsafe");
34
+ }
35
+ if (process.platform !== "win32" && stat.uid !== process.getuid!()) {
36
+ throw new ClientLifecycleError("client_lifecycle_path_unsafe");
37
+ }
38
+ }
39
+
40
+ function preparePath(lockPath: string): void {
41
+ const directory = dirname(lockPath);
42
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
43
+ assertPath(directory, true);
44
+ if (process.platform !== "win32") chmodSync(directory, 0o700);
45
+ hardenSecretDir(directory, { required: true });
46
+ // Create privately before SQLite opens it; refuse an existing symlink or hardlink
47
+ // before chmod/ACL operations or the database constructor can follow it.
48
+ try { closeSync(openSync(lockPath, "wx", 0o600)); }
49
+ catch (error) { if (errorCode(error) !== "EEXIST") throw error; }
50
+ assertPath(lockPath, false);
51
+ if (process.platform !== "win32") chmodSync(lockPath, 0o600);
52
+ hardenSecretPath(lockPath, { required: true });
53
+ assertPath(directory, true);
54
+ assertPath(lockPath, false);
55
+ }
56
+
57
+ function acquire(deps: ClientLifecycleLockDeps): Database {
58
+ let database: Database | undefined;
59
+ try {
60
+ // The production namespace belongs to the effective OS user, never HOME,
61
+ // OPENCODEX_HOME, Desktop library overrides or an environment test switch.
62
+ const path = deps.lockPath === undefined
63
+ ? join(resolveEffectiveUserRuntimeRoot(resolveEffectiveUserIdentity()), "client-desktop-lifecycle.sqlite")
64
+ : resolve(deps.lockPath);
65
+ preparePath(path);
66
+ database = new Database(path, { create: true });
67
+ database.exec("PRAGMA locking_mode = NORMAL; PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
68
+ return database;
69
+ } catch (error) {
70
+ try { database?.close(); } catch { /* preserve acquisition failure */ }
71
+ const code = errorCode(error);
72
+ if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED"
73
+ || (error instanceof Error && /database (?:is|table is) locked/i.test(error.message))) {
74
+ throw new ClientLifecycleError("client_lifecycle_busy");
75
+ }
76
+ throw new ClientLifecycleError("client_lifecycle_lock_failed", { cause: error });
77
+ }
78
+ }
79
+
80
+ type Outcome<T> = { ok: true; value: T } | { ok: false; error: unknown };
81
+
82
+ function finish<T>(database: Database, outcome: Outcome<T>): T {
83
+ let release: Outcome<void> = { ok: true, value: undefined };
84
+ try { database.exec("ROLLBACK"); }
85
+ catch (error) { release = { ok: false, error }; }
86
+ // Always close, including when rollback throws. SQLite/OS owns crash release;
87
+ // never unlink a lock database or infer lock ownership from a stale PID.
88
+ try { database.close(); }
89
+ catch (error) { if (release.ok) release = { ok: false, error }; }
90
+ if (!outcome.ok) throw outcome.error; // Includes a literal `throw undefined`.
91
+ if (!release.ok) throw new ClientLifecycleError("client_lifecycle_lock_failed", { cause: release.error });
92
+ return outcome.value;
93
+ }
94
+
95
+ export async function withClientLifecycle<T>(
96
+ work: (held: ClientLifecycleHeld) => Promise<T>,
97
+ deps: ClientLifecycleLockDeps = {},
98
+ ): Promise<T> {
99
+ const database = acquire(deps);
100
+ const held = Object.freeze({}) as ClientLifecycleHeld;
101
+ activeLeases.add(held);
102
+ let outcome: Outcome<T>;
103
+ try { outcome = { ok: true, value: await work(held) }; }
104
+ catch (error) { outcome = { ok: false, error }; }
105
+ finally { activeLeases.delete(held); }
106
+ return finish(database, outcome);
107
+ }
108
+
109
+ export function withClientLifecycleSync<T>(
110
+ work: (held: ClientLifecycleHeld) => T,
111
+ deps: ClientLifecycleLockDeps = {},
112
+ ): T {
113
+ const database = acquire(deps);
114
+ const held = Object.freeze({}) as ClientLifecycleHeld;
115
+ activeLeases.add(held);
116
+ let outcome: Outcome<T>;
117
+ try {
118
+ const value = work(held);
119
+ if (value !== null && (typeof value === "object" || typeof value === "function")
120
+ && typeof (value as { then?: unknown }).then === "function") {
121
+ // Observe native rejected promises without invoking arbitrary thenables.
122
+ if (value instanceof Promise) void Promise.prototype.then.call(value, undefined, () => undefined);
123
+ throw new ClientLifecycleError("client_lifecycle_async_callback");
124
+ }
125
+ outcome = { ok: true, value };
126
+ } catch (error) { outcome = { ok: false, error }; }
127
+ finally { activeLeases.delete(held); }
128
+ return finish(database, outcome);
129
+ }
@@ -6,8 +6,11 @@ import {
6
6
  mutatePersistedConfig,
7
7
  readConfigDiagnostics,
8
8
  saveConfig,
9
+ withConfigMutationLockSync,
9
10
  } from "../config";
10
11
  import type { OcxClientConnectionConfig } from "../types";
12
+ import { inspectRemoteDesktopStore, readDesktopDisconnectReceipt } from "../claude/desktop-remote-store";
13
+ import { withClientLifecycleSync, type ClientLifecycleLockDeps } from "./lifecycle-lock";
11
14
  import {
12
15
  readServiceApiTokenState,
13
16
  readTokenBackupState,
@@ -71,62 +74,88 @@ export function readClientConnectionState(): ClientConnectionState {
71
74
  return { kind: "connected", value: client };
72
75
  }
73
76
 
74
- /**
75
- * Does the persisted config record a rotation that has not finished?
76
- *
77
- * Read fresh rather than taken from a caller-supplied snapshot: the whole point is to see a
78
- * `pendingOperation` that landed after that snapshot was taken.
79
- */
80
- function rotationInFlight(): boolean {
77
+ export function sameClientConnectionOwner(
78
+ left: Pick<OcxClientConnectionConfig, "serverUrl" | "apiKeyId" | "connectedAt">,
79
+ right: Pick<OcxClientConnectionConfig, "serverUrl" | "apiKeyId" | "connectedAt">,
80
+ ): boolean {
81
+ return left.serverUrl === right.serverUrl && left.apiKeyId === right.apiKeyId && left.connectedAt === right.connectedAt;
82
+ }
83
+
84
+ /** Read-only: safe at a Codex N/C commit boundary; never acquires L or removes recovery state. */
85
+ export function assertNoClientDisconnectPending(): void {
86
+ const receipt = readDesktopDisconnectReceipt();
87
+ if (receipt.kind === "unsafe") throw new Error("client_disconnect_receipt_unsafe");
88
+ if (receipt.kind === "valid" && receipt.value.phase !== "complete") {
89
+ throw new Error("client_disconnect_pending");
90
+ }
91
+ }
92
+
93
+ /** Full snapshot CAS for work returning from an await, including selection and rotation state. */
94
+ export function assertClientConnectionUnchanged(expected: OcxClientConnectionConfig): void {
95
+ assertNoClientDisconnectPending();
81
96
  const current = readClientConnectionState();
82
- return current.kind === "connected" && current.value.pendingOperation !== undefined;
97
+ if (current.kind !== "connected" || JSON.stringify(current.value) !== JSON.stringify(expected)) {
98
+ throw new Error("client_connection_changed");
99
+ }
83
100
  }
84
101
 
85
- export function inspectClientRotationRecoveryGate(
86
- state: ClientConnectionState = readClientConnectionState(),
87
- ): ClientRotationRecoveryGate {
102
+ /** Undefined means only "possible orphan": the caller must repeat this read under L/C. */
103
+ function observeClientRotationRecovery(): ClientRotationRecoveryGate | undefined {
104
+ const state = readClientConnectionState();
88
105
  const current = readServiceApiTokenState();
89
106
  const backup = readTokenBackupState();
107
+ const receipt = readDesktopDisconnectReceipt();
108
+ if (receipt.kind === "unsafe") return { kind: "unsafe", reason: "client_disconnect_receipt_unsafe" };
109
+ if (receipt.kind === "valid" && receipt.value.phase !== "complete") {
110
+ return { kind: "recovery-required", reason: "client_disconnect_pending" };
111
+ }
90
112
  if (state.kind === "connected" && state.value.pendingOperation) {
91
113
  if (current.kind !== "present" || backup.kind !== "present") {
92
- return {
93
- kind: "unsafe",
94
- reason: "pending key rotation requires owner-only service-api-token and service-api-token.prev files",
95
- };
114
+ return { kind: "unsafe", reason: "pending rotation requires current and backup token files" };
96
115
  }
97
- return {
98
- kind: "recovery-required",
99
- reason: "rerun ocx connect rotate with --pairing-code-stdin or --admin-token-stdin",
100
- };
116
+ return { kind: "recovery-required", reason: "rerun ocx connect rotate with transient authority" };
101
117
  }
102
- if (backup.kind === "unsafe") return { kind: "unsafe", reason: backup.reason };
118
+ if (backup.kind === "unsafe") return { kind: "unsafe", reason: "service token backup is unsafe" };
103
119
  if (backup.kind === "present" && current.kind === "present") {
104
- // Only an ORPHAN backup is cleanable, and this branch cannot always tell an orphan from
105
- // a backup belonging to a rotation that is mid-flight.
106
- //
107
- // `rotateConnectedClientKey` writes the .prev backup BEFORE it persists
108
- // `pendingOperation`. A concurrent `ocx connect status` landing in that window sees
109
- // "backup present, token present, no pending marker" — indistinguishable from a stale
110
- // leftover — and deleted the live rollback target. If the rotation then failed, its
111
- // restore had nothing to restore from.
112
- //
113
- // Re-reading the persisted state closes most of the window: the caller's `state` may
114
- // have been captured before the marker landed, while a fresh read sees it. The
115
- // remaining window is narrow enough that the rotation's own lock is the right owner,
116
- // and deleting nothing is the safe side of it.
117
- if (rotationInFlight()) {
118
- return { kind: "recovery-required", reason: "a key rotation is in flight; leave service-api-token.prev in place" };
120
+ if (state.kind === "invalid" || state.kind === "mismatched"
121
+ || (state.kind === "connected" && current.fingerprint !== state.value.tokenFingerprint)) {
122
+ return { kind: "unsafe", reason: "connected token ownership changed" };
119
123
  }
120
- try {
121
- removeOrphanTokenBackup();
122
- return { kind: "orphan-cleaned" };
123
- } catch (error) {
124
- return { kind: "unsafe", reason: error instanceof Error ? error.message : "token backup cleanup failed" };
124
+ if (state.kind === "connected") {
125
+ const desktop = inspectRemoteDesktopStore({ serverUrl: state.value.serverUrl, apiKeyId: state.value.apiKeyId, connectedAt: state.value.connectedAt });
126
+ if (desktop.kind !== "absent" && desktop.kind !== "restored") {
127
+ // The inspection DTO deliberately exposes no credential generation. Let
128
+ // explicit rotation reconcile an active Desktop copy before discarding .prev.
129
+ return { kind: "recovery-required", reason: "Desktop credential reconciliation requires ocx connect rotate" };
130
+ }
125
131
  }
132
+ return undefined;
126
133
  }
127
134
  return { kind: "clean" };
128
135
  }
129
136
 
137
+ export function inspectClientRotationRecoveryGate(
138
+ _state: ClientConnectionState = readClientConnectionState(),
139
+ lockDeps?: ClientLifecycleLockDeps,
140
+ ): ClientRotationRecoveryGate {
141
+ try {
142
+ // Ordinary status is read-only: even acquiring C creates config-mutation.sqlite.
143
+ // Only actual orphan cleanup needs L/C; this first observation authorizes no write.
144
+ const observed = observeClientRotationRecovery();
145
+ if (observed) return observed;
146
+ return withClientLifecycleSync(() => withConfigMutationLockSync((): ClientRotationRecoveryGate => {
147
+ const fresh = observeClientRotationRecovery();
148
+ if (fresh) return fresh;
149
+ removeOrphanTokenBackup();
150
+ return { kind: "orphan-cleaned" };
151
+ }), lockDeps);
152
+ } catch (error) {
153
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
154
+ if (code === "client_lifecycle_busy") return { kind: "recovery-required", reason: "client_lifecycle_busy" };
155
+ return { kind: "unsafe", reason: "client rotation state could not be inspected safely" };
156
+ }
157
+ }
158
+
130
159
  export function commitClientConnection(
131
160
 
132
161
  state: OcxClientConnectionConfig,
@@ -157,13 +186,13 @@ export function commitClientConnection(
157
186
  }
158
187
 
159
188
  export function clearClientConnection(
160
- expectedApiKeyId: string,
189
+ expected: string | Pick<OcxClientConnectionConfig, "serverUrl" | "apiKeyId" | "connectedAt">,
161
190
  ): "committed" | "absent" | "conflict" {
162
191
  const outcome = mutatePersistedConfig(config => {
163
192
  if (!config.client && config.runtimeRole !== "client") {
164
193
  return { changed: false, value: "absent" as const };
165
194
  }
166
- if (!config.client || config.runtimeRole !== "client" || config.client.apiKeyId !== expectedApiKeyId) {
195
+ if (!config.client || config.runtimeRole !== "client" || (typeof expected === "string" ? config.client.apiKeyId !== expected : !sameClientConnectionOwner(config.client, expected))) {
167
196
  return { changed: false, value: "conflict" as const };
168
197
  }
169
198
  deleteConfigTopLevelKey(config, "client");