@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/claude/desktop-3p.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
|
+
import { readClientConnectionState } from "../client/state";
|
|
2
|
+
import { readServiceApiTokenState, readTokenBackupState } from "../lib/service-secrets";
|
|
3
|
+
import { withClientLifecycleSync, type ClientLifecycleLockDeps } from "../client/lifecycle-lock";
|
|
4
|
+
import { applyRemoteDesktopStore, inspectRemoteDesktopCleanup, restoreRemoteDesktopStore } from "./desktop-remote-store";
|
|
5
|
+
import { canonicalDirectory } from "./desktop-remote-store-io";
|
|
6
|
+
import {
|
|
7
|
+
resolveDesktop3pConfigLibraryPath, parseMetadata, SAFE_DESKTOP_PROFILE_ID, isRecord,
|
|
8
|
+
isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, profilePath, readDesktopProfileForeignKeys,
|
|
9
|
+
type Desktop3pConfigLibraryOptions, type Desktop3pMetadata, type Desktop3pMetadataEntry,
|
|
10
|
+
} from "./desktop-3p-library";
|
|
11
|
+
export { resolveDesktop3pConfigLibraryPath, type Desktop3pConfigLibraryOptions } from "./desktop-3p-library";
|
|
1
12
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
13
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
14
|
import { join } from "node:path";
|
|
5
|
-
import { atomicWriteFile } from "../config";
|
|
15
|
+
import { atomicWriteFile, readConfigDiagnostics, withConfigMutationLockSync } from "../config";
|
|
16
|
+
import { claudeDesktopIntegrationEnabled } from "../codex/desired-state";
|
|
6
17
|
import type { OcxClaudeDesktopProfile } from "../types";
|
|
7
|
-
import { claudeDesktopConfigLibraryDir, resolveConfigLibraryDir } from "./desktop-3p-paths";
|
|
8
18
|
import {
|
|
9
19
|
reconcileDesktopProfile,
|
|
10
20
|
renderDesktopProfile,
|
|
21
|
+
validDateAlias,
|
|
11
22
|
type DesktopProfileModel,
|
|
12
23
|
} from "./desktop-profile";
|
|
13
24
|
import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../codex/catalog";
|
|
@@ -50,33 +61,6 @@ export interface Desktop3pRoutedModel {
|
|
|
50
61
|
*/
|
|
51
62
|
export const DESKTOP_SUPPORTS_1M_THRESHOLD = 1_000_000;
|
|
52
63
|
|
|
53
|
-
export interface Desktop3pConfigLibraryOptions {
|
|
54
|
-
env?: NodeJS.ProcessEnv;
|
|
55
|
-
platform?: NodeJS.Platform;
|
|
56
|
-
homeDir?: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Resolve the config library from the same user-data root Claude Desktop uses. Keeping this in one
|
|
61
|
-
* helper prevents the writer and dashboard status probe from agreeing on a path Desktop never reads.
|
|
62
|
-
*
|
|
63
|
-
* The resolution itself lives in `./desktop-3p-paths`, which ports Claude Desktop's own `GE()`
|
|
64
|
-
* branch for branch — including the `-3p` suffix the app appends to its userData root. Dropping
|
|
65
|
-
* that suffix points us at a directory Desktop never reads (GitHub #539).
|
|
66
|
-
*/
|
|
67
|
-
export function resolveDesktop3pConfigLibraryPath(
|
|
68
|
-
options: Desktop3pConfigLibraryOptions = {},
|
|
69
|
-
): string {
|
|
70
|
-
if (options.env === undefined && options.platform === undefined && options.homeDir === undefined) {
|
|
71
|
-
return claudeDesktopConfigLibraryDir();
|
|
72
|
-
}
|
|
73
|
-
return resolveConfigLibraryDir({
|
|
74
|
-
env: options.env ?? process.env,
|
|
75
|
-
platform: options.platform ?? process.platform,
|
|
76
|
-
home: options.homeDir ?? homedir(),
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
64
|
/** CLI arg parsing for `ocx claude desktop` mode flags (mutually exclusive). */
|
|
81
65
|
export function parseDesktop3pModeArgs(flags: string[]): { mode: Desktop3pConfigMode } | { error: string } {
|
|
82
66
|
const known = new Map<string, Desktop3pConfigMode>([
|
|
@@ -91,18 +75,6 @@ export function parseDesktop3pModeArgs(flags: string[]): { mode: Desktop3pConfig
|
|
|
91
75
|
return { mode: picked[0] ?? "static" };
|
|
92
76
|
}
|
|
93
77
|
|
|
94
|
-
interface Desktop3pMetadataEntry {
|
|
95
|
-
id: string;
|
|
96
|
-
name: string;
|
|
97
|
-
[key: string]: unknown;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
interface Desktop3pMetadata {
|
|
101
|
-
appliedId?: string;
|
|
102
|
-
entries: Desktop3pMetadataEntry[];
|
|
103
|
-
[key: string]: unknown;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
78
|
export type Desktop3pLibraryKind =
|
|
107
79
|
| "not_installed"
|
|
108
80
|
| "standard"
|
|
@@ -144,6 +116,7 @@ export interface Desktop3pRemovalResult {
|
|
|
144
116
|
|
|
145
117
|
let desktop3pRegistry = new Map<string, string>();
|
|
146
118
|
let desktop3pAliasesByRoute = new Map<string, string>();
|
|
119
|
+
let desktop3pRealAnthropicIds = new Set<string>();
|
|
147
120
|
|
|
148
121
|
/** Derive a stable letter-first, three-character base36 code from a route key. */
|
|
149
122
|
export function deriveDesktop3pCode(route: string): string {
|
|
@@ -192,7 +165,7 @@ function collectDesktop3pModels(
|
|
|
192
165
|
routedModels: Array<Desktop3pRoutedModel>,
|
|
193
166
|
profile?: OcxClaudeDesktopProfile,
|
|
194
167
|
nativeContextCap?: NativeContextLimitsInput,
|
|
195
|
-
): { models: Desktop3pModelEntry[]; registry: Map<string, string> } {
|
|
168
|
+
): { models: Desktop3pModelEntry[]; registry: Map<string, string>; realAnthropicIds: Set<string> } {
|
|
196
169
|
const registry = new Map<string, string>();
|
|
197
170
|
const models: Desktop3pModelEntry[] = [];
|
|
198
171
|
const candidates: Desktop3pRoutedModel[] = [
|
|
@@ -205,6 +178,9 @@ function collectDesktop3pModels(
|
|
|
205
178
|
}),
|
|
206
179
|
...routedModels,
|
|
207
180
|
];
|
|
181
|
+
const realAnthropicIds = new Set(candidates
|
|
182
|
+
.filter(model => model.provider === "anthropic" && model.id.startsWith("claude-"))
|
|
183
|
+
.map(model => model.id));
|
|
208
184
|
|
|
209
185
|
if (profile) {
|
|
210
186
|
const profileModels = candidates.map(({ provider, id, contextWindow }) => ({
|
|
@@ -242,7 +218,7 @@ function collectDesktop3pModels(
|
|
|
242
218
|
registry.set(legacy, model.route);
|
|
243
219
|
}
|
|
244
220
|
desktop3pAliasesByRoute = aliasesByRoute;
|
|
245
|
-
return { models, registry };
|
|
221
|
+
return { models, registry, realAnthropicIds };
|
|
246
222
|
}
|
|
247
223
|
|
|
248
224
|
for (const { provider, id, contextWindow } of candidates) {
|
|
@@ -285,7 +261,7 @@ function collectDesktop3pModels(
|
|
|
285
261
|
|
|
286
262
|
if (models[0]) models[0].isFamilyDefault = true;
|
|
287
263
|
desktop3pAliasesByRoute = new Map(candidates.map(({ provider, id }) => [`${provider}/${id}`, desktop3pAlias(provider, id)]));
|
|
288
|
-
return { models, registry };
|
|
264
|
+
return { models, registry, realAnthropicIds };
|
|
289
265
|
}
|
|
290
266
|
|
|
291
267
|
/** Build and install the registry used to decode Desktop aliases. */
|
|
@@ -295,8 +271,9 @@ export function buildDesktop3pRegistry(
|
|
|
295
271
|
profile?: OcxClaudeDesktopProfile,
|
|
296
272
|
nativeContextCap?: NativeContextLimitsInput,
|
|
297
273
|
): Map<string, string> {
|
|
298
|
-
const { registry } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap);
|
|
274
|
+
const { registry, realAnthropicIds } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap);
|
|
299
275
|
desktop3pRegistry = registry;
|
|
276
|
+
desktop3pRealAnthropicIds = realAnthropicIds;
|
|
300
277
|
return registry;
|
|
301
278
|
}
|
|
302
279
|
|
|
@@ -307,8 +284,9 @@ export function generateDesktop3pModels(
|
|
|
307
284
|
profile?: OcxClaudeDesktopProfile,
|
|
308
285
|
nativeContextCap?: NativeContextLimitsInput,
|
|
309
286
|
): Desktop3pModelEntry[] {
|
|
310
|
-
const { models, registry } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap);
|
|
287
|
+
const { models, registry, realAnthropicIds } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap);
|
|
311
288
|
desktop3pRegistry = registry;
|
|
289
|
+
desktop3pRealAnthropicIds = realAnthropicIds;
|
|
312
290
|
return models;
|
|
313
291
|
}
|
|
314
292
|
|
|
@@ -317,6 +295,21 @@ export function resolveDesktop3pAlias(alias: string): string | null {
|
|
|
317
295
|
return desktop3pRegistry.get(alias) ?? null;
|
|
318
296
|
}
|
|
319
297
|
|
|
298
|
+
/** Exact registered and identity-preserving catalog IDs precede synthetic syntax. */
|
|
299
|
+
export function isKnownDesktop3pModelId(id: string): boolean {
|
|
300
|
+
return desktop3pRegistry.has(id) || desktop3pRealAnthropicIds.has(id);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Only missing IDs in the emitted Desktop namespaces are managed-alias errors. */
|
|
304
|
+
export function isUnresolvedDesktop3pAlias(id: string): boolean {
|
|
305
|
+
if (isKnownDesktop3pModelId(id)) return false;
|
|
306
|
+
// Validate the managed base even when Fast routing is disabled. This checks
|
|
307
|
+
// identity only; it neither enables a tier nor strips an exact full catalog ID.
|
|
308
|
+
const base = id.endsWith("--fast") ? id.slice(0, -"--fast".length) : id;
|
|
309
|
+
if (isKnownDesktop3pModelId(base)) return false;
|
|
310
|
+
return validDateAlias(base) || /^claude-opus-4-(?:8-)?[a-z][a-z0-9]{2}$/.test(base);
|
|
311
|
+
}
|
|
312
|
+
|
|
320
313
|
/** Alias selected by the installed profile registry, falling back to the legacy hash shape. */
|
|
321
314
|
export function activeDesktop3pAlias(provider: string, modelId: string): string {
|
|
322
315
|
return desktop3pAliasesByRoute.get(`${provider}/${modelId}`) ?? desktop3pAlias(provider, modelId);
|
|
@@ -363,32 +356,6 @@ export function generateDesktop3pConfig(
|
|
|
363
356
|
};
|
|
364
357
|
}
|
|
365
358
|
|
|
366
|
-
function parseMetadata(path: string): Desktop3pMetadata {
|
|
367
|
-
if (!existsSync(path)) return { entries: [] };
|
|
368
|
-
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<Desktop3pMetadata>;
|
|
369
|
-
if (!Array.isArray(parsed.entries)) throw new Error("Claude Desktop 3P _meta.json has no entries array");
|
|
370
|
-
return { ...parsed, entries: parsed.entries };
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
const SAFE_DESKTOP_PROFILE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
374
|
-
|
|
375
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
376
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
function isOwnedDesktopEntry(entry: Desktop3pMetadataEntry | undefined): boolean {
|
|
380
|
-
return entry?.name === "opencodex" || entry?.name === "opencodex-standard";
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/** A gateway row is removable; the selected standard row must always remain. */
|
|
384
|
-
function isOwnedDesktopGatewayEntry(entry: Desktop3pMetadataEntry | undefined): boolean {
|
|
385
|
-
return entry?.name === "opencodex";
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function profilePath(libraryPath: string, id: string): string {
|
|
389
|
-
return join(libraryPath, `${id}.json`);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
359
|
/**
|
|
393
360
|
* Read Desktop's selected config without changing its library.
|
|
394
361
|
*
|
|
@@ -481,7 +448,76 @@ export function inspectDesktop3pConfigLibrary(
|
|
|
481
448
|
* (or any other benign mismatch), while the Integrations card still showed the
|
|
482
449
|
* leftover profile as applied/stale.
|
|
483
450
|
*/
|
|
451
|
+
/** Keep failures diagnosable without reflecting paths, config bytes or arbitrary errors. */
|
|
452
|
+
function desktopMutationFailureReason(error: unknown): string {
|
|
453
|
+
const code = error !== null && typeof error === "object" && "code" in error ? error.code : undefined;
|
|
454
|
+
switch (code) {
|
|
455
|
+
case "client_lifecycle_busy": return "client_lifecycle_busy";
|
|
456
|
+
case "client_lifecycle_lock_failed": return "client_lifecycle_lock_failed";
|
|
457
|
+
case "CONFIG_MUTATION_LOCK_UNAVAILABLE": return "config_mutation_lock_unavailable";
|
|
458
|
+
case "EACCES":
|
|
459
|
+
case "EPERM": return "desktop_filesystem_denied";
|
|
460
|
+
case "ENOENT": return "desktop_path_missing";
|
|
461
|
+
case "EBUSY": return "desktop_filesystem_busy";
|
|
462
|
+
default: return "desktop_mutation_failed";
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
484
466
|
export function removeDesktop3pStandardPivot(
|
|
467
|
+
options: Desktop3pConfigLibraryOptions & {
|
|
468
|
+
appliedFingerprint?: string | null; unlink?: (path: string) => void;
|
|
469
|
+
lifecycleLockDeps?: ClientLifecycleLockDeps;
|
|
470
|
+
} = {},
|
|
471
|
+
): Desktop3pRemovalResult {
|
|
472
|
+
const libraryPath = resolveDesktop3pConfigLibraryPath(options);
|
|
473
|
+
try {
|
|
474
|
+
return withClientLifecycleSync<Desktop3pRemovalResult>(held => withConfigMutationLockSync(() => {
|
|
475
|
+
const connection = readClientConnectionState();
|
|
476
|
+
if (connection.kind === "invalid" || connection.kind === "mismatched") {
|
|
477
|
+
return { ok: false, changed: false, kind: "unsafe", libraryPath, reason: `desktop_client_state_${connection.kind}` };
|
|
478
|
+
}
|
|
479
|
+
if (connection.kind === "connected"
|
|
480
|
+
&& canonicalDirectory(libraryPath) !== canonicalDirectory(resolveDesktop3pConfigLibraryPath())) {
|
|
481
|
+
return { ok: false, changed: false, kind: "unsafe", libraryPath, reason: "desktop_library_identity_changed" };
|
|
482
|
+
}
|
|
483
|
+
const latest = readConfigDiagnostics();
|
|
484
|
+
if (latest.source === "fallback") return { ok: false, changed: false, kind: "unsafe", libraryPath, reason: "desktop_config_invalid" };
|
|
485
|
+
if (claudeDesktopIntegrationEnabled(latest.config)) {
|
|
486
|
+
const observed = inspectDesktop3pConfigLibrary(options);
|
|
487
|
+
if (observed.kind === "not_installed" || observed.kind === "no_owned_state") {
|
|
488
|
+
return { ok: true, changed: false, kind: "noop", libraryPath };
|
|
489
|
+
}
|
|
490
|
+
return { ok: false, changed: false, kind: "unsafe", libraryPath, reason: "desired_state_changed" };
|
|
491
|
+
}
|
|
492
|
+
const cleanup = inspectRemoteDesktopCleanup();
|
|
493
|
+
if (cleanup.kind === "absent") {
|
|
494
|
+
if (connection.kind === "disconnected") return removeDesktop3pStandardPivotLocal(options);
|
|
495
|
+
const client = connection.value;
|
|
496
|
+
const known = [readServiceApiTokenState(), readTokenBackupState()].flatMap(token => token.kind === "present" ? [token.fingerprint] : []);
|
|
497
|
+
const result = restoreRemoteDesktopStore(held, {
|
|
498
|
+
owner: { serverUrl: new URL(client.serverUrl).origin, apiKeyId: client.apiKeyId, connectedAt: client.connectedAt },
|
|
499
|
+
knownTokenFingerprints: known,
|
|
500
|
+
});
|
|
501
|
+
return result.ok
|
|
502
|
+
? { ok: true, changed: result.changed, kind: result.changed ? "removed" : "noop", libraryPath }
|
|
503
|
+
: { ok: false, changed: result.changed, kind: "unsafe", reason: result.reason, libraryPath };
|
|
504
|
+
}
|
|
505
|
+
if (cleanup.kind === "unsafe" || canonicalDirectory(libraryPath) !== canonicalDirectory(resolveDesktop3pConfigLibraryPath())) {
|
|
506
|
+
return { ok: false, changed: false, kind: "unsafe", libraryPath };
|
|
507
|
+
}
|
|
508
|
+
const known = [readServiceApiTokenState(), readTokenBackupState()]
|
|
509
|
+
.flatMap(token => token.kind === "present" ? [token.fingerprint] : []);
|
|
510
|
+
const result = restoreRemoteDesktopStore(held, { owner: cleanup.owner, knownTokenFingerprints: known });
|
|
511
|
+
return result.ok
|
|
512
|
+
? { ok: true, changed: result.changed, kind: result.changed ? "removed" : "noop", libraryPath }
|
|
513
|
+
: { ok: false, changed: result.changed, kind: result.reason === "cleanup_pending" ? "cleanup_incomplete" : "unsafe", reason: result.reason, libraryPath };
|
|
514
|
+
}), options.lifecycleLockDeps);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
return { ok: false, changed: false, kind: "write_failed", libraryPath, reason: desktopMutationFailureReason(error) };
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function removeDesktop3pStandardPivotLocal(
|
|
485
521
|
options: Desktop3pConfigLibraryOptions & {
|
|
486
522
|
appliedFingerprint?: string | null;
|
|
487
523
|
unlink?: (path: string) => void;
|
|
@@ -554,8 +590,8 @@ export function removeDesktop3pStandardPivot(
|
|
|
554
590
|
JSON.stringify({ ...metadataAfterPivot, entries: metadataAfterPivot.entries.filter(entry => !targetIds.includes(entry.id)) }, null, 2) + "\n",
|
|
555
591
|
);
|
|
556
592
|
return { ok: true, changed: true, kind: "removed", libraryPath: inspected.libraryPath };
|
|
557
|
-
} catch {
|
|
558
|
-
return { ok: false, changed: false, kind: "write_failed", libraryPath: inspected.libraryPath };
|
|
593
|
+
} catch (error) {
|
|
594
|
+
return { ok: false, changed: false, kind: "write_failed", libraryPath: inspected.libraryPath, reason: desktopMutationFailureReason(error) };
|
|
559
595
|
}
|
|
560
596
|
}
|
|
561
597
|
|
|
@@ -568,6 +604,59 @@ export function writeDesktop3pConfig(
|
|
|
568
604
|
mode: Desktop3pConfigMode = "static",
|
|
569
605
|
profile?: OcxClaudeDesktopProfile,
|
|
570
606
|
nativeContextCap?: NativeContextLimitsInput,
|
|
607
|
+
lifecycleLockDeps?: ClientLifecycleLockDeps,
|
|
608
|
+
): { written: boolean; path: string; reason?: string; fingerprint?: string } {
|
|
609
|
+
try {
|
|
610
|
+
return withClientLifecycleSync(() => withConfigMutationLockSync(() => {
|
|
611
|
+
const connection = readClientConnectionState();
|
|
612
|
+
if (connection.kind === "invalid" || connection.kind === "mismatched") {
|
|
613
|
+
return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: `desktop_client_state_${connection.kind}` };
|
|
614
|
+
}
|
|
615
|
+
const latest = readConfigDiagnostics();
|
|
616
|
+
if (latest.source === "fallback") return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_config_invalid" };
|
|
617
|
+
if (!claudeDesktopIntegrationEnabled(latest.config)) {
|
|
618
|
+
return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desired_state_changed" };
|
|
619
|
+
}
|
|
620
|
+
if (connection.kind === "connected" || inspectRemoteDesktopCleanup().kind !== "absent") {
|
|
621
|
+
return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_remote_store_active" };
|
|
622
|
+
}
|
|
623
|
+
return writeDesktop3pConfigWithGenerator(() => (
|
|
624
|
+
generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode, profile, nativeContextCap)
|
|
625
|
+
));
|
|
626
|
+
}), lifecycleLockDeps);
|
|
627
|
+
} catch { return { written: false, path: resolveDesktop3pConfigLibraryPath(), reason: "desktop_lifecycle_busy_or_unsafe" }; }
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Write the hub's exact entries without constructing a client-local alias registry. */
|
|
631
|
+
export function writeRemoteDesktop3pConfig(options: {
|
|
632
|
+
baseUrl: string;
|
|
633
|
+
apiKey: string;
|
|
634
|
+
mode: Desktop3pConfigMode;
|
|
635
|
+
models: Desktop3pModelEntry[];
|
|
636
|
+
lifecycleLockDeps?: ClientLifecycleLockDeps;
|
|
637
|
+
}): { written: boolean; path: string; reason?: string; fingerprint?: string } {
|
|
638
|
+
try {
|
|
639
|
+
return withClientLifecycleSync(held => {
|
|
640
|
+
const connection = readClientConnectionState();
|
|
641
|
+
const token = readServiceApiTokenState();
|
|
642
|
+
if (connection.kind !== "connected" || token.kind !== "present") {
|
|
643
|
+
return { written: false, path: "", reason: "desktop_remote_connection_required" };
|
|
644
|
+
}
|
|
645
|
+
const client = connection.value;
|
|
646
|
+
const result = applyRemoteDesktopStore(held, {
|
|
647
|
+
baseUrl: options.baseUrl, apiKey: options.apiKey, mode: options.mode, models: options.models,
|
|
648
|
+
owner: { serverUrl: new URL(client.serverUrl).origin, apiKeyId: client.apiKeyId, connectedAt: client.connectedAt },
|
|
649
|
+
expectedTokenFingerprint: token.fingerprint,
|
|
650
|
+
});
|
|
651
|
+
return result.ok
|
|
652
|
+
? { written: result.status === "applied", path: result.path ?? "", fingerprint: result.fingerprint }
|
|
653
|
+
: { written: false, path: "", reason: result.reason };
|
|
654
|
+
}, options.lifecycleLockDeps);
|
|
655
|
+
} catch { return { written: false, path: "", reason: "desktop_lifecycle_busy_or_unsafe" }; }
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function writeDesktop3pConfigWithGenerator(
|
|
659
|
+
generate: () => object,
|
|
571
660
|
): { written: boolean; path: string; reason?: string; fingerprint?: string } {
|
|
572
661
|
const libraryPath = resolveDesktop3pConfigLibraryPath();
|
|
573
662
|
const metadataPath = join(libraryPath, "_meta.json");
|
|
@@ -579,13 +668,13 @@ export function writeDesktop3pConfig(
|
|
|
579
668
|
const selected = metadata.entries.find(entry => entry?.id === metadata.appliedId && isOwnedDesktopGatewayEntry(entry));
|
|
580
669
|
const existing = selected ?? metadata.entries.find(entry => isOwnedDesktopGatewayEntry(entry) && typeof entry.id === "string");
|
|
581
670
|
const id = existing?.id ?? randomUUID();
|
|
582
|
-
configPath =
|
|
671
|
+
configPath = profilePath(libraryPath, id);
|
|
583
672
|
const entry: Desktop3pMetadataEntry = existing ? { ...existing, id, name: "opencodex" } : { id, name: "opencodex" };
|
|
584
673
|
const entries = existing
|
|
585
674
|
? metadata.entries.map(current => current === existing ? entry : current)
|
|
586
675
|
: [...metadata.entries, entry];
|
|
587
676
|
|
|
588
|
-
const generated =
|
|
677
|
+
const generated = generate();
|
|
589
678
|
const preserved = readDesktopProfileForeignKeys(configPath);
|
|
590
679
|
const configJson = JSON.stringify({ ...preserved, ...generated }, null, 2) + "\n";
|
|
591
680
|
const fingerprint = createHash("sha256").update(configJson).digest("hex").slice(0, 16);
|
|
@@ -604,24 +693,6 @@ export function writeDesktop3pConfig(
|
|
|
604
693
|
}
|
|
605
694
|
}
|
|
606
695
|
|
|
607
|
-
const OPENCODEX_DESKTOP_PROFILE_KEYS = new Set([
|
|
608
|
-
"inferenceProvider",
|
|
609
|
-
"inferenceCredentialKind",
|
|
610
|
-
"inferenceGatewayBaseUrl",
|
|
611
|
-
"inferenceGatewayApiKey",
|
|
612
|
-
"modelDiscoveryEnabled",
|
|
613
|
-
"inferenceModels",
|
|
614
|
-
]);
|
|
615
|
-
|
|
616
|
-
function readDesktopProfileForeignKeys(path: string): Record<string, unknown> {
|
|
617
|
-
if (!existsSync(path)) return {};
|
|
618
|
-
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
619
|
-
if (!isRecord(parsed)) throw new Error("Claude Desktop 3P profile is not a JSON object");
|
|
620
|
-
return Object.fromEntries(
|
|
621
|
-
Object.entries(parsed).filter(([key]) => !OPENCODEX_DESKTOP_PROFILE_KEYS.has(key)),
|
|
622
|
-
);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
696
|
/** Backup an existing owned config then atomically replace it. Exported for failure-path tests. */
|
|
626
697
|
export function atomicReplaceDesktopConfig(
|
|
627
698
|
path: string,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { OcxConfig } from "../types";
|
|
2
|
+
import {
|
|
3
|
+
filterCatalogVisibleModels,
|
|
4
|
+
nativeContextLimits,
|
|
5
|
+
orderForSubagents,
|
|
6
|
+
type CatalogModel,
|
|
7
|
+
type NativeContextLimits,
|
|
8
|
+
} from "../codex/catalog";
|
|
9
|
+
import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../codex/catalog/native-models";
|
|
10
|
+
import {
|
|
11
|
+
availableAccountGatedNativeModels,
|
|
12
|
+
type CodexModelEntitlementSnapshot,
|
|
13
|
+
} from "../codex/model-entitlements";
|
|
14
|
+
import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
|
|
15
|
+
import { providerCodexAccountMode } from "../providers/registry";
|
|
16
|
+
import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
|
|
17
|
+
|
|
18
|
+
export interface DesktopDiscoveryInputs {
|
|
19
|
+
nativeSlugs: string[];
|
|
20
|
+
routedModels: CatalogModel[];
|
|
21
|
+
nativeContextCap: NativeContextLimits;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Project captured discovery state without reading caches or installing aliases. */
|
|
25
|
+
export function buildDesktopDiscoveryInputs(options: {
|
|
26
|
+
config: OcxConfig;
|
|
27
|
+
models: readonly CatalogModel[];
|
|
28
|
+
modelEntitlements: CodexModelEntitlementSnapshot;
|
|
29
|
+
desktopNativeCandidates: readonly string[];
|
|
30
|
+
}): DesktopDiscoveryInputs {
|
|
31
|
+
const { config, models, modelEntitlements, desktopNativeCandidates } = options;
|
|
32
|
+
const eligibleAccountIds = providerCodexAccountMode(
|
|
33
|
+
OPENAI_CODEX_PROVIDER_ID,
|
|
34
|
+
config.providers[OPENAI_CODEX_PROVIDER_ID],
|
|
35
|
+
) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined;
|
|
36
|
+
const available = availableAccountGatedNativeModels(modelEntitlements, eligibleAccountIds);
|
|
37
|
+
return {
|
|
38
|
+
nativeSlugs: desktopNativeCandidates.filter(slug => (
|
|
39
|
+
!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || available.has(slug)
|
|
40
|
+
)),
|
|
41
|
+
routedModels: orderForSubagents(filterCatalogVisibleModels([...models], config), config.subagentModels),
|
|
42
|
+
nativeContextCap: nativeContextLimits(config),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -83,7 +83,7 @@ function isRealAnthropicRoute(route: string): boolean {
|
|
|
83
83
|
return route.startsWith("anthropic/claude-");
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
function validDateAlias(alias: string): boolean {
|
|
86
|
+
export function validDateAlias(alias: string): boolean {
|
|
87
87
|
const match = DATE_ALIAS.exec(alias);
|
|
88
88
|
if (!match) return false;
|
|
89
89
|
const year = Number(match[1]!.slice(0, 4));
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readConfigDiagnostics } from "../config";
|
|
4
|
+
import { readServiceApiTokenState, readTokenBackupState, serviceApiTokenFingerprint } from "../lib/service-secrets";
|
|
5
|
+
import { profilePath, type Desktop3pMetadata } from "./desktop-3p-library";
|
|
6
|
+
import { StoreIO, metadata, storePaths, MAX_DESKTOP_METADATA_ENTRIES, type JsonFile } from "./desktop-remote-store-io";
|
|
7
|
+
import {
|
|
8
|
+
DesktopStoreError, digest, origin, parseBaseline, parseDisconnect, parseOwner, parseState, projection, projectionHash, mergeProjection,
|
|
9
|
+
sameOwner, type ArtifactPending, type Baseline, type DesktopRemoteOwner, type Projection, type StoreState,
|
|
10
|
+
} from "./desktop-remote-store-state";
|
|
11
|
+
|
|
12
|
+
export function currentConnection(owner: DesktopRemoteOwner) {
|
|
13
|
+
parseOwner(owner);
|
|
14
|
+
const { config, source } = readConfigDiagnostics();
|
|
15
|
+
const client = config.client;
|
|
16
|
+
if (source !== "file" || config.runtimeRole !== "client" || !client
|
|
17
|
+
|| origin(client.serverUrl) !== owner.serverUrl || client.apiKeyId !== owner.apiKeyId || client.connectedAt !== owner.connectedAt) throw new DesktopStoreError("conflict");
|
|
18
|
+
return { config, client };
|
|
19
|
+
}
|
|
20
|
+
export function serviceGenerations(): string[] {
|
|
21
|
+
const token = readServiceApiTokenState();
|
|
22
|
+
const backup = readTokenBackupState();
|
|
23
|
+
if (token.kind === "unsafe" || backup.kind === "unsafe") throw new DesktopStoreError("unsafe");
|
|
24
|
+
return [token, backup].flatMap(t => t.kind === "present" ? [t.fingerprint] : []);
|
|
25
|
+
}
|
|
26
|
+
export function profileCredential(value: Record<string, unknown> | null): { origin: string; fingerprint: string } | null {
|
|
27
|
+
if (!value || value.inferenceProvider !== "gateway" || value.inferenceCredentialKind !== "static") return null;
|
|
28
|
+
if (typeof value.inferenceGatewayApiKey !== "string") throw new DesktopStoreError("unsafe");
|
|
29
|
+
return { origin: origin(value.inferenceGatewayBaseUrl), fingerprint: serviceApiTokenFingerprint(value.inferenceGatewayApiKey) };
|
|
30
|
+
}
|
|
31
|
+
export function loadBundle(io: StoreIO) {
|
|
32
|
+
const paths = storePaths();
|
|
33
|
+
const stateFile = io.read(paths.state, 64 * 1024, true);
|
|
34
|
+
const baselineFile = io.read(paths.baseline, 1024 * 1024, true);
|
|
35
|
+
const state = stateFile ? parseState(stateFile.value) : null;
|
|
36
|
+
const baseline = baselineFile ? parseBaseline(baselineFile.value) : null;
|
|
37
|
+
if (!state && baseline) throw new DesktopStoreError("unsafe");
|
|
38
|
+
if (state) {
|
|
39
|
+
if (state.home !== paths.home || state.library !== paths.library) throw new DesktopStoreError("unsafe");
|
|
40
|
+
if (!baseline && state.phase !== "prepared" && state.phase !== "cleaned") throw new DesktopStoreError("unsafe");
|
|
41
|
+
if (baseline && (baselineFile!.hash !== state.baselineHash || baseline.home !== paths.home
|
|
42
|
+
|| baseline.library !== paths.library || baseline.targetId !== state.targetId || baseline.kind !== state.baselineKind
|
|
43
|
+
|| !sameOwner(baseline.owner, state.owner))) throw new DesktopStoreError("unsafe");
|
|
44
|
+
}
|
|
45
|
+
return { paths, stateFile, baselineFile, state, baseline };
|
|
46
|
+
}
|
|
47
|
+
export type Bundle = ReturnType<typeof loadBundle>;
|
|
48
|
+
export function requireOwner(bundle: Bundle, owner: DesktopRemoteOwner): void {
|
|
49
|
+
if (bundle.state && !sameOwner(bundle.state.owner, owner)) throw new DesktopStoreError("conflict");
|
|
50
|
+
}
|
|
51
|
+
export function selectedFile(io: StoreIO, library: string, meta: Desktop3pMetadata): JsonFile | null {
|
|
52
|
+
if (!meta.appliedId) return null;
|
|
53
|
+
const file = io.read(profilePath(library, meta.appliedId));
|
|
54
|
+
if (!file) throw new DesktopStoreError("unsafe");
|
|
55
|
+
return file;
|
|
56
|
+
}
|
|
57
|
+
export function locateLegacy(io: StoreIO, owner: DesktopRemoteOwner, known: readonly string[]) {
|
|
58
|
+
const paths = storePaths();
|
|
59
|
+
const meta = metadata(io, paths.library);
|
|
60
|
+
selectedFile(io, paths.library, meta.value);
|
|
61
|
+
let found: { id: string; file: JsonFile } | null = null;
|
|
62
|
+
for (const entry of meta.value.entries) {
|
|
63
|
+
const file = io.read(profilePath(paths.library, entry.id));
|
|
64
|
+
if (!file) { if (entry.id === meta.value.appliedId) throw new DesktopStoreError("unsafe"); continue; }
|
|
65
|
+
const credential = profileCredential(file.value);
|
|
66
|
+
if (!credential || credential.origin !== owner.serverUrl) continue;
|
|
67
|
+
if (entry.name !== "opencodex" || !known.includes(credential.fingerprint) || found) throw new DesktopStoreError("conflict");
|
|
68
|
+
found = { id: entry.id, file };
|
|
69
|
+
}
|
|
70
|
+
return found;
|
|
71
|
+
}
|
|
72
|
+
export function saveState(io: StoreIO, bundle: Bundle, state: StoreState): void {
|
|
73
|
+
parseState(state);
|
|
74
|
+
io.write(bundle.paths.state, state, bundle.stateFile, 64 * 1024);
|
|
75
|
+
bundle.state = state;
|
|
76
|
+
bundle.stateFile = io.read(bundle.paths.state, 64 * 1024, true);
|
|
77
|
+
}
|
|
78
|
+
export function establish(io: StoreIO, owner: DesktopRemoteOwner, known: readonly string[], onlyLegacy: boolean): Bundle | null {
|
|
79
|
+
let bundle = loadBundle(io);
|
|
80
|
+
const terminalFile = io.read(bundle.paths.disconnect, 64 * 1024, true);
|
|
81
|
+
const terminal = terminalFile ? parseDisconnect(terminalFile.value) : null;
|
|
82
|
+
if (terminal?.phase === "complete" && !sameOwner(terminal.owner, owner)) {
|
|
83
|
+
currentConnection(owner);
|
|
84
|
+
if (bundle.baselineFile || (bundle.state && (bundle.state.phase !== "cleaned" || !sameOwner(bundle.state.owner, terminal.owner)))) throw new DesktopStoreError("conflict");
|
|
85
|
+
if (bundle.stateFile) io.remove(bundle.paths.state, bundle.stateFile);
|
|
86
|
+
io.remove(bundle.paths.disconnect, terminalFile!);
|
|
87
|
+
bundle = loadBundle(io);
|
|
88
|
+
}
|
|
89
|
+
requireOwner(bundle, owner);
|
|
90
|
+
if (bundle.state && bundle.baseline) return bundle;
|
|
91
|
+
if (bundle.state?.phase === "cleaned") throw new DesktopStoreError("conflict");
|
|
92
|
+
const meta = metadata(io, bundle.paths.library);
|
|
93
|
+
const selected = selectedFile(io, bundle.paths.library, meta.value);
|
|
94
|
+
const legacy = locateLegacy(io, owner, known);
|
|
95
|
+
if (onlyLegacy && !legacy && !bundle.state) return null;
|
|
96
|
+
const entry = meta.value.entries.find(e => e.id === meta.value.appliedId && e.name === "opencodex")
|
|
97
|
+
?? meta.value.entries.find(e => e.name === "opencodex");
|
|
98
|
+
const targetId = bundle.state?.targetId ?? legacy?.id ?? entry?.id ?? randomUUID();
|
|
99
|
+
if (!meta.value.entries.some(e => e.id === targetId) && meta.value.entries.length >= MAX_DESKTOP_METADATA_ENTRIES) {
|
|
100
|
+
throw new DesktopStoreError("conflict");
|
|
101
|
+
}
|
|
102
|
+
const target = io.read(profilePath(bundle.paths.library, targetId));
|
|
103
|
+
const credential = profileCredential(target?.value ?? null);
|
|
104
|
+
if (credential && known.includes(credential.fingerprint) && credential.origin !== owner.serverUrl) throw new DesktopStoreError("conflict");
|
|
105
|
+
const fallback = credential?.origin === owner.serverUrl;
|
|
106
|
+
if (fallback && (!known.includes(credential.fingerprint) || (entry?.name !== "opencodex" && legacy?.id !== targetId))) throw new DesktopStoreError("conflict");
|
|
107
|
+
const priorSelection = meta.value.appliedId && selected && !(fallback && meta.value.appliedId === targetId)
|
|
108
|
+
? { id: meta.value.appliedId, hash: selected.hash } : null;
|
|
109
|
+
const baseline: Baseline = {
|
|
110
|
+
version: 1, owner, home: bundle.paths.home, library: bundle.paths.library, targetId,
|
|
111
|
+
kind: fallback ? "standard_fallback" : "known", targetExisted: target !== null,
|
|
112
|
+
projection: fallback ? {} : projection(target?.value ?? {}), priorSelection,
|
|
113
|
+
};
|
|
114
|
+
const baselineHash = digest(JSON.stringify(baseline, null, 2) + "\n");
|
|
115
|
+
if (bundle.state && (bundle.state.phase !== "prepared" || bundle.state.baselineHash !== baselineHash
|
|
116
|
+
|| bundle.state.lastProjectionHash !== projectionHash(target?.value ?? null))) throw new DesktopStoreError("conflict");
|
|
117
|
+
if (!bundle.state) saveState(io, bundle, {
|
|
118
|
+
version: 1, owner, home: bundle.paths.home, library: bundle.paths.library, targetId,
|
|
119
|
+
baselineRef: "baseline.json", baselineHash, baselineKind: baseline.kind, phase: "prepared",
|
|
120
|
+
lastProjectionHash: projectionHash(target?.value ?? null), tokenFingerprint: known[0]!,
|
|
121
|
+
});
|
|
122
|
+
io.write(bundle.paths.baseline, baseline, null);
|
|
123
|
+
bundle = loadBundle(io);
|
|
124
|
+
saveState(io, bundle, { ...bundle.state!, phase: "active" });
|
|
125
|
+
return bundle;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function artifact(io: StoreIO, bundle: Bundle, next: Projection, kind: ArtifactPending["kind"], tokenFingerprint: string, desiredSelection?: string | null): void {
|
|
129
|
+
const state = bundle.state!;
|
|
130
|
+
const targetPath = profilePath(bundle.paths.library, state.targetId);
|
|
131
|
+
let current = io.read(targetPath);
|
|
132
|
+
const meta = metadata(io, bundle.paths.library);
|
|
133
|
+
const existing = meta.value.entries.find(e => e.id === state.targetId);
|
|
134
|
+
// An absent row is valid only while a recorded first creation is unfinished.
|
|
135
|
+
// Keeping the original null projection receipt across apply -> restore proves it.
|
|
136
|
+
const uncommittedCreation = bundle.baseline?.targetExisted === false && state.lastProjectionHash === projectionHash(null);
|
|
137
|
+
if ((existing && existing.name !== "opencodex") || (!existing && !uncommittedCreation)) throw new DesktopStoreError("conflict");
|
|
138
|
+
if (!existing && meta.value.entries.length >= MAX_DESKTOP_METADATA_ENTRIES) throw new DesktopStoreError("conflict");
|
|
139
|
+
const before = projectionHash(current?.value ?? null), after = projectionHash(next);
|
|
140
|
+
const selection = meta.value.appliedId ?? null;
|
|
141
|
+
const pending = state.pending;
|
|
142
|
+
if (pending) {
|
|
143
|
+
if (pending.kind !== kind || pending.after !== after || pending.tokenFingerprint !== tokenFingerprint
|
|
144
|
+
|| (before !== pending.before && before !== pending.after)) throw new DesktopStoreError("conflict");
|
|
145
|
+
if (kind === "apply" && selection !== pending.beforeSelection && selection !== pending.afterSelection) throw new DesktopStoreError("conflict");
|
|
146
|
+
} else if (before !== state.lastProjectionHash) throw new DesktopStoreError("conflict");
|
|
147
|
+
const afterSelection = desiredSelection === undefined ? selection : desiredSelection;
|
|
148
|
+
const settledPhase = kind === "restore" ? "restored" : "active";
|
|
149
|
+
if (!pending && existing && before === after && afterSelection === selection
|
|
150
|
+
&& state.phase === settledPhase && state.tokenFingerprint === tokenFingerprint) return;
|
|
151
|
+
if (!pending) saveState(io, bundle, { ...state, pending: { kind, before, after, beforeSelection: selection, afterSelection, tokenFingerprint } });
|
|
152
|
+
if (before !== after) {
|
|
153
|
+
io.write(targetPath, mergeProjection(current?.value ?? {}, next), current);
|
|
154
|
+
current = io.read(targetPath);
|
|
155
|
+
}
|
|
156
|
+
// Build from fresh metadata, so unrelated rows/fields are never restored from baseline bytes.
|
|
157
|
+
const fresh = metadata(io, bundle.paths.library);
|
|
158
|
+
const freshEntry = fresh.value.entries.find(e => e.id === state.targetId);
|
|
159
|
+
if ((fresh.value.appliedId ?? null) !== selection || freshEntry?.name !== existing?.name) throw new DesktopStoreError("conflict");
|
|
160
|
+
if (!existing || afterSelection !== selection) {
|
|
161
|
+
if (!existing && fresh.value.entries.length >= MAX_DESKTOP_METADATA_ENTRIES) throw new DesktopStoreError("conflict");
|
|
162
|
+
if (afterSelection && !io.read(profilePath(bundle.paths.library, afterSelection))) throw new DesktopStoreError("unsafe");
|
|
163
|
+
const entries = existing ? fresh.value.entries : [...fresh.value.entries, { id: state.targetId, name: "opencodex" }];
|
|
164
|
+
const value = { ...fresh.value, entries, ...(afterSelection ? { appliedId: afterSelection } : {}) };
|
|
165
|
+
io.write(join(bundle.paths.library, "_meta.json"), value, fresh.file);
|
|
166
|
+
}
|
|
167
|
+
const { pending: _pending, ...settled } = bundle.state!;
|
|
168
|
+
saveState(io, bundle, { ...settled, phase: kind === "restore" ? "restored" : "active", lastProjectionHash: after, tokenFingerprint });
|
|
169
|
+
}
|
|
170
|
+
export function cleanBackup(io: StoreIO, bundle: Bundle, known: readonly string[]): void {
|
|
171
|
+
const path = `${profilePath(bundle.paths.library, bundle.state!.targetId)}.bak`;
|
|
172
|
+
const file = io.read(path);
|
|
173
|
+
if (!file) return;
|
|
174
|
+
const credential = profileCredential(file.value);
|
|
175
|
+
if (!credential || credential.origin !== bundle.state!.owner.serverUrl) return;
|
|
176
|
+
if (!known.includes(credential.fingerprint)) throw new DesktopStoreError("conflict");
|
|
177
|
+
const foreign = mergeProjection(file.value, {});
|
|
178
|
+
try {
|
|
179
|
+
if (Object.keys(foreign).length) io.write(path, foreign, file);
|
|
180
|
+
else io.remove(path, file);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error instanceof DesktopStoreError) throw error;
|
|
183
|
+
throw new DesktopStoreError("cleanup_pending");
|
|
184
|
+
}
|
|
185
|
+
}
|