@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.
- package/README.md +25 -17
- package/gui/dist/assets/index-B7_K1Hsj.js +115 -0
- package/gui/dist/assets/index-ltx3L-WS.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +23 -2
- package/src/adapters/cursor/native-exec.ts +63 -11
- package/src/adapters/cursor/tool-definitions.ts +1 -1
- package/src/adapters/cursor/tool-schemas.ts +59 -0
- package/src/adapters/kiro.ts +54 -8
- package/src/adapters/openai-chat.ts +23 -6
- package/src/adapters/openai-responses.ts +3 -1
- package/src/adapters/opencode-go.ts +35 -0
- package/src/claude/agents-inject.ts +12 -5
- package/src/claude/desktop-3p-library.ts +89 -0
- package/src/claude/desktop-3p.ts +166 -95
- package/src/claude/desktop-discovery-inputs.ts +44 -0
- package/src/claude/desktop-profile.ts +1 -1
- package/src/claude/desktop-remote-store-artifact.ts +185 -0
- package/src/claude/desktop-remote-store-io.ts +91 -0
- package/src/claude/desktop-remote-store-state.ts +127 -0
- package/src/claude/desktop-remote-store.ts +269 -0
- package/src/claude/inbound-model-options.ts +10 -2
- package/src/claude/inbound-records.ts +7 -0
- package/src/claude/inbound.ts +1 -1
- package/src/claude/model-info.ts +16 -3
- package/src/cli/account-api.ts +7 -1
- package/src/cli/aside-profiles.ts +17 -0
- package/src/cli/capabilities.ts +39 -0
- package/src/cli/claude-agent-startup-sync.ts +3 -1
- package/src/cli/claude-desktop.ts +127 -18
- package/src/cli/connect.ts +27 -14
- package/src/cli/dispatch.ts +30 -16
- package/src/cli/export-command.ts +3 -1
- package/src/cli/index.ts +28 -16
- package/src/cli/integrations.ts +46 -12
- package/src/cli/opencode.ts +11 -4
- package/src/cli/runtime-api.ts +7 -2
- package/src/cli/uninstall-client-state.ts +78 -0
- package/src/client/connect.ts +476 -223
- package/src/client/hub-client.ts +81 -0
- package/src/client/hub-relay.ts +38 -6
- package/src/client/lifecycle-lock.ts +129 -0
- package/src/client/state.ts +71 -42
- package/src/clients/aside-profiles.ts +224 -0
- package/src/codex/account-lifecycle.ts +5 -9
- package/src/codex/auth-api.ts +50 -4
- package/src/codex/catalog/metadata.ts +0 -2
- package/src/codex/catalog/provider-fetch.ts +7 -5
- package/src/codex/catalog/sync.ts +88 -24
- package/src/codex/convergence.ts +2 -0
- package/src/codex/inject.ts +45 -14
- package/src/codex/journal.ts +6 -6
- package/src/codex/quota-refresh-outcome.ts +27 -0
- package/src/codex/shim.ts +7 -1
- package/src/combos/resolve.ts +10 -7
- package/src/config.ts +18 -5
- package/src/generated/compatibility-version.json +209 -105
- package/src/images/loop.ts +5 -2
- package/src/integrations/aside-profile-context.ts +270 -0
- package/src/integrations/aside-profile-journal.ts +229 -0
- package/src/integrations/aside-profiles.ts +176 -0
- package/src/integrations/catalog-refresh.ts +40 -0
- package/src/integrations/config-io.ts +16 -1
- package/src/integrations/owned-refresh.ts +14 -2
- package/src/integrations/state.ts +3 -1
- package/src/integrations/writer.ts +3 -1
- package/src/lab/fabric/producer-isolate.ts +84 -46
- package/src/lib/account-selection-events.ts +32 -0
- package/src/lib/errors.ts +9 -1
- package/src/lib/provider-outbound.ts +2 -45
- package/src/lib/proxy-env.ts +67 -0
- package/src/oauth/account-quota-rank.ts +9 -1
- package/src/oauth/anthropic-routing.ts +91 -23
- package/src/oauth/generic-account-failover.ts +15 -53
- package/src/oauth/index.ts +67 -68
- package/src/oauth/pool-settings-capability.ts +2 -1
- package/src/oauth/store.ts +91 -7
- package/src/oauth/types.ts +8 -0
- package/src/providers/api-key-selection.ts +110 -0
- package/src/providers/api-keys.ts +38 -31
- package/src/providers/context-cap.ts +27 -3
- package/src/providers/key-failover.ts +27 -17
- package/src/providers/provider-id-rewrite.ts +10 -8
- package/src/providers/registry.ts +1 -0
- package/src/responses/custom-tool-compat.ts +10 -0
- package/src/responses/function-call-compat.ts +173 -0
- package/src/responses/namespace-tool-compat.ts +27 -4
- package/src/responses/parser.ts +8 -0
- package/src/responses/task-input.ts +36 -0
- package/src/responses/tool-name-aliases.ts +79 -0
- package/src/router.ts +2 -0
- package/src/routing/capability.ts +10 -3
- package/src/routing/compatibility/assemble.ts +16 -1
- package/src/routing/evaluator.ts +5 -1
- package/src/server/auth-cors.ts +3 -0
- package/src/server/chat-native.ts +44 -9
- package/src/server/claude-messages.ts +71 -36
- package/src/server/grok-responses-snapshot-repair.ts +333 -0
- package/src/server/index.ts +26 -10
- package/src/server/management/account-selection-stream.ts +70 -0
- package/src/server/management/aside-profile-routes.ts +231 -0
- package/src/server/management/config-routes.ts +36 -41
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +61 -3
- package/src/server/management/logs-usage-routes.ts +1 -0
- package/src/server/management/model-routes.ts +20 -9
- package/src/server/management/model-rows.ts +10 -2
- package/src/server/management/oauth-account-routes.ts +7 -0
- package/src/server/management/provider-routes.ts +10 -8
- package/src/server/management/route-registry.ts +20 -2
- package/src/server/management-auth.ts +16 -0
- package/src/server/relay-eager.ts +17 -5
- package/src/server/relay.ts +43 -4
- package/src/server/request-log.ts +29 -1
- package/src/server/responses/agent-task-recovery-cache.ts +11 -0
- package/src/server/responses/agent-task-recovery.ts +24 -4
- package/src/server/responses/codex-ws-pool.ts +4 -4
- package/src/server/responses/codex-ws-session.ts +2 -2
- package/src/server/responses/collaboration.ts +2 -1
- package/src/server/responses/combo-stream-preflight.ts +26 -7
- package/src/server/responses/core.ts +668 -151
- package/src/server/responses/fetch-helpers.ts +6 -1
- package/src/server/responses/ws-upstream.ts +8 -3
- package/src/server/responses-custom-tool-repair.ts +29 -9
- package/src/server/responses-function-tool-repair.ts +183 -0
- package/src/server/responses-snapshot-codec.ts +14 -0
- package/src/server/responses-snapshot-repair.ts +1 -13
- package/src/server/responses-undeclared-tool-guard.ts +1 -68
- package/src/server/sse-payload-rewrite.ts +3 -1
- package/src/service.ts +6 -2
- package/src/types/config.ts +23 -16
- package/src/types/provider.ts +14 -3
- package/src/types/request.ts +2 -0
- package/src/usage/log.ts +9 -0
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/index-DS1NE4Jn.css +0 -1
- package/gui/dist/assets/index-Djowl68T.js +0 -112
package/src/client/connect.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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<
|
|
341
|
+
): Promise<ClientRotationResult> {
|
|
247
342
|
try {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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<
|
|
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(
|
|
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"
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
+
withConfigMutationLockSync(() => {
|
|
409
|
+
assertClientConnectionUnchanged(connection!);
|
|
410
|
+
commitClientConnection(marked);
|
|
411
|
+
});
|
|
290
412
|
connection = marked;
|
|
291
413
|
markerPersisted = true;
|
|
292
|
-
const replacement =
|
|
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
|
-
|
|
302
|
-
|
|
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)
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
}
|
|
331
|
-
|
|
332
|
-
finally { removeOrphanTokenBackup(); }
|
|
452
|
+
} catch {
|
|
453
|
+
throw new RotationRecoveryRequiredError("rotation rollback was incomplete; preserve current and backup tokens");
|
|
333
454
|
}
|
|
334
|
-
} else {
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
const
|
|
382
|
-
throw new Error(
|
|
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
|
-
|
|
409
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
460
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
544
|
-
|
|
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
|
-
|
|
581
|
-
|
|
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
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
618
|
-
|
|
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 (!
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
}
|