@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/images/loop.ts
CHANGED
|
@@ -253,6 +253,8 @@ export interface ImageBridgeDeps {
|
|
|
253
253
|
stallTimeoutSec?: number;
|
|
254
254
|
/** Provider-specific fetch (e.g. xAI transport wrapper). Falls back to global fetch. */
|
|
255
255
|
fetchImpl?: typeof globalThis.fetch;
|
|
256
|
+
/** Bind physical dispatch to this iteration's built request; pacing remains owned by the loop. */
|
|
257
|
+
fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest) => typeof globalThis.fetch;
|
|
256
258
|
/** Reserve the routed provider's next request-start slot before each adapter dispatch. */
|
|
257
259
|
waitForRequestSlot?: (signal?: AbortSignal) => Promise<void>;
|
|
258
260
|
/** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */
|
|
@@ -499,6 +501,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
499
501
|
cachedRequest = request;
|
|
500
502
|
cachedAdapter = requestAdapter;
|
|
501
503
|
}
|
|
504
|
+
const requestFetch = deps.fetchForRequest?.(request, iterParsed) ?? fetchImpl;
|
|
502
505
|
let response: Response;
|
|
503
506
|
try {
|
|
504
507
|
if (requestAdapter.fetchResponse) {
|
|
@@ -509,7 +512,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
509
512
|
timeoutMs: connectTimeoutMs,
|
|
510
513
|
returnRawErrors: true,
|
|
511
514
|
stream: true,
|
|
512
|
-
executor:
|
|
515
|
+
executor: requestFetch,
|
|
513
516
|
});
|
|
514
517
|
} else {
|
|
515
518
|
response = await fetchWithResetRetry(
|
|
@@ -524,7 +527,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
524
527
|
// Same reset-recovery parity as the web-search loop: the replay needs
|
|
525
528
|
// `keepalive: false` to abandon the pooled socket, because Bun has ignored the
|
|
526
529
|
// hop-by-hop header alone (oven-sh/bun#20492).
|
|
527
|
-
return
|
|
530
|
+
return requestFetch(request.url, applyUpstreamRecoveryInit({
|
|
528
531
|
method: request.method,
|
|
529
532
|
headers: h,
|
|
530
533
|
body: request.body,
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { lstatSync, readdirSync, realpathSync } from "node:fs";
|
|
2
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { ClientPathError, type ExportModel } from "../clients/config-export";
|
|
4
|
+
import { assertAsideProfileBoundary, guardAsideProfileIO, listAsideProfiles, type AsideProfile } from "../clients/aside-profiles";
|
|
5
|
+
import type { OcxConfig } from "../types";
|
|
6
|
+
import { type IntegrationIO } from "./config-io";
|
|
7
|
+
import type { JournalEntry } from "./journal";
|
|
8
|
+
import { IntegrationMutationBusyError, runIntegrationMutationFlight } from "./mutation-flight";
|
|
9
|
+
import { fingerprint } from "./ownership";
|
|
10
|
+
import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
|
|
11
|
+
import type { IntegrationWriteInput, WriteOutcome } from "./writer";
|
|
12
|
+
import type { IntegrationWriterLockSeams } from "./writer-lock";
|
|
13
|
+
|
|
14
|
+
export interface AsideProfilesInput {
|
|
15
|
+
config: OcxConfig;
|
|
16
|
+
models: readonly ExportModel[] | (() => Promise<readonly ExportModel[]>);
|
|
17
|
+
port: number;
|
|
18
|
+
env?: NodeJS.ProcessEnv;
|
|
19
|
+
home?: string;
|
|
20
|
+
store?: IntegrationStateStore;
|
|
21
|
+
io?: IntegrationIO;
|
|
22
|
+
persistConfig?: (config: OcxConfig) => void | Promise<void>;
|
|
23
|
+
lockSeams?: IntegrationWriterLockSeams;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class AsideProfileError extends Error {
|
|
27
|
+
constructor(readonly code: string, readonly status: number, message: string) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "AsideProfileError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type AsideProfilePolicy = NonNullable<OcxConfig["asideProfileSync"]>;
|
|
34
|
+
export type AsideProfileWriteOutcome = WriteOutcome & { profileId: number };
|
|
35
|
+
export interface AsideProfileScope {
|
|
36
|
+
profile: AsideProfile;
|
|
37
|
+
store: IntegrationStateStore;
|
|
38
|
+
io: IntegrationIO;
|
|
39
|
+
assertBoundary: () => void;
|
|
40
|
+
}
|
|
41
|
+
export interface AsideProfileContext {
|
|
42
|
+
input: AsideProfilesInput;
|
|
43
|
+
profiles: AsideProfile[];
|
|
44
|
+
rootStore: IntegrationStateStore;
|
|
45
|
+
legacyProfileId: number | null;
|
|
46
|
+
defaultEnabled: boolean;
|
|
47
|
+
models: () => Promise<readonly ExportModel[]>;
|
|
48
|
+
scopes: Map<number, AsideProfileScope>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function storeUnsafe(): never {
|
|
52
|
+
throw new AsideProfileError("aside_profile_store_unsafe", 409, "Aside profile ownership storage cannot be accessed safely");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Allow aliases above the trusted anchor, never at or below it. */
|
|
56
|
+
function storeGuard(anchor: string, target: string): () => void {
|
|
57
|
+
const base = resolve(anchor);
|
|
58
|
+
const root = resolve(target);
|
|
59
|
+
const rel = relative(base, root);
|
|
60
|
+
if (rel.startsWith(`..${sep}`) || rel === ".." || resolve(base, rel) !== root) storeUnsafe();
|
|
61
|
+
const identities = new Map<string, string>();
|
|
62
|
+
const inspect = (path: string, directory: boolean): boolean => {
|
|
63
|
+
try {
|
|
64
|
+
const stat = lstatSync(path);
|
|
65
|
+
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile()) || (!directory && stat.nlink > 1)) storeUnsafe();
|
|
66
|
+
if (directory) {
|
|
67
|
+
const identity = `${realpathSync(path)}:${stat.dev}:${stat.ino}`;
|
|
68
|
+
if (identities.has(path) && identities.get(path) !== identity) storeUnsafe();
|
|
69
|
+
identities.set(path, identity);
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT" && !identities.has(path)) return false;
|
|
74
|
+
storeUnsafe();
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
return () => {
|
|
78
|
+
let path = base;
|
|
79
|
+
if (!inspect(path, true)) return;
|
|
80
|
+
for (const part of rel ? rel.split(sep) : []) {
|
|
81
|
+
path = join(path, part);
|
|
82
|
+
if (!inspect(path, true)) return;
|
|
83
|
+
}
|
|
84
|
+
for (const name of ["records.json", "journal.jsonl", "maintenance.json"]) inspect(join(root, name), false);
|
|
85
|
+
const snapshots = join(root, "snapshots");
|
|
86
|
+
if (!inspect(snapshots, true)) return;
|
|
87
|
+
const aside = join(snapshots, "aside");
|
|
88
|
+
if (inspect(aside, true)) for (const name of readdirSync(aside)) inspect(join(aside, name), false);
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Store methods close over their original root; IO bookkeeping must bind to the guarded facade. */
|
|
93
|
+
function guardedStore(store: IntegrationStateStore, anchor: string): IntegrationStateStore {
|
|
94
|
+
const guard = storeGuard(anchor, store.root);
|
|
95
|
+
guard();
|
|
96
|
+
return new Proxy(store, {
|
|
97
|
+
get(target, property, receiver) {
|
|
98
|
+
const value: unknown = Reflect.get(target, property, receiver);
|
|
99
|
+
if (typeof value !== "function") return value;
|
|
100
|
+
return (...args: unknown[]) => {
|
|
101
|
+
guard();
|
|
102
|
+
if (property === "readSnapshot") assertAsideSnapshotEntry(args[0] as JournalEntry);
|
|
103
|
+
// Maintenance in this service is Aside-scoped, including a legacy shared store.
|
|
104
|
+
if (property === "retryPendingPrunes") {
|
|
105
|
+
if (store.readMaintenance().pruneFailures.aside) {
|
|
106
|
+
if (store.pruneSnapshots("aside").ok) store.clearPruneFailure("aside");
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
return Reflect.apply(value, target, args);
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function asideRootStore(input: AsideProfilesInput): IntegrationStateStore {
|
|
117
|
+
const raw = input.store ?? createIntegrationStateStore();
|
|
118
|
+
return guardedStore(raw, raw.root);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function assertAsideSnapshotEntry(entry: JournalEntry): void {
|
|
122
|
+
if (!entry || entry.clientId !== "aside" || typeof entry.opId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(entry.opId) || !entry.snapshot
|
|
123
|
+
|| !["none", "stored", "expired"].includes(entry.snapshot.kind)
|
|
124
|
+
|| (entry.snapshot.kind === "stored" && entry.snapshot.relPath !== join("snapshots", "aside", entry.opId))) {
|
|
125
|
+
throw new AsideProfileError("aside_operation_invalid", 409, "Aside operation snapshot metadata is invalid");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function createAsideProfileContext(input: AsideProfilesInput): AsideProfileContext {
|
|
130
|
+
let profiles: AsideProfile[];
|
|
131
|
+
try { profiles = listAsideProfiles(input.env, input.home); }
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (error instanceof ClientPathError) throw new AsideProfileError("aside_profiles_unavailable", 409, error.message);
|
|
134
|
+
throw new AsideProfileError("aside_profiles_unavailable", 409, "Aside profiles cannot be read");
|
|
135
|
+
}
|
|
136
|
+
const rootStore = asideRootStore(input);
|
|
137
|
+
const record = rootStore.readRecords().aside;
|
|
138
|
+
const matched = record?.clientId === "aside" ? profiles.find(profile => profile.configPath === record.configPath) : undefined;
|
|
139
|
+
const pinned = input.config.asideProfileSync?.legacyProfileId;
|
|
140
|
+
const newest = !record && pinned === undefined ? rootStore.listOperations("aside", 1)[0] : undefined;
|
|
141
|
+
const legacyProfileId = pinned !== undefined ? pinned : record
|
|
142
|
+
? matched?.id ?? null
|
|
143
|
+
: profiles.find(profile => profile.configPath === newest?.configPath)?.id ?? null;
|
|
144
|
+
let loaded: Promise<readonly ExportModel[]> | undefined;
|
|
145
|
+
return {
|
|
146
|
+
input: { ...input, env: { ...(input.env ?? process.env) } }, profiles, rootStore, legacyProfileId, scopes: new Map(),
|
|
147
|
+
defaultEnabled: input.config.asideProfileSync?.allProfiles ?? Boolean(matched),
|
|
148
|
+
models: () => loaded ??= Promise.resolve().then(() => typeof input.models === "function" ? input.models() : input.models),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function selectAsideProfiles(ctx: AsideProfileContext, profileId?: number): AsideProfile[] {
|
|
153
|
+
if (profileId === undefined) return ctx.profiles;
|
|
154
|
+
if (!Number.isSafeInteger(profileId) || profileId < 0 || Object.is(profileId, -0)) {
|
|
155
|
+
throw new AsideProfileError("invalid_aside_profile", 400, "Aside profile must be a nonnegative safe integer");
|
|
156
|
+
}
|
|
157
|
+
const profile = ctx.profiles.find(candidate => candidate.id === profileId);
|
|
158
|
+
if (!profile) throw new AsideProfileError("aside_profile_not_found", 404, "Aside profile is not registered");
|
|
159
|
+
return [profile];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function asideProfileEnabled(ctx: AsideProfileContext, id: number): boolean {
|
|
163
|
+
return ctx.input.config.asideProfileSync?.profiles?.[String(id)] ?? ctx.defaultEnabled;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function asideProfileScope(ctx: AsideProfileContext, profile: AsideProfile): AsideProfileScope {
|
|
167
|
+
try { return resolveScope(ctx, profile); }
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error instanceof ClientPathError) throw new AsideProfileError("aside_profile_unsafe", 409, error.message);
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function resolveScope(ctx: AsideProfileContext, profile: AsideProfile): AsideProfileScope {
|
|
175
|
+
const cached = ctx.scopes.get(profile.id);
|
|
176
|
+
if (cached) { cached.assertBoundary(); return cached; }
|
|
177
|
+
assertAsideProfileBoundary(profile, ctx.profiles);
|
|
178
|
+
const store = profile.id === ctx.legacyProfileId ? ctx.rootStore
|
|
179
|
+
: guardedStore(createIntegrationStateStore(join(ctx.rootStore.root, "aside-profiles", String(profile.id))), ctx.rootStore.root);
|
|
180
|
+
const assertBoundary = () => {
|
|
181
|
+
assertAsideProfileBoundary(profile, ctx.profiles);
|
|
182
|
+
const record = store.readRecords().aside;
|
|
183
|
+
if (record && (record.clientId !== "aside" || record.configPath !== profile.configPath)) {
|
|
184
|
+
throw new AsideProfileError("aside_profile_owner_mismatch", 409, "Aside ownership belongs to a different profile");
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
assertBoundary();
|
|
188
|
+
const baseIO = ctx.input.io ?? store.io();
|
|
189
|
+
const guarded = guardAsideProfileIO(profile, {
|
|
190
|
+
...baseIO,
|
|
191
|
+
appendJournal: entry => store.appendJournal(entry),
|
|
192
|
+
putRecord: record => store.putRecord(record),
|
|
193
|
+
dropRecord: clientId => store.dropRecord(clientId),
|
|
194
|
+
}, ctx.profiles);
|
|
195
|
+
const io: IntegrationIO = {
|
|
196
|
+
...guarded,
|
|
197
|
+
writeText: (path, text) => { assertBoundary(); guarded.writeText(path, text); },
|
|
198
|
+
removeFile: path => { assertBoundary(); guarded.removeFile(path); },
|
|
199
|
+
mkdirp: path => { assertBoundary(); guarded.mkdirp(path); },
|
|
200
|
+
};
|
|
201
|
+
const scope = { profile, store, io, assertBoundary };
|
|
202
|
+
ctx.scopes.set(profile.id, scope);
|
|
203
|
+
return scope;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function asideWriteInput(ctx: AsideProfileContext, scope: AsideProfileScope): Promise<IntegrationWriteInput> {
|
|
207
|
+
const models = await ctx.models();
|
|
208
|
+
scope.assertBoundary();
|
|
209
|
+
return {
|
|
210
|
+
clientId: "aside", config: ctx.input.config, models, port: ctx.input.port,
|
|
211
|
+
env: ctx.input.env, home: ctx.input.home, store: scope.store, io: scope.io,
|
|
212
|
+
resolvedPaths: { configPath: scope.profile.configPath, detectDir: scope.profile.detectDir },
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Save intent first; an unsuccessful save must not leave even in-memory intent changed. */
|
|
217
|
+
export async function persistAsidePolicy(ctx: AsideProfileContext, change?: { enabled: boolean; profileId?: number }): Promise<void> {
|
|
218
|
+
const { config, persistConfig } = ctx.input;
|
|
219
|
+
if (!persistConfig) throw new AsideProfileError("aside_profile_persistence_required", 500, "Aside profile changes require configuration persistence");
|
|
220
|
+
const previous = config.asideProfileSync;
|
|
221
|
+
const policy: AsideProfilePolicy = {
|
|
222
|
+
...previous, allProfiles: ctx.defaultEnabled, legacyProfileId: ctx.legacyProfileId,
|
|
223
|
+
profiles: { ...previous?.profiles },
|
|
224
|
+
};
|
|
225
|
+
if (change && change.profileId === undefined) {
|
|
226
|
+
policy.allProfiles = change.enabled;
|
|
227
|
+
policy.profiles = {};
|
|
228
|
+
} else if (change) policy.profiles![String(change.profileId)] = change.enabled;
|
|
229
|
+
config.asideProfileSync = policy;
|
|
230
|
+
try { await persistConfig(config); }
|
|
231
|
+
catch {
|
|
232
|
+
if (previous === undefined) delete config.asideProfileSync;
|
|
233
|
+
else config.asideProfileSync = previous;
|
|
234
|
+
throw new AsideProfileError("aside_profile_persist_failed", 500, "Aside profile preferences could not be saved; no profile files were changed");
|
|
235
|
+
}
|
|
236
|
+
ctx.defaultEnabled = policy.allProfiles!;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function runAsideProfileAction<T>(
|
|
240
|
+
input: AsideProfilesInput,
|
|
241
|
+
profileId: number | undefined,
|
|
242
|
+
semantics: string,
|
|
243
|
+
action: (ctx: AsideProfileContext, profiles: AsideProfile[]) => Promise<T>,
|
|
244
|
+
): Promise<T> {
|
|
245
|
+
const ctx = createAsideProfileContext(input);
|
|
246
|
+
const profiles = selectAsideProfiles(ctx, profileId);
|
|
247
|
+
const key = `aside:${fingerprint(`${ctx.rootStore.root}:${profiles.map(p => p.root).join(",")}`)}:${profiles.map(p => p.id).sort((a, b) => a - b).join(",")}:${semantics}:${crypto.randomUUID()}`;
|
|
248
|
+
try {
|
|
249
|
+
return await runIntegrationMutationFlight("aside", key, input.io?.now ?? Date.now, async () => {
|
|
250
|
+
// Publish the flight before invoking user-supplied persistence callbacks.
|
|
251
|
+
await Promise.resolve();
|
|
252
|
+
return action(ctx, profiles);
|
|
253
|
+
});
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (error instanceof IntegrationMutationBusyError) {
|
|
256
|
+
throw new AsideProfileError("integration_mutation_busy", 409, "An Aside profile operation is already running");
|
|
257
|
+
}
|
|
258
|
+
if (error instanceof AsideProfileError) throw error;
|
|
259
|
+
if (error instanceof ClientPathError) throw new AsideProfileError("aside_profile_unsafe", 409, error.message);
|
|
260
|
+
throw new AsideProfileError("aside_profile_operation_failed", 500, "Aside profile operation could not be completed");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function asideProfileFailure(profileId: number, error: unknown): AsideProfileWriteOutcome {
|
|
265
|
+
return {
|
|
266
|
+
clientId: "aside", profileId, ok: false, reason: "unsafe", state: "unsafe",
|
|
267
|
+
message: error instanceof AsideProfileError || error instanceof ClientPathError
|
|
268
|
+
? error.message : "Aside profile could not be updated safely",
|
|
269
|
+
};
|
|
270
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { EXPORT_CLIENTS } from "../clients/config-export";
|
|
2
|
+
import { loadTarget, parseConfig } from "./config-io";
|
|
3
|
+
import { matchesOperationResult, type JournalEntry } from "./journal";
|
|
4
|
+
import { fingerprint, type OwnershipRecord } from "./ownership";
|
|
5
|
+
import { classifyIntegration, exportContextOf } from "./state";
|
|
6
|
+
import type { IntegrationStateStore } from "./store";
|
|
7
|
+
import { restoreIntegrationCoordinated, type IntegrationWriteInput } from "./writer";
|
|
8
|
+
import {
|
|
9
|
+
AsideProfileError, asideProfileFailure, asideProfileScope, asideRootStore, asideWriteInput, assertAsideSnapshotEntry,
|
|
10
|
+
createAsideProfileContext, persistAsidePolicy, runAsideProfileAction, selectAsideProfiles,
|
|
11
|
+
type AsideProfileContext, type AsideProfilesInput, type AsideProfileScope, type AsideProfileWriteOutcome,
|
|
12
|
+
} from "./aside-profile-context";
|
|
13
|
+
|
|
14
|
+
export interface AsideOperation {
|
|
15
|
+
profileId: number;
|
|
16
|
+
entry: JournalEntry;
|
|
17
|
+
store: IntegrationStateStore;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function operationRows(ctx: AsideProfileContext, profileId?: number): AsideOperation[] {
|
|
21
|
+
const rows: AsideOperation[] = [];
|
|
22
|
+
const entriesByRoot = new Map<string, JournalEntry[]>();
|
|
23
|
+
for (const profile of selectAsideProfiles(ctx, profileId)) {
|
|
24
|
+
const scope = asideProfileScope(ctx, profile);
|
|
25
|
+
const stores = scope.store.root === ctx.rootStore.root ? [scope.store] : [scope.store, ctx.rootStore];
|
|
26
|
+
for (const store of stores) {
|
|
27
|
+
let entries = entriesByRoot.get(store.root);
|
|
28
|
+
if (entries === undefined) {
|
|
29
|
+
entries = store.listOperations("aside", Number.MAX_SAFE_INTEGER);
|
|
30
|
+
entriesByRoot.set(store.root, entries);
|
|
31
|
+
}
|
|
32
|
+
for (const entry of entries) {
|
|
33
|
+
if (entry.clientId === "aside" && entry.configPath === profile.configPath) {
|
|
34
|
+
assertAsideSnapshotEntry(entry);
|
|
35
|
+
if (typeof entry.at !== "string") throw new AsideProfileError("aside_operation_invalid", 409, "Aside operation timestamp is invalid");
|
|
36
|
+
rows.push({ profileId: profile.id, entry, store });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// A copied entry retains its original timestamp; import time cannot make it newest.
|
|
42
|
+
return rows.sort((a, b) => b.entry.at.localeCompare(a.entry.at));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function uniqueOperations(rows: AsideOperation[]): AsideOperation[] {
|
|
46
|
+
const seen = new Map<string, AsideOperation>();
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
const previous = seen.get(row.entry.opId);
|
|
49
|
+
if (previous && (previous.profileId !== row.profileId || JSON.stringify(previous.entry) !== JSON.stringify(row.entry))) {
|
|
50
|
+
throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation identifies multiple profiles");
|
|
51
|
+
}
|
|
52
|
+
if (!previous) {
|
|
53
|
+
seen.set(row.entry.opId, row);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Identical journal rows can outlive different snapshot-retention windows.
|
|
57
|
+
const previousSnapshot = previous.store.readSnapshot(previous.entry);
|
|
58
|
+
const candidateSnapshot = row.store.readSnapshot(row.entry);
|
|
59
|
+
if (previousSnapshot.kind === "stored" && candidateSnapshot.kind === "stored"
|
|
60
|
+
&& previousSnapshot.text !== candidateSnapshot.text) {
|
|
61
|
+
throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation has conflicting snapshot copies");
|
|
62
|
+
}
|
|
63
|
+
if (previousSnapshot.kind === "expired" && candidateSnapshot.kind === "stored") seen.set(row.entry.opId, row);
|
|
64
|
+
}
|
|
65
|
+
return [...seen.values()];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function listAsideOperations(input: AsideProfilesInput, profileId?: number): AsideOperation[] {
|
|
69
|
+
try { return uniqueOperations(operationRows(createAsideProfileContext(input), profileId)); }
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (profileId === undefined && error instanceof AsideProfileError && error.code === "aside_profiles_unavailable") return [];
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function findOperation(ctx: AsideProfileContext, opId: string, profileId?: number): AsideOperation | null {
|
|
77
|
+
if (typeof opId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(opId)) {
|
|
78
|
+
throw new AsideProfileError("invalid_op_id", 400, "Aside operation ID is invalid");
|
|
79
|
+
}
|
|
80
|
+
const rows = uniqueOperations(operationRows(ctx, profileId));
|
|
81
|
+
const found = rows.find(row => row.entry.opId === opId);
|
|
82
|
+
if (found) return found;
|
|
83
|
+
const legacy = ctx.rootStore.findOperation(opId);
|
|
84
|
+
if (legacy?.clientId === "aside") {
|
|
85
|
+
if (!ctx.profiles.some(profile => profile.configPath === legacy.configPath)) {
|
|
86
|
+
throw new AsideProfileError("aside_profile_not_found", 404, "The operation's Aside profile is no longer registered");
|
|
87
|
+
}
|
|
88
|
+
if (profileId !== undefined) throw new AsideProfileError("aside_operation_profile_mismatch", 409, "Aside operation belongs to a different profile");
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function findAsideOperation(input: AsideProfilesInput, opId: string, profileId?: number): AsideOperation | null {
|
|
94
|
+
const legacy = asideRootStore(input).findOperation(opId);
|
|
95
|
+
if (profileId === undefined && legacy && legacy.clientId !== "aside") return null;
|
|
96
|
+
try { return findOperation(createAsideProfileContext(input), opId, profileId); }
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (profileId === undefined && !legacy && error instanceof AsideProfileError && error.code === "aside_profiles_unavailable") return null;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Guarded history projection: never expose profile bytes to API serializers. */
|
|
104
|
+
export function asideOperationMatchesCurrent(input: AsideProfilesInput, row: AsideOperation): boolean {
|
|
105
|
+
try {
|
|
106
|
+
const ctx = createAsideProfileContext(input);
|
|
107
|
+
const verified = findOperation(ctx, row.entry.opId, row.profileId);
|
|
108
|
+
if (!verified || verified.entry.configPath !== row.entry.configPath) return false;
|
|
109
|
+
const profile = selectAsideProfiles(ctx, row.profileId)[0]!;
|
|
110
|
+
const scope = asideProfileScope(ctx, profile);
|
|
111
|
+
const target = loadTarget(scope.io, profile.configPath);
|
|
112
|
+
return target.ok && scope.io.statKind(profile.detectDir) === "dir" && matchesOperationResult(verified.entry, target.before);
|
|
113
|
+
} catch { return false; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function requiredOperation(ctx: AsideProfileContext, opId: string, profileId?: number): AsideOperation {
|
|
117
|
+
const row = findOperation(ctx, opId, profileId);
|
|
118
|
+
if (!row) throw new AsideProfileError("integration_operation_not_found", 404, "Aside operation not found");
|
|
119
|
+
return row;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validatePriorRecord(record: OwnershipRecord | null, configPath: string): void {
|
|
123
|
+
if (record === null) return;
|
|
124
|
+
if (!record || record.clientId !== "aside" || record.configPath !== configPath
|
|
125
|
+
|| typeof record.fileFingerprint !== "string" || typeof record.blockFingerprint !== "string"
|
|
126
|
+
|| typeof record.opId !== "string" || typeof record.appliedAt !== "string"
|
|
127
|
+
|| !Array.isArray(record.fragmentPaths) || record.fragmentPaths.length !== 1
|
|
128
|
+
|| record.fragmentPaths[0]?.length !== 2 || record.fragmentPaths[0][0] !== "providers"
|
|
129
|
+
|| record.fragmentPaths[0][1] !== "opencodex"
|
|
130
|
+
|| (record.createdContainers !== undefined && (!Array.isArray(record.createdContainers)
|
|
131
|
+
|| !record.createdContainers.every(path => path === "providers")))) {
|
|
132
|
+
throw new AsideProfileError("aside_operation_invalid", 409, "Aside operation ownership metadata is invalid");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function snapshotWasOwned(entry: JournalEntry, text: string | null, bound: IntegrationWriteInput): boolean {
|
|
137
|
+
const record = entry.priorRecord;
|
|
138
|
+
if (!record || text === null || record.fileFingerprint !== fingerprint(text)) return false;
|
|
139
|
+
const state = classifyIntegration({
|
|
140
|
+
fileText: text, fileIsRegular: true, parsed: parseConfig(text, EXPORT_CLIENTS.aside.format), record,
|
|
141
|
+
contribution: EXPORT_CLIENTS.aside.buildContribution(exportContextOf(bound)),
|
|
142
|
+
configPath: entry.configPath, clientId: "aside",
|
|
143
|
+
}).state;
|
|
144
|
+
return state === "current" || state === "stale";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Import only an immutable historical row and its bytes, never the legacy ownership record. */
|
|
148
|
+
function importOperation(row: AsideOperation, scope: AsideProfileScope): void {
|
|
149
|
+
if (row.store.root === scope.store.root) return;
|
|
150
|
+
const existing = scope.store.findOperation(row.entry.opId);
|
|
151
|
+
if (existing && JSON.stringify(existing) !== JSON.stringify(row.entry)) {
|
|
152
|
+
throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside operation conflicts with existing profile history");
|
|
153
|
+
}
|
|
154
|
+
const snapshot = row.store.readSnapshot(row.entry);
|
|
155
|
+
if (snapshot.kind === "expired") throw new AsideProfileError("integration_snapshot_expired", 410, "That backup has expired");
|
|
156
|
+
scope.io.statKind(scope.profile.detectDir);
|
|
157
|
+
scope.assertBoundary();
|
|
158
|
+
if (snapshot.kind === "stored") {
|
|
159
|
+
const present = scope.store.readSnapshot(row.entry);
|
|
160
|
+
if (present.kind === "stored" && present.text !== snapshot.text) {
|
|
161
|
+
throw new AsideProfileError("aside_operation_ambiguous", 409, "Aside snapshot conflicts with existing profile history");
|
|
162
|
+
}
|
|
163
|
+
if (present.kind !== "stored") scope.store.captureSnapshot("aside", row.entry.opId, snapshot.text);
|
|
164
|
+
}
|
|
165
|
+
if (!existing) scope.store.appendJournal(structuredClone(row.entry));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function restoreAsideProfile(
|
|
169
|
+
input: AsideProfilesInput,
|
|
170
|
+
request: { opId: string; profileId?: number; confirmDrift?: boolean },
|
|
171
|
+
): Promise<AsideProfileWriteOutcome> {
|
|
172
|
+
return runAsideProfileAction<AsideProfileWriteOutcome>(input, request.profileId, `restore:${request.opId}:${Boolean(request.confirmDrift)}`, async ctx => {
|
|
173
|
+
const row = requiredOperation(ctx, request.opId, request.profileId);
|
|
174
|
+
const profile = selectAsideProfiles(ctx, row.profileId)[0]!;
|
|
175
|
+
const scope = asideProfileScope(ctx, profile);
|
|
176
|
+
assertAsideSnapshotEntry(row.entry);
|
|
177
|
+
validatePriorRecord(row.entry.priorRecord, profile.configPath);
|
|
178
|
+
const snapshot = row.store.readSnapshot(row.entry);
|
|
179
|
+
if (snapshot.kind === "expired") {
|
|
180
|
+
return { clientId: "aside", profileId: profile.id, ok: false, reason: "snapshot_expired", state: "absent", message: "That backup has expired" };
|
|
181
|
+
}
|
|
182
|
+
const bound = await asideWriteInput(ctx, scope);
|
|
183
|
+
const target = loadTarget(scope.io, profile.configPath);
|
|
184
|
+
if (!target.ok || scope.io.statKind(profile.detectDir) !== "dir") {
|
|
185
|
+
return asideProfileFailure(profile.id, new AsideProfileError("aside_profile_unsafe", 409, "Aside profile cannot be restored safely"));
|
|
186
|
+
}
|
|
187
|
+
if (!request.confirmDrift && !matchesOperationResult(row.entry, target.before)) {
|
|
188
|
+
return { clientId: "aside", profileId: profile.id, ok: false, reason: "drift_requires_confirm", state: "conflict", message: "This profile changed after that operation; confirm to replace it" };
|
|
189
|
+
}
|
|
190
|
+
const restoredText = snapshot.kind === "stored" ? snapshot.text : null;
|
|
191
|
+
await persistAsidePolicy(ctx, { profileId: profile.id, enabled: snapshotWasOwned(row.entry, restoredText, bound) });
|
|
192
|
+
try {
|
|
193
|
+
scope.assertBoundary();
|
|
194
|
+
const currentSnapshot = row.store.readSnapshot(row.entry);
|
|
195
|
+
if (JSON.stringify(row.store.findOperation(row.entry.opId)) !== JSON.stringify(row.entry)
|
|
196
|
+
|| currentSnapshot.kind !== snapshot.kind
|
|
197
|
+
|| (currentSnapshot.kind === "stored" && currentSnapshot.text !== restoredText)) {
|
|
198
|
+
throw new AsideProfileError("aside_operation_changed", 409, "Aside operation or snapshot changed while saving preferences");
|
|
199
|
+
}
|
|
200
|
+
importOperation(row, scope);
|
|
201
|
+
return { ...await restoreIntegrationCoordinated({ ...bound, opId: request.opId, confirmDrift: request.confirmDrift }, { lockSeams: input.lockSeams }), profileId: profile.id };
|
|
202
|
+
} catch (error) { return asideProfileFailure(profile.id, error); }
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function deleteAsideOperation(
|
|
207
|
+
input: AsideProfilesInput,
|
|
208
|
+
request: { opId: string; profileId?: number; principal?: string },
|
|
209
|
+
): Promise<{ ok: true; clientId: "aside"; profileId: number; opId: string; snapshotRemoved: boolean }> {
|
|
210
|
+
return runAsideProfileAction(input, request.profileId, `delete:${request.opId}`, async ctx => {
|
|
211
|
+
const row = requiredOperation(ctx, request.opId, request.profileId);
|
|
212
|
+
const rows = operationRows(ctx, row.profileId);
|
|
213
|
+
if (rows[0]?.entry.opId === request.opId) {
|
|
214
|
+
throw new AsideProfileError("integration_journal_newest_protected", 409, "The newest operation for an Aside profile cannot be deleted");
|
|
215
|
+
}
|
|
216
|
+
await persistAsidePolicy(ctx);
|
|
217
|
+
const stores = new Map(rows.filter(candidate => candidate.entry.opId === request.opId).map(candidate => [candidate.store.root, candidate.store]));
|
|
218
|
+
const tombstone = { tombstone: request.opId, at: new Date(input.io?.now() ?? Date.now()).toISOString(), by: request.principal ?? "management" };
|
|
219
|
+
// Retire every copy before pruning any snapshot; deduped history must not resurrect a source row.
|
|
220
|
+
for (const store of stores.values()) store.retireOperation(tombstone);
|
|
221
|
+
let snapshotRemoved = true;
|
|
222
|
+
for (const store of stores.values()) {
|
|
223
|
+
const pruned = store.pruneSnapshots("aside");
|
|
224
|
+
if (pruned.ok) store.clearPruneFailure("aside");
|
|
225
|
+
else { snapshotRemoved = false; store.markPruneFailure("aside", pruned.error); }
|
|
226
|
+
}
|
|
227
|
+
return { ok: true, clientId: "aside", profileId: row.profileId, opId: request.opId, snapshotRemoved };
|
|
228
|
+
});
|
|
229
|
+
}
|