@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
@@ -6,7 +6,17 @@ import {
6
6
  unlinkSync,
7
7
  } from "node:fs";
8
8
  import { hostname } from "node:os";
9
- import { atomicWriteFile, loadConfig } from "../config";
9
+ import { atomicWriteFile, loadConfig, withConfigMutationLockSync } from "../config";
10
+ import { claudeDesktopIntegrationEnabledNow } from "../codex/desired-state";
11
+ import {
12
+ inspectRemoteDesktopStore, readDesktopDisconnectReceipt, writeDesktopDisconnectReceipt,
13
+ replaceRemoteDesktopCredential, restoreRemoteDesktopStore, finishRemoteDesktopCleanup,
14
+ type DesktopDisconnectReceipt, type DesktopRemoteOwner, type DesktopStoreResult,
15
+ } from "../claude/desktop-remote-store";
16
+ import {
17
+ withClientLifecycle, withClientLifecycleSync,
18
+ type ClientLifecycleHeld, type ClientLifecycleLockDeps,
19
+ } from "./lifecycle-lock";
10
20
  import { invalidateCodexModelsCache } from "../codex/catalog/sync";
11
21
  import {
12
22
  injectCodexConfig,
@@ -55,6 +65,7 @@ import {
55
65
  clearClientConnection,
56
66
  commitClientConnection,
57
67
  readClientConnectionState,
68
+ assertNoClientDisconnectPending, assertClientConnectionUnchanged, sameClientConnectionOwner,
58
69
  } from "./state";
59
70
 
60
71
  class RotationRecoveryRequiredError extends Error {
@@ -77,6 +88,7 @@ export interface ConnectOptions {
77
88
  export interface ClientConnectDeps {
78
89
  fetchImpl?: typeof fetch;
79
90
  now?: () => Date;
91
+ lifecycleLockDeps?: ClientLifecycleLockDeps;
80
92
  }
81
93
 
82
94
  export interface RotateClientOptions {
@@ -178,168 +190,278 @@ async function rotationAuthority(
178
190
  return { kind: "gui-session", value: session };
179
191
  }
180
192
 
181
- function clearRotationState(
193
+ export type ClientRotationResult = OcxClientConnectionConfig & {
194
+ rotationOutcome: "committed" | "rolled_back";
195
+ };
196
+
197
+ function desktopOwner(connection: OcxClientConnectionConfig): DesktopRemoteOwner {
198
+ return { serverUrl: connection.serverUrl, apiKeyId: connection.apiKeyId, connectedAt: connection.connectedAt };
199
+ }
200
+
201
+ function requireDesktopResult(result: DesktopStoreResult): Extract<DesktopStoreResult, { ok: true }> {
202
+ if (!result.ok) throw new Error(`desktop_lifecycle_${result.reason}`);
203
+ return result;
204
+ }
205
+
206
+ function assertRotationCandidates(
182
207
  connection: OcxClientConnectionConfig,
183
- tokenFingerprint: string,
184
- ): OcxClientConnectionConfig {
185
- const next = { ...connection, tokenFingerprint };
186
- delete next.pendingOperation;
187
- commitClientConnection(next);
188
- return next;
208
+ currentFingerprint: string,
209
+ backupFingerprint: string,
210
+ ): void {
211
+ assertClientConnectionUnchanged(connection);
212
+ const current = readServiceApiTokenState();
213
+ const backup = readTokenBackupState();
214
+ if (current.kind !== "present" || backup.kind !== "present"
215
+ || current.fingerprint !== currentFingerprint || backup.fingerprint !== backupFingerprint) {
216
+ throw new RotationRecoveryRequiredError("rotation token generations changed; preserve recovery files");
217
+ }
218
+ }
219
+
220
+ function alignDesktopCredential(
221
+ held: ClientLifecycleHeld,
222
+ connection: OcxClientConnectionConfig,
223
+ previousFingerprint: string,
224
+ token: { token: string; fingerprint: string },
225
+ ): void {
226
+ withConfigMutationLockSync(() => {
227
+ assertClientConnectionUnchanged(connection);
228
+ const current = readServiceApiTokenState();
229
+ if (current.kind !== "present" || current.fingerprint !== token.fingerprint) {
230
+ throw new Error("client_token_changed");
231
+ }
232
+ if (inspectRemoteDesktopStore(desktopOwner(connection)).kind === "absent") return;
233
+ let result: DesktopStoreResult;
234
+ if (!claudeDesktopIntegrationEnabledNow()) {
235
+ result = restoreRemoteDesktopStore(held, {
236
+ owner: desktopOwner(connection), knownTokenFingerprints: [connection.tokenFingerprint, current.fingerprint],
237
+ });
238
+ } else {
239
+ result = replaceRemoteDesktopCredential(held, {
240
+ owner: desktopOwner(connection), expectedTokenFingerprint: previousFingerprint, replacementKey: current.token,
241
+ });
242
+ if (!result.ok && !result.changed && result.reason === "conflict" && previousFingerprint !== current.fingerprint) {
243
+ // Recovery may find Desktop already on the chosen generation while only
244
+ // service-api-token needed rollback. Retry that exact, freshly proven
245
+ // current generation; never retry a partial write or an unsafe artifact.
246
+ result = replaceRemoteDesktopCredential(held, {
247
+ owner: desktopOwner(connection), expectedTokenFingerprint: current.fingerprint, replacementKey: current.token,
248
+ });
249
+ }
250
+ }
251
+ requireDesktopResult(result);
252
+ });
253
+ }
254
+
255
+ function finalizeRotation(
256
+ held: ClientLifecycleHeld,
257
+ connection: OcxClientConnectionConfig,
258
+ previousFingerprint: string,
259
+ token: { token: string; fingerprint: string },
260
+ rotationOutcome: ClientRotationResult["rotationOutcome"],
261
+ ): ClientRotationResult {
262
+ try {
263
+ alignDesktopCredential(held, connection, previousFingerprint, token);
264
+ return withConfigMutationLockSync(() => {
265
+ assertClientConnectionUnchanged(connection);
266
+ const current = readServiceApiTokenState();
267
+ if (current.kind !== "present" || current.fingerprint !== token.fingerprint) throw new Error("client_token_changed");
268
+ const next = { ...connection, tokenFingerprint: token.fingerprint };
269
+ delete next.pendingOperation;
270
+ commitClientConnection(next);
271
+ removeOrphanTokenBackup();
272
+ // Outcome is an API result only, never a persisted client configuration field.
273
+ return { ...next, rotationOutcome };
274
+ });
275
+ } catch {
276
+ throw new RotationRecoveryRequiredError("rotation local finalization is incomplete; preserve recovery files");
277
+ }
189
278
  }
190
279
 
191
280
  async function recoverRotationWithAuthority(
281
+ held: ClientLifecycleHeld,
192
282
  connection: OcxClientConnectionConfig,
193
283
  authority: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession },
194
284
  deps: ClientConnectDeps,
195
- ): Promise<OcxClientConnectionConfig> {
196
- const pending = connection.pendingOperation;
197
- if (!pending || pending.oldKeyBackupPath !== serviceApiTokenBackupPath()) {
198
- throw new RotationRecoveryRequiredError("rotation recovery state is missing or invalid");
199
- }
200
- const current = readServiceApiTokenState();
201
- const backup = readTokenBackupState();
202
- if (current.kind !== "present" || backup.kind !== "present") {
203
- throw new RotationRecoveryRequiredError(
204
- "rotation recovery requires owner-only current and .prev token files; preserve both and rerun ocx connect rotate with transient authority",
205
- );
206
- }
207
- let currentAccepted: boolean;
208
- let backupAccepted: boolean;
285
+ ): Promise<ClientRotationResult> {
209
286
  try {
210
- [currentAccepted, backupAccepted] = await Promise.all([
287
+ const pending = connection.pendingOperation;
288
+ if (!pending || pending.oldKeyBackupPath !== serviceApiTokenBackupPath()) throw new Error("invalid rotation marker");
289
+ const current = readServiceApiTokenState();
290
+ const backup = readTokenBackupState();
291
+ if (current.kind !== "present" || backup.kind !== "present" || backup.fingerprint !== connection.tokenFingerprint) {
292
+ throw new Error("rotation recovery generations are unavailable");
293
+ }
294
+ const assertFresh = () => withConfigMutationLockSync(() => assertRotationCandidates(connection, current.fingerprint, backup.fingerprint));
295
+ assertFresh();
296
+ if (current.fingerprint === backup.fingerprint) {
297
+ // A crash after the marker but before replacement leaves two copies of OLD.
298
+ // Their equal successful probes must never commit an uninstalled new hub key.
299
+ if (!await probeClientKeyId(connection.serverUrl, backup.token, connection.apiKeyId, { fetchImpl: deps.fetchImpl })) {
300
+ throw new Error("old generation not admitted");
301
+ }
302
+ assertFresh();
303
+ await abortClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, pending.rotationId, { fetchImpl: deps.fetchImpl });
304
+ if (!await probeClientKeyId(connection.serverUrl, backup.token, connection.apiKeyId, { fetchImpl: deps.fetchImpl })) {
305
+ throw new Error("old generation not admitted after abort");
306
+ }
307
+ assertFresh();
308
+ return finalizeRotation(held, connection, backup.fingerprint, backup, "rolled_back");
309
+ }
310
+ const [currentAccepted, backupAccepted] = await Promise.all([
211
311
  probeClientKeyId(connection.serverUrl, current.token, connection.apiKeyId, { fetchImpl: deps.fetchImpl }),
212
312
  probeClientKeyId(connection.serverUrl, backup.token, connection.apiKeyId, { fetchImpl: deps.fetchImpl }),
213
313
  ]);
314
+ assertFresh();
315
+ if (currentAccepted && backupAccepted) {
316
+ alignDesktopCredential(held, connection, backup.fingerprint, current);
317
+ await commitClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, pending.rotationId, { fetchImpl: deps.fetchImpl });
318
+ assertFresh();
319
+ return finalizeRotation(held, connection, backup.fingerprint, current, "committed");
320
+ }
321
+ if (currentAccepted && !backupAccepted) {
322
+ return finalizeRotation(held, connection, backup.fingerprint, current, "committed");
323
+ }
324
+ if (!currentAccepted && backupAccepted) {
325
+ // Remote abort is confirmed before either local credential is rolled back.
326
+ await abortClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, pending.rotationId, { fetchImpl: deps.fetchImpl });
327
+ assertFresh();
328
+ const restored = withConfigMutationLockSync(() => restoreTokenBackup(pending.oldKeyBackupPath));
329
+ return finalizeRotation(held, connection, current.fingerprint, { token: backup.token, fingerprint: restored.fingerprint }, "rolled_back");
330
+ }
331
+ throw new Error("both generations rejected");
214
332
  } catch (error) {
215
- throw new RotationRecoveryRequiredError(
216
- "rotation recovery could not establish both key admissions; preserve service-api-token and .prev",
217
- { cause: error },
218
- );
219
- }
220
- if (currentAccepted && backupAccepted) {
221
- await commitClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, pending.rotationId, { fetchImpl: deps.fetchImpl });
222
- const next = clearRotationState(connection, current.fingerprint);
223
- removeOrphanTokenBackup();
224
- return next;
225
- }
226
- if (currentAccepted && !backupAccepted) {
227
- const next = clearRotationState(connection, current.fingerprint);
228
- removeOrphanTokenBackup();
229
- return next;
230
- }
231
- if (!currentAccepted && backupAccepted) {
232
- const restored = restoreTokenBackup(pending.oldKeyBackupPath);
233
- await abortClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, pending.rotationId, { fetchImpl: deps.fetchImpl });
234
- const next = clearRotationState(connection, restored.fingerprint);
235
- removeOrphanTokenBackup();
236
- return next;
333
+ if (error instanceof RotationRecoveryRequiredError) throw error;
334
+ throw new RotationRecoveryRequiredError("rotation recovery could not settle admission and Desktop state; preserve current and backup tokens");
237
335
  }
238
- throw new RotationRecoveryRequiredError(
239
- "both rotation candidates were rejected; preserve service-api-token and .prev and repair admission from the hub",
240
- );
241
336
  }
242
337
 
243
338
  export async function recoverPendingClientRotation(
244
339
  options: RotateClientOptions,
245
340
  deps: ClientConnectDeps = {},
246
- ): Promise<OcxClientConnectionConfig> {
341
+ ): Promise<ClientRotationResult> {
247
342
  try {
248
- const state = readClientConnectionState();
249
- if (state.kind !== "connected" || !state.value.pendingOperation) {
250
- throw new Error("no pending client key rotation to recover");
251
- }
252
- const authority = await rotationAuthority(state.value, options, deps);
253
- return await recoverRotationWithAuthority(state.value, authority, deps);
254
- } finally {
255
- releaseCredential(options.credential);
256
- }
343
+ return await withClientLifecycle(async held => {
344
+ assertNoClientDisconnectPending();
345
+ const state = readClientConnectionState();
346
+ if (state.kind !== "connected" || !state.value.pendingOperation) throw new Error("no pending client key rotation to recover");
347
+ const authority = await rotationAuthority(state.value, options, deps);
348
+ assertClientConnectionUnchanged(state.value);
349
+ return recoverRotationWithAuthority(held, state.value, authority, deps);
350
+ }, deps.lifecycleLockDeps);
351
+ } finally { releaseCredential(options.credential); }
257
352
  }
