diff --git a/src/local-agent-sessions.test.ts b/src/local-agent-sessions.test.ts index 83a8e03a..e650f721 100644 --- a/src/local-agent-sessions.test.ts +++ b/src/local-agent-sessions.test.ts @@ -1,9 +1,9 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import test, { after } from "node:test"; -import { LocalAgentSessionManager, AgentSessionError, getWorkerProcessOwnership } from "./local-agent-sessions.js"; +import { LocalAgentSessionManager, AgentSessionError, OpencodeCatalogDriftError, fingerprintCatalogRuntimeIdentity, getWorkerProcessOwnership } from "./local-agent-sessions.js"; import { LocalAgentStore } from "./local-agent-store.js"; import type { LocalAgentProfile } from "./local-agent-profiles.js"; @@ -949,3 +949,249 @@ test("runWorkerTurnFromFile persists typed AgentProviderFailureError details", a clean(); } }); + +// ─── OpenCode catalog drift attribution (issue #58) ────────────────────────── +// Production-path coverage: the pre-provider guard in runWorkerTurnFromFile +// must reject each material drift with an attributable kind while keeping the +// exact fail-closed behavior, and must never leak host-sensitive identity. + +function driftLiveSnapshot(overrides: Record = {}): unknown { + return { + generation: "drift-live-gen", + source: "sdk", + fetchedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + freshness: "fresh", + runtime: { source: "sdk", version: "unknown" }, + entries: [], + ...overrides, + }; +} + +function driftReceipt(overrides: Record = {}): Record { + return { + provider: "opencode", + model: "opencode/probe-model", + source: "sdk", + generation: "drift-live-gen", + fetchedAt: new Date().toISOString(), + freshness: "fresh", + runtimeIdentity: "sdk:unknown:unknown", + ...overrides, + }; +} + +async function runOpencodeDriftTurn(liveSnapshot: unknown): Promise<{ + clean: () => void; + closeOnly: () => void; + stateDir: string; + config: any; + turnInvocations: number; + record: any; +}> { + const stateDir = mkdtempSync(join(tmpdir(), "devspace-drift-test-")); + const config = { + stateDir, + subagents: true, + oauth: { scopes: ["devspace"] }, + } as any; + let turnInvocations = 0; + const fakeSource = { + acquire: async () => liveSnapshot, + close: () => undefined, + }; + const manager = new LocalAgentSessionManager( + config, + async () => {}, + async () => true, + async () => { + turnInvocations += 1; + throw new Error("provider must not be invoked after drift refusal"); + }, + undefined, + undefined, + undefined, + fakeSource as never, + ); + const projectRoot = join(stateDir, "project"); + mkdirSync(projectRoot, { recursive: true }); + const store = (manager as any).store as LocalAgentStore; + const created = store.create({ + workspaceId: "ws_drift", + workspaceRoot: projectRoot, + profileName: "opencode-test", + provider: "opencode", + model: "opencode/probe-model", + lifecycleKind: "detached_worker_v2", + executionContract: { catalogReceipt: driftReceipt() } as any, + }); + const token = "drift-worker-token"; + store.prepareWorker(created.id, token); + const promptFile = join(stateDir, `prompt-${created.id}.txt`); + writeFileSync(promptFile, "drift probe"); + await manager.runWorkerTurnFromFile(created.id, promptFile, token); + const closeOnly = () => { + try { + manager.close(); + } catch {} + }; + const clean = () => { + closeOnly(); + try { + rmSync(stateDir, { recursive: true, force: true }); + } catch {} + }; + return { clean, closeOnly, stateDir, config, turnInvocations, record: store.getById(created.id)! }; +} + +for ( + const [kind, liveOverride] of [ + ["GENERATION_CHANGED", { generation: "drift-other-gen" }], + ["SOURCE_CHANGED", { source: "cli" }], + [ + "SNAPSHOT_EXPIRED", + { + fetchedAt: new Date(Date.now() - 120_000).toISOString(), + expiresAt: new Date(Date.now() - 60_000).toISOString(), + }, + ], + ["FRESHNESS_CHANGED", { freshness: "stale" }], + ["RUNTIME_IDENTITY_CHANGED", { runtime: { source: "sdk", version: "unknown", executable: "/drift/probe/opencode" } }], + ] as const +) { + test(`OpenCode catalog drift is rejected and attributable: ${kind}`, async () => { + const { clean, turnInvocations, record } = await runOpencodeDriftTurn(driftLiveSnapshot(liveOverride)); + try { + assert.equal(record.status, "error"); + assert.equal(record.terminalReason, "provider_error"); + assert.match( + record.error ?? "", + new RegExp(`^Persisted OpenCode catalog receipt drifted before provider invocation; refusing execution\\. Drift: ${kind}\\.$`), + ); + assert.equal(turnInvocations, 0, "drift refusal must precede provider invocation"); + } finally { + clean(); + } + }); +} + +test("OpenCode unchanged receipt reaches model validation instead of drift refusal", async () => { + const { clean, turnInvocations, record } = await runOpencodeDriftTurn(driftLiveSnapshot()); + try { + assert.equal(record.status, "error"); + assert.match(record.error ?? "", /not available in the current catalog/); + assert.doesNotMatch(record.error ?? "", /Drift: /); + assert.equal(turnInvocations, 0); + } finally { + clean(); + } +}); + +test("OpenCode drift evidence never exposes host-sensitive runtime identity", () => { + const raw = "sdk:unknown:/home/u/.secret-tokens/opencode"; + const error = new OpencodeCatalogDriftError("RUNTIME_IDENTITY_CHANGED", { + expected: { + generation: "drift-live-gen", + source: "sdk", + freshness: "fresh", + runtimeIdentityFingerprint: fingerprintCatalogRuntimeIdentity("sdk:unknown:unknown"), + }, + observed: { + generation: "drift-live-gen", + source: "sdk", + freshness: "fresh", + runtimeIdentityFingerprint: fingerprintCatalogRuntimeIdentity(raw), + }, + }); + assert.equal(error.code, "OPENCODE_CATALOG_RECEIPT_DRIFTED"); + assert.equal(error.driftKind, "RUNTIME_IDENTITY_CHANGED"); + assert.match(error.message, /Drift: RUNTIME_IDENTITY_CHANGED\./); + const serialized = JSON.stringify({ message: error.message, code: error.code, evidence: error.evidence }); + assert.ok(!serialized.includes(".secret-tokens"), "raw host path must not appear in drift evidence"); + assert.ok(!serialized.includes(raw), "raw runtime identity must not appear in drift evidence"); + assert.equal(fingerprintCatalogRuntimeIdentity(raw), fingerprintCatalogRuntimeIdentity(raw)); + assert.equal(fingerprintCatalogRuntimeIdentity(raw).length, 16); + assert.notEqual(fingerprintCatalogRuntimeIdentity(raw), fingerprintCatalogRuntimeIdentity("sdk:unknown:unknown")); +}); + +test("OpenCode drift evidence survives durable persistence and store reopen: RUNTIME_IDENTITY_CHANGED", async () => { + const secretExecutable = "/home/u/.secret-tokens/opencode"; + const first = await runOpencodeDriftTurn( + driftLiveSnapshot({ runtime: { source: "sdk", version: "unknown", executable: secretExecutable } }), + ); + const agentId = first.record.id; + assert.equal(first.record.status, "error"); + assert.equal(first.turnInvocations, 0, "drift refusal must precede provider invocation"); + // Reopen the same durable store; nothing may come from the ephemeral Error. + first.closeOnly(); + const manager2 = new LocalAgentSessionManager( + first.config, + async () => {}, + async () => true, + ); + try { + const store2 = (manager2 as any).store as LocalAgentStore; + const reread = store2.getById(agentId)!; + assert.equal(reread.status, "error"); + assert.equal(reread.errorCode, "OPENCODE_CATALOG_RECEIPT_DRIFTED"); + assert.match(reread.error ?? "", /Drift: RUNTIME_IDENTITY_CHANGED\./); + const details = reread.errorDetails as any; + assert.equal(details?.code, "OPENCODE_CATALOG_RECEIPT_DRIFTED"); + assert.equal(details?.driftKind, "RUNTIME_IDENTITY_CHANGED"); + assert.deepEqual(details?.expected, { + generation: "drift-live-gen", + source: "sdk", + freshness: "fresh", + runtimeIdentityFingerprint: fingerprintCatalogRuntimeIdentity("sdk:unknown:unknown"), + }); + assert.equal(details?.observed?.generation, "drift-live-gen"); + assert.equal(details?.observed?.source, "sdk"); + assert.equal(details?.observed?.freshness, "fresh"); + assert.equal( + details?.observed?.runtimeIdentityFingerprint, + fingerprintCatalogRuntimeIdentity(`sdk:unknown:${secretExecutable}`), + ); + assert.equal(typeof details?.observed?.liveFetchedAt, "string"); + assert.equal(typeof details?.observed?.liveExpiresAt, "string"); + const serialized = JSON.stringify(reread); + assert.ok(!serialized.includes(secretExecutable), "raw executable path must not persist"); + assert.ok(!serialized.includes(".secret-tokens"), "secret-looking path must not persist"); + } finally { + try { + manager2.close(); + } catch {} + try { + rmSync(first.stateDir, { recursive: true, force: true }); + } catch {} + } +}); + +test("OpenCode drift evidence survives durable persistence and store reopen: GENERATION_CHANGED", async () => { + const first = await runOpencodeDriftTurn(driftLiveSnapshot({ generation: "drift-other-gen" })); + const agentId = first.record.id; + assert.equal(first.record.status, "error"); + assert.equal(first.turnInvocations, 0, "drift refusal must precede provider invocation"); + first.closeOnly(); + const manager2 = new LocalAgentSessionManager( + first.config, + async () => {}, + async () => true, + ); + try { + const store2 = (manager2 as any).store as LocalAgentStore; + const reread = store2.getById(agentId)!; + assert.equal(reread.errorCode, "OPENCODE_CATALOG_RECEIPT_DRIFTED"); + const details = reread.errorDetails as any; + assert.equal(details?.driftKind, "GENERATION_CHANGED"); + assert.equal(details?.expected?.generation, "drift-live-gen"); + assert.equal(details?.observed?.generation, "drift-other-gen"); + assert.match(reread.error ?? "", /Drift: GENERATION_CHANGED\./); + } finally { + try { + manager2.close(); + } catch {} + try { + rmSync(first.stateDir, { recursive: true, force: true }); + } catch {} + } +}); diff --git a/src/local-agent-sessions.ts b/src/local-agent-sessions.ts index 06e15d71..8b2244ce 100644 --- a/src/local-agent-sessions.ts +++ b/src/local-agent-sessions.ts @@ -13,6 +13,7 @@ import { LocalAgentStore, type LocalAgentRecord, type LocalAgentStatus, + type OpencodeCatalogDriftEvidencePayload, } from "./local-agent-store.js"; import { isLocalAgentProvider, loadLocalAgentProfiles, type LocalAgentProfile } from "./local-agent-profiles.js"; import { @@ -127,6 +128,64 @@ export class AgentSessionError extends Error { } } +/** + * Which material OpenCode catalog drift predicate refused a pre-provider turn. + * The guard stays fail-closed; this only names the first predicate that fired + * so a later witness can distinguish a real catalog change from stale + * snapshot/scope bookkeeping without weakening the refusal. + */ +export type OpencodeCatalogDriftKind = + | "GENERATION_CHANGED" + | "SOURCE_CHANGED" + | "SNAPSHOT_EXPIRED" + | "FRESHNESS_CHANGED" + | "RUNTIME_IDENTITY_CHANGED"; + +/** Bounded expected-vs-observed drift tuple. Fingerprints only, no raw paths. */ +export interface OpencodeCatalogDriftEvidence { + expected: { + generation: string; + source: string; + freshness: string; + runtimeIdentityFingerprint: string; + }; + observed: { + generation: string; + source: string; + freshness: string; + runtimeIdentityFingerprint: string; + liveFetchedAt?: string; + liveExpiresAt?: string; + }; +} + +/** + * Fail-closed OpenCode catalog drift refusal with attribution. + * The message keeps the historical refusal sentence verbatim and appends the + * machine-readable drift kind, so every existing durable/log surface that only + * carries the message still identifies the predicate. Structured evidence + * lives on the error fields; host-sensitive identity is fingerprinted and + * never emitted raw. + */ +export class OpencodeCatalogDriftError extends Error { + readonly code = "OPENCODE_CATALOG_RECEIPT_DRIFTED" as const; + constructor( + readonly driftKind: OpencodeCatalogDriftKind, + readonly evidence: OpencodeCatalogDriftEvidence, + ) { + super( + "Persisted OpenCode catalog receipt drifted before provider invocation; refusing execution." + + ` Drift: ${driftKind}.`, + ); + this.name = "OpencodeCatalogDriftError"; + } +} + +/** Stable bounded fingerprint for host-sensitive runtime identity strings. */ +export function fingerprintCatalogRuntimeIdentity(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex").slice(0, 16); +} + /** Raised when the worker workspace fails the canonical containment gate. */ export class WorkspaceContainmentError extends Error { constructor(message: string) { @@ -235,7 +294,7 @@ export interface AgentStatusOutput { error?: string; errorCode?: string; errorRetryable?: boolean; - errorDetails?: AgentProviderFailureDetails; + errorDetails?: AgentProviderFailureDetails | OpencodeCatalogDriftEvidencePayload; createdAt: string; updatedAt: string; startedAt?: string; @@ -1836,12 +1895,31 @@ export class LocalAgentSessionManager { if (catalogReceipt?.provider === "opencode") { const liveCatalog = await this.opencodeCatalogSource.acquire(); const runtimeIdentity = `${liveCatalog.runtime?.source ?? "unknown"}:${liveCatalog.runtime?.version ?? "unknown"}:${liveCatalog.runtime?.executable ?? "unknown"}`; - if (liveCatalog.generation !== catalogReceipt.generation - || liveCatalog.source !== catalogReceipt.source - || !catalogSnapshotIsFresh(liveCatalog.fetchedAt, liveCatalog.expiresAt) - || (liveCatalog.freshness ?? "unknown") !== catalogReceipt.freshness - || runtimeIdentity !== catalogReceipt.runtimeIdentity) { - throw new Error("Persisted OpenCode catalog receipt drifted before provider invocation; refusing execution."); + const liveFresh = liveCatalog.freshness ?? "unknown"; + const driftKind: OpencodeCatalogDriftKind | undefined = + liveCatalog.generation !== catalogReceipt.generation ? "GENERATION_CHANGED" + : liveCatalog.source !== catalogReceipt.source ? "SOURCE_CHANGED" + : !catalogSnapshotIsFresh(liveCatalog.fetchedAt, liveCatalog.expiresAt) ? "SNAPSHOT_EXPIRED" + : liveFresh !== catalogReceipt.freshness ? "FRESHNESS_CHANGED" + : runtimeIdentity !== catalogReceipt.runtimeIdentity ? "RUNTIME_IDENTITY_CHANGED" + : undefined; + if (driftKind !== undefined) { + throw new OpencodeCatalogDriftError(driftKind, { + expected: { + generation: catalogReceipt.generation, + source: catalogReceipt.source, + freshness: catalogReceipt.freshness, + runtimeIdentityFingerprint: fingerprintCatalogRuntimeIdentity(catalogReceipt.runtimeIdentity), + }, + observed: { + generation: liveCatalog.generation, + source: liveCatalog.source, + freshness: liveFresh, + runtimeIdentityFingerprint: fingerprintCatalogRuntimeIdentity(runtimeIdentity), + ...(liveCatalog.fetchedAt === undefined ? {} : { liveFetchedAt: liveCatalog.fetchedAt }), + ...(liveCatalog.expiresAt === undefined ? {} : { liveExpiresAt: liveCatalog.expiresAt }), + }, + }); } const validation = validateOpencodeModelAndVariant(catalogReceipt.model, catalogReceipt.effort, liveCatalog); if (!validation.valid) throw new Error(validation.reason ?? "Persisted OpenCode catalog receipt is no longer valid."); @@ -1955,9 +2033,20 @@ export class LocalAgentSessionManager { let latestResponse: string | undefined; let errorCode: string | undefined; let errorRetryable: boolean | undefined; - let errorDetails: AgentProviderFailureDetails | string | undefined; + let errorDetails: AgentProviderFailureDetails | OpencodeCatalogDriftEvidencePayload | string | undefined; - if (AgentProviderFailureError.is(error)) { + if (error instanceof OpencodeCatalogDriftError) { + errorCode = error.code; + errorRetryable = false; + errorDetails = { + code: error.code, + errorClass: "CATALOG_RECEIPT_DRIFT", + retryable: false, + driftKind: error.driftKind, + expected: error.evidence.expected, + observed: error.evidence.observed, + }; + } else if (AgentProviderFailureError.is(error)) { errorCode = error.code; errorRetryable = error.retryable; errorDetails = { diff --git a/src/local-agent-store.ts b/src/local-agent-store.ts index 1b7f0633..fe3bacb5 100644 --- a/src/local-agent-store.ts +++ b/src/local-agent-store.ts @@ -70,6 +70,38 @@ export interface PhysicalTerminationState { previousWorkerToken?: string; } +/** + * Durable attribution payload for a pre-provider catalog drift refusal. + * This is a contract failure, not a provider execution failure: it carries + * no provider message, session, or model Route claim. All identity material + * is bounded (hashes, enums, timestamps); raw host paths never persist here. + */ +export interface OpencodeCatalogDriftEvidencePayload { + code: "OPENCODE_CATALOG_RECEIPT_DRIFTED"; + errorClass: "CATALOG_RECEIPT_DRIFT"; + retryable: false; + driftKind: + | "GENERATION_CHANGED" + | "SOURCE_CHANGED" + | "SNAPSHOT_EXPIRED" + | "FRESHNESS_CHANGED" + | "RUNTIME_IDENTITY_CHANGED"; + expected: { + generation: string; + source: string; + freshness: string; + runtimeIdentityFingerprint: string; + }; + observed: { + generation: string; + source: string; + freshness: string; + runtimeIdentityFingerprint: string; + liveFetchedAt?: string; + liveExpiresAt?: string; + }; +} + export interface LocalAgentRecord { id: string; workspaceId?: string; @@ -93,7 +125,7 @@ export interface LocalAgentRecord { error?: string; errorCode?: string; errorRetryable?: boolean; - errorDetails?: AgentProviderFailureDetails; + errorDetails?: AgentProviderFailureDetails | OpencodeCatalogDriftEvidencePayload; providerContinuityState?: "KNOWN_UNVERIFIED" | "RESUME_VERIFIED" | "LOST" | "UNKNOWN"; createdAt: string; updatedAt: string; @@ -183,7 +215,7 @@ export interface FinishTurnCasInput { error?: string; errorCode?: string; errorRetryable?: boolean; - errorDetails?: AgentProviderFailureDetails | string; + errorDetails?: AgentProviderFailureDetails | OpencodeCatalogDriftEvidencePayload | string; terminalReason?: AgentTerminalReason; scopeState?: ScopeState; cumulativeChangedPaths?: string[]; @@ -1346,10 +1378,42 @@ function rowToLocalAgentRecord(row: LocalAgentRow): LocalAgentRecord { }; } -function readErrorDetails(value: string | null): AgentProviderFailureDetails | undefined { +function readErrorDetails( + value: string | null, +): AgentProviderFailureDetails | OpencodeCatalogDriftEvidencePayload | undefined { if (!value) return undefined; try { const parsed = JSON.parse(value) as Partial; + const drifted = parsed as Partial; + if ( + drifted.code === "OPENCODE_CATALOG_RECEIPT_DRIFTED" && + typeof drifted.driftKind === "string" && + drifted.expected !== undefined && + typeof drifted.expected === "object" && + drifted.observed !== undefined && + typeof drifted.observed === "object" + ) { + return { + code: "OPENCODE_CATALOG_RECEIPT_DRIFTED", + errorClass: "CATALOG_RECEIPT_DRIFT", + retryable: false, + driftKind: drifted.driftKind, + expected: { + generation: String(drifted.expected.generation ?? ""), + source: String(drifted.expected.source ?? ""), + freshness: String(drifted.expected.freshness ?? ""), + runtimeIdentityFingerprint: String(drifted.expected.runtimeIdentityFingerprint ?? ""), + }, + observed: { + generation: String(drifted.observed.generation ?? ""), + source: String(drifted.observed.source ?? ""), + freshness: String(drifted.observed.freshness ?? ""), + runtimeIdentityFingerprint: String(drifted.observed.runtimeIdentityFingerprint ?? ""), + ...(typeof drifted.observed.liveFetchedAt === "string" ? { liveFetchedAt: drifted.observed.liveFetchedAt } : {}), + ...(typeof drifted.observed.liveExpiresAt === "string" ? { liveExpiresAt: drifted.observed.liveExpiresAt } : {}), + }, + }; + } if (parsed.code && parsed.errorClass) { return { code: parsed.code,