@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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, mkdirSync, readFileSync, realpathSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { atomicWriteFile, getConfigDir } from "../config";
|
|
4
|
+
import { hardenSecretDir } from "../lib/windows-secret-acl";
|
|
5
|
+
import { isRecord, resolveDesktop3pConfigLibraryPath, SAFE_DESKTOP_PROFILE_ID, type Desktop3pMetadata } from "./desktop-3p-library";
|
|
6
|
+
import { DesktopStoreError, digest } from "./desktop-remote-store-state";
|
|
7
|
+
|
|
8
|
+
export const MAX_DESKTOP_METADATA_ENTRIES = 256;
|
|
9
|
+
|
|
10
|
+
export interface JsonFile { value: Record<string, unknown>; hash: string; identity: string }
|
|
11
|
+
const identity = (s: NonNullable<ReturnType<typeof lstatSync>>): string => `${s.dev}:${s.ino}:${s.size}:${s.mtimeMs}`;
|
|
12
|
+
function missing(error: unknown): boolean { return (error as NodeJS.ErrnoException)?.code === "ENOENT"; }
|
|
13
|
+
export function canonicalDirectory(path: string): string {
|
|
14
|
+
const absolute = resolve(path);
|
|
15
|
+
try {
|
|
16
|
+
const stat = lstatSync(absolute);
|
|
17
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new DesktopStoreError("unsafe");
|
|
18
|
+
return realpathSync(absolute);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (!missing(error)) throw error;
|
|
21
|
+
const parent = dirname(absolute);
|
|
22
|
+
return parent === absolute ? absolute : join(canonicalDirectory(parent), basename(absolute));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function storePaths() {
|
|
26
|
+
const home = canonicalDirectory(getConfigDir());
|
|
27
|
+
const library = canonicalDirectory(resolveDesktop3pConfigLibraryPath());
|
|
28
|
+
const root = canonicalDirectory(join(home, "desktop-remote"));
|
|
29
|
+
return { home, library, root, state: join(root, "state.json"), baseline: join(root, "baseline.json"), disconnect: join(root, "disconnect.json") };
|
|
30
|
+
}
|
|
31
|
+
export class StoreIO {
|
|
32
|
+
changed = false;
|
|
33
|
+
private bytes = 0;
|
|
34
|
+
private readonly seenSizes = new Map<string, number>();
|
|
35
|
+
read(path: string, maxBytes = 1024 * 1024, privateFile = false): JsonFile | null {
|
|
36
|
+
let stat: ReturnType<typeof lstatSync>;
|
|
37
|
+
try { stat = lstatSync(path); }
|
|
38
|
+
catch (error) { if (missing(error)) return null; throw new DesktopStoreError("unsafe"); }
|
|
39
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxBytes
|
|
40
|
+
|| (privateFile && process.platform !== "win32" && (stat.mode & 0o077) !== 0)) throw new DesktopStoreError("unsafe");
|
|
41
|
+
const counted = this.seenSizes.get(path) ?? 0;
|
|
42
|
+
this.bytes += Math.max(0, stat.size - counted);
|
|
43
|
+
this.seenSizes.set(path, Math.max(counted, stat.size));
|
|
44
|
+
if (this.bytes > 8 * 1024 * 1024) throw new DesktopStoreError("unsafe");
|
|
45
|
+
try {
|
|
46
|
+
const bytes = readFileSync(path);
|
|
47
|
+
if (bytes.byteLength > maxBytes || identity(lstatSync(path)) !== identity(stat)) throw new DesktopStoreError("conflict");
|
|
48
|
+
const value: unknown = JSON.parse(bytes.toString("utf8"));
|
|
49
|
+
if (!isRecord(value)) throw new DesktopStoreError("unsafe");
|
|
50
|
+
return { value, hash: digest(bytes.toString("utf8")), identity: identity(stat) };
|
|
51
|
+
} catch (error) { if (error instanceof DesktopStoreError) throw error; throw new DesktopStoreError("unsafe"); }
|
|
52
|
+
}
|
|
53
|
+
ensureDirectory(path: string): void {
|
|
54
|
+
canonicalDirectory(path);
|
|
55
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
56
|
+
canonicalDirectory(path);
|
|
57
|
+
chmodSync(path, 0o700);
|
|
58
|
+
if (process.platform === "win32") hardenSecretDir(path, { required: true });
|
|
59
|
+
}
|
|
60
|
+
compare(path: string, expected: JsonFile | null): void {
|
|
61
|
+
const fresh = this.read(path);
|
|
62
|
+
if (fresh?.hash !== expected?.hash || fresh?.identity !== expected?.identity) throw new DesktopStoreError("conflict");
|
|
63
|
+
}
|
|
64
|
+
write(path: string, value: unknown, expected: JsonFile | null, maxBytes = 1024 * 1024): void {
|
|
65
|
+
const bytes = JSON.stringify(value, null, 2) + "\n";
|
|
66
|
+
if (Buffer.byteLength(bytes) > maxBytes) throw new DesktopStoreError("unsafe");
|
|
67
|
+
this.compare(path, expected);
|
|
68
|
+
this.ensureDirectory(dirname(path));
|
|
69
|
+
atomicWriteFile(path, bytes, undefined, { validateBeforeRename: () => this.compare(path, expected) });
|
|
70
|
+
this.changed = true;
|
|
71
|
+
}
|
|
72
|
+
remove(path: string, expected: JsonFile): void {
|
|
73
|
+
this.compare(path, expected);
|
|
74
|
+
unlinkSync(path);
|
|
75
|
+
this.changed = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function metadata(io: StoreIO, library: string): { file: JsonFile | null; value: Desktop3pMetadata } {
|
|
79
|
+
const file = io.read(join(library, "_meta.json"));
|
|
80
|
+
if (!file) return { file, value: { entries: [] } };
|
|
81
|
+
const value = file.value;
|
|
82
|
+
if (!Array.isArray(value.entries) || value.entries.length > MAX_DESKTOP_METADATA_ENTRIES) throw new DesktopStoreError("unsafe");
|
|
83
|
+
const seen = new Set<string>();
|
|
84
|
+
for (const entry of value.entries) {
|
|
85
|
+
if (!isRecord(entry) || typeof entry.id !== "string" || !SAFE_DESKTOP_PROFILE_ID.test(entry.id)
|
|
86
|
+
|| seen.has(entry.id) || typeof entry.name !== "string") throw new DesktopStoreError("unsafe");
|
|
87
|
+
seen.add(entry.id);
|
|
88
|
+
}
|
|
89
|
+
if (value.appliedId !== undefined && (typeof value.appliedId !== "string" || !seen.has(value.appliedId))) throw new DesktopStoreError("unsafe");
|
|
90
|
+
return { file, value: value as unknown as Desktop3pMetadata };
|
|
91
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { OPENCODEX_DESKTOP_PROFILE_KEYS, SAFE_DESKTOP_PROFILE_ID, isRecord } from "./desktop-3p-library";
|
|
3
|
+
|
|
4
|
+
export type DesktopRemoteOwner = { serverUrl: string; apiKeyId: string; connectedAt: string };
|
|
5
|
+
export type DesktopStoreResult =
|
|
6
|
+
| { ok: true; changed: boolean; status: "absent" | "applied" | "updated" | "restored";
|
|
7
|
+
baselineKind?: "known" | "standard_fallback";
|
|
8
|
+
restoration?: "owned_projection" | "standard_fallback" | "selection_preserved";
|
|
9
|
+
retainedForeignData?: boolean; restartRequired: boolean; path?: string; fingerprint?: string }
|
|
10
|
+
| { ok: false; changed: boolean; reason: "busy" | "conflict" | "unsafe" | "recovery_required" | "cleanup_pending" | "desired_disabled" };
|
|
11
|
+
export type StoreReason = Extract<DesktopStoreResult, { ok: false }>["reason"];
|
|
12
|
+
export class DesktopStoreError extends Error {
|
|
13
|
+
constructor(readonly reason: StoreReason) { super(`desktop_store_${reason}`); }
|
|
14
|
+
}
|
|
15
|
+
export type Projection = Record<string, unknown>;
|
|
16
|
+
export interface Baseline {
|
|
17
|
+
version: 1; owner: DesktopRemoteOwner; home: string; library: string; targetId: string;
|
|
18
|
+
kind: "known" | "standard_fallback"; targetExisted: boolean; projection: Projection;
|
|
19
|
+
priorSelection: { id: string; hash: string } | null;
|
|
20
|
+
}
|
|
21
|
+
export interface ArtifactPending {
|
|
22
|
+
kind: "apply" | "rotate" | "restore"; before: string; after: string;
|
|
23
|
+
beforeSelection: string | null; afterSelection: string | null;
|
|
24
|
+
tokenFingerprint: string;
|
|
25
|
+
}
|
|
26
|
+
export interface StoreState {
|
|
27
|
+
version: 1; owner: DesktopRemoteOwner; home: string; library: string; targetId: string;
|
|
28
|
+
baselineRef: "baseline.json"; baselineHash: string; baselineKind: Baseline["kind"];
|
|
29
|
+
phase: "prepared" | "active" | "restored" | "cleaned";
|
|
30
|
+
lastProjectionHash: string; tokenFingerprint: string; pending?: ArtifactPending;
|
|
31
|
+
}
|
|
32
|
+
export interface DesktopDisconnectReceipt {
|
|
33
|
+
version: 1; owner: DesktopRemoteOwner; tokenFingerprint: string; keepCatalog: boolean;
|
|
34
|
+
phase: "prepared" | "desktop_restored" | "catalog_settled" | "removing_token" | "token_removed"
|
|
35
|
+
| "clearing_connection" | "connection_cleared" | "complete";
|
|
36
|
+
desktopAfterFingerprint?: string;
|
|
37
|
+
catalogAfter?: { kind: "absent" } | { kind: "file"; fingerprint: string };
|
|
38
|
+
}
|
|
39
|
+
export const DISCONNECT_PHASES = ["prepared", "desktop_restored", "catalog_settled", "removing_token", "token_removed", "clearing_connection", "connection_cleared", "complete"] as const;
|
|
40
|
+
export function canonical(value: unknown): string {
|
|
41
|
+
const sort = (v: unknown): unknown => Array.isArray(v) ? v.map(sort) : isRecord(v)
|
|
42
|
+
? Object.fromEntries(Object.keys(v).sort().map(k => [k, sort(v[k])])) : v;
|
|
43
|
+
const text = JSON.stringify(sort(value));
|
|
44
|
+
if (text === undefined) throw new DesktopStoreError("unsafe");
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
export function digest(value: string): string { return createHash("sha256").update(value).digest("hex"); }
|
|
48
|
+
export function projection(value: Record<string, unknown>): Projection {
|
|
49
|
+
return Object.fromEntries(Object.entries(value).filter(([key]) => OPENCODEX_DESKTOP_PROFILE_KEYS.has(key)));
|
|
50
|
+
}
|
|
51
|
+
export function projectionHash(value: Record<string, unknown> | null): string {
|
|
52
|
+
return digest(canonical(value === null ? null : projection(value)));
|
|
53
|
+
}
|
|
54
|
+
export function mergeProjection(current: Record<string, unknown>, owned: Projection): Record<string, unknown> {
|
|
55
|
+
return { ...Object.fromEntries(Object.entries(current).filter(([k]) => !OPENCODEX_DESKTOP_PROFILE_KEYS.has(k))), ...owned };
|
|
56
|
+
}
|
|
57
|
+
export function sameOwner(a: DesktopRemoteOwner, b: DesktopRemoteOwner): boolean { return canonical(a) === canonical(b); }
|
|
58
|
+
export function exact(value: Record<string, unknown>, keys: string[]): void {
|
|
59
|
+
if (Object.keys(value).some(k => !keys.includes(k))) throw new DesktopStoreError("unsafe");
|
|
60
|
+
}
|
|
61
|
+
export function origin(value: unknown): string {
|
|
62
|
+
if (typeof value !== "string") throw new DesktopStoreError("unsafe");
|
|
63
|
+
let url: URL;
|
|
64
|
+
try { url = new URL(value); } catch { throw new DesktopStoreError("unsafe"); }
|
|
65
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash
|
|
66
|
+
|| !['/', '/v1', '/v1/'].includes(url.pathname)) throw new DesktopStoreError("unsafe");
|
|
67
|
+
return url.origin;
|
|
68
|
+
}
|
|
69
|
+
export function parseOwner(value: unknown): DesktopRemoteOwner {
|
|
70
|
+
if (!isRecord(value)) throw new DesktopStoreError("unsafe");
|
|
71
|
+
exact(value, ["serverUrl", "apiKeyId", "connectedAt"]);
|
|
72
|
+
if (typeof value.apiKeyId !== "string" || !value.apiKeyId || value.apiKeyId.length > 256
|
|
73
|
+
|| typeof value.connectedAt !== "string" || !Number.isFinite(Date.parse(value.connectedAt))
|
|
74
|
+
|| origin(value.serverUrl) !== value.serverUrl) throw new DesktopStoreError("unsafe");
|
|
75
|
+
return value as DesktopRemoteOwner;
|
|
76
|
+
}
|
|
77
|
+
export function isHash(value: unknown): value is string { return typeof value === "string" && /^[a-f0-9]{64}$/.test(value); }
|
|
78
|
+
export function parseState(value: unknown): StoreState {
|
|
79
|
+
if (!isRecord(value)) throw new DesktopStoreError("unsafe");
|
|
80
|
+
exact(value, ["version", "owner", "home", "library", "targetId", "baselineRef", "baselineHash", "baselineKind", "phase", "lastProjectionHash", "tokenFingerprint", "pending"]);
|
|
81
|
+
parseOwner(value.owner);
|
|
82
|
+
if (value.version !== 1 || typeof value.home !== "string" || typeof value.library !== "string"
|
|
83
|
+
|| typeof value.targetId !== "string" || !SAFE_DESKTOP_PROFILE_ID.test(value.targetId)
|
|
84
|
+
|| value.baselineRef !== "baseline.json" || !isHash(value.baselineHash) || !isHash(value.lastProjectionHash)
|
|
85
|
+
|| !isHash(value.tokenFingerprint) || !["known", "standard_fallback"].includes(String(value.baselineKind))
|
|
86
|
+
|| !["prepared", "active", "restored", "cleaned"].includes(String(value.phase))) throw new DesktopStoreError("unsafe");
|
|
87
|
+
if (value.pending !== undefined) {
|
|
88
|
+
const p = value.pending;
|
|
89
|
+
if (!isRecord(p)) throw new DesktopStoreError("unsafe");
|
|
90
|
+
exact(p, ["kind", "before", "after", "beforeSelection", "afterSelection", "tokenFingerprint"]);
|
|
91
|
+
if (!['apply', 'rotate', 'restore'].includes(String(p.kind)) || !isHash(p.before) || !isHash(p.after) || !isHash(p.tokenFingerprint)
|
|
92
|
+
|| [p.beforeSelection, p.afterSelection].some(id => id !== null && (typeof id !== "string" || !SAFE_DESKTOP_PROFILE_ID.test(id)))) throw new DesktopStoreError("unsafe");
|
|
93
|
+
}
|
|
94
|
+
return value as unknown as StoreState;
|
|
95
|
+
}
|
|
96
|
+
export function parseBaseline(value: unknown): Baseline {
|
|
97
|
+
if (!isRecord(value)) throw new DesktopStoreError("unsafe");
|
|
98
|
+
exact(value, ["version", "owner", "home", "library", "targetId", "kind", "targetExisted", "projection", "priorSelection"]);
|
|
99
|
+
parseOwner(value.owner);
|
|
100
|
+
if (value.version !== 1 || typeof value.home !== "string" || typeof value.library !== "string"
|
|
101
|
+
|| typeof value.targetId !== "string" || !SAFE_DESKTOP_PROFILE_ID.test(value.targetId)
|
|
102
|
+
|| !["known", "standard_fallback"].includes(String(value.kind)) || typeof value.targetExisted !== "boolean"
|
|
103
|
+
|| !isRecord(value.projection) || Object.keys(value.projection).some(k => !OPENCODEX_DESKTOP_PROFILE_KEYS.has(k))) throw new DesktopStoreError("unsafe");
|
|
104
|
+
if (value.kind === "standard_fallback" && Object.keys(value.projection).length) throw new DesktopStoreError("unsafe");
|
|
105
|
+
if (value.priorSelection !== null) {
|
|
106
|
+
const p = value.priorSelection;
|
|
107
|
+
if (!isRecord(p)) throw new DesktopStoreError("unsafe");
|
|
108
|
+
exact(p, ["id", "hash"]);
|
|
109
|
+
if (typeof p.id !== "string" || !SAFE_DESKTOP_PROFILE_ID.test(p.id) || !isHash(p.hash)) throw new DesktopStoreError("unsafe");
|
|
110
|
+
}
|
|
111
|
+
return value as unknown as Baseline;
|
|
112
|
+
}
|
|
113
|
+
export function parseDisconnect(value: unknown): DesktopDisconnectReceipt {
|
|
114
|
+
if (!isRecord(value)) throw new DesktopStoreError("unsafe");
|
|
115
|
+
exact(value, ["version", "owner", "tokenFingerprint", "keepCatalog", "phase", "desktopAfterFingerprint", "catalogAfter"]);
|
|
116
|
+
parseOwner(value.owner);
|
|
117
|
+
if (value.version !== 1 || !isHash(value.tokenFingerprint) || typeof value.keepCatalog !== "boolean"
|
|
118
|
+
|| !DISCONNECT_PHASES.includes(value.phase as DesktopDisconnectReceipt['phase'])
|
|
119
|
+
|| (value.desktopAfterFingerprint !== undefined && !isHash(value.desktopAfterFingerprint))) throw new DesktopStoreError("unsafe");
|
|
120
|
+
if (value.catalogAfter !== undefined) {
|
|
121
|
+
const c = value.catalogAfter;
|
|
122
|
+
if (!isRecord(c)) throw new DesktopStoreError("unsafe");
|
|
123
|
+
exact(c, c.kind === "absent" ? ["kind"] : ["kind", "fingerprint"]);
|
|
124
|
+
if (c.kind !== "absent" && (c.kind !== "file" || !isHash(c.fingerprint))) throw new DesktopStoreError("unsafe");
|
|
125
|
+
}
|
|
126
|
+
return value as unknown as DesktopDisconnectReceipt;
|
|
127
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readConfigDiagnostics, withConfigMutationLockSync } from "../config";
|
|
4
|
+
import { assertClientLifecycleHeld, type ClientLifecycleHeld } from "../client/lifecycle-lock";
|
|
5
|
+
import { readServiceApiTokenState, serviceApiTokenFingerprint } from "../lib/service-secrets";
|
|
6
|
+
import { assertDesktop3pModelsValid } from "./desktop-3p-guard";
|
|
7
|
+
import { profilePath } from "./desktop-3p-library";
|
|
8
|
+
import type { Desktop3pConfigMode, Desktop3pModelEntry } from "./desktop-3p";
|
|
9
|
+
import { StoreIO, metadata, storePaths } from "./desktop-remote-store-io";
|
|
10
|
+
import {
|
|
11
|
+
DesktopStoreError, DISCONNECT_PHASES, canonical, origin, parseDisconnect, parseOwner,
|
|
12
|
+
projection, projectionHash, sameOwner, type DesktopDisconnectReceipt, type DesktopRemoteOwner, type DesktopStoreResult,
|
|
13
|
+
} from "./desktop-remote-store-state";
|
|
14
|
+
import {
|
|
15
|
+
artifact, cleanBackup, currentConnection, establish, loadBundle, locateLegacy, profileCredential,
|
|
16
|
+
requireOwner, saveState, selectedFile, serviceGenerations, type Bundle,
|
|
17
|
+
} from "./desktop-remote-store-artifact";
|
|
18
|
+
export type { DesktopDisconnectReceipt, DesktopRemoteOwner, DesktopStoreResult } from "./desktop-remote-store-state";
|
|
19
|
+
|
|
20
|
+
function mutation(held: ClientLifecycleHeld, operation: (io: StoreIO) => DesktopStoreResult): DesktopStoreResult {
|
|
21
|
+
assertClientLifecycleHeld(held);
|
|
22
|
+
const io = new StoreIO();
|
|
23
|
+
try { return withConfigMutationLockSync(() => operation(io)); }
|
|
24
|
+
catch (error) { return { ok: false, changed: io.changed, reason: error instanceof DesktopStoreError ? error.reason : io.changed ? "recovery_required" : "unsafe" }; }
|
|
25
|
+
}
|
|
26
|
+
function absent(io?: StoreIO): DesktopStoreResult { return { ok: true, changed: io?.changed ?? false, status: "absent", restartRequired: false }; }
|
|
27
|
+
function success(io: StoreIO, bundle: Bundle, status: "applied" | "updated" | "restored", extra: Partial<Extract<DesktopStoreResult, { ok: true }>> = {}): DesktopStoreResult {
|
|
28
|
+
return { ok: true, changed: io.changed, status, baselineKind: bundle.state!.baselineKind,
|
|
29
|
+
restartRequired: io.changed, path: profilePath(bundle.paths.library, bundle.state!.targetId),
|
|
30
|
+
fingerprint: bundle.state!.lastProjectionHash, ...extra };
|
|
31
|
+
}
|
|
32
|
+
function receipt(io: StoreIO): DesktopDisconnectReceipt | null {
|
|
33
|
+
const file = io.read(storePaths().disconnect, 64 * 1024, true);
|
|
34
|
+
return file ? parseDisconnect(file.value) : null;
|
|
35
|
+
}
|
|
36
|
+
function noDisconnect(io: StoreIO): void {
|
|
37
|
+
const value = receipt(io);
|
|
38
|
+
if (value && value.phase !== "complete") throw new DesktopStoreError("conflict");
|
|
39
|
+
}
|
|
40
|
+
function knownGeneration(expected: string, candidates: readonly string[]): void {
|
|
41
|
+
if (!candidates.includes(expected)) throw new DesktopStoreError("conflict");
|
|
42
|
+
}
|
|
43
|
+
function recordedGenerations(io: StoreIO, owner: DesktopRemoteOwner): string[] {
|
|
44
|
+
const bundle = loadBundle(io);
|
|
45
|
+
const terminal = receipt(io);
|
|
46
|
+
if (bundle.state?.phase === "cleaned" && terminal?.phase === "complete"
|
|
47
|
+
&& sameOwner(bundle.state.owner, terminal.owner) && !sameOwner(owner, terminal.owner)) return serviceGenerations();
|
|
48
|
+
requireOwner(bundle, owner);
|
|
49
|
+
return [...serviceGenerations(), ...(bundle.state ? [bundle.state.tokenFingerprint] : []),
|
|
50
|
+
...(bundle.state?.pending ? [bundle.state.pending.tokenFingerprint] : [])];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function inspectRemoteDesktopStore(owner: DesktopRemoteOwner): {
|
|
54
|
+
kind: "absent" | "active" | "pending" | "restored" | "legacy_current_connection" | "conflict" | "unsafe";
|
|
55
|
+
} {
|
|
56
|
+
try {
|
|
57
|
+
parseOwner(owner);
|
|
58
|
+
const io = new StoreIO(), bundle = loadBundle(io);
|
|
59
|
+
if (bundle.state?.phase === "cleaned") {
|
|
60
|
+
const terminal = receipt(io);
|
|
61
|
+
if (terminal?.phase === "complete" && sameOwner(bundle.state.owner, terminal.owner) && !sameOwner(owner, terminal.owner)) return { kind: "absent" };
|
|
62
|
+
}
|
|
63
|
+
requireOwner(bundle, owner);
|
|
64
|
+
if (bundle.state) {
|
|
65
|
+
if (bundle.state.phase === "prepared" || bundle.state.pending) return { kind: "pending" };
|
|
66
|
+
if (bundle.state.phase === "cleaned") return { kind: "restored" };
|
|
67
|
+
const meta = metadata(io, bundle.paths.library);
|
|
68
|
+
selectedFile(io, bundle.paths.library, meta.value);
|
|
69
|
+
if (!meta.value.entries.some(e => e.id === bundle.state!.targetId && e.name === "opencodex")) return { kind: "conflict" };
|
|
70
|
+
const file = io.read(profilePath(bundle.paths.library, bundle.state.targetId));
|
|
71
|
+
if (projectionHash(file?.value ?? null) !== bundle.state.lastProjectionHash) return { kind: "conflict" };
|
|
72
|
+
return { kind: bundle.state.phase === "restored" ? "restored" : "active" };
|
|
73
|
+
}
|
|
74
|
+
if (existsSync(bundle.paths.root) && !receipt(io)) throw new DesktopStoreError("unsafe");
|
|
75
|
+
const legacy = locateLegacy(io, owner, serviceGenerations());
|
|
76
|
+
if (legacy) currentConnection(owner);
|
|
77
|
+
return { kind: legacy ? "legacy_current_connection" : "absent" };
|
|
78
|
+
} catch (error) { return { kind: error instanceof DesktopStoreError && error.reason === "conflict" ? "conflict" : "unsafe" }; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function applyRemoteDesktopStore(held: ClientLifecycleHeld, options: {
|
|
82
|
+
owner: DesktopRemoteOwner; expectedTokenFingerprint: string;
|
|
83
|
+
baseUrl: string; apiKey: string; mode: Desktop3pConfigMode; models: Desktop3pModelEntry[];
|
|
84
|
+
}): DesktopStoreResult {
|
|
85
|
+
return mutation(held, io => {
|
|
86
|
+
const { config, client } = currentConnection(options.owner);
|
|
87
|
+
noDisconnect(io);
|
|
88
|
+
if (client.pendingOperation) throw new DesktopStoreError("conflict");
|
|
89
|
+
const token = readServiceApiTokenState();
|
|
90
|
+
if (token.kind !== "present" || token.fingerprint !== options.expectedTokenFingerprint || token.fingerprint !== client.tokenFingerprint
|
|
91
|
+
|| serviceApiTokenFingerprint(options.apiKey) !== token.fingerprint || origin(options.baseUrl) !== options.owner.serverUrl) throw new DesktopStoreError("conflict");
|
|
92
|
+
if (config.clientIntegrations?.["claude-desktop"] === false) throw new DesktopStoreError("desired_disabled");
|
|
93
|
+
if (!options.models.length || !["static", "hybrid", "discovery"].includes(options.mode)) throw new DesktopStoreError("unsafe");
|
|
94
|
+
assertDesktop3pModelsValid(options.models);
|
|
95
|
+
const bundle = establish(io, options.owner, [token.fingerprint, ...serviceGenerations()], false)!;
|
|
96
|
+
const value = {
|
|
97
|
+
inferenceProvider: "gateway", inferenceCredentialKind: "static", inferenceGatewayBaseUrl: options.owner.serverUrl,
|
|
98
|
+
inferenceGatewayApiKey: options.apiKey, modelDiscoveryEnabled: options.mode !== "static",
|
|
99
|
+
...(options.mode === "discovery" ? {} : { inferenceModels: options.models }),
|
|
100
|
+
};
|
|
101
|
+
artifact(io, bundle, value, "apply", token.fingerprint, bundle.state!.targetId);
|
|
102
|
+
cleanBackup(io, bundle, [token.fingerprint, ...serviceGenerations()]);
|
|
103
|
+
return success(io, bundle, "applied");
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function replaceRemoteDesktopCredential(held: ClientLifecycleHeld, options: {
|
|
108
|
+
owner: DesktopRemoteOwner; expectedTokenFingerprint: string; replacementKey: string;
|
|
109
|
+
}): DesktopStoreResult {
|
|
110
|
+
return mutation(held, io => {
|
|
111
|
+
const { config, client } = currentConnection(options.owner);
|
|
112
|
+
noDisconnect(io);
|
|
113
|
+
const token = readServiceApiTokenState();
|
|
114
|
+
const generations = recordedGenerations(io, options.owner);
|
|
115
|
+
const replacement = serviceApiTokenFingerprint(options.replacementKey);
|
|
116
|
+
if (token.kind !== "present" || replacement !== token.fingerprint
|
|
117
|
+
|| (token.fingerprint !== client.tokenFingerprint && !client.pendingOperation)) throw new DesktopStoreError("conflict");
|
|
118
|
+
knownGeneration(options.expectedTokenFingerprint, generations);
|
|
119
|
+
const bundle = establish(io, options.owner, [options.expectedTokenFingerprint, ...generations], true);
|
|
120
|
+
if (!bundle) return absent(io);
|
|
121
|
+
if (bundle.state!.phase === "restored") return restoreArtifact(io, bundle, generations);
|
|
122
|
+
if (config.clientIntegrations?.["claude-desktop"] === false) return restoreArtifact(io, bundle, generations);
|
|
123
|
+
const file = io.read(profilePath(bundle.paths.library, bundle.state!.targetId));
|
|
124
|
+
if (!file) throw new DesktopStoreError("unsafe");
|
|
125
|
+
const credential = profileCredential(file.value);
|
|
126
|
+
if (!credential || credential.origin !== options.owner.serverUrl
|
|
127
|
+
|| ![options.expectedTokenFingerprint, replacement].includes(credential.fingerprint)) throw new DesktopStoreError("conflict");
|
|
128
|
+
artifact(io, bundle, { ...projection(file.value), inferenceGatewayApiKey: options.replacementKey }, "rotate", replacement);
|
|
129
|
+
cleanBackup(io, bundle, [options.expectedTokenFingerprint, ...generations]);
|
|
130
|
+
return success(io, bundle, "updated");
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function restoreArtifact(io: StoreIO, bundle: Bundle, known: readonly string[]): DesktopStoreResult {
|
|
135
|
+
let state = bundle.state!;
|
|
136
|
+
const baseline = bundle.baseline;
|
|
137
|
+
if (!baseline) throw new DesktopStoreError("recovery_required");
|
|
138
|
+
const meta = metadata(io, bundle.paths.library);
|
|
139
|
+
selectedFile(io, bundle.paths.library, meta.value);
|
|
140
|
+
let selection = meta.value.appliedId ?? state.targetId;
|
|
141
|
+
let restoration: "owned_projection" | "standard_fallback" | "selection_preserved" = baseline.kind === "standard_fallback" ? "standard_fallback" : "owned_projection";
|
|
142
|
+
if (selection !== state.targetId) restoration = "selection_preserved";
|
|
143
|
+
else if (baseline.priorSelection && baseline.priorSelection.id !== state.targetId) {
|
|
144
|
+
const priorSelection = baseline.priorSelection;
|
|
145
|
+
const prior = io.read(profilePath(bundle.paths.library, priorSelection.id));
|
|
146
|
+
if (!prior || prior.hash !== priorSelection.hash || !meta.value.entries.some(e => e.id === priorSelection.id)) throw new DesktopStoreError("conflict");
|
|
147
|
+
const key = profileCredential(prior.value);
|
|
148
|
+
if (key && known.includes(key.fingerprint)) throw new DesktopStoreError("conflict");
|
|
149
|
+
selection = priorSelection.id;
|
|
150
|
+
}
|
|
151
|
+
const originalKey = profileCredential(baseline.projection);
|
|
152
|
+
if (originalKey && known.includes(originalKey.fingerprint)) throw new DesktopStoreError("conflict");
|
|
153
|
+
const current = io.read(profilePath(bundle.paths.library, state.targetId));
|
|
154
|
+
const retainedForeignData = current !== null && Object.keys(current.value).length > Object.keys(projection(current.value)).length;
|
|
155
|
+
if (state.pending && state.pending.kind !== "restore") {
|
|
156
|
+
const observed = projectionHash(current?.value ?? null);
|
|
157
|
+
if (observed !== state.pending.before && observed !== state.pending.after) throw new DesktopStoreError("conflict");
|
|
158
|
+
const targetEntry = meta.value.entries.find(e => e.id === state.targetId);
|
|
159
|
+
const uncommittedCreation = !baseline.targetExisted && state.lastProjectionHash === projectionHash(null);
|
|
160
|
+
if ((targetEntry && targetEntry.name !== "opencodex") || (!targetEntry && !uncommittedCreation)) throw new DesktopStoreError("conflict");
|
|
161
|
+
const tokenFingerprint = observed === state.pending.after ? state.pending.tokenFingerprint : state.tokenFingerprint;
|
|
162
|
+
// Transition the intent, not the last committed projection. A profile may
|
|
163
|
+
// already exist while its new metadata row has never been committed.
|
|
164
|
+
saveState(io, bundle, { ...state, tokenFingerprint, pending: {
|
|
165
|
+
kind: "restore", before: observed, after: projectionHash(baseline.projection),
|
|
166
|
+
beforeSelection: meta.value.appliedId ?? null, afterSelection: selection, tokenFingerprint,
|
|
167
|
+
} });
|
|
168
|
+
state = bundle.state!;
|
|
169
|
+
}
|
|
170
|
+
artifact(io, bundle, baseline.projection, "restore", state.tokenFingerprint, selection);
|
|
171
|
+
cleanBackup(io, bundle, [...known, state.tokenFingerprint]);
|
|
172
|
+
return success(io, bundle, "restored", { restoration, retainedForeignData: !baseline.targetExisted && retainedForeignData });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function restoreRemoteDesktopStore(held: ClientLifecycleHeld, options: {
|
|
176
|
+
owner: DesktopRemoteOwner; knownTokenFingerprints: readonly string[];
|
|
177
|
+
}): DesktopStoreResult {
|
|
178
|
+
return mutation(held, io => {
|
|
179
|
+
currentConnection(options.owner);
|
|
180
|
+
const generations = recordedGenerations(io, options.owner);
|
|
181
|
+
if (options.knownTokenFingerprints.some(hash => !generations.includes(hash))) throw new DesktopStoreError("conflict");
|
|
182
|
+
const bundle = establish(io, options.owner, generations, true);
|
|
183
|
+
if (!bundle) return absent(io);
|
|
184
|
+
return restoreArtifact(io, bundle, [...generations, ...options.knownTokenFingerprints]);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function readDesktopDisconnectReceipt():
|
|
189
|
+
| { kind: "absent" } | { kind: "valid"; value: DesktopDisconnectReceipt } | { kind: "unsafe" } {
|
|
190
|
+
try { const value = receipt(new StoreIO()); return value ? { kind: "valid", value } : { kind: "absent" }; }
|
|
191
|
+
catch { return { kind: "unsafe" }; }
|
|
192
|
+
}
|
|
193
|
+
export function writeDesktopDisconnectReceipt(held: ClientLifecycleHeld, expected: DesktopDisconnectReceipt | null, next: DesktopDisconnectReceipt): void {
|
|
194
|
+
assertClientLifecycleHeld(held);
|
|
195
|
+
try {
|
|
196
|
+
withConfigMutationLockSync(() => {
|
|
197
|
+
parseDisconnect(next);
|
|
198
|
+
if (expected) parseDisconnect(expected);
|
|
199
|
+
const io = new StoreIO(), paths = storePaths();
|
|
200
|
+
const file = io.read(paths.disconnect, 64 * 1024, true);
|
|
201
|
+
const current = file ? parseDisconnect(file.value) : null;
|
|
202
|
+
if (canonical(current) !== canonical(expected)) throw new DesktopStoreError("conflict");
|
|
203
|
+
const rollover = current?.phase === "complete" && next.phase === "prepared" && !sameOwner(current.owner, next.owner);
|
|
204
|
+
if (!current || rollover) {
|
|
205
|
+
if (next.phase !== "prepared") throw new DesktopStoreError("conflict");
|
|
206
|
+
const { client } = currentConnection(next.owner);
|
|
207
|
+
const token = readServiceApiTokenState();
|
|
208
|
+
if (client.pendingOperation || token.kind !== "present" || token.fingerprint !== next.tokenFingerprint || client.tokenFingerprint !== token.fingerprint) throw new DesktopStoreError("conflict");
|
|
209
|
+
if (rollover) {
|
|
210
|
+
const bundle = loadBundle(io);
|
|
211
|
+
if (bundle.baselineFile || (bundle.state && (bundle.state.phase !== "cleaned" || !sameOwner(bundle.state.owner, current!.owner)))) throw new DesktopStoreError("conflict");
|
|
212
|
+
if (bundle.stateFile) io.remove(paths.state, bundle.stateFile);
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
if (!sameOwner(current.owner, next.owner) || current.tokenFingerprint !== next.tokenFingerprint || current.keepCatalog !== next.keepCatalog) throw new DesktopStoreError("conflict");
|
|
216
|
+
const delta = DISCONNECT_PHASES.indexOf(next.phase) - DISCONNECT_PHASES.indexOf(current.phase);
|
|
217
|
+
if (delta !== 0 && delta !== 1) throw new DesktopStoreError("conflict");
|
|
218
|
+
if ((current.desktopAfterFingerprint && current.desktopAfterFingerprint !== next.desktopAfterFingerprint)
|
|
219
|
+
|| (current.catalogAfter && canonical(current.catalogAfter) !== canonical(next.catalogAfter))) throw new DesktopStoreError("conflict");
|
|
220
|
+
}
|
|
221
|
+
io.write(paths.disconnect, next, file, 64 * 1024);
|
|
222
|
+
});
|
|
223
|
+
} catch (error) {
|
|
224
|
+
throw new Error(error instanceof DesktopStoreError && error.reason === "conflict"
|
|
225
|
+
? "desktop_disconnect_receipt_conflict" : error instanceof DesktopStoreError && error.reason === "unsafe"
|
|
226
|
+
? "desktop_disconnect_receipt_unsafe" : "desktop_disconnect_receipt_write_failed");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function inspectRemoteDesktopCleanup():
|
|
231
|
+
| { kind: "absent" } | { kind: "active" | "restored" | "pending"; owner: DesktopRemoteOwner } | { kind: "unsafe" } {
|
|
232
|
+
try {
|
|
233
|
+
const io = new StoreIO(), bundle = loadBundle(io), r = receipt(io);
|
|
234
|
+
if (r && bundle.state && !sameOwner(r.owner, bundle.state.owner)) throw new DesktopStoreError("unsafe");
|
|
235
|
+
if (bundle.state) {
|
|
236
|
+
const state = bundle.state;
|
|
237
|
+
if (state.phase === "cleaned" && r?.phase === "complete") {
|
|
238
|
+
return bundle.baselineFile ? { kind: "pending", owner: state.owner } : { kind: "absent" };
|
|
239
|
+
}
|
|
240
|
+
if (state.pending || state.phase === "prepared") return { kind: "pending", owner: state.owner };
|
|
241
|
+
if (r && r.phase !== "complete" && state.phase === "cleaned") return { kind: "pending", owner: state.owner };
|
|
242
|
+
return { kind: state.phase === "restored" || state.phase === "cleaned" ? "restored" : "active", owner: state.owner };
|
|
243
|
+
}
|
|
244
|
+
if (r && r.phase !== "complete") return { kind: "pending", owner: r.owner };
|
|
245
|
+
if (!r && existsSync(bundle.paths.root)) throw new DesktopStoreError("unsafe");
|
|
246
|
+
return { kind: "absent" };
|
|
247
|
+
} catch { return { kind: "unsafe" }; }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function finishRemoteDesktopCleanup(held: ClientLifecycleHeld, owner: DesktopRemoteOwner): DesktopStoreResult {
|
|
251
|
+
return mutation(held, io => {
|
|
252
|
+
const r = receipt(io);
|
|
253
|
+
if (!r || !sameOwner(r.owner, owner) || !["connection_cleared", "complete"].includes(r.phase)) throw new DesktopStoreError("conflict");
|
|
254
|
+
const diagnostics = readConfigDiagnostics();
|
|
255
|
+
if (diagnostics.source === "fallback" || diagnostics.config.client || diagnostics.config.runtimeRole === "client"
|
|
256
|
+
|| readServiceApiTokenState().kind !== "absent") throw new DesktopStoreError("conflict");
|
|
257
|
+
const bundle = loadBundle(io);
|
|
258
|
+
requireOwner(bundle, owner);
|
|
259
|
+
if (!bundle.state) return absent(io);
|
|
260
|
+
if (bundle.state.phase !== "restored" && bundle.state.phase !== "cleaned") throw new DesktopStoreError("conflict");
|
|
261
|
+
const target = io.read(profilePath(bundle.paths.library, bundle.state.targetId));
|
|
262
|
+
if (projectionHash(target?.value ?? null) !== bundle.state.lastProjectionHash) throw new DesktopStoreError("conflict");
|
|
263
|
+
cleanBackup(io, bundle, [r.tokenFingerprint, bundle.state.tokenFingerprint]);
|
|
264
|
+
// Mark cleanup before unlink so a crash between the two remains recoverable.
|
|
265
|
+
if (bundle.state.phase !== "cleaned") saveState(io, bundle, { ...bundle.state, phase: "cleaned" });
|
|
266
|
+
if (bundle.baselineFile) io.remove(bundle.paths.baseline, bundle.baselineFile);
|
|
267
|
+
return success(io, bundle, "restored", { restartRequired: false });
|
|
268
|
+
});
|
|
269
|
+
}
|
|
@@ -2,8 +2,9 @@ import type { OcxClaudeCodeConfig } from "../types";
|
|
|
2
2
|
import { isAnthropicOutputSchema } from "../adapters/anthropic-output-schema";
|
|
3
3
|
import { resolveAlias } from "./alias";
|
|
4
4
|
import { stripOneMillionMarker } from "./context-windows";
|
|
5
|
-
import { resolveDesktop3pAlias } from "./desktop-3p";
|
|
6
|
-
import {
|
|
5
|
+
import { isUnresolvedDesktop3pAlias, resolveDesktop3pAlias } from "./desktop-3p";
|
|
6
|
+
import { validDateAlias } from "./desktop-profile";
|
|
7
|
+
import { AnthropicRequestError, DesktopModelMappingUnavailableError, isRec, type Rec } from "./inbound-records";
|
|
7
8
|
|
|
8
9
|
function isClaudeClassifierModel(model: string): boolean {
|
|
9
10
|
const stripped = model.replace(/-\d{8}$/, "");
|
|
@@ -54,6 +55,13 @@ export function resolveInboundModel(model: string, cc?: OcxClaudeCodeConfig): st
|
|
|
54
55
|
const map = cc?.modelMap ?? {};
|
|
55
56
|
const exact = map[model];
|
|
56
57
|
if (typeof exact === "string" && exact.length > 0) return exact;
|
|
58
|
+
if (isUnresolvedDesktop3pAlias(model)) {
|
|
59
|
+
const base = model.endsWith("--fast") ? model.slice(0, -"--fast".length) : model;
|
|
60
|
+
// A missing date-shaped ID is ambiguous even after a successful but partial
|
|
61
|
+
// discovery. Never infer that a genuine native model is invalid or reroute it.
|
|
62
|
+
if (validDateAlias(base)) throw new DesktopModelMappingUnavailableError();
|
|
63
|
+
throw new AnthropicRequestError("Unknown Claude Desktop alias; reapply the Desktop profile from the connected hub");
|
|
64
|
+
}
|
|
57
65
|
const stripped = model.replace(/-\d{8}$/, "");
|
|
58
66
|
const dateless = map[stripped];
|
|
59
67
|
if (typeof dateless === "string" && dateless.length > 0) return dateless;
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export class AnthropicRequestError extends Error {}
|
|
2
2
|
|
|
3
|
+
/** A date-shaped Desktop ID can also name a genuine native model absent from discovery. */
|
|
4
|
+
export class DesktopModelMappingUnavailableError extends AnthropicRequestError {
|
|
5
|
+
constructor() {
|
|
6
|
+
super("Claude Desktop model mapping is unavailable; refresh model discovery or reapply the connected hub profile");
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
export type Rec = Record<string, unknown>;
|
|
4
11
|
|
|
5
12
|
export function isRec(v: unknown): v is Rec {
|
package/src/claude/inbound.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import type { OcxClaudeCodeConfig } from "../types";
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
14
|
|
|
15
|
-
export { AnthropicRequestError } from "./inbound-records";
|
|
15
|
+
export { AnthropicRequestError, DesktopModelMappingUnavailableError } from "./inbound-records";
|
|
16
16
|
export { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, extractOcxRouteDirective, extractOcxEffortDirective } from "./inbound-model-options";
|
|
17
17
|
import { AnthropicRequestError, isRec, type Rec } from "./inbound-records";
|
|
18
18
|
import { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, formatFromOutputConfig } from "./inbound-model-options";
|
package/src/claude/model-info.ts
CHANGED
|
@@ -143,14 +143,19 @@ export function buildAnthropicModelInfos(
|
|
|
143
143
|
// the auto-context widening that let a 372K route carry the marker (and be
|
|
144
144
|
// over-filled) is the #854 defect and does not come back. Guards (audit R1#11):
|
|
145
145
|
// same dedupe set, never double-suffix.
|
|
146
|
-
const push1mVariant = (
|
|
146
|
+
const push1mVariant = (
|
|
147
|
+
base: AnthropicModelInfo,
|
|
148
|
+
contextWindow: number | undefined,
|
|
149
|
+
maxInputTokens?: number,
|
|
150
|
+
selectorId?: string,
|
|
151
|
+
) => {
|
|
147
152
|
// The [1m] marker makes Claude Code account 1e6 tokens for the row, so it
|
|
148
153
|
// may only name models whose AUTHORITATIVE effective window is >= 1M —
|
|
149
154
|
// never the auto-context widening, which would mark a 372K route and have
|
|
150
155
|
// Claude Code over-fill it (the #854 defect).
|
|
151
156
|
if (contextWindow === undefined || contextWindow < ONE_MILLION) return;
|
|
152
157
|
if (base.id.includes("[1m]")) return;
|
|
153
|
-
const id = `${base.id}[1m]`;
|
|
158
|
+
const id = selectorId ?? `${base.id}[1m]`;
|
|
154
159
|
if (seen.has(id)) return;
|
|
155
160
|
seen.add(id);
|
|
156
161
|
// The marker fixes Claude Code's accounting at 1e6, but a model may accept less input
|
|
@@ -220,7 +225,15 @@ export function buildAnthropicModelInfos(
|
|
|
220
225
|
out.push(info);
|
|
221
226
|
// Anthropic passthrough guard (audit 021 #3): never auto-widen canonical claude
|
|
222
227
|
// routes — only a genuine >=1M window earns the variant row there.
|
|
223
|
-
|
|
228
|
+
// Claude Code groups canonical Fable ids before it compares the [1m] marker. This
|
|
229
|
+
// reversible alias only separates picker families; it is not an OpenAI-native route.
|
|
230
|
+
// The Messages ingress restores the canonical Anthropic id before passthrough.
|
|
231
|
+
const oneMillionSelector = idStyle === "readable"
|
|
232
|
+
&& m.provider === "anthropic"
|
|
233
|
+
&& listedModelId.startsWith("claude-fable-")
|
|
234
|
+
? `${claudeCodeNativeAlias(listedModelId)}[1m]`
|
|
235
|
+
: undefined;
|
|
236
|
+
push1mVariant(info, m.contextWindow, routedMaxInput, oneMillionSelector);
|
|
224
237
|
// The whole model is passed, not a (provider, id) pair: a combo row lives in its own
|
|
225
238
|
// namespace with no config.providers entry, so the caller classifies it from the
|
|
226
239
|
// aggregated supportsServiceTier the row already carries.
|
package/src/cli/account-api.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { runningProxyUpdateHeaders } from "../oauth/login-cli";
|
|
|
8
8
|
import { isPublicOAuthProvider } from "../oauth/index";
|
|
9
9
|
import { getProviderRegistryEntry, providerCodexAccountMode } from "../providers/registry";
|
|
10
10
|
import type { OcxConfig } from "../types";
|
|
11
|
+
import { projectCodexQuotaRefreshOutcome, type CodexQuotaRefreshOutcome } from "../codex/quota-refresh-outcome";
|
|
11
12
|
|
|
12
13
|
export type AccountType = "codex" | "oauth" | "api-key";
|
|
13
14
|
|
|
@@ -24,6 +25,7 @@ export interface AccountRow {
|
|
|
24
25
|
/** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */
|
|
25
26
|
priority?: number;
|
|
26
27
|
quota?: CodexQuotaDto | null;
|
|
28
|
+
quotaRefresh?: CodexQuotaRefreshOutcome;
|
|
27
29
|
/**
|
|
28
30
|
* Whether the pool is holding this account out of rotation.
|
|
29
31
|
*
|
|
@@ -237,6 +239,7 @@ interface CodexAccountDto {
|
|
|
237
239
|
needsReauth?: boolean;
|
|
238
240
|
priority?: number;
|
|
239
241
|
quota?: CodexQuotaDto | null;
|
|
242
|
+
quotaRefresh?: unknown;
|
|
240
243
|
paused?: boolean;
|
|
241
244
|
}
|
|
242
245
|
|
|
@@ -299,7 +302,10 @@ export async function fetchCodexRows(
|
|
|
299
302
|
needsReauth: a.needsReauth,
|
|
300
303
|
priority: typeof a.priority === "number" ? a.priority : 0,
|
|
301
304
|
paused: a.paused === true,
|
|
302
|
-
...(includeQuota ? {
|
|
305
|
+
...(includeQuota ? {
|
|
306
|
+
quota: projectQuota(a.quota),
|
|
307
|
+
quotaRefresh: projectCodexQuotaRefreshOutcome(a.quotaRefresh),
|
|
308
|
+
} : {}),
|
|
303
309
|
}));
|
|
304
310
|
return { rows, activeId, autoSwitchThreshold, status: 200 };
|
|
305
311
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OwnedIntegrationRefreshOutcome } from "../integrations/owned-refresh";
|
|
2
|
+
import { runtimeRequest, RuntimeApiError, type RuntimeApiDeps } from "./runtime-api";
|
|
3
|
+
|
|
4
|
+
/** Aside policy and file writes share the running server's mutation owner. Never fall back locally. */
|
|
5
|
+
export async function refreshAsideProfilesThroughServer(
|
|
6
|
+
deps: RuntimeApiDeps = {},
|
|
7
|
+
): Promise<OwnedIntegrationRefreshOutcome[]> {
|
|
8
|
+
const result = await runtimeRequest<{ results?: OwnedIntegrationRefreshOutcome[] }>(
|
|
9
|
+
"/api/client-integrations/aside/sync",
|
|
10
|
+
{ method: "POST", body: "{}" },
|
|
11
|
+
deps,
|
|
12
|
+
);
|
|
13
|
+
if (!Array.isArray(result.results)) {
|
|
14
|
+
throw new RuntimeApiError("The running proxy does not support Aside profile synchronization", 502, result);
|
|
15
|
+
}
|
|
16
|
+
return result.results;
|
|
17
|
+
}
|