258
353
 
259
354
  export async function rotateConnectedClientKey(
260
355
  options: RotateClientOptions,
261
356
  deps: ClientConnectDeps = {},
262
- ): Promise<OcxClientConnectionConfig> {
357
+ ): Promise<ClientRotationResult> {
358
+ try {
359
+ return await withClientLifecycle(held => rotateConnectedClientKeyHeld(held, options, deps), deps.lifecycleLockDeps);
360
+ } finally { releaseCredential(options.credential); }
361
+ }
362
+
363
+ async function rotateConnectedClientKeyHeld(
364
+ held: ClientLifecycleHeld,
365
+ options: RotateClientOptions,
366
+ deps: ClientConnectDeps,
367
+ ): Promise<ClientRotationResult> {
263
368
  let connection: OcxClientConnectionConfig | null = null;
264
369
  let authority: { kind: "admin"; value: Uint8Array } | { kind: "gui-session"; value: ConnectGuiSession } | null = null;
265
370
  let started: { rotationId: string; key: string; createdAt: string } | null = null;
266
371
  let markerPersisted = false;
372
+ let backupCreated = false;
373
+ let hubCommitted = false;
267
374
  try {
375
+ assertNoClientDisconnectPending();
268
376
  const state = readClientConnectionState();
269
- if (state.kind !== "connected") throw new Error(`connect rotate is available only while connected (${state.kind})`);
377
+ if (state.kind !== "connected") throw new Error("connect rotate is available only while connected");
270
378
  connection = state.value;
271
- authority = await rotationAuthority(connection, options, deps);
272
- if (connection.pendingOperation) return await recoverRotationWithAuthority(connection, authority, deps);
273
379
  const current = readServiceApiTokenState();
274
- if (current.kind !== "present" || current.fingerprint !== connection.tokenFingerprint) {
275
- throw new Error(current.kind === "unsafe" ? current.reason : "connected service token ownership changed");
380
+ if (current.kind !== "present") throw new Error("connected service token unavailable");
381
+ if (!connection.pendingOperation) {
382
+ if (current.fingerprint !== connection.tokenFingerprint) throw new Error("connected service token ownership changed");
383
+ const desktop = inspectRemoteDesktopStore(desktopOwner(connection));
384
+ if (["conflict", "unsafe", "pending"].includes(desktop.kind)) throw new Error("desktop_lifecycle_recovery_required");
385
+ // Establish legacy fallback ownership and reject edited projections BEFORE hub issuance.
386
+ alignDesktopCredential(held, connection, current.fingerprint, current);
387
+ withConfigMutationLockSync(() => {
388
+ assertClientConnectionUnchanged(connection!);
389
+ const orphan = readTokenBackupState();
390
+ if (orphan.kind === "unsafe") throw new Error("service token backup is unsafe");
391
+ if (orphan.kind === "present") removeOrphanTokenBackup();
392
+ });
276
393
  }
277
- writeTokenBackup(current.fingerprint);
394
+ authority = await rotationAuthority(connection, options, deps);
395
+ assertClientConnectionUnchanged(connection);
396
+ if (connection.pendingOperation) return recoverRotationWithAuthority(held, connection, authority, deps);
397
+ withConfigMutationLockSync(() => {
398
+ assertClientConnectionUnchanged(connection!);
399
+ writeTokenBackup(current.fingerprint);
400
+ });
401
+ backupCreated = true;
278
402
  const rotation = await startClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, { fetchImpl: deps.fetchImpl });
279
403
  started = { rotationId: rotation.rotationId, key: rotation.key, createdAt: rotation.createdAt };
280
404
  const marked: OcxClientConnectionConfig = {
281
405
  ...connection,
282
- pendingOperation: {
283
- kind: "rotate",
284
- rotationId: rotation.rotationId,
285
- newKeyIssuedAt: rotation.createdAt,
286
- oldKeyBackupPath: serviceApiTokenBackupPath(),
287
- },
406
+ pendingOperation: { kind: "rotate", rotationId: rotation.rotationId, newKeyIssuedAt: rotation.createdAt, oldKeyBackupPath: serviceApiTokenBackupPath() },
288
407
  };
289
- commitClientConnection(marked);
408
+ withConfigMutationLockSync(() => {
409
+ assertClientConnectionUnchanged(connection!);
410
+ commitClientConnection(marked);
411
+ });
290
412
  connection = marked;
291
413
  markerPersisted = true;
292
- const replacement = replaceServiceApiTokenFile(rotation.key);
414
+ const replacement = withConfigMutationLockSync(() => {
415
+ assertRotationCandidates(connection!, current.fingerprint, current.fingerprint);
416
+ return replaceServiceApiTokenFile(rotation.key);
417
+ });
418
+ alignDesktopCredential(held, connection, current.fingerprint, { token: rotation.key, fingerprint: replacement.fingerprint });
293
419
  if (!await probeClientKeyId(connection.serverUrl, rotation.key, connection.apiKeyId, { fetchImpl: deps.fetchImpl })) {
294
420
  throw new Error("new client key admission probe was refused");
295
421
  }
422
+ withConfigMutationLockSync(() => assertRotationCandidates(connection!, replacement.fingerprint, current.fingerprint));
296
423
  try {
297
424
  await commitClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, rotation.rotationId, { fetchImpl: deps.fetchImpl });
298
425
  } catch {
299
- return await recoverRotationWithAuthority(connection, authority, deps);
426
+ return await recoverRotationWithAuthority(held, connection, authority, deps);
300
427
  }
301
- const next = clearRotationState(connection, replacement.fingerprint);
302
- removeOrphanTokenBackup();
303
- return next;
428
+ hubCommitted = true;
429
+ return finalizeRotation(held, connection, current.fingerprint, { token: rotation.key, fingerprint: replacement.fingerprint }, "committed");
304
430
  } catch (error) {
305
- if (error instanceof RotationRecoveryRequiredError) throw error;
431
+ if (error instanceof RotationRecoveryRequiredError || hubCommitted) {
432
+ throw error instanceof RotationRecoveryRequiredError ? error : new RotationRecoveryRequiredError("committed rotation requires local recovery");
433
+ }
306
434
  if (connection && authority && started) {
307
- if (markerPersisted && connection.pendingOperation) {
308
- try {
309
- // Abort FIRST, restore second.
310
- //
311
- // The old order restored the local token and then asked the hub to abort. If that
312
- // abort failed transiently the process was left holding the old key locally while
313
- // the hub still had a pending rotation for the new one — two sides disagreeing
314
- // about which generation is current, with the failure surfaced only as "rollback
315
- // was incomplete". Confirming the hub's state first means the local file is only
316
- // rewound once the authority that decides it has agreed.
317
- await abortClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, started.rotationId, { fetchImpl: deps.fetchImpl });
318
- const restored = restoreTokenBackup(connection.pendingOperation.oldKeyBackupPath);
319
- clearRotationState(connection, restored.fingerprint);
320
- removeOrphanTokenBackup();
321
- } catch (recoveryError) {
322
- // Both candidates and the pending marker stay on disk. Recovery cannot tell which
323
- // generation is authoritative without the hub, so it preserves the evidence and
324
- // names the command that carries the authority to ask.
325
- throw new RotationRecoveryRequiredError(
326
- "rotation rollback was incomplete; preserve service-api-token and .prev and rerun ocx connect rotate with transient authority",
327
- { cause: recoveryError },
328
- );
435
+ try {
436
+ await abortClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, started.rotationId, { fetchImpl: deps.fetchImpl });
437
+ if (markerPersisted && connection.pendingOperation) {
438
+ const beforeRestore = readServiceApiTokenState();
439
+ const backup = readTokenBackupState();
440
+ if (beforeRestore.kind !== "present" || backup.kind !== "present") throw new Error("rotation rollback token unavailable");
441
+ withConfigMutationLockSync(() => {
442
+ assertRotationCandidates(connection!, beforeRestore.fingerprint, backup.fingerprint);
443
+ restoreTokenBackup(connection!.pendingOperation!.oldKeyBackupPath);
444
+ });
445
+ finalizeRotation(held, connection, beforeRestore.fingerprint, backup, "rolled_back");
446
+ } else {
447
+ withConfigMutationLockSync(() => {
448
+ assertClientConnectionUnchanged(connection!);
449
+ if (backupCreated) removeOrphanTokenBackup();
450
+ });
329
451
  }
330
- } else {
331
- try { await abortClientKeyRotation(connection.managementUrl, authority, connection.apiKeyId, started.rotationId, { fetchImpl: deps.fetchImpl }); }
332
- finally { removeOrphanTokenBackup(); }
452
+ } catch {
453
+ throw new RotationRecoveryRequiredError("rotation rollback was incomplete; preserve current and backup tokens");
333
454
  }
334
- } else {
335
- const backup = readTokenBackupState();
336
- if (backup.kind === "present") removeOrphanTokenBackup();
455
+ } else if (backupCreated && connection) {
456
+ withConfigMutationLockSync(() => {
457
+ assertClientConnectionUnchanged(connection!);
458
+ removeOrphanTokenBackup();
459
+ });
337
460
  }
338
461
  throw error;
339
462
  } finally {
340
463
  if (started) started.key = "";
341
464
  authority = null;
342
- releaseCredential(options.credential);
343
465
  }
