From e96c5dc4829a4c2256475c314ba64fd2f4083cbb Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 00:08:38 +0900 Subject: [PATCH 01/83] feat(integrations): own value-free mutation planning in a pure seam An operator confirming apply, overwrite, disable or undo agrees to consequences nobody has shown them, and the only way to show those consequences safely is to compute them somewhere that cannot write. src/integrations/mutation-plan.ts is that place. It owns the closed operation, change-kind and foreign-edit vocabularies, the bounded plan shape, managed schema path canonicalization, deterministic ordering with deduplication and a cap, and a versioned fingerprint over every input a plan's authority rests on. It holds no IO, takes no lock, and does not import the writer, so the direction is state/ownership/merge into the planner and the planner into the writer. Two decisions are load-bearing. A path segment that is not representable in the managed grammar invalidates its whole path rather than being skipped, because a skipped segment names a different place than the one being changed, and an ownership record on disk accepts arbitrary strings and is not a validation authority. And the fingerprint covers the model snapshot and, for restore, a digest of the snapshot's actual bytes rather than only its operation id: the contribution is derived from the models, and the bytes are what would land in the user's file. RefusalReason moves here so a plan can report a refusal without depending on the module that performs writes. The writer re-exports it, so every existing caller is unaffected and no second vocabulary exists. No behavior changes: nothing calls the planner yet. local checks: NOT RUN --- src/integrations/mutation-plan.ts | 191 ++++++++++++++++++++++++++++++ src/integrations/writer.ts | 15 ++- 2 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 src/integrations/mutation-plan.ts diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts new file mode 100644 index 00000000000..15e37c0bdf1 --- /dev/null +++ b/src/integrations/mutation-plan.ts @@ -0,0 +1,191 @@ +/** + * Value-free planning for integration mutations. + * + * An operator confirming "apply", "overwrite", "disable" or "undo" is agreeing to consequences + * nobody has shown them. This module computes those consequences as bounded managed schema paths + * and closed change kinds, and binds them to a fingerprint over every input the decision rested + * on, so a confirmation can be refused when the state it described has moved. + * + * Two rules give the output its safety. Nothing here is a value: paths are structural, and a + * segment that is not representable in the managed grammar is dropped rather than echoed, because + * an ownership record on disk accepts arbitrary strings and is not a validation authority. And + * nothing here writes: this module owns no IO, takes no lock, and must never import the writer. + * The dependency direction is state/ownership/merge into here, and here into the writer and the + * preview route. + */ +import { createHash } from "node:crypto"; +import { canonicalContribution, fingerprint, type OwnershipRecord } from "./ownership"; +import type { ExportModel, ManagedContribution } from "../clients/config-export"; +import type { IntegrationClientId } from "./registry"; +import type { IntegrationState } from "./state"; +import type { JournalEntry } from "./journal"; + +/** + * Why a mutation refused. Declared here rather than in the writer so the planner can report a + * refusal without depending on the module that performs writes; the writer re-exports it, so this + * is a move rather than a second vocabulary. + */ +export type RefusalReason = + | "not_installed" + | "conflict" + | "unsafe" + | "non_loopback" + | "drift_requires_confirm" + | "snapshot_expired" + | "write_failed"; + +export type IntegrationPlanOperation = "apply" | "overwrite" | "disable" | "restore"; +export type IntegrationPlanChangeKind = "add" | "replace" | "remove" | "snapshot" | "ownership" | "journal"; +export type IntegrationPlanForeignEdit = "none" | "unowned" | "foreign-edit" | "drift"; + +/** + * Effects that are not places in the client's document. They carry no disk location and no value, + * so an operator learns that history will be written without learning where it lives. + */ +export const PLAN_SNAPSHOT_PATH = "$snapshot"; +export const PLAN_OWNERSHIP_PATH = "$ownership"; +export const PLAN_JOURNAL_PATH = "$journal"; + +/** + * Upper bound on reported changes. Above the largest contribution any registered client builds and + * well below a response worth truncating, so the cap is a guard rather than a routine limit. + */ +export const PLAN_CHANGE_LIMIT = 256; + +export interface IntegrationPlanChange { + readonly kind: IntegrationPlanChangeKind; + readonly path: string; +} + +export interface IntegrationMutationPlan { + readonly version: 1; + readonly clientId: IntegrationClientId; + readonly operation: IntegrationPlanOperation; + readonly state: IntegrationState; + readonly foreignEdit: IntegrationPlanForeignEdit; + readonly changes: readonly IntegrationPlanChange[]; + readonly fingerprint: string; + readonly canApply: boolean; + readonly refusalReason?: RefusalReason; + readonly profileId?: number; +} + +const PLAIN_KEY = /^[A-Za-z0-9_-]+$/; +const SELECTOR_HINT = /[[\]=]/; + +/** + * One path segment as it may be published. + * + * A plain key is structural and safe to name. Anything carrying selector syntax identifies a + * member chosen at runtime, so it collapses to a wildcard: which entry was selected is exactly the + * kind of identity this response does not publish. Anything else is not representable, and an + * unrepresentable segment invalidates the whole path rather than being silently skipped, because + * skipping it would produce a path that names a different place than the one being changed. + */ +function canonicalSegment(segment: string): string | null { + if (PLAIN_KEY.test(segment)) return segment; + if (SELECTOR_HINT.test(segment)) return "*"; + return null; +} + +/** A managed schema path, or null when any segment is not representable. */ +export function canonicalSchemaPath(path: readonly string[]): string | null { + if (path.length === 0) return null; + const segments: string[] = []; + for (const segment of path) { + const canonical = canonicalSegment(segment); + if (canonical === null) return null; + segments.push(canonical); + } + return segments.join("."); +} + +const KIND_ORDER: readonly IntegrationPlanChangeKind[] = ["add", "replace", "remove", "snapshot", "ownership", "journal"]; + +/** + * Deterministic, deduplicated and capped. Deterministic because the fingerprint is taken over this + * projection, so an unstable order would stale a plan that did not change. + */ +export function orderPlanChanges(changes: readonly IntegrationPlanChange[]): readonly IntegrationPlanChange[] { + const seen = new Set(); + const unique: IntegrationPlanChange[] = []; + for (const change of changes) { + const key = `${change.kind}\u0000${change.path}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(change); + } + unique.sort((left, right) => { + const byKind = KIND_ORDER.indexOf(left.kind) - KIND_ORDER.indexOf(right.kind); + if (byKind !== 0) return byKind; + return left.path < right.path ? -1 : left.path > right.path ? 1 : 0; + }); + return Object.freeze(unique.slice(0, PLAN_CHANGE_LIMIT)); +} + +/** + * Every input the plan's authority rests on. + * + * `models` is here because the desired contribution is derived from it, so a model roster that + * changed between preview and commit changes what would be written. `snapshot` carries a digest of + * the bytes a restore would actually publish rather than only the operation id, because the id + * names the row and the bytes are what lands in the user's file. + */ +export interface PlanFingerprintInput { + readonly operation: IntegrationPlanOperation; + readonly clientId: IntegrationClientId; + readonly profileId?: number; + readonly configPath: string; + readonly detectDir: string; + /** Exact current bytes, or null when the target is missing. Missing and empty are not equal. */ + readonly before: string | null; + readonly contribution: ManagedContribution | null; + readonly record: OwnershipRecord | null; + readonly models: readonly ExportModel[]; + readonly restore?: { + readonly opId: string; + readonly entry: JournalEntry; + readonly snapshotKind: string; + /** Digest of the snapshot's exact text, or null when it holds none. */ + readonly snapshotText: string | null; + readonly confirmDrift: boolean; + }; +} + +const PLAN_FINGERPRINT_VERSION = "p1"; + +function digest(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 32); +} + +/** + * An opaque optimistic-concurrency token, not authorization. + * + * Longer than the 16-hex ownership fingerprint because this one is supplied by a caller and + * compared for equality, so accidental collision matters more than it does for a stored digest. + * The version prefix means a future input set invalidates old tokens instead of silently + * comparing two different meanings. + */ +export function planFingerprint(input: PlanFingerprintInput): string { + const restore = input.restore; + const components = [ + PLAN_FINGERPRINT_VERSION, + input.operation, + input.clientId, + input.profileId === undefined ? null : input.profileId, + input.configPath, + input.detectDir, + input.before === null ? "\u0000absent" : fingerprint(input.before), + input.contribution === null ? null : fingerprint(canonicalContribution(input.contribution)), + input.record === null ? null : fingerprint(JSON.stringify(input.record)), + fingerprint(JSON.stringify(input.models)), + restore === undefined ? null : [ + restore.opId, + fingerprint(JSON.stringify(restore.entry)), + restore.snapshotKind, + restore.snapshotText === null ? "\u0000none" : fingerprint(restore.snapshotText), + restore.confirmDrift, + ], + ]; + return `${PLAN_FINGERPRINT_VERSION}:${digest(JSON.stringify(components))}`; +} diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 0bac4da13a7..52e10775ff4 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -36,6 +36,7 @@ import type { IntegrationState } from "./state"; import { serializeDocument, UnserializableValueError } from "./serialize"; import { ClientPathError } from "../clients/config-export"; import { matchesOperationResult, newOpId, type JournalEntry } from "./journal"; +import type { RefusalReason } from "./mutation-plan"; import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; import { patchYamlFragmentSource, @@ -62,14 +63,12 @@ function yamlRefusalReason( } import { withIntegrationWriterLock, type IntegrationWriterLockSeams } from "./writer-lock"; -export type RefusalReason = - | "not_installed" - | "conflict" - | "unsafe" - | "non_loopback" - | "drift_requires_confirm" - | "snapshot_expired" - | "write_failed"; +/** + * Owned by the planner so a plan can report a refusal without depending on the writer, and + * re-exported here because this module was its original home and every caller imports it from + * the writer. + */ +export type { RefusalReason }; export interface WriteOk { ok: true; From 483ac7bc03271df71a92658d4664aa301ede7c73 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 00:12:23 +0900 Subject: [PATCH 02/83] refactor(integrations): share one observation between preview and mutation A preview that reads the file separately from the mutation it authorizes can disagree with it, and then the operator confirms one thing while another happens. The read, parse, contribution build, record selection and classification now live once in the planner, and the writer consumes that result instead of performing its own. The move is behaviour-preserving for mutation, including refusal ordering, which is load-bearing. What changes is that the two effects a preview must not have are named rather than implied: pending-prune maintenance and Cline transaction recovery both write, so they are explicit options with no default. Mutation passes both; a preview will pass neither. Leaving them defaulted was the more dangerous option, because a preview would inherit a write while still being called a read. Refusals cross the boundary in the planner's own vocabulary, so the planner keeps no dependency on the writer's result type; the writer translates them back into WriteRefused, preserving the Cline residual marker. IntegrationWriteInput moves down with the observation that consumes it and is re-exported, so the management routes, the Aside adapters and the existing tests are unaffected. local checks: NOT RUN --- src/integrations/mutation-plan.ts | 139 +++++++++++++++++++++++++++++- src/integrations/writer.ts | 105 ++++------------------ 2 files changed, 154 insertions(+), 90 deletions(-) diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index 15e37c0bdf1..b0255dd7c6d 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -15,9 +15,14 @@ */ import { createHash } from "node:crypto"; import { canonicalContribution, fingerprint, type OwnershipRecord } from "./ownership"; -import type { ExportModel, ManagedContribution } from "../clients/config-export"; -import type { IntegrationClientId } from "./registry"; -import type { IntegrationState } from "./state"; +import { ClientPathError, EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export"; +import { createClineIO, ClineTransactionError } from "./cline-io"; +import { parseClineDocument } from "./cline-document"; +import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; +import { INTEGRATION_CLIENTS, resolveIntegrationPaths, type IntegrationClientId } from "./registry"; +import { classifyIntegration, exportContextOf, type IntegrationState } from "./state"; +import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; +import type { OcxConfig } from "../types"; import type { JournalEntry } from "./journal"; /** @@ -189,3 +194,131 @@ export function planFingerprint(input: PlanFingerprintInput): string { ]; return `${PLAN_FINGERPRINT_VERSION}:${digest(JSON.stringify(components))}`; } + +/** + * What a mutation is asked to do. Declared here because the observation below consumes it and the + * planner must not depend on the writer; the writer re-exports it, so callers are unaffected. + */ +export interface IntegrationWriteInput { + clientId: IntegrationClientId; + models: readonly ExportModel[]; + config: OcxConfig; + port: number; + env?: NodeJS.ProcessEnv; + home?: string; + store?: IntegrationStateStore; + io?: IntegrationIO; + /** Frozen once by the async coordinator; synchronous callers may omit it. */ + resolvedPaths?: { configPath: string; detectDir: string }; +} + +/** A refusal in the planner's own vocabulary, so observation does not depend on the writer's result type. */ +export interface IntegrationObservationFailure { + readonly reason: RefusalReason; + readonly state: IntegrationState; + readonly message: string; + readonly snapshotPath?: string; + /** A Cline transaction left residue that only a mutation may clear. */ + readonly residual?: boolean; +} + +function observationFailure( + reason: RefusalReason, + state: IntegrationState, + message: string, + snapshotPath?: string, +): IntegrationObservationFailure { + return { reason, state, message, ...(snapshotPath ? { snapshotPath } : {}) }; +} + +/** + * What preview and mutation are allowed to touch while looking. + * + * Both are false for a preview and both are true for a mutation, and neither defaults, because the + * difference is the whole safety argument. `maintenance` runs pending snapshot pruning, which + * writes; `recover` lets the Cline adapter repair a pending transaction, which also writes. A + * preview that quietly inherited either would be a mutation wearing a read's name. + */ +export interface ObservationEffects { + readonly maintenance: boolean; + readonly recover: boolean; +} + +/** + * Detect, gate, read, parse and classify, once, for both preview and mutation. + * + * Extracted from the writer so a plan and the mutation it authorizes rest on the same + * classification rather than two independent reads that can disagree. The ordering of refusals is + * load-bearing and is preserved exactly as the writer had it. + */ +export function observeIntegration(input: IntegrationWriteInput, effects: ObservationEffects) { + const store = input.store ?? createIntegrationStateStore(); + let io = input.io ?? defaultIntegrationIO(store); + const clientId = input.clientId; + const spec = INTEGRATION_CLIENTS[clientId]; + const exportSpec = EXPORT_CLIENTS[clientId]; + /* + * Resolution itself can refuse: a relative OPENCLAW_* selector is rejected + * because we cannot know the gateway's working directory. That is a refusal + * about the user's configuration, not an internal fault, so it must not + * escape as an exception — the collection route would answer 500 for the + * whole Integrations page because one client is misconfigured. + */ + let configPath: string; + let detectDir: string; + try { + /* + * Resolve the PAIR, never one half. + * + * The coordinated path hands us a frozen pair, but applyIntegration, + * refreshIntegration and disableIntegration are public and may be called + * without one. Resolving configPath here and detectDir separately later let + * an Aside account switch land between the two, so a direct apply could + * verify account 1 was installed and then write account 0's catalog. + */ + const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home); + configPath = resolved.configPath; + detectDir = resolved.detectDir; + if (clientId === "cline") io = createClineIO(io, configPath, store, effects.recover); + } catch (error) { + if (error instanceof ClineTransactionError) { + return { failed: { ...observationFailure("unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const; + } + if (!(error instanceof ClientPathError)) throw error; + return { failed: observationFailure("unsafe", "unsafe", error.message) } as const; + } + // Pruning writes, so only a mutation may perform it. Preview reports the state it finds. + if (effects.maintenance) store.retryPendingPrunes(); + + const target = loadTarget(io, configPath); + if (!target.ok) { + return { + failed: observationFailure("unsafe", "unsafe", + target.why === "read-failed" + ? `${configPath} exists but could not be read` + : `${configPath} is not a regular file`), + } as const; + } + const before = target.before; + const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, exportSpec.format); + if (parsed === PARSE_FAILED) { + return { failed: observationFailure("unsafe", "unsafe", + `${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, a duplicate member, or nesting deeper than 1000 levels)`) } as const; + } + const contribution = exportSpec.buildContribution(exportContextOf(input)); + // A record proves ownership of the file it was written FOR. Matching only by + // client id let a record for one home authorize a write to another whose + // bytes happened to hash the same — which deleted a config we never touched. + const stored = store.readRecords()[clientId] ?? null; + const record = stored && stored.clientId === clientId && stored.configPath === configPath + ? stored + : null; + // `configPath`/`clientId` are load-bearing, not decoration: a record proves + // ownership of ONE file, and the writer mutates whatever path resolves NOW. + // Without them a record written for another home directory would grant + // ownership here and disable would delete fragments it never wrote. + const classified = classifyIntegration({ + fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId, + }); + return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } as const; +} diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 52e10775ff4..4bf2766c164 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -11,12 +11,12 @@ */ import { homedir } from "node:os"; import { createClineIO, ClineTransactionError } from "./cline-io"; -import { parseClineDocument, serializeClineDocument, preserveClineSelection } from "./cline-document"; +import { serializeClineDocument, preserveClineSelection } from "./cline-document"; import { dirname } from "node:path"; import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export"; import { shouldInjectApiAuthHeader } from "../codex/inject"; import type { OcxConfig } from "../types"; -import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; +import { defaultIntegrationIO, loadTarget, type IntegrationIO } from "./config-io"; import { fingerprint, canonicalContribution, @@ -31,12 +31,12 @@ import { } from "./ownership-policy"; import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, removeFragments } from "./merge"; import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry"; -import { classifyIntegration, exportContextOf } from "./state"; +import { exportContextOf } from "./state"; import type { IntegrationState } from "./state"; import { serializeDocument, UnserializableValueError } from "./serialize"; import { ClientPathError } from "../clients/config-export"; import { matchesOperationResult, newOpId, type JournalEntry } from "./journal"; -import type { RefusalReason } from "./mutation-plan"; +import { observeIntegration, type IntegrationWriteInput, type RefusalReason } from "./mutation-plan"; import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; import { patchYamlFragmentSource, @@ -68,7 +68,7 @@ import { withIntegrationWriterLock, type IntegrationWriterLockSeams } from "./wr * re-exported here because this module was its original home and every caller imports it from * the writer. */ -export type { RefusalReason }; +export type { IntegrationWriteInput, RefusalReason }; export interface WriteOk { ok: true; @@ -93,19 +93,6 @@ export interface WriteRefused { export type WriteOutcome = WriteOk | WriteRefused; -export interface IntegrationWriteInput { - clientId: IntegrationClientId; - models: readonly ExportModel[]; - config: OcxConfig; - port: number; - env?: NodeJS.ProcessEnv; - home?: string; - store?: IntegrationStateStore; - io?: IntegrationIO; - /** Frozen once by the async coordinator; synchronous callers may omit it. */ - resolvedPaths?: { configPath: string; detectDir: string }; -} - export interface IntegrationRestoreInput extends IntegrationWriteInput { opId: string; confirmDrift?: boolean; @@ -226,76 +213,20 @@ function sourcePreservingFragmentValue( return fragment?.value; } -/** Shared preflight: detect, gate, read, parse and classify. */ +/** + * Mutation's view of the shared observation. + * + * The read, parse and classify live in the planner so a preview and the mutation it authorizes + * cannot disagree. Mutation keeps the effects a preview must not have: pending-prune maintenance + * and Cline transaction recovery both write. Translating the planner's refusal into WriteRefused + * here keeps the planner free of any dependency on this module's result type. + */ function preflight(input: IntegrationWriteInput) { - const store = input.store ?? createIntegrationStateStore(); - let io = input.io ?? defaultIntegrationIO(store); - const clientId = input.clientId; - const spec = INTEGRATION_CLIENTS[clientId]; - const exportSpec = EXPORT_CLIENTS[clientId]; - /* - * Resolution itself can refuse: a relative OPENCLAW_* selector is rejected - * because we cannot know the gateway's working directory. That is a refusal - * about the user's configuration, not an internal fault, so it must not - * escape as an exception — the collection route would answer 500 for the - * whole Integrations page because one client is misconfigured. - */ - let configPath: string; - let detectDir: string; - try { - /* - * Resolve the PAIR, never one half. - * - * The coordinated path hands us a frozen pair, but applyIntegration, - * refreshIntegration and disableIntegration are public and may be called - * without one. Resolving configPath here and detectDir separately later let - * an Aside account switch land between the two, so a direct apply could - * verify account 1 was installed and then write account 0's catalog. - */ - const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home); - configPath = resolved.configPath; - detectDir = resolved.detectDir; - if (clientId === "cline") io = createClineIO(io, configPath, store, true); - } catch (error) { - if (error instanceof ClineTransactionError) { - return { failed: { ...refuse(clientId, "unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const; - } - if (!(error instanceof ClientPathError)) throw error; - return { failed: refuse(clientId, "unsafe", "unsafe", error.message) } as const; - } - store.retryPendingPrunes(); - - const target = loadTarget(io, configPath); - if (!target.ok) { - return { - failed: refuse(clientId, "unsafe", "unsafe", - target.why === "read-failed" - ? `${configPath} exists but could not be read` - : `${configPath} is not a regular file`), - } as const; - } - const before = target.before; - const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, exportSpec.format); - if (parsed === PARSE_FAILED) { - return { failed: refuse(clientId, "unsafe", "unsafe", - `${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, a duplicate member, or nesting deeper than 1000 levels)`) } as const; - } - const contribution = exportSpec.buildContribution(exportContextOf(input)); - // A record proves ownership of the file it was written FOR. Matching only by - // client id let a record for one home authorize a write to another whose - // bytes happened to hash the same — which deleted a config we never touched. - const stored = store.readRecords()[clientId] ?? null; - const record = stored && stored.clientId === clientId && stored.configPath === configPath - ? stored - : null; - // `configPath`/`clientId` are load-bearing, not decoration: a record proves - // ownership of ONE file, and the writer mutates whatever path resolves NOW. - // Without them a record written for another home directory would grant - // ownership here and disable would delete fragments it never wrote. - const classified = classifyIntegration({ - fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId, - }); - return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } as const; + const observed = observeIntegration(input, { maintenance: true, recover: true }); + if (!observed.failed) return observed; + const { reason, state, message, snapshotPath, residual } = observed.failed; + const refused = refuse(input.clientId, reason, state, message, snapshotPath); + return { failed: residual ? { ...refused, residual: true } : refused } as const; } /** From 6b91ce8f67a257335c3dc8269a0d71ada1501454 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 00:18:11 +0900 Subject: [PATCH 03/83] test(integrations): pin the plan projection and fingerprint contract Covers the three decisions the plan seam exists to guarantee. A path segment that is not representable in the managed grammar invalidates its whole path rather than being dropped, because an ownership record accepts arbitrary strings and a partially dropped path names a different place than the one being changed. A selected member collapses to a wildcard, so which entry was chosen is not published. The fingerprint moves for every input a confirmation rests on, including the model roster, since the contribution is derived from it, and it distinguishes an absent file from an empty one because restoring over each is a different operation. For restore it moves when the snapshot bytes change while the operation id and snapshot kind stay the same: binding only the id would leave the bytes that actually land in the user's file outside the confirmation. Reported changes are deduplicated, ordered and capped, and the result is frozen so a caller that sorted it in place could not edit shared plan state. Registered in both test-layout inventories. local checks: NOT RUN --- scripts/test-layout/layout.json | 1 + tests/clients/mutation-plan.test.ts | 158 +++++++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 3 files changed, 160 insertions(+) create mode 100644 tests/clients/mutation-plan.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 32615632839..3e2964cfcc3 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -967,6 +967,7 @@ "muse-spark-web-search-compat.test.ts": "providers", "muse-subscription-usage.test.ts": "providers", "muse-tool-name-alias.test.ts": "providers", + "mutation-plan.test.ts": "clients", "namespace-tool-compat.test.ts": "responses", "native-alias-maintainer-regressions.test.ts": "codex-integration", "native-claude-code-toggle.test.ts": "codex-integration", diff --git a/tests/clients/mutation-plan.test.ts b/tests/clients/mutation-plan.test.ts new file mode 100644 index 00000000000..5948aef05f6 --- /dev/null +++ b/tests/clients/mutation-plan.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test"; +import { + PLAN_CHANGE_LIMIT, + canonicalSchemaPath, + orderPlanChanges, + planFingerprint, + type IntegrationPlanChange, + type PlanFingerprintInput, +} from "../../src/integrations/mutation-plan"; +import type { ManagedContribution } from "../../src/clients/config-export"; +import type { OwnershipRecord } from "../../src/integrations/ownership"; +import type { JournalEntry } from "../../src/integrations/journal"; + +const CONFIG_PATH = "/home/example/.cline/config.json"; + +const RECORD: OwnershipRecord = { + clientId: "cline", + configPath: CONFIG_PATH, + fileFingerprint: "0123456789abcdef", + blockFingerprint: "fedcba9876543210", + fragmentPaths: [["providers", "opencodex"]], + appliedAt: "2026-01-01T00:00:00.000Z", + opId: "op-base", +}; + +const CONTRIBUTION: ManagedContribution = { + clientId: "cline", + fragments: [{ path: ["providers", "opencodex"], value: { baseUrl: "http://127.0.0.1:10100" } }], +}; + +const ENTRY: JournalEntry = { + opId: "op-base", + clientId: "cline", + kind: "apply", + at: "2026-01-01T00:00:00.000Z", + configPath: CONFIG_PATH, + snapshot: { kind: "stored", relPath: "snapshots/op-base.json" }, + resultFingerprint: "0123456789abcdef", + resultAbsent: false, + priorRecord: null, +}; + +const BASE: PlanFingerprintInput = { + operation: "apply", + clientId: "cline", + configPath: CONFIG_PATH, + detectDir: "/home/example/.cline", + before: "{}", + contribution: CONTRIBUTION, + record: RECORD, + models: [{ namespaced: "anthropic/claude", provider: "anthropic", id: "claude" }], +}; + +const RESTORE: NonNullable = { + opId: "op-base", + entry: ENTRY, + snapshotKind: "stored", + snapshotText: "{\"a\":1}", + confirmDrift: false, +}; + +const RESTORE_BASE: PlanFingerprintInput = { ...BASE, operation: "restore", restore: RESTORE }; + +describe("integration mutation plan projection", () => { + test("a managed path keeps plain keys and collapses a selected member", () => { + expect(canonicalSchemaPath(["providers", "opencodex", "baseUrl"])).toBe("providers.opencodex.baseUrl"); + // Which entry was selected is runtime identity, so the selector becomes a wildcard. + expect(canonicalSchemaPath(["providers", "[name=opencodex]", "baseUrl"])).toBe("providers.*.baseUrl"); + }); + + test("a segment that is not representable invalidates the whole path", () => { + // An ownership record accepts arbitrary strings, so a path is never trusted because a record + // carries it. Dropping only the bad segment would name a different place than the real one. + expect(canonicalSchemaPath(["providers", "../../etc/passwd"])).toBeNull(); + expect(canonicalSchemaPath(["providers", "a key with spaces"])).toBeNull(); + expect(canonicalSchemaPath([])).toBeNull(); + }); + + test("changes are deduplicated and ordered by kind then path", () => { + const input: IntegrationPlanChange[] = [ + { kind: "journal", path: "$journal" }, + { kind: "replace", path: "providers.b" }, + { kind: "add", path: "providers.z" }, + { kind: "replace", path: "providers.a" }, + { kind: "add", path: "providers.z" }, + ]; + expect(orderPlanChanges(input)).toEqual([ + { kind: "add", path: "providers.z" }, + { kind: "replace", path: "providers.a" }, + { kind: "replace", path: "providers.b" }, + { kind: "journal", path: "$journal" }, + ]); + }); + + test("the reported change list is capped and frozen", () => { + const many: IntegrationPlanChange[] = Array.from({ length: PLAN_CHANGE_LIMIT + 5 }, (_unused, index) => ({ + kind: "add" as const, + path: `providers.p${String(index).padStart(4, "0")}`, + })); + const ordered = orderPlanChanges(many); + expect(ordered.length).toBe(PLAN_CHANGE_LIMIT); + // Frozen because a caller that sorted this in place would be editing shared plan state. + expect(() => (ordered as IntegrationPlanChange[]).push({ kind: "add", path: "providers.extra" })).toThrow(); + }); +}); + +describe("integration plan fingerprint", () => { + test("is stable for the same inputs and carries its version", () => { + expect(planFingerprint(BASE)).toBe(planFingerprint({ ...BASE })); + expect(planFingerprint(BASE).startsWith("p1:")).toBe(true); + }); + + test("every authority input changes it", () => { + const variants: PlanFingerprintInput[] = [ + BASE, + { ...BASE, operation: "overwrite" }, + { ...BASE, clientId: "opencode" }, + { ...BASE, profileId: 1 }, + { ...BASE, configPath: "/home/other/.cline/config.json" }, + { ...BASE, detectDir: "/home/other/.cline" }, + // Different bytes, and absent distinguished from empty: restoring over a missing file and + // over an empty one are different operations. + { ...BASE, before: "{ }" }, + { ...BASE, before: "" }, + { ...BASE, before: null }, + { ...BASE, contribution: null }, + { + ...BASE, + contribution: { + clientId: "cline", + fragments: [{ path: ["providers", "opencodex"], value: { baseUrl: "http://127.0.0.1:10101" } }], + }, + }, + { ...BASE, record: null }, + // The contribution is derived from the model roster, so a changed roster changes what a + // confirmed apply would write. + { ...BASE, models: [{ namespaced: "anthropic/claude", provider: "anthropic", id: "claude-2" }] }, + { ...BASE, models: [] }, + ]; + expect(new Set(variants.map(planFingerprint)).size).toBe(variants.length); + }); + + test("restore binds the selected row and the bytes it would publish", () => { + const variants: PlanFingerprintInput[] = [ + RESTORE_BASE, + { ...RESTORE_BASE, restore: { ...RESTORE, opId: "op-other" } }, + { ...RESTORE_BASE, restore: { ...RESTORE, entry: { ...ENTRY, resultAbsent: true } } }, + { ...RESTORE_BASE, restore: { ...RESTORE, entry: { ...ENTRY, priorRecord: RECORD } } }, + { ...RESTORE_BASE, restore: { ...RESTORE, snapshotKind: "none" } }, + // Same row and same snapshot kind, different snapshot bytes. Binding only the operation id + // would leave the bytes that actually land in the user's file outside the confirmation. + { ...RESTORE_BASE, restore: { ...RESTORE, snapshotText: "{\"a\":2}" } }, + { ...RESTORE_BASE, restore: { ...RESTORE, snapshotText: null } }, + { ...RESTORE_BASE, restore: { ...RESTORE, confirmDrift: true } }, + ]; + expect(new Set(variants.map(planFingerprint)).size).toBe(variants.length); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 242d3b21983..93d545520e4 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -792,6 +792,7 @@ "muse-spark-web-search-compat.test.ts": "providers", "muse-subscription-usage.test.ts": "providers", "muse-tool-name-alias.test.ts": "providers", + "mutation-plan.test.ts": "clients", "namespace-tool-compat.test.ts": "responses", "native-alias-maintainer-regressions.test.ts": "codex-integration", "native-claude-code-toggle.test.ts": "codex-integration", From 8a6f7b4c95ccb69dda0423256892fdd0d2a44ef9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 00:23:04 +0900 Subject: [PATCH 04/83] fix(integrations): bind install state and admission to the plan fingerprint Review found that a confirmation could survive two changes it should not. The fingerprint bound the detect directory's path but not what was actually there, so an uninstall left every other component identical and the token still matched. It also bound nothing about admission eligibility, so a config change that makes the integration an illegal target was invisible to a plan that had already been shown. Both are now inputs: the observed kind of the detect directory, which is what the not_installed refusal is derived from, and whether admission policy blocks the client, which is the non_loopback refusal's input. A plan is a claim about a decision, so every input that decision is derived from has to be inside it. local checks: NOT RUN --- src/integrations/mutation-plan.ts | 17 +++++++++++++++++ tests/clients/mutation-plan.test.ts | 7 +++++++ 2 files changed, 24 insertions(+) diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index b0255dd7c6d..a693d393485 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -142,6 +142,21 @@ export interface PlanFingerprintInput { readonly profileId?: number; readonly configPath: string; readonly detectDir: string; + /** + * What the detect directory actually was when observed, not merely where it is. + * + * Binding only the path leaves a confirmation valid across an uninstall: the contribution is + * unchanged, so every other component matches, while the answer to "is this client installed" + * has flipped. The observed kind is the input the not_installed refusal is derived from. + */ + readonly installKind: string; + /** + * Whether admission policy blocks this integration, which is the non_loopback refusal's input. + * + * Config eligibility can change without touching the file, the record or the contribution, so a + * plan that did not bind it could be confirmed after the proxy stopped being a legal target. + */ + readonly admissionBlocked: boolean; /** Exact current bytes, or null when the target is missing. Missing and empty are not equal. */ readonly before: string | null; readonly contribution: ManagedContribution | null; @@ -180,6 +195,8 @@ export function planFingerprint(input: PlanFingerprintInput): string { input.profileId === undefined ? null : input.profileId, input.configPath, input.detectDir, + input.installKind, + input.admissionBlocked, input.before === null ? "\u0000absent" : fingerprint(input.before), input.contribution === null ? null : fingerprint(canonicalContribution(input.contribution)), input.record === null ? null : fingerprint(JSON.stringify(input.record)), diff --git a/tests/clients/mutation-plan.test.ts b/tests/clients/mutation-plan.test.ts index 5948aef05f6..db74c11f1e1 100644 --- a/tests/clients/mutation-plan.test.ts +++ b/tests/clients/mutation-plan.test.ts @@ -45,6 +45,8 @@ const BASE: PlanFingerprintInput = { clientId: "cline", configPath: CONFIG_PATH, detectDir: "/home/example/.cline", + installKind: "dir", + admissionBlocked: false, before: "{}", contribution: CONTRIBUTION, record: RECORD, @@ -118,6 +120,11 @@ describe("integration plan fingerprint", () => { { ...BASE, profileId: 1 }, { ...BASE, configPath: "/home/other/.cline/config.json" }, { ...BASE, detectDir: "/home/other/.cline" }, + // The contribution is identical across an uninstall and across a change in admission + // eligibility, so binding the path alone would keep a stale confirmation valid. + { ...BASE, installKind: "missing" }, + { ...BASE, installKind: "file" }, + { ...BASE, admissionBlocked: true }, // Different bytes, and absent distinguished from empty: restoring over a missing file and // over an empty one are different operations. { ...BASE, before: "{ }" }, From 24a372dd4125c412132290835fe1b72614c33ed0 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 00:25:31 +0900 Subject: [PATCH 05/83] fix(integrations): publish only declared managed paths, as templates Review found the path rule was an allowlist wearing a grammar's clothes. Any alphanumeric segment passed, and Kimi writes one fragment per model at models., so a user's model identifier would have been published verbatim. The same rule accepted any plain path sitting in an ownership record, and a record on disk is not a validation authority. Each client now declares where its managed fragments live. A path is published only when it matches one of that client's templates exactly: static segments must match literally, a dynamic position accepts any observed segment, and the string that leaves the planner is the template rather than the observed path. Publishing a value is therefore structurally impossible rather than merely unlikely, and a path nobody declared fails closed instead of being described. The declarations are checked against the client union, so adding a client without saying where it writes is a type error. Regressions cover the Kimi alias, a foreign static segment, another client's shape, extra depth, and that every shipped client has a declaration. local checks: NOT RUN --- src/integrations/mutation-plan.ts | 79 +++++++++++++++++++++-------- tests/clients/mutation-plan.test.ts | 40 +++++++++++---- 2 files changed, 88 insertions(+), 31 deletions(-) diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index a693d393485..313fac16d26 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -16,6 +16,7 @@ import { createHash } from "node:crypto"; import { canonicalContribution, fingerprint, type OwnershipRecord } from "./ownership"; import { ClientPathError, EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export"; +import { OPENCODE_PROVIDER_ID } from "../clients/config-export/constants"; import { createClineIO, ClineTransactionError } from "./cline-io"; import { parseClineDocument } from "./cline-document"; import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; @@ -75,34 +76,72 @@ export interface IntegrationMutationPlan { readonly profileId?: number; } -const PLAIN_KEY = /^[A-Za-z0-9_-]+$/; -const SELECTOR_HINT = /[[\]=]/; +/** A position whose observed value is never published, only its presence. */ +export const DYNAMIC_SEGMENT = "*"; /** - * One path segment as it may be published. + * Where each client's managed fragments live, declared rather than inferred. * - * A plain key is structural and safe to name. Anything carrying selector syntax identifies a - * member chosen at runtime, so it collapses to a wildcard: which entry was selected is exactly the - * kind of identity this response does not publish. Anything else is not representable, and an - * unrepresentable segment invalidates the whole path rather than being silently skipped, because - * skipping it would produce a path that names a different place than the one being changed. + * A general "looks like a plain key" rule is not good enough, and Kimi is the proof: it writes one + * fragment per model at `models.`, so an alphanumeric allowlist would publish a user's + * model identifier verbatim. The same rule would accept any plain path sitting in an ownership + * record, and a record on disk is not a validation authority. + * + * So a path is published only when it matches one of these templates exactly. Static segments must + * match literally, a DYNAMIC_SEGMENT position accepts any observed segment, and the string that + * leaves this module is the TEMPLATE rather than the observed path. That is what makes publishing + * a value structurally impossible instead of merely unlikely. + * + * The satisfies clause makes a new client a type error here, so nobody can add one whose managed + * paths silently have no declaration. */ -function canonicalSegment(segment: string): string | null { - if (PLAIN_KEY.test(segment)) return segment; - if (SELECTOR_HINT.test(segment)) return "*"; - return null; +const CLIENT_MANAGED_PATHS = { + opencode: [["provider", OPENCODE_PROVIDER_ID], ["providers", OPENCODE_PROVIDER_ID]], + pi: [["providers", OPENCODE_PROVIDER_ID]], + omp: [["providers", OPENCODE_PROVIDER_ID]], + hermes: [["providers", OPENCODE_PROVIDER_ID]], + openclaw: [["models", "providers", OPENCODE_PROVIDER_ID]], + kimi: [["providers", OPENCODE_PROVIDER_ID], ["models", DYNAMIC_SEGMENT]], + gajae: [["providers", OPENCODE_PROVIDER_ID]], + dsh: [["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID]], + mcode: [["custom_provider", OPENCODE_PROVIDER_ID]], + zcode: [["provider", OPENCODE_PROVIDER_ID]], + prime: [["providers", OPENCODE_PROVIDER_ID]], + aside: [["providers", OPENCODE_PROVIDER_ID]], + raycast: [["providers", `[id=${OPENCODE_PROVIDER_ID}]`]], + omo: [["providers", OPENCODE_PROVIDER_ID]], + cline: [ + ["settings", "providers", OPENCODE_PROVIDER_ID], + ["catalog", "providers", OPENCODE_PROVIDER_ID], + ], +} satisfies Record; + +/** Not a configuration surface. Exported so a parity case can compare it against the shipped clients. */ +export const MANAGED_PATH_TEMPLATES: Readonly> = CLIENT_MANAGED_PATHS; + +function matchesTemplate(template: readonly string[], path: readonly string[]): boolean { + if (template.length !== path.length) return false; + return template.every((segment, index) => { + const observed = path[index]; + if (observed === undefined || observed.length === 0) return false; + return segment === DYNAMIC_SEGMENT || segment === observed; + }); } -/** A managed schema path, or null when any segment is not representable. */ -export function canonicalSchemaPath(path: readonly string[]): string | null { +/** + * The managed schema path this change touches, or null when the path is outside the client's + * declared grammar. + * + * Null is not an error to work around. A path nobody declared is either a record written by a + * different version or something arbitrary, and neither is safe to name, so the caller reports the + * fixed ownership pseudo-path or a refusal instead of inventing a description. + */ +export function canonicalSchemaPath(clientId: IntegrationClientId, path: readonly string[]): string | null { if (path.length === 0) return null; - const segments: string[] = []; - for (const segment of path) { - const canonical = canonicalSegment(segment); - if (canonical === null) return null; - segments.push(canonical); + for (const template of CLIENT_MANAGED_PATHS[clientId]) { + if (matchesTemplate(template, path)) return template.join("."); } - return segments.join("."); + return null; } const KIND_ORDER: readonly IntegrationPlanChangeKind[] = ["add", "replace", "remove", "snapshot", "ownership", "journal"]; diff --git a/tests/clients/mutation-plan.test.ts b/tests/clients/mutation-plan.test.ts index db74c11f1e1..405dc0c6b1d 100644 --- a/tests/clients/mutation-plan.test.ts +++ b/tests/clients/mutation-plan.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + MANAGED_PATH_TEMPLATES, PLAN_CHANGE_LIMIT, canonicalSchemaPath, orderPlanChanges, @@ -7,7 +8,7 @@ import { type IntegrationPlanChange, type PlanFingerprintInput, } from "../../src/integrations/mutation-plan"; -import type { ManagedContribution } from "../../src/clients/config-export"; +import { EXPORT_CLIENTS, type ManagedContribution } from "../../src/clients/config-export"; import type { OwnershipRecord } from "../../src/integrations/ownership"; import type { JournalEntry } from "../../src/integrations/journal"; @@ -64,18 +65,35 @@ const RESTORE: NonNullable = { const RESTORE_BASE: PlanFingerprintInput = { ...BASE, operation: "restore", restore: RESTORE }; describe("integration mutation plan projection", () => { - test("a managed path keeps plain keys and collapses a selected member", () => { - expect(canonicalSchemaPath(["providers", "opencodex", "baseUrl"])).toBe("providers.opencodex.baseUrl"); - // Which entry was selected is runtime identity, so the selector becomes a wildcard. - expect(canonicalSchemaPath(["providers", "[name=opencodex]", "baseUrl"])).toBe("providers.*.baseUrl"); + test("a declared managed path is published as its template", () => { + expect(canonicalSchemaPath("cline", ["settings", "providers", "opencodex"])).toBe("settings.providers.opencodex"); + expect(canonicalSchemaPath("raycast", ["providers", "[id=opencodex]"])).toBe("providers.[id=opencodex]"); }); - test("a segment that is not representable invalidates the whole path", () => { - // An ownership record accepts arbitrary strings, so a path is never trusted because a record - // carries it. Dropping only the bad segment would name a different place than the real one. - expect(canonicalSchemaPath(["providers", "../../etc/passwd"])).toBeNull(); - expect(canonicalSchemaPath(["providers", "a key with spaces"])).toBeNull(); - expect(canonicalSchemaPath([])).toBeNull(); + test("a dynamic position never publishes the member it selected", () => { + // Kimi writes one fragment per model, so this position holds a user's model alias. An + // alphanumeric allowlist would have emitted it verbatim. + const path = canonicalSchemaPath("kimi", ["models", "kimi-k2-private-alias"]); + expect(path).toBe("models.*"); + expect(path).not.toContain("kimi-k2-private-alias"); + }); + + test("a path outside the client's declared grammar is refused, not described", () => { + // An ownership record accepts arbitrary strings, so a path is never published because a record + // carries it. Depth, a foreign static segment and another client's shape all fail closed. + expect(canonicalSchemaPath("kimi", ["models", "alias", "contextWindow"])).toBeNull(); + expect(canonicalSchemaPath("pi", ["providers", "someone-elses-provider"])).toBeNull(); + expect(canonicalSchemaPath("pi", ["settings", "providers", "opencodex"])).toBeNull(); + expect(canonicalSchemaPath("cline", ["..", "..", "etc"])).toBeNull(); + expect(canonicalSchemaPath("cline", [])).toBeNull(); + }); + + test("every shipped client declares where its managed fragments live", () => { + for (const clientId of Object.keys(EXPORT_CLIENTS)) { + const templates = MANAGED_PATH_TEMPLATES[clientId as keyof typeof MANAGED_PATH_TEMPLATES]; + expect(templates, clientId).toBeDefined(); + expect(templates.length, clientId).toBeGreaterThan(0); + } }); test("changes are deduplicated and ordered by kind then path", () => { From b6a6bdbed944b4c683fd8d86f2c6d0d78b431193 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 00:34:01 +0900 Subject: [PATCH 06/83] test(integrations): prove real builder paths canonicalize, not just that templates exist The template table is a second copy of what the exporters do, and the earlier case only asserted each client had a non-empty list. That would stay green while a client's actual fragment path drifted out of its declaration, which is the one failure the table exists to prevent. Each shipped client's contribution is now built from a bounded fixture context and every emitted fragment path is canonicalized through that client's own templates. Any segment the templates do not name must not appear in the published path, so Kimi's per-model alias is checked against real builder output rather than a hand-written example. local checks: NOT RUN --- tests/clients/mutation-plan.test.ts | 47 +++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/tests/clients/mutation-plan.test.ts b/tests/clients/mutation-plan.test.ts index 405dc0c6b1d..59ab119a93e 100644 --- a/tests/clients/mutation-plan.test.ts +++ b/tests/clients/mutation-plan.test.ts @@ -8,9 +8,32 @@ import { type IntegrationPlanChange, type PlanFingerprintInput, } from "../../src/integrations/mutation-plan"; -import { EXPORT_CLIENTS, type ManagedContribution } from "../../src/clients/config-export"; +import { + EXPORT_CLIENTS, + EXPORT_CLIENT_IDS, + type ExportContext, + type ExportModel, + type ManagedContribution, +} from "../../src/clients/config-export"; import type { OwnershipRecord } from "../../src/integrations/ownership"; import type { JournalEntry } from "../../src/integrations/journal"; +import type { OcxConfig } from "../../src/types"; + +const FIXTURE_MODELS: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000 }, + { namespaced: "gpt-5.5", provider: "openai", id: "gpt-5.5", native: true, contextWindow: 400_000 }, +]; + +const FIXTURE_CONFIG: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +function fixtureContext(): ExportContext { + return { baseUrl: "http://127.0.0.1:10100/v1", models: FIXTURE_MODELS, config: FIXTURE_CONFIG }; +} const CONFIG_PATH = "/home/example/.cline/config.json"; @@ -88,11 +111,23 @@ describe("integration mutation plan projection", () => { expect(canonicalSchemaPath("cline", [])).toBeNull(); }); - test("every shipped client declares where its managed fragments live", () => { - for (const clientId of Object.keys(EXPORT_CLIENTS)) { - const templates = MANAGED_PATH_TEMPLATES[clientId as keyof typeof MANAGED_PATH_TEMPLATES]; - expect(templates, clientId).toBeDefined(); - expect(templates.length, clientId).toBeGreaterThan(0); + test("every path a shipped client actually writes canonicalizes through its own templates", () => { + // The declarations are a second copy of what the exporters do, so the only assertion worth + // making is against real builder output. A template list that merely exists proves nothing. + for (const clientId of EXPORT_CLIENT_IDS) { + expect(MANAGED_PATH_TEMPLATES[clientId].length, clientId).toBeGreaterThan(0); + const contribution = EXPORT_CLIENTS[clientId].buildContribution(fixtureContext()); + expect(contribution.fragments.length, clientId).toBeGreaterThan(0); + for (const fragment of contribution.fragments) { + const canonical = canonicalSchemaPath(clientId, fragment.path); + expect(canonical, `${clientId}: ${fragment.path.join(".")}`).not.toBeNull(); + // A dynamic position must not carry its observed value into the published path. + for (const segment of fragment.path) { + if (!MANAGED_PATH_TEMPLATES[clientId].some(template => template.includes(segment))) { + expect(canonical, `${clientId} leaked ${segment}`).not.toContain(segment); + } + } + } } }); From d3924078603c154139684e11594064ce87ad4ed0 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 00:37:31 +0900 Subject: [PATCH 07/83] feat(integrations): assemble the value-free plan an operator confirms Turns the shared observation into the plan itself: the managed places the operation would touch, the history it would write, the foreign-edit status, and whether it can proceed at all. Refusals are derived in the writer's own order, which is not cosmetic. An uninstalled client is reported as not installed rather than as whatever its leftover file classifies as, and an unreadable file is reported before either, because that is the sequence the writer refuses in. A plan naming a different reason than the mutation would name is worse than no plan. Overwrite stays the one operation allowed through a conflict the operator has been shown. Drift is passed in rather than recomputed, so the plan and the mutation read it from the same comparison, and it is bound into the fingerprint with the rest of the restore inputs. A path that does not canonicalize is omitted rather than guessed at. For a shipped client that cannot happen and the parity case proves it; what it covers is a record written by another version, where declining to describe a path is the honest answer while the fixed ownership entry still tells the operator that ownership changes. A refused plan reports no places and still carries its reason, because knowing undo is blocked by an expired backup is the answer an operator needs. Regressions cover the change list, replacement versus addition, refusal ordering, overwrite through conflict, expired and drifted restore, and that neither a configured value nor a filesystem path reaches the response. local checks: NOT RUN --- src/integrations/mutation-plan.ts | 108 +++++++++++++++++++++++++++- tests/clients/mutation-plan.test.ts | 72 ++++++++++++++++++- 2 files changed, 178 insertions(+), 2 deletions(-) diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index 313fac16d26..a9b8c675a49 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -21,7 +21,7 @@ import { createClineIO, ClineTransactionError } from "./cline-io"; import { parseClineDocument } from "./cline-document"; import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; import { INTEGRATION_CLIENTS, resolveIntegrationPaths, type IntegrationClientId } from "./registry"; -import { classifyIntegration, exportContextOf, type IntegrationState } from "./state"; +import { classifyIntegration, exportContextOf, type IntegrationState, type StateReason } from "./state"; import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; import type { OcxConfig } from "../types"; import type { JournalEntry } from "./journal"; @@ -208,6 +208,12 @@ export interface PlanFingerprintInput { /** Digest of the snapshot's exact text, or null when it holds none. */ readonly snapshotText: string | null; readonly confirmDrift: boolean; + /** + * Whether the target has changed since the operation being undone, which is the + * drift_requires_confirm predicate. Passed in rather than recomputed so the plan and the + * mutation read drift from the same comparison. + */ + readonly driftsFromResult: boolean; }; } @@ -246,11 +252,111 @@ export function planFingerprint(input: PlanFingerprintInput): string { restore.snapshotKind, restore.snapshotText === null ? "\u0000none" : fingerprint(restore.snapshotText), restore.confirmDrift, + restore.driftsFromResult, ], ]; return `${PLAN_FINGERPRINT_VERSION}:${digest(JSON.stringify(components))}`; } +/** The observation facts a plan is derived from, beside the fingerprint inputs. */ +export interface PlanInput extends PlanFingerprintInput { + readonly classified: { readonly state: IntegrationState; readonly reason?: StateReason }; +} + +function foreignEditOf(input: PlanInput): IntegrationPlanForeignEdit { + if (input.restore?.driftsFromResult) return "drift"; + if (input.classified.reason === "unowned-key") return "unowned"; + if (input.classified.reason === "foreign-edit") return "foreign-edit"; + return "none"; +} + +/** + * Why this operation would refuse, in the writer's own order. + * + * The order is not cosmetic. An uninstalled client is reported as not installed rather than as + * whatever its leftover file happens to classify as, and an unreadable or unparseable file is + * reported before either, because that is the sequence the writer itself refuses in. A plan that + * named a different reason than the mutation would name is worse than no plan. + */ +function refusalOf(input: PlanInput): RefusalReason | undefined { + if (input.classified.state === "unsafe") return "unsafe"; + if (input.installKind !== "dir") return "not_installed"; + if (input.admissionBlocked) return "non_loopback"; + if (input.operation === "restore") { + if (input.restore === undefined) return "unsafe"; + if (input.restore.snapshotKind === "expired") return "snapshot_expired"; + if (input.restore.driftsFromResult && !input.restore.confirmDrift) return "drift_requires_confirm"; + return undefined; + } + // Overwrite exists precisely to proceed through a conflict the operator has been shown. + if (input.classified.state === "conflict" && input.operation !== "overwrite") return "conflict"; + return undefined; +} + +function ownedPath(record: OwnershipRecord | null, path: readonly string[]): boolean { + return (record?.fragmentPaths ?? []).some(owned => + owned.length === path.length && owned.every((segment, index) => segment === path[index])); +} + +/** + * The managed places this operation would touch, plus the history it would write. + * + * A path that does not canonicalize is omitted rather than guessed at. For a shipped client that + * cannot happen, and the parity case proves it; what it does cover is a record written by another + * version, where declining to describe a path is the honest answer and the fixed ownership entry + * still tells the operator that ownership changes. + */ +function changesOf(input: PlanInput): readonly IntegrationPlanChange[] { + const changes: IntegrationPlanChange[] = []; + if (input.operation === "apply" || input.operation === "overwrite") { + for (const fragment of input.contribution?.fragments ?? []) { + const path = canonicalSchemaPath(input.clientId, fragment.path); + if (path === null) continue; + changes.push({ kind: ownedPath(input.record, fragment.path) ? "replace" : "add", path }); + } + } + if (input.operation === "disable" || input.operation === "restore") { + for (const owned of input.record?.fragmentPaths ?? []) { + const path = canonicalSchemaPath(input.clientId, owned); + if (path === null) continue; + changes.push({ kind: "remove", path }); + } + } + if (input.operation === "restore") { + for (const fragment of input.restore?.entry.priorRecord?.fragmentPaths ?? []) { + const path = canonicalSchemaPath(input.clientId, fragment); + if (path === null) continue; + changes.push({ kind: "replace", path }); + } + } + changes.push({ kind: "snapshot", path: PLAN_SNAPSHOT_PATH }); + changes.push({ kind: "ownership", path: PLAN_OWNERSHIP_PATH }); + changes.push({ kind: "journal", path: PLAN_JOURNAL_PATH }); + return orderPlanChanges(changes); +} + +/** + * The whole plan, value-free. + * + * A refused plan is still worth returning: knowing that undo is blocked because the backup expired + * is the answer an operator needs, and it carries no more detail than an allowed one. + */ +export function buildMutationPlan(input: PlanInput): IntegrationMutationPlan { + const refusalReason = refusalOf(input); + return Object.freeze({ + version: 1 as const, + clientId: input.clientId, + operation: input.operation, + state: input.classified.state, + foreignEdit: foreignEditOf(input), + changes: refusalReason === undefined ? changesOf(input) : Object.freeze([]), + fingerprint: planFingerprint(input), + canApply: refusalReason === undefined, + ...(refusalReason === undefined ? {} : { refusalReason }), + ...(input.profileId === undefined ? {} : { profileId: input.profileId }), + }); +} + /** * What a mutation is asked to do. Declared here because the observation below consumes it and the * planner must not depend on the writer; the writer re-exports it, so callers are unaffected. diff --git a/tests/clients/mutation-plan.test.ts b/tests/clients/mutation-plan.test.ts index 59ab119a93e..08419cd5387 100644 --- a/tests/clients/mutation-plan.test.ts +++ b/tests/clients/mutation-plan.test.ts @@ -2,10 +2,12 @@ import { describe, expect, test } from "bun:test"; import { MANAGED_PATH_TEMPLATES, PLAN_CHANGE_LIMIT, + buildMutationPlan, canonicalSchemaPath, orderPlanChanges, planFingerprint, type IntegrationPlanChange, + type PlanInput, type PlanFingerprintInput, } from "../../src/integrations/mutation-plan"; import { @@ -47,9 +49,14 @@ const RECORD: OwnershipRecord = { opId: "op-base", }; +const CANARY = "canary-value-must-not-be-published"; + const CONTRIBUTION: ManagedContribution = { clientId: "cline", - fragments: [{ path: ["providers", "opencodex"], value: { baseUrl: "http://127.0.0.1:10100" } }], + fragments: [ + { path: ["settings", "providers", "opencodex"], value: { baseUrl: "http://127.0.0.1:10100", apiKey: CANARY } }, + { path: ["catalog", "providers", "opencodex"], value: { models: [CANARY] } }, + ], }; const ENTRY: JournalEntry = { @@ -83,6 +90,7 @@ const RESTORE: NonNullable = { snapshotKind: "stored", snapshotText: "{\"a\":1}", confirmDrift: false, + driftsFromResult: false, }; const RESTORE_BASE: PlanFingerprintInput = { ...BASE, operation: "restore", restore: RESTORE }; @@ -212,7 +220,69 @@ describe("integration plan fingerprint", () => { { ...RESTORE_BASE, restore: { ...RESTORE, snapshotText: "{\"a\":2}" } }, { ...RESTORE_BASE, restore: { ...RESTORE, snapshotText: null } }, { ...RESTORE_BASE, restore: { ...RESTORE, confirmDrift: true } }, + { ...RESTORE_BASE, restore: { ...RESTORE, driftsFromResult: true } }, ]; expect(new Set(variants.map(planFingerprint)).size).toBe(variants.length); }); }); + +const PLAN_BASE: PlanInput = { ...BASE, classified: { state: "absent" } }; + +describe("integration mutation plan", () => { + test("an allowed apply names every managed place and the history it writes", () => { + const plan = buildMutationPlan(PLAN_BASE); + expect(plan.canApply).toBe(true); + expect(plan.refusalReason).toBeUndefined(); + expect(plan.changes).toEqual([ + { kind: "add", path: "catalog.providers.opencodex" }, + { kind: "add", path: "settings.providers.opencodex" }, + { kind: "snapshot", path: "$snapshot" }, + { kind: "ownership", path: "$ownership" }, + { kind: "journal", path: "$journal" }, + ]); + }); + + test("a previously owned place is a replacement rather than an addition", () => { + const plan = buildMutationPlan({ + ...PLAN_BASE, + classified: { state: "stale" }, + record: { ...RECORD, fragmentPaths: [["settings", "providers", "opencodex"]] }, + }); + expect(plan.changes).toContainEqual({ kind: "replace", path: "settings.providers.opencodex" }); + expect(plan.changes).toContainEqual({ kind: "add", path: "catalog.providers.opencodex" }); + }); + + test("no configured value reaches the plan", () => { + const plan = buildMutationPlan(PLAN_BASE); + expect(JSON.stringify(plan)).not.toContain(CANARY); + expect(JSON.stringify(plan)).not.toContain(CONFIG_PATH); + }); + + test("refusals follow the writer's order and report no places", () => { + // Unsafe is decided before the install check, exactly as the writer refuses. + expect(buildMutationPlan({ ...PLAN_BASE, classified: { state: "unsafe", reason: "unparseable" }, installKind: "missing" }).refusalReason) + .toBe("unsafe"); + expect(buildMutationPlan({ ...PLAN_BASE, installKind: "missing" }).refusalReason).toBe("not_installed"); + expect(buildMutationPlan({ ...PLAN_BASE, admissionBlocked: true }).refusalReason).toBe("non_loopback"); + const refused = buildMutationPlan({ ...PLAN_BASE, installKind: "missing" }); + expect(refused.canApply).toBe(false); + expect(refused.changes).toEqual([]); + }); + + test("overwrite is the operation allowed through a conflict", () => { + const conflicted: PlanInput = { ...PLAN_BASE, classified: { state: "conflict", reason: "foreign-edit" } }; + expect(buildMutationPlan(conflicted).refusalReason).toBe("conflict"); + expect(buildMutationPlan({ ...conflicted, operation: "overwrite" }).canApply).toBe(true); + expect(buildMutationPlan(conflicted).foreignEdit).toBe("foreign-edit"); + }); + + test("restore reports an expired backup and unconfirmed drift", () => { + const plan: PlanInput = { ...RESTORE_BASE, classified: { state: "current" } }; + expect(buildMutationPlan({ ...plan, restore: { ...RESTORE, snapshotKind: "expired" } }).refusalReason) + .toBe("snapshot_expired"); + const drifted = buildMutationPlan({ ...plan, restore: { ...RESTORE, driftsFromResult: true } }); + expect(drifted.refusalReason).toBe("drift_requires_confirm"); + expect(drifted.foreignEdit).toBe("drift"); + expect(buildMutationPlan({ ...plan, restore: { ...RESTORE, driftsFromResult: true, confirmDrift: true } }).canApply).toBe(true); + }); +}); From f8f53edb2b0f85c98245b719c84b0e85e7a3f1ba Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 00:39:36 +0900 Subject: [PATCH 08/83] feat(integrations): add the read-only preview entrypoint previewIntegration runs the shared observation with both write-capable effects off, so it prunes nothing, recovers nothing, takes no lock and enters no mutation flight. Everything it reads is a read: the target file, the ownership records, and for restore the journal row and its snapshot. When observation itself refuses, the plan carries a fixed unbound token rather than a fingerprint. Such a plan never read the state it would have bound, so there is nothing to bind, and sharing one value is safe precisely because canApply is false and only a plan that could apply may be bound to a mutation. A restore whose operation id is unknown or belongs to another client is refused without saying which, since naming it would report on journal contents the caller did not select. The filesystem regression proving preview leaves the config, store, snapshots and journal byte-identical is the next commit; it needs a temp home whose path resolution is verified rather than assumed. local checks: NOT RUN --- src/integrations/mutation-plan.ts | 92 ++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index a9b8c675a49..60c8974b7b8 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -20,11 +20,12 @@ import { OPENCODE_PROVIDER_ID } from "../clients/config-export/constants"; import { createClineIO, ClineTransactionError } from "./cline-io"; import { parseClineDocument } from "./cline-document"; import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; -import { INTEGRATION_CLIENTS, resolveIntegrationPaths, type IntegrationClientId } from "./registry"; +import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry"; +import { shouldInjectApiAuthHeader } from "../codex/inject"; import { classifyIntegration, exportContextOf, type IntegrationState, type StateReason } from "./state"; import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; import type { OcxConfig } from "../types"; -import type { JournalEntry } from "./journal"; +import { matchesOperationResult, type JournalEntry } from "./journal"; /** * Why a mutation refused. Declared here rather than in the writer so the planner can report a @@ -357,6 +358,93 @@ export function buildMutationPlan(input: PlanInput): IntegrationMutationPlan { }); } +/** + * The token a plan carries when observation itself refused. + * + * Such a plan never read the state it would have bound, so there is nothing to bind. It is safe + * for every one of them to share this value because `canApply` is false, and a mutation may only + * be bound to a plan that could apply. + */ +export const PLAN_UNBOUND_FINGERPRINT = `${PLAN_FINGERPRINT_VERSION}:unbound`; + +function unboundPlan( + clientId: IntegrationClientId, + operation: IntegrationPlanOperation, + failure: IntegrationObservationFailure, + profileId?: number, +): IntegrationMutationPlan { + return Object.freeze({ + version: 1 as const, + clientId, + operation, + state: failure.state, + foreignEdit: "none" as const, + changes: Object.freeze([]), + fingerprint: PLAN_UNBOUND_FINGERPRINT, + canApply: false, + refusalReason: failure.reason, + ...(profileId === undefined ? {} : { profileId }), + }); +} + +export interface PreviewRequest { + readonly operation: IntegrationPlanOperation; + /** Required for restore; names the journalled operation being undone. */ + readonly opId?: string; + readonly confirmDrift?: boolean; + readonly profileId?: number; +} + +/** + * Plan an operation without performing it. + * + * Observation runs with both write-capable effects off, so this path prunes nothing, recovers + * nothing, takes no lock and enters no mutation flight. Everything it reads is a read: the target + * file, the ownership records, and for restore the journal row and its snapshot. + */ +export function previewIntegration(input: IntegrationWriteInput, request: PreviewRequest): IntegrationMutationPlan { + const observed = observeIntegration(input, { maintenance: false, recover: false }); + if (observed.failed) return unboundPlan(input.clientId, request.operation, observed.failed, request.profileId); + + const shared = { + operation: request.operation, + clientId: observed.clientId, + configPath: observed.configPath, + detectDir: observed.detectDir, + installKind: observed.io.statKind(observed.detectDir), + // Loopback-only clients cannot carry the admission header a non-loopback bind requires. + admissionBlocked: isLoopbackOnly(observed.clientId) && shouldInjectApiAuthHeader(input.config), + before: observed.before, + contribution: observed.contribution, + record: observed.record, + models: input.models, + classified: observed.classified, + ...(request.profileId === undefined ? {} : { profileId: request.profileId }), + }; + + if (request.operation !== "restore") return buildMutationPlan(shared); + + const entry = request.opId === undefined ? null : observed.store.findOperation(request.opId); + if (!entry || entry.clientId !== observed.clientId) { + // Naming which of the two it was would report on journal contents the caller did not select. + return unboundPlan(observed.clientId, "restore", { + reason: "unsafe", state: observed.classified.state, message: "that operation cannot be undone", + }, request.profileId); + } + const snapshot = observed.store.readSnapshot(entry); + return buildMutationPlan({ + ...shared, + restore: { + opId: entry.opId, + entry, + snapshotKind: snapshot.kind, + snapshotText: snapshot.kind === "stored" ? snapshot.text : null, + confirmDrift: request.confirmDrift === true, + driftsFromResult: !matchesOperationResult(entry, observed.before), + }, + }); +} + /** * What a mutation is asked to do. Declared here because the observation below consumes it and the * planner must not depend on the writer; the writer re-exports it, so callers are unaffected. From d987ee4fc96d83e0651434366ad400e161c249e6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 00:40:55 +0900 Subject: [PATCH 09/83] test(integrations): prove a preview leaves the filesystem untouched The feature's whole promise is that looking changes nothing, and that is a claim about the filesystem rather than about the code's intentions. This drives the real preview against a temp home and a temp store, then compares the client file's bytes and every file under the store before and after. The path resolution is asserted rather than assumed. Every client resolves its config through the home argument today, and a client that stopped doing so must fail this test loudly instead of quietly reading somebody's real configuration. Also checks that the plan describes places and not contents: neither the home path nor the proxy address appears anywhere in it. local checks: NOT RUN --- tests/clients/mutation-plan.test.ts | 63 +++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/clients/mutation-plan.test.ts b/tests/clients/mutation-plan.test.ts index 08419cd5387..162f922ed99 100644 --- a/tests/clients/mutation-plan.test.ts +++ b/tests/clients/mutation-plan.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative } from "node:path"; import { MANAGED_PATH_TEMPLATES, PLAN_CHANGE_LIMIT, @@ -6,6 +9,7 @@ import { canonicalSchemaPath, orderPlanChanges, planFingerprint, + previewIntegration, type IntegrationPlanChange, type PlanInput, type PlanFingerprintInput, @@ -20,6 +24,9 @@ import { import type { OwnershipRecord } from "../../src/integrations/ownership"; import type { JournalEntry } from "../../src/integrations/journal"; import type { OcxConfig } from "../../src/types"; +import { createIntegrationStateStore } from "../../src/integrations/store"; +import { resolveIntegrationPaths } from "../../src/integrations/registry"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; const FIXTURE_MODELS: ExportModel[] = [ { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000 }, @@ -286,3 +293,59 @@ describe("integration mutation plan", () => { expect(buildMutationPlan({ ...plan, restore: { ...RESTORE, driftsFromResult: true, confirmDrift: true } }).canApply).toBe(true); }); }); + +/** Every file under a directory with its bytes, so "nothing changed" is a comparison and not a claim. */ +function treeSnapshot(root: string): Record { + const seen: Record = {}; + const walk = (dir: string): void => { + for (const name of readdirSync(dir).sort()) { + const full = join(dir, name); + if (statSync(full).isDirectory()) walk(full); + else seen[relative(root, full)] = readFileSync(full, "utf8"); + } + }; + walk(root); + return seen; +} + +describe("integration preview writes nothing", () => { + test("planning an apply leaves the client file and the whole store untouched", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-preview-home-")); + const storeRoot = mkdtempSync(join(tmpdir(), "ocx-preview-store-")); + try { + const env = {} as NodeJS.ProcessEnv; + const { configPath, detectDir } = resolveIntegrationPaths("opencode", env, home); + // Fail loudly rather than reading somebody's real configuration: every client resolves + // through the home argument, and a client that stopped doing so must not be read here. + expect(configPath.startsWith(home), configPath).toBe(true); + mkdirSync(detectDir, { recursive: true }); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, "{}\n"); + + const store = createIntegrationStateStore(storeRoot); + const configBefore = readFileSync(configPath, "utf8"); + const storeBefore = treeSnapshot(storeRoot); + + const plan = previewIntegration( + { clientId: "opencode", models: FIXTURE_MODELS, config: FIXTURE_CONFIG, port: 10100, env, home, store }, + { operation: "apply" }, + ); + + expect(plan.canApply).toBe(true); + expect(plan.changes.some(change => change.kind === "add")).toBe(true); + expect(plan.changes).toContainEqual({ kind: "journal", path: "$journal" }); + + // The whole promise of the feature, checked against the filesystem rather than asserted. + expect(readFileSync(configPath, "utf8")).toBe(configBefore); + expect(treeSnapshot(storeRoot)).toEqual(storeBefore); + + // A plan describes places, never contents or locations. + const serialized = JSON.stringify(plan); + expect(serialized).not.toContain(home); + expect(serialized).not.toContain("127.0.0.1"); + } finally { + removeTreeWithRetry(home); + removeTreeWithRetry(storeRoot); + } + }); +}); From 5a6b0908eb81316b7e3d76b2897e683b1eee449e Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 00:54:12 +0900 Subject: [PATCH 10/83] fix(integrations): decide each operation the way that operation decides Review found the plan applied one global refusal sequence to all four operations, and the writer does no such thing. Apply checks installation and admission before anything the classifier says, reports a conflict ahead of unsafe, and treats an already-current file as a success that writes nothing. Disable never asks about installation or admission at all: removing what we wrote from a file that still exists is meaningful whether or not the client is installed now, and it emits nothing admission policy could object to; an absent block is a no-op rather than a refusal. Restore stays on its own journal, snapshot and drift questions. A plan is only useful if it reaches the same verdict, for the same reason, as the mutation it describes. Two consequences follow. An operation that would write nothing now says so and names no places, because reporting a snapshot and a journal row for a no-op describes consequences that never happen. And whether a managed place is occupied is read from the document rather than from our own record, since deciding from the record called an overwrite of somebody else's key an addition, which is the one situation overwrite exists for. The filesystem proof now snapshots the entire temp home rather than the target file alone, so a writer lock or a marker sibling appearing next to the file would fail it. It seeds pending maintenance and asserts it is still pending afterwards, and it leaves an interrupted client transaction in place to show that a preview repairs nothing. local checks: NOT RUN --- src/integrations/mutation-plan.ts | 98 +++++++++++++++++++++------ tests/clients/mutation-plan.test.ts | 101 ++++++++++++++++++++++++---- 2 files changed, 166 insertions(+), 33 deletions(-) diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index 60c8974b7b8..b6749c27b25 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -22,7 +22,7 @@ import { parseClineDocument } from "./cline-document"; import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry"; import { shouldInjectApiAuthHeader } from "../codex/inject"; -import { classifyIntegration, exportContextOf, type IntegrationState, type StateReason } from "./state"; +import { classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state"; import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; import type { OcxConfig } from "../types"; import { matchesOperationResult, type JournalEntry } from "./journal"; @@ -73,6 +73,14 @@ export interface IntegrationMutationPlan { readonly changes: readonly IntegrationPlanChange[]; readonly fingerprint: string; readonly canApply: boolean; + /** + * Whether confirming would write at all. + * + * An apply against an already-current file and a disable against an absent one both succeed + * while writing nothing, so reporting a snapshot and a journal row for them would describe + * consequences that never happen. + */ + readonly willChange: boolean; readonly refusalReason?: RefusalReason; readonly profileId?: number; } @@ -262,8 +270,23 @@ export function planFingerprint(input: PlanFingerprintInput): string { /** The observation facts a plan is derived from, beside the fingerprint inputs. */ export interface PlanInput extends PlanFingerprintInput { readonly classified: { readonly state: IntegrationState; readonly reason?: StateReason }; + /** + * The parsed target document. Whether a managed place is occupied is a fact about the file, not + * about our record: an overwrite of a key somebody else wrote replaces a value even though no + * record of ours mentions it. + */ + readonly parsed: unknown; } +type PlanOutcome = + | { readonly kind: "refuse"; readonly reason: RefusalReason } + | { readonly kind: "noop" } + | { readonly kind: "change" }; + +const CHANGE: PlanOutcome = { kind: "change" }; +const NOOP: PlanOutcome = { kind: "noop" }; +const deny = (reason: RefusalReason): PlanOutcome => ({ kind: "refuse", reason }); + function foreignEditOf(input: PlanInput): IntegrationPlanForeignEdit { if (input.restore?.driftsFromResult) return "drift"; if (input.classified.reason === "unowned-key") return "unowned"; @@ -279,26 +302,52 @@ function foreignEditOf(input: PlanInput): IntegrationPlanForeignEdit { * reported before either, because that is the sequence the writer itself refuses in. A plan that * named a different reason than the mutation would name is worse than no plan. */ -function refusalOf(input: PlanInput): RefusalReason | undefined { - if (input.classified.state === "unsafe") return "unsafe"; - if (input.installKind !== "dir") return "not_installed"; - if (input.admissionBlocked) return "non_loopback"; - if (input.operation === "restore") { - if (input.restore === undefined) return "unsafe"; - if (input.restore.snapshotKind === "expired") return "snapshot_expired"; - if (input.restore.driftsFromResult && !input.restore.confirmDrift) return "drift_requires_confirm"; - return undefined; - } +function applyOutcome(input: PlanInput): PlanOutcome { + if (input.installKind !== "dir") return deny("not_installed"); + if (input.admissionBlocked) return deny("non_loopback"); // Overwrite exists precisely to proceed through a conflict the operator has been shown. - if (input.classified.state === "conflict" && input.operation !== "overwrite") return "conflict"; - return undefined; + if (input.classified.state === "conflict" && input.operation !== "overwrite") return deny("conflict"); + if (input.classified.state === "unsafe") return deny("unsafe"); + if (input.classified.state === "current") return NOOP; + return CHANGE; +} + +/** + * Disable answers a different question, so it asks different ones. + * + * It never checks installation or admission: removing what we wrote from a file that still exists + * is meaningful whether or not the client is installed now, and it emits nothing that admission + * policy could object to. An absent block is a success that writes nothing rather than a refusal. + */ +function disableOutcome(input: PlanInput): PlanOutcome { + if (input.classified.state === "absent") return NOOP; + if (input.classified.state === "conflict") return deny("conflict"); + if (input.classified.state === "unsafe") return deny("unsafe"); + return CHANGE; +} + +function restoreOutcome(input: PlanInput): PlanOutcome { + if (input.restore === undefined) return deny("unsafe"); + if (input.restore.snapshotKind === "expired") return deny("snapshot_expired"); + if (input.restore.driftsFromResult && !input.restore.confirmDrift) return deny("drift_requires_confirm"); + return CHANGE; } -function ownedPath(record: OwnershipRecord | null, path: readonly string[]): boolean { - return (record?.fragmentPaths ?? []).some(owned => - owned.length === path.length && owned.every((segment, index) => segment === path[index])); +/** + * What this operation would do, decided the way the operation itself decides it. + * + * Applying one global sequence to all four was wrong: it reported disable as refused on an + * uninstalled client the writer would have accepted, and it ranked the classifier's unsafe ahead + * of a conflict that apply reports first. A plan is only useful if it reaches the same verdict, + * for the same reason, as the mutation it describes. + */ +function outcomeOf(input: PlanInput): PlanOutcome { + if (input.operation === "restore") return restoreOutcome(input); + if (input.operation === "disable") return disableOutcome(input); + return applyOutcome(input); } + /** * The managed places this operation would touch, plus the history it would write. * @@ -313,7 +362,10 @@ function changesOf(input: PlanInput): readonly IntegrationPlanChange[] { for (const fragment of input.contribution?.fragments ?? []) { const path = canonicalSchemaPath(input.clientId, fragment.path); if (path === null) continue; - changes.push({ kind: ownedPath(input.record, fragment.path) ? "replace" : "add", path }); + // Occupied is a fact about the document. Deciding from our own record instead would call an + // overwrite of somebody else's key an addition, which is the one case overwrite exists for. + const occupied = readPath(input.parsed, fragment.path) !== undefined; + changes.push({ kind: occupied ? "replace" : "add", path }); } } if (input.operation === "disable" || input.operation === "restore") { @@ -343,17 +395,19 @@ function changesOf(input: PlanInput): readonly IntegrationPlanChange[] { * is the answer an operator needs, and it carries no more detail than an allowed one. */ export function buildMutationPlan(input: PlanInput): IntegrationMutationPlan { - const refusalReason = refusalOf(input); + const outcome = outcomeOf(input); return Object.freeze({ version: 1 as const, clientId: input.clientId, operation: input.operation, state: input.classified.state, foreignEdit: foreignEditOf(input), - changes: refusalReason === undefined ? changesOf(input) : Object.freeze([]), + // Only a plan that would actually write describes places to write. + changes: outcome.kind === "change" ? changesOf(input) : Object.freeze([]), fingerprint: planFingerprint(input), - canApply: refusalReason === undefined, - ...(refusalReason === undefined ? {} : { refusalReason }), + canApply: outcome.kind !== "refuse", + willChange: outcome.kind === "change", + ...(outcome.kind === "refuse" ? { refusalReason: outcome.reason } : {}), ...(input.profileId === undefined ? {} : { profileId: input.profileId }), }); } @@ -382,6 +436,7 @@ function unboundPlan( changes: Object.freeze([]), fingerprint: PLAN_UNBOUND_FINGERPRINT, canApply: false, + willChange: false, refusalReason: failure.reason, ...(profileId === undefined ? {} : { profileId }), }); @@ -419,6 +474,7 @@ export function previewIntegration(input: IntegrationWriteInput, request: Previe record: observed.record, models: input.models, classified: observed.classified, + parsed: observed.parsed, ...(request.profileId === undefined ? {} : { profileId: request.profileId }), }; diff --git a/tests/clients/mutation-plan.test.ts b/tests/clients/mutation-plan.test.ts index 162f922ed99..da76d2ed340 100644 --- a/tests/clients/mutation-plan.test.ts +++ b/tests/clients/mutation-plan.test.ts @@ -25,6 +25,7 @@ import type { OwnershipRecord } from "../../src/integrations/ownership"; import type { JournalEntry } from "../../src/integrations/journal"; import type { OcxConfig } from "../../src/types"; import { createIntegrationStateStore } from "../../src/integrations/store"; +import { clinePendingPath } from "../../src/integrations/cline-io"; import { resolveIntegrationPaths } from "../../src/integrations/registry"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -233,12 +234,16 @@ describe("integration plan fingerprint", () => { }); }); -const PLAN_BASE: PlanInput = { ...BASE, classified: { state: "absent" } }; +const PLAN_BASE: PlanInput = { ...BASE, classified: { state: "absent" }, parsed: {} }; + +/** A document that already holds a value where the settings fragment goes. */ +const OCCUPIED = { settings: { providers: { opencodex: { baseUrl: "http://elsewhere" } } } }; describe("integration mutation plan", () => { test("an allowed apply names every managed place and the history it writes", () => { const plan = buildMutationPlan(PLAN_BASE); expect(plan.canApply).toBe(true); + expect(plan.willChange).toBe(true); expect(plan.refusalReason).toBeUndefined(); expect(plan.changes).toEqual([ { kind: "add", path: "catalog.providers.opencodex" }, @@ -249,11 +254,15 @@ describe("integration mutation plan", () => { ]); }); - test("a previously owned place is a replacement rather than an addition", () => { + test("an occupied place is a replacement whether or not we own it", () => { + // Deciding from our own record would call an overwrite of somebody else's key an addition, + // which is the one situation overwrite exists for. const plan = buildMutationPlan({ ...PLAN_BASE, - classified: { state: "stale" }, - record: { ...RECORD, fragmentPaths: [["settings", "providers", "opencodex"]] }, + operation: "overwrite", + classified: { state: "conflict", reason: "unowned-key" }, + record: null, + parsed: OCCUPIED, }); expect(plan.changes).toContainEqual({ kind: "replace", path: "settings.providers.opencodex" }); expect(plan.changes).toContainEqual({ kind: "add", path: "catalog.providers.opencodex" }); @@ -265,17 +274,44 @@ describe("integration mutation plan", () => { expect(JSON.stringify(plan)).not.toContain(CONFIG_PATH); }); - test("refusals follow the writer's order and report no places", () => { - // Unsafe is decided before the install check, exactly as the writer refuses. - expect(buildMutationPlan({ ...PLAN_BASE, classified: { state: "unsafe", reason: "unparseable" }, installKind: "missing" }).refusalReason) + test("apply refuses in the writer's order: installation, admission, conflict, then unsafe", () => { + const conflicted: PlanInput = { ...PLAN_BASE, classified: { state: "conflict", reason: "foreign-edit" } }; + // Installation outranks a conflict the file would otherwise report. + expect(buildMutationPlan({ ...conflicted, installKind: "missing" }).refusalReason).toBe("not_installed"); + expect(buildMutationPlan({ ...conflicted, admissionBlocked: true }).refusalReason).toBe("non_loopback"); + // And a conflict outranks the classifier's unsafe, which apply reports last. + expect(buildMutationPlan(conflicted).refusalReason).toBe("conflict"); + expect(buildMutationPlan({ ...PLAN_BASE, classified: { state: "unsafe", reason: "blocked-container" } }).refusalReason) .toBe("unsafe"); - expect(buildMutationPlan({ ...PLAN_BASE, installKind: "missing" }).refusalReason).toBe("not_installed"); - expect(buildMutationPlan({ ...PLAN_BASE, admissionBlocked: true }).refusalReason).toBe("non_loopback"); const refused = buildMutationPlan({ ...PLAN_BASE, installKind: "missing" }); expect(refused.canApply).toBe(false); expect(refused.changes).toEqual([]); }); + test("disable asks its own questions and never asks about installation", () => { + const disable: PlanInput = { ...PLAN_BASE, operation: "disable", classified: { state: "current" } }; + // Removing what we wrote from a file that still exists is meaningful whether or not the + // client is installed now, and it emits nothing admission policy could object to. + expect(buildMutationPlan({ ...disable, installKind: "missing" }).canApply).toBe(true); + expect(buildMutationPlan({ ...disable, admissionBlocked: true }).canApply).toBe(true); + expect(buildMutationPlan({ ...disable, classified: { state: "conflict", reason: "foreign-edit" } }).refusalReason) + .toBe("conflict"); + }); + + test("an operation that would write nothing says so and names no places", () => { + // The writer succeeds without writing in both of these, so reporting a snapshot and a journal + // row would describe consequences that never happen. + const applied = buildMutationPlan({ ...PLAN_BASE, classified: { state: "current" } }); + expect(applied.canApply).toBe(true); + expect(applied.willChange).toBe(false); + expect(applied.changes).toEqual([]); + + const disabled = buildMutationPlan({ ...PLAN_BASE, operation: "disable", classified: { state: "absent" } }); + expect(disabled.canApply).toBe(true); + expect(disabled.willChange).toBe(false); + expect(disabled.changes).toEqual([]); + }); + test("overwrite is the operation allowed through a conflict", () => { const conflicted: PlanInput = { ...PLAN_BASE, classified: { state: "conflict", reason: "foreign-edit" } }; expect(buildMutationPlan(conflicted).refusalReason).toBe("conflict"); @@ -284,7 +320,7 @@ describe("integration mutation plan", () => { }); test("restore reports an expired backup and unconfirmed drift", () => { - const plan: PlanInput = { ...RESTORE_BASE, classified: { state: "current" } }; + const plan: PlanInput = { ...RESTORE_BASE, classified: { state: "current" }, parsed: {} }; expect(buildMutationPlan({ ...plan, restore: { ...RESTORE, snapshotKind: "expired" } }).refusalReason) .toBe("snapshot_expired"); const drifted = buildMutationPlan({ ...plan, restore: { ...RESTORE, driftsFromResult: true } }); @@ -323,7 +359,9 @@ describe("integration preview writes nothing", () => { writeFileSync(configPath, "{}\n"); const store = createIntegrationStateStore(storeRoot); - const configBefore = readFileSync(configPath, "utf8"); + // Maintenance a mutation would retry on its way through. A preview must leave it pending. + store.markPruneFailure("opencode", "seeded pending prune"); + const homeBefore = treeSnapshot(home); const storeBefore = treeSnapshot(storeRoot); const plan = previewIntegration( @@ -336,8 +374,12 @@ describe("integration preview writes nothing", () => { expect(plan.changes).toContainEqual({ kind: "journal", path: "$journal" }); // The whole promise of the feature, checked against the filesystem rather than asserted. - expect(readFileSync(configPath, "utf8")).toBe(configBefore); + // The entire home rather than the target alone: a writer lock or a marker sibling would + // appear next to the file, not inside it, and that is exactly what must not happen here. + expect(treeSnapshot(home)).toEqual(homeBefore); expect(treeSnapshot(storeRoot)).toEqual(storeBefore); + // Still pending, so the read path ran no maintenance. + expect(Object.keys(store.readMaintenance().pruneFailures)).toContain("opencode"); // A plan describes places, never contents or locations. const serialized = JSON.stringify(plan); @@ -348,4 +390,39 @@ describe("integration preview writes nothing", () => { removeTreeWithRetry(storeRoot); } }); + + test("a pending client transaction is left exactly as found", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-preview-cline-")); + const storeRoot = mkdtempSync(join(tmpdir(), "ocx-preview-cline-store-")); + try { + const env = {} as NodeJS.ProcessEnv; + const { configPath, detectDir } = resolveIntegrationPaths("cline", env, home); + expect(configPath.startsWith(home), configPath).toBe(true); + mkdirSync(detectDir, { recursive: true }); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, "{}\n"); + + const store = createIntegrationStateStore(storeRoot); + const markerPath = clinePendingPath(store, configPath); + mkdirSync(dirname(markerPath), { recursive: true }); + writeFileSync(markerPath, "{\"pending\":\"left over from an interrupted mutation\"}\n"); + + const homeBefore = treeSnapshot(home); + const storeBefore = treeSnapshot(storeRoot); + + const plan = previewIntegration( + { clientId: "cline", models: FIXTURE_MODELS, config: FIXTURE_CONFIG, port: 10100, env, home, store }, + { operation: "apply" }, + ); + + // Recovery is a mutation. A preview reports what it found and repairs nothing, so the + // marker and its siblings survive byte for byte whatever the plan concluded. + expect(plan.version).toBe(1); + expect(treeSnapshot(home)).toEqual(homeBefore); + expect(treeSnapshot(storeRoot)).toEqual(storeBefore); + } finally { + removeTreeWithRetry(home); + removeTreeWithRetry(storeRoot); + } + }); }); From 552ea802f4fdd1e51326c63a36e18d4b3e722337 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 00:58:10 +0900 Subject: [PATCH 11/83] feat(management): let a read-only caller bring its own model roster A preview has to show what a writer would write without performing the gather, because the gather reaches providers and, through the initial-selection finalizer, can persist configuration. That is not something a read may do. Only the gather is the problem. Everything after it is the projection: the disabled computation, native and account-bound rows, custom rows and the public list. Swapping the gather while duplicating the projection would be worse than leaving it alone, because the two copies would drift and a preview would then disagree with the commit that follows it for a reason that has nothing to do with the roster changing. So the roster becomes a parameter and the projection stays exactly where it is, shared by construction. Supplying it is the only way to skip the gather, and skipping the gather is the only thing supplying it does. local checks: NOT RUN --- scripts/test-layout/layout.json | 1 + src/server/management/model-rows.ts | 31 ++++++++++---- tests/fixtures/test-layout-expected.json | 1 + tests/server/management-model-roster.test.ts | 43 ++++++++++++++++++++ 4 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 tests/server/management-model-roster.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 3e2964cfcc3..70c15f0c45b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -927,6 +927,7 @@ "management-client-config-route.test.ts": "server", "management-integration-journal-delete.test.ts": "server", "management-integration-routes.test.ts": "server", + "management-model-roster.test.ts": "server", "management-origin-tls.test.ts": "server", "management-provider-pinsless-validation.test.ts": "server", "management-provider-proto-override.test.ts": "server", diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index e406979468a..db16cb64479 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -77,14 +77,24 @@ export function effectiveManagementDisplayName( */ export async function listManagementModelRows( config: OcxConfig, - options: { entitlementWaitMs?: number } = {}, + options: { entitlementWaitMs?: number; models?: readonly CatalogModel[] } = {}, ): Promise { - const [models] = await Promise.all([ - fetchAllModels(config), - ensureCodexEntitlementFreshness(config, { - waitMs: options.entitlementWaitMs ?? 3_000, - }), - ]); + /* + * A supplied roster skips the gather, and that is the point rather than an optimization. + * `fetchAllModels` reaches providers and can persist an initial model selection, which a + * read-only caller must not do. Everything below this line is the projection — the disabled + * computation, native and account-bound rows, custom rows and the public list — so a caller + * that brings its own roster still sees exactly what a writer would, and the two cannot + * disagree about the roster for any reason except the roster itself. + */ + const models = options.models === undefined + ? (await Promise.all([ + fetchAllModels(config), + ensureCodexEntitlementFreshness(config, { + waitMs: options.entitlementWaitMs ?? 3_000, + }), + ]))[0] + : [...options.models]; const disabled = new Set(config.disabledModels ?? []); // Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced // from the static supported set so a disabled model stays listed and re-enableable. @@ -236,8 +246,11 @@ export function toExportModel(row: ManagementModelRow): ExportModel { * tab is absent from `/v1/models` and exporting it would hand the client a * selector the proxy refuses to route. */ -export async function loadExportModels(config: OcxConfig): Promise { - const rows = await listManagementModelRows(config); +export async function loadExportModels( + config: OcxConfig, + models?: readonly CatalogModel[], +): Promise { + const rows = await listManagementModelRows(config, models === undefined ? {} : { models }); // Management deliberately lists the full roster so hidden models can be enabled. // A client picker must also honor the provider selection, not just its blocklist. const visibleRouted = new Set(filterCatalogVisibleModels(rows.filter(row => !row.native), config)); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 93d545520e4..8bf8280eca2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -752,6 +752,7 @@ "management-client-config-route.test.ts": "server", "management-integration-journal-delete.test.ts": "server", "management-integration-routes.test.ts": "server", + "management-model-roster.test.ts": "server", "management-origin-tls.test.ts": "server", "management-provider-pinsless-validation.test.ts": "server", "management-provider-proto-override.test.ts": "server", diff --git a/tests/server/management-model-roster.test.ts b/tests/server/management-model-roster.test.ts new file mode 100644 index 00000000000..b15dfb3947b --- /dev/null +++ b/tests/server/management-model-roster.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { listManagementModelRows, loadExportModels } from "../../src/server/management/model-rows"; +import type { CatalogModel } from "../../src/codex/catalog"; +import type { OcxConfig } from "../../src/types"; + +/** + * A read-only caller has to be able to see what a writer would write without performing the + * gather, because the gather reaches providers and can persist an initial model selection. What + * it must NOT get is a different projection, or a preview and the commit that follows it would + * disagree about the roster for a reason that has nothing to do with the roster changing. + */ +const CONFIG: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "supplied", + providers: { supplied: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +const SUPPLIED: CatalogModel[] = [{ id: "supplied-model", provider: "supplied" }]; + +describe("a supplied roster replaces the gather and keeps the projection", () => { + test("rows come from the roster the caller brought", async () => { + const rows = await listManagementModelRows(CONFIG, { models: SUPPLIED }); + // No provider here serves this id, so a gather could not have produced this row. Its + // presence is what proves the supplied roster was used instead. + expect(rows.some(row => row.id === "supplied-model")).toBe(true); + }); + + test("the disabled computation still applies to a supplied roster", async () => { + const rows = await listManagementModelRows( + { ...CONFIG, disabledModels: ["supplied/supplied-model"] } as OcxConfig, + { models: SUPPLIED }, + ); + const row = rows.find(candidate => candidate.id === "supplied-model"); + expect(row?.disabled).toBe(true); + }); + + test("the export projection accepts a supplied roster without gathering", async () => { + // Visibility and provider selection decide which rows survive here, so this asserts the call + // completes through the same path rather than pinning that policy from outside. + await expect(loadExportModels(CONFIG, SUPPLIED)).resolves.toBeInstanceOf(Array); + }); +}); From 5c0dd8354e1afd7798b7351a9e6e0cec99484409 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:00:42 +0900 Subject: [PATCH 12/83] feat(management): serve integration mutation plans on read-only routes Two POSTs that write nothing: one plans an apply, overwrite or disable for a named client, the other plans an undo for a journalled operation. Both build the same input a mutation builds and differ in one deliberate way, the roster comes from the read-only path that skips the initial-selection finalizer, so a preview and the commit that follows cannot disagree except when the state truly moved. A restore preview answers an unknown operation as not found rather than reading the row back. It runs before any confirmation, which makes it the cheapest place in the system to probe journal contents, and it declines to be one. They are POSTs because the operation descriptor is a bounded body, and they are registered as non-mutating with an interactive-preview exemption: there is no standalone thing for a CLI to do with a plan, since it is only meaningful to the caller about to commit it and a scripted caller drives the mutation directly. local checks: NOT RUN --- src/server/management/integration-routes.ts | 92 ++++++++++++++++++++- src/server/management/model-rows.ts | 19 +++++ src/server/management/route-registry.ts | 9 ++ 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 2363e745d5d..8407cf23fb6 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -45,7 +45,8 @@ import { import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; -import { loadExportModels } from "./model-rows"; +import { loadExportModels, previewExportModels } from "./model-rows"; +import { previewIntegration } from "../../integrations/mutation-plan"; const INTEGRATION_ROUTE_PREFIX = "/api/client-integrations/"; @@ -246,6 +247,30 @@ async function buildIntegrationWriteInput( }; } +/** + * The same input a mutation would build, from the read-only roster. + * + * It differs from the mutation's in exactly one way, and the difference is deliberate: the roster + * comes from `previewExportModels`, which gathers without running the initial-selection finalizer + * that persists configuration. Everything else is shared, so a preview and the commit that follows + * it cannot disagree for any reason except the state genuinely moving. + */ +async function buildIntegrationPreviewInput( + clientId: IntegrationClientId, + ctx: ManagementContext, + store: IntegrationStateStore, +): Promise { + return { + clientId, + models: await previewExportModels(ctx.config), + config: ctx.config, + port: Number(ctx.url.port) || ctx.config.port, + store, + io: integrationMutationTestHooks?.io, + ...pathOverrides(), + }; +} + /** * The file's current bytes, or `null` when it is missing. * @@ -565,6 +590,71 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise !row.native), config)); return rows.filter(row => !row.disabled && (row.native || visibleRouted.has(row))).map(toExportModel); } + +/** + * The export roster for a read that must not change anything. + * + * `fetchAllModels` is discovery plus the initial-selection finalizer, and that finalizer reaches + * `mutatePersistedConfig`. Writing the user's configuration because somebody opened a dialog is + * not acceptable, so this gathers directly and skips the finalizer. It also skips the entitlement + * freshness wait, which exists to make a picker current and has no business blocking a read for + * three seconds. + * + * Discovery itself still runs, through the same TTL-cached path the models list already uses on + * every Integrations page load, so this is not a new class of work on a read — only the writing + * part is removed. The roster is fingerprinted into the plan, so a commit that would be built + * from a different one is refused rather than silently applied. + */ +export async function previewExportModels(config: OcxConfig): Promise { + const { gatherRoutedModels } = await import("../../codex/catalog"); + return loadExportModels(config, await gatherRoutedModels(config)); +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 525bb705e22..3ec01fa7ffb 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -40,6 +40,13 @@ export type ExemptionReason = | "local-transport" /** Older clients use this alias; the current CLI drives its declared replacement. */ | "compatibility-alias" + /** + * A read-only POST whose purpose is to bind an interactive confirmation to the mutation that + * immediately follows it. There is no standalone thing for a CLI to do with one: the plan is + * only meaningful to the caller that is about to commit it, and a scripted caller drives the + * mutation directly. + */ + | "interactive-preview" /** Unreachable in the live dispatch order; delete rather than expose. */ | "dead" /** @@ -197,6 +204,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: false }, { method: "DELETE", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Retiring one rollback row is a dashboard-local cleanup; the CLI verb that would drive it is owed by a later work-phase and is not implemented here.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } }, { method: "POST", path: "/api/client-integrations/restore", module: "server/management/integration-routes", mutates: true }, + { method: "POST", path: "/api/client-integrations/preview", module: "server/management/integration-routes", mutates: false, exempt: { reason: "interactive-preview", why: "Plans an apply, overwrite or disable so a dashboard confirmation can show what would change and bind to it. Read-only, and useful only to the caller about to commit; a scripted caller drives PUT /api/client-integrations/{clientId} directly." } }, + { method: "POST", path: "/api/client-integrations/restore/preview", module: "server/management/integration-routes", mutates: false, exempt: { reason: "interactive-preview", why: "Plans an undo so drift is shown before a restore rather than discovered by a rejected mutation. Read-only, and useful only to the caller about to commit; a scripted caller drives POST /api/client-integrations/restore directly." } }, // server/management/lab-automation-routes { method: "GET", path: "/api/lab/automation", module: "server/management/lab-automation-routes", mutates: false, exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } }, { method: "GET", path: "/api/lab/automation/runs", module: "server/management/lab-automation-routes", mutates: false, exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } }, From 72e906d444e35c2a22067010ac7666fdc623d40b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:02:18 +0900 Subject: [PATCH 13/83] test(management): cover the preview routes against the real temp home Plans an apply through the route and asserts the client file is byte-identical afterwards, that the response names places rather than locations, and that a malformed operation or an unknown client is refused before any planning runs. An undo for an operation that is not there answers not found rather than reading the journal row back to the caller. local checks: NOT RUN --- .../management-integration-routes.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index e0d6563aeaa..81d84e798df 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -141,6 +141,14 @@ function hermesConfigPath(): string { return INTEGRATION_CLIENTS.hermes.configPath(routeEnv, home); } +async function previewApi(path: string, body: unknown): Promise { + return api(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + async function rawApi(path: string, init: RequestInit = {}): Promise { const url = new URL(`http://127.0.0.1:10100${path}`); return handleManagementAPI( @@ -1212,3 +1220,47 @@ describe("admission", () => { } }); }); + +describe("integration previews are reads", () => { + test("planning an apply reports what it would change and changes nothing", async () => { + const configPath = installHermes(); + const before = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; + + const response = await previewApi("/api/client-integrations/preview", { clientId: "hermes", operation: "apply" }); + expect(response.status).toBe(200); + const plan = await response.json() as { + version: number; + canApply: boolean; + willChange: boolean; + changes: { kind: string; path: string }[]; + fingerprint: string; + }; + + expect(plan.version).toBe(1); + expect(plan.canApply).toBe(true); + expect(plan.willChange).toBe(true); + expect(plan.changes.some(change => change.kind === "journal")).toBe(true); + expect(plan.fingerprint.length).toBeGreaterThan(0); + // A plan names places, never locations or contents. + expect(JSON.stringify(plan)).not.toContain(home); + + const after = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; + expect(after).toBe(before); + }); + + test("a plan is refused before any planning when the request does not name a real operation", async () => { + const badOperation = await previewApi("/api/client-integrations/preview", { clientId: "hermes", operation: "launch" }); + expect(badOperation.status).toBe(400); + expect((await badOperation.json() as { code: string }).code).toBe("invalid_preview_operation"); + + const badClient = await previewApi("/api/client-integrations/preview", { clientId: "not-a-client", operation: "apply" }); + expect(badClient.status).toBeGreaterThanOrEqual(400); + }); + + test("planning an undo for an operation that is not there declines to read the journal back", async () => { + const response = await previewApi("/api/client-integrations/restore/preview", { opId: "no-such-operation" }); + expect(response.status).toBe(404); + const body = await response.json() as { code: string }; + expect(body.code).toBe("integration_operation_not_found"); + }); +}); From 5bc8393ac35aad8e8a45aefa08a884119a0104ed Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:04:57 +0900 Subject: [PATCH 14/83] fix(integrations): plan an undo the way the writer performs one Restore was being previewed through the general observation, and the writer's undo path reads a different specification. It compares the resolved config path against the one the journal row was recorded for, reads the snapshot, and reads the target's bytes. It never parses and never classifies. Three consequences, all wrong in the direction that matters. A file that was readable but unparseable classified unsafe, so the preview refused an undo the writer would perform, denying the operator their backup at the moment the file is in the state that most needs one. Only the row's client was checked, not the path it was recorded against, so a row from a previous home previewed as applicable when path equality is the single thing that undo exists to enforce. And the coordinated restore's refusal for writer-lock clients with no client home was never applied. observeRestore now mirrors that order exactly and derives state from bytes alone: a missing target is absent, one that no longer matches the recorded result is a conflict, anything else is current. Admission is not consulted, because restore emits nothing an admission policy could object to and the writer does not consult it either. The places an undo touches come from the provenance the row carries rather than from whatever a record says today. local checks: NOT RUN --- src/integrations/mutation-plan.ts | 129 ++++++++++++++++++++++++---- tests/clients/mutation-plan.test.ts | 66 ++++++++++++++ 2 files changed, 179 insertions(+), 16 deletions(-) diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index b6749c27b25..802c4eebad4 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -368,7 +368,7 @@ function changesOf(input: PlanInput): readonly IntegrationPlanChange[] { changes.push({ kind: occupied ? "replace" : "add", path }); } } - if (input.operation === "disable" || input.operation === "restore") { + if (input.operation === "disable") { for (const owned of input.record?.fragmentPaths ?? []) { const path = canonicalSchemaPath(input.clientId, owned); if (path === null) continue; @@ -376,6 +376,8 @@ function changesOf(input: PlanInput): readonly IntegrationPlanChange[] { } } if (input.operation === "restore") { + // Provenance is restored, never re-derived, so the places an undo touches are the ones the + // row recorded as ours before that operation ran, not whatever a record says today. for (const fragment of input.restore?.entry.priorRecord?.fragmentPaths ?? []) { const path = canonicalSchemaPath(input.clientId, fragment); if (path === null) continue; @@ -442,6 +444,73 @@ function unboundPlan( }); } +/** + * Restore reads a different specification, so it gets its own observation. + * + * The writer's undo path never parses and never classifies: it compares the resolved config path + * against the one the journal row was recorded for, reads the snapshot, and reads the target's + * BYTES. Routing a preview through the general observation therefore refused an undo of a file + * that was readable but unparseable, which is the state that most needs restoring, and accepted a + * row recorded against a previous home, which is the single case path equality exists to refuse. + */ +export function observeRestore(input: IntegrationWriteInput, opId: string, effects: ObservationEffects) { + const store = input.store ?? createIntegrationStateStore(); + let io = input.io ?? defaultIntegrationIO(store); + const clientId = input.clientId; + let resolved: { configPath: string; detectDir: string }; + try { + resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home); + } catch (error) { + if (!(error instanceof ClientPathError)) throw error; + return { failed: observationFailure("unsafe", "unsafe", error.message) } as const; + } + // Coordinated restore refuses this before it looks at the row at all: an undo will not create + // the client's home, so a writer-lock client without one has nothing to restore into. + if (INTEGRATION_CLIENTS[clientId].writerLock && io.statKind(resolved.detectDir) !== "dir") { + return { + failed: observationFailure("unsafe", "unsafe", "the client home is missing; restore will not create it"), + } as const; + } + const entry = store.findOperation(opId); + if (!entry || entry.clientId !== clientId) { + return { failed: observationFailure("unsafe", "unsafe", "that operation cannot be undone") } as const; + } + const configPath = entry.configPath; + // An undo acts on the path the operation was journaled against. A row recorded for one home must + // never be allowed to rewrite a file in another. + if (resolved.configPath !== configPath) { + return { + failed: observationFailure("conflict", "conflict", "that operation was recorded for a different location"), + } as const; + } + if (clientId === "cline") { + try { io = createClineIO(io, configPath, store, effects.recover); } + catch (error) { + if (!(error instanceof ClineTransactionError)) throw error; + return { failed: { ...observationFailure("unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const; + } + } + const snapshot = store.readSnapshot(entry); + const target = loadTarget(io, configPath); + if (!target.ok) { + return { failed: observationFailure("unsafe", "unsafe", "the target cannot be read safely") } as const; + } + const before = target.before; + return { + failed: undefined, + clientId, + configPath, + detectDir: resolved.detectDir, + installKind: io.statKind(resolved.detectDir), + entry, + snapshotKind: snapshot.kind, + snapshotText: snapshot.kind === "stored" ? snapshot.text : null, + before, + // Bytes only. Parsing here is exactly what must not happen. + driftsFromResult: !matchesOperationResult(entry, before), + } as const; +} + export interface PreviewRequest { readonly operation: IntegrationPlanOperation; /** Required for restore; names the journalled operation being undone. */ @@ -458,6 +527,9 @@ export interface PreviewRequest { * file, the ownership records, and for restore the journal row and its snapshot. */ export function previewIntegration(input: IntegrationWriteInput, request: PreviewRequest): IntegrationMutationPlan { + // Restore never reaches the general observation, because the writer's undo path never parses + // or classifies and a preview that did would answer a different question. + if (request.operation === "restore") return previewRestore(input, request); const observed = observeIntegration(input, { maintenance: false, recover: false }); if (observed.failed) return unboundPlan(input.clientId, request.operation, observed.failed, request.profileId); @@ -478,26 +550,51 @@ export function previewIntegration(input: IntegrationWriteInput, request: Previe ...(request.profileId === undefined ? {} : { profileId: request.profileId }), }; - if (request.operation !== "restore") return buildMutationPlan(shared); + return buildMutationPlan(shared); +} + +/** + * Plan an undo the way the writer performs one. + * + * State is derived from bytes alone: a missing target is absent, a target that no longer matches + * the row's recorded result is a conflict, and anything else is current. Admission is not asked + * about, because restore emits nothing an admission policy could object to and the writer does not + * ask either. + */ +function previewRestore(input: IntegrationWriteInput, request: PreviewRequest): IntegrationMutationPlan { + const refusal = (message: string): IntegrationMutationPlan => + unboundPlan(input.clientId, "restore", { reason: "unsafe", state: "unsafe", message }, request.profileId); + if (request.opId === undefined) return refusal("that operation cannot be undone"); + + const observed = observeRestore(input, request.opId, { maintenance: false, recover: false }); + if (observed.failed) return unboundPlan(input.clientId, "restore", observed.failed, request.profileId); + + const state: IntegrationState = observed.before === null + ? "absent" + : observed.driftsFromResult ? "conflict" : "current"; - const entry = request.opId === undefined ? null : observed.store.findOperation(request.opId); - if (!entry || entry.clientId !== observed.clientId) { - // Naming which of the two it was would report on journal contents the caller did not select. - return unboundPlan(observed.clientId, "restore", { - reason: "unsafe", state: observed.classified.state, message: "that operation cannot be undone", - }, request.profileId); - } - const snapshot = observed.store.readSnapshot(entry); return buildMutationPlan({ - ...shared, + operation: "restore", + clientId: observed.clientId, + configPath: observed.configPath, + detectDir: observed.detectDir, + installKind: observed.installKind, + admissionBlocked: false, + before: observed.before, + contribution: null, + record: null, + models: input.models, + classified: { state }, + parsed: undefined, restore: { - opId: entry.opId, - entry, - snapshotKind: snapshot.kind, - snapshotText: snapshot.kind === "stored" ? snapshot.text : null, + opId: observed.entry.opId, + entry: observed.entry, + snapshotKind: observed.snapshotKind, + snapshotText: observed.snapshotText, confirmDrift: request.confirmDrift === true, - driftsFromResult: !matchesOperationResult(entry, observed.before), + driftsFromResult: observed.driftsFromResult, }, + ...(request.profileId === undefined ? {} : { profileId: request.profileId }), }); } diff --git a/tests/clients/mutation-plan.test.ts b/tests/clients/mutation-plan.test.ts index da76d2ed340..6bf8d4b765b 100644 --- a/tests/clients/mutation-plan.test.ts +++ b/tests/clients/mutation-plan.test.ts @@ -22,6 +22,7 @@ import { type ManagedContribution, } from "../../src/clients/config-export"; import type { OwnershipRecord } from "../../src/integrations/ownership"; +import { fingerprint } from "../../src/integrations/ownership"; import type { JournalEntry } from "../../src/integrations/journal"; import type { OcxConfig } from "../../src/types"; import { createIntegrationStateStore } from "../../src/integrations/store"; @@ -426,3 +427,68 @@ describe("integration preview writes nothing", () => { } }); }); + +describe("planning an undo reads the writer's specification", () => { + function seedRestore(home: string, storeRoot: string, targetText: string, recordedPath?: string) { + const store = createIntegrationStateStore(storeRoot); + const env = {} as NodeJS.ProcessEnv; + const { configPath, detectDir } = resolveIntegrationPaths("opencode", env, home); + expect(configPath.startsWith(home), configPath).toBe(true); + mkdirSync(detectDir, { recursive: true }); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, targetText); + const opId = "op-seeded"; + const snapshot = store.captureSnapshot("opencode", opId, "{}\n"); + store.appendJournal({ + opId, + clientId: "opencode", + kind: "apply", + at: "2026-01-01T00:00:00.000Z", + configPath: recordedPath ?? configPath, + snapshot, + resultFingerprint: fingerprint(targetText), + resultAbsent: false, + priorRecord: null, + } satisfies JournalEntry); + return { store, env, opId }; + } + + test("a target that cannot be parsed is exactly the one worth restoring", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-restore-parse-")); + const storeRoot = mkdtempSync(join(tmpdir(), "ocx-restore-parse-store-")); + try { + // The writer's undo reads bytes and never parses, so refusing here would deny an operator + // the backup at the moment the file is in the state that most needs one. + const { store, env, opId } = seedRestore(home, storeRoot, "{ this is not valid json"); + const plan = previewIntegration( + { clientId: "opencode", models: FIXTURE_MODELS, config: FIXTURE_CONFIG, port: 10100, env, home, store }, + { operation: "restore", opId }, + ); + expect(plan.refusalReason).toBeUndefined(); + expect(plan.canApply).toBe(true); + } finally { + removeTreeWithRetry(home); + removeTreeWithRetry(storeRoot); + } + }); + + test("a row recorded against another location is refused rather than planned", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-restore-path-")); + const storeRoot = mkdtempSync(join(tmpdir(), "ocx-restore-path-store-")); + try { + // Path equality is the single thing the writer's undo exists to enforce: a row recorded for + // one home must never rewrite a file in another. + const { store, env, opId } = seedRestore(home, storeRoot, "{}\n", join(home, "elsewhere", "opencode.json")); + const plan = previewIntegration( + { clientId: "opencode", models: FIXTURE_MODELS, config: FIXTURE_CONFIG, port: 10100, env, home, store }, + { operation: "restore", opId }, + ); + expect(plan.canApply).toBe(false); + expect(plan.refusalReason).toBe("conflict"); + expect(plan.changes).toEqual([]); + } finally { + removeTreeWithRetry(home); + removeTreeWithRetry(storeRoot); + } + }); +}); From 6a9974a142fe028a79562e894e2e46fa3de07a15 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:06:53 +0900 Subject: [PATCH 15/83] fix(management): never gather a model roster to serve a preview Skipping the initial-selection finalizer was not enough, and the reviewer was right to push back. Discovery itself refreshes credentials and writes the provider model cache, so a preview that gathered was still a write wearing a read's name. That the models list already performs a gather describes what a GET happens to do, not what a preview is permitted to do. previewExportModels now reads already-captured per-provider cache entries and never fetches. When no provider has one there is no honest snapshot to plan against, so the route answers a bounded refusal instead of triggering discovery to manufacture one; the operator opens the models view or performs the mutation directly, both of which are allowed to gather. The route regression asserts that refusal and that the client file is untouched, which is the property that mattered in the first place. local checks: NOT RUN --- src/server/management/integration-routes.ts | 24 +++++++++++-- src/server/management/model-rows.ts | 34 ++++++++++++------- .../management-integration-routes.test.ts | 25 +++++--------- 3 files changed, 51 insertions(+), 32 deletions(-) diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 8407cf23fb6..44128983fa7 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -259,10 +259,14 @@ async function buildIntegrationPreviewInput( clientId: IntegrationClientId, ctx: ManagementContext, store: IntegrationStateStore, -): Promise { +): Promise { + const models = await previewExportModels(ctx.config); + // No cached roster means no honest snapshot to plan against. Gathering one here would make a + // read refresh credentials and write the provider cache, which is the thing preview must not do. + if (models === null) return null; return { clientId, - models: await previewExportModels(ctx.config), + models, config: ctx.config, port: Number(ctx.url.port) || ctx.config.port, store, @@ -296,6 +300,20 @@ function invalidClientResponse(ctx: ManagementContext): Response { }, 400, ctx.req, ctx.config); } +/** + * No cached model roster, so there is nothing honest to plan against. + * + * Answered as a bounded refusal rather than by gathering one: discovery refreshes credentials and + * writes the provider cache, and a preview that did either would be a write wearing a read's name. + * The caller opens the models view or performs the mutation directly. + */ +function previewUnavailableResponse(ctx: ManagementContext): Response { + return jsonResponse({ + error: "no model roster is cached yet, so this change cannot be planned", + code: "integration_preview_unavailable", + }, 409, ctx.req, ctx.config); +} + function internalErrorResponse(error: unknown, ctx: ManagementContext): Response { return jsonResponse({ error: error instanceof Error ? error.message : String(error), @@ -611,6 +629,7 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { - const { gatherRoutedModels } = await import("../../codex/catalog"); - return loadExportModels(config, await gatherRoutedModels(config)); +export async function previewExportModels(config: OcxConfig): Promise { + const { getFreshCached, getStaleCached, DEFAULT_MODEL_CACHE_TTL_MS } = await import("../../codex/model-cache"); + const providers = Object.keys(config.providers ?? {}); + const cached: CatalogModel[] = []; + let anyCache = false; + for (const provider of providers) { + const rows = getFreshCached(provider, DEFAULT_MODEL_CACHE_TTL_MS) ?? getStaleCached(provider); + if (rows === null) continue; + anyCache = true; + cached.push(...rows); + } + if (!anyCache && providers.length > 0) return null; + return loadExportModels(config, cached); } diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index 81d84e798df..2f9f4d85d88 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -1222,27 +1222,18 @@ describe("admission", () => { }); describe("integration previews are reads", () => { - test("planning an apply reports what it would change and changes nothing", async () => { + test("planning without a cached roster refuses rather than gathering one", async () => { const configPath = installHermes(); const before = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; const response = await previewApi("/api/client-integrations/preview", { clientId: "hermes", operation: "apply" }); - expect(response.status).toBe(200); - const plan = await response.json() as { - version: number; - canApply: boolean; - willChange: boolean; - changes: { kind: string; path: string }[]; - fingerprint: string; - }; - - expect(plan.version).toBe(1); - expect(plan.canApply).toBe(true); - expect(plan.willChange).toBe(true); - expect(plan.changes.some(change => change.kind === "journal")).toBe(true); - expect(plan.fingerprint.length).toBeGreaterThan(0); - // A plan names places, never locations or contents. - expect(JSON.stringify(plan)).not.toContain(home); + // Discovery refreshes credentials and writes the provider cache, so a preview may not perform + // one to manufacture a roster. With nothing cached there is no honest snapshot to plan + // against, and saying so is the correct answer. + expect(response.status).toBe(409); + const body = await response.json() as { code: string }; + expect(body.code).toBe("integration_preview_unavailable"); + expect(JSON.stringify(body)).not.toContain(home); const after = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; expect(after).toBe(before); From f4c090ed50a825ef2106b7c03b419b7a87cfac81 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:08:43 +0900 Subject: [PATCH 16/83] feat(management): bind a confirmed plan to the mutation it authorizes Toggle and restore now accept an operation and a plan fingerprint together, and re-plan before the writer runs. A fingerprint that no longer matches returns 409 with a freshly computed plan, so the operator decides again against what is true now instead of confirming something that has moved. Both fields or neither. A half-bound request is refused rather than quietly treated as unbound, because dropping one half would answer 200 to a caller who believed their confirmation was being checked, and a confirmation naming a different operation than the request performs is not a confirmation of that request. The fingerprint is an optimistic token and never authorization: management authentication and every ownership rule still decide whether the mutation may happen at all. Existing unbound callers are unaffected. Validation currently runs immediately before the writer rather than inside its lock, which closes the window an operator can observe but not the one between this check and the lock; moving it under coordinatedWrite is the next step and the writer's own compare-before-commit guard remains in place meanwhile. local checks: NOT RUN --- src/server/management/integration-routes.ts | 94 ++++++++++++++++++- .../management-integration-routes.test.ts | 32 +++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 44128983fa7..8c591566356 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -46,7 +46,11 @@ import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; import { loadExportModels, previewExportModels } from "./model-rows"; -import { previewIntegration } from "../../integrations/mutation-plan"; +import { + previewIntegration, + type IntegrationPlanOperation, + type PreviewRequest, +} from "../../integrations/mutation-plan"; const INTEGRATION_ROUTE_PREFIX = "/api/client-integrations/"; @@ -314,6 +318,58 @@ function previewUnavailableResponse(ctx: ManagementContext): Response { }, 409, ctx.req, ctx.config); } +/** + * A confirmed plan, or a reason the request cannot carry one. + * + * Both fields or neither. A half-bound request is rejected rather than quietly treated as + * unbound, because dropping one half would answer 200 to a caller who believed their + * confirmation was being checked. + */ +function planBindingOf( + body: Record, +): { operation: IntegrationPlanOperation; fingerprint: string } | "none" | "half" | "unknown-operation" { + const { operation, planFingerprint } = body; + if (operation === undefined && planFingerprint === undefined) return "none"; + if (operation === undefined || typeof planFingerprint !== "string" || planFingerprint.length === 0) return "half"; + if (operation !== "apply" && operation !== "overwrite" && operation !== "disable" && operation !== "restore") { + return "unknown-operation"; + } + return { operation, fingerprint: planFingerprint }; +} + +function halfBoundResponse(ctx: ManagementContext): Response { + return jsonResponse({ + error: "operation and planFingerprint must be sent together", + code: "invalid_preview_binding", + }, 400, ctx.req, ctx.config); +} + +/** + * Re-plan and compare before the mutation runs. + * + * The fingerprint is an optimistic token, never authorization: management authentication and + * every ownership rule still apply. What it adds is that a confirmation stops meaning anything + * the moment the state it described moved, and the refusal carries a fresh plan so the operator + * decides again against what is true now. + */ +async function stalePlanResponse( + clientId: IntegrationClientId, + ctx: ManagementContext, + store: IntegrationStateStore, + request: PreviewRequest, + fingerprint: string, +): Promise { + const input = await buildIntegrationPreviewInput(clientId, ctx, store); + if (!input) return previewUnavailableResponse(ctx); + const plan = previewIntegration(input, request); + if (plan.canApply && plan.fingerprint === fingerprint) return null; + return jsonResponse({ + error: "integration preview is stale", + code: "integration_preview_stale", + plan, + }, 409, ctx.req, ctx.config); +} + function internalErrorResponse(error: unknown, ctx: ManagementContext): Response { return jsonResponse({ error: error instanceof Error ? error.message : String(error), @@ -694,6 +750,14 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { const body = await response.json() as { code: string }; expect(body.code).toBe("integration_operation_not_found"); }); + + test("a half-bound confirmation is rejected rather than quietly treated as unbound", async () => { + installHermes(); + // Dropping one half would answer 200 to a caller who believed their confirmation was being + // checked, which is worse than refusing the request. + const onlyOperation = await api("/api/client-integrations/hermes", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true, operation: "apply" }), + }); + expect(onlyOperation.status).toBe(400); + expect((await onlyOperation.json() as { code: string }).code).toBe("invalid_preview_binding"); + + const onlyFingerprint = await api("/api/client-integrations/hermes", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true, planFingerprint: "p1:whatever" }), + }); + expect(onlyFingerprint.status).toBe(400); + expect((await onlyFingerprint.json() as { code: string }).code).toBe("invalid_preview_binding"); + }); + + test("a confirmation naming a different operation than the request is not a confirmation of it", async () => { + installHermes(); + const response = await api("/api/client-integrations/hermes", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: false, operation: "apply", planFingerprint: "p1:whatever" }), + }); + expect(response.status).toBe(400); + expect((await response.json() as { code: string }).code).toBe("invalid_preview_operation"); + }); }); From 23d36cd2c7ca58961dd20c384f95c09d3978897a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:10:12 +0900 Subject: [PATCH 17/83] fix(integrations): check a confirmation where the lock already holds Validating the fingerprint just before calling the writer left a window between the check and lock acquisition. Narrow, but the wrong kind of narrow: the whole point of binding a confirmation is that it describes the state the mutation is about to change, and a check outside the lock describes state from a moment earlier. coordinatedWrite now takes a revalidation hook and runs it after the input is frozen and the lock is held, before any side effect. Clients without a writer lock, and the case where an absent client home means no lock is taken, run the same hook immediately before the operation, which is as close as those paths allow. The route keeps the fresh plan from that hook so a stale confirmation still answers 409 with something the operator can decide against, rather than a bare refusal that tells them only that they were too late. local checks: NOT RUN --- src/integrations/writer.ts | 22 +++++- src/server/management/integration-routes.ts | 79 ++++++++++++++------- 2 files changed, 72 insertions(+), 29 deletions(-) diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 4bf2766c164..4a2c9e6e551 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -678,6 +678,15 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome export interface CoordinatedIntegrationOptions { lockSeams?: IntegrationWriterLockSeams; + /** + * Checked after the input is frozen and the writer lock is held, before any side effect. + * + * A confirmation is a statement about state the operator was shown. Checking it out here, where + * the lock already excludes cooperating writers, is what makes it a decision about the same + * state the mutation is about to change rather than about state from a moment earlier. A + * non-null result refuses without writing anything. + */ + revalidate?: (frozen: IntegrationWriteInput) => Promise; } /** Freeze all mutable resolution seams before the first lock await. */ @@ -729,15 +738,22 @@ async function coordinatedWrite( if (!prepared.ok) return prepared.refusal; const frozen = prepared.value; const spec = INTEGRATION_CLIENTS[frozen.clientId]; - if (!spec.writerLock) return operation(frozen); + if (!spec.writerLock) { + const refused = await options?.revalidate?.(frozen); + return refused ?? operation(frozen); + } // An absent client home is not created merely to acquire a sibling lock. if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") { - return operation(frozen); + const refused = await options?.revalidate?.(frozen); + return refused ?? operation(frozen); } return withIntegrationWriterLock( frozen.resolvedPaths.configPath, - async () => operation(frozen), + async () => { + const refused = await options?.revalidate?.(frozen); + return refused ?? operation(frozen); + }, options?.lockSeams, spec.writerLock.suffix, ); diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 8c591566356..85b36ebd054 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -31,6 +31,7 @@ import { disableIntegrationCoordinated, overwriteIntegrationCoordinated, restoreIntegrationCoordinated, + type CoordinatedIntegrationOptions, type IntegrationRestoreInput, type IntegrationWriteInput, type WriteRefused, @@ -48,6 +49,7 @@ import type { ManagementContext } from "./context"; import { loadExportModels, previewExportModels } from "./model-rows"; import { previewIntegration, + type IntegrationMutationPlan, type IntegrationPlanOperation, type PreviewRequest, } from "../../integrations/mutation-plan"; @@ -352,22 +354,39 @@ function halfBoundResponse(ctx: ManagementContext): Response { * the moment the state it described moved, and the refusal carries a fresh plan so the operator * decides again against what is true now. */ -async function stalePlanResponse( +function stalePlanGuard( clientId: IntegrationClientId, ctx: ManagementContext, store: IntegrationStateStore, request: PreviewRequest, fingerprint: string, -): Promise { - const input = await buildIntegrationPreviewInput(clientId, ctx, store); - if (!input) return previewUnavailableResponse(ctx); - const plan = previewIntegration(input, request); - if (plan.canApply && plan.fingerprint === fingerprint) return null; - return jsonResponse({ - error: "integration preview is stale", - code: "integration_preview_stale", - plan, - }, 409, ctx.req, ctx.config); +): { + revalidate: NonNullable; + response: () => Response | null; +} { + let stale: IntegrationMutationPlan | "unavailable" | null = null; + return { + revalidate: async () => { + const input = await buildIntegrationPreviewInput(clientId, ctx, store); + if (!input) { + stale = "unavailable"; + return { ok: false, reason: "conflict", state: "conflict", clientId, message: "no model roster is cached" }; + } + const plan = previewIntegration(input, request); + if (plan.canApply && plan.fingerprint === fingerprint) return null; + stale = plan; + return { ok: false, reason: "conflict", state: plan.state, clientId, message: "that confirmation no longer describes this file" }; + }, + response: () => { + if (stale === null) return null; + if (stale === "unavailable") return previewUnavailableResponse(ctx); + return jsonResponse({ + error: "integration preview is stale", + code: "integration_preview_stale", + plan: stale, + }, 409, ctx.req, ctx.config); + }, + }; } function internalErrorResponse(error: unknown, ctx: ManagementContext): Response { @@ -782,16 +801,13 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise restoreIntegrationCoordinated(restoreInput, { lockSeams: integrationMutationTestHooks?.lockSeams, + ...(restoreGuard ? { revalidate: restoreGuard.revalidate } : {}), }), ); + const restoreStale = restoreGuard?.response(); + if (restoreStale) return restoreStale; if (!result.ok) { /* * Drift is NOT special-cased here. @@ -898,22 +917,30 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { - const options = { lockSeams: integrationMutationTestHooks?.lockSeams }; + const options = { + lockSeams: integrationMutationTestHooks?.lockSeams, + ...(guard ? { revalidate: guard.revalidate } : {}), + }; if (!parsed.enabled) return disableIntegrationCoordinated(input, options); return parsed.overwriteConflict === true ? overwriteIntegrationCoordinated(input, options) : applyIntegrationCoordinated(input, options); }, ); + const stale = guard?.response(); + if (stale) return stale; if (!result.ok) return writerFailureResponse(requestedClient, result, ctx); return jsonResponse(result satisfies IntegrationToggleEnvelope, 200, req, ctx.config); } catch (error) { From 2fa163484122b0c922a0fad01def6c7c073650ba Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:11:26 +0900 Subject: [PATCH 18/83] fix(integrations): check an Aside confirmation before its preference write Aside is the one place where checking under the writer lock is provably too late, and the code already said so. mutateAsideProfiles persists the user's preference before any writer runs, under a comment noting that the await precedes model loading, writer preflight, snapshots and all client writes. restoreAsideProfile persists and then imports the operation, which captures a snapshot and appends a journal row, before the coordinated restore. A confirmation validated under that lock would therefore fire after it had already rewritten the preference and the history it was meant to protect. Both now take a revalidation hook and run it once profile and path selection is frozen, which is everything the check needs, and before anything is written. Bulk mutation is unaffected because one fingerprint cannot honestly bind several independently changing files. local checks: NOT RUN --- src/integrations/aside-profile-journal.ts | 9 +++++++++ src/integrations/aside-profiles.ts | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/integrations/aside-profile-journal.ts b/src/integrations/aside-profile-journal.ts index 0fd7f471834..d0027cbc11e 100644 --- a/src/integrations/aside-profile-journal.ts +++ b/src/integrations/aside-profile-journal.ts @@ -168,6 +168,7 @@ function importOperation(row: AsideOperation, scope: AsideProfileScope): void { export function restoreAsideProfile( input: AsideProfilesInput, request: { opId: string; profileId?: number; confirmDrift?: boolean }, + options?: { revalidate?: () => Promise }, ): Promise { return runAsideProfileAction(input, request.profileId, `restore:${request.opId}:${Boolean(request.confirmDrift)}`, async ctx => { const row = requiredOperation(ctx, request.opId, request.profileId); @@ -188,6 +189,14 @@ export function restoreAsideProfile( 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" }; } const restoredText = snapshot.kind === "stored" ? snapshot.text : null; + /* + * Checked before the preference write and before importOperation, which captures a snapshot + * and appends a journal row. Both happen ahead of the coordinated restore, so a confirmation + * checked under the writer lock would already have rewritten the user's preference and their + * history by the time it was consulted. + */ + const stale = await options?.revalidate?.(); + if (stale) return stale; await persistAsidePolicy(ctx, { profileId: profile.id, enabled: snapshotWasOwned(row.entry, restoredText, bound) }); try { scope.assertBoundary(); diff --git a/src/integrations/aside-profiles.ts b/src/integrations/aside-profiles.ts index 94514f253a8..9ec1cbd62d2 100644 --- a/src/integrations/aside-profiles.ts +++ b/src/integrations/aside-profiles.ts @@ -104,6 +104,7 @@ export async function getAsideProfileState(input: AsideProfilesInput, id: number export function mutateAsideProfiles( input: AsideProfilesInput, change: { enabled: boolean; profileId?: number; overwriteConflict?: boolean }, + options?: { revalidate?: () => Promise }, ): Promise { return runAsideProfileAction(input, change.profileId, `${change.enabled ? "enable" : "disable"}:${Boolean(change.overwriteConflict)}`, async (ctx, profiles) => { const refused = new Map(); @@ -111,6 +112,25 @@ export function mutateAsideProfiles( try { asideProfileScope(ctx, profile); } catch (error) { refused.set(profile.id, asideProfileFailure(profile.id, error)); } } + /* + * A confirmation is checked HERE, not under the writer lock. + * + * The await below persists the user's Aside preference before any writer runs, so a check + * that waited for the lock would fire after the thing it was meant to prevent. Profile and + * path selection is frozen by this point, which is everything the check needs. + */ + const stale = await options?.revalidate?.(); + if (stale) { + return { + ok: false, + clientId: "aside", + changed: false, + state: "conflict", + message: "that confirmation no longer describes this profile", + results: [stale], + result: stale, + }; + } // This await precedes model loading, writer preflight, snapshots and all client writes. await persistAsidePolicy(ctx, change); const results: AsideProfileWriteOutcome[] = []; From 007d14a183e0e48e326e26eaf11332e7a0c5cb1c Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:12:15 +0900 Subject: [PATCH 19/83] test(management): a bound change cannot commit on a plan that fails to validate The case worth pinning is not which refusal comes back but that none of them lets the write through. A refactor that dropped the binding on the way to the writer, or moved the check somewhere it no longer runs, would turn this into a 200 and a modified file. Asserts the refusal and that the client file is byte-identical afterwards, accepting either reason the plan can fail to validate, since both mean the same thing to the operator: the change they confirmed is not the change that would happen now. local checks: NOT RUN --- .../management-integration-routes.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index b8c6d4088ff..6e3aadc9b91 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -1286,4 +1286,24 @@ describe("integration previews are reads", () => { expect(response.status).toBe(400); expect((await response.json() as { code: string }).code).toBe("invalid_preview_operation"); }); + + test("a bound change cannot commit without a plan that still validates", async () => { + const configPath = installHermes(); + const before = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; + + const response = await api("/api/client-integrations/hermes", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true, operation: "apply", planFingerprint: "p1:not-the-current-plan" }), + }); + + // Whatever the reason the plan does not validate, the mutation does not happen. This is the + // case that would catch a refactor quietly dropping the binding on the way to the writer. + expect(response.status).toBe(409); + const body = await response.json() as { code: string }; + expect(["integration_preview_stale", "integration_preview_unavailable"]).toContain(body.code); + + const after = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; + expect(after).toBe(before); + }); }); From e2f2a7815350bfedf4cd15791c3f5d1a941ddeb3 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:16:05 +0900 Subject: [PATCH 20/83] fix(integrations): answer an expired backup and a vanished file the way the writer does Two exact divergences from the writer's undo path. Expiry is now decided before the target is read. Reading first meant an expired backup over an unreadable file reported the file as the problem, when the answer the operator needs is that the backup is gone. And drift decides the state before absence does. A row that recorded a file and now finds none has drifted; calling that absent described a missing file as an ordinary undo while the writer refuses it pending confirmation. Absence is only honest when the row recorded absence too, and both cases are pinned. local checks: NOT RUN --- src/integrations/mutation-plan.ts | 19 ++++++++++++++++--- tests/clients/mutation-plan.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index 802c4eebad4..a24e41e2bfb 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -491,6 +491,14 @@ export function observeRestore(input: IntegrationWriteInput, opId: string, effec } } const snapshot = store.readSnapshot(entry); + /* + * Expiry is decided before the target is read, exactly as the writer decides it. Reading first + * let an expired backup over an unreadable file report the file as the problem, when the answer + * the operator needs is that the backup is gone. + */ + if (snapshot.kind === "expired") { + return { failed: observationFailure("snapshot_expired", "absent", "that backup has expired") } as const; + } const target = loadTarget(io, configPath); if (!target.ok) { return { failed: observationFailure("unsafe", "unsafe", "the target cannot be read safely") } as const; @@ -569,9 +577,14 @@ function previewRestore(input: IntegrationWriteInput, request: PreviewRequest): const observed = observeRestore(input, request.opId, { maintenance: false, recover: false }); if (observed.failed) return unboundPlan(input.clientId, "restore", observed.failed, request.profileId); - const state: IntegrationState = observed.before === null - ? "absent" - : observed.driftsFromResult ? "conflict" : "current"; + /* + * Drift decides first. A row that recorded a file and now finds none has drifted, and calling + * that absent would report a missing file as an ordinary undo while the writer refuses it + * pending confirmation. Absent is only honest when the recorded result was absence too. + */ + const state: IntegrationState = observed.driftsFromResult + ? "conflict" + : observed.before === null ? "absent" : "current"; return buildMutationPlan({ operation: "restore", diff --git a/tests/clients/mutation-plan.test.ts b/tests/clients/mutation-plan.test.ts index 6bf8d4b765b..7d0687be841 100644 --- a/tests/clients/mutation-plan.test.ts +++ b/tests/clients/mutation-plan.test.ts @@ -329,6 +329,30 @@ describe("integration mutation plan", () => { expect(drifted.foreignEdit).toBe("drift"); expect(buildMutationPlan({ ...plan, restore: { ...RESTORE, driftsFromResult: true, confirmDrift: true } }).canApply).toBe(true); }); + + test("a file that was recorded and is now gone has drifted, it is not simply absent", () => { + // Calling it absent would describe a missing file as an ordinary undo, while the writer + // refuses it pending confirmation. Absence is only honest when the row recorded absence too. + const vanished = buildMutationPlan({ + ...RESTORE_BASE, + classified: { state: "conflict" }, + parsed: {}, + before: null, + restore: { ...RESTORE, driftsFromResult: true }, + }); + expect(vanished.state).toBe("conflict"); + expect(vanished.refusalReason).toBe("drift_requires_confirm"); + + const recordedAbsent = buildMutationPlan({ + ...RESTORE_BASE, + classified: { state: "absent" }, + parsed: {}, + before: null, + restore: { ...RESTORE, driftsFromResult: false }, + }); + expect(recordedAbsent.state).toBe("absent"); + expect(recordedAbsent.canApply).toBe(true); + }); }); /** Every file under a directory with its bytes, so "nothing changed" is a comparison and not a claim. */ From 1aad46b0cde92caf04752359d6632e9ee3afd7f9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:17:16 +0900 Subject: [PATCH 21/83] fix(management): preview reads a finished roster, not raw provider caches The per-provider cache was the wrong source and the re-review was right to block it. Static and forward providers return without ever populating one, so a static-only install would have refused previews permanently; a mixed config would have silently dropped the cacheless providers; and the cache sits before the retention, metadata, augmentation, combo and filtering that decide what a client is actually given. So preview reads the completed projection instead. An ordinary export load retains its final result, keyed by a digest over the provider graph's shape, the blocklist and custom models, and a preview returns that only when the key still matches. Changing any of them retires the snapshot rather than letting a plan describe a roster the user no longer has. Credentials are not part of the key and are not read here. A cold process has no snapshot and the route answers a bounded refusal until the ordinary models path populates one, which is a recoverable state rather than a read that gathers. No second gather exists anywhere on this path. local checks: NOT RUN --- src/server/management/integration-routes.ts | 2 +- src/server/management/model-rows.ts | 54 +++++++++++++++----- tests/server/management-model-roster.test.ts | 34 +++++++++++- 3 files changed, 74 insertions(+), 16 deletions(-) diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 85b36ebd054..4f1153909b4 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -266,7 +266,7 @@ async function buildIntegrationPreviewInput( ctx: ManagementContext, store: IntegrationStateStore, ): Promise { - const models = await previewExportModels(ctx.config); + const models = previewExportModels(ctx.config); // No cached roster means no honest snapshot to plan against. Gathering one here would make a // read refresh credentials and write the provider cache, which is the thing preview must not do. if (models === null) return null; diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 2d28a2f5544..89bf0bfe47b 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -9,6 +9,7 @@ * Bodies are unchanged from their previous home; only `export` was added. */ import type { CatalogModel } from "../../codex/catalog"; +import { createHash } from "node:crypto"; import { catalogModelSlug, filterCatalogVisibleModels, @@ -254,7 +255,41 @@ export async function loadExportModels( // Management deliberately lists the full roster so hidden models can be enabled. // A client picker must also honor the provider selection, not just its blocklist. const visibleRouted = new Set(filterCatalogVisibleModels(rows.filter(row => !row.native), config)); - return rows.filter(row => !row.disabled && (row.native || visibleRouted.has(row))).map(toExportModel); + const exported = rows.filter(row => !row.disabled && (row.native || visibleRouted.has(row))).map(toExportModel); + // Retain the FINAL projection, not an input to it. A preview that rebuilt from raw provider + // caches would miss static and forward providers, which never populate one, and would skip the + // retention, metadata, combo and filtering this function applies afterwards. + lastExportSnapshot = { key: exportSnapshotKey(config), models: Object.freeze([...exported]) }; + return exported; +} + +/** + * The completed export roster from the last ordinary load, if it still describes this config. + * + * A preview may not gather, so it reads only what an authoritative load already finished. The key + * is a digest over the provider graph's shape, the blocklist and custom models, so changing any of + * them retires the snapshot rather than letting a preview plan against a roster the user no longer + * has. Credentials are not part of it and are never read here. + * + * A cold process has no snapshot, and the caller answers a bounded refusal until the ordinary + * models path populates one. That is a real and recoverable state, unlike a read that gathers. + */ +let lastExportSnapshot: { key: string; models: readonly ExportModel[] } | null = null; + +function exportSnapshotKey(config: OcxConfig): string { + const providers = Object.entries(config.providers ?? {}) + .map(([name, provider]) => [name, provider.adapter, provider.baseUrl, provider.models ?? null]) + .sort((left, right) => String(left[0]) < String(right[0]) ? -1 : 1); + return createHash("sha256").update(JSON.stringify({ + providers, + disabled: config.disabledModels ?? [], + custom: config.customModels ?? [], + })).digest("hex").slice(0, 32); +} + +/** Test seam: a fresh process has no snapshot, and suites must be able to reproduce that. */ +export function resetExportSnapshotForTests(): void { + lastExportSnapshot = null; } /** @@ -269,17 +304,8 @@ export async function loadExportModels( * has a cached roster there is no honest snapshot to plan against, and the caller reports a * bounded refusal rather than triggering a gather to manufacture one. */ -export async function previewExportModels(config: OcxConfig): Promise { - const { getFreshCached, getStaleCached, DEFAULT_MODEL_CACHE_TTL_MS } = await import("../../codex/model-cache"); - const providers = Object.keys(config.providers ?? {}); - const cached: CatalogModel[] = []; - let anyCache = false; - for (const provider of providers) { - const rows = getFreshCached(provider, DEFAULT_MODEL_CACHE_TTL_MS) ?? getStaleCached(provider); - if (rows === null) continue; - anyCache = true; - cached.push(...rows); - } - if (!anyCache && providers.length > 0) return null; - return loadExportModels(config, cached); +export function previewExportModels(config: OcxConfig): readonly ExportModel[] | null { + const snapshot = lastExportSnapshot; + if (snapshot === null || snapshot.key !== exportSnapshotKey(config)) return null; + return snapshot.models; } diff --git a/tests/server/management-model-roster.test.ts b/tests/server/management-model-roster.test.ts index b15dfb3947b..acc8e3817e3 100644 --- a/tests/server/management-model-roster.test.ts +++ b/tests/server/management-model-roster.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { listManagementModelRows, loadExportModels } from "../../src/server/management/model-rows"; +import { + listManagementModelRows, + loadExportModels, + previewExportModels, + resetExportSnapshotForTests, +} from "../../src/server/management/model-rows"; import type { CatalogModel } from "../../src/codex/catalog"; import type { OcxConfig } from "../../src/types"; @@ -41,3 +46,30 @@ describe("a supplied roster replaces the gather and keeps the projection", () => await expect(loadExportModels(CONFIG, SUPPLIED)).resolves.toBeInstanceOf(Array); }); }); + +describe("a preview reads only a roster an authoritative load already finished", () => { + test("a cold process has none, and an ordinary load leaves the final projection behind", async () => { + resetExportSnapshotForTests(); + // Recoverable rather than broken: the operator opens the models view and the snapshot appears. + expect(previewExportModels(CONFIG)).toBeNull(); + + const exported = await loadExportModels(CONFIG, SUPPLIED); + // The FINAL projection, not an input to it: rebuilding from raw provider caches would miss + // static and forward providers and skip the filtering this applies afterwards. + expect(previewExportModels(CONFIG)).toEqual(exported); + }); + + test("changing what the roster depends on retires the snapshot", async () => { + resetExportSnapshotForTests(); + await loadExportModels(CONFIG, SUPPLIED); + expect(previewExportModels(CONFIG)).not.toBeNull(); + + // A blocklist edit changes which models a client would be given, so a plan built against the + // old roster no longer describes what the user has. + const blocked = { ...CONFIG, disabledModels: ["supplied/supplied-model"] } as OcxConfig; + expect(previewExportModels(blocked)).toBeNull(); + + const added = { ...CONFIG, customModels: [{ id: "c1", provider: "supplied", modelId: "extra" }] } as OcxConfig; + expect(previewExportModels(added)).toBeNull(); + }); +}); From 3e70b2dd0e8a7dd9c576fd6a99b9b6fb93116ec2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:18:34 +0900 Subject: [PATCH 22/83] fix(integrations): actually check a bound undo, and bind it to one roster Two defects review found in the binding, both of which made it weaker than it appeared. restoreIntegrationCoordinated has its own coordinated path and never called the revalidation hook, so a bound restore carried a fingerprint that nothing ever compared. It now runs the hook in both branches, with the lock held where there is one, before the snapshot, the write and the journal row. And a bound toggle built its writer input through the refreshing loader before the guard ran, while the guard re-planned against the passive snapshot. The mutation and its own confirmation check would then disagree about the roster by construction, which is precisely the disagreement the binding exists to detect. A bound request now uses the passive snapshot for both; an unbound one keeps its existing path unchanged. local checks: NOT RUN --- src/integrations/writer.ts | 13 +++++++++++-- src/server/management/integration-routes.ts | 11 ++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 4a2c9e6e551..56759294d25 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -795,7 +795,11 @@ export async function restoreIntegrationCoordinated( if (!prepared.ok) return prepared.refusal; const frozen = prepared.value; const spec = INTEGRATION_CLIENTS[frozen.clientId]; - if (!spec.writerLock) return restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift }); + const run = () => restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift }); + if (!spec.writerLock) { + const refused = await options?.revalidate?.(frozen); + return refused ?? run(); + } if (frozen.io.statKind(frozen.resolvedPaths.detectDir) !== "dir") { return refuse( frozen.clientId, @@ -806,7 +810,12 @@ export async function restoreIntegrationCoordinated( } return withIntegrationWriterLock( frozen.resolvedPaths.configPath, - async () => restoreIntegration({ ...frozen, opId: input.opId, confirmDrift: input.confirmDrift }), + async () => { + // An undo is bound like any other confirmation, and this is the only place where that check + // happens with the lock held and before the snapshot, the write and the journal row. + const refused = await options?.revalidate?.(frozen); + return refused ?? run(); + }, options?.lockSeams, spec.writerLock.suffix, ); diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 4f1153909b4..fae7a9c142f 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -916,7 +916,16 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise Date: Sun, 20 Sep 2026 01:19:41 +0900 Subject: [PATCH 23/83] fix(management): never silently drop a preview binding on the Aside routes The pre-persistence hooks added for the Aside paths were unreachable over HTTP, because these routes neither parsed nor forwarded the binding fields. A caller sending them got a mutation that proceeded unchecked while believing their confirmation was being verified, which is worse than either honouring or refusing it. Both routes now parse the fields strictly: together or not at all, the operation must agree with the change being requested, and an exact profile is required since one fingerprint cannot honestly describe several independently changing files. A well-formed binding is then refused outright, because the profile-scoped plan owner these routes would need does not exist yet. That refusal is deliberate and temporary. It removes the silent-drop failure now; honouring the binding needs the Aside preview owner, which is the next unit. Unbound callers, which is every caller today, are unaffected. local checks: NOT RUN --- src/server/management/aside-profile-routes.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index 8047e6c7a85..8d2d5ab5940 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -22,6 +22,24 @@ export interface AsideProfileRouteOptions { class ProfileQueryError extends Error { readonly status = 400; readonly code = "invalid_aside_profile"; } +/** + * Reject a preview binding the Aside routes cannot honour yet. + * + * Silently dropping the fields would be the worst of the three options: the caller believes their + * confirmation is being checked, the mutation proceeds unchecked, and nothing says otherwise. The + * profile-scoped plan owner these routes need does not exist yet, so a bound request is refused + * outright until it does. Unbound callers, which is every caller today, are unaffected. + */ +function rejectUnsupportedBinding(body: Record, expected: string): void { + const { operation, planFingerprint } = body; + if (operation === undefined && planFingerprint === undefined) return; + if (operation === undefined || typeof planFingerprint !== "string" || planFingerprint.length === 0) { + throw new ProfileQueryError("operation and planFingerprint must be sent together"); + } + if (operation !== expected) throw new ProfileQueryError("operation does not match the requested change"); + throw new ProfileQueryError("a confirmed plan cannot be bound to an Aside profile change yet"); +} + const ASIDE_INTEGRATION_PATH = "/api/client-integrations/aside"; const ASIDE_PROFILES_PATH = "/api/client-integrations/aside/profiles"; @@ -134,6 +152,12 @@ export async function handleAsideProfileRoutes( if (!isObject(body) || typeof body.enabled !== "boolean") throw new ProfileQueryError("enabled must be a boolean"); if (body.overwriteConflict !== undefined && typeof body.overwriteConflict !== "boolean") throw new ProfileQueryError("overwriteConflict must be a boolean"); if (body.overwriteConflict === true && !body.enabled) throw new ProfileQueryError("overwriteConflict applies only to enabling an integration"); + // An exact profile is required before a binding could ever mean anything: one fingerprint + // cannot honestly describe several independently changing files. + if (body.operation !== undefined || body.planFingerprint !== undefined) { + if (id === undefined) throw new ProfileQueryError("a confirmed plan applies to one profile"); + rejectUnsupportedBinding(body, body.enabled ? (body.overwriteConflict === true ? "overwrite" : "apply") : "disable"); + } const batch = await mutateAsideProfiles(options.input(), { enabled: body.enabled, profileId: id, overwriteConflict: body.overwriteConflict === true }); if (id !== undefined) { const result = batch.results[0]; @@ -196,6 +220,10 @@ export async function asideRestoreResponse( if (id === undefined) return null; return jsonResponse({ error: "integration operation not found", code: "integration_operation_not_found", opId: body.opId }, 404, ctx.req, ctx.config); } + if (body.operation !== undefined || body.planFingerprint !== undefined) { + if (id === undefined) throw new ProfileQueryError("a confirmed plan applies to one profile"); + rejectUnsupportedBinding(body, "restore"); + } const result = await restoreAsideProfile(input, { ...body, profileId: operation.profileId }); return result.ok ? jsonResponse(result, 200, ctx.req, ctx.config) : options.failure(result); } catch (error) { From 1f6bcb4c130356dbb5ff71130063cf6e0ee9cf73 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:20:25 +0900 Subject: [PATCH 24/83] test(management): prove a confirmation is compared, not merely required The existing case accepted either refusal, so it could pass without a fingerprint ever being compared. It proved that a bound request without a valid plan does not write, which is worth having, but it could not distinguish a working binding from one that refuses everything. This drives the real sequence: populate the passive roster snapshot, preview an apply, commit with that exact operation and fingerprint, then replay the same confirmation. The commit succeeds once and the replay comes back stale with the file byte-identical to what the first commit produced. The replay is the case that matters. Without a genuine comparison it would apply a second time, because nothing else in the request distinguishes it from the first. local checks: NOT RUN --- .../management-integration-routes.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index 6e3aadc9b91..606e8318e4a 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -11,6 +11,7 @@ import { createIntegrationStateStore, type IntegrationStateStore } from "../../s import { applyIntegration } from "../../src/integrations/writer"; import type { IntegrationWriterLockSeams } from "../../src/integrations/writer-lock"; import { handleManagementAPI } from "../../src/server/management-api"; +import { loadExportModels, resetExportSnapshotForTests } from "../../src/server/management/model-rows"; import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks, @@ -1239,6 +1240,37 @@ describe("integration previews are reads", () => { expect(after).toBe(before); }); + test("a previewed change commits exactly once and then reports itself stale", async () => { + const configPath = installHermes(); + resetExportSnapshotForTests(); + // A preview reads only a roster an authoritative load already finished, so give it one. + await loadExportModels(config, []); + + const preview = await previewApi("/api/client-integrations/preview", { clientId: "hermes", operation: "apply" }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + + const commit = await api("/api/client-integrations/hermes", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true, operation: "apply", planFingerprint: plan.fingerprint }), + }); + expect(commit.status).toBe(200); + const committed = readFileSync(configPath, "utf8"); + + // The same confirmation replayed now describes a file that no longer exists in that state. + // Without a real fingerprint comparison this would apply a second time. + const replay = await api("/api/client-integrations/hermes", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true, operation: "apply", planFingerprint: plan.fingerprint }), + }); + expect(replay.status).toBe(409); + expect((await replay.json() as { code: string }).code).toBe("integration_preview_stale"); + expect(readFileSync(configPath, "utf8")).toBe(committed); + }); + test("a plan is refused before any planning when the request does not name a real operation", async () => { const badOperation = await previewApi("/api/client-integrations/preview", { clientId: "hermes", operation: "launch" }); expect(badOperation.status).toBe(400); From d8de0d1c34bf7b612ce8a32e772e7f9c920adbb5 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:25:35 +0900 Subject: [PATCH 25/83] fix(management): carry the binding into every path that can mutate Review found two ways a bound request still reached a writer without its confirmation being examined. The generic restore route parsed the binding and then called the Aside restore handler with only the operation id and drift flag, so a bound Aside restore slipped past the rejection that route had just gained and executed unbound. The binding now travels with the request. And a bound restore built its writer input through the refreshing loader before the guard, while the guard re-planned from the passive snapshot, so the mutation and its own check disagreed about the roster by construction. Bound restores now use the passive input for both; unbound restores keep the old path. Also drops a provider field from the roster snapshot key that does not exist on the config type, which is what broke the typecheck gate. local checks: NOT RUN The Aside profile binding is still refused rather than honoured, which is an interim state and not the finished requirement. --- src/server/management/aside-profile-routes.ts | 9 +++++++-- src/server/management/integration-routes.ts | 14 ++++++++++++-- src/server/management/model-rows.ts | 2 +- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index 8d2d5ab5940..4c42b635755 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -30,7 +30,10 @@ class ProfileQueryError extends Error { readonly status = 400; readonly code = " * profile-scoped plan owner these routes need does not exist yet, so a bound request is refused * outright until it does. Unbound callers, which is every caller today, are unaffected. */ -function rejectUnsupportedBinding(body: Record, expected: string): void { +function rejectUnsupportedBinding( + body: { operation?: unknown; planFingerprint?: unknown }, + expected: string, +): void { const { operation, planFingerprint } = body; if (operation === undefined && planFingerprint === undefined) return; if (operation === undefined || typeof planFingerprint !== "string" || planFingerprint.length === 0) { @@ -204,7 +207,9 @@ export async function asideJournalResponse( } export async function asideRestoreResponse( - ctx: ManagementContext, body: { opId: string; confirmDrift?: boolean }, options: AsideProfileRouteOptions, + ctx: ManagementContext, + body: { opId: string; confirmDrift?: boolean; operation?: unknown; planFingerprint?: unknown }, + options: AsideProfileRouteOptions, ): Promise { try { validateClientSelector(ctx); diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index fae7a9c142f..7f6f21ab138 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -777,7 +777,14 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise [name, provider.adapter, provider.baseUrl, provider.models ?? null]) + .map(([name, provider]) => [name, provider.adapter, provider.baseUrl]) .sort((left, right) => String(left[0]) < String(right[0]) ? -1 : 1); return createHash("sha256").update(JSON.stringify({ providers, From 43756d64b46c5151fb8fe4d34be5189cf1892344 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:30:13 +0900 Subject: [PATCH 26/83] fix(management): forward the binding through the nested Aside restore too The nested profile restore adapter rebuilt the request body from the two fields it cared about, which dropped any binding before the handler that would have examined it. A bound restore on that path therefore reached the writer with its confirmation unexamined, the same defect just fixed on the generic route and in the same shape: a body reconstructed field by field silently loses anything the reconstruction does not know about. Both Aside entry points now forward the fields as sent. local checks: NOT RUN Bound Aside profile changes are still refused rather than honoured. That is a fail-closed interim, not the delivered feature. --- src/server/management/aside-profile-routes.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index 4c42b635755..5445abc3bb2 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -133,7 +133,14 @@ export async function handleAsideProfileRoutes( const body = await readProfileBody(req); if (!isObject(body) || typeof body.opId !== "string" || !body.opId.trim() || (body.confirmDrift !== undefined && typeof body.confirmDrift !== "boolean")) throw new ProfileQueryError("Invalid Aside restore request"); - return asideRestoreResponse(ctx, { opId: body.opId.trim(), confirmDrift: body.confirmDrift === true }, options); + // Rebuilding the body here previously dropped any binding, so a bound nested restore + // reached the writer with its confirmation unexamined. Forward the fields as sent. + return asideRestoreResponse(ctx, { + opId: body.opId.trim(), + confirmDrift: body.confirmDrift === true, + ...(body.operation === undefined ? {} : { operation: body.operation }), + ...(body.planFingerprint === undefined ? {} : { planFingerprint: body.planFingerprint }), + }, options); } if (url.pathname === "/api/client-integrations/aside/sync") { if (req.method !== "POST") return null; From 8c3e4a244f11df35998af3487e5c3c5091dadeab Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:34:50 +0900 Subject: [PATCH 27/83] fix(management): validate the roster the mutation will actually write The guard rebuilt its own input from the current snapshot instead of planning the one the coordinator had frozen. An ordinary export load can replace that snapshot at any moment, and nothing serialises it against the writer lock, so the check could validate a fingerprint computed from one roster while the mutation went on to write from another. That is the exact failure binding exists to prevent, reintroduced inside the mechanism meant to prevent it. Revalidation now plans the coordinator's frozen input. Whether the snapshot it came from is still current is a separate question, answered by comparing an opaque generation identity captured with the input, so a replacement is detected without ever swapping the roster the mutation is about to use. On mismatch the response carries a plan built from the new snapshot, and the old one is never committed. The generation identity also gives the roster snapshot a real version rather than a value inferred from its contents. local checks: NOT RUN --- src/server/management/integration-routes.ts | 30 ++++++++++++++------- src/server/management/model-rows.ts | 24 +++++++++++++++-- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 7f6f21ab138..71625764367 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -46,7 +46,7 @@ import { import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; -import { loadExportModels, previewExportModels } from "./model-rows"; +import { exportSnapshotIdentity, loadExportModels, previewExportModels } from "./model-rows"; import { previewIntegration, type IntegrationMutationPlan, @@ -360,19 +360,27 @@ function stalePlanGuard( store: IntegrationStateStore, request: PreviewRequest, fingerprint: string, + capturedIdentity: string | null, ): { revalidate: NonNullable; response: () => Response | null; } { let stale: IntegrationMutationPlan | "unavailable" | null = null; return { - revalidate: async () => { - const input = await buildIntegrationPreviewInput(clientId, ctx, store); - if (!input) { - stale = "unavailable"; - return { ok: false, reason: "conflict", state: "conflict", clientId, message: "no model roster is cached" }; + revalidate: async frozen => { + /* + * Plan the coordinator's OWN frozen input, never a freshly built one. Rebuilding here let + * the check validate against one roster while the mutation wrote from another, because an + * ordinary load can replace the snapshot at any time and nothing serialises that against + * this lock. The captured identity is verified separately, so a replacement is detected + * without ever swapping the roster this mutation is about to use. + */ + if (exportSnapshotIdentity(ctx.config) !== capturedIdentity) { + const refreshed = await buildIntegrationPreviewInput(clientId, ctx, store); + stale = refreshed === null ? "unavailable" : previewIntegration(refreshed, request); + return { ok: false, reason: "conflict", state: "conflict", clientId, message: "the model roster changed while confirming" }; } - const plan = previewIntegration(input, request); + const plan = previewIntegration(frozen, request); if (plan.canApply && plan.fingerprint === fingerprint) return null; stale = plan; return { ok: false, reason: "conflict", state: plan.state, clientId, message: "that confirmation no longer describes this file" }; @@ -815,9 +823,10 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise Date: Sun, 20 Sep 2026 01:35:49 +0900 Subject: [PATCH 28/83] feat(integrations): plan one Aside profile's change without performing it The Aside binding could not be honoured because nothing produced a plan an Aside route could compare a fingerprint against. This is that owner. A profile is the unit, because a fingerprint can only honestly describe one independently changing file. The scope it builds is the same scope the mutation uses - that profile's store, IO and resolved path pair - so the plan describes what would actually happen rather than an approximation. The roster comes from whatever the caller injected, so a preview inherits the caller's no-gather guarantee instead of reaching for a second source of models. Nothing calls it yet. Wiring it into the profile routes and the revalidation hooks, and deleting the interim refusal, is the next commit; the refusal stays until the guard that replaces it exists. local checks: NOT RUN --- src/integrations/aside-profiles.ts | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/integrations/aside-profiles.ts b/src/integrations/aside-profiles.ts index 9ec1cbd62d2..227c1dd769c 100644 --- a/src/integrations/aside-profiles.ts +++ b/src/integrations/aside-profiles.ts @@ -12,10 +12,44 @@ import { asideRootStore, createAsideProfileContext, persistAsidePolicy, runAsideProfileAction, selectAsideProfiles, type AsideProfileContext, type AsideProfilesInput, type AsideProfileWriteOutcome, } from "./aside-profile-context"; +import { AsideProfileError as AsideProfileErrorClass } from "./aside-profile-context"; +import { + previewIntegration, + type IntegrationMutationPlan, + type IntegrationPlanOperation, +} from "./mutation-plan"; export { AsideProfileError } from "./aside-profile-context"; export type { AsideProfilesInput, AsideProfileWriteOutcome } from "./aside-profile-context"; +/** + * Plan one profile's change without performing it. + * + * A profile is the unit here because a fingerprint can only honestly describe one independently + * changing file. The scope this builds is the same scope the mutation will use — that profile's + * store, IO and resolved path pair — so the plan describes the thing that would actually happen + * rather than an approximation of it. + * + * The roster comes from whatever the caller injected, so a preview inherits the caller's no-gather + * guarantee instead of reaching for a second source of models. + */ +export async function previewAsideProfile( + input: AsideProfilesInput, + request: { profileId: number; operation: IntegrationPlanOperation; opId?: string; confirmDrift?: boolean }, +): Promise { + const ctx = createAsideProfileContext(input); + const profile = selectAsideProfiles(ctx, request.profileId)[0]; + if (!profile) throw new AsideProfileErrorClass("aside_profile_not_found", 404, "That Aside profile is not available"); + const scope = asideProfileScope(ctx, profile); + const bound = await asideWriteInput(ctx, scope); + return previewIntegration(bound, { + operation: request.operation, + profileId: request.profileId, + ...(request.opId === undefined ? {} : { opId: request.opId }), + ...(request.confirmDrift === undefined ? {} : { confirmDrift: request.confirmDrift }), + }); +} + export interface AsideProfileState extends IntegrationStatus { profileId: number; name?: string; From b8ff89630715ee3090f01ba717f303b52d8897d8 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:39:38 +0900 Subject: [PATCH 29/83] feat(management): honour a confirmed plan on Aside profile changes Replaces the interim refusal with the real guard. A bound profile toggle or restore now re-plans that exact profile scope and compares the fingerprint before anything is written, and a mismatch returns 409 with the fresh plan. Placement is the whole point here. The hooks this passes through sit ahead of the preference write and the journal import, both of which happen before any writer lock is taken, so a check under that lock would fire after the thing it was meant to prevent. The guard and the mutation share one roster by construction: a bound request replaces the injected models with the passive snapshot for both, so they cannot disagree about what would be written for any reason except the state genuinely moving. Without a cached roster the request is refused rather than planned against a gathered one. A binding still requires an exact profile, since one fingerprint cannot honestly describe several independently changing files, and bulk profile mutation stays unbound. local checks: NOT RUN --- src/server/management/aside-profile-routes.ts | 129 +++++++++++++++--- 1 file changed, 109 insertions(+), 20 deletions(-) diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index 5445abc3bb2..2ea20808632 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -3,14 +3,16 @@ import { ClientPathError } from "../../clients/config-export"; import { IntegrationMutationBusyError } from "../../integrations/mutation-flight"; import { IntegrationWriterLockBusyError } from "../../integrations/writer-lock"; import { - getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, refreshAsideProfiles, - type AsideProfilesInput, + getAsideProfileState, listAsideProfileStates, mutateAsideProfiles, previewAsideProfile, refreshAsideProfiles, + type AsideProfilesInput, type AsideProfileWriteOutcome, } from "../../integrations/aside-profiles"; import { listAsideOperations, findAsideOperation, restoreAsideProfile, deleteAsideOperation, asideOperationMatchesCurrent, } from "../../integrations/aside-profile-journal"; import type { WriteRefused } from "../../integrations/writer"; +import type { IntegrationMutationPlan, IntegrationPlanOperation } from "../../integrations/mutation-plan"; +import { previewExportModels } from "./model-rows"; import type { ManagementContext } from "./context"; import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { jsonResponse } from "../auth-cors"; @@ -23,24 +25,66 @@ export interface AsideProfileRouteOptions { class ProfileQueryError extends Error { readonly status = 400; readonly code = "invalid_aside_profile"; } /** - * Reject a preview binding the Aside routes cannot honour yet. + * The confirmed plan carried with this request, or null when there is none. * - * Silently dropping the fields would be the worst of the three options: the caller believes their - * confirmation is being checked, the mutation proceeds unchecked, and nothing says otherwise. The - * profile-scoped plan owner these routes need does not exist yet, so a bound request is refused - * outright until it does. Unbound callers, which is every caller today, are unaffected. + * Both fields or neither, and the operation must be the one being requested. Dropping a supplied + * binding would be the worst available behaviour: the caller believes their confirmation is being + * checked while the mutation proceeds unchecked. */ -function rejectUnsupportedBinding( +function asideBinding( body: { operation?: unknown; planFingerprint?: unknown }, - expected: string, -): void { + expected: IntegrationPlanOperation, +): { operation: IntegrationPlanOperation; fingerprint: string } | null { const { operation, planFingerprint } = body; - if (operation === undefined && planFingerprint === undefined) return; + if (operation === undefined && planFingerprint === undefined) return null; if (operation === undefined || typeof planFingerprint !== "string" || planFingerprint.length === 0) { throw new ProfileQueryError("operation and planFingerprint must be sent together"); } if (operation !== expected) throw new ProfileQueryError("operation does not match the requested change"); - throw new ProfileQueryError("a confirmed plan cannot be bound to an Aside profile change yet"); + return { operation: expected, fingerprint: planFingerprint }; +} + +/** + * The guard a bound profile change runs before it writes anything. + * + * It re-plans the same profile scope and compares. The hooks that call it sit ahead of the + * preference write and the journal import, which is the only placement that helps here: those two + * happen before any writer lock is taken, so a check under that lock would fire after the thing it + * was meant to prevent. + */ +function asideGuardFor( + boundInput: AsideProfilesInput, + profileIdValue: number, + binding: { operation: IntegrationPlanOperation; fingerprint: string }, + request: { opId?: string; confirmDrift?: boolean }, + capture: { plan: IntegrationMutationPlan | null }, +): () => Promise { + return async () => { + const plan = await previewAsideProfile(boundInput, { + profileId: profileIdValue, + operation: binding.operation, + ...(request.opId === undefined ? {} : { opId: request.opId }), + ...(request.confirmDrift === undefined ? {} : { confirmDrift: request.confirmDrift }), + }); + if (plan.canApply && plan.fingerprint === binding.fingerprint) return null; + capture.plan = plan; + return { + ok: false, + reason: "conflict", + state: plan.state, + clientId: "aside", + message: "that confirmation no longer describes this profile", + profileId: profileIdValue, + }; + }; +} + +function stalePlanResponse(ctx: ManagementContext, plan: IntegrationMutationPlan): Response { + return jsonResponse({ + error: "integration preview is stale", + code: "integration_preview_stale", + plan, + }, 409, ctx.req, ctx.config); } const ASIDE_INTEGRATION_PATH = "/api/client-integrations/aside"; @@ -164,11 +208,34 @@ export async function handleAsideProfileRoutes( if (body.overwriteConflict === true && !body.enabled) throw new ProfileQueryError("overwriteConflict applies only to enabling an integration"); // An exact profile is required before a binding could ever mean anything: one fingerprint // cannot honestly describe several independently changing files. - if (body.operation !== undefined || body.planFingerprint !== undefined) { - if (id === undefined) throw new ProfileQueryError("a confirmed plan applies to one profile"); - rejectUnsupportedBinding(body, body.enabled ? (body.overwriteConflict === true ? "overwrite" : "apply") : "disable"); + const expected: IntegrationPlanOperation = body.enabled + ? (body.overwriteConflict === true ? "overwrite" : "apply") + : "disable"; + if ((body.operation !== undefined || body.planFingerprint !== undefined) && id === undefined) { + throw new ProfileQueryError("a confirmed plan applies to one profile"); + } + const binding = asideBinding(body, expected); + const capture: { plan: IntegrationMutationPlan | null } = { plan: null }; + let mutationInput = options.input(); + let revalidate: (() => Promise) | undefined; + if (binding !== null && id !== undefined) { + const roster = previewExportModels(ctx.config); + if (roster === null) { + return jsonResponse({ + error: "no model roster is cached yet, so this change cannot be planned", + code: "integration_preview_unavailable", + }, 409, req, ctx.config); + } + // One roster for the guard and the mutation, so they cannot disagree by construction. + mutationInput = { ...mutationInput, models: roster }; + revalidate = asideGuardFor(mutationInput, id, binding, {}, capture); } - const batch = await mutateAsideProfiles(options.input(), { enabled: body.enabled, profileId: id, overwriteConflict: body.overwriteConflict === true }); + const batch = await mutateAsideProfiles( + mutationInput, + { enabled: body.enabled, profileId: id, overwriteConflict: body.overwriteConflict === true }, + revalidate ? { revalidate } : undefined, + ); + if (capture.plan) return stalePlanResponse(ctx, capture.plan); if (id !== undefined) { const result = batch.results[0]; if (!result) throw new Error("Aside profile mutation returned no result"); @@ -232,11 +299,33 @@ export async function asideRestoreResponse( if (id === undefined) return null; return jsonResponse({ error: "integration operation not found", code: "integration_operation_not_found", opId: body.opId }, 404, ctx.req, ctx.config); } - if (body.operation !== undefined || body.planFingerprint !== undefined) { - if (id === undefined) throw new ProfileQueryError("a confirmed plan applies to one profile"); - rejectUnsupportedBinding(body, "restore"); + if ((body.operation !== undefined || body.planFingerprint !== undefined) && id === undefined) { + throw new ProfileQueryError("a confirmed plan applies to one profile"); + } + const restoreBinding = asideBinding(body, "restore"); + const capture: { plan: IntegrationMutationPlan | null } = { plan: null }; + let restoreInput = input; + let revalidate: (() => Promise) | undefined; + if (restoreBinding !== null) { + const roster = previewExportModels(ctx.config); + if (roster === null) { + return jsonResponse({ + error: "no model roster is cached yet, so this change cannot be planned", + code: "integration_preview_unavailable", + }, 409, ctx.req, ctx.config); + } + restoreInput = { ...restoreInput, models: roster }; + revalidate = asideGuardFor(restoreInput, operation.profileId, restoreBinding, { + opId: body.opId, + confirmDrift: body.confirmDrift === true, + }, capture); } - const result = await restoreAsideProfile(input, { ...body, profileId: operation.profileId }); + const result = await restoreAsideProfile( + restoreInput, + { ...body, profileId: operation.profileId }, + revalidate ? { revalidate } : undefined, + ); + if (capture.plan) return stalePlanResponse(ctx, capture.plan); return result.ok ? jsonResponse(result, 200, ctx.req, ctx.config) : options.failure(result); } catch (error) { if (error instanceof ClientPathError && !ctx.url.searchParams.has("profile") From edf2c7522cd4e8cdebabc5be9e9c83bbc4417143 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:43:38 +0900 Subject: [PATCH 30/83] fix(management): take the roster and its identity in one read The roster was captured by an awaited call and its snapshot identity read afterwards, so a concurrent export load could publish a new snapshot in between. The guard would then hold the identity of one snapshot while the mutation held the roster of another, and the check would pass by validating the wrong pair. previewExportSnapshot returns both from a single synchronous read of one reference, and every bound path now carries that pair: the input is built from its roster and the guard compares its identity. Nothing reads the identity independently afterwards. local checks: NOT RUN --- src/server/management/integration-routes.ts | 61 ++++++++++++--------- src/server/management/model-rows.ts | 13 ++++- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 71625764367..487341b0ba5 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -46,7 +46,7 @@ import { import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; -import { exportSnapshotIdentity, loadExportModels, previewExportModels } from "./model-rows"; +import { exportSnapshotIdentity, loadExportModels, previewExportSnapshot } from "./model-rows"; import { previewIntegration, type IntegrationMutationPlan, @@ -265,19 +265,22 @@ async function buildIntegrationPreviewInput( clientId: IntegrationClientId, ctx: ManagementContext, store: IntegrationStateStore, -): Promise { - const models = previewExportModels(ctx.config); +): Promise<{ input: IntegrationWriteInput; identity: string } | null> { + const snapshot = previewExportSnapshot(ctx.config); // No cached roster means no honest snapshot to plan against. Gathering one here would make a // read refresh credentials and write the provider cache, which is the thing preview must not do. - if (models === null) return null; + if (snapshot === null) return null; return { - clientId, - models, - config: ctx.config, - port: Number(ctx.url.port) || ctx.config.port, - store, - io: integrationMutationTestHooks?.io, - ...pathOverrides(), + identity: snapshot.identity, + input: { + clientId, + models: snapshot.models, + config: ctx.config, + port: Number(ctx.url.port) || ctx.config.port, + store, + io: integrationMutationTestHooks?.io, + ...pathOverrides(), + }, }; } @@ -377,7 +380,7 @@ function stalePlanGuard( */ if (exportSnapshotIdentity(ctx.config) !== capturedIdentity) { const refreshed = await buildIntegrationPreviewInput(clientId, ctx, store); - stale = refreshed === null ? "unavailable" : previewIntegration(refreshed, request); + stale = refreshed === null ? "unavailable" : previewIntegration(refreshed.input, request); return { ok: false, reason: "conflict", state: "conflict", clientId, message: "the model roster changed while confirming" }; } const plan = previewIntegration(frozen, request); @@ -711,9 +714,9 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise Date: Sun, 20 Sep 2026 01:44:32 +0900 Subject: [PATCH 31/83] fix(management): keep the roster snapshot independent of its producer Freezing the array left the model objects shared with the caller that produced them, so anything editing one in place would have silently rewritten the roster a later preview plans against, and moved the fingerprint with it. The snapshot is now a deep clone. Also corrects the recovery claim in the comment. I had named /api/models, which calls listManagementModelRows and therefore populates nothing. The path that does populate it is the Integrations collection read, which is the page an operator must open before they can confirm anything, so a cold process recovers through the ordinary flow rather than a special step. local checks: NOT RUN --- src/server/management/model-rows.ts | 11 ++++++++--- tests/server/management-model-roster.test.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index c496bb85402..5ec3491d4f0 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -259,10 +259,13 @@ export async function loadExportModels( // Retain the FINAL projection, not an input to it. A preview that rebuilt from raw provider // caches would miss static and forward providers, which never populate one, and would skip the // retention, metadata, combo and filtering this function applies afterwards. + // A deep clone, not a frozen view of the caller's array. Freezing the array alone left the model + // objects shared, so a caller mutating one in place would have silently rewritten the roster a + // later preview plans against, and the fingerprint would have moved with it. lastExportSnapshot = { key: exportSnapshotKey(config), generation: ++exportSnapshotGeneration, - models: Object.freeze([...exported]), + models: Object.freeze(structuredClone(exported)), }; return exported; } @@ -275,8 +278,10 @@ export async function loadExportModels( * them retires the snapshot rather than letting a preview plan against a roster the user no longer * has. Credentials are not part of it and are never read here. * - * A cold process has no snapshot, and the caller answers a bounded refusal until the ordinary - * models path populates one. That is a real and recoverable state, unlike a read that gathers. + * A cold process has no snapshot and the caller answers a bounded refusal. Recovery is the + * ordinary flow rather than a special step: the Integrations collection read calls + * `loadExportModels`, so the page an operator must open before confirming anything is the page + * that populates this. */ let lastExportSnapshot: { key: string; generation: number; models: readonly ExportModel[] } | null = null; let exportSnapshotGeneration = 0; diff --git a/tests/server/management-model-roster.test.ts b/tests/server/management-model-roster.test.ts index acc8e3817e3..0798571d454 100644 --- a/tests/server/management-model-roster.test.ts +++ b/tests/server/management-model-roster.test.ts @@ -72,4 +72,17 @@ describe("a preview reads only a roster an authoritative load already finished", const added = { ...CONFIG, customModels: [{ id: "c1", provider: "supplied", modelId: "extra" }] } as OcxConfig; expect(previewExportModels(added)).toBeNull(); }); + + test("the snapshot does not share objects with the caller that produced it", async () => { + resetExportSnapshotForTests(); + const exported = await loadExportModels(CONFIG, SUPPLIED); + const first = exported[0]; + expect(first).toBeDefined(); + + // Freezing only the array left the models themselves shared, so a caller editing one in place + // would have rewritten what a later preview plans against, and moved the fingerprint with it. + if (first) first.id = "mutated-by-the-caller"; + const snapshot = previewExportModels(CONFIG); + expect(snapshot?.some(model => model.id === "mutated-by-the-caller")).toBe(false); + }); }); From e0a89d1e940307dd51cba10f7b853b1567d54786 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:48:10 +0900 Subject: [PATCH 32/83] test(management): a refused confirmation writes nothing anywhere Asserting the target file's bytes was too narrow. A snapshot, an ownership record or a journal row written on the way to a refusal is still a write, and those live in the store rather than in the client's config, so the old assertion would have passed straight through them. The refusal case now compares the whole store listing before and after. local checks: NOT RUN --- tests/server/management-integration-routes.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index 606e8318e4a..649f0985706 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -1322,6 +1322,9 @@ describe("integration previews are reads", () => { test("a bound change cannot commit without a plan that still validates", async () => { const configPath = installHermes(); const before = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; + const storeListing = (): string => + existsSync(storeRoot) ? readdirSync(storeRoot, { recursive: true }).sort().join("|") : ""; + const storeBefore = storeListing(); const response = await api("/api/client-integrations/hermes", { method: "PUT", @@ -1337,5 +1340,8 @@ describe("integration previews are reads", () => { const after = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; expect(after).toBe(before); + // Target bytes alone would miss a snapshot, an ownership record or a journal row written on + // the way to a refusal, and those are writes too. + expect(storeListing()).toBe(storeBefore); }); }); From 2e37a53da74cdb607b126ad2d6ebe755c9e9a340 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:52:00 +0900 Subject: [PATCH 33/83] fix(management): clone the roster on the way out too, and check writes by content Two halves of the same mistake, both found by review. The retained snapshot was cloned on the way in but handed out by reference, so a reader holding those objects could edit the roster every later preview plans against without going near this module. Reads now clone as well, with the same atomic identity, and the regression mutates both a model and a nested array through a read before asserting a fresh read is unaffected. And the zero-write assertion compared file names. A journal append, an overwritten ownership record and a replaced snapshot all leave the listing identical, so it would have passed straight through every one of them. It now compares content. That test also stopped accepting either refusal. With a roster present the only way to reach 409 is a fingerprint comparison, so it asserts exactly that and the case can no longer pass without the binding being compared at all. local checks: NOT RUN --- src/server/management/model-rows.ts | 8 ++++- .../management-integration-routes.test.ts | 29 ++++++++++++++----- tests/server/management-model-roster.test.ts | 13 +++++++++ 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 5ec3491d4f0..b4556072084 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -337,7 +337,13 @@ export function previewExportSnapshot( // believing it held the identity of another. const snapshot = lastExportSnapshot; if (snapshot === null || snapshot.key !== exportSnapshotKey(config)) return null; - return { models: snapshot.models, identity: `${snapshot.key}:${snapshot.generation}` }; + // Cloned on the way out as well as on the way in. The retained copy is the authority, and a + // reader holding its objects could edit the roster every later preview plans against without + // going anywhere near this module. + return { + models: structuredClone(snapshot.models) as readonly ExportModel[], + identity: `${snapshot.key}:${snapshot.generation}`, + }; } export function previewExportModels(config: OcxConfig): readonly ExportModel[] | null { diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index 649f0985706..cbb75e9fbb8 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; @@ -1322,9 +1322,23 @@ describe("integration previews are reads", () => { test("a bound change cannot commit without a plan that still validates", async () => { const configPath = installHermes(); const before = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; - const storeListing = (): string => - existsSync(storeRoot) ? readdirSync(storeRoot, { recursive: true }).sort().join("|") : ""; - const storeBefore = storeListing(); + // Content, not names. A journal append, an overwritten ownership record and a replaced + // snapshot all leave the file list identical, and every one of them is a write. + const storeContents = (): string => { + if (!existsSync(storeRoot)) return ""; + return readdirSync(storeRoot, { recursive: true }) + .map(entry => String(entry)) + .sort() + .map(entry => { + const full = join(storeRoot, entry); + if (!existsSync(full) || statSync(full).isDirectory()) return `${entry}/`; + return `${entry}:${readFileSync(full, "utf8")}`; + }) + .join("\u0000"); + }; + resetExportSnapshotForTests(); + await loadExportModels(config, []); + const storeBefore = storeContents(); const response = await api("/api/client-integrations/hermes", { method: "PUT", @@ -1334,14 +1348,13 @@ describe("integration previews are reads", () => { // Whatever the reason the plan does not validate, the mutation does not happen. This is the // case that would catch a refactor quietly dropping the binding on the way to the writer. + // With a roster present this can only be a fingerprint comparison, so the reason is exact. expect(response.status).toBe(409); const body = await response.json() as { code: string }; - expect(["integration_preview_stale", "integration_preview_unavailable"]).toContain(body.code); + expect(body.code).toBe("integration_preview_stale"); const after = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; expect(after).toBe(before); - // Target bytes alone would miss a snapshot, an ownership record or a journal row written on - // the way to a refusal, and those are writes too. - expect(storeListing()).toBe(storeBefore); + expect(storeContents()).toBe(storeBefore); }); }); diff --git a/tests/server/management-model-roster.test.ts b/tests/server/management-model-roster.test.ts index 0798571d454..63287510929 100644 --- a/tests/server/management-model-roster.test.ts +++ b/tests/server/management-model-roster.test.ts @@ -84,5 +84,18 @@ describe("a preview reads only a roster an authoritative load already finished", if (first) first.id = "mutated-by-the-caller"; const snapshot = previewExportModels(CONFIG); expect(snapshot?.some(model => model.id === "mutated-by-the-caller")).toBe(false); + + // The same has to hold for a reader: holding the retained objects would let a consumer edit + // the roster every later preview plans against. + const read = previewExportModels(CONFIG); + const readFirst = read?.[0]; + expect(readFirst).toBeDefined(); + if (readFirst) { + readFirst.id = "mutated-by-a-reader"; + readFirst.inputModalities = ["mutated"]; + } + const fresh = previewExportModels(CONFIG); + expect(fresh?.some(model => model.id === "mutated-by-a-reader")).toBe(false); + expect(fresh?.some(model => model.inputModalities?.includes("mutated"))).toBe(false); }); }); From 45b2f9fda76f994b4912996e8968ce3c210657a6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:52:54 +0900 Subject: [PATCH 34/83] test(management): pin the interleaving the identity check exists for Publishes a new roster from the lock-acquisition seam, which puts the replacement exactly where it is hardest to notice: after the request captured its input and before the guard runs under the lock. A guard that re-read the roster at that moment would validate the new one and write the old. That is the failure the captured-pair ordering exists to make impossible, and until now nothing proved it stayed impossible. The commit comes back stale and the client file is untouched. local checks: NOT RUN --- .../management-integration-routes.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index cbb75e9fbb8..ffc42d871ca 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -1271,6 +1271,42 @@ describe("integration previews are reads", () => { expect(readFileSync(configPath, "utf8")).toBe(committed); }); + test("a roster replaced while the lock is taken refuses instead of writing", async () => { + const configPath = installDsh(); + resetExportSnapshotForTests(); + await loadExportModels(config, []); + + const preview = await previewApi("/api/client-integrations/preview", { clientId: "dsh", operation: "apply" }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + const before = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; + + /* + * Publishing a new roster as the lock is acquired puts the replacement exactly where it is + * hardest to notice: after the request captured its input and before the guard runs under the + * lock. A check that re-read the roster at that moment would validate the new one and write + * the old, which is the failure this ordering exists to make impossible. + */ + const lockSeams: IntegrationWriterLockSeams = { + writeFile: async () => { await loadExportModels(config, []); }, + removeFile: async () => {}, + now: () => 0, + delay: async () => {}, + pid: 12, + }; + setIntegrationMutationFlightTestHooks({ store, lockSeams }); + + const commit = await api("/api/client-integrations/dsh", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true, operation: "apply", planFingerprint: plan.fingerprint }), + }); + expect(commit.status).toBe(409); + expect((await commit.json() as { code: string }).code).toBe("integration_preview_stale"); + expect(existsSync(configPath) ? readFileSync(configPath, "utf8") : null).toBe(before); + }); + test("a plan is refused before any planning when the request does not name a real operation", async () => { const badOperation = await previewApi("/api/client-integrations/preview", { clientId: "hermes", operation: "launch" }); expect(badOperation.status).toBe(400); From d891209c3a41f4b71e8d6ce5df736da30d8eb5ad Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 01:57:54 +0900 Subject: [PATCH 35/83] test(management): carry a bound undo all the way through Every binding case for restore so far was a refusal, which proves the guard can say no but not that a correct confirmation still reaches the writer. A guard that refused everything would have passed all of them. Applies, previews the resulting journal row, commits the undo with that exact operation and fingerprint, and asserts the file actually changed back. local checks: NOT RUN --- .../management-integration-routes.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index ffc42d871ca..d5d076ec6ce 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -1307,6 +1307,37 @@ describe("integration previews are reads", () => { expect(existsSync(configPath) ? readFileSync(configPath, "utf8") : null).toBe(before); }); + test("a previewed undo commits with its binding", async () => { + const configPath = installHermes(); + resetExportSnapshotForTests(); + await loadExportModels(config, []); + + const applied = await api("/api/client-integrations/hermes", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true }), + }); + expect(applied.status).toBe(200); + const afterApply = readFileSync(configPath, "utf8"); + + const opId = store.listOperations("hermes")[0]?.opId; + expect(opId).toBeDefined(); + + const preview = await previewApi("/api/client-integrations/restore/preview", { opId }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + + // The binding has to carry a bound undo all the way through, not just refuse a bad one. + const undo = await api("/api/client-integrations/restore", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ opId, operation: "restore", planFingerprint: plan.fingerprint }), + }); + expect(undo.status).toBe(200); + expect(readFileSync(configPath, "utf8")).not.toBe(afterApply); + }); + test("a plan is refused before any planning when the request does not name a real operation", async () => { const badOperation = await previewApi("/api/client-integrations/preview", { clientId: "hermes", operation: "launch" }); expect(badOperation.status).toBe(400); From 94654a9f9c23be9efc593f10780545714d54be13 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 02:02:57 +0900 Subject: [PATCH 36/83] feat(management): route the Aside profile preview The profile plan owner existed but nothing served it, so the dashboard had no way to obtain a plan for a profile it was about to change. A bound profile request could only ever have been refused, because no caller could get the fingerprint it needed. POST on a profile's preview path plans that one profile, using the passive roster like every other preview and refusing when none is cached. An unscoped preview is rejected: a plan describes one profile, so there is nothing for an unscoped one to describe. Also extends the interleaving case to witness the whole store's content rather than the target file alone, since a snapshot or journal row written before the guard refused would be invisible to a bytes check on one file. local checks: NOT RUN --- src/server/management/aside-profile-routes.ts | 33 +++++++++++++++++-- src/server/management/route-registry.ts | 1 + .../management-integration-routes.test.ts | 18 ++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index 2ea20808632..d6dfe314c22 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -12,7 +12,7 @@ import { } from "../../integrations/aside-profile-journal"; import type { WriteRefused } from "../../integrations/writer"; import type { IntegrationMutationPlan, IntegrationPlanOperation } from "../../integrations/mutation-plan"; -import { previewExportModels } from "./model-rows"; +import { previewExportModels, previewExportSnapshot } from "./model-rows"; import type { ManagementContext } from "./context"; import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { jsonResponse } from "../auth-cors"; @@ -140,7 +140,7 @@ function nestedProfileContext(ctx: ManagementContext): { ctx: ManagementContext; url.searchParams.set("client", "aside"); return { ctx: { ...ctx, url }, action: "journal" }; } - if (parts.length > 2 || !parts[0] || (parts[1] !== undefined && !["journal", "restore"].includes(parts[1]))) { + if (parts.length > 2 || !parts[0] || (parts[1] !== undefined && !["journal", "restore", "preview"].includes(parts[1]))) { throw new ProfileQueryError("Invalid Aside profile path"); } const prior = url.searchParams.get("profile"); @@ -172,6 +172,35 @@ export async function handleAsideProfileRoutes( } return null; } + if (normalized.action === "preview") { + if (req.method !== "POST") return null; + // A plan describes one profile, so an unscoped preview has nothing to describe. + if (id === undefined) throw new ProfileQueryError("a plan applies to one profile"); + const body = await readProfileBody(req); + if (!isObject(body)) throw new ProfileQueryError("preview body must be an object"); + const operation = body.operation; + if (operation !== "apply" && operation !== "overwrite" && operation !== "disable" && operation !== "restore") { + throw new ProfileQueryError("operation must be apply, overwrite, disable or restore"); + } + if (operation === "restore" && (typeof body.opId !== "string" || !body.opId.trim())) { + throw new ProfileQueryError("opId must be a non-empty string"); + } + const roster = previewExportSnapshot(ctx.config); + if (roster === null) { + return jsonResponse({ + error: "no model roster is cached yet, so this change cannot be planned", + code: "integration_preview_unavailable", + }, 409, req, ctx.config); + } + const plan = await previewAsideProfile({ ...options.input(), models: roster.models }, { + profileId: id, + operation, + ...(operation === "restore" + ? { opId: String(body.opId).trim(), confirmDrift: body.confirmDrift === true } + : {}), + }); + return jsonResponse(plan, 200, req, ctx.config); + } if (normalized.action === "restore") { if (req.method !== "POST") return null; const body = await readProfileBody(req); diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 3ec01fa7ffb..d549aee3ed9 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -166,6 +166,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" }, { method: "DELETE", path: "/api/client-integrations/aside/profiles/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside history deletion uses the dashboard journal cleanup; the CLI has history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } }, { method: "GET", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode" }, + { method: "POST", path: "/api/client-integrations/aside/profiles/{profileId}/preview", module: "server/management/aside-profile-routes", mutates: false, mechanism: "prefix-decode", exempt: { reason: "interactive-preview", why: "Plans one Aside profile's change so a dashboard confirmation can show what would change and bind to it. Read-only, and useful only to the caller about to commit; a scripted caller drives the profile toggle or restore directly." } }, { method: "DELETE", path: "/api/client-integrations/aside/profiles/{profileId}/journal", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode", exempt: { reason: "deferred-verb", why: "Aside profile history deletion uses the dashboard journal cleanup; the CLI has scoped history and restore but no deletion verb yet.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_fin/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } }, { method: "POST", path: "/api/client-integrations/aside/profiles/{profileId}/restore", module: "server/management/aside-profile-routes", mutates: true, mechanism: "prefix-decode" }, // server/management/codex-prompt-routes diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index d5d076ec6ce..5bb14f73e61 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -142,6 +142,20 @@ function hermesConfigPath(): string { return INTEGRATION_CLIENTS.hermes.configPath(routeEnv, home); } +/** Whole-store content, so an append or an overwrite cannot hide behind an unchanged file list. */ +function storeContentWitness(root: string): string { + if (!existsSync(root)) return ""; + return readdirSync(root, { recursive: true }) + .map(entry => String(entry)) + .sort() + .map(entry => { + const full = join(root, entry); + if (!existsSync(full) || statSync(full).isDirectory()) return `${entry}/`; + return `${entry}:${readFileSync(full, "utf8")}`; + }) + .join("\u0000"); +} + async function previewApi(path: string, body: unknown): Promise { return api(path, { method: "POST", @@ -1281,6 +1295,7 @@ describe("integration previews are reads", () => { const plan = await preview.json() as { canApply: boolean; fingerprint: string }; expect(plan.canApply).toBe(true); const before = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; + const storeBefore = storeContentWitness(storeRoot); /* * Publishing a new roster as the lock is acquired puts the replacement exactly where it is @@ -1305,6 +1320,9 @@ describe("integration previews are reads", () => { expect(commit.status).toBe(409); expect((await commit.json() as { code: string }).code).toBe("integration_preview_stale"); expect(existsSync(configPath) ? readFileSync(configPath, "utf8") : null).toBe(before); + // The race has to leave the store alone too: a snapshot or journal row written before the + // guard refused would be invisible to a target-bytes check. + expect(storeContentWitness(storeRoot)).toBe(storeBefore); }); test("a previewed undo commits with its binding", async () => { From 50ecfda2c238821e2adaabc3cc15ee2164e9c1d4 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 02:04:09 +0900 Subject: [PATCH 37/83] docs(structure): record who owns a mutation plan and why it is placed where it is The integration contract gains a read-only plans section covering the single shared observation, the strict dependency direction, the two effects a preview declines by name, the declared path grammar, and the fingerprint's inputs and placement. Aside is written down as the exception that proves the placement: it persists preferences and imports journal rows before any writer lock, so its check runs earlier rather than under one. The management API contract gains the three preview endpoints, what they refuse and why, and the paired binding rules on the mutation routes. Both say plainly that the fingerprint is an optimistic token and never authorization, because that is the sentence a future change is most likely to forget. local checks: NOT RUN --- structure/clients/integrations.md | 46 +++++++++++++++++++++++++++++ structure/gui-and-management-api.md | 1 + 2 files changed, 47 insertions(+) diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index bbdd23646d4..8b4bb4b3003 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -23,6 +23,7 @@ parsing and ownership rules below. | `src/integrations/ownership.ts` | Durable ownership records: file, generated contribution, protected contribution, exact fragment paths, and operation identity. | | `src/integrations/ownership-policy.ts` | Client-scoped declarations for fields a client is documented to derive after apply. It must never contain a broad format-wide exemption. | | `src/integrations/writer.ts` | Apply, refresh, disable, and restore transactions, including snapshot-first ordering, compare-before-commit, and compensation. | +| `src/integrations/mutation-plan.ts` | The shared observation both a preview and a mutation read, and the value-free plan an operator confirms. It owns no IO of its own, takes no lock, and must never import `writer.ts`. | | `src/integrations/store.ts` / `journal.ts` | One-root persistence for ownership records, operation history, snapshots, and retention maintenance. | ## Data Flow @@ -43,6 +44,51 @@ Status and mutation must use the same classifier. A special case added only to a would be misleading because refresh or disable could still reject the same file; a special case added only to a writer would let a mutation bypass the state users saw. +## Read-only mutation plans + +An operator confirming apply, overwrite, disable or undo is agreeing to consequences they were +never shown. A plan is what shows them, and it is only trustworthy if it describes the operation +that will actually run. + +One observation serves both. `mutation-plan.ts` owns the read, parse, contribution build, record +selection and classification that the writer used to perform itself, so a preview and the mutation +it authorizes cannot disagree. The direction is strict: state, ownership and merge feed the plan; +the plan feeds the writer and the preview routes. The plan module must never import the writer. + +Preview and mutation differ in exactly two ways, and both are declared rather than implied. +Pending-prune maintenance and client transaction recovery each write, so they are explicit options +with no default that a preview passes as false. And a preview takes its model roster from the +retained export snapshot instead of gathering one, because discovery refreshes credentials and +writes the provider cache. With no snapshot the request is refused; the Integrations collection +read populates one, so the page an operator must open before confirming anything is the page that +recovers it. + +Each operation is decided the way that operation decides it. Apply checks installation and +admission before the classifier and reports a conflict ahead of unsafe; disable asks neither, +because removing what we wrote from a file that still exists is meaningful regardless of +installation; restore reads the journal row and the target's bytes and never parses, so a file +that is readable but unparseable is still restorable. An operation that would write nothing says +so and names no places. + +Published paths are declared, not inferred. Every client states where its managed fragments live, +a path is emitted only on an exact template match, and the string emitted is the template rather +than the observed path, so a dynamic position renders as a wildcard and a value cannot leak. A +path outside the declaration is refused rather than described: an ownership record accepts +arbitrary strings and is not a validation authority. + +The fingerprint covers every input the decision rests on, including the roster, the observed +install kind and the admission predicate, and for restore the snapshot's actual bytes rather than +only its operation id. A confirmed mutation re-plans the coordinator's frozen input with the lock +held, before any snapshot, write or journal row, and separately checks that the snapshot it +captured is still current; the roster and that identity are read together so the check cannot +validate one snapshot while the mutation writes another. Aside is the exception that proves the +placement: it persists preferences and imports journal rows before any writer lock, so its check +runs once profile and path selection is frozen and before those writes. + +The fingerprint is an optimistic token, never authorization. Management authentication and every +ownership rule still decide whether a mutation may happen, and the writer's own +compare-before-commit guard is unchanged. + Gajae export and managed refresh share the loopback-only provider builder. It writes the non-secret `LOOPBACK_API_KEY_PLACEHOLDER` as `apiKey`, so the client can activate the provider without a separately populated environment variable. The managed contribution owns only the diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 47ef706b615..664d07a2dfc 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -147,6 +147,7 @@ this document owns is which module holds which area and what invariant that area | Models and visibility | `src/server/management/model-routes.ts` — `GET /api/models`, `PUT /api/disabled-models`, `PUT /api/model-visibility`, `PUT /api/selected-models`, `GET/POST /api/custom-models`. Visibility writes trigger catalog sync through the owning server path. | | Effort and fallback | `src/server/management/agent-settings-routes.ts` — `GET/PUT /api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`. Caps clamp; they do not reject. | | Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply`, `GET /api/claude-desktop/status`, `GET/PUT /api/claude-code`. Apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)). | +| File-integration plans | `src/server/management/integration-routes.ts` and `aside-profile-routes.ts` — `POST /api/client-integrations/preview`, `POST /api/client-integrations/restore/preview`, and `POST /api/client-integrations/aside/profiles/{profileId}/preview`. Management-authenticated, declared non-mutating, and they write nothing: no snapshot, no lock, no maintenance, no recovery. They answer `409 integration_preview_unavailable` rather than gathering a model roster, because discovery refreshes credentials and writes the provider cache. Responses carry only declared managed schema paths, closed change kinds and an opaque fingerprint; no value, filesystem location or selected member identity appears. Mutation routes accept `operation` and `planFingerprint` together or not at all, reject a half-bound request and an operation that disagrees with the change, and answer `409 integration_preview_stale` with a freshly computed plan. Binding is an optimistic token, never authorization. [The integration contract](clients/integrations.md) owns the ordering. | | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). | | Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. | | Workflow budget | `src/server/management/workflow-budget-routes.ts` — `GET /api/workflow-budget` reads the tracked roots or one root, and `POST /api/workflow-budget/clear` clears exactly one. The clear moves the windowed send ring and the child map and nothing else: `active` belongs to turns still in flight, the spend ledger is a token budget an operator did not ask to forgive, and the lifetime send total survives so a clear cannot launder the record. A refusal event carries `spendScope` and `spendLimit` when a token ceiling fired, so the reason is readable without the config open beside it; no scope id is ever attached, because root ids are client thread headers and identity ids are credentials. Both are `deferred-verb` in the route registry — they are owed CLI verbs, and because the ledger is process memory there is no local projection the CLI could read instead. See [`../devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md`](../devlog/_plan/260915_workflow_budget_window/030_wfc_diff_plan.md). | From e53d68d0a440fdd0dbbe23a78072ad139ed11c49 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 02:10:05 +0900 Subject: [PATCH 38/83] fix(management): retire the roster snapshot when discovery moves underneath it The config key could not see the case that matters most: the same configuration resolving to different models because a discovery completed. A plan built before that and confirmed after it would have described a roster the provider no longer has. The snapshot now carries the model cache's own generation for each configured provider, and a preview is only served while that still matches. Reading it needed a passive observer. captureModelCacheGeneration seeds an entry for a provider it has not seen, which is right for a discovery about to run and wrong for a caller that must change nothing, and isModelCacheGenerationCurrent inherits that. observeModelCacheGeneration reads the same stamp and treats absence as zero, so observing a provider cannot alter what a later capture returns. This is the gathered half of the authority. The configuration half is still a content digest over a hand-maintained field list, because neither candidate generation owner advances on the config writes that change a roster; that remains open rather than papered over. local checks: NOT RUN --- src/codex/model-cache.ts | 12 ++++++++ src/server/management/model-rows.ts | 31 ++++++++++++++++++-- tests/server/management-model-roster.test.ts | 12 ++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts index fe790715fed..e08b7f45906 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -177,6 +177,18 @@ export function isModelCacheGenerationCurrent(provider: string, generation: stri return generation === captureModelCacheGeneration(provider); } +/** + * The same stamp, read without creating one. + * + * {@link captureModelCacheGeneration} seeds an entry for a provider it has not seen, which is + * correct for a discovery about to run and wrong for a caller that must observe and change + * nothing. A reader gets the absent case as `0` rather than a newly created entry, so observing a + * provider cannot itself alter what a later capture returns. + */ +export function observeModelCacheGeneration(provider: string): string { + return `${globalCacheGeneration}:${providerCacheGenerations.get(provider) ?? 0}`; +} + /** * Store a live result unless the cache was cleared while that asynchronous discovery was running. * The optional generation keeps existing direct cache writers unchanged while discovery callers can diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index b4556072084..770f1c13f43 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -10,6 +10,7 @@ */ import type { CatalogModel } from "../../codex/catalog"; import { createHash } from "node:crypto"; +import { observeModelCacheGeneration } from "../../codex/model-cache"; import { catalogModelSlug, filterCatalogVisibleModels, @@ -264,6 +265,7 @@ export async function loadExportModels( // later preview plans against, and the fingerprint would have moved with it. lastExportSnapshot = { key: exportSnapshotKey(config), + cacheStamp: modelCacheStamp(config), generation: ++exportSnapshotGeneration, models: Object.freeze(structuredClone(exported)), }; @@ -283,9 +285,26 @@ export async function loadExportModels( * `loadExportModels`, so the page an operator must open before confirming anything is the page * that populates this. */ -let lastExportSnapshot: { key: string; generation: number; models: readonly ExportModel[] } | null = null; +let lastExportSnapshot: + | { key: string; cacheStamp: string; generation: number; models: readonly ExportModel[] } + | null = null; let exportSnapshotGeneration = 0; +/** + * Where the gathered half of the roster stands, observed without changing it. + * + * The config key cannot see a provider's models changing underneath an unchanged configuration, + * which is exactly what discovery does. This reads the cache's own generation for each configured + * provider through the passive observer, so a completed discovery retires the snapshot and a + * preview stops planning against a roster that no longer reflects the provider. + */ +function modelCacheStamp(config: OcxConfig): string { + return Object.keys(config.providers ?? {}) + .sort() + .map(provider => `${provider}=${observeModelCacheGeneration(provider)}`) + .join(","); +} + /** * Opaque identity of the snapshot a caller is holding, or null when there is none for this config. * @@ -297,7 +316,9 @@ let exportSnapshotGeneration = 0; */ export function exportSnapshotIdentity(config: OcxConfig): string | null { const snapshot = lastExportSnapshot; - if (snapshot === null || snapshot.key !== exportSnapshotKey(config)) return null; + if (snapshot === null) return null; + if (snapshot.key !== exportSnapshotKey(config)) return null; + if (snapshot.cacheStamp !== modelCacheStamp(config)) return null; return `${snapshot.key}:${snapshot.generation}`; } @@ -336,7 +357,11 @@ export function previewExportSnapshot( // concurrent load publish a new snapshot between them, so a caller could hold one roster while // believing it held the identity of another. const snapshot = lastExportSnapshot; - if (snapshot === null || snapshot.key !== exportSnapshotKey(config)) return null; + if (snapshot === null) return null; + if (snapshot.key !== exportSnapshotKey(config)) return null; + // A completed discovery retires the snapshot: the configuration is unchanged, but the models it + // resolves to are not the ones this roster was built from. + if (snapshot.cacheStamp !== modelCacheStamp(config)) return null; // Cloned on the way out as well as on the way in. The retained copy is the authority, and a // reader holding its objects could edit the roster every later preview plans against without // going anywhere near this module. diff --git a/tests/server/management-model-roster.test.ts b/tests/server/management-model-roster.test.ts index 63287510929..d079892a21a 100644 --- a/tests/server/management-model-roster.test.ts +++ b/tests/server/management-model-roster.test.ts @@ -7,6 +7,7 @@ import { } from "../../src/server/management/model-rows"; import type { CatalogModel } from "../../src/codex/catalog"; import type { OcxConfig } from "../../src/types"; +import { providerCacheGenerations } from "../../src/codex/model-cache"; /** * A read-only caller has to be able to see what a writer would write without performing the @@ -73,6 +74,17 @@ describe("a preview reads only a roster an authoritative load already finished", expect(previewExportModels(added)).toBeNull(); }); + test("a completed discovery retires the snapshot even though the config never changed", async () => { + resetExportSnapshotForTests(); + await loadExportModels(CONFIG, SUPPLIED); + expect(previewExportModels(CONFIG)).not.toBeNull(); + + // The config key cannot see this: the same configuration now resolves to different models, + // which is exactly what discovery does and exactly what a plan must not ignore. + providerCacheGenerations.set("supplied", (providerCacheGenerations.get("supplied") ?? 0) + 1); + expect(previewExportModels(CONFIG)).toBeNull(); + }); + test("the snapshot does not share objects with the caller that produced it", async () => { resetExportSnapshotForTests(); const exported = await loadExportModels(CONFIG, SUPPLIED); From 1c37027d9dae6b717e2c9afe375f87068ce7e7db Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 02:13:35 +0900 Subject: [PATCH 39/83] test(management): assert what a successful undo actually does The bound-restore case read the config file back after the undo, which cannot work: Hermes has no file before the apply, so the snapshot apply takes is "none" and a successful undo deletes the file rather than rewriting it. The assertion would have thrown on a passing implementation. It now asserts the file is gone and that the journal carries both the restore row and the apply it undid, which is where the evidence actually lives. local checks: NOT RUN --- tests/server/management-integration-routes.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index 5bb14f73e61..4da38bbb96a 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -1336,7 +1336,7 @@ describe("integration previews are reads", () => { body: JSON.stringify({ enabled: true }), }); expect(applied.status).toBe(200); - const afterApply = readFileSync(configPath, "utf8"); + expect(existsSync(configPath)).toBe(true); const opId = store.listOperations("hermes")[0]?.opId; expect(opId).toBeDefined(); @@ -1353,7 +1353,15 @@ describe("integration previews are reads", () => { body: JSON.stringify({ opId, operation: "restore", planFingerprint: plan.fingerprint }), }); expect(undo.status).toBe(200); - expect(readFileSync(configPath, "utf8")).not.toBe(afterApply); + /* + * Hermes has no config file before the apply, so the snapshot that apply took is "none" and a + * successful undo DELETES the file rather than rewriting it. Reading bytes back here was + * wrong: the file is gone, and the evidence that the undo happened lives in the journal. + */ + expect(existsSync(configPath)).toBe(false); + const rows = store.listOperations("hermes"); + expect(rows.some(row => row.kind === "restore")).toBe(true); + expect(rows.some(row => row.opId === opId)).toBe(true); }); test("a plan is refused before any planning when the request does not name a real operation", async () => { From e99250dfe8eaa2ff7efe9dac362db568dae1ea15 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 02:14:51 +0900 Subject: [PATCH 40/83] fix(models): retire a derived roster on publication, not on authority change The stamp I bound was the cache generation, and that advances when an authority clear revokes an in-flight discovery's right to publish. It does not move when a discovery succeeds and publishes different rows, which is the case a derived roster most needs to notice. My regression incremented the generation by hand and so never exercised the real trigger. A separate revision now counts accepted publications and removals. It is deliberately not the generation: moving that on a successful publication would cancel cache writes that are still legitimate, which is the opposite of what it is for. Reading stays passive, with an unseen provider at zero rather than a seeded entry. The regression drives a real setCached with changed rows. The configuration half of this authority is still a content digest over a hand-maintained field list, as noted before, and is still open. local checks: NOT RUN --- src/codex/model-cache.ts | 29 +++++++++++++++----- src/server/management/model-rows.ts | 4 +-- tests/server/management-model-roster.test.ts | 8 +++--- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts index e08b7f45906..ca4c668bab4 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -71,6 +71,8 @@ function deleteCachedProvider(provider: string): number { cache.delete(provider); cacheBytes = Math.max(0, cacheBytes - entry.sizeBytes); if (oldestCachedProvider === provider) recomputeOldestCachedProvider(); + // A removal changes this provider's content as surely as a publication does. + bumpProviderCacheRevision(provider); return entry.sizeBytes; } @@ -178,15 +180,26 @@ export function isModelCacheGenerationCurrent(provider: string, generation: stri } /** - * The same stamp, read without creating one. + * How many times this provider's cached content has actually changed. * - * {@link captureModelCacheGeneration} seeds an entry for a provider it has not seen, which is - * correct for a discovery about to run and wrong for a caller that must observe and change - * nothing. A reader gets the absent case as `0` rather than a newly created entry, so observing a - * provider cannot itself alter what a later capture returns. + * Deliberately separate from the generation. A generation revokes an in-flight discovery's right + * to publish, so it advances on an authority clear and must not be repurposed: moving it on a + * successful publication would cancel writes that are still legitimate. This counts accepted + * publications and removals instead, which is what a reader holding a derived roster needs to + * know, and it advances on exactly the event a generation does not: a discovery that succeeded + * and changed the rows. + * + * Reading is passive. An unseen provider reads as `0` rather than seeding an entry, so observing + * one cannot alter what a later capture or publication sees. */ -export function observeModelCacheGeneration(provider: string): string { - return `${globalCacheGeneration}:${providerCacheGenerations.get(provider) ?? 0}`; +const providerCacheRevisions = new Map(); + +function bumpProviderCacheRevision(provider: string): void { + providerCacheRevisions.set(provider, (providerCacheRevisions.get(provider) ?? 0) + 1); +} + +export function observeModelCacheRevision(provider: string): string { + return `${providerCacheRevisions.get(provider) ?? 0}`; } /** @@ -212,6 +225,8 @@ export function setCached( oldestCachedAt = now; } enforceAppOwnedMemoryBudget(); + // Published and accepted, so anything derived from this provider's rows is now out of date. + bumpProviderCacheRevision(provider); return true; } diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 770f1c13f43..862dcbf9844 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -10,7 +10,7 @@ */ import type { CatalogModel } from "../../codex/catalog"; import { createHash } from "node:crypto"; -import { observeModelCacheGeneration } from "../../codex/model-cache"; +import { observeModelCacheRevision } from "../../codex/model-cache"; import { catalogModelSlug, filterCatalogVisibleModels, @@ -301,7 +301,7 @@ let exportSnapshotGeneration = 0; function modelCacheStamp(config: OcxConfig): string { return Object.keys(config.providers ?? {}) .sort() - .map(provider => `${provider}=${observeModelCacheGeneration(provider)}`) + .map(provider => `${provider}=${observeModelCacheRevision(provider)}`) .join(","); } diff --git a/tests/server/management-model-roster.test.ts b/tests/server/management-model-roster.test.ts index d079892a21a..581b553dfe5 100644 --- a/tests/server/management-model-roster.test.ts +++ b/tests/server/management-model-roster.test.ts @@ -7,7 +7,7 @@ import { } from "../../src/server/management/model-rows"; import type { CatalogModel } from "../../src/codex/catalog"; import type { OcxConfig } from "../../src/types"; -import { providerCacheGenerations } from "../../src/codex/model-cache"; +import { setCached } from "../../src/codex/model-cache"; /** * A read-only caller has to be able to see what a writer would write without performing the @@ -79,9 +79,9 @@ describe("a preview reads only a roster an authoritative load already finished", await loadExportModels(CONFIG, SUPPLIED); expect(previewExportModels(CONFIG)).not.toBeNull(); - // The config key cannot see this: the same configuration now resolves to different models, - // which is exactly what discovery does and exactly what a plan must not ignore. - providerCacheGenerations.set("supplied", (providerCacheGenerations.get("supplied") ?? 0) + 1); + // The real trigger: a discovery that succeeded and published different rows. The config key + // cannot see it, and an authority generation does not move for it either. + expect(setCached("supplied", [{ id: "discovered-later", provider: "supplied" }])).toBe(true); expect(previewExportModels(CONFIG)).toBeNull(); }); From c4d207cabf1ee280c2f1e617573c59e3176e02d2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 02:16:24 +0900 Subject: [PATCH 41/83] fix(integrations): plan the undo row the mutation will actually use An Aside profile restore resolves a journal row through findAsideOperation, which selects a specific row from a specific store, and Aside can legitimately hold more than one valid copy. The preview re-resolved from a reconstructed scope, so a plan and the mutation it authorized could each pick a different valid row. The fingerprint then compared two plans that were never about the same operation. The route resolves once and passes the exact row and its source store into both the preview and the guard, and neither resolves again. The general restore observation accepts a pre-selected row for the same reason. This is the same shape as the roster substitution fixed earlier: the check was correct in form and attached to a different object than the mutation used. local checks: NOT RUN --- src/integrations/aside-profiles.ts | 12 +++++++- src/integrations/mutation-plan.ts | 28 ++++++++++++++++--- src/server/management/aside-profile-routes.ts | 25 +++++++++++++++-- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/src/integrations/aside-profiles.ts b/src/integrations/aside-profiles.ts index 227c1dd769c..9f6d9ec033c 100644 --- a/src/integrations/aside-profiles.ts +++ b/src/integrations/aside-profiles.ts @@ -2,6 +2,8 @@ import type { AsideProfile } from "../clients/aside-profiles"; import { asideHomeDir } from "../clients/config-export"; import { join } from "node:path"; import type { OwnedIntegrationRefreshOutcome } from "./owned-refresh"; +import type { JournalEntry } from "./journal"; +import type { IntegrationStateStore } from "./store"; import { readIntegrationState, type IntegrationState, type IntegrationStatus } from "./state"; import { applyIntegrationCoordinated, disableIntegrationCoordinated, @@ -35,7 +37,14 @@ export type { AsideProfilesInput, AsideProfileWriteOutcome } from "./aside-profi */ export async function previewAsideProfile( input: AsideProfilesInput, - request: { profileId: number; operation: IntegrationPlanOperation; opId?: string; confirmDrift?: boolean }, + request: { + profileId: number; + operation: IntegrationPlanOperation; + opId?: string; + confirmDrift?: boolean; + /** The row and store the route already selected; re-resolving could pick a different copy. */ + resolved?: { entry: JournalEntry; store: IntegrationStateStore }; + }, ): Promise { const ctx = createAsideProfileContext(input); const profile = selectAsideProfiles(ctx, request.profileId)[0]; @@ -47,6 +56,7 @@ export async function previewAsideProfile( profileId: request.profileId, ...(request.opId === undefined ? {} : { opId: request.opId }), ...(request.confirmDrift === undefined ? {} : { confirmDrift: request.confirmDrift }), + ...(request.resolved === undefined ? {} : { resolved: request.resolved }), }); } diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index a24e41e2bfb..a848b2ddf4e 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -453,8 +453,21 @@ function unboundPlan( * that was readable but unparseable, which is the state that most needs restoring, and accepted a * row recorded against a previous home, which is the single case path equality exists to refuse. */ -export function observeRestore(input: IntegrationWriteInput, opId: string, effects: ObservationEffects) { - const store = input.store ?? createIntegrationStateStore(); +export function observeRestore( + input: IntegrationWriteInput, + opId: string, + effects: ObservationEffects, + /** + * The row a caller already selected, with the store it came from. + * + * Aside can hold more than one valid copy of the same operation, so re-resolving here could + * legitimately pick a different row than the mutation will. The plan would then describe an + * operation the confirmation was never about. A caller that has resolved one passes it in, and + * neither side resolves again. + */ + resolved?: { entry: JournalEntry; store: IntegrationStateStore }, +) { + const store = resolved?.store ?? input.store ?? createIntegrationStateStore(); let io = input.io ?? defaultIntegrationIO(store); const clientId = input.clientId; let resolved: { configPath: string; detectDir: string }; @@ -471,7 +484,7 @@ export function observeRestore(input: IntegrationWriteInput, opId: string, effec failed: observationFailure("unsafe", "unsafe", "the client home is missing; restore will not create it"), } as const; } - const entry = store.findOperation(opId); + const entry = resolved?.entry ?? store.findOperation(opId); if (!entry || entry.clientId !== clientId) { return { failed: observationFailure("unsafe", "unsafe", "that operation cannot be undone") } as const; } @@ -525,6 +538,8 @@ export interface PreviewRequest { readonly opId?: string; readonly confirmDrift?: boolean; readonly profileId?: number; + /** A row and store the caller already selected, so neither side resolves it twice. */ + readonly resolved?: { entry: JournalEntry; store: IntegrationStateStore }; } /** @@ -574,7 +589,12 @@ function previewRestore(input: IntegrationWriteInput, request: PreviewRequest): unboundPlan(input.clientId, "restore", { reason: "unsafe", state: "unsafe", message }, request.profileId); if (request.opId === undefined) return refusal("that operation cannot be undone"); - const observed = observeRestore(input, request.opId, { maintenance: false, recover: false }); + const observed = observeRestore( + input, + request.opId, + { maintenance: false, recover: false }, + request.resolved, + ); if (observed.failed) return unboundPlan(input.clientId, "restore", observed.failed, request.profileId); /* diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index d6dfe314c22..9f2670ee299 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -10,6 +10,7 @@ import { listAsideOperations, findAsideOperation, restoreAsideProfile, deleteAsideOperation, asideOperationMatchesCurrent, } from "../../integrations/aside-profile-journal"; +import type { AsideOperation } from "../../integrations/aside-profile-journal"; import type { WriteRefused } from "../../integrations/writer"; import type { IntegrationMutationPlan, IntegrationPlanOperation } from "../../integrations/mutation-plan"; import { previewExportModels, previewExportSnapshot } from "./model-rows"; @@ -56,7 +57,7 @@ function asideGuardFor( boundInput: AsideProfilesInput, profileIdValue: number, binding: { operation: IntegrationPlanOperation; fingerprint: string }, - request: { opId?: string; confirmDrift?: boolean }, + request: { opId?: string; confirmDrift?: boolean; resolved?: AsideOperation }, capture: { plan: IntegrationMutationPlan | null }, ): () => Promise { return async () => { @@ -65,6 +66,11 @@ function asideGuardFor( operation: binding.operation, ...(request.opId === undefined ? {} : { opId: request.opId }), ...(request.confirmDrift === undefined ? {} : { confirmDrift: request.confirmDrift }), + // The row the route selected, so the guard and the mutation mean the same operation even + // when more than one valid copy exists. + ...(request.resolved === undefined + ? {} + : { resolved: { entry: request.resolved.entry, store: request.resolved.store } }), }); if (plan.canApply && plan.fingerprint === binding.fingerprint) return null; capture.plan = plan; @@ -185,6 +191,16 @@ export async function handleAsideProfileRoutes( if (operation === "restore" && (typeof body.opId !== "string" || !body.opId.trim())) { throw new ProfileQueryError("opId must be a non-empty string"); } + // The preview resolves the row the same way the mutation will, once, here. + const selected = operation === "restore" + ? findAsideOperation(options.input(), String(body.opId).trim(), id) + : null; + if (operation === "restore" && selected === null) { + return jsonResponse({ + error: "integration operation not found", + code: "integration_operation_not_found", + }, 404, req, ctx.config); + } const roster = previewExportSnapshot(ctx.config); if (roster === null) { return jsonResponse({ @@ -196,7 +212,11 @@ export async function handleAsideProfileRoutes( profileId: id, operation, ...(operation === "restore" - ? { opId: String(body.opId).trim(), confirmDrift: body.confirmDrift === true } + ? { + opId: String(body.opId).trim(), + confirmDrift: body.confirmDrift === true, + ...(selected === null ? {} : { resolved: { entry: selected.entry, store: selected.store } }), + } : {}), }); return jsonResponse(plan, 200, req, ctx.config); @@ -347,6 +367,7 @@ export async function asideRestoreResponse( revalidate = asideGuardFor(restoreInput, operation.profileId, restoreBinding, { opId: body.opId, confirmDrift: body.confirmDrift === true, + resolved: operation, }, capture); } const result = await restoreAsideProfile( From 7117736a010b3f9af71e8e9b201fb980f83f772d Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 02:23:06 +0900 Subject: [PATCH 42/83] fix: unblock the typecheck and finish carrying one selected row and one content revision Four corrections. observeRestore took a parameter named resolved into a function that already declares a local of that name, which is the compile error that took the whole gate red. The parameter is now selectedOperation. The Aside restore mutation still resolved its own copy of the journal row after the preview and guard had been given an exact one, so the three could disagree on a duplicate store. It now accepts the selected row and uses it. A wholesale cache clear empties the map, so no per-provider counter can record it and every derived roster would have read as unchanged. The observed revision now carries a global term that a clear advances, which also lets the per-provider entries be dropped in the same step without an ABA when a provider returns. Dropping those entries on a clear is also what stops the map growing with providers that no longer exist. local checks: NOT RUN Not claimed: this does not resolve gather concurrency in general, only the publication and clear events a derived roster must notice. --- src/codex/model-cache.ts | 12 +++++++++++- src/integrations/aside-profile-journal.ts | 13 +++++++++++-- src/integrations/mutation-plan.ts | 6 +++--- src/server/management/aside-profile-routes.ts | 4 +++- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts index ca4c668bab4..a2d928f5a88 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -193,13 +193,18 @@ export function isModelCacheGenerationCurrent(provider: string, generation: stri * one cannot alter what a later capture or publication sees. */ const providerCacheRevisions = new Map(); +let globalContentRevision = 0; function bumpProviderCacheRevision(provider: string): void { providerCacheRevisions.set(provider, (providerCacheRevisions.get(provider) ?? 0) + 1); } export function observeModelCacheRevision(provider: string): string { - return `${providerCacheRevisions.get(provider) ?? 0}`; + // The global term covers a clear that empties the map wholesale, which per-provider counters + // cannot express: without it, wiping every entry and republishing identical-looking rows would + // read as unchanged. It also survives pruning, so a retired provider cannot come back with a + // counter that matches a roster built before it left. + return `${globalContentRevision}:${providerCacheRevisions.get(provider) ?? 0}`; } /** @@ -247,6 +252,11 @@ export function clearModelCache( } else { if (revokesInFlightDiscovery) globalCacheGeneration += 1; cache.clear(); + // A wholesale clear changes every provider's content at once, and clearing the map means no + // per-provider counter can record it. Advancing the global term retires every derived roster + // and lets the per-provider entries be dropped without an ABA on the way back. + globalContentRevision += 1; + providerCacheRevisions.clear(); cacheBytes = 0; oldestCachedProvider = undefined; oldestCachedAt = null; diff --git a/src/integrations/aside-profile-journal.ts b/src/integrations/aside-profile-journal.ts index d0027cbc11e..83d5a284b39 100644 --- a/src/integrations/aside-profile-journal.ts +++ b/src/integrations/aside-profile-journal.ts @@ -167,11 +167,20 @@ function importOperation(row: AsideOperation, scope: AsideProfileScope): void { export function restoreAsideProfile( input: AsideProfilesInput, - request: { opId: string; profileId?: number; confirmDrift?: boolean }, + request: { + opId: string; + profileId?: number; + confirmDrift?: boolean; + /** + * The row a caller already selected. Aside can hold more than one valid copy, so resolving + * again here could act on a different one than the plan the operator confirmed described. + */ + selectedOperation?: AsideOperation; + }, options?: { revalidate?: () => Promise }, ): Promise { return runAsideProfileAction(input, request.profileId, `restore:${request.opId}:${Boolean(request.confirmDrift)}`, async ctx => { - const row = requiredOperation(ctx, request.opId, request.profileId); + const row = request.selectedOperation ?? requiredOperation(ctx, request.opId, request.profileId); const profile = selectAsideProfiles(ctx, row.profileId)[0]!; const scope = asideProfileScope(ctx, profile); assertAsideSnapshotEntry(row.entry); diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index a848b2ddf4e..e2ec2ecc1f9 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -465,9 +465,9 @@ export function observeRestore( * operation the confirmation was never about. A caller that has resolved one passes it in, and * neither side resolves again. */ - resolved?: { entry: JournalEntry; store: IntegrationStateStore }, + selectedOperation?: { entry: JournalEntry; store: IntegrationStateStore }, ) { - const store = resolved?.store ?? input.store ?? createIntegrationStateStore(); + const store = selectedOperation?.store ?? input.store ?? createIntegrationStateStore(); let io = input.io ?? defaultIntegrationIO(store); const clientId = input.clientId; let resolved: { configPath: string; detectDir: string }; @@ -484,7 +484,7 @@ export function observeRestore( failed: observationFailure("unsafe", "unsafe", "the client home is missing; restore will not create it"), } as const; } - const entry = resolved?.entry ?? store.findOperation(opId); + const entry = selectedOperation?.entry ?? store.findOperation(opId); if (!entry || entry.clientId !== clientId) { return { failed: observationFailure("unsafe", "unsafe", "that operation cannot be undone") } as const; } diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index 9f2670ee299..21f641b6a99 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -372,7 +372,9 @@ export async function asideRestoreResponse( } const result = await restoreAsideProfile( restoreInput, - { ...body, profileId: operation.profileId }, + // The same row the preview and guard used. Letting the mutation resolve its own copy is how + // a confirmation ends up bound to an operation other than the one that runs. + { ...body, profileId: operation.profileId, selectedOperation: operation }, revalidate ? { revalidate } : undefined, ); if (capture.plan) return stalePlanResponse(ctx, capture.plan); From 55ba4d5485acff37c8b0355aa2b6d78f36afa19a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 02:27:26 +0900 Subject: [PATCH 43/83] fix(models): prune revision entries, and advance the global term before dropping one Reconciliation tracked every per-provider map except the new revisions, so a provider the configuration no longer has kept its entry for the life of the process unless something cleared the whole cache, which may never happen. Dropping the entry alone would have been an ABA: the provider returns, its entry comes back at zero, and a roster built before it left matches again. The global content term is advanced before any deletion, so a returning provider cannot present the counter a stale roster was built against. Regressions cover both events that no per-provider counter can express: a wholesale clear, and a provider removed and then brought back. local checks: NOT RUN --- src/codex/model-cache.ts | 8 ++++++ tests/server/management-model-roster.test.ts | 28 +++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts index a2d928f5a88..8aaa7ff3803 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -274,6 +274,7 @@ export function reconcileModelCacheProviders( const removedProviders = new Set(); const trackedProviders = new Set([ ...providerCacheGenerations.keys(), + ...providerCacheRevisions.keys(), ...failureAt.keys(), ...discoveryStatus.keys(), ...liveModelCounts.keys(), @@ -284,10 +285,17 @@ export function reconcileModelCacheProviders( if (validProviders.has(provider)) continue; if (!revokedRemovedProviderAuthority) { globalCacheGeneration += 1; + // Advanced BEFORE any revision entry is dropped, so a provider that comes back cannot + // present the counter a roster was built against. Without this the delete below is an ABA: + // the entry returns at zero and an old stamp matches again. + globalContentRevision += 1; revokedRemovedProviderAuthority = true; } providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1); providerCacheGenerations.delete(provider); + // Dropped rather than left behind: a provider the configuration no longer has must not keep + // an entry alive for the life of the process merely because nothing cleared the whole cache. + providerCacheRevisions.delete(provider); deleteCachedProvider(provider); failureAt.delete(provider); discoveryStatus.delete(provider); diff --git a/tests/server/management-model-roster.test.ts b/tests/server/management-model-roster.test.ts index 581b553dfe5..5f2c550abd4 100644 --- a/tests/server/management-model-roster.test.ts +++ b/tests/server/management-model-roster.test.ts @@ -7,7 +7,7 @@ import { } from "../../src/server/management/model-rows"; import type { CatalogModel } from "../../src/codex/catalog"; import type { OcxConfig } from "../../src/types"; -import { setCached } from "../../src/codex/model-cache"; +import { clearModelCache, reconcileModelCacheProviders, setCached } from "../../src/codex/model-cache"; /** * A read-only caller has to be able to see what a writer would write without performing the @@ -85,6 +85,32 @@ describe("a preview reads only a roster an authoritative load already finished", expect(previewExportModels(CONFIG)).toBeNull(); }); + test("a wholesale clear retires the snapshot even though no provider counter survives it", async () => { + resetExportSnapshotForTests(); + await loadExportModels(CONFIG, SUPPLIED); + expect(previewExportModels(CONFIG)).not.toBeNull(); + + // The clear empties the map, so per-provider counters cannot record it. Only a global term + // can, and without one every derived roster would read as unchanged. + clearModelCache(); + expect(previewExportModels(CONFIG)).toBeNull(); + }); + + test("a provider that leaves and returns cannot match a roster built before it left", async () => { + resetExportSnapshotForTests(); + await loadExportModels(CONFIG, SUPPLIED); + expect(previewExportModels(CONFIG)).not.toBeNull(); + + // Reconciliation drops the provider's revision entry. If that were the whole story the entry + // would come back at zero and the old stamp would match again, which is the ABA this guards. + reconcileModelCacheProviders(new Set()); + expect(previewExportModels(CONFIG)).toBeNull(); + + await loadExportModels(CONFIG, SUPPLIED); + const reborn = previewExportModels(CONFIG); + expect(reborn).not.toBeNull(); + }); + test("the snapshot does not share objects with the caller that produced it", async () => { resetExportSnapshotForTests(); const exported = await loadExportModels(CONFIG, SUPPLIED); From ba3311bc4ff6d7530a1543a0bb5f6ace21622933 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 02:28:25 +0900 Subject: [PATCH 44/83] test(management): prove a bound profile change is planned, committed once, and refused when stale Three cases the binding had none of. Profile zero is planned successfully, because zero is a real profile id and the kind of thing an absent-check turns into a bug. A previewed profile change commits with its binding and then reports itself stale on replay, with the profile's file unchanged and a sibling profile untouched. And a stale confirmation is refused with the preference never saved. That last assertion is the one that matters for placement: Aside writes its preference before any writer runs, so a check that fired under the writer lock would have saved it already and the test would fail. local checks: NOT RUN --- tests/server/aside-profiles-routes.test.ts | 61 ++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index a21f0168b64..fe212767ea2 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -13,6 +13,7 @@ import type { OcxConfig } from "../../src/types"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { loadExportModels, resetExportSnapshotForTests } from "../../src/server/management/model-rows"; let root: string; let home: string; @@ -57,6 +58,12 @@ afterEach(() => { }); function path(id: number): string { return join(home, ".aside", "u", String(id), "models.json"); } + +/** A preview only answers from a roster an authoritative load already finished. */ +async function seedRoster(): Promise { + resetExportSnapshotForTests(); + await loadExportModels(config, []); +} function document(id: number) { return JSON.parse(readFileSync(path(id), "utf8")); } async function api(pathname: string, method = "GET", body?: unknown) { return rawApi(pathname, method, body === undefined ? undefined : JSON.stringify(body)); @@ -196,6 +203,60 @@ test("profile history and Undo cannot recreate an undone enable on the next sync expect(document(0).providers.opencodex).toBeUndefined(); }); +test("profile zero can be planned; it is a real profile, not an absent one", async () => { + await seedRoster(); + const response = await api("/api/client-integrations/aside/profiles/0/preview", "POST", { operation: "apply" }); + expect(response.status).toBe(200); + const plan = await response.json() as { canApply: boolean; profileId?: number; fingerprint: string }; + expect(plan.canApply).toBe(true); + expect(plan.profileId).toBe(0); + // A plan names places, never the profile's location. + expect(JSON.stringify(plan)).not.toContain(home); +}); + +test("a previewed profile change commits once and then reports itself stale", async () => { + await seedRoster(); + const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { operation: "apply" }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + + const commit = await api("/api/client-integrations/aside/profiles/1", "PUT", { + enabled: true, operation: "apply", planFingerprint: plan.fingerprint, + }); + expect(commit.status).toBe(200); + expect(document(1).providers.opencodex).toBeDefined(); + const committed = readFileSync(path(1), "utf8"); + + // Replaying the same confirmation describes a profile that is no longer in that state. Without + // a real comparison this would apply a second time. + const replay = await api("/api/client-integrations/aside/profiles/1", "PUT", { + enabled: true, operation: "apply", planFingerprint: plan.fingerprint, + }); + expect(replay.status).toBe(409); + expect((await replay.json() as { code: string }).code).toBe("integration_preview_stale"); + expect(readFileSync(path(1), "utf8")).toBe(committed); + // The refusal must not touch a sibling profile either. + expect(document(2).providers.opencodex).toBeUndefined(); +}); + +test("a stale profile confirmation is refused before the preference is written", async () => { + await seedRoster(); + const preview = await api("/api/client-integrations/aside/profiles/2/preview", "POST", { operation: "apply" }); + const plan = await preview.json() as { fingerprint: string }; + const before = readFileSync(path(2), "utf8"); + + const response = await api("/api/client-integrations/aside/profiles/2", "PUT", { + enabled: true, operation: "apply", planFingerprint: `${plan.fingerprint}-not-current`, + }); + expect(response.status).toBe(409); + expect((await response.json() as { code: string }).code).toBe("integration_preview_stale"); + expect(readFileSync(path(2), "utf8")).toBe(before); + // Aside persists its preference before any writer runs, so a check that fired later would have + // saved this already. + expect(saved).toBeUndefined(); +}); + test.each(["../0", "01", "-1", "9007199254740992"])("rejects invalid profile %s before file mutation", async id => { const before = [0,1,2].map(i => readFileSync(path(i), "utf8")); const response = await api(`/api/client-integrations/aside?profile=${encodeURIComponent(id)}`, "PUT", { enabled: true }); From 5815b4fac1b05f34f9d3286a59b92546de6a622a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 02:32:49 +0900 Subject: [PATCH 45/83] fix(models): drop a pruned provider's revision after the cache deletion that bumps it The prune deleted the revision entry and then called the cache deletion, whose own bump recreated the entry it had just removed. The map therefore still grew with providers the configuration no longer has. Deletion now happens last. The global epoch is already advanced above it, so removing the entry is still not an ABA when the provider returns. local checks: NOT RUN --- src/codex/model-cache.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts index 8aaa7ff3803..c69d15abc9e 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -293,13 +293,15 @@ export function reconcileModelCacheProviders( } providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1); providerCacheGenerations.delete(provider); - // Dropped rather than left behind: a provider the configuration no longer has must not keep - // an entry alive for the life of the process merely because nothing cleared the whole cache. - providerCacheRevisions.delete(provider); deleteCachedProvider(provider); failureAt.delete(provider); discoveryStatus.delete(provider); liveModelCounts.delete(provider); + // AFTER the cache deletion, which bumps this provider's revision and would otherwise recreate + // the entry we just removed. Dropped rather than left behind: a provider the configuration no + // longer has must not keep an entry alive for the life of the process merely because nothing + // cleared the whole cache. The global epoch advanced above, so the removal is not an ABA. + providerCacheRevisions.delete(provider); removedProviders.add(provider); } lastReconciledGeneration = generation; From 0db8dd0e339ed807b051824c5ea8136a37060b2f Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 02:38:38 +0900 Subject: [PATCH 46/83] test(models): make the prune case actually remove a cached provider The reconcile regression supplied its roster, which skips the gather, so nothing tracked the provider and the prune had nothing to remove. It asserted the snapshot was retired by a code path that never ran. The provider is now cached before the removal and cached again on return, so the case exercises a real prune and a real ABA opportunity rather than an empty one. local checks: NOT RUN --- tests/server/management-model-roster.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/server/management-model-roster.test.ts b/tests/server/management-model-roster.test.ts index 5f2c550abd4..aeaae35b698 100644 --- a/tests/server/management-model-roster.test.ts +++ b/tests/server/management-model-roster.test.ts @@ -98,6 +98,10 @@ describe("a preview reads only a roster an authoritative load already finished", test("a provider that leaves and returns cannot match a roster built before it left", async () => { resetExportSnapshotForTests(); + // The provider has to be CACHED for reconciliation to have anything to remove. Supplying a + // roster skips the gather, so without this nothing tracks "supplied" and the prune is a no-op + // that proves nothing. + expect(setCached("supplied", [{ id: "cached-before-removal", provider: "supplied" }])).toBe(true); await loadExportModels(CONFIG, SUPPLIED); expect(previewExportModels(CONFIG)).not.toBeNull(); @@ -106,6 +110,7 @@ describe("a preview reads only a roster an authoritative load already finished", reconcileModelCacheProviders(new Set()); expect(previewExportModels(CONFIG)).toBeNull(); + expect(setCached("supplied", [{ id: "cached-after-return", provider: "supplied" }])).toBe(true); await loadExportModels(CONFIG, SUPPLIED); const reborn = previewExportModels(CONFIG); expect(reborn).not.toBeNull(); From c892334754fdadd4ca08d1394e0a7bb95ea6c7bf Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 02:40:13 +0900 Subject: [PATCH 47/83] test(management): witness the whole store when a profile confirmation is refused The stale profile case checked the target file and the saved preference. Neither sees an ownership record, a snapshot or a journal row, and an Aside restore imports history before any writer runs, so the two writes most likely to slip through were exactly the ones not being watched. It now compares the whole Aside tree and the whole store by content before and after the refusal. local checks: NOT RUN --- tests/server/aside-profiles-routes.test.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index fe212767ea2..8d010e17975 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -1,6 +1,6 @@ import { loadConfig } from "../../src/config"; import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { handleManagementAPI } from "../../src/server/management-api"; @@ -59,6 +59,20 @@ afterEach(() => { function path(id: number): string { return join(home, ".aside", "u", String(id), "models.json"); } +/** Whole-tree content, so an appended journal row or replaced snapshot cannot hide. */ +function treeWitness(dir: string): string { + if (!existsSync(dir)) return ""; + return readdirSync(dir, { recursive: true }) + .map(entry => String(entry)) + .sort() + .map(entry => { + const full = join(dir, entry); + if (!existsSync(full) || statSync(full).isDirectory()) return `${entry}/`; + return `${entry}:${readFileSync(full, "utf8")}`; + }) + .join("\u0000"); +} + /** A preview only answers from a roster an authoritative load already finished. */ async function seedRoster(): Promise { resetExportSnapshotForTests(); @@ -245,6 +259,8 @@ test("a stale profile confirmation is refused before the preference is written", const preview = await api("/api/client-integrations/aside/profiles/2/preview", "POST", { operation: "apply" }); const plan = await preview.json() as { fingerprint: string }; const before = readFileSync(path(2), "utf8"); + const homeBefore = treeWitness(join(home, ".aside")); + const storeBefore = treeWitness(join(root, "store")); const response = await api("/api/client-integrations/aside/profiles/2", "PUT", { enabled: true, operation: "apply", planFingerprint: `${plan.fingerprint}-not-current`, @@ -252,6 +268,10 @@ test("a stale profile confirmation is refused before the preference is written", expect(response.status).toBe(409); expect((await response.json() as { code: string }).code).toBe("integration_preview_stale"); expect(readFileSync(path(2), "utf8")).toBe(before); + // Ownership records, snapshots and journal rows live in the store, and an Aside import writes + // history before any writer runs, so the target file alone would not see either of them. + expect(treeWitness(join(home, ".aside"))).toBe(homeBefore); + expect(treeWitness(join(root, "store"))).toBe(storeBefore); // Aside persists its preference before any writer runs, so a check that fired later would have // saved this already. expect(saved).toBeUndefined(); From 69fd770dad5a6caf921ec73ba74641b824761d7c Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 02:55:54 +0900 Subject: [PATCH 48/83] fix(management): identify a roster by the configuration that was admitted, not a field list The snapshot key was a digest over fields I chose, which is only as complete as whoever last thought about it: a field it forgets is a roster change nothing notices, and the reviewer was right that it already missed export-affecting configuration. It now uses the admission snapshot, which hashes the configuration file in the same read it parses, so no window exists between hashing and reading and no list needs maintaining. Unreadable configuration yields no key, and that fails closed in both directions: nothing is retained and nothing is served. A preview with no provable idea of which configuration a roster belongs to is worse than no preview. One residual is named in the code rather than papered over: the digest describes the file on disk while the roster was built from an in-memory configuration a caller supplied, and proving those are the same needs the admitted configuration to carry its own revision. That is separate work and is not claimed here. local checks: NOT RUN --- src/server/management/model-rows.ts | 43 ++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 862dcbf9844..fc21afdb561 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -9,8 +9,8 @@ * Bodies are unchanged from their previous home; only `export` was added. */ import type { CatalogModel } from "../../codex/catalog"; -import { createHash } from "node:crypto"; import { observeModelCacheRevision } from "../../codex/model-cache"; +import { readConfigAdmissionSnapshot } from "../../config/diagnostics"; import { catalogModelSlug, filterCatalogVisibleModels, @@ -263,8 +263,14 @@ export async function loadExportModels( // A deep clone, not a frozen view of the caller's array. Freezing the array alone left the model // objects shared, so a caller mutating one in place would have silently rewritten the roster a // later preview plans against, and the fingerprint would have moved with it. + const configKey = exportSnapshotKey(config); + // No provable configuration identity means no honest snapshot to keep. + if (configKey === null) { + lastExportSnapshot = null; + return exported; + } lastExportSnapshot = { - key: exportSnapshotKey(config), + key: configKey, cacheStamp: modelCacheStamp(config), generation: ++exportSnapshotGeneration, models: Object.freeze(structuredClone(exported)), @@ -317,20 +323,30 @@ function modelCacheStamp(config: OcxConfig): string { export function exportSnapshotIdentity(config: OcxConfig): string | null { const snapshot = lastExportSnapshot; if (snapshot === null) return null; - if (snapshot.key !== exportSnapshotKey(config)) return null; + const configKey = exportSnapshotKey(config); + if (configKey === null || snapshot.key !== configKey) return null; if (snapshot.cacheStamp !== modelCacheStamp(config)) return null; return `${snapshot.key}:${snapshot.generation}`; } -function exportSnapshotKey(config: OcxConfig): string { - const providers = Object.entries(config.providers ?? {}) - .map(([name, provider]) => [name, provider.adapter, provider.baseUrl]) - .sort((left, right) => String(left[0]) < String(right[0]) ? -1 : 1); - return createHash("sha256").update(JSON.stringify({ - providers, - disabled: config.disabledModels ?? [], - custom: config.customModels ?? [], - })).digest("hex").slice(0, 32); +/** + * What configuration this roster was derived under, or null when that cannot be established. + * + * A hand-written list of the fields that seemed to matter was the wrong instrument: it is only as + * complete as whoever last thought about it, and a field it forgets is a roster change nothing + * notices. The admission snapshot hashes the configuration file in the same read it parses, so it + * covers every field without anyone maintaining a list. + * + * Null when the file cannot be read. That is deliberate and fails closed: with no way to say which + * configuration a roster belongs to, there is no honest snapshot to keep or to serve. + * + * Known residual, not closed by this: the digest describes the file on disk, and the roster was + * built from an in-memory configuration that a caller supplied. Proving those two are the same + * needs the admitted configuration to carry its own revision, which is separate work. + */ +function exportSnapshotKey(_config: OcxConfig): string | null { + const snapshot = readConfigAdmissionSnapshot(); + return snapshot.contentSha256; } /** Test seam: a fresh process has no snapshot, and suites must be able to reproduce that. */ @@ -358,7 +374,8 @@ export function previewExportSnapshot( // believing it held the identity of another. const snapshot = lastExportSnapshot; if (snapshot === null) return null; - if (snapshot.key !== exportSnapshotKey(config)) return null; + const configKey = exportSnapshotKey(config); + if (configKey === null || snapshot.key !== configKey) return null; // A completed discovery retires the snapshot: the configuration is unchanged, but the models it // resolves to are not the ones this roster was built from. if (snapshot.cacheStamp !== modelCacheStamp(config)) return null; From 43e15202a1fc6716cedbfa9a829b0607b91487e5 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 03:00:03 +0900 Subject: [PATCH 49/83] fix(management): a missing config file is defaults, not an unprovable configuration Binding the roster to the admission digest introduced a regression I caught reading my own change back: the snapshot reports no digest both when the file cannot be parsed and when it simply is not there. Collapsing those together would have refused every preview on a machine with no configuration file, which is the ordinary state of a fresh install and of CI. Absence is now its own stable identity. A file that exists and cannot be parsed is genuinely unprovable and still fails closed, which is the case the guard was for. local checks: NOT RUN --- src/server/management/model-rows.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index fc21afdb561..ef299c68c9d 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -346,7 +346,16 @@ export function exportSnapshotIdentity(config: OcxConfig): string | null { */ function exportSnapshotKey(_config: OcxConfig): string | null { const snapshot = readConfigAdmissionSnapshot(); - return snapshot.contentSha256; + if (snapshot.contentSha256 !== null) return snapshot.contentSha256; + /* + * A file that is not there is a well-defined configuration, not an unprovable one: it means + * defaults, and it is the ordinary state of a fresh install. Collapsing it into the unreadable + * case would have refused every preview on a machine with no config file, including CI. + * + * A file that exists and cannot be parsed is genuinely unprovable, and that still fails closed. + */ + const { source, error } = snapshot.diagnostics; + return source === "default" && error === null ? "absent" : null; } /** Test seam: a fresh process has no snapshot, and suites must be able to reproduce that. */ From 84464a7cebc48f941ca1b4c07938e7cba640c20e Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 03:00:46 +0900 Subject: [PATCH 50/83] test(management): pin both halves of the configuration identity An absent configuration file is defaults and must serve a roster, or the feature looks dead on a fresh install and in CI. A file that exists and cannot be parsed says nothing about which configuration a roster belongs to and must fail closed. Those are different states and the code now treats them so. The cases run against their own configuration directory rather than whatever the machine happens to have, which is also what makes the second one expressible at all. local checks: NOT RUN --- tests/server/management-model-roster.test.ts | 38 ++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/server/management-model-roster.test.ts b/tests/server/management-model-roster.test.ts index aeaae35b698..edd42cf55e0 100644 --- a/tests/server/management-model-roster.test.ts +++ b/tests/server/management-model-roster.test.ts @@ -1,4 +1,9 @@ import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; import { listManagementModelRows, loadExportModels, @@ -24,6 +29,26 @@ const CONFIG: OcxConfig = { const SUPPLIED: CatalogModel[] = [{ id: "supplied-model", provider: "supplied" }]; +/** + * A roster is identified by the configuration it was admitted under, so these cases need their own + * configuration directory rather than whatever the machine running them happens to have. + */ +let configRoot = ""; +let priorHome: string | undefined; + +beforeEach(() => { + configRoot = mkdtempSync(join(tmpdir(), "ocx-roster-config-")); + priorHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = configRoot; + mkdirSync(configRoot, { recursive: true }); +}); + +afterEach(() => { + if (priorHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorHome; + removeTreeWithRetry(configRoot); +}); + describe("a supplied roster replaces the gather and keeps the projection", () => { test("rows come from the roster the caller brought", async () => { const rows = await listManagementModelRows(CONFIG, { models: SUPPLIED }); @@ -116,6 +141,19 @@ describe("a preview reads only a roster an authoritative load already finished", expect(reborn).not.toBeNull(); }); + test("an absent config file is a configuration; an unreadable one is not", async () => { + // Absent means defaults, which is a fresh install and CI. Refusing there would make the + // feature look dead rather than safe. + resetExportSnapshotForTests(); + await loadExportModels(CONFIG, SUPPLIED); + expect(previewExportModels(CONFIG)).not.toBeNull(); + + // A file that exists and cannot be parsed says nothing about which configuration this roster + // belongs to, so it fails closed. + writeFileSync(join(configRoot, "config.json"), "{ not valid json"); + expect(previewExportModels(CONFIG)).toBeNull(); + }); + test("the snapshot does not share objects with the caller that produced it", async () => { resetExportSnapshotForTests(); const exported = await loadExportModels(CONFIG, SUPPLIED); From 1228ac24ad602eff9c02102c84fc3d7aae00e544 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 03:01:35 +0900 Subject: [PATCH 51/83] docs(management-api): document previewing and confirming an integration change Covers the three preview routes, the plan's shape, and what a caller must do with each refusal: a stale confirmation carries a fresh plan and needs another decision rather than a retry, and an unavailable roster is the state of a freshly started proxy that reading the integrations collection resolves. States plainly that a fingerprint is an optimistic check and never authorization, and that a plan returns no configuration value, file location or selected member identity. The seven translated copies still need the same contract; they are unwritten and this English page does not contradict them, it is ahead of them. local checks: NOT RUN --- .../content/docs/reference/management-api.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index d03a78f5f42..fc163ea0d54 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -124,6 +124,43 @@ nonnegative integer account ID returned by the profile list; it is not a browser | `POST /api/client-integrations/aside/profiles/{profileId}/restore` | Undo an operation using `{ "opId": "..." }`; optional `confirmDrift: true` permits replacing later edits | 404 missing operation/profile; 409 busy, mismatch, or required drift confirmation; 410 expired snapshot; 500 save/write failure | | `POST /api/client-integrations/aside/sync` | Refresh enabled profiles through the server's mutation owner; body `{}` | 400 nonempty body/profile selector; 409 busy; 207 per-profile refusals | +## Previewing an integration change + +A preview reports what a change would do without doing it. These routes write nothing: no +snapshot, no ownership record, no journal row, no lock, and no maintenance or recovery. + +| Route | Purpose | Notable responses | +| --- | --- | --- | +| `POST /api/client-integrations/preview` | Plan `apply`, `overwrite` or `disable` for one client; body `{ "clientId": "...", "operation": "..." }` | 400 invalid client or operation; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | Plan an undo; body `{ "opId": "...", "confirmDrift": false }` | 404 unknown operation; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Plan one Aside profile's change, including `restore` with an `opId` | 400 invalid body or unscoped request; 404 unknown profile or operation | + +A plan carries `version`, `clientId`, `operation`, `state`, `foreignEdit`, a `changes` list of +`kind` and `path` pairs, an opaque `fingerprint`, `canApply`, `willChange`, and an optional +`refusalReason` and `profileId`. Paths are managed schema paths or the fixed `$snapshot`, +`$ownership` and `$journal` markers; a position chosen at runtime appears as `*`. No +configuration value, file location or selected member identity is returned. + +`canApply: true` with `willChange: false` means the operation succeeds and writes nothing, such +as applying what is already applied. + +`integration_preview_unavailable` means no model roster has been established yet, which is the +state of a freshly started proxy. Reading `GET /api/client-integrations` establishes one. + +## Confirming a previewed change + +Mutation routes accept `operation` and `planFingerprint` alongside their existing body. Send both +or neither: a request carrying one is rejected, as is one whose `operation` disagrees with the +change being requested. Aside bindings apply to a single profile, because one fingerprint cannot +describe several independently changing files. + +The server re-plans before writing anything and returns `409 integration_preview_stale` with a +freshly computed `plan` when the confirmation no longer describes what would happen. Decide again +against the new plan; the request is not retried automatically. + +A fingerprint is an optimistic check, never authorization. Management authentication and every +ownership rule still decide whether a change may happen at all. + Bulk PUT returns `{ ok, clientId, changed, state, message, results }`; each result identifies its `profileId` and reports the writer outcome. Sync returns `{ ok, clientId, results }`, with per-profile refresh outcomes. Both return HTTP 200 when all returned attempts succeed and From 1734e3772fdb213b02e9f54f7165803fce6e022b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 03:02:49 +0900 Subject: [PATCH 52/83] docs(ko): document previewing and confirming an integration change Korean copy of the preview and binding contract, written to the page's own register rather than transliterated from the English. Six locales still need it. They are abridged rather than full mirrors of the English reference, so each needs a section sized to the page it lands on. local checks: NOT RUN --- .../docs/ko/reference/management-api.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index b2c7951e89b..c5a389589e6 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -86,6 +86,42 @@ Authorization: Bearer | `GET /api/client-integrations/journal?client=...` | 롤백 작업을 조회합니다. 특정 클라이언트로 제한할 수 있으며 각 행에는 서버가 계산한 `deletable` 값이 포함됩니다. | 400 잘못된 클라이언트 | | `DELETE /api/client-integrations/journal?opId=...` | 이전 롤백 작업을 폐기하고 가능한 경우 스냅샷도 제거합니다. 성공 응답의 `snapshotRemoved`가 `false`면 유지보수 재시도를 위해 정리 작업이 보존됩니다. | 400 `opId` 누락, 404 없거나 이미 폐기된 작업, 409 해당 클라이언트의 최신 작업 | +## 통합 변경 미리 보기 + +미리 보기는 변경 내용을 적용하지 않고 보여 줍니다. 스냅샷도, 소유권 기록도, 저널도, 잠금도, +복구도 남기지 않습니다. + +| 메서드 및 경로 | 목적 | 주요 오류 | +| --- | --- | --- | +| `POST /api/client-integrations/preview` | 클라이언트 하나의 `apply`, `overwrite`, `disable`을 계획합니다. 본문은 `{ "clientId": "...", "operation": "..." }` | 400 잘못된 클라이언트나 작업, 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | 실행 취소를 계획합니다. 본문은 `{ "opId": "...", "confirmDrift": false }` | 404 없는 작업, 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Aside 프로필 하나의 변경을 계획합니다. `restore`에는 `opId`가 필요합니다 | 400 잘못된 본문이나 프로필 미지정, 404 없는 프로필이나 작업 | + +계획에는 `version`, `clientId`, `operation`, `state`, `foreignEdit`, `kind`와 `path` 쌍으로 이루어진 +`changes`, 불투명한 `fingerprint`, `canApply`, `willChange`가 담기고 `refusalReason`과 +`profileId`는 선택 항목입니다. 경로는 관리 대상 스키마 경로이거나 `$snapshot`, `$ownership`, +`$journal` 표식이며, 실행 중에 정해지는 자리는 `*`로 적습니다. 설정 값이나 파일 위치, 선택된 +항목의 이름은 돌려주지 않습니다. + +`canApply`가 참인데 `willChange`가 거짓이면 작업은 성공하지만 아무것도 쓰지 않습니다. 이미 +적용된 것을 다시 적용하는 경우가 그렇습니다. + +`integration_preview_unavailable`은 아직 모델 목록이 준비되지 않았다는 뜻으로, 프록시를 막 +시작했을 때 나타납니다. `GET /api/client-integrations`를 한 번 읽으면 준비됩니다. + +## 미리 본 변경 확정하기 + +변경 요청 본문에 `operation`과 `planFingerprint`를 함께 보냅니다. 둘 다 보내거나 둘 다 +생략해야 하며, 하나만 보내거나 요청과 다른 작업을 적으면 거부합니다. Aside는 프로필 하나에만 +묶을 수 있습니다. 지문 하나로 여러 파일의 변경을 설명할 수는 없기 때문입니다. + +서버는 쓰기 전에 다시 계획해 보고, 확인한 내용이 더 이상 맞지 않으면 새 계획을 담아 +`409 integration_preview_stale`을 돌려줍니다. 자동으로 다시 시도하지 않으니 새 계획을 보고 +다시 결정하면 됩니다. + +지문은 낙관적 확인일 뿐 권한이 아닙니다. 변경을 허용할지는 관리 인증과 소유권 규칙이 +결정합니다. + 삭제는 저널을 다시 쓰지 않고 툼스톤을 추가합니다. 현재 실행 취소 지점을 유지하기 위해 각 클라이언트의 최신 작업은 서버에서 삭제하지 못하게 보호합니다. From d944bf329cf2c81bcc2ca1699527123e175c198c Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 03:03:44 +0900 Subject: [PATCH 53/83] docs(ja, fr): document previewing and confirming an integration change Japanese and French copies, each written to its own page rather than transliterated, and each sized to a reference that is abridged relative to the English one. Four locales remain: ru, tr, zh-cn and zh-tw. local checks: NOT RUN --- .../docs/fr/reference/management-api.md | 39 +++++++++++++++++++ .../docs/ja/reference/management-api.md | 37 ++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index 8bd738a0c4d..dedaeb0eba0 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -103,6 +103,45 @@ Pour comprendre la liste de modèles et le comportement chiffré des tâches con | `GET /api/client-integrations/journal?client=...` | Lister les opérations de restauration, éventuellement pour un seul client. Chaque ligne contient le champ `deletable` calculé par le serveur. | 400 client invalide | | `DELETE /api/client-integrations/journal?opId=...` | Retirer une ancienne opération et supprimer son instantané si possible. La réponse contient `snapshotRemoved` ; `false` conserve le nettoyage pour une nouvelle tentative de maintenance. | 400 `opId` absent ; 404 opération absente ou déjà retirée ; 409 opération la plus récente du client | +## Prévisualiser une modification d'intégration + +Une prévisualisation montre ce qu'une modification ferait sans la faire. Ces routes n'écrivent +rien : ni instantané, ni enregistrement de propriété, ni ligne de journal, ni verrou, ni +maintenance, ni récupération. + +| Méthode et chemin | Objet | Erreurs notables | +| --- | --- | --- | +| `POST /api/client-integrations/preview` | Planifier `apply`, `overwrite` ou `disable` pour un client ; corps `{ "clientId": "...", "operation": "..." }` | 400 client ou opération invalide ; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | Planifier une annulation ; corps `{ "opId": "...", "confirmDrift": false }` | 404 opération inconnue ; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Planifier la modification d'un seul profil Aside ; `restore` exige un `opId` | 400 corps invalide ou profil non précisé ; 404 profil ou opération inconnus | + +Un plan contient `version`, `clientId`, `operation`, `state`, `foreignEdit`, une liste `changes` +de paires `kind` et `path`, une empreinte `fingerprint` opaque, `canApply`, `willChange`, ainsi +que `refusalReason` et `profileId` facultatifs. Les chemins sont des chemins de schéma gérés ou +les marqueurs fixes `$snapshot`, `$ownership` et `$journal` ; une position déterminée à +l'exécution apparaît comme `*`. Aucune valeur de configuration, aucun emplacement de fichier et +aucune identité d'élément sélectionné n'est renvoyé. + +`canApply` à vrai avec `willChange` à faux signifie que l'opération réussit sans rien écrire, +par exemple appliquer ce qui est déjà appliqué. + +`integration_preview_unavailable` indique qu'aucune liste de modèles n'est encore établie, ce qui +est l'état d'un proxy qui vient de démarrer. Lire `GET /api/client-integrations` l'établit. + +## Confirmer une modification prévisualisée + +Les routes de modification acceptent `operation` et `planFingerprint` à côté de leur corps +habituel. Envoyez les deux ou aucun : une requête n'en portant qu'un est rejetée, tout comme une +requête dont l'`operation` contredit la modification demandée. Une liaison Aside porte sur un seul +profil, car une empreinte ne peut pas décrire plusieurs fichiers qui changent indépendamment. + +Le serveur replanifie avant d'écrire et renvoie `409 integration_preview_stale` avec un `plan` +recalculé lorsque la confirmation ne décrit plus ce qui se produirait. Décidez de nouveau d'après +ce plan ; la requête n'est pas réessayée automatiquement. + +Une empreinte est une vérification optimiste, jamais une autorisation. L'authentification de l'API +d'administration et les règles de propriété décident seules si une modification peut avoir lieu. + La suppression ajoute une pierre tombale au lieu de réécrire le journal. Le serveur protège l'opération la plus récente de chaque client afin de conserver le point d'annulation actuel. diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 4398ff81180..c84d1c4c343 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -82,6 +82,43 @@ Authorization: Bearer | `GET /api/client-integrations/journal?client=...` | ロールバック操作を一覧表示します。任意でクライアントを指定でき、各行にはサーバー計算の `deletable` が含まれます。 | 400 無効なクライアント | | `DELETE /api/client-integrations/journal?opId=...` | 古いロールバック操作を廃止し、可能ならスナップショットも削除します。成功時の `snapshotRemoved` が `false` の場合、保守処理で再試行されます。 | 400 `opId` なし、404 存在しないか廃止済み、409 そのクライアントの最新操作 | +## 統合変更のプレビュー + +プレビューは変更を適用せずに内容だけを示します。スナップショットも所有権記録もジャーナルも +ロックも復旧も、いっさい書き込みません。 + +| メソッドとパス | 目的 | 主なエラー | +| --- | --- | --- | +| `POST /api/client-integrations/preview` | クライアント 1 つの `apply`、`overwrite`、`disable` を計画します。本文は `{ "clientId": "...", "operation": "..." }` | 400 不正なクライアントまたは操作、409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | 取り消しを計画します。本文は `{ "opId": "...", "confirmDrift": false }` | 404 該当操作なし、409 `integration_preview_unavailable` | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Aside プロファイル 1 つの変更を計画します。`restore` には `opId` が必要です | 400 不正な本文またはプロファイル未指定、404 該当プロファイルまたは操作なし | + +計画には `version`、`clientId`、`operation`、`state`、`foreignEdit`、`kind` と `path` の組からなる +`changes`、不透明な `fingerprint`、`canApply`、`willChange` が含まれ、`refusalReason` と +`profileId` は任意です。パスは管理対象スキーマのパスか、`$snapshot`、`$ownership`、`$journal` +の固定表記で、実行時に決まる位置は `*` になります。設定値やファイルの場所、選ばれた項目の +名前は返しません。 + +`canApply` が真で `willChange` が偽なら、操作は成功しますが何も書き込みません。すでに適用済み +のものを適用した場合などです。 + +`integration_preview_unavailable` はモデル一覧がまだ用意されていない状態で、プロキシを起動した +直後に現れます。`GET /api/client-integrations` を一度読めば用意されます。 + +## プレビューした変更の確定 + +変更要求の本文に `operation` と `planFingerprint` を一緒に送ります。両方送るか両方省くかの +どちらかで、片方だけ、または要求と異なる操作を書いた場合は拒否します。Aside はプロファイル +1 つにしか結び付けられません。1 つのフィンガープリントで複数ファイルの変更は説明できないから +です。 + +サーバーは書き込む前に計画し直し、確認した内容がもう当てはまらなければ新しい計画を添えて +`409 integration_preview_stale` を返します。自動で再試行はしないので、新しい計画を見て判断し +直してください。 + +フィンガープリントは楽観的な確認であって権限ではありません。変更してよいかどうかは管理 API の +認証と所有権の規則が決めます。 + 削除時はジャーナルを書き換えず、トゥームストーンを追記します。現在の取り消し地点を 残すため、各クライアントの最新操作はサーバー側で保護されます。 From ea8b89fc4c06982ab3f7878d1368aa60dd942e6a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 03:05:02 +0900 Subject: [PATCH 54/83] docs(ru, tr, zh-cn, zh-tw): document previewing and confirming an integration change Completes the localized reference coverage. All seven translations now carry the three preview routes, the plan's shape, the two refusals and what a caller should do with each, and the rule that a fingerprint is an optimistic check rather than authorization. Each is written to its own page rather than transliterated, and sized to a reference that is abridged relative to the English one. local checks: NOT RUN --- .../docs/ru/reference/management-api.md | 38 +++++++++++++++++++ .../docs/tr/reference/management-api.md | 37 ++++++++++++++++++ .../docs/zh-cn/reference/management-api.md | 33 ++++++++++++++++ .../docs/zh-tw/reference/management-api.md | 33 ++++++++++++++++ 4 files changed, 141 insertions(+) diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index 978744bbb0d..fcf61bd9367 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -104,6 +104,44 @@ GUI-сессия в стиле loopback не выпускается. | `GET /api/client-integrations/journal?client=...` | Получить операции отката, при необходимости только для одного клиента. Каждая запись содержит вычисленный сервером флаг `deletable`. | 400 неверный клиент | | `DELETE /api/client-integrations/journal?opId=...` | Исключить старую операцию отката и по возможности удалить её снимок. В успешном ответе `snapshotRemoved: false` означает, что очистка сохранена для повторной попытки обслуживания. | 400 нет `opId`; 404 операция отсутствует или уже исключена; 409 последняя операция клиента | +## Предварительный просмотр изменения интеграции + +Предпросмотр показывает, что сделало бы изменение, не делая его. Эти маршруты ничего не пишут: +ни снимка, ни записи о владении, ни строки журнала, ни блокировки, ни обслуживания, ни +восстановления. + +| Метод и путь | Назначение | Основные ошибки | +| --- | --- | --- | +| `POST /api/client-integrations/preview` | Спланировать `apply`, `overwrite` или `disable` для одного клиента; тело `{ "clientId": "...", "operation": "..." }` | 400 неверный клиент или операция; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | Спланировать отмену; тело `{ "opId": "...", "confirmDrift": false }` | 404 операция не найдена; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Спланировать изменение одного профиля Aside; для `restore` нужен `opId` | 400 неверное тело или профиль не указан; 404 профиль или операция не найдены | + +План содержит `version`, `clientId`, `operation`, `state`, `foreignEdit`, список `changes` из пар +`kind` и `path`, непрозрачный `fingerprint`, `canApply` и `willChange`; `refusalReason` и +`profileId` необязательны. Пути — это управляемые пути схемы либо фиксированные метки +`$snapshot`, `$ownership` и `$journal`; позиция, определяемая во время работы, показывается как +`*`. Ни значения конфигурации, ни расположение файлов, ни имя выбранного элемента не возвращаются. + +`canApply` истинно при `willChange` ложном означает, что операция завершится успешно и ничего не +запишет, например повторное применение уже применённого. + +`integration_preview_unavailable` означает, что список моделей ещё не сформирован: так выглядит +только что запущенный прокси. Одно чтение `GET /api/client-integrations` его формирует. + +## Подтверждение просмотренного изменения + +Маршруты изменения принимают `operation` и `planFingerprint` рядом с обычным телом. Отправляйте +оба или ни одного: запрос только с одним отклоняется, как и запрос, `operation` которого не +совпадает с запрошенным изменением. Привязка Aside относится к одному профилю, поскольку один +отпечаток не может описать несколько независимо меняющихся файлов. + +Перед записью сервер планирует заново и возвращает `409 integration_preview_stale` с заново +вычисленным `plan`, когда подтверждение больше не описывает происходящее. Примите решение +заново по новому плану; запрос не повторяется автоматически. + +Отпечаток — это оптимистичная проверка, а не разрешение. Возможность изменения по-прежнему +определяют аутентификация управляющего API и правила владения. + Удаление добавляет отметку вместо перезаписи журнала. Сервер защищает последнюю операцию каждого клиента, чтобы сохранить текущую точку отмены. diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index 354310f01ef..132f8b1710a 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -110,6 +110,43 @@ bakın. | `GET /api/client-integrations/journal?client=...` | Geri alma işlemlerini, isteğe bağlı olarak tek bir istemci için listeler. Her satır sunucunun hesapladığı `deletable` alanını içerir. | 400 geçersiz istemci | | `DELETE /api/client-integrations/journal?opId=...` | Eski bir geri alma işlemini kullanımdan kaldırır ve mümkünse anlık görüntüsünü siler. Başarılı yanıtta `snapshotRemoved: false`, temizliğin bakım yeniden denemesi için saklandığını belirtir. | 400 eksik `opId`; 404 bulunmayan veya zaten kaldırılmış işlem; 409 istemcinin en yeni işlemi | +## Entegrasyon değişikliğini önizleme + +Önizleme, bir değişikliğin ne yapacağını yapmadan gösterir. Bu yollar hiçbir şey yazmaz: anlık +görüntü, sahiplik kaydı, günlük satırı, kilit, bakım ve kurtarma yoktur. + +| Yöntem ve yol | Amaç | Önemli hatalar | +| --- | --- | --- | +| `POST /api/client-integrations/preview` | Tek bir istemci için `apply`, `overwrite` veya `disable` planlar; gövde `{ "clientId": "...", "operation": "..." }` | 400 geçersiz istemci veya işlem; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | Bir geri almayı planlar; gövde `{ "opId": "...", "confirmDrift": false }` | 404 işlem bulunamadı; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Tek bir Aside profilinin değişikliğini planlar; `restore` için `opId` gerekir | 400 geçersiz gövde veya profil belirtilmemiş; 404 profil veya işlem bulunamadı | + +Plan; `version`, `clientId`, `operation`, `state`, `foreignEdit`, `kind` ve `path` çiftlerinden +oluşan bir `changes` listesi, opak bir `fingerprint`, `canApply` ve `willChange` içerir; +`refusalReason` ile `profileId` isteğe bağlıdır. Yollar ya yönetilen şema yollarıdır ya da sabit +`$snapshot`, `$ownership` ve `$journal` işaretleridir; çalışma sırasında belirlenen bir konum `*` +olarak görünür. Hiçbir yapılandırma değeri, dosya konumu veya seçilen öğenin adı döndürülmez. + +`canApply` doğru ve `willChange` yanlışsa işlem başarılı olur ama hiçbir şey yazmaz; örneğin zaten +uygulanmış olanı yeniden uygulamak. + +`integration_preview_unavailable`, henüz bir model listesinin oluşmadığını belirtir; yeni başlamış +bir vekilin durumu budur. `GET /api/client-integrations` bir kez okunduğunda oluşur. + +## Önizlenen değişikliği onaylama + +Değişiklik yolları, olağan gövdenin yanında `operation` ve `planFingerprint` kabul eder. İkisini +birlikte gönderin ya da hiçbirini: yalnızca birini taşıyan bir istek reddedilir, `operation` değeri +istenen değişiklikle çelişen bir istek de öyle. Aside bağlaması tek bir profile içindir; çünkü bir +parmak izi birbirinden bağımsız değişen birden çok dosyayı anlatamaz. + +Sunucu yazmadan önce yeniden planlar ve onay artık olacak olanı anlatmıyorsa yeniden hesaplanmış +bir `plan` ile `409 integration_preview_stale` döndürür. Yeni plana bakarak yeniden karar verin; +istek kendiliğinden yinelenmez. + +Parmak izi iyimser bir denetimdir, yetkilendirme değildir. Bir değişikliğin yapılıp +yapılamayacağına yönetim API kimlik doğrulaması ve sahiplik kuralları karar verir. + Silme, günlüğü yeniden yazmak yerine bir silme kaydı ekler. Geçerli geri alma noktasını korumak için her istemcinin en yeni işlemi sunucu tarafında korunur. diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index 480accdf331..6e178d1c93f 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -86,6 +86,39 @@ Authorization: Bearer | `GET /api/client-integrations/journal?client=...` | 列出回滚操作,也可限定为单个客户端。每一项都包含由服务器计算的 `deletable` 字段。 | 400 客户端无效 | | `DELETE /api/client-integrations/journal?opId=...` | 停用一条较旧的回滚操作,并在可能时删除其快照。成功响应中的 `snapshotRemoved: false` 表示清理任务已保留,等待维护重试。 | 400 缺少 `opId`;404 操作不存在或已停用;409 该客户端的最新操作 | +## 预览集成变更 + +预览只展示变更会做什么,而不会执行它。这些路由不写入任何内容:不留快照、不留归属记录、不留 +日志行、不加锁、不做维护和恢复。 + +| 方法与路径 | 用途 | 主要错误 | +| --- | --- | --- | +| `POST /api/client-integrations/preview` | 为一个客户端规划 `apply`、`overwrite` 或 `disable`;请求体为 `{ "clientId": "...", "operation": "..." }` | 400 客户端或操作无效;409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | 规划一次撤销;请求体为 `{ "opId": "...", "confirmDrift": false }` | 404 操作不存在;409 `integration_preview_unavailable` | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | 规划单个 Aside 配置档的变更;`restore` 需要 `opId` | 400 请求体无效或未指定配置档;404 配置档或操作不存在 | + +计划包含 `version`、`clientId`、`operation`、`state`、`foreignEdit`,由 `kind` 与 `path` 组成的 +`changes` 列表,不透明的 `fingerprint`,以及 `canApply` 和 `willChange`;`refusalReason` 与 +`profileId` 为可选。路径要么是受管理的结构路径,要么是 `$snapshot`、`$ownership`、`$journal` +这三个固定标记;运行时才确定的位置显示为 `*`。不会返回任何配置值、文件位置或所选条目的名称。 + +`canApply` 为真而 `willChange` 为假,表示操作会成功但不写入任何内容,例如重复应用已经应用过 +的内容。 + +`integration_preview_unavailable` 表示尚未建立模型清单,这是代理刚启动时的状态。读取一次 +`GET /api/client-integrations` 即可建立。 + +## 确认已预览的变更 + +变更路由接受与原有请求体并列的 `operation` 和 `planFingerprint`。两者要么都发送,要么都省略: +只带其中一个会被拒绝,`operation` 与所请求的变更不一致也会被拒绝。Aside 的绑定只针对单个配置 +档,因为一个指纹无法描述多个各自独立变化的文件。 + +服务器在写入前会重新规划,若确认的内容已不再符合实际,会返回 `409 integration_preview_stale` +并附上重新计算的 `plan`。请根据新计划再做一次决定;请求不会自动重试。 + +指纹只是乐观校验,不是授权。变更是否被允许,仍由管理 API 的认证和归属规则决定。 + 删除操作会追加墓碑记录,而不会重写日志。服务器会保护每个客户端的最新操作, 以保留当前的撤销点。 diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index 4342a698e06..69a4f9e700a 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -82,6 +82,39 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | `GET /api/client-integrations/journal?client=...` | 列出復原操作,也可限定為單一用戶端。每一項都包含由伺服器計算的 `deletable` 欄位。 | 400 用戶端無效 | | `DELETE /api/client-integrations/journal?opId=...` | 停用一筆較舊的復原操作,並在可能時刪除其快照。成功回應中的 `snapshotRemoved: false` 表示清理工作已保留,等待維護重試。 | 400 缺少 `opId`;404 操作不存在或已停用;409 該用戶端的最新操作 | +## 預覽整合變更 + +預覽只呈現變更會做什麼,而不會執行。這些路由不寫入任何內容:不留快照、不留歸屬紀錄、不留 +日誌列、不加鎖、不做維護與復原。 + +| 方法與路徑 | 用途 | 主要錯誤 | +| --- | --- | --- | +| `POST /api/client-integrations/preview` | 為單一用戶端規劃 `apply`、`overwrite` 或 `disable`;請求內容為 `{ "clientId": "...", "operation": "..." }` | 400 用戶端或操作無效;409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | 規劃一次復原;請求內容為 `{ "opId": "...", "confirmDrift": false }` | 404 操作不存在;409 `integration_preview_unavailable` | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | 規劃單一 Aside 設定檔的變更;`restore` 需要 `opId` | 400 請求內容無效或未指定設定檔;404 設定檔或操作不存在 | + +計畫包含 `version`、`clientId`、`operation`、`state`、`foreignEdit`,由 `kind` 與 `path` 組成的 +`changes` 清單,不透明的 `fingerprint`,以及 `canApply` 與 `willChange`;`refusalReason` 與 +`profileId` 為選用。路徑要麼是受管理的結構路徑,要麼是 `$snapshot`、`$ownership`、`$journal` +這三個固定標記;執行時才決定的位置會顯示為 `*`。不會回傳任何設定值、檔案位置或所選項目的名稱。 + +`canApply` 為真而 `willChange` 為假,表示操作會成功但不寫入任何內容,例如重複套用已經套用過 +的內容。 + +`integration_preview_unavailable` 表示尚未建立模型清單,這是代理剛啟動時的狀態。讀取一次 +`GET /api/client-integrations` 即可建立。 + +## 確認已預覽的變更 + +變更路由接受與原有請求內容並列的 `operation` 與 `planFingerprint`。兩者要麼都送出,要麼都省略: +只帶其中一個會被拒絕,`operation` 與所請求的變更不一致也會被拒絕。Aside 的綁定只針對單一設定 +檔,因為一個指紋無法描述多個各自獨立變動的檔案。 + +伺服器在寫入前會重新規劃,若確認的內容已不再符合實際,會回傳 `409 integration_preview_stale` +並附上重新計算的 `plan`。請依新計畫再做一次決定;請求不會自動重試。 + +指紋只是樂觀檢查,不是授權。變更是否被允許,仍由管理 API 的驗證與歸屬規則決定。 + 刪除操作會附加墓碑記錄,而不會重寫日誌。伺服器會保護每個用戶端的最新操作, 以保留目前的復原點。 From 2738c710653c7fbbdba596c5d657b84fd4a2bbc4 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 03:09:57 +0900 Subject: [PATCH 55/83] docs: correct what an unavailable roster actually means I wrote that the refusal means a roster has not been established yet and that reading the integrations collection establishes one. Both were too strong. The refusal means no usable roster is currently retained, which covers a cold process and equally a roster retired because the configuration or the provider cache moved. And the collection read only establishes one when discovery succeeds and the configuration can be identified, since publication is declined without a configuration identity. It is the usual remedy, not a guarantee. Corrected in the English and all seven localized references and in the integration contract. local checks: NOT RUN --- docs-site/src/content/docs/fr/reference/management-api.md | 7 +++++-- docs-site/src/content/docs/ja/reference/management-api.md | 6 ++++-- docs-site/src/content/docs/ko/reference/management-api.md | 6 ++++-- docs-site/src/content/docs/reference/management-api.md | 7 +++++-- docs-site/src/content/docs/ru/reference/management-api.md | 6 ++++-- docs-site/src/content/docs/tr/reference/management-api.md | 6 ++++-- .../src/content/docs/zh-cn/reference/management-api.md | 5 +++-- .../src/content/docs/zh-tw/reference/management-api.md | 5 +++-- structure/clients/integrations.md | 8 +++++--- 9 files changed, 37 insertions(+), 19 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index dedaeb0eba0..f3eb3f0fb22 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -125,8 +125,11 @@ aucune identité d'élément sélectionné n'est renvoyé. `canApply` à vrai avec `willChange` à faux signifie que l'opération réussit sans rien écrire, par exemple appliquer ce qui est déjà appliqué. -`integration_preview_unavailable` indique qu'aucune liste de modèles n'est encore établie, ce qui -est l'état d'un proxy qui vient de démarrer. Lire `GET /api/client-integrations` l'établit. +`integration_preview_unavailable` indique qu'aucune liste de modèles utilisable n'est actuellement +conservée : un proxy qui vient de démarrer est un cas, une liste abandonnée parce que la +configuration ou le cache de fournisseurs a changé en est un autre. Lire +`GET /api/client-integrations` en établit une lorsque la découverte réussit et que la +configuration peut être identifiée ; c'est le remède habituel, pas une garantie. ## Confirmer une modification prévisualisée diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index c84d1c4c343..bcd1d095528 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -102,8 +102,10 @@ Authorization: Bearer `canApply` が真で `willChange` が偽なら、操作は成功しますが何も書き込みません。すでに適用済み のものを適用した場合などです。 -`integration_preview_unavailable` はモデル一覧がまだ用意されていない状態で、プロキシを起動した -直後に現れます。`GET /api/client-integrations` を一度読めば用意されます。 +`integration_preview_unavailable` は今使えるモデル一覧がないという意味です。プロキシを起動した +直後もそうですし、設定やプロバイダーキャッシュが変わって以前の一覧を破棄した場合もそうです。 +`GET /api/client-integrations` を読むと、取得に成功し設定を特定できたときに用意されるので、 +通常はこれで解決しますが、必ず用意されるとはかぎりません。 ## プレビューした変更の確定 diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index c5a389589e6..ebaffd6917b 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -106,8 +106,10 @@ Authorization: Bearer `canApply`가 참인데 `willChange`가 거짓이면 작업은 성공하지만 아무것도 쓰지 않습니다. 이미 적용된 것을 다시 적용하는 경우가 그렇습니다. -`integration_preview_unavailable`은 아직 모델 목록이 준비되지 않았다는 뜻으로, 프록시를 막 -시작했을 때 나타납니다. `GET /api/client-integrations`를 한 번 읽으면 준비됩니다. +`integration_preview_unavailable`은 지금 쓸 수 있는 모델 목록이 없다는 뜻입니다. 프록시를 막 +시작했을 때도 그렇고, 설정이나 공급자 캐시가 바뀌어 기존 목록을 버린 경우에도 그렇습니다. +`GET /api/client-integrations`를 읽으면 조회가 성공하고 설정을 확인할 수 있을 때 목록이 +준비되므로, 보통은 이렇게 해결되지만 항상 보장되지는 않습니다. ## 미리 본 변경 확정하기 diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index fc163ea0d54..60263a989d7 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -144,8 +144,11 @@ configuration value, file location or selected member identity is returned. `canApply: true` with `willChange: false` means the operation succeeds and writes nothing, such as applying what is already applied. -`integration_preview_unavailable` means no model roster has been established yet, which is the -state of a freshly started proxy. Reading `GET /api/client-integrations` establishes one. +`integration_preview_unavailable` means no usable model roster is currently retained. A freshly +started proxy is one way to be in that state; a roster retired because the configuration or the +provider cache moved is another. Reading `GET /api/client-integrations` establishes one when +discovery succeeds and the configuration can be identified, so it is the usual remedy rather than +a guarantee. ## Confirming a previewed change diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index fcf61bd9367..5ea513d79d6 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -125,8 +125,10 @@ GUI-сессия в стиле loopback не выпускается. `canApply` истинно при `willChange` ложном означает, что операция завершится успешно и ничего не запишет, например повторное применение уже применённого. -`integration_preview_unavailable` означает, что список моделей ещё не сформирован: так выглядит -только что запущенный прокси. Одно чтение `GET /api/client-integrations` его формирует. +`integration_preview_unavailable` означает, что сейчас нет пригодного списка моделей: только что +запущенный прокси — один такой случай, список, отброшенный из-за изменения конфигурации или кэша +провайдеров, — другой. Чтение `GET /api/client-integrations` формирует его, когда обнаружение +прошло успешно и конфигурацию удалось определить; это обычное решение, а не гарантия. ## Подтверждение просмотренного изменения diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index 132f8b1710a..52e85791687 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -130,8 +130,10 @@ olarak görünür. Hiçbir yapılandırma değeri, dosya konumu veya seçilen ö `canApply` doğru ve `willChange` yanlışsa işlem başarılı olur ama hiçbir şey yazmaz; örneğin zaten uygulanmış olanı yeniden uygulamak. -`integration_preview_unavailable`, henüz bir model listesinin oluşmadığını belirtir; yeni başlamış -bir vekilin durumu budur. `GET /api/client-integrations` bir kez okunduğunda oluşur. +`integration_preview_unavailable`, şu anda kullanılabilir bir model listesi tutulmadığını belirtir: +yeni başlamış bir vekil bunun bir hâlidir, yapılandırma ya da sağlayıcı önbelleği değiştiği için +bırakılmış bir liste de öyle. `GET /api/client-integrations` okunduğunda keşif başarılı olur ve +yapılandırma belirlenebilirse liste oluşur; bu her zamanki çözümdür, bir güvence değildir. ## Önizlenen değişikliği onaylama diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index 6e178d1c93f..fc569a9da38 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -105,8 +105,9 @@ Authorization: Bearer `canApply` 为真而 `willChange` 为假,表示操作会成功但不写入任何内容,例如重复应用已经应用过 的内容。 -`integration_preview_unavailable` 表示尚未建立模型清单,这是代理刚启动时的状态。读取一次 -`GET /api/client-integrations` 即可建立。 +`integration_preview_unavailable` 表示当前没有可用的模型清单:代理刚启动是一种情况,因配置或 +提供方缓存变化而弃用了原有清单也是一种情况。读取 `GET /api/client-integrations` 会在探测成功 +且能确认配置时建立清单,因此这通常是解决办法,但并非必然建立。 ## 确认已预览的变更 diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index 69a4f9e700a..d40201e8e48 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -101,8 +101,9 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 `canApply` 為真而 `willChange` 為假,表示操作會成功但不寫入任何內容,例如重複套用已經套用過 的內容。 -`integration_preview_unavailable` 表示尚未建立模型清單,這是代理剛啟動時的狀態。讀取一次 -`GET /api/client-integrations` 即可建立。 +`integration_preview_unavailable` 表示目前沒有可用的模型清單:代理剛啟動是一種情況,因設定或 +供應方快取變動而捨棄原有清單也是一種情況。讀取 `GET /api/client-integrations` 會在探測成功 +且能確認設定時建立清單,因此這通常是解決方式,但並非必然建立。 ## 確認已預覽的變更 diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 8b4bb4b3003..113b49164e9 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -59,9 +59,11 @@ Preview and mutation differ in exactly two ways, and both are declared rather th Pending-prune maintenance and client transaction recovery each write, so they are explicit options with no default that a preview passes as false. And a preview takes its model roster from the retained export snapshot instead of gathering one, because discovery refreshes credentials and -writes the provider cache. With no snapshot the request is refused; the Integrations collection -read populates one, so the page an operator must open before confirming anything is the page that -recovers it. +writes the provider cache. With no usable snapshot the request is refused, which covers a cold +process and equally a snapshot retired because the configuration or the provider cache moved. The +Integrations collection read populates one when discovery succeeds and the configuration can be +identified, so the page an operator opens before confirming anything is the usual way back rather +than a guarantee. Each operation is decided the way that operation decides it. Apply checks installation and admission before the classifier and reports a conflict ahead of unsafe; disable asks neither, From 34488404009c44785fc3cc93980298cf894367e2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 03:10:50 +0900 Subject: [PATCH 56/83] test(management): a duplicate copy of an operation cannot redirect a bound undo Aside can legitimately hold the same operation in more than one profile store, and that is the situation the row-resolution fix exists for: a preview and the mutation could each resolve a valid row and resolve a different one, leaving the confirmation bound to an operation other than the one that runs. Copies a journal row into a sibling profile so the request has two valid rows available, then previews and commits a bound undo for the profile that was asked for. The undo acts on that profile and the sibling is untouched. local checks: NOT RUN --- tests/server/aside-profiles-routes.test.ts | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index 8d010e17975..b8ecc9cf55b 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -277,6 +277,42 @@ test("a stale profile confirmation is refused before the preference is written", expect(saved).toBeUndefined(); }); +test("a duplicate copy of an operation cannot redirect a bound undo", async () => { + await seedRoster(); + const applied = await api("/api/client-integrations/aside/profiles/1", "PUT", { enabled: true }); + expect(applied.status).toBe(200); + expect(document(1).providers.opencodex).toBeDefined(); + + /* + * Aside can legitimately hold the same operation in more than one profile store. Copying the + * row into a sibling gives the request two valid rows to resolve, which is the situation where + * a preview and the mutation could each pick a different one and the confirmation would then + * describe an operation that is not the one running. + */ + const rowsPath = join(root, "store", "aside-profiles", "1", "journal.jsonl"); + const siblingDir = join(root, "store", "aside-profiles", "2"); + mkdirSync(siblingDir, { recursive: true }); + writeFileSync(join(siblingDir, "journal.jsonl"), readFileSync(rowsPath, "utf8")); + + const opId = JSON.parse(readFileSync(rowsPath, "utf8").trim().split("\n")[0] ?? "{}").opId as string; + expect(typeof opId).toBe("string"); + + const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { operation: "restore", opId }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { canApply: boolean; fingerprint: string; profileId?: number }; + expect(plan.profileId).toBe(1); + + const siblingBefore = readFileSync(path(2), "utf8"); + const undo = await api("/api/client-integrations/aside/profiles/1/restore", "POST", { + opId, operation: "restore", planFingerprint: plan.fingerprint, + }); + expect(undo.status).toBe(200); + + // The undo acted on the profile that was asked for, and the duplicate did not redirect it. + expect(document(1).providers.opencodex).toBeUndefined(); + expect(readFileSync(path(2), "utf8")).toBe(siblingBefore); +}); + test.each(["../0", "01", "-1", "9007199254740992"])("rejects invalid profile %s before file mutation", async id => { const before = [0,1,2].map(i => readFileSync(path(i), "utf8")); const response = await api(`/api/client-integrations/aside?profile=${encodeURIComponent(id)}`, "PUT", { enabled: true }); From 36b7ddd32349f58c0b00c1f34df2edef946e9ccd Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 03:16:49 +0900 Subject: [PATCH 57/83] test(management): make the duplicate copy one resolution actually has to choose The previous version copied the row into a sibling profile, which nothing ever scans: operation rows come from the requested profile's store and the root store only. The duplicate was unreachable, so the case passed whether or not the selected row was carried through, which is the one thing it existed to prove. It now puts a stored copy in the root store and expires the profile's own, so resolution prefers the alternate and the request genuinely depends on which copy was chosen. The undo restores the original bytes from that copy, the journal records both it and the apply it undid, and the sibling profile's store is unchanged by content. local checks: NOT RUN --- tests/server/aside-profiles-routes.test.ts | 44 +++++++++++++--------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index b8ecc9cf55b..cfd561f752d 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -1,6 +1,6 @@ import { loadConfig } from "../../src/config"; import { afterEach, beforeEach, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { handleManagementAPI } from "../../src/server/management-api"; @@ -277,40 +277,50 @@ test("a stale profile confirmation is refused before the preference is written", expect(saved).toBeUndefined(); }); -test("a duplicate copy of an operation cannot redirect a bound undo", async () => { +test("a bound undo follows the copy resolution actually chose", async () => { await seedRoster(); + const original = readFileSync(path(1), "utf8"); const applied = await api("/api/client-integrations/aside/profiles/1", "PUT", { enabled: true }); expect(applied.status).toBe(200); expect(document(1).providers.opencodex).toBeDefined(); + const profileStore = join(root, "store", "aside-profiles", "1"); + const rows = readFileSync(join(profileStore, "journal.jsonl"), "utf8"); + const opId = JSON.parse(rows.trim().split("\n")[0] ?? "{}").opId as string; + expect(typeof opId).toBe("string"); + /* - * Aside can legitimately hold the same operation in more than one profile store. Copying the - * row into a sibling gives the request two valid rows to resolve, which is the situation where - * a preview and the mutation could each pick a different one and the confirmation would then - * describe an operation that is not the one running. + * The same operation can live in more than one store with different retention. Resolution + * prefers the copy whose snapshot still exists, so putting a stored copy in the root store and + * expiring the profile's own forces it to choose the alternate. If a preview and the mutation + * resolved independently they could pick different copies, and the confirmation would then + * describe an operation other than the one that runs. */ - const rowsPath = join(root, "store", "aside-profiles", "1", "journal.jsonl"); - const siblingDir = join(root, "store", "aside-profiles", "2"); - mkdirSync(siblingDir, { recursive: true }); - writeFileSync(join(siblingDir, "journal.jsonl"), readFileSync(rowsPath, "utf8")); - - const opId = JSON.parse(readFileSync(rowsPath, "utf8").trim().split("\n")[0] ?? "{}").opId as string; - expect(typeof opId).toBe("string"); + writeFileSync(join(root, "store", "journal.jsonl"), rows); + const snapshotName = join("snapshots", "aside", opId); + mkdirSync(join(root, "store", "snapshots", "aside"), { recursive: true }); + writeFileSync(join(root, "store", snapshotName), readFileSync(join(profileStore, snapshotName), "utf8")); + rmSync(join(profileStore, snapshotName)); + const siblingBefore = treeWitness(join(root, "store", "aside-profiles", "2")); const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { operation: "restore", opId }); expect(preview.status).toBe(200); const plan = await preview.json() as { canApply: boolean; fingerprint: string; profileId?: number }; + expect(plan.canApply).toBe(true); expect(plan.profileId).toBe(1); - const siblingBefore = readFileSync(path(2), "utf8"); const undo = await api("/api/client-integrations/aside/profiles/1/restore", "POST", { opId, operation: "restore", planFingerprint: plan.fingerprint, }); expect(undo.status).toBe(200); - // The undo acted on the profile that was asked for, and the duplicate did not redirect it. - expect(document(1).providers.opencodex).toBeUndefined(); - expect(readFileSync(path(2), "utf8")).toBe(siblingBefore); + // Restored from the copy that was chosen, and the journal records both the undo and the apply. + expect(readFileSync(path(1), "utf8")).toBe(original); + const finalRows = readFileSync(join(root, "store", "journal.jsonl"), "utf8") + + readFileSync(join(profileStore, "journal.jsonl"), "utf8"); + expect(finalRows).toContain(opId); + expect(finalRows).toContain("restore"); + expect(treeWitness(join(root, "store", "aside-profiles", "2"))).toBe(siblingBefore); }); test.each(["../0", "01", "-1", "9007199254740992"])("rejects invalid profile %s before file mutation", async id => { From 11551f2e12b3cb99baa66a3f04d3b63c246d1d88 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 03:18:45 +0900 Subject: [PATCH 58/83] fix(models): carry each provider's revision out with the rows it vouches for Sampling the cache revision after a gather returns is wrong under concurrent flights. Flight A can publish rows and retain them while flight B publishes before A's await resolves, so the roster recorded A's rows under B's revision and every later check agreed it was current. Every provider result is built through one helper, and that helper now stamps the provider's content revision as it constructs the result. That is the only point synchronous with choosing the rows; anywhere later is another chance for the two to come from different moments. The flight carries the map out, the gather exposes it through the same out-parameter pattern its other flight-local authority already uses, and the export projection retains those revisions instead of observing globals. A provider the gather did not report falls back to observation so the stamp stays total. That covers a provider configured after the rows were chosen, and it retires the snapshot on the next read rather than implying the roster covered it. A supplied roster still observes, because no gather happened and there is nothing tighter available. local checks: NOT RUN --- src/codex/catalog/provider-models.ts | 15 +++++++++++- src/codex/catalog/routed-gather.ts | 19 +++++++++++++++ src/server/management/model-rows.ts | 36 ++++++++++++++++++++++++---- src/server/management/shared.ts | 13 +++++++--- 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts index ced0cb939ed..21451f1eb5c 100644 --- a/src/codex/catalog/provider-models.ts +++ b/src/codex/catalog/provider-models.ts @@ -11,6 +11,7 @@ import { clearModelCache, clearProviderDiscoveryStatus, captureModelCacheGeneration, + observeModelCacheRevision, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, @@ -108,6 +109,14 @@ import { captureProviderGather, materializeCapturedHeaders } from "./gather-capt export interface ProviderModelsResult { readonly models: CatalogModel[]; readonly outcome: CatalogGatherProviderModelOutcome; + /** + * The provider's cache content revision as it stood when these rows were chosen. + * + * Sampled here rather than by the caller because only this point is synchronous with the + * choice. A caller reading it after its own await can observe a revision another flight + * published in between, and would then record these rows under that flight's identity. + */ + readonly contentRevision: string; } export const refreshingModelsAuthResolver: ModelsAuthResolver = { kind: "refreshing" }; @@ -145,7 +154,11 @@ export async function fetchProviderModelsWithAuth( const observed = ( models: CatalogModel[], state: CatalogGatherProviderModelOutcome["state"], - ): ProviderModelsResult => ({ models, outcome: { provider: name, state } }); + ): ProviderModelsResult => ({ + models, + outcome: { provider: name, state }, + contentRevision: observeModelCacheRevision(name), + }); // Capture before any credential refresh or outbound await. OAuth account changes clear this // generation, so a request started with the former account cannot later publish its result. const cacheGeneration = captureModelCacheGeneration(name); diff --git a/src/codex/catalog/routed-gather.ts b/src/codex/catalog/routed-gather.ts index c610ab45d41..24ef744dc56 100644 --- a/src/codex/catalog/routed-gather.ts +++ b/src/codex/catalog/routed-gather.ts @@ -113,6 +113,15 @@ export interface GatherRoutedModelsOptions { providerModelOutcomes?: CatalogGatherProviderModelOutcome[]; /** Internal convergence sink for the immutable policy that produced the returned rows. */ discoveryPolicySnapshots?: CatalogProviderDiscoveryPolicySnapshot[]; + /** + * Each provider's cache content revision as of the moment its rows were chosen. + * + * A caller that derives something from these rows and wants to know later whether the rows are + * still current must use this, not a revision sampled after the gather returns: another flight + * can publish between the choice and the sample, and the derived value would then carry that + * flight's identity while holding these rows. + */ + providerContentRevisions?: Map; } interface GatherFlightResult { @@ -121,6 +130,7 @@ interface GatherFlightResult { providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[]; providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[]; discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[]; + providerContentRevisions: ReadonlyMap; } interface GatherInflightEntry { readonly discoveryPolicyIdentity: string; @@ -256,6 +266,7 @@ async function gatherRoutedModelsWithAuth( providerAuthOutcomes, providerModelOutcomes, discoveryPolicySnapshots, + providerContentRevisions, } = await entry.promise; if (options?.comboOmissions) { options.comboOmissions.length = 0; @@ -269,6 +280,12 @@ async function gatherRoutedModelsWithAuth( options.providerModelOutcomes.length = 0; options.providerModelOutcomes.push(...providerModelOutcomes); } + if (options?.providerContentRevisions) { + options.providerContentRevisions.clear(); + for (const [provider, revision] of providerContentRevisions) { + options.providerContentRevisions.set(provider, revision); + } + } if (options?.discoveryPolicySnapshots) { options.discoveryPolicySnapshots.length = 0; options.discoveryPolicySnapshots.push(...discoveryPolicySnapshots); @@ -763,6 +780,8 @@ async function gatherRoutedModelsUncached( providerAuthOutcomes: localProviderAuthOutcomes, providerModelOutcomes, discoveryPolicySnapshots: capture.discoveryPolicySnapshots, + // Stamped by each provider at the moment it chose its rows, not sampled here. + providerContentRevisions: new Map(providerResults.map(result => [result.outcome.provider, result.contentRevision])), }; } diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index ef299c68c9d..9fe38883dea 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -79,7 +79,12 @@ export function effectiveManagementDisplayName( */ export async function listManagementModelRows( config: OcxConfig, - options: { entitlementWaitMs?: number; models?: readonly CatalogModel[] } = {}, + options: { + entitlementWaitMs?: number; + models?: readonly CatalogModel[]; + /** Filled with each provider's content revision as of the moment its rows were chosen. */ + providerContentRevisions?: Map; + } = {}, ): Promise { /* * A supplied roster skips the gather, and that is the point rather than an optimization. @@ -91,7 +96,7 @@ export async function listManagementModelRows( */ const models = options.models === undefined ? (await Promise.all([ - fetchAllModels(config), + fetchAllModels(config, options.providerContentRevisions), ensureCodexEntitlementFreshness(config, { waitMs: options.entitlementWaitMs ?? 3_000, }), @@ -252,7 +257,14 @@ export async function loadExportModels( config: OcxConfig, models?: readonly CatalogModel[], ): Promise { - const rows = await listManagementModelRows(config, models === undefined ? {} : { models }); + // The gather stamps each provider as it chooses its rows, so the roster and the revisions that + // vouch for it come from the same moment. Sampling afterwards would let a concurrent flight's + // publication be recorded against rows it never produced. + const gathered = new Map(); + const rows = await listManagementModelRows( + config, + models === undefined ? { providerContentRevisions: gathered } : { models }, + ); // Management deliberately lists the full roster so hidden models can be enabled. // A client picker must also honor the provider selection, not just its blocklist. const visibleRouted = new Set(filterCatalogVisibleModels(rows.filter(row => !row.native), config)); @@ -271,7 +283,9 @@ export async function loadExportModels( } lastExportSnapshot = { key: configKey, - cacheStamp: modelCacheStamp(config), + // Prefer the revisions the gather stamped; fall back to observing only when the roster was + // supplied and no gather happened, where there is nothing tighter to use. + cacheStamp: gathered.size > 0 ? stampFrom(config, gathered) : modelCacheStamp(config), generation: ++exportSnapshotGeneration, models: Object.freeze(structuredClone(exported)), }; @@ -311,6 +325,20 @@ function modelCacheStamp(config: OcxConfig): string { .join(","); } +/** + * The same stamp shape, built from revisions the gather recorded rather than from observation. + * + * A provider the gather did not report falls back to observation so the stamp stays total; that + * happens for a provider configured after the rows were chosen, and it retires the snapshot on + * the next read rather than pretending the roster covered it. + */ +function stampFrom(config: OcxConfig, gathered: ReadonlyMap): string { + return Object.keys(config.providers ?? {}) + .sort() + .map(provider => `${provider}=${gathered.get(provider) ?? observeModelCacheRevision(provider)}`) + .join(","); +} + /** * Opaque identity of the snapshot a caller is holding, or null when there is none for this config. * diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index c48f5f53f7a..fa3845250f2 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -245,12 +245,19 @@ export function requestLogDto( * share the same fetch, the same per-provider cache (dedups Codex's frequent /v1/models polling), * and the same stale fallback when a provider blips, instead of a parallel uncached copy. */ -export async function fetchAllModels(config: OcxConfig): Promise { +export async function fetchAllModels( + config: OcxConfig, + /** Filled with each provider's content revision as of the moment its rows were chosen. */ + providerContentRevisions?: Map, +): Promise { const { gatherRoutedModels } = await import("../../codex/catalog"); const baseline = captureInitialSelectionBaseline(config); - if (!baseline) return gatherRoutedModels(config); + if (!baseline) return gatherRoutedModels(config, providerContentRevisions ? { providerContentRevisions } : undefined); const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; - const models = await gatherRoutedModels(config, { providerModelOutcomes: outcomes }); + const models = await gatherRoutedModels(config, { + providerModelOutcomes: outcomes, + ...(providerContentRevisions ? { providerContentRevisions } : {}), + }); finalizeInitialModelSelection(config, baseline, uniqueCatalogModelsForPublicList(models), outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider)); return models; From 8663aac7a7c7403f26e98777fb79db7727175fa9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 03:24:11 +0900 Subject: [PATCH 59/83] test(integrations): make the captured-row handoff observable, not merely plausible The HTTP duplicate case proved resolution finds the alternate copy, but it would still pass if the selected row were dropped on the way to the writer, because a fresh lookup would find the same row again. It tested discovery, not the handoff. A lower-level case now removes the row from the journal a fresh lookup reads and restores with the row already captured. Only the captured row can carry that through; a resolver that looked it up again would find nothing and refuse. The route case keeps its job as route proof and its assertions are now exact: the restored bytes, a restore row and the original operation found by parsing the profile's own journal rather than a substring of two files concatenated, the chosen copy unrewritten, and the sibling profile unchanged in both its document and its store. local checks: NOT RUN --- tests/clients/aside-profiles.test.ts | 23 ++++++++++++++++++++++ tests/server/aside-profiles-routes.test.ts | 19 +++++++++++++----- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/tests/clients/aside-profiles.test.ts b/tests/clients/aside-profiles.test.ts index 800ef329f2a..ffdf64adc03 100644 --- a/tests/clients/aside-profiles.test.ts +++ b/tests/clients/aside-profiles.test.ts @@ -247,6 +247,29 @@ describe("Aside profile desired state, ownership and history", () => { expect((await getAsideProfileState(input(), 1)).enabled).toBe(false); }); + test("a captured operation is the one restored, not whatever a fresh lookup would find", async () => { + const original = readFileSync(path(1), "utf8"); + const enabled = await mutateAsideProfiles(input(), { profileId: 1, enabled: true }); + const enabledResult = enabled.results[0]!; + if (!enabledResult.ok) throw new Error("fixture enable failed"); + const opId = enabledResult.opId!; + const captured = findAsideOperation(input(), opId, 1)!; + expect(captured).toBeDefined(); + + /* + * Remove the row from the journal the fresh lookup reads, then restore with the row already + * captured. A resolver that looked the operation up again would find nothing and refuse; only + * the captured row can carry this through, which is what makes the handoff observable rather + * than merely plausible. + */ + await deleteAsideOperation(input(), { opId, profileId: 1 }); + expect(findAsideOperation(input(), opId, 1)).toBeNull(); + + const restored = await restoreAsideProfile(input(), { opId, profileId: 1, selectedOperation: captured }); + expect(restored.ok).toBe(true); + expect(readFileSync(path(1), "utf8")).toBe(original); + }); + test("disable then Undo restores target intent without changing sibling overrides", async () => { seedLegacy(); await mutateAsideProfiles(input(), { profileId: 2, enabled: false }); diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index cfd561f752d..a8257d210bc 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -303,6 +303,7 @@ test("a bound undo follows the copy resolution actually chose", async () => { rmSync(join(profileStore, snapshotName)); const siblingBefore = treeWitness(join(root, "store", "aside-profiles", "2")); + const sibling2Before = readFileSync(path(2), "utf8"); const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { operation: "restore", opId }); expect(preview.status).toBe(200); const plan = await preview.json() as { canApply: boolean; fingerprint: string; profileId?: number }; @@ -314,12 +315,20 @@ test("a bound undo follows the copy resolution actually chose", async () => { }); expect(undo.status).toBe(200); - // Restored from the copy that was chosen, and the journal records both the undo and the apply. + // Restored from the copy that was chosen, byte for byte. expect(readFileSync(path(1), "utf8")).toBe(original); - const finalRows = readFileSync(join(root, "store", "journal.jsonl"), "utf8") - + readFileSync(join(profileStore, "journal.jsonl"), "utf8"); - expect(finalRows).toContain(opId); - expect(finalRows).toContain("restore"); + + // The undo is journalled in the profile's own store as a restore row, rather than inferred from + // a substring of two files concatenated together. + const profileRows = readFileSync(join(profileStore, "journal.jsonl"), "utf8") + .trim().split("\n").map(line => JSON.parse(line) as { kind: string; opId: string }); + expect(profileRows.some(row => row.kind === "restore")).toBe(true); + expect(profileRows.some(row => row.opId === opId)).toBe(true); + + // The copy resolution chose is not rewritten, and the sibling profile is untouched in both its + // document and its store. + expect(readFileSync(join(root, "store", "journal.jsonl"), "utf8")).toBe(rows); + expect(readFileSync(path(2), "utf8")).toBe(sibling2Before); expect(treeWitness(join(root, "store", "aside-profiles", "2"))).toBe(siblingBefore); }); From 76864f7fb24097727f4a2821523a722f61bb129c Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 03:36:44 +0900 Subject: [PATCH 60/83] test(integrations): choose the captured copy when only lookup diverges The previous version of this case deleted the operation the fresh lookup would find, which is not reachable: deleting the newest row throws before restore, and deleting an older one prunes the snapshot the unchanged-source guard checks. It proved nothing it claimed to prove. The divergence is now confined to enumeration. The root store's listOperations hides the copied row after capture while findOperation and readSnapshot stay real, so re-resolution lands on the profile store's expired copy and refuses before any write, and only the captured row carries the restore through. The refusal is taken through the public entry point and witnessed with no saves, an unchanged profile store tree, unchanged applied bytes and an unchanged source. The routes case now asserts the success envelope and pins the single restore row to the opId that envelope returned, and witnesses the root snapshot bytes rather than only the root journal. local checks: NOT RUN --- tests/clients/aside-profiles.test.ts | 101 +++++++++++++++++---- tests/server/aside-profiles-routes.test.ts | 8 +- 2 files changed, 89 insertions(+), 20 deletions(-) diff --git a/tests/clients/aside-profiles.test.ts b/tests/clients/aside-profiles.test.ts index ffdf64adc03..b5b3cad956f 100644 --- a/tests/clients/aside-profiles.test.ts +++ b/tests/clients/aside-profiles.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ExportModel } from "../../src/clients/config-export"; @@ -247,27 +247,90 @@ describe("Aside profile desired state, ownership and history", () => { expect((await getAsideProfileState(input(), 1)).enabled).toBe(false); }); - test("a captured operation is the one restored, not whatever a fresh lookup would find", async () => { + test("a captured stored copy survives lookup-only divergence without bypassing source guards", async () => { + // Pin profile 0 to the legacy root: profile 1 must stay on its own child store even after + // its historical row is copied into the root journal. + config.asideProfileSync = { allProfiles: false, legacyProfileId: 0 }; const original = readFileSync(path(1), "utf8"); const enabled = await mutateAsideProfiles(input(), { profileId: 1, enabled: true }); - const enabledResult = enabled.results[0]!; - if (!enabledResult.ok) throw new Error("fixture enable failed"); - const opId = enabledResult.opId!; - const captured = findAsideOperation(input(), opId, 1)!; - expect(captured).toBeDefined(); - - /* - * Remove the row from the journal the fresh lookup reads, then restore with the row already - * captured. A resolver that looked the operation up again would find nothing and refuse; only - * the captured row can carry this through, which is what makes the handoff observable rather - * than merely plausible. - */ - await deleteAsideOperation(input(), { opId, profileId: 1 }); - expect(findAsideOperation(input(), opId, 1)).toBeNull(); - - const restored = await restoreAsideProfile(input(), { opId, profileId: 1, selectedOperation: captured }); - expect(restored.ok).toBe(true); + const applied = enabled.results[0]!; + if (!applied.ok || !applied.opId) throw new Error("fixture enable failed"); + const opId = applied.opId; + const profileStore = createIntegrationStateStore(join(store.root, "aside-profiles", "1")); + const entry = profileStore.findOperation(opId); + if (!entry) throw new Error("fixture operation missing from profile store"); + const snapshot = profileStore.readSnapshot(entry); + if (snapshot.kind !== "stored") throw new Error("fixture snapshot is not stored"); + expect(snapshot.text).toBe(original); + expect(store.findOperation(opId)).toBeNull(); + store.captureSnapshot("aside", opId, snapshot.text); + store.appendJournal(structuredClone(entry)); + rmSync(snapshot.path); + expect(profileStore.readSnapshot(entry).kind).toBe("expired"); + + let hideRootCopy = false; + const lookupStore: IntegrationStateStore = { + ...store, + // Only enumeration diverges. Source revalidation and snapshot reads remain real. + listOperations(clientId, limit) { + const rows = store.listOperations(clientId, Number.MAX_SAFE_INTEGER); + return rows.filter(row => !hideRootCopy || row.opId !== opId).slice(0, limit ?? 50); + }, + }; + const fixture = input({ store: lookupStore }); + const captured = findAsideOperation(fixture, opId, 1); + if (!captured) throw new Error("fixture capture failed"); + expect(captured.profileId).toBe(1); + expect(captured.store.root).toBe(store.root); + expect(captured.store.readSnapshot(captured.entry)).toMatchObject({ kind: "stored", text: original }); + + const treeBytes = (dir: string): string => !existsSync(dir) ? "" : readdirSync(dir, { recursive: true }) + .map(String).sort().map(name => { + const full = join(dir, name); + return statSync(full).isDirectory() ? name + "/" : name + ":" + readFileSync(full, "utf8"); + }).join("\u0000"); + const siblingDir = join(store.root, "aside-profiles", "2"); + createIntegrationStateStore(siblingDir).captureSnapshot("aside", "sibling-witness", "untouched"); + const siblingBefore = treeBytes(siblingDir); + const siblingDocuments = [0, 2].map(id => readFileSync(path(id), "utf8")); + const sourceWitness = () => ["journal.jsonl", "records.json", "maintenance.json", join("snapshots", "aside", opId)] + .map(name => existsSync(join(store.root, name)) ? readFileSync(join(store.root, name), "utf8") : null); + const sourceBefore = sourceWitness(); + const profileBefore = treeBytes(profileStore.root); + const appliedBytes = readFileSync(path(1), "utf8"); + const savesBefore = saves; + + hideRootCopy = true; + const fresh = findAsideOperation(fixture, opId, 1); + if (!fresh) throw new Error("fixture fresh lookup failed"); + expect(fresh.store.root).toBe(profileStore.root); + expect(fresh.store.readSnapshot(fresh.entry).kind).toBe("expired"); + expect(captured.store.findOperation(opId)).toEqual(captured.entry); + expect(captured.store.readSnapshot(captured.entry)).toMatchObject({ kind: "stored", text: original }); + // Counterfactual through the real public entry point: re-resolution refuses before any write. + expect(await restoreAsideProfile(fixture, { opId, profileId: 1 })).toMatchObject({ + ok: false, clientId: "aside", profileId: 1, reason: "snapshot_expired", + }); + expect(saves).toBe(savesBefore); + expect(treeBytes(profileStore.root)).toBe(profileBefore); + expect(readFileSync(path(1), "utf8")).toBe(appliedBytes); + expect(sourceWitness()).toEqual(sourceBefore); + + const restored = await restoreAsideProfile(fixture, { opId, profileId: 1, selectedOperation: captured }); + expect(restored).toMatchObject({ ok: true, changed: true, clientId: "aside", profileId: 1 }); + if (!restored.ok || !restored.opId) throw new Error("captured restore did not commit"); expect(readFileSync(path(1), "utf8")).toBe(original); + const finalRows = profileStore.listOperations("aside"); + expect(finalRows.map(row => ({ kind: row.kind, opId: row.opId }))).toEqual([ + { kind: "restore", opId: restored.opId }, { kind: "apply", opId }, + ]); + expect(finalRows[0]!.configPath).toBe(path(1)); + expect(profileStore.readSnapshot(finalRows[0]!)).toMatchObject({ kind: "stored", text: appliedBytes }); + expect(profileStore.readSnapshot(entry)).toMatchObject({ kind: "stored", text: original }); + expect(sourceWitness()).toEqual(sourceBefore); + expect(captured.store.findOperation(opId)).toEqual(captured.entry); + expect([0, 2].map(id => readFileSync(path(id), "utf8"))).toEqual(siblingDocuments); + expect(treeBytes(siblingDir)).toBe(siblingBefore); }); test("disable then Undo restores target intent without changing sibling overrides", async () => { diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index a8257d210bc..328d8628974 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -314,6 +314,9 @@ test("a bound undo follows the copy resolution actually chose", async () => { opId, operation: "restore", planFingerprint: plan.fingerprint, }); expect(undo.status).toBe(200); + const result = await undo.json() as { ok: boolean; changed: boolean; clientId: string; profileId: number; opId: string }; + expect(result).toMatchObject({ ok: true, changed: true, clientId: "aside", profileId: 1 }); + expect(typeof result.opId).toBe("string"); // Restored from the copy that was chosen, byte for byte. expect(readFileSync(path(1), "utf8")).toBe(original); @@ -322,12 +325,15 @@ test("a bound undo follows the copy resolution actually chose", async () => { // a substring of two files concatenated together. const profileRows = readFileSync(join(profileStore, "journal.jsonl"), "utf8") .trim().split("\n").map(line => JSON.parse(line) as { kind: string; opId: string }); - expect(profileRows.some(row => row.kind === "restore")).toBe(true); + expect(profileRows.filter(row => row.kind === "restore")).toEqual([ + expect.objectContaining({ kind: "restore", opId: result.opId }), + ]); expect(profileRows.some(row => row.opId === opId)).toBe(true); // The copy resolution chose is not rewritten, and the sibling profile is untouched in both its // document and its store. expect(readFileSync(join(root, "store", "journal.jsonl"), "utf8")).toBe(rows); + expect(readFileSync(join(root, "store", snapshotName), "utf8")).toBe(original); expect(readFileSync(path(2), "utf8")).toBe(sibling2Before); expect(treeWitness(join(root, "store", "aside-profiles", "2"))).toBe(siblingBefore); }); From 65f5d57b06ca3c11d53374bcf0a73e477a413050 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 03:36:44 +0900 Subject: [PATCH 61/83] test(models): interleave two publications inside one export load The production fix carries each provider's content revision out with the rows it vouches for, and nothing exercised the interleaving it exists for. This runs a real one. The load gathers two providers, alpha answers immediately and beta is held open, so the load is still inside its own await once alpha has chosen, published and stamped its rows. A competing publication for alpha lands in that window. The load still returns the rows it fetched, which is correct, but the retained preview is refused because the revision that vouches for those rows is no longer the current one. Reverting the fix makes the retained stamp the competing publication's, so the preview would match on the way out and serve those rows as current: the second case fails. The first case is the control, so a preview refused for an unrelated reason cannot pass for the race being closed. local checks: NOT RUN --- scripts/test-layout/layout.json | 1 + tests/fixtures/test-layout-expected.json | 1 + ...anagement-model-roster-gather-race.test.ts | 133 ++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 tests/server/management-model-roster-gather-race.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 70c15f0c45b..e5ea63a1292 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -927,6 +927,7 @@ "management-client-config-route.test.ts": "server", "management-integration-journal-delete.test.ts": "server", "management-integration-routes.test.ts": "server", + "management-model-roster-gather-race.test.ts": "server", "management-model-roster.test.ts": "server", "management-origin-tls.test.ts": "server", "management-provider-pinsless-validation.test.ts": "server", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 8bf8280eca2..cd4242bbf8c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -752,6 +752,7 @@ "management-client-config-route.test.ts": "server", "management-integration-journal-delete.test.ts": "server", "management-integration-routes.test.ts": "server", + "management-model-roster-gather-race.test.ts": "server", "management-model-roster.test.ts": "server", "management-origin-tls.test.ts": "server", "management-provider-pinsless-validation.test.ts": "server", diff --git a/tests/server/management-model-roster-gather-race.test.ts b/tests/server/management-model-roster-gather-race.test.ts new file mode 100644 index 00000000000..be1a64e0f03 --- /dev/null +++ b/tests/server/management-model-roster-gather-race.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { withStubbedProviderFetch } from "../helpers/catalog-provider-fetch"; +import { + loadExportModels, + previewExportModels, + resetExportSnapshotForTests, +} from "../../src/server/management/model-rows"; +import { clearGatherRoutedModelsInflight, resetCatalogRuntimeStateForTests } from "../../src/codex/catalog"; +import { clearModelCache, getStaleCached, setCached } from "../../src/codex/model-cache"; +import type { ExportModel } from "../../src/clients/config-export"; +import type { OcxConfig } from "../../src/types"; + +/** + * Two publications can interleave inside one export load, and the roster it retains has to be + * identified by the revision that stood when its rows were chosen rather than by whatever is + * current when it finishes. + * + * The interleaving here is real, not simulated: the load runs a two-provider gather, alpha answers + * immediately and beta is held open, so the load is still inside its own await after alpha has + * chosen and published its rows. A competing publication lands in that window. Sampling the + * revision at retention time would record alpha's rows under the competing publication's identity, + * and every later check would then agree the preview was current. + */ +const ALPHA_HOST = "https://alpha.gather-race.test/v1"; +const BETA_HOST = "https://beta.gather-race.test/v1"; + +const originalFetch = globalThis.fetch; +let configRoot = ""; +let priorHome: string | undefined; + +function raceConfig(): OcxConfig { + return withStubbedProviderFetch({ + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "alpha", + providers: { + alpha: { adapter: "openai-chat", baseUrl: ALPHA_HOST, models: [] }, + beta: { adapter: "openai-chat", baseUrl: BETA_HOST, models: [] }, + }, + } as OcxConfig); +} + +/** Alpha answers at once; beta stays open until the returned release is called. */ +function holdBetaOpen(): () => void { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const alpha = String(input).includes("alpha."); + if (!alpha) await gate; + return new Response(JSON.stringify({ data: [{ id: alpha ? "alpha-chosen" : "beta-only" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + return release; +} + +/** + * Resolves once alpha's rows are published, which is also once they are stamped: the provider + * publishes and builds its result in one synchronous block, so a cache entry another task can see + * is proof the revision that vouches for those rows has already been taken. + */ +async function afterAlphaChoseItsRows(): Promise { + for (let attempt = 0; attempt < 2_000; attempt += 1) { + if (getStaleCached("alpha") !== null) return; + await new Promise(resolve => setTimeout(resolve, 1)); + } + throw new Error("alpha never published its rows"); +} + +const alphaRows = (models: readonly ExportModel[]): string[] => + models.filter(model => model.provider === "alpha").map(model => model.namespaced); + +beforeEach(() => { + configRoot = mkdtempSync(join(tmpdir(), "ocx-gather-race-")); + priorHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = configRoot; + resetExportSnapshotForTests(); + clearModelCache(); + clearGatherRoutedModelsInflight(); + resetCatalogRuntimeStateForTests(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + resetExportSnapshotForTests(); + clearModelCache(); + clearGatherRoutedModelsInflight(); + resetCatalogRuntimeStateForTests(); + if (priorHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorHome; + removeTreeWithRetry(configRoot); +}); + +describe("an export load retains the revision its rows were chosen under", () => { + test("a load nothing competes with leaves a preview that serves exactly what it returned", async () => { + const release = holdBetaOpen(); + const config = raceConfig(); + const load = loadExportModels(config); + await afterAlphaChoseItsRows(); + release(); + const exported = await load; + + expect(alphaRows(exported)).toEqual(["alpha/alpha-chosen"]); + // The control for the case below. Without it, a preview refused for some unrelated reason + // would look like the race being closed. + expect(previewExportModels(config)).toEqual(exported); + }); + + test("rows chosen before a competing publication are never served as current", async () => { + const release = holdBetaOpen(); + const config = raceConfig(); + const load = loadExportModels(config); + await afterAlphaChoseItsRows(); + // The competing flight publishes here: alpha has chosen and stamped its rows, and this load + // has not reached its retention yet because beta is still open. + expect(setCached("alpha", [{ id: "alpha-superseded", provider: "alpha" }])).toBe(true); + release(); + const exported = await load; + + // The load still returns the rows it fetched. Those rows are not wrong; they are simply no + // longer the current ones. + expect(alphaRows(exported)).toEqual(["alpha/alpha-chosen"]); + // What must not survive is the claim that they are current. Sampling the revision at + // retention would have read the competing publication's, matched it on the way out, and + // handed a preview these rows under an identity they never had. + expect(previewExportModels(config)).toBeNull(); + }); +}); From 52d084a397dab0dc2b46595156b3a4dba9bc92c9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 03:51:24 +0900 Subject: [PATCH 62/83] fix(management): identify a roster by the configuration in hand, not only the file The snapshot key named the configuration file and nothing else, so it could not tell which in-memory configuration a roster was actually built from. Two callers holding different configuration objects that correspond to the same bytes shared one key, and a configuration edited in place while a load was awaiting was invisible to it. That was recorded as a known residual rather than fixed. The identity now carries both terms: the admission snapshot's file digest and a canonical structural digest of the configuration object itself. Structural for the same reason the file digest exists, because a list of the fields that seemed to matter is only as complete as whoever last thought about it, and it already missed export-affecting configuration once. The load takes that identity before the gather and requires it to be unchanged after. Reading it only at retention would attribute an edit that landed during the await to rows chosen under the previous configuration, which is the same substitution the per-provider revision prevents one layer down. A configuration that moves during a load leaves no snapshot rather than one recorded under an identity its rows never had. Null is unprovable in either term and every caller fails closed on it. Nothing derived from the configuration is logged or serialized; the digest travels and the configuration does not. The existing case asserting that a blocklist or custom-model edit retires the snapshot was left behind by the move to a file-only key and could not have been passing; it is covered again by construction, and a new case pins the in-memory divergence directly, including a field no hand-written list ever named. local checks: NOT RUN --- src/config/admitted-identity.ts | 77 +++++++++++++++++ src/server/management/model-rows.ts | 53 ++++-------- structure/clients/integrations.md | 6 +- structure/runtime.md | 1 + ...anagement-model-roster-gather-race.test.ts | 86 ++++++++++++------- tests/server/management-model-roster.test.ts | 21 +++++ 6 files changed, 178 insertions(+), 66 deletions(-) create mode 100644 src/config/admitted-identity.ts diff --git a/src/config/admitted-identity.ts b/src/config/admitted-identity.ts new file mode 100644 index 00000000000..d1cc4817623 --- /dev/null +++ b/src/config/admitted-identity.ts @@ -0,0 +1,77 @@ +import { createHash } from "node:crypto"; +import { readConfigAdmissionSnapshot } from "./diagnostics"; +import type { OcxConfig } from "../types"; + +/** + * Which configuration a derived artifact was built from, or null when that cannot be established. + * + * Two questions have to be answered together, and answering either one alone is what made the + * earlier versions of this wrong. The file digest says WHICH FILE the configuration came from, and + * it is the only thing that can see a field nobody thought to list. It cannot see that the object + * a caller is holding is the parse of that file: callers pass an in-memory configuration around, + * routes mutate it in place, and a roster built from one in-memory state would otherwise be + * retained under a key that only describes bytes on disk. + * + * So the identity carries both terms. The second is a digest of the object itself, taken + * structurally rather than from a list of fields, for the same reason the first one exists: a list + * is only as complete as whoever last thought about it. + * + * Null means unprovable and every caller must fail closed on it. Refusing to serve a preview is + * recoverable; serving a roster that belongs to a configuration the user no longer has is not. + */ +export function admittedConfigIdentity(config: OcxConfig): string | null { + const file = admittedConfigFileTerm(); + if (file === null) return null; + const held = heldConfigDigest(config); + return held === null ? null : `${file}:${held}`; +} + +function admittedConfigFileTerm(): string | null { + const snapshot = readConfigAdmissionSnapshot(); + if (snapshot.contentSha256 !== null) return snapshot.contentSha256; + /* + * A file that is not there is a well-defined configuration, not an unprovable one: it means + * defaults, and it is the ordinary state of a fresh install. Collapsing it into the unreadable + * case would refuse every preview on a machine with no config file, including CI. + * + * A file that exists and cannot be parsed is genuinely unprovable, and that still fails closed. + */ + const { source, error } = snapshot.diagnostics; + return source === "default" && error === null ? "absent" : null; +} + +/** + * A digest of the configuration object as it stands right now. + * + * Structural, so it moves for any change rather than for a chosen set of them, and canonical, so + * two objects describing the same configuration cannot digest differently because their keys were + * inserted in a different order. + * + * Nothing derived from this is logged or serialized: the digest travels, the configuration does + * not. Null when the object cannot be canonicalized at all, which fails closed. + */ +function heldConfigDigest(config: OcxConfig): string | null { + try { + return createHash("sha256").update(JSON.stringify(canonical(config))).digest("hex"); + } catch { + return null; + } +} + +/** + * Key-sorted entry pairs rather than objects, because JSON.stringify preserves insertion order and + * two configurations that differ only in that order are the same configuration. + * + * A value JSON cannot carry (a function a caller attached, an explicit undefined) becomes null + * here. That is deliberate: such a value is not part of the configuration's content, and treating + * it as absent would make an added one invisible. + */ +function canonical(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonical); + if (value !== null && typeof value === "object") { + return Object.keys(value as Record) + .sort() + .map(key => [key, canonical((value as Record)[key])]); + } + return typeof value === "function" || value === undefined ? null : value; +} diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 9fe38883dea..22ac00510e2 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -10,7 +10,7 @@ */ import type { CatalogModel } from "../../codex/catalog"; import { observeModelCacheRevision } from "../../codex/model-cache"; -import { readConfigAdmissionSnapshot } from "../../config/diagnostics"; +import { admittedConfigIdentity } from "../../config/admitted-identity"; import { catalogModelSlug, filterCatalogVisibleModels, @@ -257,6 +257,11 @@ export async function loadExportModels( config: OcxConfig, models?: readonly CatalogModel[], ): Promise { + // Which configuration these rows are about to be chosen under, read before the gather starts. + // Reading it afterwards would let a configuration edit that landed while the gather was awaiting + // be attributed to a roster built under the previous one, which is the same substitution the + // per-provider revision below prevents one layer down. + const admittedAtChoice = admittedConfigIdentity(config); // The gather stamps each provider as it chooses its rows, so the roster and the revisions that // vouch for it come from the same moment. Sampling afterwards would let a concurrent flight's // publication be recorded against rows it never produced. @@ -275,7 +280,12 @@ export async function loadExportModels( // A deep clone, not a frozen view of the caller's array. Freezing the array alone left the model // objects shared, so a caller mutating one in place would have silently rewritten the roster a // later preview plans against, and the fingerprint would have moved with it. - const configKey = exportSnapshotKey(config); + // Provable, and still the same one the rows were chosen under. Comparing rather than re-reading + // is what makes this fail closed: a configuration that moved during the load leaves no snapshot + // rather than leaving one recorded under an identity its rows never had. + const configKey = admittedAtChoice !== null && admittedConfigIdentity(config) === admittedAtChoice + ? admittedAtChoice + : null; // No provable configuration identity means no honest snapshot to keep. if (configKey === null) { lastExportSnapshot = null; @@ -296,9 +306,9 @@ export async function loadExportModels( * The completed export roster from the last ordinary load, if it still describes this config. * * A preview may not gather, so it reads only what an authoritative load already finished. The key - * is a digest over the provider graph's shape, the blocklist and custom models, so changing any of - * them retires the snapshot rather than letting a preview plan against a roster the user no longer - * has. Credentials are not part of it and are never read here. + * names both the configuration file the roster was admitted under and the in-memory configuration + * it was actually built from, so changing either one retires the snapshot rather than letting a + * preview plan against a roster the user no longer has. * * A cold process has no snapshot and the caller answers a bounded refusal. Recovery is the * ordinary flow rather than a special step: the Integrations collection read calls @@ -351,41 +361,12 @@ function stampFrom(config: OcxConfig, gathered: ReadonlyMap): st export function exportSnapshotIdentity(config: OcxConfig): string | null { const snapshot = lastExportSnapshot; if (snapshot === null) return null; - const configKey = exportSnapshotKey(config); + const configKey = admittedConfigIdentity(config); if (configKey === null || snapshot.key !== configKey) return null; if (snapshot.cacheStamp !== modelCacheStamp(config)) return null; return `${snapshot.key}:${snapshot.generation}`; } -/** - * What configuration this roster was derived under, or null when that cannot be established. - * - * A hand-written list of the fields that seemed to matter was the wrong instrument: it is only as - * complete as whoever last thought about it, and a field it forgets is a roster change nothing - * notices. The admission snapshot hashes the configuration file in the same read it parses, so it - * covers every field without anyone maintaining a list. - * - * Null when the file cannot be read. That is deliberate and fails closed: with no way to say which - * configuration a roster belongs to, there is no honest snapshot to keep or to serve. - * - * Known residual, not closed by this: the digest describes the file on disk, and the roster was - * built from an in-memory configuration that a caller supplied. Proving those two are the same - * needs the admitted configuration to carry its own revision, which is separate work. - */ -function exportSnapshotKey(_config: OcxConfig): string | null { - const snapshot = readConfigAdmissionSnapshot(); - if (snapshot.contentSha256 !== null) return snapshot.contentSha256; - /* - * A file that is not there is a well-defined configuration, not an unprovable one: it means - * defaults, and it is the ordinary state of a fresh install. Collapsing it into the unreadable - * case would have refused every preview on a machine with no config file, including CI. - * - * A file that exists and cannot be parsed is genuinely unprovable, and that still fails closed. - */ - const { source, error } = snapshot.diagnostics; - return source === "default" && error === null ? "absent" : null; -} - /** Test seam: a fresh process has no snapshot, and suites must be able to reproduce that. */ export function resetExportSnapshotForTests(): void { lastExportSnapshot = null; @@ -411,7 +392,7 @@ export function previewExportSnapshot( // believing it held the identity of another. const snapshot = lastExportSnapshot; if (snapshot === null) return null; - const configKey = exportSnapshotKey(config); + const configKey = admittedConfigIdentity(config); if (configKey === null || snapshot.key !== configKey) return null; // A completed discovery retires the snapshot: the configuration is unchanged, but the models it // resolves to are not the ones this roster was built from. diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 113b49164e9..b1933fbd853 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -60,7 +60,11 @@ Pending-prune maintenance and client transaction recovery each write, so they ar with no default that a preview passes as false. And a preview takes its model roster from the retained export snapshot instead of gathering one, because discovery refreshes credentials and writes the provider cache. With no usable snapshot the request is refused, which covers a cold -process and equally a snapshot retired because the configuration or the provider cache moved. The +process and equally a snapshot retired because the configuration or the provider cache moved. A +roster is identified by both the configuration file it was admitted under and the in-memory +configuration it was built from (`src/config/admitted-identity.ts`), captured before the gather and +required to be unchanged after it, so a configuration edited mid-load leaves no snapshot rather +than one recorded under an identity its rows never had. The Integrations collection read populates one when discovery succeeds and the configuration can be identified, so the page an operator opens before confirming anything is the usual way back rather than a guarantee. diff --git a/structure/runtime.md b/structure/runtime.md index d5053cc74c0..e782250e9ee 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -98,6 +98,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/config/paths.ts` | Resolves `OPENCODEX_HOME`, `config.json`, and owner-only directory hardening. | | `src/config/atomic-write.ts` | Shared synchronous/asynchronous temp-harden-rename writer and residual-temp failure contract. The temp is ACL-hardened before it holds a byte and again before the rename, both `required: true`; the second call is a memo hit rather than a second icacls sequence because the writer re-asserts descriptor/path identity after the content write and re-attributes the harden through `reattributeHardenedSecretPath`. Windows takes no `chmod` on that path — it sets the read-only attribute, not the DACL, and its ChangeTime bump is what used to retire the memo. | | `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. | +| `src/config/admitted-identity.ts` | Which configuration a derived artifact was built from: the admission snapshot's file digest paired with a canonical structural digest of the in-memory configuration object. Neither term alone is enough — the file cannot see which object a caller is holding, and the object cannot say which file it came from. Null is unprovable and every caller fails closed on it. | | `src/server/ports.ts` | Owns bind availability and ephemeral-port selection. Temporary probes dispose accepted peers and wait for listener close before reporting success. | | `src/cli/status.ts` / `src/cli/status-probes.ts` | Status snapshot assembly and the shared read-only health/stale-process probes used by status and doctor. Probe evidence keeps recorded-port choice, before/after snapshots and per-call timer cleanup together. | | `src/cli/doctor.ts` | Read-only environment diagnostics. Sections print through `console.log`; each is a `collect*` helper above `runDoctor` so it is testable without the command. Only a `FAIL`-level condition records a doctor failure — a degraded-but-working install must not break a green pipeline. `collectDefaultModelExposure` compares Codex's root `model` pin against the exposed set, which it READS rather than recomputes: the running proxy's `/v1/models` when one answers, otherwise the on-disk catalog's `visibility: "list"` slugs. It reports exposed, not exposed, or undeterminable, and never the second when it could not read either surface. | diff --git a/tests/server/management-model-roster-gather-race.test.ts b/tests/server/management-model-roster-gather-race.test.ts index be1a64e0f03..91b33b41c68 100644 --- a/tests/server/management-model-roster-gather-race.test.ts +++ b/tests/server/management-model-roster-gather-race.test.ts @@ -15,15 +15,12 @@ import type { ExportModel } from "../../src/clients/config-export"; import type { OcxConfig } from "../../src/types"; /** - * Two publications can interleave inside one export load, and the roster it retains has to be - * identified by the revision that stood when its rows were chosen rather than by whatever is - * current when it finishes. + * Things can change inside one export load, and what it retains has to be identified by the state + * its rows were chosen under rather than by whatever is current when it finishes. * - * The interleaving here is real, not simulated: the load runs a two-provider gather, alpha answers - * immediately and beta is held open, so the load is still inside its own await after alpha has - * chosen and published its rows. A competing publication lands in that window. Sampling the - * revision at retention time would record alpha's rows under the competing publication's identity, - * and every later check would then agree the preview was current. + * The interleaving here is real. The load runs a two-provider gather, alpha answers immediately and + * beta is held open, so the load is still inside its own await after alpha has chosen, published + * and stamped its rows. Whatever the case does in that window lands where it matters. */ const ALPHA_HOST = "https://alpha.gather-race.test/v1"; const BETA_HOST = "https://beta.gather-race.test/v1"; @@ -72,6 +69,31 @@ async function afterAlphaChoseItsRows(): Promise { throw new Error("alpha never published its rows"); } +/** + * Runs the supplied step in the window where alpha has chosen and beta has not answered, then lets + * the load finish. + * + * Beta is released in a finally and the load is awaited there too, because a failure inside the + * window would otherwise leave a gather in flight while teardown clears the caches it is about to + * publish into, and the next case would inherit it. + */ +async function loadWhileBetaIsHeld( + during: (config: OcxConfig) => void | Promise, +): Promise<{ config: OcxConfig; exported: readonly ExportModel[] }> { + const release = holdBetaOpen(); + const config = raceConfig(); + const load = loadExportModels(config); + const settled = load.catch(() => undefined); + try { + await afterAlphaChoseItsRows(); + await during(config); + } finally { + release(); + await settled; + } + return { config, exported: await load }; +} + const alphaRows = (models: readonly ExportModel[]): string[] => models.filter(model => model.provider === "alpha").map(model => model.namespaced); @@ -96,38 +118,44 @@ afterEach(() => { removeTreeWithRetry(configRoot); }); -describe("an export load retains the revision its rows were chosen under", () => { +describe("an export load retains what its rows were chosen under", () => { test("a load nothing competes with leaves a preview that serves exactly what it returned", async () => { - const release = holdBetaOpen(); - const config = raceConfig(); - const load = loadExportModels(config); - await afterAlphaChoseItsRows(); - release(); - const exported = await load; + const { config, exported } = await loadWhileBetaIsHeld(() => {}); expect(alphaRows(exported)).toEqual(["alpha/alpha-chosen"]); - // The control for the case below. Without it, a preview refused for some unrelated reason - // would look like the race being closed. + // The control for the two cases below. Without it, a preview refused for some unrelated reason + // would look like a race being closed. expect(previewExportModels(config)).toEqual(exported); }); test("rows chosen before a competing publication are never served as current", async () => { - const release = holdBetaOpen(); - const config = raceConfig(); - const load = loadExportModels(config); - await afterAlphaChoseItsRows(); - // The competing flight publishes here: alpha has chosen and stamped its rows, and this load - // has not reached its retention yet because beta is still open. - expect(setCached("alpha", [{ id: "alpha-superseded", provider: "alpha" }])).toBe(true); - release(); - const exported = await load; + const { config, exported } = await loadWhileBetaIsHeld(() => { + // The competing flight publishes here: alpha has chosen and stamped its rows, and this load + // has not reached its retention yet because beta is still open. + expect(setCached("alpha", [{ id: "alpha-superseded", provider: "alpha" }])).toBe(true); + }); // The load still returns the rows it fetched. Those rows are not wrong; they are simply no // longer the current ones. expect(alphaRows(exported)).toEqual(["alpha/alpha-chosen"]); - // What must not survive is the claim that they are current. Sampling the revision at - // retention would have read the competing publication's, matched it on the way out, and - // handed a preview these rows under an identity they never had. + // What must not survive is the claim that they are current. Sampling the revision at retention + // would have read the competing publication's, matched it on the way out, and handed a preview + // these rows under an identity they never had. + expect(previewExportModels(config)).toBeNull(); + }); + + test("a configuration edited during the load leaves no snapshot at all", async () => { + const { config, exported } = await loadWhileBetaIsHeld(edited => { + // Management routes edit the live configuration in place. This one lands after the gather + // has chosen its providers, so the rows cannot include the new one and never could have. + edited.providers.gamma = { adapter: "openai-chat", baseUrl: "https://gamma.gather-race.test/v1", models: [] }; + }); + + expect(alphaRows(exported)).toEqual(["alpha/alpha-chosen"]); + expect(exported.some(model => model.provider === "gamma")).toBe(false); + // Retaining would record this roster under a configuration that has a provider it does not + // cover, and a later preview would serve it as the full picture. Refusing costs one ordinary + // load and is the only honest answer. expect(previewExportModels(config)).toBeNull(); }); }); diff --git a/tests/server/management-model-roster.test.ts b/tests/server/management-model-roster.test.ts index edd42cf55e0..4b950b1565a 100644 --- a/tests/server/management-model-roster.test.ts +++ b/tests/server/management-model-roster.test.ts @@ -99,6 +99,27 @@ describe("a preview reads only a roster an authoritative load already finished", expect(previewExportModels(added)).toBeNull(); }); + test("a preview is refused to a caller holding a different in-memory configuration", async () => { + resetExportSnapshotForTests(); + await loadExportModels(CONFIG, SUPPLIED); + expect(previewExportModels(CONFIG)).not.toBeNull(); + + // Same configuration file, different configuration object. The file digest alone cannot see + // this, and neither could a hand-written list of the fields that seemed to matter: fastRows + // changes what the export projection emits and no such list ever mentioned it. + expect(previewExportModels({ ...CONFIG, fastRows: false } as OcxConfig)).toBeNull(); + + // A difference that changes nothing about the roster is still a different configuration. The + // identity is structural precisely so it does not depend on anyone deciding which fields count. + expect(previewExportModels({ ...CONFIG, shutdownTimeoutMs: 7_000 } as OcxConfig)).toBeNull(); + + // A separate object describing the same configuration is the same configuration: this is an + // identity of the content, not of the object a caller happens to be holding. + const equivalent = previewExportModels({ ...CONFIG } as OcxConfig); + expect(equivalent).not.toBeNull(); + expect(equivalent).toEqual(previewExportModels(CONFIG)); + }); + test("a completed discovery retires the snapshot even though the config never changed", async () => { resetExportSnapshotForTests(); await loadExportModels(CONFIG, SUPPLIED); From 9848c5049229d5ce6fd6752f8002bf0fb5ca11fc Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 04:05:49 +0900 Subject: [PATCH 63/83] fix(management): gather and project one admitted configuration, then prove it A roster was identified by the configuration file and, since the previous commit, by the configuration object as well. Both terms were read around the gather rather than carried through it, so one pass could still gather under the state it found and project under whatever the resident object had become, and a file that was readable but did not load cleanly was accepted on the strength of its digest alone, before anything looked at what the parse produced. An export load now detaches the configuration it is about to use and runs the whole authoritative pass against that copy: the gather, the projection and the visibility filter all see one state. The detachment is a real copy rather than a serialization, because serializing drops functions silently and invokes getters and toJSON, which would let the object decide what the check sees. An accessor, a cycle, and any value JSON could not have produced are refused rather than read. A provider's fetch executor is the one field that is not data; it is carried by reference so the transport still works and swapping it invalidates the binding. Before anything is retained the admission is revalidated against three things that move independently: the file, the resident object, and the detached copy a consumer was handed. Only then does the roster become preview authority. The binding deliberately does not require the resident configuration to equal the file. The proxy routes by what it is holding, so that is what a preview and the mutation it authorizes must describe, and a file edited under a process that has not adopted it is supported rather than broken: live reconciliation retains live changes and the active listener binding on purpose and can persist a binding the resident object does not have. Demanding equality would make preview permanently unavailable on those configurations while proving nothing about the rows. Pending initial selection is resolved against the live configuration before the admission is taken, so it still adopts and persists where it always did rather than committing into a private copy. The identity a caller carries between a preview and its confirmation is now process-local and opaque. It used to be the configuration file's digest with a counter appended, which handed a dashboard a fingerprint of the operator's configuration dressed as a token. local checks: NOT RUN --- src/config/admitted-identity.ts | 292 +++++++++++++++--- src/server/management/model-rows.ts | 107 ++++--- structure/clients/integrations.md | 13 +- structure/runtime.md | 2 +- ...anagement-model-roster-gather-race.test.ts | 49 ++- 5 files changed, 366 insertions(+), 97 deletions(-) diff --git a/src/config/admitted-identity.ts b/src/config/admitted-identity.ts index d1cc4817623..7696e69102d 100644 --- a/src/config/admitted-identity.ts +++ b/src/config/admitted-identity.ts @@ -1,77 +1,277 @@ -import { createHash } from "node:crypto"; import { readConfigAdmissionSnapshot } from "./diagnostics"; +import { getConfigPath } from "./paths"; import type { OcxConfig } from "../types"; /** - * Which configuration a derived artifact was built from, or null when that cannot be established. + * A configuration a roster may be built from, detached from the object the caller holds. * - * Two questions have to be answered together, and answering either one alone is what made the - * earlier versions of this wrong. The file digest says WHICH FILE the configuration came from, and - * it is the only thing that can see a field nobody thought to list. It cannot see that the object - * a caller is holding is the parse of that file: callers pass an in-memory configuration around, - * routes mutate it in place, and a roster built from one in-memory state would otherwise be - * retained under a key that only describes bytes on disk. + * The detachment is the point. A gather suspends, and while it is suspended a management route can + * edit the resident configuration in place; gathering from one state and projecting from another + * would produce rows that belong to neither. Every authoritative pass uses this copy from + * beginning to end, so what the resident object does in the meantime becomes a question about + * whether the result may be retained rather than a question about what the result is. * - * So the identity carries both terms. The second is a digest of the object itself, taken - * structurally rather than from a list of fields, for the same reason the first one exists: a list - * is only as complete as whoever last thought about it. + * What this deliberately does NOT do is require the resident configuration to equal the file on + * disk. The proxy routes by the configuration it is holding, so that is the configuration a + * preview and the mutation it authorizes must both describe. A file the operator has edited and + * the process has not adopted is a supported state rather than a fault: live reconciliation + * (src/config/live-reconcile.ts) merges persisted state while deliberately retaining live changes + * and the active listener binding, and can persist a binding the resident object does not have. + * Demanding equality would make preview permanently unavailable on exactly those configurations + * while proving nothing about the rows, which come from the resident object either way. + */ +export interface ExportConfigAdmission { + readonly config: OcxConfig; +} + +/** + * What was true when an admission was captured, kept here rather than on the admission object. + * + * None of it is data a consumer has any business reading: the file term is a digest of the + * operator's configuration file and the canonical form contains their credentials. Holding it in a + * module WeakMap means an admission can be passed around, and even serialized by a careless + * caller, without carrying any of it. + */ +interface AdmissionEvidence { + readonly path: string; + readonly file: string; + readonly data: string; + readonly executors: ReadonlyMap; +} + +const evidence = new WeakMap(); + +/** + * Detach the configuration a roster is about to be built from, and record what it was. + * + * Two things are recorded because two things can move independently. The resident configuration is + * what the rows are derived from, so its complete structure is captured: structurally rather than + * as a list of the fields that seemed to matter, because such a list is only as complete as + * whoever last thought about it and this one had already missed export-affecting configuration. + * The configuration file is recorded beside it, so a roster does not outlive an operator editing + * the configuration under a process that has not adopted it yet. * - * Null means unprovable and every caller must fail closed on it. Refusing to serve a preview is - * recoverable; serving a roster that belongs to a configuration the user no longer has is not. + * Null when the file cannot be read, when it is there but the loader would have had to salvage it, + * and when the configuration object cannot be copied as plain data. Every caller fails closed on + * it: refusing a preview costs an ordinary load, and serving one that describes a configuration + * nobody has costs a file the operator did not ask for. */ -export function admittedConfigIdentity(config: OcxConfig): string | null { - const file = admittedConfigFileTerm(); +export function captureExportConfigAdmission(live: OcxConfig): ExportConfigAdmission | null { + const path = getConfigPath(); + const file = admittedFileTerm(); if (file === null) return null; - const held = heldConfigDigest(config); - return held === null ? null : `${file}:${held}`; + const resident = detachConfig(live); + if (resident === null) return null; + const admission: ExportConfigAdmission = { config: withExecutors(resident) }; + evidence.set(admission, { path, file, data: canonical(resident.data), executors: resident.executors }); + return admission; } -function admittedConfigFileTerm(): string | null { +/** + * Whether an admission still describes the configuration in hand and the file it was taken beside. + * + * Three things are checked because three things can move: the file can be rewritten, the resident + * object can be edited in place, and a consumer of the detached copy can mutate what it was given. + * The last matters as much as the others, because a pass that edited its own input and then + * published would be retaining a roster under a state that no longer describes even that input. + * + * Passive: it reads the configuration file and nothing else. No credential is resolved, no + * provider is contacted, no path is hardened and nothing is written. + */ +export function isExportConfigAdmissionCurrent(admission: ExportConfigAdmission, live: OcxConfig): boolean { + const captured = evidence.get(admission); + if (captured === undefined) return false; + // A different configuration home is a different question, not a stale answer to this one. + if (getConfigPath() !== captured.path) return false; + const file = admittedFileTerm(); + if (file === null || file !== captured.file) return false; + const resident = detachConfig(live); + if (resident === null || canonical(resident.data) !== captured.data) return false; + if (!sameExecutors(resident.executors, captured.executors)) return false; + const working = detachConfig(admission.config); + return working !== null + && canonical(working.data) === captured.data + && sameExecutors(working.executors, captured.executors); +} + +/** + * The configuration file as an opaque term: its exact bytes, or the distinguished absence of one. + * + * Null for a file that cannot be read, because then a later read cannot tell whether it changed. + * Null too for one that is there and does not load cleanly: the resident configuration is then a + * salvage of it rather than its contents, and binding a roster to bytes that describe something + * else records a relationship that does not hold. Before this, a digest was accepted ahead of any + * look at what the parse produced. + * + * Absence is a configuration rather than the lack of one. No file means defaults, which is an + * ordinary fresh install and the ordinary state in CI. + */ +function admittedFileTerm(): string | null { const snapshot = readConfigAdmissionSnapshot(); - if (snapshot.contentSha256 !== null) return snapshot.contentSha256; - /* - * A file that is not there is a well-defined configuration, not an unprovable one: it means - * defaults, and it is the ordinary state of a fresh install. Collapsing it into the unreadable - * case would refuse every preview on a machine with no config file, including CI. - * - * A file that exists and cannot be parsed is genuinely unprovable, and that still fails closed. - */ const { source, error } = snapshot.diagnostics; + if (snapshot.kind === "read") return source === "file" && error === null ? snapshot.contentSha256 : null; return source === "default" && error === null ? "absent" : null; } +interface DetachedConfig { + readonly data: Record; + readonly executors: ReadonlyMap; +} + /** - * A digest of the configuration object as it stands right now. + * A plain-data copy of a configuration, plus the transport executors kept out of it. + * + * Serializing with JSON is not a safe copier here and using it as one would have been a quiet + * hole: it drops functions without saying so, and it invokes getters and toJSON, so an object + * carrying either could decide what this sees. This walks own enumerable string keys, refuses an + * accessor rather than calling it, refuses any value JSON could not have produced, and refuses a + * cycle. * - * Structural, so it moves for any change rather than for a chosen set of them, and canonical, so - * two objects describing the same configuration cannot digest differently because their keys were - * inserted in a different order. + * The one exception is a provider's fetch executor, which a caller owns and the gather uses + * instead of the global transport. It is kept by reference for the detached copy and compared by + * reference afterwards, so replacing it invalidates the admission while it is never serialized. * - * Nothing derived from this is logged or serialized: the digest travels, the configuration does - * not. Null when the object cannot be canonicalized at all, which fails closed. + * Symbol keys are skipped rather than refused. The configuration carries process bookkeeping on + * symbols by convention, and none of it is the operator's configuration. */ -function heldConfigDigest(config: OcxConfig): string | null { +function detachConfig(live: OcxConfig): DetachedConfig | null { + const executors = new Map(); + const root = live as unknown; + if (!isPlainObject(root)) return null; + const data: Record = {}; + for (const key of ownDataKeys(root)) { + if (key === null) return null; + const value = root[key]; + if (value === undefined) continue; + if (key !== "providers") { + const copied = plainCopy(value, new Set()); + if (copied === REFUSED) return null; + data[key] = copied; + continue; + } + if (!isPlainObject(value)) return null; + const providers: Record = {}; + for (const name of ownDataKeys(value)) { + if (name === null) return null; + const provider = value[name]; + if (provider === undefined) continue; + if (!isPlainObject(provider)) return null; + const copiedProvider: Record = {}; + for (const field of ownDataKeys(provider)) { + if (field === null) return null; + const fieldValue = provider[field]; + if (fieldValue === undefined) continue; + if (field === "fetch") { + if (typeof fieldValue !== "function") return null; + executors.set(name, fieldValue); + continue; + } + const copied = plainCopy(fieldValue, new Set()); + if (copied === REFUSED) return null; + copiedProvider[field] = copied; + } + providers[name] = copiedProvider; + } + data[key] = providers; + } + return { data, executors }; +} + +/** The copy the gather actually runs against, with the executors put back by reference. */ +function withExecutors(detached: DetachedConfig): OcxConfig { + const config = copyOfData(detached.data); + const providers = config.providers; + if (isPlainObject(providers)) { + for (const [name, executor] of detached.executors) { + const provider = providers[name]; + if (isPlainObject(provider)) provider.fetch = executor; + } + } + return config as unknown as OcxConfig; +} + +function sameExecutors(left: ReadonlyMap, right: ReadonlyMap): boolean { + if (left.size !== right.size) return false; + for (const [name, executor] of left) { + if (!right.has(name) || right.get(name) !== executor) return false; + } + return true; +} + +const REFUSED = Symbol("refused"); + +/** Own enumerable string keys, with null in the position of any key that is an accessor. */ +function ownDataKeys(value: Record): Array { + return Object.keys(value).map(key => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && descriptor.get === undefined && descriptor.set === undefined ? key : null; + }); +} + +function plainCopy(value: unknown, seen: Set): unknown { + if (value === null) return null; + const type = typeof value; + if (type === "string" || type === "boolean") return value; + if (type === "number") return Number.isFinite(value as number) ? value : REFUSED; + if (type !== "object") return REFUSED; + const object = value as object; + if (seen.has(object)) return REFUSED; + seen.add(object); try { - return createHash("sha256").update(JSON.stringify(canonical(config))).digest("hex"); - } catch { - return null; + if (Array.isArray(object)) { + const copied: unknown[] = []; + for (const entry of object) { + // A hole or an explicit undefined in an array serializes as null, which is what a reader of + // the persisted configuration would find in that position. + if (entry === undefined) { copied.push(null); continue; } + const item = plainCopy(entry, seen); + if (item === REFUSED) return REFUSED; + copied.push(item); + } + return copied; + } + if (!isPlainObject(object)) return REFUSED; + const copied: Record = {}; + for (const key of ownDataKeys(object)) { + if (key === null) return REFUSED; + const entry = object[key]; + if (entry === undefined) continue; + const item = plainCopy(entry, seen); + if (item === REFUSED) return REFUSED; + copied[key] = item; + } + return copied; + } finally { + seen.delete(object); } } +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** A second copy, so what is compared later is never the object handed to a consumer. */ +function copyOfData(data: Record): Record { + const copied = plainCopy(data, new Set()); + return copied === REFUSED ? {} : copied as Record; +} + /** - * Key-sorted entry pairs rather than objects, because JSON.stringify preserves insertion order and - * two configurations that differ only in that order are the same configuration. - * - * A value JSON cannot carry (a function a caller attached, an explicit undefined) becomes null - * here. That is deliberate: such a value is not part of the configuration's content, and treating - * it as absent would make an added one invisible. + * Key-sorted entry pairs rather than objects, because property order is observable through + * serialization and two configurations that differ only in it are the same configuration. */ -function canonical(value: unknown): unknown { - if (Array.isArray(value)) return value.map(canonical); +function canonical(value: unknown): string { + return JSON.stringify(sorted(value)); +} + +function sorted(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sorted); if (value !== null && typeof value === "object") { return Object.keys(value as Record) .sort() - .map(key => [key, canonical((value as Record)[key])]); + .map(key => [key, sorted((value as Record)[key])]); } - return typeof value === "function" || value === undefined ? null : value; + return value; } diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 22ac00510e2..bcda3e3f89d 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -10,7 +10,11 @@ */ import type { CatalogModel } from "../../codex/catalog"; import { observeModelCacheRevision } from "../../codex/model-cache"; -import { admittedConfigIdentity } from "../../config/admitted-identity"; +import { + captureExportConfigAdmission, + isExportConfigAdmissionCurrent, + type ExportConfigAdmission, +} from "../../config/admitted-identity"; import { catalogModelSlug, filterCatalogVisibleModels, @@ -30,7 +34,7 @@ import { routedSlug, slugEquals } from "../../providers/slug-codec"; import type { OcxConfig } from "../../types"; import { ensureCodexEntitlementFreshness } from "../../codex/model-entitlements"; import { fetchAllModels } from "./shared"; -import { initialModelSelectionPending } from "../../providers/initial-model-selection"; +import { initialModelSelectionPending, pendingModelSelectionProviders } from "../../providers/initial-model-selection"; import { catalogFastRowEligible, fastRowId } from "../fast-row"; import { knownEffortRowIds } from "../effort-row"; @@ -257,22 +261,37 @@ export async function loadExportModels( config: OcxConfig, models?: readonly CatalogModel[], ): Promise { - // Which configuration these rows are about to be chosen under, read before the gather starts. - // Reading it afterwards would let a configuration edit that landed while the gather was awaiting - // be attributed to a roster built under the previous one, which is the same substitution the - // per-provider revision below prevents one layer down. - const admittedAtChoice = admittedConfigIdentity(config); + // Initial selection adopts into the live configuration and persists it, so it has to finish + // before anything is admitted. Admitting first would bind this roster to bytes the same load is + // about to rewrite, and finalizing against a detached copy would adopt the choices into the copy + // while leaving the live configuration pending. + if (models === undefined && pendingModelSelectionProviders(config).size > 0) { + const { resolvePendingInitialModelSelection } = await import("../../providers/initial-model-selection-runtime"); + await resolvePendingInitialModelSelection(config); + } + // The configuration this pass will use from beginning to end, proved to be the one on disk. + // Without it there is nothing that may be retained, and the caller still gets its rows: only the + // preview authority is withheld. + const admission = captureExportConfigAdmission(config); + const admitted = admission?.config ?? config; // The gather stamps each provider as it chooses its rows, so the roster and the revisions that // vouch for it come from the same moment. Sampling afterwards would let a concurrent flight's // publication be recorded against rows it never produced. const gathered = new Map(); - const rows = await listManagementModelRows( - config, - models === undefined ? { providerContentRevisions: gathered } : { models }, - ); + // Gathering here rather than through the shared fetch is what keeps the detached copy out of the + // initial-selection finalizer: the projection below takes a roster, and that branch performs no + // discovery and no configuration write. The entitlement refresh keeps the budget it has always + // had, and runs alongside as it did inside the projection. + const roster = models === undefined + ? (await Promise.all([ + (await import("../../codex/catalog")).gatherRoutedModels(admitted, { providerContentRevisions: gathered }), + ensureCodexEntitlementFreshness(admitted, { waitMs: 3_000 }), + ]))[0] + : models; + const rows = await listManagementModelRows(admitted, { models: roster }); // Management deliberately lists the full roster so hidden models can be enabled. // A client picker must also honor the provider selection, not just its blocklist. - const visibleRouted = new Set(filterCatalogVisibleModels(rows.filter(row => !row.native), config)); + const visibleRouted = new Set(filterCatalogVisibleModels(rows.filter(row => !row.native), admitted)); const exported = rows.filter(row => !row.disabled && (row.native || visibleRouted.has(row))).map(toExportModel); // Retain the FINAL projection, not an input to it. A preview that rebuilt from raw provider // caches would miss static and forward providers, which never populate one, and would skip the @@ -280,22 +299,18 @@ export async function loadExportModels( // A deep clone, not a frozen view of the caller's array. Freezing the array alone left the model // objects shared, so a caller mutating one in place would have silently rewritten the roster a // later preview plans against, and the fingerprint would have moved with it. - // Provable, and still the same one the rows were chosen under. Comparing rather than re-reading - // is what makes this fail closed: a configuration that moved during the load leaves no snapshot - // rather than leaving one recorded under an identity its rows never had. - const configKey = admittedAtChoice !== null && admittedConfigIdentity(config) === admittedAtChoice - ? admittedAtChoice - : null; - // No provable configuration identity means no honest snapshot to keep. - if (configKey === null) { + // Still the configuration these rows were chosen under, on disk and in hand alike. Revalidating + // rather than re-reading an identity is what makes this fail closed: a configuration that moved + // during the load leaves no snapshot rather than one recorded under a state its rows never had. + if (admission === null || !isExportConfigAdmissionCurrent(admission, config)) { lastExportSnapshot = null; return exported; } lastExportSnapshot = { - key: configKey, + admission, // Prefer the revisions the gather stamped; fall back to observing only when the roster was // supplied and no gather happened, where there is nothing tighter to use. - cacheStamp: gathered.size > 0 ? stampFrom(config, gathered) : modelCacheStamp(config), + cacheStamp: gathered.size > 0 ? stampFrom(admitted, gathered) : modelCacheStamp(admitted), generation: ++exportSnapshotGeneration, models: Object.freeze(structuredClone(exported)), }; @@ -305,21 +320,39 @@ export async function loadExportModels( /** * The completed export roster from the last ordinary load, if it still describes this config. * - * A preview may not gather, so it reads only what an authoritative load already finished. The key - * names both the configuration file the roster was admitted under and the in-memory configuration - * it was actually built from, so changing either one retires the snapshot rather than letting a - * preview plan against a roster the user no longer has. + * A preview may not gather, so it reads only what an authoritative load already finished. The + * admission it carries proved, when the roster was built, that the configuration in hand was the + * one on disk; a later read repeats that proof, so a rewritten file, an edited resident object or + * a mutated working copy each retire the snapshot rather than letting a preview plan against a + * configuration nobody has. * - * A cold process has no snapshot and the caller answers a bounded refusal. Recovery is the - * ordinary flow rather than a special step: the Integrations collection read calls - * `loadExportModels`, so the page an operator must open before confirming anything is the page - * that populates this. + * A cold process has no snapshot and the caller answers a bounded refusal, and an ordinary load + * populates one: the Integrations collection read calls `loadExportModels`, so the page an + * operator opens before confirming anything is usually the page that fills this in. That is not a + * repair for every refusal. A configuration that disagrees with its file keeps refusing however + * many times the page is opened, because nothing here reloads or reconciles anything; once the + * two agree again the next ordinary read rebuilds the snapshot by itself. */ let lastExportSnapshot: - | { key: string; cacheStamp: string; generation: number; models: readonly ExportModel[] } + | { admission: ExportConfigAdmission; cacheStamp: string; generation: number; models: readonly ExportModel[] } | null = null; let exportSnapshotGeneration = 0; +/** + * A process-local prefix for the roster identity a caller carries between a preview and the + * mutation that confirms it. + * + * The identity used to be the configuration digest with a counter appended, which handed a + * dashboard an opaque-looking string that was in fact a fingerprint of the operator's + * configuration file. It only has to be unforgeable within this process and distinct across + * restarts, so it says nothing about the configuration at all. + */ +const rosterIdentityPrefix = `r${Math.trunc(Math.random() * 0xffffffff).toString(36)}`; + +function rosterIdentity(generation: number): string { + return `${rosterIdentityPrefix}:${generation}`; +} + /** * Where the gathered half of the roster stands, observed without changing it. * @@ -361,10 +394,9 @@ function stampFrom(config: OcxConfig, gathered: ReadonlyMap): st export function exportSnapshotIdentity(config: OcxConfig): string | null { const snapshot = lastExportSnapshot; if (snapshot === null) return null; - const configKey = admittedConfigIdentity(config); - if (configKey === null || snapshot.key !== configKey) return null; + if (!isExportConfigAdmissionCurrent(snapshot.admission, config)) return null; if (snapshot.cacheStamp !== modelCacheStamp(config)) return null; - return `${snapshot.key}:${snapshot.generation}`; + return rosterIdentity(snapshot.generation); } /** Test seam: a fresh process has no snapshot, and suites must be able to reproduce that. */ @@ -392,8 +424,9 @@ export function previewExportSnapshot( // believing it held the identity of another. const snapshot = lastExportSnapshot; if (snapshot === null) return null; - const configKey = admittedConfigIdentity(config); - if (configKey === null || snapshot.key !== configKey) return null; + // The roster was built from a configuration proved to be the one on disk; this asks whether both + // are still that same configuration, and reads nothing but the file to answer. + if (!isExportConfigAdmissionCurrent(snapshot.admission, config)) return null; // A completed discovery retires the snapshot: the configuration is unchanged, but the models it // resolves to are not the ones this roster was built from. if (snapshot.cacheStamp !== modelCacheStamp(config)) return null; @@ -402,7 +435,7 @@ export function previewExportSnapshot( // going anywhere near this module. return { models: structuredClone(snapshot.models) as readonly ExportModel[], - identity: `${snapshot.key}:${snapshot.generation}`, + identity: rosterIdentity(snapshot.generation), }; } diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index b1933fbd853..605ff505717 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -60,11 +60,14 @@ Pending-prune maintenance and client transaction recovery each write, so they ar with no default that a preview passes as false. And a preview takes its model roster from the retained export snapshot instead of gathering one, because discovery refreshes credentials and writes the provider cache. With no usable snapshot the request is refused, which covers a cold -process and equally a snapshot retired because the configuration or the provider cache moved. A -roster is identified by both the configuration file it was admitted under and the in-memory -configuration it was built from (`src/config/admitted-identity.ts`), captured before the gather and -required to be unchanged after it, so a configuration edited mid-load leaves no snapshot rather -than one recorded under an identity its rows never had. The +process and equally a snapshot retired because the configuration or the provider cache moved. The +roster is gathered and projected from a detached copy of the configuration +(`src/config/admitted-identity.ts`) taken before the gather, so an edit that lands mid-load changes +neither half of the result; the same admission is revalidated against the resident object and the +configuration file before anything is retained, so such an edit leaves no snapshot rather than one +recorded under a state its rows never had. The identity a caller carries between a preview and the +mutation that confirms it is process-local and opaque, and describes nothing about the +configuration. The Integrations collection read populates one when discovery succeeds and the configuration can be identified, so the page an operator opens before confirming anything is the usual way back rather than a guarantee. diff --git a/structure/runtime.md b/structure/runtime.md index e782250e9ee..142ce6ce48a 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -98,7 +98,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/config/paths.ts` | Resolves `OPENCODEX_HOME`, `config.json`, and owner-only directory hardening. | | `src/config/atomic-write.ts` | Shared synchronous/asynchronous temp-harden-rename writer and residual-temp failure contract. The temp is ACL-hardened before it holds a byte and again before the rename, both `required: true`; the second call is a memo hit rather than a second icacls sequence because the writer re-asserts descriptor/path identity after the content write and re-attributes the harden through `reattributeHardenedSecretPath`. Windows takes no `chmod` on that path — it sets the read-only attribute, not the DACL, and its ChangeTime bump is what used to retire the memo. | | `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. | -| `src/config/admitted-identity.ts` | Which configuration a derived artifact was built from: the admission snapshot's file digest paired with a canonical structural digest of the in-memory configuration object. Neither term alone is enough — the file cannot see which object a caller is holding, and the object cannot say which file it came from. Null is unprovable and every caller fails closed on it. | +| `src/config/admitted-identity.ts` | Which configuration a derived artifact was built from. Detaches the resident configuration as plain data so one pass cannot gather under one state and project under another, and records the complete structure beside the configuration file's bytes. Refuses an accessor, a cycle, a value JSON could not produce, an unreadable file and a file the loader would have had to salvage; a provider's `fetch` executor is the one non-data field, held and compared by reference. It does NOT require the resident configuration to equal the file: the proxy routes by what it holds, and live reconciliation retains live changes and the active listener binding on purpose. Evidence stays in a module WeakMap, never on the config and never in a response. | | `src/server/ports.ts` | Owns bind availability and ephemeral-port selection. Temporary probes dispose accepted peers and wait for listener close before reporting success. | | `src/cli/status.ts` / `src/cli/status-probes.ts` | Status snapshot assembly and the shared read-only health/stale-process probes used by status and doctor. Probe evidence keeps recorded-port choice, before/after snapshots and per-call timer cleanup together. | | `src/cli/doctor.ts` | Read-only environment diagnostics. Sections print through `console.log`; each is a `collect*` helper above `runDoctor` so it is testable without the command. Only a `FAIL`-level condition records a doctor failure — a degraded-but-working install must not break a green pipeline. `collectDefaultModelExposure` compares Codex's root `model` pin against the exposed set, which it READS rather than recomputes: the running proxy's `/v1/models` when one answers, otherwise the on-disk catalog's `visibility: "list"` slugs. It reports exposed, not exposed, or undeterminable, and never the second when it could not read either surface. | diff --git a/tests/server/management-model-roster-gather-race.test.ts b/tests/server/management-model-roster-gather-race.test.ts index 91b33b41c68..baf56f418ff 100644 --- a/tests/server/management-model-roster-gather-race.test.ts +++ b/tests/server/management-model-roster-gather-race.test.ts @@ -144,18 +144,51 @@ describe("an export load retains what its rows were chosen under", () => { expect(previewExportModels(config)).toBeNull(); }); - test("a configuration edited during the load leaves no snapshot at all", async () => { + test("a configuration edited during the load changes neither the rows nor what is retained", async () => { const { config, exported } = await loadWhileBetaIsHeld(edited => { - // Management routes edit the live configuration in place. This one lands after the gather - // has chosen its providers, so the rows cannot include the new one and never could have. - edited.providers.gamma = { adapter: "openai-chat", baseUrl: "https://gamma.gather-race.test/v1", models: [] }; + // Management routes edit the live configuration in place. This edit would remove the row + // below from the projection, which is what makes it visible: the load gathered under the + // configuration it admitted and has to project under that same one. + edited.disabledModels = ["alpha/alpha-chosen"]; }); + // Gathered under the admitted configuration and projected under it too. Reading the live + // object for the projection would have produced rows belonging to neither configuration. expect(alphaRows(exported)).toEqual(["alpha/alpha-chosen"]); - expect(exported.some(model => model.provider === "gamma")).toBe(false); - // Retaining would record this roster under a configuration that has a provider it does not - // cover, and a later preview would serve it as the full picture. Refusing costs one ordinary - // load and is the only honest answer. + // Retaining would record this roster under a configuration that does not produce it. Refusing + // costs one ordinary load and is the only honest answer. expect(previewExportModels(config)).toBeNull(); }); + + test("replacing a provider's transport executor retires the roster it gathered", async () => { + const { config, exported } = await loadWhileBetaIsHeld(() => {}); + expect(previewExportModels(config)).toEqual(exported); + + // The executor is the one part of a provider that is not data, so it is held by reference + // rather than compared as content. A different executor can answer differently, so a roster + // gathered through the previous one is no longer a roster for this configuration. + // The executor is not part of the configuration's declared shape; the transport reads it off + // the provider at call time, which is exactly why it is held by reference here. + (config.providers.alpha as { fetch?: typeof fetch }).fetch = (async () => new Response("{}")) as typeof fetch; + expect(previewExportModels(config)).toBeNull(); + }); + + test("a configuration carrying an accessor is refused without the accessor being read", async () => { + let reads = 0; + const { config, exported } = await loadWhileBetaIsHeld(edited => { + Object.defineProperty(edited, "disabledModels", { + configurable: true, + enumerable: true, + get: () => { reads += 1; return []; }, + }); + }); + + // The rows are still the ordinary answer; only the preview authority is withheld. + expect(alphaRows(exported)).toEqual(["alpha/alpha-chosen"]); + expect(previewExportModels(config)).toBeNull(); + // A copier that serialized the configuration would have called this and then compared whatever + // it chose to return. Refusing without invoking it is the difference between reading a + // configuration and asking it what it would like to be. + expect(reads).toBe(0); + }); }); From 7ecd76f1eaf7ca022018ab4921395e683081b157 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 04:11:31 +0900 Subject: [PATCH 64/83] fix(integrations): freeze the configuration a coordinated write was checked against A coordinated write freezes every mutable resolution seam before its first await, and the configuration was the one it still held by reference. The sequence is plan, await the writer lock, revalidate, then serialize the document from that input, so a management route editing the live configuration in that window left the plan vouching for one configuration while the document described another. The exposure is real rather than theoretical: the client document carries the proxy base URL and its auth shape, both read from this object after the check. The freeze now takes a plain-data copy, so the plan, the revalidation and the document all read one configuration. The copy is the same detacher the export admission uses, which refuses an accessor rather than invoking it and carries a provider transport executor by reference; a configuration it cannot copy is left as the caller's object, which is no worse than the reference that was there before. The regression drives a real coordinated apply and edits the live configuration inside the revalidation, after it has agreed the write may proceed. Reverting the freeze writes the edited host into the client document. local checks: NOT RUN --- scripts/test-layout/layout.json | 1 + src/config/admitted-identity.ts | 18 ++++ src/integrations/writer.ts | 12 ++- .../integrations-writer-frozen-config.test.ts | 83 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 5 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 tests/clients/integrations-writer-frozen-config.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7cf182b843d..d70f0ba3603 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -819,6 +819,7 @@ "integrations-merge.test.ts": "clients", "integrations-serialize.test.ts": "clients", "integrations-state.test.ts": "clients", + "integrations-writer-frozen-config.test.ts": "clients", "integrations-writer.test.ts": "clients", "interactive-confirm.test.ts": "cli", "issue-452-empty-503.test.ts": "codex-integration", diff --git a/src/config/admitted-identity.ts b/src/config/admitted-identity.ts index 7696e69102d..e38170b9487 100644 --- a/src/config/admitted-identity.ts +++ b/src/config/admitted-identity.ts @@ -67,6 +67,24 @@ export function captureExportConfigAdmission(live: OcxConfig): ExportConfigAdmis return admission; } +/** + * A plain-data copy of a configuration for a consumer that must not observe later edits. + * + * The integration writer is the case this exists for. It freezes every other resolution seam + * before its first await and then held the configuration by reference, so a plan checked under one + * configuration could be written from another: the check and the document it authorizes were + * reading the same object at two different moments. One copy taken before the await gives both of + * them the same configuration. + * + * A configuration this cannot copy as plain data is returned as the caller's own object. That is + * deliberate and is never worse than the reference the caller already had; it is the export + * admission above, not this, that has to refuse what it cannot prove. + */ +export function detachedConfigSnapshot(config: OcxConfig): OcxConfig { + const detached = detachConfig(config); + return detached === null ? config : withExecutors(detached); +} + /** * Whether an admission still describes the configuration in hand and the file it was taken beside. * diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 56759294d25..b3fb5980828 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -15,6 +15,7 @@ import { serializeClineDocument, preserveClineSelection } from "./cline-document import { dirname } from "node:path"; import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export"; import { shouldInjectApiAuthHeader } from "../codex/inject"; +import { detachedConfigSnapshot } from "../config/admitted-identity"; import type { OcxConfig } from "../types"; import { defaultIntegrationIO, loadTarget, type IntegrationIO } from "./config-io"; import { @@ -712,7 +713,16 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration const resolvedPaths = input.resolvedPaths ? { ...input.resolvedPaths } : resolveIntegrationPaths(input.clientId, env, home); - return { ...input, env, home, store, io, resolvedPaths }; + /* + * The configuration is a seam like the others, and it was the one still held by reference. A + * coordinated write plans from this input, awaits the writer lock and a revalidation, and only + * then serializes the document from it. A management route editing the live configuration in + * that window would have been checked in one configuration and written from another, which is + * the substitution the fingerprint exists to prevent. Copying it here gives the plan and the + * document one configuration. + */ + const config = detachedConfigSnapshot(input.config); + return { ...input, config, env, home, store, io, resolvedPaths }; } function tryFreezeIntegrationInput(input: IntegrationWriteInput): diff --git a/tests/clients/integrations-writer-frozen-config.test.ts b/tests/clients/integrations-writer-frozen-config.test.ts new file mode 100644 index 00000000000..a26e7ead9f3 --- /dev/null +++ b/tests/clients/integrations-writer-frozen-config.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import type { ExportModel } from "../../src/clients/config-export"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegrationCoordinated } from "../../src/integrations/writer"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/** + * A coordinated write plans from its input, awaits the writer lock and a revalidation, and only + * then serializes the document. Everything else it resolves is frozen before that await, and the + * configuration was the one thing still held by reference: an edit landing in the window meant the + * plan that authorized the write and the document it produced described different configurations. + */ +let home: string; +let store: IntegrationStateStore; +const TEST_ENV = {} as NodeJS.ProcessEnv; + +const MODELS: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000 }, +]; + +const CHECKED_HOST = "127.0.0.1"; +const LATER_HOST = "10.11.12.13"; + +beforeEach(() => { + const base = mkdtempSync(join(tmpdir(), "ocx-writer-frozen-config-")); + home = join(base, "home"); + mkdirSync(home, { recursive: true }); + store = createIntegrationStateStore(join(base, "store", "integrations")); +}); + +afterEach(() => { + removeTreeWithRetry(dirname(home)); +}); + +function installHermes(): string { + const spec = INTEGRATION_CLIENTS.hermes; + mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); + const configPath = spec.configPath(TEST_ENV, home); + mkdirSync(dirname(configPath), { recursive: true }); + return configPath; +} + +test("the configuration a coordinated write checked is the one it writes", async () => { + const configPath = installHermes(); + const config = { + port: 10100, + hostname: CHECKED_HOST, + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + } as unknown as OcxConfig; + + let edited = false; + const result = await applyIntegrationCoordinated( + { clientId: "hermes", models: MODELS, config, port: 10100, store, env: TEST_ENV, home }, + { + revalidate: async () => { + // The revalidation has just agreed that this write may proceed. A management route editing + // the live configuration here is the whole hazard: it is after the check and before the + // document exists. + config.hostname = LATER_HOST; + edited = true; + return null; + }, + }, + ); + + expect(result.ok).toBe(true); + expect(edited).toBe(true); + const written = readFileSync(configPath, "utf8"); + // The document describes the configuration the check passed on. Holding the caller's object + // would have serialized the edit instead, with the plan still vouching for the other one. The + // port form is asserted rather than the checked host itself because a loopback hostname is + // normalized on the way into a client document, while the edited one would arrive verbatim. + expect(written).toContain(":10100/v1"); + expect(written).not.toContain(LATER_HOST); + // And the edit itself is untouched: freezing the input is not an excuse to write it back. + expect(config.hostname).toBe(LATER_HOST); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 948d49d20b2..9b33376b176 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -646,6 +646,7 @@ "integrations-merge.test.ts": "clients", "integrations-serialize.test.ts": "clients", "integrations-state.test.ts": "clients", + "integrations-writer-frozen-config.test.ts": "clients", "integrations-writer.test.ts": "clients", "interactive-confirm.test.ts": "cli", "issue-452-empty-503.test.ts": "codex-integration", From 87e8a63fef0a1df9f52032f9a3d1f2d1382917a2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 04:29:44 +0900 Subject: [PATCH 65/83] fix(integrations): one input from the check to the document, or a refusal Three places still read the caller's objects twice, with an await in between, and the second read is not the one any check approved. The coordinated write froze every resolution seam except the two that carry the document's content. It now copies the configuration and the model roster as plain data before the first await, so a management route editing the live configuration, or a caller editing the model objects it passed, cannot land between the plan and the bytes. Aside is the wider window and the more certain one: it checks the confirmation, awaits the preference write, and only then builds each profile's write input. That preference write edits the live configuration itself, so the document was always serialized from a configuration the check had not seen. The context now captures the configuration when it is created, before the check, and every profile write and policy read in the action uses that copy. The preference write remains a real effect on the live configuration. An input that cannot be copied is refused before the plan, the lock and the document. Returning the caller's object was a copy in name only, and the caller went on to describe it as the input it had checked. The copier moved to src/lib/plain-data.ts and reads through property descriptors everywhere, including array elements. The previous walk iterated arrays normally, so an accessor defined on an index would have been invoked by the check that claimed not to invoke accessors. Three regressions drive real coordinated writes and edit the configuration, the roster and the Aside preference in the window each hazard lives in, plus a refusal case that asserts the accessor is never read. local checks: NOT RUN --- src/config/admitted-identity.ts | 175 +++++------------- src/integrations/aside-profile-context.ts | 24 ++- src/integrations/writer.ts | 35 +++- src/lib/plain-data.ts | 103 +++++++++++ structure/clients/integrations.md | 2 +- structure/runtime.md | 1 + .../integrations-writer-frozen-config.test.ts | 131 ++++++++++++- 7 files changed, 333 insertions(+), 138 deletions(-) create mode 100644 src/lib/plain-data.ts diff --git a/src/config/admitted-identity.ts b/src/config/admitted-identity.ts index e38170b9487..db23465fcae 100644 --- a/src/config/admitted-identity.ts +++ b/src/config/admitted-identity.ts @@ -1,5 +1,6 @@ import { readConfigAdmissionSnapshot } from "./diagnostics"; import { getConfigPath } from "./paths"; +import { canonicalPlainData, copyPlainData, isPlainObject, ownDataKeys } from "../lib/plain-data"; import type { OcxConfig } from "../types"; /** @@ -62,29 +63,13 @@ export function captureExportConfigAdmission(live: OcxConfig): ExportConfigAdmis if (file === null) return null; const resident = detachConfig(live); if (resident === null) return null; - const admission: ExportConfigAdmission = { config: withExecutors(resident) }; - evidence.set(admission, { path, file, data: canonical(resident.data), executors: resident.executors }); + const config = withExecutors(resident); + if (config === null) return null; + const admission: ExportConfigAdmission = { config }; + evidence.set(admission, { path, file, data: canonicalPlainData(resident.data), executors: resident.executors }); return admission; } -/** - * A plain-data copy of a configuration for a consumer that must not observe later edits. - * - * The integration writer is the case this exists for. It freezes every other resolution seam - * before its first await and then held the configuration by reference, so a plan checked under one - * configuration could be written from another: the check and the document it authorizes were - * reading the same object at two different moments. One copy taken before the await gives both of - * them the same configuration. - * - * A configuration this cannot copy as plain data is returned as the caller's own object. That is - * deliberate and is never worse than the reference the caller already had; it is the export - * admission above, not this, that has to refuse what it cannot prove. - */ -export function detachedConfigSnapshot(config: OcxConfig): OcxConfig { - const detached = detachConfig(config); - return detached === null ? config : withExecutors(detached); -} - /** * Whether an admission still describes the configuration in hand and the file it was taken beside. * @@ -104,22 +89,43 @@ export function isExportConfigAdmissionCurrent(admission: ExportConfigAdmission, const file = admittedFileTerm(); if (file === null || file !== captured.file) return false; const resident = detachConfig(live); - if (resident === null || canonical(resident.data) !== captured.data) return false; + if (resident === null || canonicalPlainData(resident.data) !== captured.data) return false; if (!sameExecutors(resident.executors, captured.executors)) return false; const working = detachConfig(admission.config); return working !== null - && canonical(working.data) === captured.data + && canonicalPlainData(working.data) === captured.data && sameExecutors(working.executors, captured.executors); } +/** + * A plain-data copy of a configuration for a consumer that must not observe later edits, or null. + * + * The integration writer is the case this exists for. It freezes every other resolution seam + * before its first await and then held the configuration by reference, so a plan checked under one + * configuration could be written from another: the check and the document it authorized were + * reading the same object at two different moments. One copy taken before the await gives both of + * them the same configuration. + * + * Null rather than the caller's object when the copy cannot be made. Handing back the reference + * would have been a copy in name only, and the caller would have gone on to describe it as the + * configuration it checked. + */ +export function detachedConfigSnapshot(config: OcxConfig): OcxConfig | null { + const detached = detachConfig(config); + return detached === null ? null : withExecutors(detached); +} + /** * The configuration file as an opaque term: its exact bytes, or the distinguished absence of one. * + * This is a byte observation and nothing more. It says the operator's configuration file has not + * been rewritten since a roster was built; it is not a claim about whether the configuration the + * process is holding agrees with that file. + * * Null for a file that cannot be read, because then a later read cannot tell whether it changed. - * Null too for one that is there and does not load cleanly: the resident configuration is then a - * salvage of it rather than its contents, and binding a roster to bytes that describe something - * else records a relationship that does not hold. Before this, a digest was accepted ahead of any - * look at what the parse produced. + * Null too for one that is there and does not load cleanly, which is the existing contract for a + * derived roster rather than an inference about the resident configuration. Before this, a digest + * was accepted ahead of any look at what the parse produced. * * Absence is a configuration rather than the lack of one. No file means defaults, which is an * ordinary fresh install and the ordinary state in CI. @@ -137,20 +143,12 @@ interface DetachedConfig { } /** - * A plain-data copy of a configuration, plus the transport executors kept out of it. + * A configuration as plain data, with the transport executors kept out of it. * - * Serializing with JSON is not a safe copier here and using it as one would have been a quiet - * hole: it drops functions without saying so, and it invokes getters and toJSON, so an object - * carrying either could decide what this sees. This walks own enumerable string keys, refuses an - * accessor rather than calling it, refuses any value JSON could not have produced, and refuses a - * cycle. - * - * The one exception is a provider's fetch executor, which a caller owns and the gather uses - * instead of the global transport. It is kept by reference for the detached copy and compared by - * reference afterwards, so replacing it invalidates the admission while it is never serialized. - * - * Symbol keys are skipped rather than refused. The configuration carries process bookkeeping on - * symbols by convention, and none of it is the operator's configuration. + * The copier refuses everything JSON could not have produced, so the one thing that needs handling + * here is a provider's fetch executor: a caller owns it and the gather uses it instead of the + * global transport. It is held by reference for the detached copy and compared by reference + * afterwards, so replacing it invalidates the binding while it is never serialized. */ function detachConfig(live: OcxConfig): DetachedConfig | null { const executors = new Map(); @@ -162,9 +160,9 @@ function detachConfig(live: OcxConfig): DetachedConfig | null { const value = root[key]; if (value === undefined) continue; if (key !== "providers") { - const copied = plainCopy(value, new Set()); - if (copied === REFUSED) return null; - data[key] = copied; + const copied = copyPlainData(value); + if (!copied.ok) return null; + data[key] = copied.value; continue; } if (!isPlainObject(value)) return null; @@ -184,9 +182,9 @@ function detachConfig(live: OcxConfig): DetachedConfig | null { executors.set(name, fieldValue); continue; } - const copied = plainCopy(fieldValue, new Set()); - if (copied === REFUSED) return null; - copiedProvider[field] = copied; + const copied = copyPlainData(fieldValue); + if (!copied.ok) return null; + copiedProvider[field] = copied.value; } providers[name] = copiedProvider; } @@ -195,9 +193,12 @@ function detachConfig(live: OcxConfig): DetachedConfig | null { return { data, executors }; } -/** The copy the gather actually runs against, with the executors put back by reference. */ -function withExecutors(detached: DetachedConfig): OcxConfig { - const config = copyOfData(detached.data); +/** The copy a consumer runs against, with the executors put back by reference. */ +function withExecutors(detached: DetachedConfig): OcxConfig | null { + // A second copy, so what is compared later is never the object handed to a consumer. + const copied = copyPlainData(detached.data); + if (!copied.ok) return null; + const config = copied.value; const providers = config.providers; if (isPlainObject(providers)) { for (const [name, executor] of detached.executors) { @@ -215,81 +216,3 @@ function sameExecutors(left: ReadonlyMap, right: ReadonlyMap): Array { - return Object.keys(value).map(key => { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - return descriptor !== undefined && descriptor.get === undefined && descriptor.set === undefined ? key : null; - }); -} - -function plainCopy(value: unknown, seen: Set): unknown { - if (value === null) return null; - const type = typeof value; - if (type === "string" || type === "boolean") return value; - if (type === "number") return Number.isFinite(value as number) ? value : REFUSED; - if (type !== "object") return REFUSED; - const object = value as object; - if (seen.has(object)) return REFUSED; - seen.add(object); - try { - if (Array.isArray(object)) { - const copied: unknown[] = []; - for (const entry of object) { - // A hole or an explicit undefined in an array serializes as null, which is what a reader of - // the persisted configuration would find in that position. - if (entry === undefined) { copied.push(null); continue; } - const item = plainCopy(entry, seen); - if (item === REFUSED) return REFUSED; - copied.push(item); - } - return copied; - } - if (!isPlainObject(object)) return REFUSED; - const copied: Record = {}; - for (const key of ownDataKeys(object)) { - if (key === null) return REFUSED; - const entry = object[key]; - if (entry === undefined) continue; - const item = plainCopy(entry, seen); - if (item === REFUSED) return REFUSED; - copied[key] = item; - } - return copied; - } finally { - seen.delete(object); - } -} - -function isPlainObject(value: unknown): value is Record { - if (value === null || typeof value !== "object" || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -/** A second copy, so what is compared later is never the object handed to a consumer. */ -function copyOfData(data: Record): Record { - const copied = plainCopy(data, new Set()); - return copied === REFUSED ? {} : copied as Record; -} - -/** - * Key-sorted entry pairs rather than objects, because property order is observable through - * serialization and two configurations that differ only in it are the same configuration. - */ -function canonical(value: unknown): string { - return JSON.stringify(sorted(value)); -} - -function sorted(value: unknown): unknown { - if (Array.isArray(value)) return value.map(sorted); - if (value !== null && typeof value === "object") { - return Object.keys(value as Record) - .sort() - .map(key => [key, sorted((value as Record)[key])]); - } - return value; -} diff --git a/src/integrations/aside-profile-context.ts b/src/integrations/aside-profile-context.ts index d0bd9536cb8..04aec4041d6 100644 --- a/src/integrations/aside-profile-context.ts +++ b/src/integrations/aside-profile-context.ts @@ -2,6 +2,7 @@ import { lstatSync, readdirSync, realpathSync } from "node:fs"; import { join, relative, resolve, sep } from "node:path"; import { ClientPathError, type ExportModel } from "../clients/config-export"; import { assertAsideProfileBoundary, guardAsideProfileIO, listAsideProfiles, type AsideProfile } from "../clients/aside-profiles"; +import { detachedConfigSnapshot } from "../config/admitted-identity"; import type { OcxConfig } from "../types"; import { type IntegrationIO } from "./config-io"; import type { JournalEntry } from "./journal"; @@ -46,6 +47,17 @@ export interface AsideProfileContext { defaultEnabled: boolean; models: () => Promise; scopes: Map; + /** + * The configuration every profile write in this action is built from, copied when the context + * was created. + * + * An Aside mutation checks its confirmation, then awaits the preference write, and only then + * builds each profile's write input. The preference write edits the live configuration itself, + * so reading it again afterwards guaranteed the document was serialized from a configuration + * the check never saw. This copy is taken before the check and carried through every write, so + * the two describe the same thing. + */ + boundConfig: OcxConfig; } function storeUnsafe(): never { @@ -142,10 +154,15 @@ export function createAsideProfileContext(input: AsideProfilesInput): AsideProfi ? matched?.id ?? null : profiles.find(profile => profile.configPath === newest?.configPath)?.id ?? null; let loaded: Promise | undefined; + const boundConfig = detachedConfigSnapshot(input.config); + if (boundConfig === null) { + throw new AsideProfileError("aside_profiles_unavailable", 409, "The proxy configuration could not be captured for this change"); + } return { input: { ...input, env: { ...(input.env ?? process.env) } }, profiles, rootStore, legacyProfileId, scopes: new Map(), defaultEnabled: input.config.asideProfileSync?.allProfiles ?? Boolean(matched), models: () => loaded ??= Promise.resolve().then(() => typeof input.models === "function" ? input.models() : input.models), + boundConfig, }; } @@ -160,7 +177,10 @@ export function selectAsideProfiles(ctx: AsideProfileContext, profileId?: number } export function asideProfileEnabled(ctx: AsideProfileContext, id: number): boolean { - return ctx.input.config.asideProfileSync?.profiles?.[String(id)] ?? ctx.defaultEnabled; + // The bound copy, like every other policy read in this context: the preference write inside an + // action edits the live configuration, and a decision taken afterwards would be answering from + // a policy the action itself installed. + return ctx.boundConfig.asideProfileSync?.profiles?.[String(id)] ?? ctx.defaultEnabled; } export function asideProfileScope(ctx: AsideProfileContext, profile: AsideProfile): AsideProfileScope { @@ -207,7 +227,7 @@ export async function asideWriteInput(ctx: AsideProfileContext, scope: AsideProf const models = await ctx.models(); scope.assertBoundary(); return { - clientId: "aside", config: ctx.input.config, models, port: ctx.input.port, + clientId: "aside", config: ctx.boundConfig, models, port: ctx.input.port, env: ctx.input.env, home: ctx.input.home, store: scope.store, io: scope.io, resolvedPaths: { configPath: scope.profile.configPath, detectDir: scope.profile.detectDir }, }; diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index b3fb5980828..fa783c37057 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -16,6 +16,7 @@ import { dirname } from "node:path"; import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export"; import { shouldInjectApiAuthHeader } from "../codex/inject"; import { detachedConfigSnapshot } from "../config/admitted-identity"; +import { copyPlainData } from "../lib/plain-data"; import type { OcxConfig } from "../types"; import { defaultIntegrationIO, loadTarget, type IntegrationIO } from "./config-io"; import { @@ -699,6 +700,16 @@ type FrozenIntegrationInput = IntegrationWriteInput & { resolvedPaths: { configPath: string; detectDir: string }; }; +/** + * An input this write cannot hold still. + * + * A coordinated write checks a plan and then writes a document from the same input, with an await + * in between. Anything it cannot copy would have to be read from the caller's object twice, and + * the second read is not the one that was checked. Refusing is bounded and says so; carrying the + * reference and calling it a frozen input would not be. + */ +class UncopyableIntegrationInputError extends Error {} + function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegrationInput { const env = { ...(input.env ?? process.env) }; const home = input.home ?? homedir(); @@ -714,15 +725,22 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration ? { ...input.resolvedPaths } : resolveIntegrationPaths(input.clientId, env, home); /* - * The configuration is a seam like the others, and it was the one still held by reference. A - * coordinated write plans from this input, awaits the writer lock and a revalidation, and only - * then serializes the document from it. A management route editing the live configuration in - * that window would have been checked in one configuration and written from another, which is - * the substitution the fingerprint exists to prevent. Copying it here gives the plan and the - * document one configuration. + * The configuration and the roster are seams like the others, and they were the two still held + * by reference. A coordinated write plans from this input, awaits the writer lock and a + * revalidation, and only then serializes the document from it. A management route editing the + * live configuration, or a caller editing the model objects it passed in, would have been + * checked in one state and written from another, which is the substitution the fingerprint + * exists to prevent. Copying both here gives the plan and the document one input. */ const config = detachedConfigSnapshot(input.config); - return { ...input, config, env, home, store, io, resolvedPaths }; + if (config === null) { + throw new UncopyableIntegrationInputError("the proxy configuration could not be captured for this write"); + } + const models = copyPlainData(input.models); + if (!models.ok) { + throw new UncopyableIntegrationInputError("the model roster could not be captured for this write"); + } + return { ...input, config, models: models.value, env, home, store, io, resolvedPaths }; } function tryFreezeIntegrationInput(input: IntegrationWriteInput): @@ -731,6 +749,9 @@ function tryFreezeIntegrationInput(input: IntegrationWriteInput): try { return { ok: true, value: freezeIntegrationInput(input) }; } catch (error) { + if (error instanceof UncopyableIntegrationInputError) { + return { ok: false, refusal: refuse(input.clientId, "unsafe", "unsafe", error.message) }; + } if (!(error instanceof ClientPathError)) throw error; return { ok: false, diff --git a/src/lib/plain-data.ts b/src/lib/plain-data.ts new file mode 100644 index 00000000000..e2f705abc92 --- /dev/null +++ b/src/lib/plain-data.ts @@ -0,0 +1,103 @@ +/** + * Copying a value the way a consumer that must not observe later edits needs it copied. + * + * Serializing with JSON is not a copier and using it as one is a quiet hole: it drops functions + * without saying so, and it invokes getters and toJSON, so the object being copied gets to decide + * what the copy contains. Every read here goes through a property descriptor instead, including + * array elements, which an exotic array can define as accessors just as an object can. + * + * The contract is narrow on purpose. This copies what JSON could have produced and refuses + * everything else rather than approximating it: an accessor, a cycle, a function, a class instance, + * a Map, a Date, a symbol value, a bigint, a non-finite number. A caller that is refused has to + * decide what to do about it, which is the point; a copier that silently degraded would leave the + * caller believing it held a snapshot of something it never read. + * + * Symbol-keyed properties are skipped rather than refused. Process bookkeeping rides on symbols by + * convention here, and none of it is the data a consumer is being given a copy of. + */ + +/** A copy, or a refusal. A union rather than a nullable value, because null is copyable data. */ +export type PlainDataCopy = { readonly ok: true; readonly value: T } | { readonly ok: false }; + +const REFUSED = Symbol("refused"); + +export function copyPlainData(value: T): PlainDataCopy { + const copied = copyValue(value, new Set()); + return copied === REFUSED ? { ok: false } : { ok: true, value: copied as T }; +} + +/** + * A stable string for comparing two plain-data values. + * + * Key-sorted entry pairs rather than objects, because property order is observable through + * serialization and two values that differ only in it are the same value. + */ +export function canonicalPlainData(value: unknown): string { + return JSON.stringify(sorted(value)); +} + +/** Own enumerable string keys, with null in the position of any key that is an accessor. */ +export function ownDataKeys(value: Record): Array { + return Object.keys(value).map(key => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && descriptor.get === undefined && descriptor.set === undefined ? key : null; + }); +} + +export function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function copyValue(value: unknown, seen: Set): unknown { + if (value === null) return null; + const type = typeof value; + if (type === "string" || type === "boolean") return value; + if (type === "number") return Number.isFinite(value as number) ? value : REFUSED; + if (type !== "object") return REFUSED; + const object = value as object; + if (seen.has(object)) return REFUSED; + seen.add(object); + try { + if (Array.isArray(object)) return copyArray(object, seen); + if (!isPlainObject(object)) return REFUSED; + const copied: Record = {}; + for (const key of ownDataKeys(object)) { + if (key === null) return REFUSED; + const entry = (object as Record)[key]; + if (entry === undefined) continue; + const item = copyValue(entry, seen); + if (item === REFUSED) return REFUSED; + copied[key] = item; + } + return copied; + } finally { + seen.delete(object); + } +} + +function copyArray(source: unknown[], seen: Set): unknown { + const copied: unknown[] = []; + for (let index = 0; index < source.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(source, index); + // A hole is written as null, which is what a reader of the serialized value would find there. + if (descriptor === undefined) { copied.push(null); continue; } + if (descriptor.get !== undefined || descriptor.set !== undefined) return REFUSED; + if (descriptor.value === undefined) { copied.push(null); continue; } + const item = copyValue(descriptor.value, seen); + if (item === REFUSED) return REFUSED; + copied.push(item); + } + return copied; +} + +function sorted(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sorted); + if (value !== null && typeof value === "object") { + return Object.keys(value as Record) + .sort() + .map(key => [key, sorted((value as Record)[key])]); + } + return value; +} diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 605ff505717..402a60e07f0 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -22,7 +22,7 @@ parsing and ownership rules below. | `src/integrations/state.ts` | The single `absent` / `current` / `stale` / `conflict` / `unsafe` classifier used by status and every writer operation. | | `src/integrations/ownership.ts` | Durable ownership records: file, generated contribution, protected contribution, exact fragment paths, and operation identity. | | `src/integrations/ownership-policy.ts` | Client-scoped declarations for fields a client is documented to derive after apply. It must never contain a broad format-wide exemption. | -| `src/integrations/writer.ts` | Apply, refresh, disable, and restore transactions, including snapshot-first ordering, compare-before-commit, and compensation. | +| `src/integrations/writer.ts` | Apply, refresh, disable, and restore transactions, including snapshot-first ordering, compare-before-commit, and compensation. Freezing an input copies the proxy configuration and the model roster as plain data before the first await, so the plan a revalidation approves and the document that follows it read one input; an input that cannot be copied is refused rather than read twice. Aside captures the same configuration copy when its context is created, because its preference write edits the live configuration between the check and the profile writes. | | `src/integrations/mutation-plan.ts` | The shared observation both a preview and a mutation read, and the value-free plan an operator confirms. It owns no IO of its own, takes no lock, and must never import `writer.ts`. | | `src/integrations/store.ts` / `journal.ts` | One-root persistence for ownership records, operation history, snapshots, and retention maintenance. | diff --git a/structure/runtime.md b/structure/runtime.md index 7f3b2afbed6..f2ac6b2b239 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -89,6 +89,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | --- | --- | | `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | +| `src/lib/plain-data.ts` | Detached copies for a consumer that must not observe later edits. Descriptor-based reads, including array elements, so an accessor is refused rather than invoked; refuses cycles, functions, class instances and anything else JSON could not have produced, and returns a copy-or-refusal union rather than degrading silently. Symbol-keyed process bookkeeping is skipped. | | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version, because the replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `system codex-cli-update` is the deliberate read-only exception and suppresses auto-restore for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). | | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, the opt-in loopback-only hub-management listener, and facade re-exports for split server modules. The route table itself is built by `src/server/index/serve-options.ts`; this entry file owns the listener and the startup transaction. | | `src/server/images.ts` | Standalone Images data plane: default OpenAI or explicit custom-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | diff --git a/tests/clients/integrations-writer-frozen-config.test.ts b/tests/clients/integrations-writer-frozen-config.test.ts index a26e7ead9f3..0e4edd7f189 100644 --- a/tests/clients/integrations-writer-frozen-config.test.ts +++ b/tests/clients/integrations-writer-frozen-config.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import type { ExportModel } from "../../src/clients/config-export"; import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; import { applyIntegrationCoordinated } from "../../src/integrations/writer"; +import { mutateAsideProfiles } from "../../src/integrations/aside-profiles"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -24,7 +25,7 @@ const MODELS: ExportModel[] = [ ]; const CHECKED_HOST = "127.0.0.1"; -const LATER_HOST = "10.11.12.13"; +const LATER_HOST = "127.0.0.2"; beforeEach(() => { const base = mkdtempSync(join(tmpdir(), "ocx-writer-frozen-config-")); @@ -81,3 +82,129 @@ test("the configuration a coordinated write checked is the one it writes", async // And the edit itself is untouched: freezing the input is not an excuse to write it back. expect(config.hostname).toBe(LATER_HOST); }); + +/** + * The same rule one layer up, where the window is wider. + * + * An Aside change checks its confirmation, then awaits the preference write, and only then builds + * each profile's write input. The preference write edits the live configuration itself, so reading + * that object again afterwards guaranteed the document came from a configuration the check never + * saw, and anything else editing it during the await arrived the same way. + */ +test("the configuration an Aside change was checked against is the one written", async () => { + mkdirSync(join(home, ".aside", "u", "0"), { recursive: true }); + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ + currentAccountId: 0, accounts: [{ id: 0, name: "Primary" }], + })); + const profilePath = join(home, ".aside", "u", "0", "models.json"); + writeFileSync(profilePath, JSON.stringify({ theme: "keep", providers: { personal: { models: [] } } })); + + const config = { + port: 10100, + hostname: CHECKED_HOST, + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + } as unknown as OcxConfig; + + let checked = false; + const result = await mutateAsideProfiles( + { + config, models: MODELS, port: 10100, env: {} as NodeJS.ProcessEnv, home, store, + persistConfig: async () => { + // Both hosts are loopback, so neither is rewritten on the way into a client document and + // the one that appears is the one the write actually read. + config.hostname = LATER_HOST; + // A real suspension, so the profile write below genuinely resumes after this edit rather + // than being ordered ahead of it by chance. + await Promise.resolve(); + }, + }, + { profileId: 0, enabled: true }, + { + revalidate: async () => { + checked = true; + return null; + }, + }, + ); + + expect(checked).toBe(true); + expect(result.ok).toBe(true); + const written = readFileSync(profilePath, "utf8"); + expect(written).toContain(":10100/v1"); + expect(written).not.toContain(LATER_HOST); + // The preference write is a real effect on the live configuration and stays one. + expect(config.hostname).toBe(LATER_HOST); +}); + +/** + * The roster is the other half of the same input. + * + * A caller passes model objects it still owns, and the plan that authorizes the write is computed + * from them before the lock. Spreading the input carried those objects by reference, so an edit + * made after the check was serialized into the document the check had vouched for. + */ +test("the roster a coordinated write checked is the one it writes", async () => { + const configPath = installHermes(); + const config = { + port: 10100, + hostname: CHECKED_HOST, + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + } as unknown as OcxConfig; + const models: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000 }, + ]; + + const result = await applyIntegrationCoordinated( + { clientId: "hermes", models, config, port: 10100, store, env: TEST_ENV, home }, + { + revalidate: async () => { + models[0]!.id = "edited-after-the-check"; + models[0]!.namespaced = "anthropic/edited-after-the-check"; + return null; + }, + }, + ); + + expect(result.ok).toBe(true); + const written = readFileSync(configPath, "utf8"); + expect(written).toContain("claude-opus-4-8"); + expect(written).not.toContain("edited-after-the-check"); +}); + +/** + * What happens when the input cannot be held still at all. + * + * An accessor is not read here, so there is no copy to check a plan against and no way to promise + * the document matches it. The write is refused before anything is touched, which is bounded and + * honest; returning the caller's object and calling it frozen would have been neither. + */ +test("an input that cannot be copied refuses the write instead of reading it twice", async () => { + const configPath = installHermes(); + let reads = 0; + const config = { + port: 10100, + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + } as unknown as OcxConfig; + Object.defineProperty(config, "hostname", { + configurable: true, + enumerable: true, + get: () => { reads += 1; return CHECKED_HOST; }, + }); + + let checked = false; + const result = await applyIntegrationCoordinated( + { clientId: "hermes", models: MODELS, config, port: 10100, store, env: TEST_ENV, home }, + { revalidate: async () => { checked = true; return null; } }, + ); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("an uncopyable input must not be written"); + expect(result.reason).toBe("unsafe"); + // Refused before the plan, the lock and the document: nothing ran and nothing was read. + expect(checked).toBe(false); + expect(reads).toBe(0); + expect(existsSync(configPath)).toBe(false); +}); From 78ffab1f0f5e7555fd5a6aed4a3cad8b7d8c13ba Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 04:33:37 +0900 Subject: [PATCH 66/83] test(integrations): pin that a committed Aside change leaves the roster available The commit reloads the roster while holding the configuration its own preference write just edited. If that load cannot admit what it is holding it clears the retained roster, and the next confirmation is answered with no roster cached rather than with a comparison. The route-level case reports exactly that, so this pins the behaviour at the layer that owns it. local checks: NOT RUN --- .../integrations-writer-frozen-config.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/clients/integrations-writer-frozen-config.test.ts b/tests/clients/integrations-writer-frozen-config.test.ts index 0e4edd7f189..9a5da2c477f 100644 --- a/tests/clients/integrations-writer-frozen-config.test.ts +++ b/tests/clients/integrations-writer-frozen-config.test.ts @@ -7,6 +7,7 @@ import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; import { applyIntegrationCoordinated } from "../../src/integrations/writer"; import { mutateAsideProfiles } from "../../src/integrations/aside-profiles"; +import { loadExportModels, previewExportModels, resetExportSnapshotForTests } from "../../src/server/management/model-rows"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -18,6 +19,7 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; */ let home: string; let store: IntegrationStateStore; +let priorOcxHome: string | undefined; const TEST_ENV = {} as NodeJS.ProcessEnv; const MODELS: ExportModel[] = [ @@ -32,9 +34,15 @@ beforeEach(() => { home = join(base, "home"); mkdirSync(home, { recursive: true }); store = createIntegrationStateStore(join(base, "store", "integrations")); + // A roster is admitted beside the configuration file, so these cases need their own + // configuration home rather than whatever the machine running them happens to have. + priorOcxHome = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = join(base, "config"); }); afterEach(() => { + if (priorOcxHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = priorOcxHome; removeTreeWithRetry(dirname(home)); }); @@ -208,3 +216,44 @@ test("an input that cannot be copied refuses the write instead of reading it twi expect(reads).toBe(0); expect(existsSync(configPath)).toBe(false); }); + +/** + * A committed change must not take the roster away from the change after it. + * + * The Aside commit reloads the roster while holding the configuration its preference write just + * edited. If that load cannot admit what it is holding it clears the retained roster, and the next + * confirmation is answered with "no roster is cached yet" rather than a comparison. That is the + * difference between a refusal an operator can act on and one they cannot. + */ +test("an Aside change leaves the roster available for the confirmation after it", async () => { + mkdirSync(join(home, ".aside", "u", "0"), { recursive: true }); + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ + currentAccountId: 0, accounts: [{ id: 0, name: "Primary" }], + })); + const profilePath = join(home, ".aside", "u", "0", "models.json"); + writeFileSync(profilePath, JSON.stringify({ theme: "keep", providers: { personal: { models: [] } } })); + + const config = { + port: 10100, + hostname: CHECKED_HOST, + defaultProvider: "fixture", + providers: { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", liveModels: false, models: ["one", "two"] } }, + } as unknown as OcxConfig; + + resetExportSnapshotForTests(); + await loadExportModels(config, []); + expect(previewExportModels(config)).not.toBeNull(); + + const result = await mutateAsideProfiles( + { + config, models: () => loadExportModels(config), port: 10100, env: {} as NodeJS.ProcessEnv, home, store, + persistConfig: () => {}, + }, + { profileId: 0, enabled: true }, + ); + + expect(result.ok).toBe(true); + // The commit's own reload is an ordinary authoritative load: it happens while the configuration + // carries the preference this action just wrote, and it has to be able to admit that. + expect(previewExportModels(config)).not.toBeNull(); +}); From 2ef5b4131a9209d233b0f64eb800c1cf21e086e1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 04:35:21 +0900 Subject: [PATCH 67/83] test(integrations): say what a committed Aside change does to the roster Committing an Aside change writes the operator's preference into the configuration, so the configuration a roster was admitted under is no longer the one in hand and the roster is retired. A replayed confirmation is then refused for the earlier of the two reasons: there is nothing to replan against until an ordinary load runs. The route case expected a stale-plan comparison instead. It could only reach one because its fixture stubs persistence, so the configuration file never moved; with a real save the file moves and the roster has always been retired the same way. The expectation now matches what an operator gets, and the case keeps every assertion that matters: the replay writes nothing and the sibling profile is untouched. The owner-level case pins the same sequence at the layer that causes it, including the recovery: an ordinary load brings the roster back. local checks: NOT RUN --- .../integrations-writer-frozen-config.test.ts | 26 ++++++++++++------- tests/server/aside-profiles-routes.test.ts | 11 +++++--- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/tests/clients/integrations-writer-frozen-config.test.ts b/tests/clients/integrations-writer-frozen-config.test.ts index 9a5da2c477f..42db3bf0aa1 100644 --- a/tests/clients/integrations-writer-frozen-config.test.ts +++ b/tests/clients/integrations-writer-frozen-config.test.ts @@ -218,14 +218,16 @@ test("an input that cannot be copied refuses the write instead of reading it twi }); /** - * A committed change must not take the roster away from the change after it. + * What a committed Aside change does to the retained roster, stated rather than assumed. * - * The Aside commit reloads the roster while holding the configuration its preference write just - * edited. If that load cannot admit what it is holding it clears the retained roster, and the next - * confirmation is answered with "no roster is cached yet" rather than a comparison. That is the - * difference between a refusal an operator can act on and one they cannot. + * The change writes the operator's Aside preference into the configuration, so the configuration a + * roster was admitted under is no longer the one in hand and the roster is retired. The next bound + * confirmation is answered with "no roster is cached yet" until an ordinary load runs, which is + * what the Integrations collection read does. This is not new behaviour introduced by binding the + * roster to the configuration object: persisting the preference rewrites the configuration file, + * and a roster has always been retired when that file moves. */ -test("an Aside change leaves the roster available for the confirmation after it", async () => { +test("a committed Aside change retires the roster, and an ordinary load brings it back", async () => { mkdirSync(join(home, ".aside", "u", "0"), { recursive: true }); writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ currentAccountId: 0, accounts: [{ id: 0, name: "Primary" }], @@ -246,14 +248,20 @@ test("an Aside change leaves the roster available for the confirmation after it" const result = await mutateAsideProfiles( { - config, models: () => loadExportModels(config), port: 10100, env: {} as NodeJS.ProcessEnv, home, store, + // A bound confirmation hands the mutation the roster the guard used, exactly as the route + // does, so nothing reloads it in the middle of the change. + config, models: MODELS, port: 10100, env: {} as NodeJS.ProcessEnv, home, store, persistConfig: () => {}, }, { profileId: 0, enabled: true }, ); expect(result.ok).toBe(true); - // The commit's own reload is an ordinary authoritative load: it happens while the configuration - // carries the preference this action just wrote, and it has to be able to admit that. + // The preference is now part of the configuration, and the roster was admitted without it. + expect(config.asideProfileSync).toBeDefined(); + expect(previewExportModels(config)).toBeNull(); + + // Recovery is the ordinary flow rather than a special step. + await loadExportModels(config, []); expect(previewExportModels(config)).not.toBeNull(); }); diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index 328d8628974..00d472eb5fe 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -228,7 +228,7 @@ test("profile zero can be planned; it is a real profile, not an absent one", asy expect(JSON.stringify(plan)).not.toContain(home); }); -test("a previewed profile change commits once and then reports itself stale", async () => { +test("a previewed profile change commits once and then has no roster to replay against", async () => { await seedRoster(); const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { operation: "apply" }); expect(preview.status).toBe(200); @@ -242,13 +242,16 @@ test("a previewed profile change commits once and then reports itself stale", as expect(document(1).providers.opencodex).toBeDefined(); const committed = readFileSync(path(1), "utf8"); - // Replaying the same confirmation describes a profile that is no longer in that state. Without - // a real comparison this would apply a second time. + // Replaying the same confirmation is refused before anything is written, and the reason is the + // earlier of the two: committing wrote the Aside preference into the configuration, so the + // roster this plan was bound to no longer describes the configuration in hand and there is + // nothing to replan against until the collection is read again. Production reaches the same + // point through the file, which the preference write also rewrites. const replay = await api("/api/client-integrations/aside/profiles/1", "PUT", { enabled: true, operation: "apply", planFingerprint: plan.fingerprint, }); expect(replay.status).toBe(409); - expect((await replay.json() as { code: string }).code).toBe("integration_preview_stale"); + expect((await replay.json() as { code: string }).code).toBe("integration_preview_unavailable"); expect(readFileSync(path(1), "utf8")).toBe(committed); // The refusal must not touch a sibling profile either. expect(document(2).providers.opencodex).toBeUndefined(); From be6751bef4cde97e5589e0a5ef766b6f10d69dc1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 04:58:11 +0900 Subject: [PATCH 68/83] fix(integrations): check the Aside input that will be written, prepared before any effect Two bindings were still open on the Aside path, and both let a check answer about something other than what the write would use. The guard rebuilt its own view from the live configuration. The context copies the configuration when it is created, so a configuration edited to something else and back again while the action was in flight produced a check that planned the configuration the operator confirmed, agreed with the confirmation, and let the write proceed from the copy the action was actually holding. Both entry points now prepare the write input before the check and hand it to the check, which plans that input directly instead of reconstructing one. The roster was resolved lazily at each profile write, which happens after the preference write. A caller still holding those model objects could edit them in that window and the document carried the edit. It is now resolved once and copied before the check, so every profile in one action writes the same roster and the check reads exactly it. The check still runs before the preference write and before any import effect, which is the ordering that makes it useful at all. Preparing an input performs no write of its own, and a profile that cannot be prepared refuses rather than reaching an unchecked write. Two regressions: a confirmed plan taken under one configuration, an action begun under another, and the live configuration restored before the check, where the guard must plan the prepared input and refuse; and a roster edited during the preference write, where the document carries the roster that was checked. local checks: NOT RUN --- src/integrations/aside-profile-context.ts | 16 ++- src/integrations/aside-profile-journal.ts | 6 +- src/integrations/aside-profiles.ts | 56 +++++++--- src/server/management/aside-profile-routes.ts | 25 +++-- .../integrations-writer-frozen-config.test.ts | 101 +++++++++++++++++- 5 files changed, 178 insertions(+), 26 deletions(-) diff --git a/src/integrations/aside-profile-context.ts b/src/integrations/aside-profile-context.ts index 04aec4041d6..65ee091c06f 100644 --- a/src/integrations/aside-profile-context.ts +++ b/src/integrations/aside-profile-context.ts @@ -3,6 +3,7 @@ import { join, relative, resolve, sep } from "node:path"; import { ClientPathError, type ExportModel } from "../clients/config-export"; import { assertAsideProfileBoundary, guardAsideProfileIO, listAsideProfiles, type AsideProfile } from "../clients/aside-profiles"; import { detachedConfigSnapshot } from "../config/admitted-identity"; +import { copyPlainData } from "../lib/plain-data"; import type { OcxConfig } from "../types"; import { type IntegrationIO } from "./config-io"; import type { JournalEntry } from "./journal"; @@ -161,7 +162,20 @@ export function createAsideProfileContext(input: AsideProfilesInput): AsideProfi return { input: { ...input, env: { ...(input.env ?? process.env) } }, profiles, rootStore, legacyProfileId, scopes: new Map(), defaultEnabled: input.config.asideProfileSync?.allProfiles ?? Boolean(matched), - models: () => loaded ??= Promise.resolve().then(() => typeof input.models === "function" ? input.models() : input.models), + /* + * Resolved once and copied, so every profile in one action writes the same roster and a + * caller editing the model objects it passed cannot change what a checked plan described. + * The copy is taken here rather than at each write because the check reads it too. + */ + models: () => loaded ??= Promise.resolve() + .then(() => typeof input.models === "function" ? input.models() : input.models) + .then(rows => { + const copied = copyPlainData([...rows]); + if (!copied.ok) { + throw new AsideProfileError("aside_profiles_unavailable", 409, "The model roster could not be captured for this change"); + } + return copied.value; + }), boundConfig, }; } diff --git a/src/integrations/aside-profile-journal.ts b/src/integrations/aside-profile-journal.ts index 83d5a284b39..b88bd7d60c9 100644 --- a/src/integrations/aside-profile-journal.ts +++ b/src/integrations/aside-profile-journal.ts @@ -177,7 +177,7 @@ export function restoreAsideProfile( */ selectedOperation?: AsideOperation; }, - options?: { revalidate?: () => Promise }, + options?: { revalidate?: (prepared: IntegrationWriteInput) => Promise }, ): Promise { return runAsideProfileAction(input, request.profileId, `restore:${request.opId}:${Boolean(request.confirmDrift)}`, async ctx => { const row = request.selectedOperation ?? requiredOperation(ctx, request.opId, request.profileId); @@ -204,7 +204,9 @@ export function restoreAsideProfile( * checked under the writer lock would already have rewritten the user's preference and their * history by the time it was consulted. */ - const stale = await options?.revalidate?.(); + // The input this restore will actually write from, so the check cannot be answering about a + // view it rebuilt from the live configuration while the write uses this one. + const stale = await options?.revalidate?.(bound); if (stale) return stale; await persistAsidePolicy(ctx, { profileId: profile.id, enabled: snapshotWasOwned(row.entry, restoredText, bound) }); try { diff --git a/src/integrations/aside-profiles.ts b/src/integrations/aside-profiles.ts index 9f6d9ec033c..6baeaca9fc1 100644 --- a/src/integrations/aside-profiles.ts +++ b/src/integrations/aside-profiles.ts @@ -8,6 +8,7 @@ import { readIntegrationState, type IntegrationState, type IntegrationStatus } f import { applyIntegrationCoordinated, disableIntegrationCoordinated, overwriteIntegrationCoordinated, refreshIntegrationCoordinated, + type IntegrationWriteInput, } from "./writer"; import { asideProfileEnabled, asideProfileFailure, asideProfileScope, asideWriteInput, @@ -148,7 +149,7 @@ export async function getAsideProfileState(input: AsideProfilesInput, id: number export function mutateAsideProfiles( input: AsideProfilesInput, change: { enabled: boolean; profileId?: number; overwriteConflict?: boolean }, - options?: { revalidate?: () => Promise }, + options?: { revalidate?: (prepared: IntegrationWriteInput) => Promise }, ): Promise { return runAsideProfileAction(input, change.profileId, `${change.enabled ? "enable" : "disable"}:${Boolean(change.overwriteConflict)}`, async (ctx, profiles) => { const refused = new Map(); @@ -156,6 +157,20 @@ export function mutateAsideProfiles( try { asideProfileScope(ctx, profile); } catch (error) { refused.set(profile.id, asideProfileFailure(profile.id, error)); } } + /* + * The input each profile will be written from, prepared before the check and handed to it. + * + * A check that built its own view from the live configuration was answering about a different + * input than the one that would be written: the context copies the configuration when it is + * created, so live could be edited to something else and then back again while this action was + * in flight, and a check reading live would agree with a plan this write never described. + */ + const prepared = new Map(); + for (const profile of profiles) { + if (refused.has(profile.id)) continue; + try { prepared.set(profile.id, await asideWriteInput(ctx, asideProfileScope(ctx, profile))); } + catch (error) { refused.set(profile.id, asideProfileFailure(profile.id, error)); } + } /* * A confirmation is checked HERE, not under the writer lock. * @@ -163,17 +178,30 @@ export function mutateAsideProfiles( * that waited for the lock would fire after the thing it was meant to prevent. Profile and * path selection is frozen by this point, which is everything the check needs. */ - const stale = await options?.revalidate?.(); - if (stale) { - return { - ok: false, - clientId: "aside", - changed: false, - state: "conflict", - message: "that confirmation no longer describes this profile", - results: [stale], - result: stale, - }; + if (options?.revalidate) { + const guarded = change.profileId === undefined + ? [...prepared.values()][0] + : prepared.get(change.profileId); + // Nothing prepared is nothing to check and nothing to write; refusing keeps a binding from + // being dropped on the way to a write that would then be unchecked. + const stale = guarded === undefined + ? { + ok: false as const, reason: "unsafe" as const, state: "conflict" as const, clientId: "aside" as const, + message: "that profile cannot be prepared for this change", + ...(change.profileId === undefined ? {} : { profileId: change.profileId }), + } + : await options.revalidate(guarded); + if (stale) { + return { + ok: false, + clientId: "aside", + changed: false, + state: "conflict", + message: "that confirmation no longer describes this profile", + results: [stale], + result: stale, + }; + } } // This await precedes model loading, writer preflight, snapshots and all client writes. await persistAsidePolicy(ctx, change); @@ -182,8 +210,8 @@ export function mutateAsideProfiles( const refusal = refused.get(profile.id); if (refusal) { results.push(refusal); continue; } try { - const scope = asideProfileScope(ctx, profile); - const bound = await asideWriteInput(ctx, scope); + const bound = prepared.get(profile.id); + if (bound === undefined) throw new AsideProfileErrorClass("aside_profile_unsafe", 409, "Aside profile was not prepared for this change"); const operation = !change.enabled ? disableIntegrationCoordinated : change.overwriteConflict ? overwriteIntegrationCoordinated : applyIntegrationCoordinated; results.push({ ...await operation(bound, { lockSeams: input.lockSeams }), profileId: profile.id }); diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index 21f641b6a99..eba778a40ec 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -12,7 +12,9 @@ import { } from "../../integrations/aside-profile-journal"; import type { AsideOperation } from "../../integrations/aside-profile-journal"; import type { WriteRefused } from "../../integrations/writer"; +import type { IntegrationWriteInput } from "../../integrations/writer"; import type { IntegrationMutationPlan, IntegrationPlanOperation } from "../../integrations/mutation-plan"; +import { previewIntegration } from "../../integrations/mutation-plan"; import { previewExportModels, previewExportSnapshot } from "./model-rows"; import type { ManagementContext } from "./context"; import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; @@ -53,15 +55,22 @@ function asideBinding( * happen before any writer lock is taken, so a check under that lock would fire after the thing it * was meant to prevent. */ +/** + * The check a bound Aside change runs, against the input that change is about to write from. + * + * It plans the prepared input it is given rather than building one of its own. Rebuilding meant + * reading the live configuration a second time, so a configuration edited to something else and + * back again while this action was in flight produced a check that agreed with a plan the write + * never described. + */ function asideGuardFor( - boundInput: AsideProfilesInput, profileIdValue: number, binding: { operation: IntegrationPlanOperation; fingerprint: string }, request: { opId?: string; confirmDrift?: boolean; resolved?: AsideOperation }, capture: { plan: IntegrationMutationPlan | null }, -): () => Promise { - return async () => { - const plan = await previewAsideProfile(boundInput, { +): (prepared: IntegrationWriteInput) => Promise { + return async prepared => { + const plan = previewIntegration(prepared, { profileId: profileIdValue, operation: binding.operation, ...(request.opId === undefined ? {} : { opId: request.opId }), @@ -266,7 +275,7 @@ export async function handleAsideProfileRoutes( const binding = asideBinding(body, expected); const capture: { plan: IntegrationMutationPlan | null } = { plan: null }; let mutationInput = options.input(); - let revalidate: (() => Promise) | undefined; + let revalidate: ((prepared: IntegrationWriteInput) => Promise) | undefined; if (binding !== null && id !== undefined) { const roster = previewExportModels(ctx.config); if (roster === null) { @@ -277,7 +286,7 @@ export async function handleAsideProfileRoutes( } // One roster for the guard and the mutation, so they cannot disagree by construction. mutationInput = { ...mutationInput, models: roster }; - revalidate = asideGuardFor(mutationInput, id, binding, {}, capture); + revalidate = asideGuardFor(id, binding, {}, capture); } const batch = await mutateAsideProfiles( mutationInput, @@ -354,7 +363,7 @@ export async function asideRestoreResponse( const restoreBinding = asideBinding(body, "restore"); const capture: { plan: IntegrationMutationPlan | null } = { plan: null }; let restoreInput = input; - let revalidate: (() => Promise) | undefined; + let revalidate: ((prepared: IntegrationWriteInput) => Promise) | undefined; if (restoreBinding !== null) { const roster = previewExportModels(ctx.config); if (roster === null) { @@ -364,7 +373,7 @@ export async function asideRestoreResponse( }, 409, ctx.req, ctx.config); } restoreInput = { ...restoreInput, models: roster }; - revalidate = asideGuardFor(restoreInput, operation.profileId, restoreBinding, { + revalidate = asideGuardFor(operation.profileId, restoreBinding, { opId: body.opId, confirmDrift: body.confirmDrift === true, resolved: operation, diff --git a/tests/clients/integrations-writer-frozen-config.test.ts b/tests/clients/integrations-writer-frozen-config.test.ts index 42db3bf0aa1..bc86ec04392 100644 --- a/tests/clients/integrations-writer-frozen-config.test.ts +++ b/tests/clients/integrations-writer-frozen-config.test.ts @@ -6,7 +6,8 @@ import type { ExportModel } from "../../src/clients/config-export"; import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; import { applyIntegrationCoordinated } from "../../src/integrations/writer"; -import { mutateAsideProfiles } from "../../src/integrations/aside-profiles"; +import { mutateAsideProfiles, previewAsideProfile } from "../../src/integrations/aside-profiles"; +import { previewIntegration } from "../../src/integrations/mutation-plan"; import { loadExportModels, previewExportModels, resetExportSnapshotForTests } from "../../src/server/management/model-rows"; import type { OcxConfig } from "../../src/types"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -265,3 +266,101 @@ test("a committed Aside change retires the roster, and an ordinary load brings i await loadExportModels(config, []); expect(previewExportModels(config)).not.toBeNull(); }); + +function seedAsideProfile(): string { + mkdirSync(join(home, ".aside", "u", "0"), { recursive: true }); + writeFileSync(join(home, ".aside", "accounts.json"), JSON.stringify({ + currentAccountId: 0, accounts: [{ id: 0, name: "Primary" }], + })); + const profilePath = join(home, ".aside", "u", "0", "models.json"); + writeFileSync(profilePath, JSON.stringify({ theme: "keep", providers: { personal: { models: [] } } })); + return profilePath; +} + +function asideFixtureConfig(hostname: string): OcxConfig { + return { + port: 10100, + hostname, + defaultProvider: "fixture", + providers: { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", liveModels: false, models: ["one", "two"] } }, + } as unknown as OcxConfig; +} + +/** + * The check has to be about the input that will be written, not about whatever the live + * configuration says at the moment it runs. + * + * A configuration can be edited to something else and back again while an action is in flight. A + * check that rebuilt its own view from the live object would then plan the configuration the + * operator confirmed, agree with the confirmation, and let the write proceed from the copy the + * action actually holds, which is the other one. + */ +test("the Aside check plans the prepared input, not the configuration that is live when it runs", async () => { + const profilePath = seedAsideProfile(); + const before = readFileSync(profilePath, "utf8"); + const config = asideFixtureConfig(CHECKED_HOST); + const asideInput = { config, models: MODELS, port: 10100, env: {} as NodeJS.ProcessEnv, home, store, persistConfig: () => {} }; + + // A: what the operator confirmed. + const confirmed = await previewAsideProfile(asideInput, { profileId: 0, operation: "apply" }); + expect(confirmed.canApply).toBe(true); + + // B: what the action will hold, because the context copies the configuration when it is created. + config.hostname = LATER_HOST; + + const checked: string[] = []; + const result = await mutateAsideProfiles( + asideInput, + { profileId: 0, enabled: true }, + { + revalidate: async prepared => { + // Live is A again by the time the check runs. Only the prepared input still says B. + config.hostname = CHECKED_HOST; + const plan = previewIntegration(prepared, { profileId: 0, operation: "apply" }); + checked.push(plan.fingerprint); + return plan.fingerprint === confirmed.fingerprint ? null : { + ok: false, reason: "conflict", state: plan.state, clientId: "aside", + message: "that confirmation no longer describes this profile", profileId: 0, + }; + }, + }, + ); + + expect(checked).toHaveLength(1); + expect(checked[0]).not.toBe(confirmed.fingerprint); + expect(result.ok).toBe(false); + // Refused before the preference write and before any client write. + expect(config.asideProfileSync).toBeUndefined(); + expect(readFileSync(profilePath, "utf8")).toBe(before); +}); + +/** + * The roster is resolved and copied once, before the check. + * + * It used to be resolved lazily at each profile write, which happens after the preference write. A + * caller still holding those model objects could edit them in that window, and the document + * carried the edit while the plan vouched for what it read earlier. + */ +test("a roster edited during the preference write is not the roster written", async () => { + const profilePath = seedAsideProfile(); + const config = asideFixtureConfig(CHECKED_HOST); + const models: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000 }, + ]; + + const result = await mutateAsideProfiles( + { + config, models, port: 10100, env: {} as NodeJS.ProcessEnv, home, store, + persistConfig: () => { + models[0]!.id = "edited-during-the-preference-write"; + models[0]!.namespaced = "anthropic/edited-during-the-preference-write"; + }, + }, + { profileId: 0, enabled: true }, + ); + + expect(result.ok).toBe(true); + const written = readFileSync(profilePath, "utf8"); + expect(written).toContain("claude-opus-4-8"); + expect(written).not.toContain("edited-during-the-preference-write"); +}); From 36cdb569544349e174612cf9569ee01eea761afb Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 05:07:26 +0900 Subject: [PATCH 69/83] fix(integrations): bring the Aside input forward only for a checked change Preparing every Aside change's write input before the preference write was too broad in two ways that CI caught at edbc4dce15. It resolved the roster for changes nobody checks, and an unconfirmed change whose preference write fails must not have done model work by then. That is a contract with its own regression, and this broke it. It also moved the model load ahead of the preference write for ordinary toggles, so the roster that load retained was admitted under the configuration as it stood before the toggle and was retired a moment later by the toggle itself. A preview taken after an ordinary change then had nothing to plan against, which showed up as an unrelated preview route answering 409. Preparation now happens only when a confirmation has to be checked, which is the case that needs it: the check must read the same input the write will use. An unchecked change resolves its roster where it always did, after the preference write, and the write still uses the prepared input when one exists. The synthesized refusal for a profile that cannot be prepared now carries a profile id, which the outcome type requires. local checks: NOT RUN --- src/integrations/aside-profiles.ts | 26 ++++++++++++------- .../integrations-writer-frozen-config.test.ts | 4 +++ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/integrations/aside-profiles.ts b/src/integrations/aside-profiles.ts index 6baeaca9fc1..f5ca0e3af07 100644 --- a/src/integrations/aside-profiles.ts +++ b/src/integrations/aside-profiles.ts @@ -164,12 +164,18 @@ export function mutateAsideProfiles( * input than the one that would be written: the context copies the configuration when it is * created, so live could be edited to something else and then back again while this action was * in flight, and a check reading live would agree with a plan this write never described. + * + * Only a checked change prepares early. Preparing resolves the roster, and an unchecked change + * whose preference write fails must not have done any model work by then, which is a contract + * of its own. There is nothing to bring forward when there is no check to read it. */ const prepared = new Map(); - for (const profile of profiles) { - if (refused.has(profile.id)) continue; - try { prepared.set(profile.id, await asideWriteInput(ctx, asideProfileScope(ctx, profile))); } - catch (error) { refused.set(profile.id, asideProfileFailure(profile.id, error)); } + if (options?.revalidate) { + for (const profile of profiles) { + if (refused.has(profile.id)) continue; + try { prepared.set(profile.id, await asideWriteInput(ctx, asideProfileScope(ctx, profile))); } + catch (error) { refused.set(profile.id, asideProfileFailure(profile.id, error)); } + } } /* * A confirmation is checked HERE, not under the writer lock. @@ -184,11 +190,11 @@ export function mutateAsideProfiles( : prepared.get(change.profileId); // Nothing prepared is nothing to check and nothing to write; refusing keeps a binding from // being dropped on the way to a write that would then be unchecked. - const stale = guarded === undefined - ? { - ok: false as const, reason: "unsafe" as const, state: "conflict" as const, clientId: "aside" as const, + const stale: AsideProfileWriteOutcome | null = guarded === undefined + ? refused.get(change.profileId ?? -1) ?? { + ok: false, reason: "unsafe", state: "conflict", clientId: "aside", message: "that profile cannot be prepared for this change", - ...(change.profileId === undefined ? {} : { profileId: change.profileId }), + profileId: change.profileId ?? profiles[0]?.id ?? 0, } : await options.revalidate(guarded); if (stale) { @@ -210,8 +216,8 @@ export function mutateAsideProfiles( const refusal = refused.get(profile.id); if (refusal) { results.push(refusal); continue; } try { - const bound = prepared.get(profile.id); - if (bound === undefined) throw new AsideProfileErrorClass("aside_profile_unsafe", 409, "Aside profile was not prepared for this change"); + // The prepared input when a check read one, so the write is the thing that was checked. + const bound = prepared.get(profile.id) ?? await asideWriteInput(ctx, asideProfileScope(ctx, profile)); const operation = !change.enabled ? disableIntegrationCoordinated : change.overwriteConflict ? overwriteIntegrationCoordinated : applyIntegrationCoordinated; results.push({ ...await operation(bound, { lockSeams: input.lockSeams }), profileId: profile.id }); diff --git a/tests/clients/integrations-writer-frozen-config.test.ts b/tests/clients/integrations-writer-frozen-config.test.ts index bc86ec04392..8cc2a17ab08 100644 --- a/tests/clients/integrations-writer-frozen-config.test.ts +++ b/tests/clients/integrations-writer-frozen-config.test.ts @@ -357,6 +357,10 @@ test("a roster edited during the preference write is not the roster written", as }, }, { profileId: 0, enabled: true }, + // A confirmed change, which is what brings the roster forward: the check has to read the same + // rows the write will use. An unconfirmed change resolves them later, because a preference + // write that fails must not have done model work by then. + { revalidate: async () => null }, ); expect(result.ok).toBe(true); From d7590af8d59116caa0c5baf965a2576dedf3ec34 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 05:16:47 +0900 Subject: [PATCH 70/83] fix(config): treat a non-executor provider fetch field as data, not as a refusal The executor exception was keyed on the field name alone, so a provider whose configuration happens to carry a `fetch` value that is not a function refused the whole admission and took previews away for a field nobody calls. The exception now applies when the value is an executor; anything else is copied and compared as the ordinary data it is. local checks: NOT RUN --- src/config/admitted-identity.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/config/admitted-identity.ts b/src/config/admitted-identity.ts index db23465fcae..5b805ecfcfa 100644 --- a/src/config/admitted-identity.ts +++ b/src/config/admitted-identity.ts @@ -177,8 +177,10 @@ function detachConfig(live: OcxConfig): DetachedConfig | null { if (field === null) return null; const fieldValue = provider[field]; if (fieldValue === undefined) continue; - if (field === "fetch") { - if (typeof fieldValue !== "function") return null; + // The executor exception applies to an executor. A provider carrying a `fetch` value that + // is not one is ordinary data a configuration file could hold, and refusing the whole + // admission over it would take previews away for a field nobody is calling. + if (field === "fetch" && typeof fieldValue === "function") { executors.set(name, fieldValue); continue; } From ea8c69c577c3bc5419ae13886283f42c07c2aef2 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 05:30:37 +0900 Subject: [PATCH 71/83] test(management): cover both halves of the provider fetch admission The executor exception has two branches and only one was exercised. A provider schema passes unknown keys through, so a configuration file can carry a fetch value that is not a transport. One case now admits such a provider, shows its rows in the export, and then edits that value to prove it is compared as the ordinary data it is. The executor case gains the half it was missing. Putting back the very object that was already there leaves the roster valid, and a wrapper that defers to it, which would serialize identically, does not. Together those say the comparison is an identity rather than a resemblance. local checks: NOT RUN --- ...anagement-model-roster-gather-race.test.ts | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/tests/server/management-model-roster-gather-race.test.ts b/tests/server/management-model-roster-gather-race.test.ts index baf56f418ff..00d200597ac 100644 --- a/tests/server/management-model-roster-gather-race.test.ts +++ b/tests/server/management-model-roster-gather-race.test.ts @@ -24,6 +24,7 @@ import type { OcxConfig } from "../../src/types"; */ const ALPHA_HOST = "https://alpha.gather-race.test/v1"; const BETA_HOST = "https://beta.gather-race.test/v1"; +const GAMMA_HOST = "https://gamma.gather-race.test/v1"; const originalFetch = globalThis.fetch; let configRoot = ""; @@ -79,9 +80,11 @@ async function afterAlphaChoseItsRows(): Promise { */ async function loadWhileBetaIsHeld( during: (config: OcxConfig) => void | Promise, + prepare?: (config: OcxConfig) => void, ): Promise<{ config: OcxConfig; exported: readonly ExportModel[] }> { const release = holdBetaOpen(); const config = raceConfig(); + prepare?.(config); const load = loadExportModels(config); const settled = load.catch(() => undefined); try { @@ -163,13 +166,40 @@ describe("an export load retains what its rows were chosen under", () => { test("replacing a provider's transport executor retires the roster it gathered", async () => { const { config, exported } = await loadWhileBetaIsHeld(() => {}); expect(previewExportModels(config)).toEqual(exported); + const executor = (config.providers.alpha as { fetch?: typeof fetch }).fetch; + expect(typeof executor).toBe("function"); + + // The same executor is the same configuration: this is an identity, so putting back the very + // object that was there changes nothing. + (config.providers.alpha as { fetch?: typeof fetch }).fetch = executor; + expect(previewExportModels(config)).toEqual(exported); // The executor is the one part of a provider that is not data, so it is held by reference // rather than compared as content. A different executor can answer differently, so a roster // gathered through the previous one is no longer a roster for this configuration. - // The executor is not part of the configuration's declared shape; the transport reads it off - // the provider at call time, which is exactly why it is held by reference here. - (config.providers.alpha as { fetch?: typeof fetch }).fetch = (async () => new Response("{}")) as typeof fetch; + // A wrapper that defers to the previous one would serialize identically and is still a + // different answer waiting to happen, which is why nothing here compares what it looks like. + (config.providers.alpha as { fetch?: typeof fetch }).fetch = ((input: RequestInfo | URL, init?: RequestInit) => + executor!(input, init)) as typeof fetch; + expect(previewExportModels(config)).toBeNull(); + }); + + test("a provider fetch value that is not an executor is ordinary configuration", async () => { + const { config, exported } = await loadWhileBetaIsHeld(() => {}, prepared => { + // A provider schema passes unknown keys through, so a configuration file can carry this and + // the transport never calls it. Refusing the admission over it would take previews away for + // a field nothing reads. + prepared.providers.gamma = { + adapter: "openai-chat", baseUrl: GAMMA_HOST, liveModels: false, models: ["gamma-static"], + } as OcxConfig["providers"][string]; + (prepared.providers.gamma as { fetch?: unknown }).fetch = "https://not-an-executor.test/v1"; + }); + + expect(exported.some(model => model.namespaced === "gamma/gamma-static")).toBe(true); + expect(previewExportModels(config)).toEqual(exported); + + // Data, and compared as data: editing it is a configuration change like any other. + (config.providers.gamma as { fetch?: unknown }).fetch = "https://also-not-an-executor.test/v1"; expect(previewExportModels(config)).toBeNull(); }); From 104b5a48ac767c11ea216fd500ea51346ce8d22b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 05:55:23 +0900 Subject: [PATCH 72/83] fix: answer the review on the written fetch value, the roster identity and a bindingless confirmation Three findings from the review at ea8c69c577. The admission comment said nothing calls a provider fetch value that is not a function. The outbound transport did: it branched on the value being present, so a fetch an operator wrote into config.json was called, threw inside discovery, and failed that provider for a reason nothing in its configuration explains. The transport now takes its built-in path unless the value is callable, which is also what pins the peer address, and the comment says what the code does. The retained roster took a new identity on every load even when the rows, the configuration and the cache stamp were identical. The Integrations collection read performs such a load on every visit, so a confirmation an operator was in the middle of submitting could be answered stale for a change they could not see. An identical projection now keeps the identity it had, and the rows themselves are compared rather than inferred from the configuration and the cache stamp, because the projection also reads entitlement state that neither describes. A confirmed change that names no profile was refused by HTTP but not by the mutation, which picked the first prepared input instead. It now refuses there too: guessing which profile a bindingless confirmation meant would invent the thing the check exists to verify. Three regressions, one per finding: a written fetch value reaches the built-in transport and its pinned address rather than being called; an unchanged roster keeps its identity while a changed one takes a new one; and a confirmation without a profile refuses before the check, the preference write and any file. local checks: NOT RUN --- src/config/admitted-identity.ts | 8 +++-- src/integrations/aside-profiles.ts | 14 +++++---- src/lib/provider-outbound.ts | 6 +++- src/server/management/model-rows.ts | 29 ++++++++++++++----- structure/runtime.md | 2 +- .../integrations-writer-frozen-config.test.ts | 23 +++++++++++++++ tests/providers/provider-outbound.test.ts | 26 +++++++++++++++++ tests/server/management-model-roster.test.ts | 20 +++++++++++++ 8 files changed, 110 insertions(+), 18 deletions(-) diff --git a/src/config/admitted-identity.ts b/src/config/admitted-identity.ts index 5b805ecfcfa..eaca3be7cc9 100644 --- a/src/config/admitted-identity.ts +++ b/src/config/admitted-identity.ts @@ -177,9 +177,11 @@ function detachConfig(live: OcxConfig): DetachedConfig | null { if (field === null) return null; const fieldValue = provider[field]; if (fieldValue === undefined) continue; - // The executor exception applies to an executor. A provider carrying a `fetch` value that - // is not one is ordinary data a configuration file could hold, and refusing the whole - // admission over it would take previews away for a field nobody is calling. + // The executor exception applies to an executor. A provider entry keeps unknown + // configuration keys, so a fetch value that is not a function is something an operator + // wrote into the file, and it is copied and compared as the data it is. Discovery reads it + // the same way: the outbound transport takes its built-in path unless the value is + // callable. if (field === "fetch" && typeof fieldValue === "function") { executors.set(name, fieldValue); continue; diff --git a/src/integrations/aside-profiles.ts b/src/integrations/aside-profiles.ts index f5ca0e3af07..6cea53656ea 100644 --- a/src/integrations/aside-profiles.ts +++ b/src/integrations/aside-profiles.ts @@ -185,16 +185,20 @@ export function mutateAsideProfiles( * path selection is frozen by this point, which is everything the check needs. */ if (options?.revalidate) { - const guarded = change.profileId === undefined - ? [...prepared.values()][0] - : prepared.get(change.profileId); + // A confirmation describes one profile, and HTTP refuses one that names none. This refuses + // it too: guessing which prepared input a bindingless confirmation meant would be inventing + // the thing the check exists to verify. + if (change.profileId === undefined) { + throw new AsideProfileErrorClass("invalid_aside_profile", 400, "a confirmed change applies to one profile"); + } + const guarded = prepared.get(change.profileId); // Nothing prepared is nothing to check and nothing to write; refusing keeps a binding from // being dropped on the way to a write that would then be unchecked. const stale: AsideProfileWriteOutcome | null = guarded === undefined - ? refused.get(change.profileId ?? -1) ?? { + ? refused.get(change.profileId) ?? { ok: false, reason: "unsafe", state: "conflict", clientId: "aside", message: "that profile cannot be prepared for this change", - profileId: change.profileId ?? profiles[0]?.id ?? 0, + profileId: change.profileId, } : await options.revalidate(guarded); if (stale) { diff --git a/src/lib/provider-outbound.ts b/src/lib/provider-outbound.ts index 7b4a3b2cd4c..799c8d69fa6 100644 --- a/src/lib/provider-outbound.ts +++ b/src/lib/provider-outbound.ts @@ -160,7 +160,11 @@ async function providerOutboundRequest( if (postUrl?.protocol !== undefined && postUrl.protocol !== "https:") { throw new ProviderOutboundPolicyError("provider POST URL must use HTTPS"); } - if (provider.fetch) { + // A provider entry keeps unknown configuration keys, so `fetch` can arrive as a value the + // operator wrote into the file rather than an executor a caller attached. Calling that would + // throw inside discovery and fail the provider for a reason nothing in its configuration + // explains; the built-in transport is what a configured value means. + if (typeof provider.fetch === "function") { // A caller-owned executor cannot be peer-pinned here. This branch keeps literal/config // checks and redirect blocking, but does not provide the resolved-address guarantees of // the built-in transport. Main-request migration must define that executor contract first. diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index bcda3e3f89d..badadc8d6d4 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -306,14 +306,27 @@ export async function loadExportModels( lastExportSnapshot = null; return exported; } - lastExportSnapshot = { - admission, - // Prefer the revisions the gather stamped; fall back to observing only when the roster was - // supplied and no gather happened, where there is nothing tighter to use. - cacheStamp: gathered.size > 0 ? stampFrom(admitted, gathered) : modelCacheStamp(admitted), - generation: ++exportSnapshotGeneration, - models: Object.freeze(structuredClone(exported)), - }; + // Prefer the revisions the gather stamped; fall back to observing only when the roster was + // supplied and no gather happened, where there is nothing tighter to use. + const cacheStamp = gathered.size > 0 ? stampFrom(admitted, gathered) : modelCacheStamp(admitted); + const retained = lastExportSnapshot; + /* + * An identical roster keeps the identity it already had. + * + * The generation moved on every load, so an ordinary read that rebuilt the same rows, which the + * Integrations collection does, invalidated a confirmation an operator was in the middle of + * submitting. Nothing about the roster had changed; only the counter had. The rows themselves + * are compared rather than assumed equal from the configuration and the cache stamp, because a + * projection also reads entitlement state neither of those two describes. + */ + const models = Object.freeze(structuredClone(exported)); + if (retained !== null + && retained.cacheStamp === cacheStamp + && isExportConfigAdmissionCurrent(retained.admission, config) + && JSON.stringify(retained.models) === JSON.stringify(models)) { + return exported; + } + lastExportSnapshot = { admission, cacheStamp, generation: ++exportSnapshotGeneration, models }; return exported; } diff --git a/structure/runtime.md b/structure/runtime.md index def3e9744e9..6be7fff3d42 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -99,7 +99,7 @@ does not perform OAuth, and runtime credential resolution rereads the owned sour | `src/config/paths.ts` | Resolves `OPENCODEX_HOME`, `config.json`, and owner-only directory hardening. | | `src/config/atomic-write.ts` | Shared synchronous/asynchronous temp-harden-rename writer and residual-temp failure contract. The temp is ACL-hardened before it holds a byte and again before the rename, both `required: true`; the second call is a memo hit rather than a second icacls sequence because the writer re-asserts descriptor/path identity after the content write and re-attributes the harden through `reattributeHardenedSecretPath`. Windows takes no `chmod` on that path — it sets the read-only attribute, not the DACL, and its ChangeTime bump is what used to retire the memo. | | `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. | -| `src/config/admitted-identity.ts` | Which configuration a derived artifact was built from. Detaches the resident configuration as plain data so one pass cannot gather under one state and project under another, and records the complete structure beside the configuration file's bytes. Refuses an accessor, a cycle, a value JSON could not produce, an unreadable file and a file the loader would have had to salvage; a provider's `fetch` executor is the one non-data field, held and compared by reference. It does NOT require the resident configuration to equal the file: the proxy routes by what it holds, and live reconciliation retains live changes and the active listener binding on purpose. Evidence stays in a module WeakMap, never on the config and never in a response. | +| `src/config/admitted-identity.ts` | Which configuration a derived artifact was built from. Detaches the resident configuration as plain data so one pass cannot gather under one state and project under another, and records the complete structure beside the configuration file's bytes. Refuses an accessor, a cycle, a value JSON could not produce, an unreadable file and a file the loader would have had to salvage; a callable `providers[name].fetch` is the one non-data field, held and compared by reference, while a written one is ordinary data on both sides, as the outbound transport also reads it. It does NOT require the resident configuration to equal the file: the proxy routes by what it holds, and live reconciliation retains live changes and the active listener binding on purpose. Evidence stays in a module WeakMap, never on the config and never in a response. | | `src/server/ports.ts` | Owns bind availability and ephemeral-port selection. Temporary probes dispose accepted peers and wait for listener close before reporting success. | | `src/cli/status.ts` / `src/cli/status-probes.ts` | Status snapshot assembly and the shared read-only health/stale-process probes used by status and doctor. Probe evidence keeps recorded-port choice, before/after snapshots and per-call timer cleanup together. | | `src/cli/doctor.ts` | Read-only environment diagnostics. Sections print through `console.log`; each is a `collect*` helper above `runDoctor` so it is testable without the command. Only a `FAIL`-level condition records a doctor failure — a degraded-but-working install must not break a green pipeline. `collectDefaultModelExposure` compares Codex's root `model` pin against the exposed set, which it READS rather than recomputes: the running proxy's `/v1/models` when one answers, otherwise the on-disk catalog's `visibility: "list"` slugs. It reports exposed, not exposed, or undeterminable, and never the second when it could not read either surface. | diff --git a/tests/clients/integrations-writer-frozen-config.test.ts b/tests/clients/integrations-writer-frozen-config.test.ts index 8cc2a17ab08..b807bf324ae 100644 --- a/tests/clients/integrations-writer-frozen-config.test.ts +++ b/tests/clients/integrations-writer-frozen-config.test.ts @@ -368,3 +368,26 @@ test("a roster edited during the preference write is not the roster written", as expect(written).toContain("claude-opus-4-8"); expect(written).not.toContain("edited-during-the-preference-write"); }); + +/** + * A confirmation describes one profile. + * + * HTTP refuses a bound change that names none, and this refuses it too. Guessing which prepared + * input such a confirmation meant would be inventing the thing the check exists to verify. + */ +test("a confirmed change that names no profile is refused rather than guessed", async () => { + const profilePath = seedAsideProfile(); + const before = readFileSync(profilePath, "utf8"); + const config = asideFixtureConfig(CHECKED_HOST); + + let checked = false; + await expect(mutateAsideProfiles( + { config, models: MODELS, port: 10100, env: {} as NodeJS.ProcessEnv, home, store, persistConfig: () => {} }, + { enabled: true }, + { revalidate: async () => { checked = true; return null; } }, + )).rejects.toThrow(/one profile/); + + expect(checked).toBe(false); + expect(config.asideProfileSync).toBeUndefined(); + expect(readFileSync(profilePath, "utf8")).toBe(before); +}); diff --git a/tests/providers/provider-outbound.test.ts b/tests/providers/provider-outbound.test.ts index edbf073ed7f..31a2e36c2c8 100644 --- a/tests/providers/provider-outbound.test.ts +++ b/tests/providers/provider-outbound.test.ts @@ -54,6 +54,32 @@ function directDependencies( } describe("provider outbound GET transport", () => { + test("a written fetch value is configuration, not an executor to call", async () => { + for (const key of proxyKeys) delete process.env[key]; + const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); + const { dependencies, captured } = directDependencies(new Response('{"data":[]}', { + status: 200, + headers: { "content-type": "application/json" }, + })); + + /* + * A provider entry keeps unknown configuration keys, so an operator can write `fetch` into + * config.json and it arrives here as a string. Treating a present value as callable threw + * inside discovery and failed that provider for a reason nothing in its configuration + * explains. A configured value means the built-in transport, which is what pins the peer. + */ + const response = await providerOutboundGet( + "written-fetch", + { baseUrl: "https://provider.example/v1", fetch: "https://not-an-executor.example" } as never, + "https://provider.example/v1/models", + { headers: { authorization: "Bearer test-key" } }, + dependencies, + ); + + expect(await response.json()).toEqual({ data: [] }); + expect(captured.address).toBe("93.184.216.34"); + }); + test("direct HTTPS connects only to the validated address with TLS verification", async () => { for (const key of proxyKeys) delete process.env[key]; const { providerOutboundGet } = await import("../../src/lib/provider-outbound"); diff --git a/tests/server/management-model-roster.test.ts b/tests/server/management-model-roster.test.ts index 4b950b1565a..493fb7f265c 100644 --- a/tests/server/management-model-roster.test.ts +++ b/tests/server/management-model-roster.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { + exportSnapshotIdentity, listManagementModelRows, loadExportModels, previewExportModels, @@ -131,6 +132,25 @@ describe("a preview reads only a roster an authoritative load already finished", expect(previewExportModels(CONFIG)).toBeNull(); }); + test("an unchanged roster keeps the identity a caller is already holding", async () => { + resetExportSnapshotForTests(); + await loadExportModels(CONFIG, SUPPLIED); + const held = exportSnapshotIdentity(CONFIG); + expect(held).not.toBeNull(); + + // An ordinary read rebuilds the same rows, and the Integrations collection performs one on + // every visit. Moving the identity for that turned a confirmation an operator was in the + // middle of submitting into a stale one, for no change they could see. + await loadExportModels(CONFIG, SUPPLIED); + expect(exportSnapshotIdentity(CONFIG)).toBe(held); + + // A roster that genuinely differs still takes a new identity. + await loadExportModels(CONFIG, [...SUPPLIED, { id: "second-model", provider: "supplied" }]); + const moved = exportSnapshotIdentity(CONFIG); + expect(moved).not.toBeNull(); + expect(moved).not.toBe(held); + }); + test("a wholesale clear retires the snapshot even though no provider counter survives it", async () => { resetExportSnapshotForTests(); await loadExportModels(CONFIG, SUPPLIED); From a1eeb80bd351b8d7937d1721eb5a97487a408f79 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 06:09:55 +0900 Subject: [PATCH 73/83] fix(integrations): repair the typecheck and answer both review findings The previous commit shadowed the `models` parameter of `loadExportModels` with a local of the same name, which every job that typechecks rejected. The retained projection has its own name now. **An undo's change list is derived from both sides.** It read only the prior record, so undoing an initial apply, which has no prior record, described a change to nothing at all while the undo removed the managed block, and undoing a disable described replacing paths the document does not currently have. The places are now the prior record's, as provenance must be, and each one is an addition or a replacement depending on whether the document holds it today, with anything ours now and not ours afterwards reported as a removal. The plan still names only declared managed paths and no value. **A bound Aside confirmation revalidates the roster identity.** Only the rows were carried into the mutation, so an ordinary load completing while the action prepared could replace or retire the retained snapshot while the guard happily fingerprinted the rows it was holding, and the preference and the profile file were then written from a roster the operator no longer has. The guard now carries the identity from `previewExportSnapshot` and verifies it before the preference write, as the non-Aside guard does, on both the toggle and the restore path. Keeping an identical roster's identity, which landed in the previous commit, is not a substitute for this: it says an unchanged roster should not invalidate a confirmation, while this says a changed one must. The guard is exported and takes only the configuration from the context, so the check can be exercised with a real prepared input and a roster that actually moved. That window needs an ordinary load to complete while a mutation is preparing, which no seam reaches from outside the module, and a copy of the guard would have proved nothing about the one the route installs. local checks: NOT RUN --- src/integrations/mutation-plan.ts | 26 +++++++-- src/server/management/aside-profile-routes.ts | 38 +++++++++---- src/server/management/model-rows.ts | 6 +- tests/clients/mutation-plan.test.ts | 56 +++++++++++++++++++ tests/providers/provider-outbound.test.ts | 3 +- tests/server/aside-profiles-routes.test.ts | 47 +++++++++++++++- 6 files changed, 157 insertions(+), 19 deletions(-) diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index e2ec2ecc1f9..061cccc2d7c 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -376,12 +376,30 @@ function changesOf(input: PlanInput): readonly IntegrationPlanChange[] { } } if (input.operation === "restore") { - // Provenance is restored, never re-derived, so the places an undo touches are the ones the - // row recorded as ours before that operation ran, not whatever a record says today. + /* + * An undo replaces the whole document, so what it changes is the difference between the + * places that are ours now and the places the row recorded as ours before that operation ran. + * + * Reading only the prior record got both common undos wrong. Undoing an initial apply has no + * prior record, so the plan described a change to nothing at all while the undo removed the + * managed block. Undoing a disable has a prior record and an empty document, so the plan said + * it would replace paths the file does not currently have. + * + * Provenance is still restored rather than re-derived: the prior record is what says which + * places are ours afterwards. The document only answers whether each of them is there now. + */ + const prior = new Map(); for (const fragment of input.restore?.entry.priorRecord?.fragmentPaths ?? []) { const path = canonicalSchemaPath(input.clientId, fragment); - if (path === null) continue; - changes.push({ kind: "replace", path }); + if (path !== null) prior.set(path, fragment); + } + for (const [path, fragment] of prior) { + changes.push({ kind: readPath(input.parsed, fragment) === undefined ? "add" : "replace", path }); + } + for (const fragment of input.record?.fragmentPaths ?? []) { + const path = canonicalSchemaPath(input.clientId, fragment); + if (path === null || prior.has(path)) continue; + changes.push({ kind: "remove", path }); } } changes.push({ kind: "snapshot", path: PLAN_SNAPSHOT_PATH }); diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index eba778a40ec..af0cc59cb85 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -15,7 +15,7 @@ import type { WriteRefused } from "../../integrations/writer"; import type { IntegrationWriteInput } from "../../integrations/writer"; import type { IntegrationMutationPlan, IntegrationPlanOperation } from "../../integrations/mutation-plan"; import { previewIntegration } from "../../integrations/mutation-plan"; -import { previewExportModels, previewExportSnapshot } from "./model-rows"; +import { exportSnapshotIdentity, previewExportSnapshot } from "./model-rows"; import type { ManagementContext } from "./context"; import { readManagementJsonBody, readOptionalManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { jsonResponse } from "../auth-cors"; @@ -62,8 +62,15 @@ function asideBinding( * reading the live configuration a second time, so a configuration edited to something else and * back again while this action was in flight produced a check that agreed with a plan the write * never described. + * + * Exported so the check itself can be exercised with a real prepared input and a roster that + * actually moved. The window it closes needs an ordinary load to complete while a mutation is + * preparing, which no test seam reaches from outside; a copy of the guard would prove nothing + * about the one the route installs. It takes only the configuration from the request context. */ -function asideGuardFor( +export function asideGuardFor( + ctx: Pick, + capturedIdentity: string, profileIdValue: number, binding: { operation: IntegrationPlanOperation; fingerprint: string }, request: { opId?: string; confirmDrift?: boolean; resolved?: AsideOperation }, @@ -81,14 +88,25 @@ function asideGuardFor( ? {} : { resolved: { entry: request.resolved.entry, store: request.resolved.store } }), }); - if (plan.canApply && plan.fingerprint === binding.fingerprint) return null; + /* + * The roster this confirmation was planned against has to still be the retained one. Only its + * rows were carried into the mutation, so an ordinary load completing while this action + * prepared could have replaced or retired the snapshot, and fingerprinting the carried rows + * would then accept a confirmation for a roster the operator no longer has. The non-Aside + * guard verifies the same identity; this one verifies it before the preference write, which is + * the last moment that still precedes every effect this action has. + */ + const rosterMoved = exportSnapshotIdentity(ctx.config) !== capturedIdentity; + if (!rosterMoved && plan.canApply && plan.fingerprint === binding.fingerprint) return null; capture.plan = plan; return { ok: false, reason: "conflict", state: plan.state, clientId: "aside", - message: "that confirmation no longer describes this profile", + message: rosterMoved + ? "the model roster changed while confirming" + : "that confirmation no longer describes this profile", profileId: profileIdValue, }; }; @@ -277,7 +295,7 @@ export async function handleAsideProfileRoutes( let mutationInput = options.input(); let revalidate: ((prepared: IntegrationWriteInput) => Promise) | undefined; if (binding !== null && id !== undefined) { - const roster = previewExportModels(ctx.config); + const roster = previewExportSnapshot(ctx.config); if (roster === null) { return jsonResponse({ error: "no model roster is cached yet, so this change cannot be planned", @@ -285,8 +303,8 @@ export async function handleAsideProfileRoutes( }, 409, req, ctx.config); } // One roster for the guard and the mutation, so they cannot disagree by construction. - mutationInput = { ...mutationInput, models: roster }; - revalidate = asideGuardFor(id, binding, {}, capture); + mutationInput = { ...mutationInput, models: roster.models }; + revalidate = asideGuardFor(ctx, roster.identity, id, binding, {}, capture); } const batch = await mutateAsideProfiles( mutationInput, @@ -365,15 +383,15 @@ export async function asideRestoreResponse( let restoreInput = input; let revalidate: ((prepared: IntegrationWriteInput) => Promise) | undefined; if (restoreBinding !== null) { - const roster = previewExportModels(ctx.config); + const roster = previewExportSnapshot(ctx.config); if (roster === null) { return jsonResponse({ error: "no model roster is cached yet, so this change cannot be planned", code: "integration_preview_unavailable", }, 409, ctx.req, ctx.config); } - restoreInput = { ...restoreInput, models: roster }; - revalidate = asideGuardFor(operation.profileId, restoreBinding, { + restoreInput = { ...restoreInput, models: roster.models }; + revalidate = asideGuardFor(ctx, roster.identity, operation.profileId, restoreBinding, { opId: body.opId, confirmDrift: body.confirmDrift === true, resolved: operation, diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index badadc8d6d4..a78d9ca3bbc 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -319,14 +319,14 @@ export async function loadExportModels( * are compared rather than assumed equal from the configuration and the cache stamp, because a * projection also reads entitlement state neither of those two describes. */ - const models = Object.freeze(structuredClone(exported)); + const projection = Object.freeze(structuredClone(exported)); if (retained !== null && retained.cacheStamp === cacheStamp && isExportConfigAdmissionCurrent(retained.admission, config) - && JSON.stringify(retained.models) === JSON.stringify(models)) { + && JSON.stringify(retained.models) === JSON.stringify(projection)) { return exported; } - lastExportSnapshot = { admission, cacheStamp, generation: ++exportSnapshotGeneration, models }; + lastExportSnapshot = { admission, cacheStamp, generation: ++exportSnapshotGeneration, models: projection }; return exported; } diff --git a/tests/clients/mutation-plan.test.ts b/tests/clients/mutation-plan.test.ts index 7d0687be841..366525a57ad 100644 --- a/tests/clients/mutation-plan.test.ts +++ b/tests/clients/mutation-plan.test.ts @@ -516,3 +516,59 @@ describe("planning an undo reads the writer's specification", () => { } }); }); + +const MANAGED_PATHS = [["settings", "providers", "opencodex"]]; + +describe("integration mutation plan: what an undo would change", () => { + const owned = { ...RECORD, fragmentPaths: MANAGED_PATHS }; + + test("undoing an initial apply takes the managed place back out", () => { + // There was no prior record, because nothing was ours before that apply. Reading only the + // prior record therefore described this undo as touching no managed place at all, while the + // undo removes the block it added. + const plan = buildMutationPlan({ + ...PLAN_BASE, + operation: "restore", + classified: { state: "current" }, + restore: { ...RESTORE, entry: { ...ENTRY, priorRecord: null } }, + record: owned, + parsed: OCCUPIED, + }); + + expect(plan.changes).toContainEqual({ kind: "remove", path: "settings.providers.opencodex" }); + expect(plan.changes.some(change => change.kind === "replace")).toBe(false); + }); + + test("undoing a disable adds back a place the document does not have", () => { + // The disable removed it, so the document has nothing there now. Calling that a replacement + // described a mutation of something that is not in the file. + const plan = buildMutationPlan({ + ...PLAN_BASE, + operation: "restore", + classified: { state: "absent" }, + restore: { ...RESTORE, entry: { ...ENTRY, kind: "disable", priorRecord: owned } }, + record: null, + parsed: {}, + }); + + expect(plan.changes).toContainEqual({ kind: "add", path: "settings.providers.opencodex" }); + expect(plan.changes.some(change => change.kind === "replace")).toBe(false); + }); + + test("a place that is ours on both sides is replaced, and none of this publishes a value", () => { + const plan = buildMutationPlan({ + ...PLAN_BASE, + operation: "restore", + classified: { state: "current" }, + restore: { ...RESTORE, entry: { ...ENTRY, priorRecord: owned } }, + record: owned, + parsed: OCCUPIED, + }); + + expect(plan.changes).toContainEqual({ kind: "replace", path: "settings.providers.opencodex" }); + expect(plan.changes.some(change => change.kind === "remove" || change.kind === "add")).toBe(false); + // The plan names places, never what is in them or where the file lives. + expect(JSON.stringify(plan)).not.toContain(CANARY); + expect(JSON.stringify(plan)).not.toContain(CONFIG_PATH); + }); +}); diff --git a/tests/providers/provider-outbound.test.ts b/tests/providers/provider-outbound.test.ts index 31a2e36c2c8..c0d5f59a2a9 100644 --- a/tests/providers/provider-outbound.test.ts +++ b/tests/providers/provider-outbound.test.ts @@ -68,9 +68,10 @@ describe("provider outbound GET transport", () => { * inside discovery and failed that provider for a reason nothing in its configuration * explains. A configured value means the built-in transport, which is what pins the peer. */ + const written = { baseUrl: "https://provider.example/v1", fetch: "https://not-an-executor.example" }; const response = await providerOutboundGet( "written-fetch", - { baseUrl: "https://provider.example/v1", fetch: "https://not-an-executor.example" } as never, + written as unknown as Parameters[1], "https://provider.example/v1/models", { headers: { authorization: "Bearer test-key" } }, dependencies, diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index 00d472eb5fe..aaf7b96042c 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -13,7 +13,10 @@ import type { OcxConfig } from "../../src/types"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -import { loadExportModels, resetExportSnapshotForTests } from "../../src/server/management/model-rows"; +import { exportSnapshotIdentity, loadExportModels, previewExportSnapshot, resetExportSnapshotForTests } from "../../src/server/management/model-rows"; +import { asideGuardFor } from "../../src/server/management/aside-profile-routes"; +import { previewIntegration, type IntegrationMutationPlan } from "../../src/integrations/mutation-plan"; +import { setCached } from "../../src/codex/model-cache"; let root: string; let home: string; @@ -395,3 +398,45 @@ test("dedicated nested paths retain profile scope for status, history and restor expect(document(2).providers.opencodex).toBeUndefined(); expect(document(0).providers.opencodex).toBeUndefined(); }); + +test("a bound confirmation is refused when the roster it was planned against has moved", async () => { + // The guard the route installs, exercised with the input a mutation would be written from. Only + // the rows are carried into the mutation, so an ordinary load completing while it prepares can + // replace or retire the snapshot without the carried rows noticing. + resetExportSnapshotForTests(); + // A roster with rows in it, so the plan below is a real apply rather than a noop. + await loadExportModels(config, [{ id: "one", provider: "fixture" }]); + const identity = exportSnapshotIdentity(config); + expect(identity).not.toBeNull(); + const roster = previewExportSnapshot(config); + expect(roster).not.toBeNull(); + + const profileStore = createIntegrationStateStore(join(root, "store", "aside-profiles", "1")); + const prepared = { + clientId: "aside" as const, + config, + models: roster!.models, + port: 10100, + env, + home, + store: profileStore, + resolvedPaths: { configPath: path(1), detectDir: join(home, ".aside", "u", "1") }, + }; + const plan = previewIntegration(prepared, { operation: "apply" as const, profileId: 1 }); + expect(plan.canApply).toBe(true); + + const capture: { plan: IntegrationMutationPlan | null } = { plan: null }; + const guard = asideGuardFor({ config }, identity!, 1, { operation: "apply", fingerprint: plan.fingerprint }, {}, capture); + + // Current roster, matching fingerprint: the confirmation stands. + expect(await guard(prepared)).toBeNull(); + + // A discovery publishes while this change is in flight. The rows the guard holds are unchanged, + // and they are no longer the roster the operator was shown. + expect(setCached("fixture", [{ id: "published-mid-flight", provider: "fixture" }])).toBe(true); + expect(exportSnapshotIdentity(config)).toBeNull(); + const refused = await guard(prepared); + expect(refused).toMatchObject({ ok: false, reason: "conflict", clientId: "aside", profileId: 1 }); + expect(refused?.message).toContain("roster changed"); + expect(capture.plan).not.toBeNull(); +}); From f4db3ff25716d2db8cf29f9d4b2c8deaf776a51f Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 06:16:42 +0900 Subject: [PATCH 74/83] test(management): separate a replaced roster from one rebuilt unchanged Keeping an identical roster's identity made the under-lock case pass for the wrong reason: its interleaved load rebuilt the same rows, so nothing was replaced and the confirmation was correctly accepted, while the case is there to prove a replacement is caught. The interleaved load now publishes a different roster, which is what the case describes, and a second one covers the other half: a load that rebuilds the same rows is not a replacement, and a confirmation an operator is submitting is not stale because the Integrations collection refreshed the list they were already looking at. local checks: NOT RUN --- .../management-integration-routes.test.ts | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index 4da38bbb96a..a8481c49911 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -1302,9 +1302,15 @@ describe("integration previews are reads", () => { * hardest to notice: after the request captured its input and before the guard runs under the * lock. A check that re-read the roster at that moment would validate the new one and write * the old, which is the failure this ordering exists to make impossible. + * + * The replacement has to be a different roster. Reloading the same rows publishes nothing an + * operator could see, and a confirmation is not stale because an unrelated read rebuilt the + * list it was already holding. */ const lockSeams: IntegrationWriterLockSeams = { - writeFile: async () => { await loadExportModels(config, []); }, + writeFile: async () => { + await loadExportModels(config, [{ id: "published-under-the-lock", provider: "a" }]); + }, removeFile: async () => {}, now: () => 0, delay: async () => {}, @@ -1325,6 +1331,36 @@ describe("integration previews are reads", () => { expect(storeContentWitness(storeRoot)).toBe(storeBefore); }); + test("a roster rebuilt unchanged while the lock is taken is not a replacement", async () => { + const configPath = installDsh(); + resetExportSnapshotForTests(); + await loadExportModels(config, []); + + const preview = await previewApi("/api/client-integrations/preview", { clientId: "dsh", operation: "apply" }); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + + // The Integrations collection performs an ordinary load on every visit, and one can land in + // this window. It rebuilds the same rows, so the confirmation an operator is submitting still + // describes what they were shown, and refusing it would be a conflict they cannot act on. + const lockSeams: IntegrationWriterLockSeams = { + writeFile: async () => { await loadExportModels(config, []); }, + removeFile: async () => {}, + now: () => 0, + delay: async () => {}, + pid: 12, + }; + setIntegrationMutationFlightTestHooks({ store, lockSeams }); + + const commit = await api("/api/client-integrations/dsh", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ enabled: true, operation: "apply", planFingerprint: plan.fingerprint }), + }); + expect(commit.status).toBe(200); + expect(existsSync(configPath)).toBe(true); + }); + test("a previewed undo commits with its binding", async () => { const configPath = installHermes(); resetExportSnapshotForTests(); From 80a1c4fa539b36d647464e516280591bf7eb48c1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 06:20:18 +0900 Subject: [PATCH 75/83] fix(integrations): reach the undo classification from a real preview, and refuse before preparing Two gaps from the interdiff review. The restore preview passed no record and no document, so the classification the previous commit added could only ever be reached by a builder supplying those inputs by hand. It now reads both the way the general observation reads them: the ownership record from the store the restore itself selects, which is a profile's own store for a profile row, and the document the undo would rewrite. Neither can refuse anything. Restore eligibility stays the byte comparison it was, and a document that cannot be parsed is reported as such and leaves every place a replacement, which is what the list said before any of this was derived. The regressions are real work now: an apply that happened, then its undo reporting the managed places being taken back out; a disable that happened, then its undo reporting them added back; and the same undo over an unreadable document, which still plans and reports replacements instead of inventing an answer. The missing-profile refusal ran after preparation, so a confirmation this cannot check at all still resolved the roster first, and a supported loader reaches providers and can finalize an initial model selection. It now refuses before anything is prepared, and the case counts loader calls rather than passing an inert array that could not have observed the difference. local checks: NOT RUN --- src/integrations/aside-profiles.ts | 22 +++-- src/integrations/mutation-plan.ts | 41 +++++++++- .../integrations-writer-frozen-config.test.ts | 11 ++- tests/clients/mutation-plan.test.ts | 82 +++++++++++++++++++ 4 files changed, 143 insertions(+), 13 deletions(-) diff --git a/src/integrations/aside-profiles.ts b/src/integrations/aside-profiles.ts index 6cea53656ea..6026d9d0fb8 100644 --- a/src/integrations/aside-profiles.ts +++ b/src/integrations/aside-profiles.ts @@ -171,6 +171,16 @@ export function mutateAsideProfiles( */ const prepared = new Map(); if (options?.revalidate) { + /* + * Refused before anything is prepared. Preparing resolves the roster, and a supported loader + * reaches providers and can finalize an initial model selection, so a confirmation this + * cannot check at all must not cause that work first. A confirmation describes one profile, + * and HTTP refuses one that names none; guessing which prepared input a bindingless + * confirmation meant would invent the thing the check exists to verify. + */ + if (change.profileId === undefined) { + throw new AsideProfileErrorClass("invalid_aside_profile", 400, "a confirmed change applies to one profile"); + } for (const profile of profiles) { if (refused.has(profile.id)) continue; try { prepared.set(profile.id, await asideWriteInput(ctx, asideProfileScope(ctx, profile))); } @@ -185,20 +195,14 @@ export function mutateAsideProfiles( * path selection is frozen by this point, which is everything the check needs. */ if (options?.revalidate) { - // A confirmation describes one profile, and HTTP refuses one that names none. This refuses - // it too: guessing which prepared input a bindingless confirmation meant would be inventing - // the thing the check exists to verify. - if (change.profileId === undefined) { - throw new AsideProfileErrorClass("invalid_aside_profile", 400, "a confirmed change applies to one profile"); - } - const guarded = prepared.get(change.profileId); + const guarded = change.profileId === undefined ? undefined : prepared.get(change.profileId); // Nothing prepared is nothing to check and nothing to write; refusing keeps a binding from // being dropped on the way to a write that would then be unchecked. const stale: AsideProfileWriteOutcome | null = guarded === undefined - ? refused.get(change.profileId) ?? { + ? refused.get(change.profileId ?? -1) ?? { ok: false, reason: "unsafe", state: "conflict", clientId: "aside", message: "that profile cannot be prepared for this change", - profileId: change.profileId, + profileId: change.profileId ?? profiles[0]?.id ?? 0, } : await options.revalidate(guarded); if (stale) { diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index 061cccc2d7c..a770446ad9d 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -393,8 +393,15 @@ function changesOf(input: PlanInput): readonly IntegrationPlanChange[] { const path = canonicalSchemaPath(input.clientId, fragment); if (path !== null) prior.set(path, fragment); } + /* + * A document we could not read says nothing about whether a place is there, and restore does + * not need it read: eligibility is a question about bytes. Where it cannot be read, each place + * is reported as a replacement, which is what this list said before any of it was derived. + */ + const documentKnown = input.parsed !== PARSE_FAILED; for (const [path, fragment] of prior) { - changes.push({ kind: readPath(input.parsed, fragment) === undefined ? "add" : "replace", path }); + const absent = documentKnown && readPath(input.parsed, fragment) === undefined; + changes.push({ kind: absent ? "add" : "replace", path }); } for (const fragment of input.record?.fragmentPaths ?? []) { const path = canonicalSchemaPath(input.clientId, fragment); @@ -594,6 +601,25 @@ export function previewIntegration(input: IntegrationWriteInput, request: Previe return buildMutationPlan(shared); } +/** + * Which places are ours in the file this undo would rewrite, read the same way the general + * observation reads them. + * + * The store is selected exactly as the restore observation selects it, so a profile's own store is + * consulted for a profile row rather than the root one. A record written for another location + * grants nothing here, which is the same rule the writer applies. + */ +function currentRecordFor( + input: IntegrationWriteInput, + request: PreviewRequest, + clientId: IntegrationClientId, + configPath: string, +): OwnershipRecord | null { + const store = request.resolved?.store ?? input.store ?? createIntegrationStateStore(); + const stored = store.readRecords()[clientId] ?? null; + return stored && stored.clientId === clientId && stored.configPath === configPath ? stored : null; +} + /** * Plan an undo the way the writer performs one. * @@ -633,10 +659,19 @@ function previewRestore(input: IntegrationWriteInput, request: PreviewRequest): admissionBlocked: false, before: observed.before, contribution: null, - record: null, + // Descriptive, never decisive. The record says which places are ours now and the document + // says which of them the file holds, so the change list can distinguish a place this undo + // adds back from one it replaces and one it takes away. Neither is allowed to refuse: an + // unreadable document is reported as PARSE_FAILED and leaves every place a replacement, and + // restore eligibility stays the byte comparison it was. + record: currentRecordFor(input, request, observed.clientId, observed.configPath), models: input.models, classified: { state }, - parsed: undefined, + parsed: observed.before === null + ? {} + : observed.clientId === "cline" + ? parseClineDocument(observed.before) + : parseConfig(observed.before, EXPORT_CLIENTS[observed.clientId].format), restore: { opId: observed.entry.opId, entry: observed.entry, diff --git a/tests/clients/integrations-writer-frozen-config.test.ts b/tests/clients/integrations-writer-frozen-config.test.ts index b807bf324ae..11a73d9e9a1 100644 --- a/tests/clients/integrations-writer-frozen-config.test.ts +++ b/tests/clients/integrations-writer-frozen-config.test.ts @@ -381,13 +381,22 @@ test("a confirmed change that names no profile is refused rather than guessed", const config = asideFixtureConfig(CHECKED_HOST); let checked = false; + let loads = 0; await expect(mutateAsideProfiles( - { config, models: MODELS, port: 10100, env: {} as NodeJS.ProcessEnv, home, store, persistConfig: () => {} }, + { + config, + // A loader, not an array: resolving the roster is real work that reaches providers and can + // finalize an initial model selection, and a confirmation this cannot check must not cause + // it. An inert array would hide that. + models: () => { loads += 1; return MODELS; }, + port: 10100, env: {} as NodeJS.ProcessEnv, home, store, persistConfig: () => {}, + }, { enabled: true }, { revalidate: async () => { checked = true; return null; } }, )).rejects.toThrow(/one profile/); expect(checked).toBe(false); + expect(loads).toBe(0); expect(config.asideProfileSync).toBeUndefined(); expect(readFileSync(profilePath, "utf8")).toBe(before); }); diff --git a/tests/clients/mutation-plan.test.ts b/tests/clients/mutation-plan.test.ts index 366525a57ad..04b5da9ec26 100644 --- a/tests/clients/mutation-plan.test.ts +++ b/tests/clients/mutation-plan.test.ts @@ -26,6 +26,7 @@ import { fingerprint } from "../../src/integrations/ownership"; import type { JournalEntry } from "../../src/integrations/journal"; import type { OcxConfig } from "../../src/types"; import { createIntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration, disableIntegration } from "../../src/integrations/writer"; import { clinePendingPath } from "../../src/integrations/cline-io"; import { resolveIntegrationPaths } from "../../src/integrations/registry"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -572,3 +573,84 @@ describe("integration mutation plan: what an undo would change", () => { expect(JSON.stringify(plan)).not.toContain(CONFIG_PATH); }); }); + +describe("planning an undo of work that actually happened", () => { + function realApply(home: string, storeRoot: string) { + const store = createIntegrationStateStore(storeRoot); + const env = {} as NodeJS.ProcessEnv; + const { configPath, detectDir } = resolveIntegrationPaths("opencode", env, home); + mkdirSync(detectDir, { recursive: true }); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, "{}\n"); + const input = { clientId: "opencode" as const, models: FIXTURE_MODELS, config: FIXTURE_CONFIG, port: 10100, env, home, store }; + const applied = applyIntegration(input); + if (!applied.ok || !applied.opId) throw new Error("fixture apply failed"); + return { input, configPath, applyOpId: applied.opId }; + } + + test("undoing a real apply reports taking the managed places back out", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-undo-apply-")); + const storeRoot = mkdtempSync(join(tmpdir(), "ocx-undo-apply-store-")); + try { + const { input, applyOpId } = realApply(home, storeRoot); + + // Every input this classification reads comes from the apply that just happened: the + // ownership record it wrote and the document it produced, not values a builder supplied. + const plan = previewIntegration(input, { operation: "restore", opId: applyOpId }); + + expect(plan.canApply).toBe(true); + expect(plan.changes.some(change => change.kind === "remove")).toBe(true); + expect(plan.changes.some(change => change.kind === "replace")).toBe(false); + expect(plan.changes.some(change => change.kind === "add")).toBe(false); + } finally { + removeTreeWithRetry(home); + removeTreeWithRetry(storeRoot); + } + }); + + test("undoing a real disable reports adding the managed places back", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-undo-disable-")); + const storeRoot = mkdtempSync(join(tmpdir(), "ocx-undo-disable-store-")); + try { + const { input, configPath } = realApply(home, storeRoot); + const disabled = disableIntegration(input); + if (!disabled.ok || !disabled.opId) throw new Error("fixture disable failed"); + // The disable took the block out, so the document does not hold those places now. + expect(readFileSync(configPath, "utf8")).not.toContain("opencodex"); + + const plan = previewIntegration(input, { operation: "restore", opId: disabled.opId }); + + expect(plan.canApply).toBe(true); + expect(plan.changes.some(change => change.kind === "add")).toBe(true); + expect(plan.changes.some(change => change.kind === "replace")).toBe(false); + } finally { + removeTreeWithRetry(home); + removeTreeWithRetry(storeRoot); + } + }); + + test("a document that cannot be read leaves every place a replacement and refuses nothing", () => { + const home = mkdtempSync(join(tmpdir(), "ocx-undo-unreadable-")); + const storeRoot = mkdtempSync(join(tmpdir(), "ocx-undo-unreadable-store-")); + try { + const { input, configPath } = realApply(home, storeRoot); + const disabled = disableIntegration(input); + if (!disabled.ok || !disabled.opId) throw new Error("fixture disable failed"); + // Restore eligibility is a byte comparison and stays one: the document is descriptive here, + // so an unparseable file still plans, and the places it cannot speak for are replacements. + writeFileSync(configPath, "{ this is not valid json"); + + const plan = previewIntegration(input, { operation: "restore", opId: disabled.opId, confirmDrift: true }); + + expect(plan.refusalReason).toBeUndefined(); + expect(plan.canApply).toBe(true); + // The same undo reported additions when the document was readable and empty of them. With + // nothing readable to ask, it says replacement rather than inventing an answer. + expect(plan.changes.some(change => change.kind === "replace")).toBe(true); + expect(plan.changes.some(change => change.kind === "add")).toBe(false); + } finally { + removeTreeWithRetry(home); + removeTreeWithRetry(storeRoot); + } + }); +}); From ea4b55f8872dbb4a57d94acbc27b33c302a09d97 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 06:28:45 +0900 Subject: [PATCH 76/83] fix(integrations): read an undo's current ownership from the file it rewrites The restore preview asked the store the historical row was selected out of. Aside keeps a copy of an operation in the root store while the profile's own store holds the ownership for its file, so on exactly the case that exists to prove resolution picks one copy, the classification described a different file's ownership. It now reads the store bound to the target being rewritten, and the selected store stays what it is for: the row and its snapshot. The alternate-copy roundtrip, which already witnesses the restored bytes, the journals and the untouched sibling, now also asserts what the plan said about the managed place, so that case covers the store selection it was already set up to expose. The ordinary apply and disable cases no longer stop at the preview: each one performs the undo it planned and checks the file afterwards, so the plan and the bytes are asserted together rather than the plan alone. The Aside loader fixture is declared async, matching the shape the input accepts. local checks: NOT RUN --- src/integrations/mutation-plan.ts | 14 ++++++++------ .../integrations-writer-frozen-config.test.ts | 2 +- tests/clients/mutation-plan.test.ts | 15 +++++++++++++-- tests/server/aside-profiles-routes.test.ts | 13 ++++++++++++- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/integrations/mutation-plan.ts b/src/integrations/mutation-plan.ts index a770446ad9d..20cc91c9f6a 100644 --- a/src/integrations/mutation-plan.ts +++ b/src/integrations/mutation-plan.ts @@ -605,17 +605,19 @@ export function previewIntegration(input: IntegrationWriteInput, request: Previe * Which places are ours in the file this undo would rewrite, read the same way the general * observation reads them. * - * The store is selected exactly as the restore observation selects it, so a profile's own store is - * consulted for a profile row rather than the root one. A record written for another location - * grants nothing here, which is the same rule the writer applies. + * Read from the store bound to the target being rewritten, never from the store a historical row + * was selected out of. Aside keeps a copy of an operation in the root store while the profile's + * own store holds the ownership for its file, so asking the selected row's store would have + * described the wrong file's ownership. The selected store stays what it is for: the row and its + * snapshot. A record written for another location grants nothing here either, which is the same + * rule the writer applies. */ function currentRecordFor( input: IntegrationWriteInput, - request: PreviewRequest, clientId: IntegrationClientId, configPath: string, ): OwnershipRecord | null { - const store = request.resolved?.store ?? input.store ?? createIntegrationStateStore(); + const store = input.store ?? createIntegrationStateStore(); const stored = store.readRecords()[clientId] ?? null; return stored && stored.clientId === clientId && stored.configPath === configPath ? stored : null; } @@ -664,7 +666,7 @@ function previewRestore(input: IntegrationWriteInput, request: PreviewRequest): // adds back from one it replaces and one it takes away. Neither is allowed to refuse: an // unreadable document is reported as PARSE_FAILED and leaves every place a replacement, and // restore eligibility stays the byte comparison it was. - record: currentRecordFor(input, request, observed.clientId, observed.configPath), + record: currentRecordFor(input, observed.clientId, observed.configPath), models: input.models, classified: { state }, parsed: observed.before === null diff --git a/tests/clients/integrations-writer-frozen-config.test.ts b/tests/clients/integrations-writer-frozen-config.test.ts index 11a73d9e9a1..34804f505f6 100644 --- a/tests/clients/integrations-writer-frozen-config.test.ts +++ b/tests/clients/integrations-writer-frozen-config.test.ts @@ -388,7 +388,7 @@ test("a confirmed change that names no profile is refused rather than guessed", // A loader, not an array: resolving the roster is real work that reaches providers and can // finalize an initial model selection, and a confirmation this cannot check must not cause // it. An inert array would hide that. - models: () => { loads += 1; return MODELS; }, + models: async () => { loads += 1; return MODELS; }, port: 10100, env: {} as NodeJS.ProcessEnv, home, store, persistConfig: () => {}, }, { enabled: true }, diff --git a/tests/clients/mutation-plan.test.ts b/tests/clients/mutation-plan.test.ts index 04b5da9ec26..4af951a290c 100644 --- a/tests/clients/mutation-plan.test.ts +++ b/tests/clients/mutation-plan.test.ts @@ -26,7 +26,7 @@ import { fingerprint } from "../../src/integrations/ownership"; import type { JournalEntry } from "../../src/integrations/journal"; import type { OcxConfig } from "../../src/types"; import { createIntegrationStateStore } from "../../src/integrations/store"; -import { applyIntegration, disableIntegration } from "../../src/integrations/writer"; +import { applyIntegration, disableIntegration, restoreIntegration } from "../../src/integrations/writer"; import { clinePendingPath } from "../../src/integrations/cline-io"; import { resolveIntegrationPaths } from "../../src/integrations/registry"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -592,7 +592,8 @@ describe("planning an undo of work that actually happened", () => { const home = mkdtempSync(join(tmpdir(), "ocx-undo-apply-")); const storeRoot = mkdtempSync(join(tmpdir(), "ocx-undo-apply-store-")); try { - const { input, applyOpId } = realApply(home, storeRoot); + const { input, configPath, applyOpId } = realApply(home, storeRoot); + expect(readFileSync(configPath, "utf8")).toContain("opencodex"); // Every input this classification reads comes from the apply that just happened: the // ownership record it wrote and the document it produced, not values a builder supplied. @@ -602,6 +603,11 @@ describe("planning an undo of work that actually happened", () => { expect(plan.changes.some(change => change.kind === "remove")).toBe(true); expect(plan.changes.some(change => change.kind === "replace")).toBe(false); expect(plan.changes.some(change => change.kind === "add")).toBe(false); + + // And the undo does what the plan said: the managed block is gone from the file. + const restored = restoreIntegration({ ...input, opId: applyOpId }); + expect(restored.ok).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe("{}\n"); } finally { removeTreeWithRetry(home); removeTreeWithRetry(storeRoot); @@ -623,6 +629,11 @@ describe("planning an undo of work that actually happened", () => { expect(plan.canApply).toBe(true); expect(plan.changes.some(change => change.kind === "add")).toBe(true); expect(plan.changes.some(change => change.kind === "replace")).toBe(false); + + // And the undo does what the plan said: the managed block is back in the file. + const restored = restoreIntegration({ ...input, opId: disabled.opId }); + expect(restored.ok).toBe(true); + expect(readFileSync(configPath, "utf8")).toContain("opencodex"); } finally { removeTreeWithRetry(home); removeTreeWithRetry(storeRoot); diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index aaf7b96042c..09fc9e6ae06 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -312,9 +312,20 @@ test("a bound undo follows the copy resolution actually chose", async () => { const sibling2Before = readFileSync(path(2), "utf8"); const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { operation: "restore", opId }); expect(preview.status).toBe(200); - const plan = await preview.json() as { canApply: boolean; fingerprint: string; profileId?: number }; + const plan = await preview.json() as { + canApply: boolean; fingerprint: string; profileId?: number; + changes: Array<{ kind: string; path: string }>; + }; expect(plan.canApply).toBe(true); expect(plan.profileId).toBe(1); + /* + * This undo takes the managed block back out, because the apply it reverses had nothing of ours + * before it. Saying so requires reading the ownership of the file being rewritten, which lives + * in the profile's own store, while the row and its snapshot were resolved out of the root one. + * Reading the selected row's store instead would describe another file's ownership. + */ + expect(plan.changes.some(change => change.kind === "remove")).toBe(true); + expect(plan.changes.some(change => change.kind === "add")).toBe(false); const undo = await api("/api/client-integrations/aside/profiles/1/restore", "POST", { opId, operation: "restore", planFingerprint: plan.fingerprint, From fe77823a81116ef914124bc387cbc7bbbad62927 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 07:10:34 +0900 Subject: [PATCH 77/83] fix(integrations): refuse an unscoped Aside plan at the generic preview routes Aside is a set of profiles rather than one file, and every mutation it has requires one. The generic preview accepted `clientId: "aside"` because it is a registered integration client, and the generic restore preview accepted a root journal row whose client is Aside, so both produced a plan describing the legacy single-account location. No bound mutation accepts that: the mutation routes already refuse the unscoped spelling with 400 invalid_aside_profile_path. An operator could therefore be handed a confirmation nothing could carry out. Both generic routes now refuse it with that same response. The restore decision is taken after the row is found, so an unscoped undo is refused for what it is rather than reported as a missing operation. The canonical per-profile preview and restore are untouched, as is every other client. Two HTTP cases cover it, each witnessing that the client home and the whole store are unchanged byte for byte, and the second also shows an operation belonging to another client still planning through the same route. The management API reference names the refusal on both generic rows, in English and in all seven translated locales. local checks: NOT RUN --- .../docs/fr/reference/management-api.md | 4 +- .../docs/ja/reference/management-api.md | 4 +- .../docs/ko/reference/management-api.md | 4 +- .../content/docs/reference/management-api.md | 4 +- .../docs/ru/reference/management-api.md | 4 +- .../docs/tr/reference/management-api.md | 4 +- .../docs/zh-cn/reference/management-api.md | 4 +- .../docs/zh-tw/reference/management-api.md | 4 +- src/server/management/integration-routes.ts | 15 ++++++ .../management-integration-routes.test.ts | 52 +++++++++++++++++++ 10 files changed, 83 insertions(+), 16 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index 2b7b9149330..2e0fcddea25 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -111,8 +111,8 @@ maintenance, ni récupération. | Méthode et chemin | Objet | Erreurs notables | | --- | --- | --- | -| `POST /api/client-integrations/preview` | Planifier `apply`, `overwrite` ou `disable` pour un client ; corps `{ "clientId": "...", "operation": "..." }` | 400 client ou opération invalide ; 409 `integration_preview_unavailable` | -| `POST /api/client-integrations/restore/preview` | Planifier une annulation ; corps `{ "opId": "...", "confirmDrift": false }` | 404 opération inconnue ; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/preview` | Planifier `apply`, `overwrite` ou `disable` pour un client ; corps `{ "clientId": "...", "operation": "..." }` | 400 client ou opération invalide ; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | Planifier une annulation ; corps `{ "opId": "...", "confirmDrift": false }` | 404 opération inconnue ; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | | `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Planifier la modification d'un seul profil Aside ; `restore` exige un `opId` | 400 corps invalide ou profil non précisé ; 404 profil ou opération inconnus | Un plan contient `version`, `clientId`, `operation`, `state`, `foreignEdit`, une liste `changes` diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 1917c0f2da4..f414d5afba0 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -89,8 +89,8 @@ Authorization: Bearer | メソッドとパス | 目的 | 主なエラー | | --- | --- | --- | -| `POST /api/client-integrations/preview` | クライアント 1 つの `apply`、`overwrite`、`disable` を計画します。本文は `{ "clientId": "...", "operation": "..." }` | 400 不正なクライアントまたは操作、409 `integration_preview_unavailable` | -| `POST /api/client-integrations/restore/preview` | 取り消しを計画します。本文は `{ "opId": "...", "confirmDrift": false }` | 404 該当操作なし、409 `integration_preview_unavailable` | +| `POST /api/client-integrations/preview` | クライアント 1 つの `apply`、`overwrite`、`disable` を計画します。本文は `{ "clientId": "...", "operation": "..." }` | 400 不正なクライアントまたは操作、400 `invalid_aside_profile_path`、409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | 取り消しを計画します。本文は `{ "opId": "...", "confirmDrift": false }` | 404 該当操作なし、400 `invalid_aside_profile_path`、409 `integration_preview_unavailable` | | `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Aside プロファイル 1 つの変更を計画します。`restore` には `opId` が必要です | 400 不正な本文またはプロファイル未指定、404 該当プロファイルまたは操作なし | 計画には `version`、`clientId`、`operation`、`state`、`foreignEdit`、`kind` と `path` の組からなる diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index bf18079d951..bf98d22a5af 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -93,8 +93,8 @@ Authorization: Bearer | 메서드 및 경로 | 목적 | 주요 오류 | | --- | --- | --- | -| `POST /api/client-integrations/preview` | 클라이언트 하나의 `apply`, `overwrite`, `disable`을 계획합니다. 본문은 `{ "clientId": "...", "operation": "..." }` | 400 잘못된 클라이언트나 작업, 409 `integration_preview_unavailable` | -| `POST /api/client-integrations/restore/preview` | 실행 취소를 계획합니다. 본문은 `{ "opId": "...", "confirmDrift": false }` | 404 없는 작업, 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/preview` | 클라이언트 하나의 `apply`, `overwrite`, `disable`을 계획합니다. 본문은 `{ "clientId": "...", "operation": "..." }` | 400 잘못된 클라이언트나 작업, 400 `invalid_aside_profile_path`, 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | 실행 취소를 계획합니다. 본문은 `{ "opId": "...", "confirmDrift": false }` | 404 없는 작업, 400 `invalid_aside_profile_path`, 409 `integration_preview_unavailable` | | `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Aside 프로필 하나의 변경을 계획합니다. `restore`에는 `opId`가 필요합니다 | 400 잘못된 본문이나 프로필 미지정, 404 없는 프로필이나 작업 | 계획에는 `version`, `clientId`, `operation`, `state`, `foreignEdit`, `kind`와 `path` 쌍으로 이루어진 diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index aaf9d01fd0a..f32b8662941 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -131,8 +131,8 @@ snapshot, no ownership record, no journal row, no lock, and no maintenance or re | Route | Purpose | Notable responses | | --- | --- | --- | -| `POST /api/client-integrations/preview` | Plan `apply`, `overwrite` or `disable` for one client; body `{ "clientId": "...", "operation": "..." }` | 400 invalid client or operation; 409 `integration_preview_unavailable` | -| `POST /api/client-integrations/restore/preview` | Plan an undo; body `{ "opId": "...", "confirmDrift": false }` | 404 unknown operation; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/preview` | Plan `apply`, `overwrite` or `disable` for one client; body `{ "clientId": "...", "operation": "..." }` | 400 invalid client or operation; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | Plan an undo; body `{ "opId": "...", "confirmDrift": false }` | 404 unknown operation; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | | `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Plan one Aside profile's change, including `restore` with an `opId` | 400 invalid body or unscoped request; 404 unknown profile or operation | A plan carries `version`, `clientId`, `operation`, `state`, `foreignEdit`, a `changes` list of diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index ad0b035a2ba..5943cd0a22f 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -112,8 +112,8 @@ GUI-сессия в стиле loopback не выпускается. | Метод и путь | Назначение | Основные ошибки | | --- | --- | --- | -| `POST /api/client-integrations/preview` | Спланировать `apply`, `overwrite` или `disable` для одного клиента; тело `{ "clientId": "...", "operation": "..." }` | 400 неверный клиент или операция; 409 `integration_preview_unavailable` | -| `POST /api/client-integrations/restore/preview` | Спланировать отмену; тело `{ "opId": "...", "confirmDrift": false }` | 404 операция не найдена; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/preview` | Спланировать `apply`, `overwrite` или `disable` для одного клиента; тело `{ "clientId": "...", "operation": "..." }` | 400 неверный клиент или операция; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | Спланировать отмену; тело `{ "opId": "...", "confirmDrift": false }` | 404 операция не найдена; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | | `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Спланировать изменение одного профиля Aside; для `restore` нужен `opId` | 400 неверное тело или профиль не указан; 404 профиль или операция не найдены | План содержит `version`, `clientId`, `operation`, `state`, `foreignEdit`, список `changes` из пар diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index 488cfac4dd4..a436f050625 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -117,8 +117,8 @@ görüntü, sahiplik kaydı, günlük satırı, kilit, bakım ve kurtarma yoktur | Yöntem ve yol | Amaç | Önemli hatalar | | --- | --- | --- | -| `POST /api/client-integrations/preview` | Tek bir istemci için `apply`, `overwrite` veya `disable` planlar; gövde `{ "clientId": "...", "operation": "..." }` | 400 geçersiz istemci veya işlem; 409 `integration_preview_unavailable` | -| `POST /api/client-integrations/restore/preview` | Bir geri almayı planlar; gövde `{ "opId": "...", "confirmDrift": false }` | 404 işlem bulunamadı; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/preview` | Tek bir istemci için `apply`, `overwrite` veya `disable` planlar; gövde `{ "clientId": "...", "operation": "..." }` | 400 geçersiz istemci veya işlem; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | Bir geri almayı planlar; gövde `{ "opId": "...", "confirmDrift": false }` | 404 işlem bulunamadı; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | | `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Tek bir Aside profilinin değişikliğini planlar; `restore` için `opId` gerekir | 400 geçersiz gövde veya profil belirtilmemiş; 404 profil veya işlem bulunamadı | Plan; `version`, `clientId`, `operation`, `state`, `foreignEdit`, `kind` ve `path` çiftlerinden diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index fa1c00fad59..84b19cce63a 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -93,8 +93,8 @@ Authorization: Bearer | 方法与路径 | 用途 | 主要错误 | | --- | --- | --- | -| `POST /api/client-integrations/preview` | 为一个客户端规划 `apply`、`overwrite` 或 `disable`;请求体为 `{ "clientId": "...", "operation": "..." }` | 400 客户端或操作无效;409 `integration_preview_unavailable` | -| `POST /api/client-integrations/restore/preview` | 规划一次撤销;请求体为 `{ "opId": "...", "confirmDrift": false }` | 404 操作不存在;409 `integration_preview_unavailable` | +| `POST /api/client-integrations/preview` | 为一个客户端规划 `apply`、`overwrite` 或 `disable`;请求体为 `{ "clientId": "...", "operation": "..." }` | 400 客户端或操作无效;400 `invalid_aside_profile_path`;409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | 规划一次撤销;请求体为 `{ "opId": "...", "confirmDrift": false }` | 404 操作不存在;400 `invalid_aside_profile_path`;409 `integration_preview_unavailable` | | `POST /api/client-integrations/aside/profiles/{profileId}/preview` | 规划单个 Aside 配置档的变更;`restore` 需要 `opId` | 400 请求体无效或未指定配置档;404 配置档或操作不存在 | 计划包含 `version`、`clientId`、`operation`、`state`、`foreignEdit`,由 `kind` 与 `path` 组成的 diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index bf21891f09c..2837c92779b 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -89,8 +89,8 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | 方法與路徑 | 用途 | 主要錯誤 | | --- | --- | --- | -| `POST /api/client-integrations/preview` | 為單一用戶端規劃 `apply`、`overwrite` 或 `disable`;請求內容為 `{ "clientId": "...", "operation": "..." }` | 400 用戶端或操作無效;409 `integration_preview_unavailable` | -| `POST /api/client-integrations/restore/preview` | 規劃一次復原;請求內容為 `{ "opId": "...", "confirmDrift": false }` | 404 操作不存在;409 `integration_preview_unavailable` | +| `POST /api/client-integrations/preview` | 為單一用戶端規劃 `apply`、`overwrite` 或 `disable`;請求內容為 `{ "clientId": "...", "operation": "..." }` | 400 用戶端或操作無效;400 `invalid_aside_profile_path`;409 `integration_preview_unavailable` | +| `POST /api/client-integrations/restore/preview` | 規劃一次復原;請求內容為 `{ "opId": "...", "confirmDrift": false }` | 404 操作不存在;400 `invalid_aside_profile_path`;409 `integration_preview_unavailable` | | `POST /api/client-integrations/aside/profiles/{profileId}/preview` | 規劃單一 Aside 設定檔的變更;`restore` 需要 `opId` | 400 請求內容無效或未指定設定檔;404 設定檔或操作不存在 | 計畫包含 `version`、`clientId`、`operation`、`state`、`foreignEdit`,由 `kind` 與 `path` 組成的 diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index 487341b0ba5..7ae8a16d466 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -706,6 +706,16 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { expect(storeContents()).toBe(storeBefore); }); }); + +describe("the generic preview routes are not an unscoped door into Aside", () => { + /* + * Aside is a set of profiles, not one file. A plan built through the generic routes would + * describe the legacy single-account location, and no bound mutation accepts that: the mutation + * routes already refuse the unscoped spelling. Answering it here would hand an operator a + * confirmation nothing can carry out. + */ + test("planning apply for aside without a profile is refused, and writes nothing", async () => { + const homeBefore = storeContentWitness(home); + const storeBefore = storeContentWitness(storeRoot); + + const response = await previewApi("/api/client-integrations/preview", { clientId: "aside", operation: "apply" }); + + expect(response.status).toBe(400); + expect((await response.json() as { code: string }).code).toBe("invalid_aside_profile_path"); + expect(storeContentWitness(home)).toBe(homeBefore); + expect(storeContentWitness(storeRoot)).toBe(storeBefore); + }); + + test("planning an undo of an aside operation without a profile is refused for what it is", async () => { + // The row is found first, so the answer names the unscoped path rather than reporting an + // operation that is plainly there as missing. + store.appendJournal({ + opId: "aside-unscoped-op", + clientId: "aside", + kind: "apply", + at: new Date(0).toISOString(), + configPath: join(home, ".aside", "u", "1", "models.json"), + snapshot: { kind: "none" }, + resultFingerprint: "0123456789abcdef", + resultAbsent: false, + priorRecord: null, + }); + const homeBefore = storeContentWitness(home); + const storeBefore = storeContentWitness(storeRoot); + + const response = await previewApi("/api/client-integrations/restore/preview", { opId: "aside-unscoped-op" }); + + expect(response.status).toBe(400); + expect((await response.json() as { code: string }).code).toBe("invalid_aside_profile_path"); + expect(storeContentWitness(home)).toBe(homeBefore); + expect(storeContentWitness(storeRoot)).toBe(storeBefore); + + // Narrow: an operation belonging to any other client still plans here. + installHermes(); + resetExportSnapshotForTests(); + await loadExportModels(config, []); + const ok = await previewApi("/api/client-integrations/preview", { clientId: "hermes", operation: "apply" }); + expect(ok.status).toBe(200); + }); +}); From 0c671271067f2c8fcaa1f17dad2fcb7c55958fc8 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 07:29:43 +0900 Subject: [PATCH 78/83] docs(management-api): name the unavailable-roster refusal on the profile preview row The canonical per-profile preview answers 409 integration_preview_unavailable when no roster has been retained, exactly as the two generic preview routes do, and only those two rows said so. The row now lists it, in English and in all seven translated locales, each using its own separator. Documentation only: no route, behaviour or test changes. local checks: NOT RUN --- docs-site/src/content/docs/fr/reference/management-api.md | 2 +- docs-site/src/content/docs/ja/reference/management-api.md | 2 +- docs-site/src/content/docs/ko/reference/management-api.md | 2 +- docs-site/src/content/docs/reference/management-api.md | 2 +- docs-site/src/content/docs/ru/reference/management-api.md | 2 +- docs-site/src/content/docs/tr/reference/management-api.md | 2 +- docs-site/src/content/docs/zh-cn/reference/management-api.md | 2 +- docs-site/src/content/docs/zh-tw/reference/management-api.md | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index 2e0fcddea25..3f037430e03 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -113,7 +113,7 @@ maintenance, ni récupération. | --- | --- | --- | | `POST /api/client-integrations/preview` | Planifier `apply`, `overwrite` ou `disable` pour un client ; corps `{ "clientId": "...", "operation": "..." }` | 400 client ou opération invalide ; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | | `POST /api/client-integrations/restore/preview` | Planifier une annulation ; corps `{ "opId": "...", "confirmDrift": false }` | 404 opération inconnue ; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | -| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Planifier la modification d'un seul profil Aside ; `restore` exige un `opId` | 400 corps invalide ou profil non précisé ; 404 profil ou opération inconnus | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Planifier la modification d'un seul profil Aside ; `restore` exige un `opId` | 400 corps invalide ou profil non précisé ; 404 profil ou opération inconnus; 409 `integration_preview_unavailable` | Un plan contient `version`, `clientId`, `operation`, `state`, `foreignEdit`, une liste `changes` de paires `kind` et `path`, une empreinte `fingerprint` opaque, `canApply`, `willChange`, ainsi diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index f414d5afba0..5c3f3bb001c 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -91,7 +91,7 @@ Authorization: Bearer | --- | --- | --- | | `POST /api/client-integrations/preview` | クライアント 1 つの `apply`、`overwrite`、`disable` を計画します。本文は `{ "clientId": "...", "operation": "..." }` | 400 不正なクライアントまたは操作、400 `invalid_aside_profile_path`、409 `integration_preview_unavailable` | | `POST /api/client-integrations/restore/preview` | 取り消しを計画します。本文は `{ "opId": "...", "confirmDrift": false }` | 404 該当操作なし、400 `invalid_aside_profile_path`、409 `integration_preview_unavailable` | -| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Aside プロファイル 1 つの変更を計画します。`restore` には `opId` が必要です | 400 不正な本文またはプロファイル未指定、404 該当プロファイルまたは操作なし | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Aside プロファイル 1 つの変更を計画します。`restore` には `opId` が必要です | 400 不正な本文またはプロファイル未指定、404 該当プロファイルまたは操作なし、409 `integration_preview_unavailable` | 計画には `version`、`clientId`、`operation`、`state`、`foreignEdit`、`kind` と `path` の組からなる `changes`、不透明な `fingerprint`、`canApply`、`willChange` が含まれ、`refusalReason` と diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index bf98d22a5af..32960028c95 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -95,7 +95,7 @@ Authorization: Bearer | --- | --- | --- | | `POST /api/client-integrations/preview` | 클라이언트 하나의 `apply`, `overwrite`, `disable`을 계획합니다. 본문은 `{ "clientId": "...", "operation": "..." }` | 400 잘못된 클라이언트나 작업, 400 `invalid_aside_profile_path`, 409 `integration_preview_unavailable` | | `POST /api/client-integrations/restore/preview` | 실행 취소를 계획합니다. 본문은 `{ "opId": "...", "confirmDrift": false }` | 404 없는 작업, 400 `invalid_aside_profile_path`, 409 `integration_preview_unavailable` | -| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Aside 프로필 하나의 변경을 계획합니다. `restore`에는 `opId`가 필요합니다 | 400 잘못된 본문이나 프로필 미지정, 404 없는 프로필이나 작업 | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Aside 프로필 하나의 변경을 계획합니다. `restore`에는 `opId`가 필요합니다 | 400 잘못된 본문이나 프로필 미지정, 404 없는 프로필이나 작업, 409 `integration_preview_unavailable` | 계획에는 `version`, `clientId`, `operation`, `state`, `foreignEdit`, `kind`와 `path` 쌍으로 이루어진 `changes`, 불투명한 `fingerprint`, `canApply`, `willChange`가 담기고 `refusalReason`과 diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index f32b8662941..ad1479cf044 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -133,7 +133,7 @@ snapshot, no ownership record, no journal row, no lock, and no maintenance or re | --- | --- | --- | | `POST /api/client-integrations/preview` | Plan `apply`, `overwrite` or `disable` for one client; body `{ "clientId": "...", "operation": "..." }` | 400 invalid client or operation; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | | `POST /api/client-integrations/restore/preview` | Plan an undo; body `{ "opId": "...", "confirmDrift": false }` | 404 unknown operation; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | -| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Plan one Aside profile's change, including `restore` with an `opId` | 400 invalid body or unscoped request; 404 unknown profile or operation | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Plan one Aside profile's change, including `restore` with an `opId` | 400 invalid body or unscoped request; 404 unknown profile or operation, 409 `integration_preview_unavailable` | A plan carries `version`, `clientId`, `operation`, `state`, `foreignEdit`, a `changes` list of `kind` and `path` pairs, an opaque `fingerprint`, `canApply`, `willChange`, and an optional diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index 5943cd0a22f..270af4df0dc 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -114,7 +114,7 @@ GUI-сессия в стиле loopback не выпускается. | --- | --- | --- | | `POST /api/client-integrations/preview` | Спланировать `apply`, `overwrite` или `disable` для одного клиента; тело `{ "clientId": "...", "operation": "..." }` | 400 неверный клиент или операция; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | | `POST /api/client-integrations/restore/preview` | Спланировать отмену; тело `{ "opId": "...", "confirmDrift": false }` | 404 операция не найдена; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | -| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Спланировать изменение одного профиля Aside; для `restore` нужен `opId` | 400 неверное тело или профиль не указан; 404 профиль или операция не найдены | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Спланировать изменение одного профиля Aside; для `restore` нужен `opId` | 400 неверное тело или профиль не указан; 404 профиль или операция не найдены; 409 `integration_preview_unavailable` | План содержит `version`, `clientId`, `operation`, `state`, `foreignEdit`, список `changes` из пар `kind` и `path`, непрозрачный `fingerprint`, `canApply` и `willChange`; `refusalReason` и diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index a436f050625..3b6043bafba 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -119,7 +119,7 @@ görüntü, sahiplik kaydı, günlük satırı, kilit, bakım ve kurtarma yoktur | --- | --- | --- | | `POST /api/client-integrations/preview` | Tek bir istemci için `apply`, `overwrite` veya `disable` planlar; gövde `{ "clientId": "...", "operation": "..." }` | 400 geçersiz istemci veya işlem; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | | `POST /api/client-integrations/restore/preview` | Bir geri almayı planlar; gövde `{ "opId": "...", "confirmDrift": false }` | 404 işlem bulunamadı; 400 `invalid_aside_profile_path`; 409 `integration_preview_unavailable` | -| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Tek bir Aside profilinin değişikliğini planlar; `restore` için `opId` gerekir | 400 geçersiz gövde veya profil belirtilmemiş; 404 profil veya işlem bulunamadı | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | Tek bir Aside profilinin değişikliğini planlar; `restore` için `opId` gerekir | 400 geçersiz gövde veya profil belirtilmemiş; 404 profil veya işlem bulunamadı; 409 `integration_preview_unavailable` | Plan; `version`, `clientId`, `operation`, `state`, `foreignEdit`, `kind` ve `path` çiftlerinden oluşan bir `changes` listesi, opak bir `fingerprint`, `canApply` ve `willChange` içerir; diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index 84b19cce63a..0fbc663b53e 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -95,7 +95,7 @@ Authorization: Bearer | --- | --- | --- | | `POST /api/client-integrations/preview` | 为一个客户端规划 `apply`、`overwrite` 或 `disable`;请求体为 `{ "clientId": "...", "operation": "..." }` | 400 客户端或操作无效;400 `invalid_aside_profile_path`;409 `integration_preview_unavailable` | | `POST /api/client-integrations/restore/preview` | 规划一次撤销;请求体为 `{ "opId": "...", "confirmDrift": false }` | 404 操作不存在;400 `invalid_aside_profile_path`;409 `integration_preview_unavailable` | -| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | 规划单个 Aside 配置档的变更;`restore` 需要 `opId` | 400 请求体无效或未指定配置档;404 配置档或操作不存在 | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | 规划单个 Aside 配置档的变更;`restore` 需要 `opId` | 400 请求体无效或未指定配置档;404 配置档或操作不存在;409 `integration_preview_unavailable` | 计划包含 `version`、`clientId`、`operation`、`state`、`foreignEdit`,由 `kind` 与 `path` 组成的 `changes` 列表,不透明的 `fingerprint`,以及 `canApply` 和 `willChange`;`refusalReason` 与 diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index 2837c92779b..6c0f026b931 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -91,7 +91,7 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | --- | --- | --- | | `POST /api/client-integrations/preview` | 為單一用戶端規劃 `apply`、`overwrite` 或 `disable`;請求內容為 `{ "clientId": "...", "operation": "..." }` | 400 用戶端或操作無效;400 `invalid_aside_profile_path`;409 `integration_preview_unavailable` | | `POST /api/client-integrations/restore/preview` | 規劃一次復原;請求內容為 `{ "opId": "...", "confirmDrift": false }` | 404 操作不存在;400 `invalid_aside_profile_path`;409 `integration_preview_unavailable` | -| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | 規劃單一 Aside 設定檔的變更;`restore` 需要 `opId` | 400 請求內容無效或未指定設定檔;404 設定檔或操作不存在 | +| `POST /api/client-integrations/aside/profiles/{profileId}/preview` | 規劃單一 Aside 設定檔的變更;`restore` 需要 `opId` | 400 請求內容無效或未指定設定檔;404 設定檔或操作不存在;409 `integration_preview_unavailable` | 計畫包含 `version`、`clientId`、`operation`、`state`、`foreignEdit`,由 `kind` 與 `path` 組成的 `changes` 清單,不透明的 `fingerprint`,以及 `canApply` 與 `willChange`;`refusalReason` 與 From 9549ac87058a0b9fdad1f8883a92f7e429ddc65f Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 08:12:02 +0900 Subject: [PATCH 79/83] docs(management-api): scope the no-op statement to the client document A plan that reports willChange: false was documented as writing nothing. That is true of the managed client document and not of the proxy's own state on the Aside path: confirming a profile change records the operator's desired sync preference before any client document is touched, so disabling a profile whose managed block is already absent saves the preference and leaves the document and its history alone. The behaviour is deliberate and unchanged; only the explanation was incomplete. The statement is now scoped to the managed client document and the Aside case is spelled out, in English and in all seven translated locales. A route-level regression pins it: the plan reports canApply with willChange false, the confirmed disable answers 200, the profile document and both stores are unchanged byte for byte, and the disabled preference is what was saved. local checks: NOT RUN --- .../docs/fr/reference/management-api.md | 9 +++-- .../docs/ja/reference/management-api.md | 8 +++-- .../docs/ko/reference/management-api.md | 8 +++-- .../content/docs/reference/management-api.md | 9 +++-- .../docs/ru/reference/management-api.md | 7 +++- .../docs/tr/reference/management-api.md | 8 +++-- .../docs/zh-cn/reference/management-api.md | 7 ++-- .../docs/zh-tw/reference/management-api.md | 7 ++-- tests/server/aside-profiles-routes.test.ts | 33 +++++++++++++++++++ 9 files changed, 81 insertions(+), 15 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index 3f037430e03..0ce8ea8003b 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -122,8 +122,13 @@ les marqueurs fixes `$snapshot`, `$ownership` et `$journal` ; une position déte l'exécution apparaît comme `*`. Aucune valeur de configuration, aucun emplacement de fichier et aucune identité d'élément sélectionné n'est renvoyé. -`canApply` à vrai avec `willChange` à faux signifie que l'opération réussit sans rien écrire, -par exemple appliquer ce qui est déjà appliqué. +`canApply` à vrai avec `willChange` à faux signifie que l'opération réussit sans rien changer +dans le document client géré, par exemple appliquer ce qui est déjà appliqué. + +Une modification de profil Aside enregistre tout de même une chose dans ce cas : la confirmation +enregistre la préférence de synchronisation du profil avant de toucher au moindre document client. +Désactiver un profil dont le bloc géré est déjà absent enregistre donc la préférence et laisse le +document et son historique intacts. `integration_preview_unavailable` indique qu'aucune liste de modèles utilisable n'est actuellement conservée : un proxy qui vient de démarrer est un cas, une liste abandonnée parce que la diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 5c3f3bb001c..3aab78aa88c 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -99,8 +99,12 @@ Authorization: Bearer の固定表記で、実行時に決まる位置は `*` になります。設定値やファイルの場所、選ばれた項目の 名前は返しません。 -`canApply` が真で `willChange` が偽なら、操作は成功しますが何も書き込みません。すでに適用済み -のものを適用した場合などです。 +`canApply` が真で `willChange` が偽なら、操作は成功しますが管理対象のクライアント文書は何も +変わりません。すでに適用済みのものを適用した場合などです。 + +Aside プロファイルの変更はこの場合でも一つだけ保存します。確認を送ると、クライアント文書に触れる +前にそのプロファイルの同期設定が記録されるため、管理ブロックがすでにないプロファイルを無効にすると +設定だけが保存され、文書とその履歴はそのまま残ります。 `integration_preview_unavailable` は今使えるモデル一覧がないという意味です。プロキシを起動した 直後もそうですし、設定やプロバイダーキャッシュが変わって以前の一覧を破棄した場合もそうです。 diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index 32960028c95..eb153c9caeb 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -103,8 +103,12 @@ Authorization: Bearer `$journal` 표식이며, 실행 중에 정해지는 자리는 `*`로 적습니다. 설정 값이나 파일 위치, 선택된 항목의 이름은 돌려주지 않습니다. -`canApply`가 참인데 `willChange`가 거짓이면 작업은 성공하지만 아무것도 쓰지 않습니다. 이미 -적용된 것을 다시 적용하는 경우가 그렇습니다. +`canApply`가 참인데 `willChange`가 거짓이면 작업은 성공하지만 관리 대상 클라이언트 문서에서는 +아무것도 바뀌지 않습니다. 이미 적용된 것을 다시 적용하는 경우가 그렇습니다. + +Aside 프로필 변경은 이때도 한 가지를 저장합니다. 확인을 보내면 클라이언트 문서에 손대기 전에 그 +프로필의 동기화 설정이 먼저 기록되므로, 관리 블록이 이미 없는 프로필을 끄면 설정만 저장되고 +문서와 기록은 그대로 남습니다. `integration_preview_unavailable`은 지금 쓸 수 있는 모델 목록이 없다는 뜻입니다. 프록시를 막 시작했을 때도 그렇고, 설정이나 공급자 캐시가 바뀌어 기존 목록을 버린 경우에도 그렇습니다. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index ad1479cf044..4f47ad8bbc1 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -141,8 +141,13 @@ A plan carries `version`, `clientId`, `operation`, `state`, `foreignEdit`, a `ch `$ownership` and `$journal` markers; a position chosen at runtime appears as `*`. No configuration value, file location or selected member identity is returned. -`canApply: true` with `willChange: false` means the operation succeeds and writes nothing, such -as applying what is already applied. +`canApply: true` with `willChange: false` means the operation succeeds and changes nothing in +the managed client document, such as applying what is already applied. + +An Aside profile change still saves one thing in that case. Confirming it records the desired sync +preference for that profile before any client document is touched, so disabling a profile whose +managed block is already absent stores the preference and leaves the document and its history +untouched. `integration_preview_unavailable` means no usable model roster is currently retained. A freshly started proxy is one way to be in that state; a roster retired because the configuration or the diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index 270af4df0dc..793b6e9a612 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -123,7 +123,12 @@ GUI-сессия в стиле loopback не выпускается. `*`. Ни значения конфигурации, ни расположение файлов, ни имя выбранного элемента не возвращаются. `canApply` истинно при `willChange` ложном означает, что операция завершится успешно и ничего не -запишет, например повторное применение уже применённого. +изменит в управляемом клиентском документе, например повторное применение уже применённого. + +Изменение профиля Aside в этом случае всё же кое-что сохраняет: подтверждение записывает настройку +синхронизации профиля до того, как будет затронут хоть один клиентский документ. Поэтому отключение +профиля, управляемый блок которого уже отсутствует, сохраняет настройку и оставляет документ и его +историю нетронутыми. `integration_preview_unavailable` означает, что сейчас нет пригодного списка моделей: только что запущенный прокси — один такой случай, список, отброшенный из-за изменения конфигурации или кэша diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index 3b6043bafba..63beca196dc 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -127,8 +127,12 @@ oluşan bir `changes` listesi, opak bir `fingerprint`, `canApply` ve `willChange `$snapshot`, `$ownership` ve `$journal` işaretleridir; çalışma sırasında belirlenen bir konum `*` olarak görünür. Hiçbir yapılandırma değeri, dosya konumu veya seçilen öğenin adı döndürülmez. -`canApply` doğru ve `willChange` yanlışsa işlem başarılı olur ama hiçbir şey yazmaz; örneğin zaten -uygulanmış olanı yeniden uygulamak. +`canApply` doğru ve `willChange` yanlışsa işlem başarılı olur ama yönetilen istemci belgesinde +hiçbir şey değişmez; örneğin zaten uygulanmış olanı yeniden uygulamak. + +Aside profil değişikliği bu durumda yine de bir şey kaydeder: onay, herhangi bir istemci belgesine +dokunulmadan önce o profilin eşitleme tercihini yazar. Bu yüzden yönetilen bloğu zaten bulunmayan +bir profili kapatmak yalnızca tercihi saklar, belgeyi ve geçmişini olduğu gibi bırakır. `integration_preview_unavailable`, şu anda kullanılabilir bir model listesi tutulmadığını belirtir: yeni başlamış bir vekil bunun bir hâlidir, yapılandırma ya da sağlayıcı önbelleği değiştiği için diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index 0fbc663b53e..cc4396173f6 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -102,8 +102,11 @@ Authorization: Bearer `profileId` 为可选。路径要么是受管理的结构路径,要么是 `$snapshot`、`$ownership`、`$journal` 这三个固定标记;运行时才确定的位置显示为 `*`。不会返回任何配置值、文件位置或所选条目的名称。 -`canApply` 为真而 `willChange` 为假,表示操作会成功但不写入任何内容,例如重复应用已经应用过 -的内容。 +`canApply` 为真而 `willChange` 为假,表示操作会成功,但受管理的客户端文档不会有任何改动,例如 +重复应用已经应用过的内容。 + +Aside 配置档的变更在这种情况下仍会保存一件事:确认之后,会先记录该配置档的同步偏好,然后才去动 +客户端文档。因此关闭一个受管理区块已经不存在的配置档,只会保存偏好,文档及其历史保持不变。 `integration_preview_unavailable` 表示当前没有可用的模型清单:代理刚启动是一种情况,因配置或 提供方缓存变化而弃用了原有清单也是一种情况。读取 `GET /api/client-integrations` 会在探测成功 diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index 6c0f026b931..10094e71816 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -98,8 +98,11 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 `profileId` 為選用。路徑要麼是受管理的結構路徑,要麼是 `$snapshot`、`$ownership`、`$journal` 這三個固定標記;執行時才決定的位置會顯示為 `*`。不會回傳任何設定值、檔案位置或所選項目的名稱。 -`canApply` 為真而 `willChange` 為假,表示操作會成功但不寫入任何內容,例如重複套用已經套用過 -的內容。 +`canApply` 為真而 `willChange` 為假,表示操作會成功,但受管理的用戶端文件不會有任何變動,例如 +重複套用已經套用過的內容。 + +Aside 設定檔的變更在這種情況下仍會儲存一件事:確認之後,會先記錄該設定檔的同步偏好,之後才會動 +用戶端文件。因此關閉一個受管理區塊已經不存在的設定檔,只會儲存偏好,文件與其歷史維持不變。 `integration_preview_unavailable` 表示目前沒有可用的模型清單:代理剛啟動是一種情況,因設定或 供應方快取變動而捨棄原有清單也是一種情況。讀取 `GET /api/client-integrations` 會在探測成功 diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index 09fc9e6ae06..f0ba32ef715 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -451,3 +451,36 @@ test("a bound confirmation is refused when the roster it was planned against has expect(refused?.message).toContain("roster changed"); expect(capture.plan).not.toBeNull(); }); + +test("a confirmed disable of a profile with nothing applied saves the preference and touches no file", async () => { + /* + * willChange: false is a statement about the managed client document, and the plan says so. The + * change still records the operator's desired sync preference for that profile, which happens + * before any client document is touched, so the document and its history stay exactly as they + * were while the preference is saved. + */ + await seedRoster(); + const before = readFileSync(path(1), "utf8"); + const profileStoreBefore = treeWitness(join(root, "store", "aside-profiles", "1")); + const rootStoreBefore = treeWitness(join(root, "store")); + + const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { operation: "disable" }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { canApply: boolean; willChange: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + expect(plan.willChange).toBe(false); + + const disabled = await api("/api/client-integrations/aside?profile=1", "PUT", { + enabled: false, operation: "disable", planFingerprint: plan.fingerprint, + }); + expect(disabled.status).toBe(200); + + // Nothing in the client's document or in either store moved. + expect(readFileSync(path(1), "utf8")).toBe(before); + expect(treeWitness(join(root, "store", "aside-profiles", "1"))).toBe(profileStoreBefore); + expect(treeWitness(join(root, "store"))).toBe(rootStoreBefore); + + // The preference is the one thing that was written, and it is what the operator asked for. + expect(saved?.asideProfileSync?.profiles?.["1"]).toBe(false); + expect(config.asideProfileSync?.profiles?.["1"]).toBe(false); +}); From c270fb8ca3d2578e566030c0d9ab24791b3f387b Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 08:25:59 +0900 Subject: [PATCH 80/83] fix(integrations): check the target once more before an Aside write Aside takes no writer lock, and its preference write sits between the confirmation check and the write that check authorizes. Nothing stops the target file from being edited in that window, and the writer reads the file as it is now and compares it against itself, so an overwrite or a confirmed-drift restore could still land a confirmation about the earlier file on the later one. Neither of those two asks the writer's own conflict question, which is what covers the ordinary apply. Both Aside entry points now run the same comparison a second time, immediately before the document is written, against the input the write will use. The roster is deliberately not re-examined at that point. The action has just written the operator's preference into the configuration, which retires the retained roster by design, so asking that question there would refuse every confirmation on principle. What is asked is the one thing the moment can answer: whether the plan the operator confirmed still describes this file. The first check keeps its place before any effect, the preference save keeps its meaning, and an unchanged target still commits. Three route cases: an overwrite whose target is edited while preferences are being saved is refused and leaves the editor's file alone, the same for a confirmed-drift restore, and the unchanged control still commits. local checks: NOT RUN --- src/integrations/aside-profile-journal.ts | 26 +++++- src/integrations/aside-profiles.ts | 23 ++++- src/server/management/aside-profile-routes.ts | 77 +++++++++++++--- tests/server/aside-profiles-routes.test.ts | 87 ++++++++++++++++++- 4 files changed, 195 insertions(+), 18 deletions(-) diff --git a/src/integrations/aside-profile-journal.ts b/src/integrations/aside-profile-journal.ts index b88bd7d60c9..29174e25b20 100644 --- a/src/integrations/aside-profile-journal.ts +++ b/src/integrations/aside-profile-journal.ts @@ -177,7 +177,18 @@ export function restoreAsideProfile( */ selectedOperation?: AsideOperation; }, - options?: { revalidate?: (prepared: IntegrationWriteInput) => Promise }, + options?: { + revalidate?: (prepared: IntegrationWriteInput) => Promise; + /** + * Checked again immediately before the target is rewritten. + * + * The preference write and the journal import sit between the first check and the restore + * itself, and Aside takes no writer lock. The snapshot checks below still hold, but they say + * nothing about the target file, which can be edited in that window; without this, a + * confirmation about the earlier file still overwrote the later one. + */ + revalidateBeforeWrite?: (prepared: IntegrationWriteInput) => Promise; + }, ): Promise { return runAsideProfileAction(input, request.profileId, `restore:${request.opId}:${Boolean(request.confirmDrift)}`, async ctx => { const row = request.selectedOperation ?? requiredOperation(ctx, request.opId, request.profileId); @@ -218,7 +229,18 @@ export function restoreAsideProfile( throw new AsideProfileError("aside_operation_changed", 409, "Aside operation or snapshot changed while saving preferences"); } importOperation(row, scope); - return { ...await restoreIntegrationCoordinated({ ...bound, opId: request.opId, confirmDrift: request.confirmDrift }, { lockSeams: input.lockSeams }), profileId: profile.id }; + return { + ...await restoreIntegrationCoordinated( + { ...bound, opId: request.opId, confirmDrift: request.confirmDrift }, + { + lockSeams: input.lockSeams, + ...(options?.revalidateBeforeWrite + ? { revalidate: (frozen: IntegrationWriteInput) => options.revalidateBeforeWrite!(frozen) } + : {}), + }, + ), + profileId: profile.id, + }; } catch (error) { return asideProfileFailure(profile.id, error); } }); } diff --git a/src/integrations/aside-profiles.ts b/src/integrations/aside-profiles.ts index 6026d9d0fb8..2d5e2a8e9df 100644 --- a/src/integrations/aside-profiles.ts +++ b/src/integrations/aside-profiles.ts @@ -149,7 +149,18 @@ export async function getAsideProfileState(input: AsideProfilesInput, id: number export function mutateAsideProfiles( input: AsideProfilesInput, change: { enabled: boolean; profileId?: number; overwriteConflict?: boolean }, - options?: { revalidate?: (prepared: IntegrationWriteInput) => Promise }, + options?: { + revalidate?: (prepared: IntegrationWriteInput) => Promise; + /** + * Checked again immediately before the client document is written. + * + * Aside takes no writer lock, and the preference write sits between the first check and the + * write it authorizes. Nothing stops the target file from being edited in that window, and the + * writer's own comparison reads the file as it is now, so a confirmation about the earlier + * file would otherwise still overwrite the later one. + */ + revalidateBeforeWrite?: (prepared: IntegrationWriteInput) => Promise; + }, ): Promise { return runAsideProfileAction(input, change.profileId, `${change.enabled ? "enable" : "disable"}:${Boolean(change.overwriteConflict)}`, async (ctx, profiles) => { const refused = new Map(); @@ -228,7 +239,15 @@ export function mutateAsideProfiles( const bound = prepared.get(profile.id) ?? await asideWriteInput(ctx, asideProfileScope(ctx, profile)); const operation = !change.enabled ? disableIntegrationCoordinated : change.overwriteConflict ? overwriteIntegrationCoordinated : applyIntegrationCoordinated; - results.push({ ...await operation(bound, { lockSeams: input.lockSeams }), profileId: profile.id }); + results.push({ + ...await operation(bound, { + lockSeams: input.lockSeams, + ...(options?.revalidateBeforeWrite + ? { revalidate: (frozen: IntegrationWriteInput) => options.revalidateBeforeWrite!(frozen) } + : {}), + }), + profileId: profile.id, + }); } catch (error) { results.push(asideProfileFailure(profile.id, error)); } } const ok = results.every(result => result.ok); diff --git a/src/server/management/aside-profile-routes.ts b/src/server/management/aside-profile-routes.ts index af0cc59cb85..956dc8eafe0 100644 --- a/src/server/management/aside-profile-routes.ts +++ b/src/server/management/aside-profile-routes.ts @@ -77,17 +77,7 @@ export function asideGuardFor( capture: { plan: IntegrationMutationPlan | null }, ): (prepared: IntegrationWriteInput) => Promise { return async prepared => { - const plan = previewIntegration(prepared, { - profileId: profileIdValue, - operation: binding.operation, - ...(request.opId === undefined ? {} : { opId: request.opId }), - ...(request.confirmDrift === undefined ? {} : { confirmDrift: request.confirmDrift }), - // The row the route selected, so the guard and the mutation mean the same operation even - // when more than one valid copy exists. - ...(request.resolved === undefined - ? {} - : { resolved: { entry: request.resolved.entry, store: request.resolved.store } }), - }); + const plan = planFor(prepared, profileIdValue, binding, request); /* * The roster this confirmation was planned against has to still be the retained one. Only its * rows were carried into the mutation, so an ordinary load completing while this action @@ -112,6 +102,59 @@ export function asideGuardFor( }; } +/** + * The same comparison, run again immediately before the document is written. + * + * Aside takes no writer lock, and its preference write and journal import sit between the first + * check and the write that check authorizes. A target edited in that window is read fresh by the + * writer, which then compares it against itself and finds nothing to object to, so a confirmation + * about the earlier file would still overwrite the later one. + * + * The roster is deliberately not re-examined here. This action has just written the operator's + * preference into the configuration, which retires the retained roster by design, so asking that + * question at this point would refuse every confirmation on principle. What is asked is the one + * thing this moment can answer: does the plan the operator confirmed still describe this file. + */ +export function asideLateGuardFor( + profileIdValue: number, + binding: { operation: IntegrationPlanOperation; fingerprint: string }, + request: { opId?: string; confirmDrift?: boolean; resolved?: AsideOperation }, + capture: { plan: IntegrationMutationPlan | null }, +): (prepared: IntegrationWriteInput) => Promise { + return async prepared => { + const plan = planFor(prepared, profileIdValue, binding, request); + if (plan.canApply && plan.fingerprint === binding.fingerprint) return null; + capture.plan = plan; + return { + ok: false, + reason: "conflict", + state: plan.state, + clientId: "aside", + message: "that confirmation no longer describes this profile", + profileId: profileIdValue, + }; + }; +} + +function planFor( + prepared: IntegrationWriteInput, + profileIdValue: number, + binding: { operation: IntegrationPlanOperation; fingerprint: string }, + request: { opId?: string; confirmDrift?: boolean; resolved?: AsideOperation }, +): IntegrationMutationPlan { + return previewIntegration(prepared, { + profileId: profileIdValue, + operation: binding.operation, + ...(request.opId === undefined ? {} : { opId: request.opId }), + ...(request.confirmDrift === undefined ? {} : { confirmDrift: request.confirmDrift }), + // The row the route selected, so the guard and the mutation mean the same operation even + // when more than one valid copy exists. + ...(request.resolved === undefined + ? {} + : { resolved: { entry: request.resolved.entry, store: request.resolved.store } }), + }); +} + function stalePlanResponse(ctx: ManagementContext, plan: IntegrationMutationPlan): Response { return jsonResponse({ error: "integration preview is stale", @@ -294,6 +337,7 @@ export async function handleAsideProfileRoutes( const capture: { plan: IntegrationMutationPlan | null } = { plan: null }; let mutationInput = options.input(); let revalidate: ((prepared: IntegrationWriteInput) => Promise) | undefined; + let revalidateBeforeWrite: ((prepared: IntegrationWriteInput) => Promise) | undefined; if (binding !== null && id !== undefined) { const roster = previewExportSnapshot(ctx.config); if (roster === null) { @@ -305,11 +349,12 @@ export async function handleAsideProfileRoutes( // One roster for the guard and the mutation, so they cannot disagree by construction. mutationInput = { ...mutationInput, models: roster.models }; revalidate = asideGuardFor(ctx, roster.identity, id, binding, {}, capture); + revalidateBeforeWrite = asideLateGuardFor(id, binding, {}, capture); } const batch = await mutateAsideProfiles( mutationInput, { enabled: body.enabled, profileId: id, overwriteConflict: body.overwriteConflict === true }, - revalidate ? { revalidate } : undefined, + revalidate ? { revalidate, ...(revalidateBeforeWrite ? { revalidateBeforeWrite } : {}) } : undefined, ); if (capture.plan) return stalePlanResponse(ctx, capture.plan); if (id !== undefined) { @@ -382,6 +427,7 @@ export async function asideRestoreResponse( const capture: { plan: IntegrationMutationPlan | null } = { plan: null }; let restoreInput = input; let revalidate: ((prepared: IntegrationWriteInput) => Promise) | undefined; + let revalidateBeforeWrite: ((prepared: IntegrationWriteInput) => Promise) | undefined; if (restoreBinding !== null) { const roster = previewExportSnapshot(ctx.config); if (roster === null) { @@ -396,13 +442,18 @@ export async function asideRestoreResponse( confirmDrift: body.confirmDrift === true, resolved: operation, }, capture); + revalidateBeforeWrite = asideLateGuardFor(operation.profileId, restoreBinding, { + opId: body.opId, + confirmDrift: body.confirmDrift === true, + resolved: operation, + }, capture); } const result = await restoreAsideProfile( restoreInput, // The same row the preview and guard used. Letting the mutation resolve its own copy is how // a confirmation ends up bound to an operation other than the one that runs. { ...body, profileId: operation.profileId, selectedOperation: operation }, - revalidate ? { revalidate } : undefined, + revalidate ? { revalidate, ...(revalidateBeforeWrite ? { revalidateBeforeWrite } : {}) } : undefined, ); if (capture.plan) return stalePlanResponse(ctx, capture.plan); return result.ok ? jsonResponse(result, 200, ctx.req, ctx.config) : options.failure(result); diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index f0ba32ef715..6f826a1cec4 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -25,6 +25,12 @@ let config: OcxConfig; let isolation: IsolatedCodexHome; let priorOcxHome: string | undefined; let saved: OcxConfig | undefined; +/** + * Runs inside the preference write, which is where the window this fixture exercises lives: the + * confirmation has been checked, nothing has been written to the client yet, and an editor outside + * this process can still change the target. + */ +let onPersist: (() => void) | undefined; const env: NodeJS.ProcessEnv = {}; beforeEach(() => { @@ -47,6 +53,7 @@ beforeEach(() => { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", liveModels: false, models: ["one","two"] }, } } as OcxConfig; saved = undefined; + onPersist = undefined; setIntegrationPathTestHooks({ home, env }); setIntegrationMutationFlightTestHooks({ store }); }); @@ -91,7 +98,7 @@ async function rawApi(pathname: string, method: string, body?: string) { method, headers: { Host: url.host, "content-type": "application/json" }, ...(body === undefined ? {} : { body }), }), url, config, { - saveConfigPreservingClaudeCode: value => { saved = structuredClone(value); }, + saveConfigPreservingClaudeCode: value => { saved = structuredClone(value); onPersist?.(); }, createManagementConvergeCodex: catalogConvergenceFactory(), refreshOwnedCatalogIntegrations: input => refreshOwnedCatalogIntegrations({ ...input, store, env, home }), }); @@ -484,3 +491,81 @@ test("a confirmed disable of a profile with nothing applied saves the preference expect(saved?.asideProfileSync?.profiles?.["1"]).toBe(false); expect(config.asideProfileSync?.profiles?.["1"]).toBe(false); }); + +test("a target edited while the preference is being saved is not overwritten by the old confirmation", async () => { + /* + * Aside takes no writer lock, and its preference write sits between the confirmation check and + * the write that check authorizes. An overwrite does not ask the writer's own conflict question, + * so without a second look the confirmation about the earlier file would land on the later one. + */ + await seedRoster(); + const foreign = JSON.stringify({ theme: "keep", providers: { opencodex: { models: [{ id: "written-by-someone-else" }] } } }); + writeFileSync(path(1), foreign); + + const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { operation: "overwrite" }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + + const edited = JSON.stringify({ theme: "edited-after-the-check", providers: { opencodex: { models: [{ id: "still-not-ours" }] } } }); + onPersist = () => { writeFileSync(path(1), edited); }; + + const commit = await api("/api/client-integrations/aside?profile=1", "PUT", { + enabled: true, overwriteConflict: true, operation: "overwrite", planFingerprint: plan.fingerprint, + }); + + expect(commit.status).toBe(409); + expect((await commit.json() as { code: string }).code).toBe("integration_preview_stale"); + // The file the editor wrote is the file that is still there. + expect(readFileSync(path(1), "utf8")).toBe(edited); +}); + +test("an unchanged target still commits the same confirmed overwrite", async () => { + // The control for the case above: nothing moves in the window, and the confirmation stands. + await seedRoster(); + writeFileSync(path(1), JSON.stringify({ theme: "keep", providers: { opencodex: { models: [{ id: "written-by-someone-else" }] } } })); + + const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { operation: "overwrite" }); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + + const commit = await api("/api/client-integrations/aside?profile=1", "PUT", { + enabled: true, overwriteConflict: true, operation: "overwrite", planFingerprint: plan.fingerprint, + }); + + expect(commit.status).toBe(200); + // The block that was there is the one the overwrite was for, and it is gone. + expect(JSON.stringify(document(1))).not.toContain("written-by-someone-else"); + expect(document(1).providers.opencodex).toBeDefined(); +}); + +test("a confirmed drift restore does not rewrite a target edited while preferences were saved", async () => { + /* + * Confirming drift says the operator accepted the difference they were shown. It does not say + * they accepted one that appeared afterwards, and the snapshot checks say nothing about the + * target file. + */ + const enabled = await (await api("/api/client-integrations/aside?profile=1", "PUT", { enabled: true })).json(); + expect(enabled.ok).toBe(true); + const opId = enabled.opId as string; + + writeFileSync(path(1), JSON.stringify({ theme: "drifted-before-the-preview" })); + await seedRoster(); + const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { + operation: "restore", opId, confirmDrift: true, + }); + expect(preview.status).toBe(200); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + + const edited = JSON.stringify({ theme: "edited-after-the-check" }); + onPersist = () => { writeFileSync(path(1), edited); }; + + const undo = await api("/api/client-integrations/aside/profiles/1/restore", "POST", { + opId, operation: "restore", confirmDrift: true, planFingerprint: plan.fingerprint, + }); + + expect(undo.status).toBe(409); + expect((await undo.json() as { code: string }).code).toBe("integration_preview_stale"); + expect(readFileSync(path(1), "utf8")).toBe(edited); +}); From 5bd1d3149f87f6276f3b1d69e59f47b3abe3e2b8 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 08:38:51 +0900 Subject: [PATCH 81/83] fix(integrations): refuse an Aside restore before its history is copied The check added for the late-target window sat inside the coordinated write, and for a restore that is too late to be free. Copying the selected row and its snapshot into the profile's own store happens first, and a refusal returned from inside the coordinated write returns before the restore transaction begins, so nothing compensates that copy and the imported history would simply stay behind. A refusal has to write nothing, which is the whole point of the confirmation. The restore now takes its last look at the target immediately before that copy. A stale confirmation is refused with no history written and no document touched. What remains between the check and the restore's own read of the file is the copy itself, which is local synchronous store work rather than anything that waits, where the window this closes was an awaited preference write. The overwrite path keeps its check inside the coordinated write, because there the writer's own transaction is the first thing that writes and it compensates itself. The regression now leaves the row only in the root store, so a restore that proceeded would have to copy it, and witnesses both stores unchanged alongside the untouched target. local checks: NOT RUN --- src/integrations/aside-profile-journal.ts | 30 ++++++++++++++-------- tests/server/aside-profiles-routes.test.ts | 18 +++++++++++++ 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/integrations/aside-profile-journal.ts b/src/integrations/aside-profile-journal.ts index 29174e25b20..b7fb70badbf 100644 --- a/src/integrations/aside-profile-journal.ts +++ b/src/integrations/aside-profile-journal.ts @@ -180,12 +180,12 @@ export function restoreAsideProfile( options?: { revalidate?: (prepared: IntegrationWriteInput) => Promise; /** - * Checked again immediately before the target is rewritten. + * Checked again after the preference write and before anything of this operation is written. * - * The preference write and the journal import sit between the first check and the restore - * itself, and Aside takes no writer lock. The snapshot checks below still hold, but they say - * nothing about the target file, which can be edited in that window; without this, a - * confirmation about the earlier file still overwrote the later one. + * The preference write sits between the first check and the restore it authorizes, and Aside + * takes no writer lock. The snapshot checks below still hold, but they say nothing about the + * target file, which can be edited in that window; without this, a confirmation about the + * earlier file still overwrote the later one. */ revalidateBeforeWrite?: (prepared: IntegrationWriteInput) => Promise; }, @@ -228,16 +228,24 @@ export function restoreAsideProfile( || (currentSnapshot.kind === "stored" && currentSnapshot.text !== restoredText)) { throw new AsideProfileError("aside_operation_changed", 409, "Aside operation or snapshot changed while saving preferences"); } + /* + * The last look at the target, taken here rather than under the coordinated write. + * + * The import below copies the selected row and its snapshot into this profile's own store, + * which is history, and the coordinated restore has no way to take that back: a refusal + * returned from inside it happens before the restore transaction begins, so its compensation + * never runs and the imported rows would simply stay. Refusing here instead means a stale + * confirmation writes nothing at all. What remains between this check and the restore's own + * read of the file is that import, which is local synchronous store work rather than + * anything that waits. + */ + const late = await options?.revalidateBeforeWrite?.(bound); + if (late) return late; importOperation(row, scope); return { ...await restoreIntegrationCoordinated( { ...bound, opId: request.opId, confirmDrift: request.confirmDrift }, - { - lockSeams: input.lockSeams, - ...(options?.revalidateBeforeWrite - ? { revalidate: (frozen: IntegrationWriteInput) => options.revalidateBeforeWrite!(frozen) } - : {}), - }, + { lockSeams: input.lockSeams }, ), profileId: profile.id, }; diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index 6f826a1cec4..5df009a6fbc 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -544,11 +544,24 @@ test("a confirmed drift restore does not rewrite a target edited while preferenc * Confirming drift says the operator accepted the difference they were shown. It does not say * they accepted one that appeared afterwards, and the snapshot checks say nothing about the * target file. + * + * The row is left only in the root store, so a restore that proceeded would copy it and its + * snapshot into this profile's own store first. That copy is history, and the coordinated + * restore cannot take it back: a refusal from inside it returns before the restore transaction + * begins, so nothing compensates. The refusal therefore has to happen before the copy, and the + * store witnesses below are what say it did. */ const enabled = await (await api("/api/client-integrations/aside?profile=1", "PUT", { enabled: true })).json(); expect(enabled.ok).toBe(true); const opId = enabled.opId as string; + const profileStore = join(root, "store", "aside-profiles", "1"); + const snapshotName = join("snapshots", "aside", opId); + writeFileSync(join(root, "store", "journal.jsonl"), readFileSync(join(profileStore, "journal.jsonl"), "utf8")); + mkdirSync(join(root, "store", "snapshots", "aside"), { recursive: true }); + writeFileSync(join(root, "store", snapshotName), readFileSync(join(profileStore, snapshotName), "utf8")); + rmSync(join(profileStore, snapshotName)); + writeFileSync(path(1), JSON.stringify({ theme: "drifted-before-the-preview" })); await seedRoster(); const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { @@ -558,6 +571,8 @@ test("a confirmed drift restore does not rewrite a target edited while preferenc const plan = await preview.json() as { canApply: boolean; fingerprint: string }; expect(plan.canApply).toBe(true); + const profileStoreBefore = treeWitness(profileStore); + const rootStoreBefore = treeWitness(join(root, "store")); const edited = JSON.stringify({ theme: "edited-after-the-check" }); onPersist = () => { writeFileSync(path(1), edited); }; @@ -567,5 +582,8 @@ test("a confirmed drift restore does not rewrite a target edited while preferenc expect(undo.status).toBe(409); expect((await undo.json() as { code: string }).code).toBe("integration_preview_stale"); + // The editor's file is the one still there, and no history was written on the way to refusing. expect(readFileSync(path(1), "utf8")).toBe(edited); + expect(treeWitness(profileStore)).toBe(profileStoreBefore); + expect(treeWitness(join(root, "store"))).toBe(rootStoreBefore); }); From c67cf0bf19aa76bce869f2e2109096907ff7e392 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 08:59:53 +0900 Subject: [PATCH 82/83] fix(integrations): keep both looks at the target around an Aside restore The previous commit traded one protection for another. Moving the check ahead of the history copy stopped a stale confirmation from leaving imported rows behind, and it also removed the check that stood between the copy and the restore, where the coordinated path still awaits before it begins and no writer lock holds an editor out. The comment claiming nothing waits there was wrong. Both checks are back, and they answer different questions. The one before the copy refuses a confirmation that is already stale without writing history at all. The one inside the coordinated restore refuses a target that changed after the copy, which is the window the first one cannot see. They do not interfere: the copy moves the selected row and its snapshot, not ownership, and the plan is computed from that selected row and the target's own ownership rather than from the destination store's journal. Neither look re-examines the roster, which this action has already retired by saving the preference. A refusal at the second check leaves the copied history and the saved preference in place, and that is deliberate. The copy is this operation's own history rather than a change to the operator's file, and unwinding it would need a transaction across two stores and a configuration write for nothing the operator can see. The regression says so explicitly: what must not exist afterwards is a restore row, and the target must still hold what the editor wrote. local checks: NOT RUN --- src/integrations/aside-profile-journal.ts | 47 ++++++++++----- tests/server/aside-profiles-routes.test.ts | 69 ++++++++++++++++++++++ 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/src/integrations/aside-profile-journal.ts b/src/integrations/aside-profile-journal.ts index b7fb70badbf..2d22588a646 100644 --- a/src/integrations/aside-profile-journal.ts +++ b/src/integrations/aside-profile-journal.ts @@ -180,12 +180,13 @@ export function restoreAsideProfile( options?: { revalidate?: (prepared: IntegrationWriteInput) => Promise; /** - * Checked again after the preference write and before anything of this operation is written. + * Checked after the preference write, once before the selected history is copied into this + * profile's store and once more immediately before the restore itself. * - * The preference write sits between the first check and the restore it authorizes, and Aside - * takes no writer lock. The snapshot checks below still hold, but they say nothing about the - * target file, which can be edited in that window; without this, a confirmation about the - * earlier file still overwrote the later one. + * The preference write and that copy both sit between the first check and the restore they + * authorize, and Aside takes no writer lock. The snapshot checks below still hold, but they + * say nothing about the target file, which can be edited in either window; without this, a + * confirmation about the earlier file still overwrote the later one. */ revalidateBeforeWrite?: (prepared: IntegrationWriteInput) => Promise; }, @@ -229,23 +230,37 @@ export function restoreAsideProfile( throw new AsideProfileError("aside_operation_changed", 409, "Aside operation or snapshot changed while saving preferences"); } /* - * The last look at the target, taken here rather than under the coordinated write. + * Looked at twice, because the two moments answer different questions and neither covers + * the other. * - * The import below copies the selected row and its snapshot into this profile's own store, - * which is history, and the coordinated restore has no way to take that back: a refusal - * returned from inside it happens before the restore transaction begins, so its compensation - * never runs and the imported rows would simply stay. Refusing here instead means a stale - * confirmation writes nothing at all. What remains between this check and the restore's own - * read of the file is that import, which is local synchronous store work rather than - * anything that waits. + * Here, before the import, so that a confirmation already stale by this point is refused + * without copying anything: the import writes the selected row and its snapshot into this + * profile's own store, and a refusal from inside the coordinated restore returns before the + * restore transaction begins, so nothing would compensate that copy. */ - const late = await options?.revalidateBeforeWrite?.(bound); - if (late) return late; + const beforeImport = await options?.revalidateBeforeWrite?.(bound); + if (beforeImport) return beforeImport; importOperation(row, scope); return { ...await restoreIntegrationCoordinated( { ...bound, opId: request.opId, confirmDrift: request.confirmDrift }, - { lockSeams: input.lockSeams }, + { + lockSeams: input.lockSeams, + /* + * And again inside the coordinated restore, immediately before it runs. The import + * sits between the two, and Aside takes no writer lock, so that path still awaits + * before the restore begins: a target edited after the copy would otherwise be + * rewritten by a confirmation that never described it. + * + * A refusal here leaves the copied history and the saved preference in place. That is + * deliberate: the copy is this operation's own history rather than a change to the + * operator's file, and unwinding it would need a transaction across two stores and a + * configuration write for no gain the operator can see. + */ + ...(options?.revalidateBeforeWrite + ? { revalidate: (frozen: IntegrationWriteInput) => options.revalidateBeforeWrite!(frozen) } + : {}), + }, ), profileId: profile.id, }; diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index 5df009a6fbc..0794548ff1e 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { handleManagementAPI } from "../../src/server/management-api"; import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../../src/server/management/body"; import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks } from "../../src/server/management/integration-routes"; +import { defaultIntegrationIO } from "../../src/integrations/config-io"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; import { applyIntegration } from "../../src/integrations/writer"; import { refreshOwnedCatalogIntegrations } from "../../src/integrations/catalog-refresh"; @@ -587,3 +588,71 @@ test("a confirmed drift restore does not rewrite a target edited while preferenc expect(treeWitness(profileStore)).toBe(profileStoreBefore); expect(treeWitness(join(root, "store"))).toBe(rootStoreBefore); }); + +test("a target edited after the history copy is still not rewritten by the old confirmation", async () => { + /* + * The copy and the restore are two moments, and an edit can land between them: the coordinated + * restore has no writer lock to hold one out, and it still awaits before beginning. The check + * before the copy accepts the file as it was; the one inside the coordinated restore is what has + * to see the file as it became. + */ + const enabled = await (await api("/api/client-integrations/aside?profile=1", "PUT", { enabled: true })).json(); + expect(enabled.ok).toBe(true); + const opId = enabled.opId as string; + + const profileStore = join(root, "store", "aside-profiles", "1"); + const snapshotName = join("snapshots", "aside", opId); + writeFileSync(join(root, "store", "journal.jsonl"), readFileSync(join(profileStore, "journal.jsonl"), "utf8")); + mkdirSync(join(root, "store", "snapshots", "aside"), { recursive: true }); + writeFileSync(join(root, "store", snapshotName), readFileSync(join(profileStore, snapshotName), "utf8")); + rmSync(join(profileStore, snapshotName)); + + writeFileSync(path(1), JSON.stringify({ theme: "drifted-before-the-preview" })); + await seedRoster(); + const preview = await api("/api/client-integrations/aside/profiles/1/preview", "POST", { + operation: "restore", opId, confirmDrift: true, + }); + const plan = await preview.json() as { canApply: boolean; fingerprint: string }; + expect(plan.canApply).toBe(true); + + /* + * The edit lands on the second reading of this profile's document. The first belongs to the + * check that runs before the copy, which therefore accepts the file the operator confirmed; by + * the second, which the coordinated restore's own check performs, the file is something else. + */ + const edited = JSON.stringify({ theme: "edited-after-the-copy" }); + let reads = 0; + /* + * A file-level seam only. The Aside layer rebinds the journal and record writers to the + * profile's own store whatever io it is handed, so the history assertions below still describe + * the store they name. + */ + const base = defaultIntegrationIO(store); + setIntegrationMutationFlightTestHooks({ + store, + io: { + ...base, + readText: (target: string) => { + if (target === path(1)) { + reads += 1; + if (reads === 2) writeFileSync(path(1), edited); + } + return base.readText(target); + }, + }, + }); + + const undo = await api("/api/client-integrations/aside/profiles/1/restore", "POST", { + opId, operation: "restore", confirmDrift: true, planFingerprint: plan.fingerprint, + }); + + expect(reads).toBeGreaterThan(1); + expect(undo.status).toBe(409); + expect((await undo.json() as { code: string }).code).toBe("integration_preview_stale"); + // The editor's file survives: no restore transaction ran. + expect(readFileSync(path(1), "utf8")).toBe(edited); + // The copied history and the saved preference may remain; what must not is a restore row. + const profileRows = readFileSync(join(profileStore, "journal.jsonl"), "utf8") + .trim().split("\n").map(line => JSON.parse(line) as { kind: string }); + expect(profileRows.some(row => row.kind === "restore")).toBe(false); +}); From e7bc9e9aff6e00da13179f28611d2181d1b18c50 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 09:24:28 +0900 Subject: [PATCH 83/83] test(integrations): witness the history copy before a competing edit Gate the injected edit on actual destination snapshot creation and byte equality, not a read ordinal that reached the early guard. Preserve target-byte, stale-code and no-restore assertions and prove that desired preference persistence occurred. Static proposal review passed. Local tests, typecheck and builds were not run. --- tests/server/aside-profiles-routes.test.ts | 23 ++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/server/aside-profiles-routes.test.ts b/tests/server/aside-profiles-routes.test.ts index 0794548ff1e..2c202e13130 100644 --- a/tests/server/aside-profiles-routes.test.ts +++ b/tests/server/aside-profiles-routes.test.ts @@ -615,13 +615,13 @@ test("a target edited after the history copy is still not rewritten by the old c const plan = await preview.json() as { canApply: boolean; fingerprint: string }; expect(plan.canApply).toBe(true); - /* - * The edit lands on the second reading of this profile's document. The first belongs to the - * check that runs before the copy, which therefore accepts the file the operator confirmed; by - * the second, which the coordinated restore's own check performs, the file is something else. - */ + // The absent destination snapshot is the observable boundary. Neither restore preflight nor + // either pre-import check can trigger the edit; only a read after the real copy can do so. + const copiedSnapshotPath = join(profileStore, snapshotName); + const sourceSnapshot = readFileSync(join(root, "store", snapshotName), "utf8"); + expect(existsSync(copiedSnapshotPath)).toBe(false); const edited = JSON.stringify({ theme: "edited-after-the-copy" }); - let reads = 0; + let editedAfterCopy = false; /* * A file-level seam only. The Aside layer rebinds the journal and record writers to the * profile's own store whatever io it is handed, so the history assertions below still describe @@ -633,9 +633,10 @@ test("a target edited after the history copy is still not rewritten by the old c io: { ...base, readText: (target: string) => { - if (target === path(1)) { - reads += 1; - if (reads === 2) writeFileSync(path(1), edited); + if (target === path(1) && !editedAfterCopy && existsSync(copiedSnapshotPath)) { + expect(readFileSync(copiedSnapshotPath, "utf8")).toBe(sourceSnapshot); + editedAfterCopy = true; + writeFileSync(path(1), edited); } return base.readText(target); }, @@ -646,7 +647,9 @@ test("a target edited after the history copy is still not rewritten by the old c opId, operation: "restore", confirmDrift: true, planFingerprint: plan.fingerprint, }); - expect(reads).toBeGreaterThan(1); + expect(editedAfterCopy).toBe(true); + expect(readFileSync(copiedSnapshotPath, "utf8")).toBe(sourceSnapshot); + expect(saved?.asideProfileSync?.profiles?.["1"]).toBe(false); expect(undo.status).toBe(409); expect((await undo.json() as { code: string }).code).toBe("integration_preview_stale"); // The editor's file survives: no restore transaction ran.