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