344
466
  }
345
467
 
@@ -357,6 +479,16 @@ async function cleanupIssuedKey(
357
479
  }
358
480
  }
359
481
 
482
+ function assertConnectingState(expectedTokenFingerprint?: string): void {
483
+ assertNoClientDisconnectPending();
484
+ if (readClientConnectionState().kind !== "disconnected") throw new Error("client_connection_changed");
485
+ const token = readServiceApiTokenState();
486
+ if (expectedTokenFingerprint === undefined ? token.kind !== "absent"
487
+ : token.kind !== "present" || token.fingerprint !== expectedTokenFingerprint) {
488
+ throw new Error("client_token_changed");
489
+ }
490
+ }
491
+
360
492
  export async function connectClient(
361
493
  options: ConnectOptions,
362
494
  deps: ClientConnectDeps = {},
@@ -376,17 +508,11 @@ export async function connectClient(
376
508
  if (options.selectedClients.length < 1 || new Set(options.selectedClients).size !== options.selectedClients.length) {
377
509
  throw new Error("at least one unique connected client is required");
378
510
  }
379
- const state = readClientConnectionState();
380
- if (state.kind !== "disconnected") {
381
- const detail = state.kind === "connected" ? "already connected" : state.reason;
382
- throw new Error(`connect refused: client state is ${state.kind} (${detail})`);
383
- }
384
- const externalProvider = currentExternalCodexModelProvider();
385
- if (externalProvider) throw new Error(`connect refused: external Codex provider ${externalProvider} owns config.toml`);
386
- const tokenState = readServiceApiTokenState();
387
- if (tokenState.kind !== "absent") {
388
- throw new Error(tokenState.kind === "unsafe" ? tokenState.reason : "connect refused: service token file already exists");
389
- }
511
+ withClientLifecycleSync(() => withConfigMutationLockSync(() => {
512
+ assertConnectingState();
513
+ const externalProvider = currentExternalCodexModelProvider();
514
+ if (externalProvider) throw new Error("connect refused: an external Codex provider owns config.toml");
515
+ }), deps.lifecycleLockDeps);
390
516
 
391
517
  const ready = await fetchHubReady(serverUrl, { fetchImpl: deps.fetchImpl });
392
518
  if (ready.status !== "ready") throw new Error(`hub is not ready (${ready.status})`);
@@ -405,16 +531,23 @@ export async function connectClient(
405
531
  }
406
532
  issued = await issueClientKey(managementUrl, cleanupCredential, clientKeyName(), { fetchImpl: deps.fetchImpl });
407
533
 
408
- priorCatalog = catalogSnapshot();
409
- const persisted = writeServiceApiTokenFile(issued.key);
534
+ const initialFiles = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
535
+ assertConnectingState();
536
+ return { prior: catalogSnapshot(), persisted: writeServiceApiTokenFile(issued!.key) };
537
+ }), deps.lifecycleLockDeps);
538
+ priorCatalog = initialFiles.prior;
539
+ const persisted = initialFiles.persisted;
410
540
  tokenFingerprint = persisted.fingerprint;
411
541
 
412
542
  const catalog = await downloadClientCatalog(serverUrl, issued.key, {
413
543
  fetchImpl: deps.fetchImpl,
414
544
  timeoutMs: options.catalogTimeoutMs,
415
545
  });
416
- atomicWriteFile(DEFAULT_CATALOG_PATH, catalog.body);
417
- writtenCatalogFingerprint = sha256(catalog.body);
546
+ writtenCatalogFingerprint = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
547
+ assertConnectingState(persisted.fingerprint);
548
+ atomicWriteFile(DEFAULT_CATALOG_PATH, catalog.body);
549
+ return sha256(catalog.body);
550
+ }), deps.lifecycleLockDeps);
418
551
 
419
552
  const config = loadConfig();
420
553
  const target = routingTarget(serverUrl);
@@ -424,6 +557,7 @@ export async function connectClient(
424
557
  routingTarget: target,
425
558
  catalogPath: DEFAULT_CATALOG_PATH,
426
559
  journalOwner: { kind: "client", apiKeyId: issued.id },
560
+ beforeClientWrite: () => assertConnectingState(persisted.fingerprint),
427
561
  });
428
562
  if (!preflight.success) throw new Error(preflight.message);
429
563
 
@@ -432,6 +566,7 @@ export async function connectClient(
432
566
  routingTarget: target,
433
567
  catalogPath: DEFAULT_CATALOG_PATH,
434
568
  journalOwner: { kind: "client", apiKeyId: issued.id },
569
+ beforeClientWrite: () => assertConnectingState(persisted.fingerprint),
435
570
  });
436
571
  if (!injected.success || injected.status === "skipped") throw new Error(injected.message);
437
572
  injectionCommitted = true;
@@ -456,8 +591,11 @@ export async function connectClient(
456
591
  priorCatalog: priorCatalog.kind === "file" ? Buffer.from(priorCatalog.body, "utf8").toString("base64") : "",
457
592
  catalogSyncedAt: now,
458
593
  };
459
- commitClientConnection(connection);
460
- committed = true;
594
+ withClientLifecycleSync(() => withConfigMutationLockSync(() => {
595
+ assertConnectingState(persisted.fingerprint);
596
+ commitClientConnection(connection);
597
+ committed = true;
598
+ }), deps.lifecycleLockDeps);
461
599
  return connection;
462
600
  } catch (error) {
463
601
  const rollbackFailures: string[] = [];
@@ -465,13 +603,18 @@ export async function connectClient(
465
603
  const restored = restoreJournalState();
466
604
  if (!restored.complete) rollbackFailures.push("Codex journal restore was partial");
467
605
  }
468
- if (priorCatalog && writtenCatalogFingerprint && !restoreCatalogSnapshot(priorCatalog, writtenCatalogFingerprint)) {
469
- rollbackFailures.push("catalog rollback did not match the written artifact");
470
- }
471
- if (tokenFingerprint) {
472
- const removed = removeServiceApiTokenFileIfOwned(tokenFingerprint);
473
- if (removed === "changed") rollbackFailures.push("service token changed during rollback");
474
- }
606
+ try {
607
+ withClientLifecycleSync(() => withConfigMutationLockSync(() => {
608
+ assertNoClientDisconnectPending();
609
+ if (priorCatalog && writtenCatalogFingerprint && !restoreCatalogSnapshot(priorCatalog, writtenCatalogFingerprint)) {
610
+ rollbackFailures.push("catalog rollback did not match the written artifact");
611
+ }
612
+ if (tokenFingerprint) {
613
+ const removed = removeServiceApiTokenFileIfOwned(tokenFingerprint);
614
+ if (removed === "changed") rollbackFailures.push("service token changed during rollback");
615
+ }
616
+ }), deps.lifecycleLockDeps);
617
+ } catch { rollbackFailures.push("client cleanup ownership unavailable"); }
475
618
  let remoteCleanup: string | null = null;
476
619
  if (issued && cleanupCredential && managementUrl) {
477
620
  remoteCleanup = await cleanupIssuedKey(managementUrl, cleanupCredential, issued.id, deps);
@@ -498,50 +641,60 @@ export async function syncConnectedClient(
498
641
  _options: { restartCodex?: boolean } = {},
499
642
  deps: ClientConnectDeps = {},
500
643
  ): Promise<{ catalogWritten: boolean; cacheSynced: boolean; injected: boolean; stale: boolean }> {
501
- const state = readClientConnectionState();
502
- if (state.kind !== "connected") throw new Error(`connected sync refused: client state is ${state.kind}`);
503
- const token = readServiceApiTokenState();
504
- if (token.kind !== "present" || token.fingerprint !== state.value.tokenFingerprint) {
505
- throw new Error(token.kind === "absent" ? "connected service token is missing" : "connected service token ownership changed");
506
- }
507
-
508
- let catalogWritten = false;
644
+ const initial = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
645
+ assertNoClientDisconnectPending();
646
+ const state = readClientConnectionState();
647
+ if (state.kind !== "connected" || state.value.pendingOperation) throw new Error("client_sync_unavailable");
648
+ const token = readServiceApiTokenState();
649
+ if (token.kind !== "present" || token.fingerprint !== state.value.tokenFingerprint) throw new Error("client_token_changed");
650
+ return { connection: state.value, token };
651
+ }), deps.lifecycleLockDeps);
652
+ let downloaded: Awaited<ReturnType<typeof downloadClientCatalog>> | undefined;
509
653
  let stale = false;
510
- let next = state.value;
511
654
  try {
512
- const downloaded = await downloadClientCatalog(state.value.serverUrl, token.token, {
513
- fetchImpl: deps.fetchImpl,
514
- });
515
- atomicWriteFile(DEFAULT_CATALOG_PATH, downloaded.body);
516
- catalogWritten = true;
517
- const now = (deps.now ?? (() => new Date()))().toISOString();
518
- next = {
519
- ...state.value,
520
- catalogFingerprint: createHash("sha256").update(downloaded.body).digest("base64url"),
521
- catalogSyncedAt: now,
522
- };
523
- commitClientConnection(next);
655
+ downloaded = await downloadClientCatalog(initial.connection.serverUrl, initial.token.token, { fetchImpl: deps.fetchImpl });
524
656
  } catch (error) {
525
657
  const transient = error instanceof HubClientError
526
658
  && (error.code === "unreachable" || (error.status !== undefined && error.status >= 500));
527
659
  if (!transient) throw error;
528
- validLocalCatalog();
529
660
  stale = true;
530
661
  }
531
-
662
+ const next = withClientLifecycleSync(() => withConfigMutationLockSync(() => {
663
+ assertClientConnectionUnchanged(initial.connection);
664
+ const token = readServiceApiTokenState();
665
+ if (token.kind !== "present" || token.fingerprint !== initial.token.fingerprint) throw new Error("client_token_changed");
666
+ if (!downloaded) { validLocalCatalog(); return initial.connection; }
667
+ atomicWriteFile(DEFAULT_CATALOG_PATH, downloaded.body);
668
+ const updated = {
669
+ ...initial.connection,
670
+ catalogFingerprint: createHash("sha256").update(downloaded.body).digest("base64url"),
671
+ catalogSyncedAt: (deps.now ?? (() => new Date()))().toISOString(),
672
+ };
673
+ commitClientConnection(updated);
674
+ return updated;
675
+ }), deps.lifecycleLockDeps);
676
+ // Read-only: injection invokes this while N/C are held. Acquiring L here would invert C→L.
677
+ const beforeClientWrite = () => {
678
+ assertClientConnectionUnchanged(next);
679
+ const token = readServiceApiTokenState();
680
+ if (next.pendingOperation || token.kind !== "present" || token.fingerprint !== next.tokenFingerprint) throw new Error("client_token_changed");
681
+ };
532
682
  let injected = false;
533
683
  if (next.selectedClients.includes("codex")) {
534
684
  const config = loadConfig();
535
685
  const result = await injectCodexConfig(config.port, { ...config, syncResumeHistory: false }, {
536
- routingTarget: routingTarget(next.serverUrl),
537
- catalogPath: DEFAULT_CATALOG_PATH,
538
- journalOwner: { kind: "client", apiKeyId: next.apiKeyId },
686
+ routingTarget: routingTarget(next.serverUrl), catalogPath: DEFAULT_CATALOG_PATH,
687
+ journalOwner: { kind: "client", apiKeyId: next.apiKeyId }, beforeClientWrite,
539
688
  });
540
689
  if (!result.success || result.status === "skipped") throw new Error(result.message);
541
690
  injected = true;
542
691
  }
543
- const cacheSynced = invalidateCodexModelsCache({ allowWhenDesiredDisabled: true });
544
- return { catalogWritten, cacheSynced, injected, stale };
692
+ const cacheSynced = withClientLifecycleSync(() => {
693
+ beforeClientWrite();
694
+ // Cache invalidation acquires K itself (N -> K -> C); never call it while C is held.
695
+ return invalidateCodexModelsCache({ allowWhenDesiredDisabled: true });
696
+ }, deps.lifecycleLockDeps);
697
+ return { catalogWritten: downloaded !== undefined, cacheSynced, injected, stale };
545
698
  }
546
699
 
547
700
  /**
@@ -573,70 +726,168 @@ function restorePriorCatalog(connection: OcxClientConnectionConfig): "removed" |
573
726
  }
574
727
  }
575
728
 
729
+ const DISCONNECT_PHASES: readonly DesktopDisconnectReceipt["phase"][] = [
730
+ "prepared", "desktop_restored", "catalog_settled", "removing_token", "token_removed", "clearing_connection", "connection_cleared", "complete",
731
+ ];
732
+
733
+ function disconnectAtLeast(receipt: DesktopDisconnectReceipt, phase: DesktopDisconnectReceipt["phase"]): boolean {
734
+ return DISCONNECT_PHASES.indexOf(receipt.phase) >= DISCONNECT_PHASES.indexOf(phase);
735
+ }
736
+
737
+ function catalogIsRecordedPrior(connection: OcxClientConnectionConfig, snapshot: CatalogSnapshot): boolean {
738
+ return snapshot.kind === "file" && !!connection.priorCatalog
739
+ && snapshot.fingerprint === sha256(Buffer.from(connection.priorCatalog, "base64").toString("utf8"));
740
+ }
741
+
742
+ function preflightDisconnectCatalog(connection: OcxClientConnectionConfig, keepCatalog: boolean): void {
743
+ const snapshot = catalogSnapshot();
744
+ if (!keepCatalog && snapshot.kind === "file"
745
+ && !catalogMatchesFingerprint(snapshot.body, connection.catalogFingerprint)
746
+ && !catalogIsRecordedPrior(connection, snapshot)) throw new Error("client_catalog_ownership_changed");
747
+ }
748
+
749
+ function catalogAfterState(): NonNullable<DesktopDisconnectReceipt["catalogAfter"]> {
750
+ const snapshot = catalogSnapshot();
751
+ return snapshot.kind === "absent" ? { kind: "absent" } : { kind: "file", fingerprint: snapshot.fingerprint };
752
+ }
753
+
754
+ function verifyDisconnectCatalog(receipt: DesktopDisconnectReceipt): void {
755
+ if (!receipt.catalogAfter || JSON.stringify(catalogAfterState()) !== JSON.stringify(receipt.catalogAfter)) {
756
+ throw new Error("client_catalog_changed_during_disconnect");
757
+ }
758
+ }
759
+
760
+ function restoreConnectedCodex(connection: OcxClientConnectionConfig): void {
761
+ if (!connection.selectedClients.includes("codex")) return;
762
+ const owner = journalOwner();
763
+ if (owner && owner.kind === "client" && owner.apiKeyId !== connection.apiKeyId) {
764
+ throw new Error("disconnect refused: Codex journal ownership conflicts with the connected key");
765
+ }
766
+ if (owner !== null) {
767
+ if (!restoreJournalState().complete) throw new Error("disconnect refused: Codex journal restore was partial");
768
+ } else if (isCodexRoutingInjected()) {
769
+ throw new Error("disconnect refused: Codex routing is injected but no journal records the original state");
770
+ }
771
+ }
772
+
576
773
  export async function disconnectClient(
577
- options: { keepCatalog?: boolean } = {},
774
+ options: { keepCatalog?: boolean; expectedOwner?: DesktopRemoteOwner } = {},
775
+ deps: Pick<ClientConnectDeps, "lifecycleLockDeps"> = {},
578
776
  ): Promise<{
579
- restored: boolean;
580
- tokenRemoved: boolean;
581
- /** True when the catalog no longer holds remote bytes: removed outright or overwritten. */
582
- catalogRemoved: boolean;
583
- /** True only when a recorded pre-connect catalog was written back. */
584
- catalogRestored: boolean;
585
- apiKeyId: string;
777
+ restored: boolean; tokenRemoved: boolean; catalogRemoved: boolean; catalogRestored: boolean; apiKeyId: string;
778
+ desktopRestoration?: "owned_projection" | "standard_fallback" | "selection_preserved";
779
+ restartRequired: boolean;
586
780
  }> {
587
- const state = readClientConnectionState();
588
- if (state.kind !== "connected") throw new Error(`disconnect refused: client state is ${state.kind}`);
589
- const token = readServiceApiTokenState();
590
- if (token.kind !== "present" || token.fingerprint !== state.value.tokenFingerprint) {
591
- throw new Error(token.kind === "absent" ? "disconnect refused: service token is missing" : "disconnect refused: service token ownership changed");
592
- }
593
-
594
- let restored = true;
595
- if (state.value.selectedClients.includes("codex")) {
596
- const owner = journalOwner();
597
- // A journal owned by this client key is ours, obviously. A journal owned by a PROCESS is
598
- // also ours to unwind: it is what `ocx start` leaves behind, and connecting on top of it
599
- // never transfers ownership — writeJournal() declines to overwrite a journal whose
600
- // config is already injected, so the process owner survives into the connected state.
601
- //
602
- // Treating that as a conflict stranded the normal "start, then connect" path: disconnect
603
- // refused, and nothing the operator could do would satisfy the check. The genuine
604
- // conflict is a journal owned by a DIFFERENT client key, which is the one case where
605
- // restoring would unwind somebody else's routing.
606
- if (
607
- owner === null
608
- || owner.kind === "process"
609
- || owner.apiKeyId === state.value.apiKeyId
610
- ) {
611
- if (owner !== null) restored = restoreJournalState().complete;
612
- else if (isCodexRoutingInjected()) {
613
- // Injected routing with no journal at all: there is no recorded baseline to restore,
614
- // so unwinding would be a guess about what the config looked like before.
615
- throw new Error("disconnect refused: Codex routing is injected but no journal records the original state");
781
+ const keepCatalog = options.keepCatalog === true;
782
+ const prepared = withClientLifecycleSync(held => withConfigMutationLockSync(() => {
783
+ const read = readDesktopDisconnectReceipt();
784
+ if (read.kind === "unsafe") throw new Error("client_disconnect_receipt_unsafe");
785
+ const state = readClientConnectionState();
786
+ const previous = read.kind === "valid" ? read.value : null;
787
+ const observedOwner = state.kind === "connected" ? desktopOwner(state.value) : previous?.owner;
788
+ if (options.expectedOwner && (!observedOwner || !sameClientConnectionOwner(observedOwner, options.expectedOwner))) {
789
+ throw new Error("client_disconnect_expected_owner_changed");
790
+ }
791
+ let receipt = previous;
792
+ let connection: OcxClientConnectionConfig | null = null;
793
+ if (state.kind === "connected") {
794
+ connection = state.value;
795
+ if (connection.pendingOperation) throw new Error("client_rotation_recovery_required");
796
+ if (receipt?.phase === "complete" && !sameClientConnectionOwner(receipt.owner, connection)) receipt = null;
797
+ if (receipt && !sameClientConnectionOwner(receipt.owner, connection)) throw new Error("client_disconnect_owner_changed");
798
+ if (receipt?.phase === "complete") throw new Error("client_disconnect_completed_owner_reappeared");
799
+ const token = readServiceApiTokenState();
800
+ const expectedFingerprint = receipt?.tokenFingerprint ?? connection.tokenFingerprint;
801
+ if (connection.tokenFingerprint !== expectedFingerprint
802
+ || (token.kind === "present" ? token.fingerprint !== expectedFingerprint
803
+ : token.kind !== "absent" || !receipt || !disconnectAtLeast(receipt, "removing_token"))) {
804
+ throw new Error("client_token_changed");
616
805
  }
617
- } else {
618
- throw new Error("disconnect refused: Codex journal ownership conflicts with the connected key");
806
+ if (!receipt) {
807
+ const desktop = inspectRemoteDesktopStore(desktopOwner(connection));
808
+ if (desktop.kind === "unsafe" || desktop.kind === "conflict") throw new Error("desktop_lifecycle_unsafe");
809
+ preflightDisconnectCatalog(connection, keepCatalog);
810
+ receipt = { version: 1, owner: desktopOwner(connection), tokenFingerprint: connection.tokenFingerprint, keepCatalog, phase: "prepared" };
811
+ writeDesktopDisconnectReceipt(held, previous, receipt);
812
+ }
813
+ } else if (state.kind !== "disconnected" || !receipt || !disconnectAtLeast(receipt, "clearing_connection")) {
814
+ throw new Error("disconnect refused: no recoverable connected state");
619
815
  }
620
- if (!restored) throw new Error("disconnect refused: Codex journal restore was partial");
816
+ if (!receipt || receipt.keepCatalog !== keepCatalog) throw new Error("client_disconnect_options_changed");
817
+ return { receipt, connection };
818
+ }), deps.lifecycleLockDeps);
819
+
820
+ // The prepared receipt blocks even a sync queued for its actual N/C injection commit.
821
+ // Codex-only restore MUST stay outside L; it never invokes Desktop cleanup.
822
+ if (prepared.connection && !disconnectAtLeast(prepared.receipt, "desktop_restored")) {
823
+ restoreConnectedCodex(prepared.connection);
621
824
  }
622
825
 
623
- const tokenRemoval = removeServiceApiTokenFileIfOwned(state.value.tokenFingerprint);
624
- if (tokenRemoval === "changed") throw new Error("disconnect refused: service token changed before removal");
625
- let catalogRemoval: "removed" | "restored" | "absent" | "changed" = "absent";
626
- if (!options.keepCatalog) {
627
- catalogRemoval = restorePriorCatalog(state.value);
628
- if (catalogRemoval === "changed") throw new Error("disconnect refused: catalog ownership changed");
629
- }
630
- if (clearClientConnection(state.value.apiKeyId) !== "committed") {
631
- throw new Error("disconnect refused: client state changed before final commit");
632
- }
633
- return {
634
- restored,
635
- tokenRemoved: tokenRemoval === "removed",
636
- catalogRemoved: catalogRemoval === "removed" || catalogRemoval === "restored",
637
- catalogRestored: catalogRemoval === "restored",
638
- apiKeyId: state.value.apiKeyId,
639
- };
826
+ return withClientLifecycleSync(held => withConfigMutationLockSync(() => {
827
+ const read = readDesktopDisconnectReceipt();
828
+ if (read.kind !== "valid" || JSON.stringify(read.value) !== JSON.stringify(prepared.receipt)) {
829
+ throw new Error("client_disconnect_receipt_changed");
830
+ }
831
+ let receipt = read.value;
832
+ const state = readClientConnectionState();
833
+ let connection: OcxClientConnectionConfig | null = null;
834
+ if (state.kind === "connected") {
835
+ if (!sameClientConnectionOwner(state.value, receipt.owner) || state.value.pendingOperation
836
+ || state.value.tokenFingerprint !== receipt.tokenFingerprint) throw new Error("client_disconnect_owner_changed");
837
+ connection = state.value;
838
+ } else if (state.kind !== "disconnected" || !disconnectAtLeast(receipt, "clearing_connection")) {
839
+ throw new Error("client_disconnect_owner_changed");
840
+ }
841
+ const token = readServiceApiTokenState();
842
+ if (token.kind === "present" ? token.fingerprint !== receipt.tokenFingerprint
843
+ : token.kind !== "absent" || !disconnectAtLeast(receipt, "removing_token")) throw new Error("client_token_changed");
844
+ const advance = (phase: DesktopDisconnectReceipt["phase"], fields: Partial<DesktopDisconnectReceipt> = {}) => {
845
+ const next = { ...receipt, ...fields, phase };
846
+ writeDesktopDisconnectReceipt(held, receipt, next);
847
+ receipt = next;
848
+ };
849
+ let desktop: Extract<DesktopStoreResult, { ok: true }>;
850
+ if (!disconnectAtLeast(receipt, "desktop_restored")) {
851
+ desktop = requireDesktopResult(restoreRemoteDesktopStore(held, {
852
+ owner: receipt.owner, knownTokenFingerprints: [receipt.tokenFingerprint],
853
+ }));
854
+ advance("desktop_restored", desktop.fingerprint ? { desktopAfterFingerprint: desktop.fingerprint } : {});
855
+ } else {
856
+ // A retry after token/config removal must verify the completed projection,
857
+ // not invoke a mutator that needs the now-removed connection credential.
858
+ const inspected = inspectRemoteDesktopStore(receipt.owner);
859
+ if (inspected.kind !== "restored" && !(inspected.kind === "absent"
860
+ && (!receipt.desktopAfterFingerprint || receipt.phase === "complete"))) {
861
+ throw new Error("desktop_disconnect_after_state_changed");
862
+ }
863
+ desktop = { ok: true, changed: false, status: inspected.kind === "absent" ? "absent" : "restored",
864
+ restartRequired: receipt.desktopAfterFingerprint !== undefined };
865
+ }
866
+ if (!disconnectAtLeast(receipt, "catalog_settled")) {
867
+ if (!connection) throw new Error("client_disconnect_catalog_context_missing");
868
+ preflightDisconnectCatalog(connection, keepCatalog);
869
+ const snapshot = catalogSnapshot();
870
+ if (!keepCatalog && snapshot.kind !== "absent" && !catalogIsRecordedPrior(connection, snapshot)) {
871
+ if (restorePriorCatalog(connection) === "changed") throw new Error("client_catalog_ownership_changed");
872
+ }
873
+ advance("catalog_settled", { catalogAfter: catalogAfterState() });
874
+ } else verifyDisconnectCatalog(receipt);
875
+ if (!disconnectAtLeast(receipt, "removing_token")) advance("removing_token");
876
+ const tokenRemoval = removeServiceApiTokenFileIfOwned(receipt.tokenFingerprint);
877
+ if (tokenRemoval === "changed") throw new Error("client_token_changed");
878
+ if (!disconnectAtLeast(receipt, "token_removed")) advance("token_removed");
879
+ if (!disconnectAtLeast(receipt, "clearing_connection")) advance("clearing_connection");
880
+ if (clearClientConnection(receipt.owner) === "conflict") throw new Error("client_disconnect_owner_changed");
881
+ if (!disconnectAtLeast(receipt, "connection_cleared")) advance("connection_cleared");
882
+ requireDesktopResult(finishRemoteDesktopCleanup(held, receipt.owner));
883
+ if (receipt.phase !== "complete") advance("complete");
884
+ return {
885
+ restored: true, tokenRemoved: tokenRemoval === "removed",
886
+ catalogRemoved: !keepCatalog, catalogRestored: !keepCatalog && receipt.catalogAfter?.kind === "file",
887
+ apiKeyId: receipt.owner.apiKeyId, restartRequired: desktop.restartRequired,
888
+ ...(desktop.restoration ? { desktopRestoration: desktop.restoration } : {}),
889
+ };
890
+ }), deps.lifecycleLockDeps);
640
891
  }
641
892
 
642
893
  export async function revokeConnectedClientKey(
@@ -644,11 +895,13 @@ export async function revokeConnectedClientKey(
644
895
  deps: ClientConnectDeps = {},
645
896
  ): Promise<{ apiKeyId: string }> {
646
897
  try {
647
- const state = readClientConnectionState();
648
- if (state.kind !== "connected") throw new Error("connect revoke is available only while connected");
649
- await revokeClientKey(state.value.managementUrl, credential, state.value.apiKeyId, { fetchImpl: deps.fetchImpl });
650
- return { apiKeyId: state.value.apiKeyId };
651
- } finally {
652
- credential.value.fill(0);
653
- }
898
+ return await withClientLifecycle(async () => {
899
+ assertNoClientDisconnectPending();
900
+ const state = readClientConnectionState();
901
+ if (state.kind !== "connected" || state.value.pendingOperation) throw new Error("connect revoke requires a settled connection");
902
+ await revokeClientKey(state.value.managementUrl, credential, state.value.apiKeyId, { fetchImpl: deps.fetchImpl });
903
+ assertClientConnectionUnchanged(state.value);
904
+ return { apiKeyId: state.value.apiKeyId };
905
+ }, deps.lifecycleLockDeps);
906
+ } finally { credential.value.fill(0); }
654
907
  }