From 5a291aad5c0a70ae198fc397a1021b16c413879d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 21:26:50 -0700 Subject: [PATCH 1/4] Sync vendored Interchange trees to main HEAD without local patches Overwrite all eleven vendor trees with the upstream files at 1ad0104 (retrieved 2026-09-14), dropping every local patch. Adds packages/workflow-host/src/workflow-definition-loader.ts to the partial workflow-host tree and removes packages/types/src/sidecar-placement.ts, which upstream deleted. The follow-up commit re-applies the ledgered local patches. --- vendor/intx-agent/src/agent.test.ts | 7 +- vendor/intx-agent/src/agent.ts | 67 +- .../intx-agent/src/audit-integration.test.ts | 6 +- .../intx-agent/src/compactor-wiring.test.ts | 3 +- vendor/intx-agent/src/credential-resolver.ts | 46 ++ vendor/intx-agent/src/env-validation.test.ts | 2 +- vendor/intx-agent/src/env.ts | 20 + vendor/intx-agent/src/flush-errors.test.ts | 315 +------- vendor/intx-agent/src/index.ts | 4 + .../intx-agent/src/internal-fixtures/mail.ts | 2 +- .../src/internal-fixtures/planner.ts | 2 +- vendor/intx-agent/src/source.test.ts | 26 +- .../intx-agent/src/testing/audit-noop.test.ts | 5 - vendor/intx-agent/src/testing/audit-noop.ts | 4 - vendor/intx-harness/src/harness.test.ts | 8 +- vendor/intx-inference/src/adapter.ts | 15 - vendor/intx-inference/src/assembly.test.ts | 5 +- vendor/intx-inference/src/assembly.ts | 43 +- vendor/intx-inference/src/auth.test.ts | 28 +- vendor/intx-inference/src/auth.ts | 19 +- .../src/authz-extension.test.ts | 10 - vendor/intx-inference/src/authz-extension.ts | 29 +- vendor/intx-inference/src/errors.ts | 18 +- vendor/intx-inference/src/harness.test.ts | 23 +- vendor/intx-inference/src/harness.ts | 363 +++------- vendor/intx-inference/src/index.ts | 8 +- .../src/providers/anthropic.test.ts | 161 +---- .../intx-inference/src/providers/anthropic.ts | 37 +- .../src/providers/google-genai-files.ts | 4 +- .../src/providers/google-genai.test.ts | 46 -- .../src/providers/google-genai.ts | 9 +- vendor/intx-inference/src/reactor.test.ts | 469 +----------- vendor/intx-inference/src/reactor.ts | 374 ++++------ vendor/intx-inference/src/sse.ts | 20 - vendor/intx-inference/src/state.ts | 60 +- vendor/intx-mailbox/src/fetch.test.ts | 24 + vendor/intx-mailbox/src/fetch.ts | 39 +- vendor/intx-mailbox/src/index.ts | 1 + .../intx-mailbox/src/verify-signature.test.ts | 102 +++ vendor/intx-mailbox/src/verify-signature.ts | 67 ++ vendor/intx-mime/src/index.test.ts | 8 + vendor/intx-mime/src/mime.ts | 13 + vendor/intx-storage-isogit/src/store.test.ts | 32 - vendor/intx-storage-isogit/src/store.ts | 44 +- .../src/sidecar-bundle-toolcwd.test.ts | 86 +++ .../src/sidecar-bundle.test.ts | 9 +- vendor/intx-tools-posix/src/sidecar-bundle.ts | 25 +- vendor/intx-types/src/agent-address.test.ts | 33 + vendor/intx-types/src/agent-address.ts | 24 +- vendor/intx-types/src/credential-cipher.ts | 13 + vendor/intx-types/src/hex.ts | 6 +- vendor/intx-types/src/index.ts | 3 +- vendor/intx-types/src/mediated-credential.ts | 15 + vendor/intx-types/src/runtime.ts | 281 +++++--- vendor/intx-types/src/sessions.ts | 32 +- vendor/intx-types/src/sidecar-allocation.ts | 2 + .../src/sidecar-capabilities.test.ts | 69 ++ vendor/intx-types/src/sidecar-capabilities.ts | 61 ++ vendor/intx-types/src/sidecar-placement.ts | 12 - vendor/intx-types/src/sidecar.test.ts | 438 +++++++++++- vendor/intx-types/src/sidecar.ts | 353 ++++++--- vendor/intx-types/src/signer-identity.ts | 33 + vendor/intx-types/src/tenants.ts | 4 +- vendor/intx-types/src/wire-workflow.ts | 12 +- vendor/intx-types/src/workflows.ts | 23 + .../workflow-definition-loader.ts | 674 ++++++++++++++++++ 66 files changed, 2605 insertions(+), 2191 deletions(-) create mode 100644 vendor/intx-agent/src/credential-resolver.ts delete mode 100644 vendor/intx-inference/src/providers/google-genai.test.ts create mode 100644 vendor/intx-mailbox/src/verify-signature.test.ts create mode 100644 vendor/intx-mailbox/src/verify-signature.ts create mode 100644 vendor/intx-tools-posix/src/sidecar-bundle-toolcwd.test.ts create mode 100644 vendor/intx-types/src/sidecar-capabilities.test.ts create mode 100644 vendor/intx-types/src/sidecar-capabilities.ts delete mode 100644 vendor/intx-types/src/sidecar-placement.ts create mode 100644 vendor/intx-types/src/signer-identity.ts create mode 100644 vendor/intx-workflow-host/workflow-definition-loader.ts diff --git a/vendor/intx-agent/src/agent.test.ts b/vendor/intx-agent/src/agent.test.ts index ab7cba420..c65f4bc15 100644 --- a/vendor/intx-agent/src/agent.test.ts +++ b/vendor/intx-agent/src/agent.test.ts @@ -36,7 +36,7 @@ const SOURCE: InferenceSource = { id: "anthropic:claude-3-5-sonnet", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "sk-test", + credentialId: "sk-test", model: "claude-3-5-sonnet", }; @@ -63,6 +63,7 @@ function baseEnv(workdir: string): BaseEnv { return { sources: [SOURCE], defaultSource: SOURCE.id, + readCurrentMaterial: (credentialId) => ({ secret: credentialId }), storage: stubContextStore(), workdir, audit: noopAuditStore(), @@ -423,7 +424,7 @@ describe("createAgent send() on reactor suspend", () => { id: "anthropic:suspend-test", provider: "anthropic", baseURL: "http://localhost:1", - apiKey: "test-key", + credentialId: "test-key", model: "claude-test", }; @@ -435,6 +436,7 @@ describe("createAgent send() on reactor suspend", () => { return { sources: [SUSPEND_SOURCE], defaultSource: SUSPEND_SOURCE.id, + readCurrentMaterial: (credentialId) => ({ secret: credentialId }), storage, workdir, audit: noopAuditStore(), @@ -528,6 +530,7 @@ describe("createAgent send() on reactor suspend", () => { const env: BaseEnv = { sources: [SUSPEND_SOURCE], defaultSource: SUSPEND_SOURCE.id, + readCurrentMaterial: (credentialId) => ({ secret: credentialId }), storage, workdir: workDir, audit: noopAuditStore(), diff --git a/vendor/intx-agent/src/agent.ts b/vendor/intx-agent/src/agent.ts index 0a1b3c3a0..30d462ed9 100644 --- a/vendor/intx-agent/src/agent.ts +++ b/vendor/intx-agent/src/agent.ts @@ -47,6 +47,8 @@ import { type ReactorEmittedEvent, } from "@intx/inference"; import { createDefaultDependencies } from "@intx/inference/providers"; + +import { createUnconfiguredCredentialResolver } from "./credential-resolver"; import { getLogger } from "@intx/log"; import { createInboundMessage } from "@intx/mime"; import type { ErrorRecord } from "@intx/types/audit"; @@ -512,38 +514,10 @@ export async function createAgent( // the next flush, so a fourth caller arriving after the follow-up // begins still observes a clean state and starts its own flush. const accumulatedErrors: ErrorRecord[] = []; - // Resume from durable records so a rebuilt agent does not reuse seq 0 - // and collide with files the previous assembly already committed. - // Locally patched — see vendor/intx-agent/PATCHES.md#agent-ts-resume-error-seq let errorSeq = 0; - try { - for (const record of await auditStore.loadErrors(sessionId)) { - if (record.seq >= errorSeq) errorSeq = record.seq + 1; - } - } catch { - logger.warn`loadErrors failed during assembly; starting error seq at 0`; - } let flushInProgress: Promise | undefined; let pendingFollowUp: Promise | undefined; - // File key mirror of the isogit store's error filename scheme - // (`state/errors//-.json`, seq padded to - // 8, unsafe category chars replaced). Maps a - // `Duplicate error record: ` collision back to the batch member - // that caused it so only that record is dropped. - function errorFileKey(record: ErrorRecord): string { - const seq = String(record.seq).padStart(8, "0"); - const category = record.category.replace(/[^a-zA-Z0-9_-]/g, "_"); - return `${record.sessionId}/${seq}-${category}`; - } - - function dropFromAccumulator(records: readonly ErrorRecord[]): void { - for (const record of records) { - const index = accumulatedErrors.indexOf(record); - if (index !== -1) accumulatedErrors.splice(index, 1); - } - } - function flushErrors(): Promise { if (flushInProgress !== undefined) { // If another caller already arranged a follow-up flush after @@ -577,39 +551,8 @@ export async function createAgent( // expectation is that commitErrors failures are transient. flushInProgress = (async () => { try { - let remaining = batch; - for (;;) { - try { - await auditStore.commitErrors(remaining); - } catch (cause) { - // Locally patched — see vendor/intx-agent/PATCHES.md#agent-ts-duplicate-error-flush - if ( - cause instanceof Error && - cause.message.startsWith("Duplicate error record:") - ) { - const key = cause.message - .slice("Duplicate error record:".length) - .trim(); - const index = remaining.findIndex( - (record) => errorFileKey(record) === key, - ); - if (index === -1) { - logger.warn`duplicate error record already stored; dropping the colliding batch`; - dropFromAccumulator(remaining); - return; - } - const [colliding] = remaining.splice(index, 1); - if (colliding !== undefined) - dropFromAccumulator([colliding]); - logger.warn`duplicate error record already stored; dropping the colliding record`; - if (remaining.length === 0) return; - continue; - } - throw cause; - } - dropFromAccumulator(remaining); - return; - } + await auditStore.commitErrors(batch); + accumulatedErrors.splice(0, count); } finally { flushInProgress = undefined; } @@ -748,6 +691,8 @@ export async function createAgent( source: sourceRegistry.active, failOverToNextSource: () => sourceRegistry.failOverToNextSource(), resetToPreferredSource: () => sourceRegistry.resetToPreferredSource(), + readMaterial: + env.readCurrentMaterial ?? createUnconfiguredCredentialResolver(), toolRunner: resolvedTools.runner, contextStore, onEvent: handleEvent, diff --git a/vendor/intx-agent/src/audit-integration.test.ts b/vendor/intx-agent/src/audit-integration.test.ts index 6022070c4..a245f0aba 100644 --- a/vendor/intx-agent/src/audit-integration.test.ts +++ b/vendor/intx-agent/src/audit-integration.test.ts @@ -49,7 +49,7 @@ const SOURCE: InferenceSource = { id: "anthropic:audit-test", provider: "anthropic", baseURL: "http://localhost:1", - apiKey: "test-key", + credentialId: "test-key", model: "claude-test", }; @@ -71,9 +71,6 @@ function makeRecordingAuditStore(): RecordingAuditStore { async loadAudit(_sessionId: string): Promise { return committedAudit.flat(); }, - async loadErrors(_sessionId: string): Promise { - return committedErrors.flat(); - }, getCommittedAudit() { return committedAudit; }, @@ -188,6 +185,7 @@ async function buildEnv(opts: { return { sources: [SOURCE], defaultSource: SOURCE.id, + readCurrentMaterial: (credentialId) => ({ secret: credentialId }), storage, workdir: opts.workdir, audit: opts.audit, diff --git a/vendor/intx-agent/src/compactor-wiring.test.ts b/vendor/intx-agent/src/compactor-wiring.test.ts index ed48c13ae..9c9fa7db3 100644 --- a/vendor/intx-agent/src/compactor-wiring.test.ts +++ b/vendor/intx-agent/src/compactor-wiring.test.ts @@ -50,7 +50,7 @@ const SOURCE: InferenceSource = { id: "anthropic:compactor-test", provider: "anthropic", baseURL: "http://localhost:1", - apiKey: "test-key", + credentialId: "test-key", model: "claude-test", }; @@ -168,6 +168,7 @@ async function buildEnv(opts: { return { sources: [SOURCE], defaultSource: SOURCE.id, + readCurrentMaterial: (credentialId) => ({ secret: credentialId }), storage, workdir: opts.workdir, audit: noopAuditStore(), diff --git a/vendor/intx-agent/src/credential-resolver.ts b/vendor/intx-agent/src/credential-resolver.ts new file mode 100644 index 000000000..32f98b986 --- /dev/null +++ b/vendor/intx-agent/src/credential-resolver.ts @@ -0,0 +1,46 @@ +// Helpers for the inference credential-material resolver seam +// (`CredentialMaterialResolver` in `@intx/types`). An inference call resolves +// its source's secret by `credentialId` through this seam instead of reading an +// inline `apiKey`, so the source config carries no secret. The sidecar backs it +// with the run's live credential cell; the helpers here cover the two simpler +// cases. + +import type { + CredentialMaterial, + CredentialMaterialResolver, +} from "@intx/types"; + +/** + * A resolver that fails closed on every call. `createAgent` installs this when + * the env supplies no `readCurrentMaterial`, so an agent whose inference never + * resolves a credential (a mock adapter emitting no credential sentinel) needs + * no resolver, while one that DOES reach a credential surfaces a clear error + * rather than a confusing `undefined`. + */ +export function createUnconfiguredCredentialResolver(): CredentialMaterialResolver { + return (credentialId: string): CredentialMaterial => { + throw new Error( + `no credential resolver configured for this agent, but an inference call needs the secret for credential ${credentialId}; supply env.readCurrentMaterial`, + ); + }; +} + +/** + * A resolver over a fixed `credentialId -> secret` map. For callers that hold + * their secrets in memory rather than a live cell -- examples, tests, and any + * single-process agent. Fails closed when a source references a credential the + * map does not carry, mirroring the cell reader's revoked/absent behavior. + */ +export function createStaticCredentialResolver( + materials: Record, +): CredentialMaterialResolver { + return (credentialId: string): CredentialMaterial => { + const secret = materials[credentialId]; + if (secret === undefined) { + throw new Error( + `no credential material for ${credentialId} in the static resolver`, + ); + } + return { secret }; + }; +} diff --git a/vendor/intx-agent/src/env-validation.test.ts b/vendor/intx-agent/src/env-validation.test.ts index f45633024..c3e8120b6 100644 --- a/vendor/intx-agent/src/env-validation.test.ts +++ b/vendor/intx-agent/src/env-validation.test.ts @@ -16,7 +16,7 @@ const SOURCE = { id: "anthropic:claude-3-5-sonnet", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "sk-test", + credentialId: "sk-test", model: "claude-3-5-sonnet", }; diff --git a/vendor/intx-agent/src/env.ts b/vendor/intx-agent/src/env.ts index 08e462fcc..d0bb7d8aa 100644 --- a/vendor/intx-agent/src/env.ts +++ b/vendor/intx-agent/src/env.ts @@ -13,6 +13,7 @@ // tests and examples ship from `@intx/agent/testing`. import type { AuthzCallResult, Dependencies } from "@intx/inference"; +import type { CredentialMaterialResolver } from "@intx/types"; import type { AuditStore, Compactor, @@ -82,6 +83,12 @@ export interface BaseEnv { * `workdir` values pointing at the same on-disk storage directory * will silently corrupt each other -- the invariant is the caller's * to maintain. + * + * This is the lock and storage boundary, not the working tree the + * filesystem tools operate on. A tool that needs a working directory + * (e.g. `@intx/tools-posix`) reads that from its own env-DI key + * declared through `defineTool({ requires })`, which the caller may + * point at a directory distinct from `workdir`. */ workdir: string; @@ -128,6 +135,19 @@ export interface BaseEnv { */ deps?: Dependencies; + /** + * Resolves an inference source's credential secret by `credentialId` from the + * run's credential-material cell at send time -- the same cell tool + * credentials resolve from, so the source config carries no inline secret. + * + * Optional at this boundary only to spare callers whose inference never + * resolves a credential (a mock adapter that emits no credential sentinel). + * `createAgent` fills a fail-closed default that throws if an inference call + * actually needs a secret; a caller that does real credentialed inference (the + * sidecar step env, an example, a test with a real adapter) MUST supply one. + */ + readCurrentMaterial?: CredentialMaterialResolver; + /** * Optional deterministic session id. Production callers omit and let * the agent generate a fresh UUID; tests that assert on audit-record diff --git a/vendor/intx-agent/src/flush-errors.test.ts b/vendor/intx-agent/src/flush-errors.test.ts index 7064d5d6d..81783a97e 100644 --- a/vendor/intx-agent/src/flush-errors.test.ts +++ b/vendor/intx-agent/src/flush-errors.test.ts @@ -14,7 +14,6 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { type } from "arktype"; -import { createDefaultDependencies } from "@intx/inference/providers"; import { createInboundMessage } from "@intx/mime"; import { createIsogitStore } from "@intx/storage-isogit/node"; import type { AuditRecord, ErrorRecord } from "@intx/types/audit"; @@ -40,7 +39,7 @@ const UNREACHABLE_SOURCE: InferenceSource = { id: "anthropic:test-error", provider: "anthropic", baseURL: "http://localhost:1", - apiKey: "test-key", + credentialId: "test-key", model: "claude-test", }; @@ -60,9 +59,6 @@ function makeRecordingAuditStore(): RecordingAuditStore { async loadAudit(_sessionId: string): Promise { return []; }, - async loadErrors(_sessionId: string): Promise { - return committedErrors.flat(); - }, getCommittedErrors() { return committedErrors; }, @@ -95,69 +91,6 @@ function makeFailFirstAuditStore(): FailingAuditStore { async loadAudit(_sessionId: string): Promise { return []; }, - async loadErrors(_sessionId: string): Promise { - return committedErrors.flat(); - }, - getCommittedErrors() { - return committedErrors; - }, - }; -} - -function makeDuplicateErrorAuditStore(): FailingAuditStore { - return { - async commitAudit(_records: AuditRecord[]): Promise { - // No-op. - }, - async commitErrors(records: ErrorRecord[]): Promise { - throw new Error( - `Duplicate error record: ${records[0]?.sessionId ?? "session"}/00000000-credential_failure`, - ); - }, - async loadAudit(_sessionId: string): Promise { - return []; - }, - async loadErrors(_sessionId: string): Promise { - return []; - }, - getCommittedErrors() { - return []; - }, - }; -} - -// Audit store that collides on the first batch's leading record only, -// simulating a stale-seq assembly flushing [seq0/dup, seq1/fresh]: the -// first `commitErrors` throws `Duplicate error record` naming the -// colliding record's file key, and the retry succeeds. -function makePartialDuplicateAuditStore(): FailingAuditStore { - const committedErrors: ErrorRecord[][] = []; - let firstAttempt = true; - return { - async commitAudit(_records: AuditRecord[]): Promise { - // No-op. - }, - async commitErrors(records: ErrorRecord[]): Promise { - if (firstAttempt) { - firstAttempt = false; - const colliding = records[0]; - const seq = String(colliding?.seq ?? 0).padStart(8, "0"); - const category = (colliding?.category ?? "").replace( - /[^a-zA-Z0-9_-]/g, - "_", - ); - throw new Error( - `Duplicate error record: ${colliding?.sessionId ?? "session"}/${seq}-${category}`, - ); - } - committedErrors.push([...records]); - }, - async loadAudit(_sessionId: string): Promise { - return []; - }, - async loadErrors(_sessionId: string): Promise { - return []; - }, getCommittedErrors() { return committedErrors; }, @@ -191,6 +124,7 @@ async function buildAgentEnv(opts: { return { sources: [UNREACHABLE_SOURCE], defaultSource: UNREACHABLE_SOURCE.id, + readCurrentMaterial: (credentialId) => ({ secret: credentialId }), storage, workdir: opts.workdir, audit: opts.audit, @@ -220,86 +154,6 @@ async function waitForReactorDone( } } -const FORBIDDEN_DEPS = { - ...createDefaultDependencies(), - fetch: async () => - new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }), -}; - -function credentialFailureDirectors(): BaseEnv["directors"] { - return makeDirectorRegistry( - async ( - event: ReactorInboundEvent, - _state: ReactorState, - caps: ReactorCapabilities, - ) => { - if (event.type === "message.received") return caps.infer(); - if (event.type === "inference.error") { - return [caps.checkpoint("after-error"), caps.done()]; - } - return caps.done(); - }, - ); -} - -function forbiddenAgentDef(id: string) { - return defineAgent({ - id, - systemPrompt: "test", - tools: [], - capabilities: [], - inference: { - sources: [ - { - provider: UNREACHABLE_SOURCE.provider, - model: UNREACHABLE_SOURCE.model, - }, - ], - }, - }); -} - -function duplicateFlushFailures( - events: ReadonlyArray<{ type: string; data?: unknown }>, -): ReadonlyArray<{ type: string; data?: unknown }> { - return events.filter((event) => { - if (event.type !== "reactor.error") return false; - return JSON.stringify(event.data ?? {}).includes("Duplicate error record"); - }); -} - -async function runForbiddenCycle(opts: { - workdir: string; - sessionId: string; - agentId: string; -}): Promise<{ events: Array<{ type: string; data?: unknown }> }> { - const store = await createIsogitStore(opts.workdir); - const env: BaseEnv = { - sources: [UNREACHABLE_SOURCE], - defaultSource: UNREACHABLE_SOURCE.id, - storage: store, - workdir: opts.workdir, - audit: store, - authorize: permissiveAuthorize(), - directors: credentialFailureDirectors(), - sessionId: opts.sessionId, - deps: FORBIDDEN_DEPS, - }; - const agent = await createAgent(forbiddenAgentDef(opts.agentId), env); - const events: Array<{ type: string; data?: unknown }> = []; - const stream = agent.stream(); - try { - agent.deliver(inboundConversation()); - for await (const event of stream) { - events.push(event); - if (event.type === "reactor.done") break; - } - } finally { - await agent.close(); - } - return { events }; -} - describe("agent error flushing", () => { let workDir: string; @@ -617,169 +471,4 @@ describe("agent error flushing", () => { expect(batches.length).toBe(1); expect(batches[0]?.[0]?.source).toBe("reactor"); }); - - test("two credential_failure errors in one session persist without failing the run", async () => { - const sessionId = "session-credential-once"; - let inferenceErrors = 0; - const store = await createIsogitStore(workDir); - const env: BaseEnv = { - sources: [UNREACHABLE_SOURCE], - defaultSource: UNREACHABLE_SOURCE.id, - storage: store, - workdir: workDir, - audit: store, - authorize: permissiveAuthorize(), - directors: makeDirectorRegistry( - async ( - event: ReactorInboundEvent, - _state: ReactorState, - caps: ReactorCapabilities, - ) => { - if (event.type === "message.received") return caps.infer(); - if (event.type === "inference.error") { - inferenceErrors += 1; - if (inferenceErrors === 1) { - return [caps.checkpoint("after-first"), caps.infer()]; - } - return [caps.checkpoint("after-second"), caps.done()]; - } - return caps.done(); - }, - ), - sessionId, - deps: FORBIDDEN_DEPS, - }; - const agent = await createAgent(forbiddenAgentDef("cred-flush-once"), env); - const events: Array<{ type: string; data?: unknown }> = []; - const stream = agent.stream(); - try { - agent.deliver(inboundConversation()); - for await (const event of stream) { - events.push(event); - if (event.type === "reactor.done") break; - } - } finally { - await agent.close(); - } - - expect(duplicateFlushFailures(events)).toEqual([]); - const records = (await store.loadErrors(sessionId)).filter( - (record) => record.category === "credential_failure", - ); - expect(records).toHaveLength(2); - expect(new Set(records.map((record) => record.seq)).size).toBe(2); - }); - - test("two credential_failure errors persist across re-assembly without failing the session", async () => { - const sessionId = "session-credential"; - const first = await runForbiddenCycle({ - workdir: workDir, - sessionId, - agentId: "cred-flush-1", - }); - const second = await runForbiddenCycle({ - workdir: workDir, - sessionId, - agentId: "cred-flush-2", - }); - - expect(duplicateFlushFailures(first.events)).toEqual([]); - expect(duplicateFlushFailures(second.events)).toEqual([]); - const store = await createIsogitStore(workDir); - const records = (await store.loadErrors(sessionId)).filter( - (record) => record.category === "credential_failure", - ); - expect(records).toHaveLength(2); - expect(new Set(records.map((record) => record.seq)).size).toBe(2); - }); - - test("a duplicate error record from commitErrors does not fail the session", async () => { - const audit = makeDuplicateErrorAuditStore(); - const directors = credentialFailureDirectors(); - const def = forbiddenAgentDef("cred-flush-duplicate"); - const env = await buildAgentEnv({ workdir: workDir, audit, directors }); - const agent = await createAgent(def, { ...env, deps: FORBIDDEN_DEPS }); - const events: Array<{ type: string; data?: unknown }> = []; - const stream = agent.stream(); - try { - agent.deliver(inboundConversation()); - for await (const event of stream) { - events.push(event); - if (event.type === "reactor.done") break; - } - } finally { - await agent.close(); - } - - expect(duplicateFlushFailures(events)).toEqual([]); - expect(events.some((event) => event.type === "reactor.done")).toBe(true); - }); - - test("a partial duplicate collision drops only the colliding record", async () => { - // A stale-seq assembly flushing [seq0/dup, seq1/fresh] must persist - // the fresh record: the first commit names only the colliding key, - // so the flush drops that record and retries the rest. - const audit = makePartialDuplicateAuditStore(); - let inferenceErrors = 0; - const directors = makeDirectorRegistry( - async ( - event: ReactorInboundEvent, - _state: ReactorState, - caps: ReactorCapabilities, - ) => { - if (event.type === "message.received") return caps.infer(); - if (event.type === "inference.error") { - inferenceErrors += 1; - if (inferenceErrors === 1) return caps.infer(); - return [caps.checkpoint("after-second"), caps.done()]; - } - return caps.done(); - }, - ); - const def = forbiddenAgentDef("cred-flush-partial-duplicate"); - const env = await buildAgentEnv({ workdir: workDir, audit, directors }); - const agent = await createAgent(def, { ...env, deps: FORBIDDEN_DEPS }); - const events: Array<{ type: string; data?: unknown }> = []; - const stream = agent.stream(); - try { - agent.deliver(inboundConversation()); - for await (const event of stream) { - events.push(event); - if (event.type === "reactor.done") break; - } - } finally { - await agent.close(); - } - - expect(duplicateFlushFailures(events)).toEqual([]); - expect(events.some((event) => event.type === "reactor.done")).toBe(true); - const persisted = audit.getCommittedErrors().flat(); - expect(persisted).toHaveLength(1); - expect(persisted[0]?.seq).toBe(1); - }); - - test("createAgent still assembles when loadErrors throws", async () => { - const audit = makeRecordingAuditStore(); - audit.loadErrors = async () => { - throw new Error("simulated loadErrors failure"); - }; - const directors = credentialFailureDirectors(); - const def = forbiddenAgentDef("cred-flush-load-errors"); - const env = await buildAgentEnv({ workdir: workDir, audit, directors }); - const agent = await createAgent(def, { ...env, deps: FORBIDDEN_DEPS }); - const events: Array<{ type: string; data?: unknown }> = []; - const stream = agent.stream(); - try { - agent.deliver(inboundConversation()); - for await (const event of stream) { - events.push(event); - if (event.type === "reactor.done") break; - } - } finally { - await agent.close(); - } - - expect(events.some((event) => event.type === "reactor.done")).toBe(true); - expect(audit.getCommittedErrors().flat().length).toBeGreaterThan(0); - }); }); diff --git a/vendor/intx-agent/src/index.ts b/vendor/intx-agent/src/index.ts index 276605317..b5d9f4304 100644 --- a/vendor/intx-agent/src/index.ts +++ b/vendor/intx-agent/src/index.ts @@ -50,6 +50,10 @@ export { type DirectorRef, type DirectorRegistry, } from "./director-types"; +export { + createStaticCredentialResolver, + createUnconfiguredCredentialResolver, +} from "./credential-resolver"; export { validateNamespacedId } from "./namespace"; export { CanonicalizationError, canonicalizeForHash } from "./canonicalize"; export { diff --git a/vendor/intx-agent/src/internal-fixtures/mail.ts b/vendor/intx-agent/src/internal-fixtures/mail.ts index e4200d8f3..8fb3798b1 100644 --- a/vendor/intx-agent/src/internal-fixtures/mail.ts +++ b/vendor/intx-agent/src/internal-fixtures/mail.ts @@ -24,7 +24,7 @@ export const MAIL_SOURCE: InferenceSource = { id: "anthropic:claude-opus-4-6", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "sk-test-mail", + credentialId: "sk-test-mail", model: "claude-opus-4-6", }; diff --git a/vendor/intx-agent/src/internal-fixtures/planner.ts b/vendor/intx-agent/src/internal-fixtures/planner.ts index a7adb87ee..13068c0bb 100644 --- a/vendor/intx-agent/src/internal-fixtures/planner.ts +++ b/vendor/intx-agent/src/internal-fixtures/planner.ts @@ -16,7 +16,7 @@ export const PLANNER_SOURCE: InferenceSource = { id: "anthropic:claude-opus-4-6", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "sk-test-planner", + credentialId: "sk-test-planner", model: "claude-opus-4-6", }; diff --git a/vendor/intx-agent/src/source.test.ts b/vendor/intx-agent/src/source.test.ts index 1b7f05d82..c715c519f 100644 --- a/vendor/intx-agent/src/source.test.ts +++ b/vendor/intx-agent/src/source.test.ts @@ -12,7 +12,7 @@ const S_ANTHROPIC: InferenceSource = { id: "anthropic:claude-3-5-sonnet", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "sk-anthropic-1", + credentialId: "sk-anthropic-1", model: "claude-3-5-sonnet", }; @@ -20,7 +20,7 @@ const S_OPENAI: InferenceSource = { id: "openai:gpt-5.5", provider: "openai", baseURL: "https://api.openai.com", - apiKey: "sk-openai-1", + credentialId: "sk-openai-1", model: "gpt-5.5", }; @@ -42,7 +42,7 @@ describe("createSourceRegistry", () => { }); expect(reg.active.provider).toBe("openai"); expect(reg.active.model).toBe("gpt-5.5"); - expect(reg.active.apiKey).toBe("sk-openai-1"); + expect(reg.active.credentialId).toBe("sk-openai-1"); }); test("rejects an empty sources[] array", () => { @@ -65,7 +65,7 @@ describe("createSourceRegistry", () => { id: "anthropic:none", provider: "anthropic", baseURL: "u", - apiKey: "k", + credentialId: "k", }); expect(() => createSourceRegistry({ @@ -78,7 +78,7 @@ describe("createSourceRegistry", () => { test("rejects sources[] with duplicate ids", () => { expect(() => createSourceRegistry({ - sources: [S_ANTHROPIC, { ...S_ANTHROPIC, apiKey: "sk-other" }], + sources: [S_ANTHROPIC, { ...S_ANTHROPIC, credentialId: "sk-other" }], defaultSource: S_ANTHROPIC.id, }), ).toThrow(InvalidInferenceSourceError); @@ -104,7 +104,7 @@ describe("createSourceRegistry", () => { id: "anthropic:claude-3-5-haiku", provider: "anthropic", baseURL: "https://proxy.example.com", - apiKey: "sk-new", + credentialId: "sk-new", model: "claude-3-5-haiku", }); @@ -112,7 +112,7 @@ describe("createSourceRegistry", () => { expect(reg.active.id).toBe("anthropic:claude-3-5-haiku"); expect(reg.active.provider).toBe("anthropic"); expect(reg.active.baseURL).toBe("https://proxy.example.com"); - expect(reg.active.apiKey).toBe("sk-new"); + expect(reg.active.credentialId).toBe("sk-new"); expect(reg.active.model).toBe("claude-3-5-haiku"); }); @@ -166,10 +166,10 @@ describe("createSourceRegistry", () => { reg.setSource({ ...S_ANTHROPIC, baseURL: "https://other.example.com", - apiKey: "sk-other", + credentialId: "sk-other", }); - expect(inputs[0]?.apiKey).toBe("sk-anthropic-1"); + expect(inputs[0]?.credentialId).toBe("sk-anthropic-1"); expect(inputs[0]?.baseURL).toBe("https://api.anthropic.com"); }); @@ -185,7 +185,7 @@ describe("createSourceRegistry", () => { expect(reg.active).toBe(reference); expect(reg.active.id).toBe("openai:gpt-5.5"); expect(reg.active.provider).toBe("openai"); - expect(reg.active.apiKey).toBe("sk-openai-1"); + expect(reg.active.credentialId).toBe("sk-openai-1"); }); test("setSources throws when the new default matches no source", () => { @@ -219,7 +219,7 @@ describe("createSourceRegistry", () => { expect(reg.failOverToNextSource()).toBe(true); expect(reg.active).toBe(reference); // mutated in place expect(reg.active.id).toBe(S_OPENAI.id); - expect(reg.active.apiKey).toBe(S_OPENAI.apiKey); + expect(reg.active.credentialId).toBe(S_OPENAI.credentialId); // Already at the last source: no further failover target. expect(reg.failOverToNextSource()).toBe(false); @@ -275,13 +275,13 @@ describe("createSourceRegistry", () => { id: "anthropic:claude-3-5-haiku", provider: "anthropic", baseURL: "https://proxy.example.com", - apiKey: "sk-hot", + credentialId: "sk-hot", model: "claude-3-5-haiku", }); // The per-cycle reset must not discard the deliberate hot-swap. reg.resetToPreferredSource(); expect(reg.active.id).toBe("anthropic:claude-3-5-haiku"); - expect(reg.active.apiKey).toBe("sk-hot"); + expect(reg.active.credentialId).toBe("sk-hot"); }); }); diff --git a/vendor/intx-agent/src/testing/audit-noop.test.ts b/vendor/intx-agent/src/testing/audit-noop.test.ts index 741e5a8ea..f2658edcb 100644 --- a/vendor/intx-agent/src/testing/audit-noop.test.ts +++ b/vendor/intx-agent/src/testing/audit-noop.test.ts @@ -18,11 +18,6 @@ describe("noopAuditStore", () => { expect(await store.loadAudit("sess")).toEqual([]); }); - test("loadErrors returns an empty array", async () => { - const store = noopAuditStore(); - expect(await store.loadErrors("sess")).toEqual([]); - }); - test("each call returns a fresh object", () => { expect(noopAuditStore()).not.toBe(noopAuditStore()); }); diff --git a/vendor/intx-agent/src/testing/audit-noop.ts b/vendor/intx-agent/src/testing/audit-noop.ts index 0b1b42adc..612d682a7 100644 --- a/vendor/intx-agent/src/testing/audit-noop.ts +++ b/vendor/intx-agent/src/testing/audit-noop.ts @@ -25,9 +25,5 @@ export function noopAuditStore(): AuditStore { async loadAudit(_sessionId: string): Promise { return []; }, - // Locally patched — see vendor/intx-agent/PATCHES.md#testing-audit-noop-ts-load-errors - async loadErrors(_sessionId: string): Promise { - return []; - }, }; } diff --git a/vendor/intx-harness/src/harness.test.ts b/vendor/intx-harness/src/harness.test.ts index 92b068a32..5a97d6478 100644 --- a/vendor/intx-harness/src/harness.test.ts +++ b/vendor/intx-harness/src/harness.test.ts @@ -52,7 +52,7 @@ const SOURCE: InferenceSource = { id: "anthropic:claude-3-5-sonnet", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "sk-test-harness", + credentialId: "sk-test-harness", model: "claude-3-5-sonnet", }; @@ -182,6 +182,10 @@ function mailEnv(opts: { audit: noopAuditStore(), authorize: permissiveAuthorize(), directors: createDefaultDirectorRegistry(), + // Identity resolver: the mock adapter never sends the injected secret, so + // returning the credentialId as its own secret resolves any source these + // tests install (including the outbound env that overrides `sources`). + readCurrentMaterial: (credentialId) => ({ secret: credentialId }), transport: opts.transport, address: AGENT_ADDRESS, }; @@ -351,7 +355,7 @@ describe("createHarness outbound pipeline", () => { id: "anthropic:claude-3-5-sonnet", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "sk-test-harness-outbound", + credentialId: "sk-test-harness-outbound", model: "claude-3-5-sonnet", }, ], diff --git a/vendor/intx-inference/src/adapter.ts b/vendor/intx-inference/src/adapter.ts index 608f5cf69..04c9efc52 100644 --- a/vendor/intx-inference/src/adapter.ts +++ b/vendor/intx-inference/src/adapter.ts @@ -72,27 +72,12 @@ export type RetryAfterExtractor = (headers: Headers) => number | undefined; // wait before the next request, or undefined if no pacing is needed. export type PacingExtractor = (headers: Headers) => number | undefined; -// Reports whether an SSE data payload is the protocol's end-of-turn signal. -// Most protocols end the stream with the `[DONE]` sentinel (stripped by -// `parseSSE`) or by closing the socket, and need no such predicate. The -// OpenAI Responses protocol does neither: it marks completion with a semantic -// `response.completed` event and holds the connection open, so a client that -// waits for socket close hangs. Adapters for those protocols implement this so -// the harness stops reading once the terminal event is processed. -// -// Locally patched — see vendor/intx-inference/PATCHES.md#adapter-ts-stream-terminal-detector -export type StreamTerminalDetector = (sseData: string) => boolean; - export type ProviderAdapter = { buildRequest: RequestBuilder; parseResponse: ResponseParser; parseJSONResponse: JSONResponseParser; extractRetryAfterMs?: RetryAfterExtractor; extractPacingDelayMs?: PacingExtractor; - // When present, the harness stops reading the SSE stream after processing - // the events from the chunk this returns true for. Absent means the stream - // ends only on `[DONE]` or socket close. - isStreamTerminal?: StreamTerminalDetector; }; // Builds a fresh adapter for one inference call. Invoked per call so the diff --git a/vendor/intx-inference/src/assembly.test.ts b/vendor/intx-inference/src/assembly.test.ts index 91f1aa69a..ca4aa854b 100644 --- a/vendor/intx-inference/src/assembly.test.ts +++ b/vendor/intx-inference/src/assembly.test.ts @@ -179,7 +179,7 @@ function source(): InferenceSource { id: "anthropic:test-model", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "test", + credentialId: "test", model: "test-model", }; } @@ -198,9 +198,6 @@ function makeRecordingAuditStore(): AuditStore & { async commitErrors() { /* noop */ }, - async loadErrors() { - return []; - }, getCommitted() { return committed; }, diff --git a/vendor/intx-inference/src/assembly.ts b/vendor/intx-inference/src/assembly.ts index 8b4d257e5..538483e90 100644 --- a/vendor/intx-inference/src/assembly.ts +++ b/vendor/intx-inference/src/assembly.ts @@ -9,6 +9,7 @@ // directly so the wiring stays consistent across composition points. import { getLogger } from "@intx/log"; +import type { CredentialMaterialResolver } from "@intx/types"; import { createBlobReader, type BlobReader, @@ -30,7 +31,7 @@ import { type AuthzExtensionOptions, } from "./authz-extension"; import type { CorrelationValidator } from "./correlation"; -import type { Dependencies, PollBatchLivenessPredicate } from "./harness"; +import type { Dependencies } from "./harness"; import { createReactor, type Reactor, @@ -66,6 +67,12 @@ export type ReactorAssemblyConfig = { failOverToNextSource?: () => boolean; /** Reset `source` to the most-preferred source, in place. */ resetToPreferredSource?: () => void; + /** + * Resolves the active source's credential secret by `credentialId` from the + * run's credential cell at send time. Threaded verbatim to the reactor; + * optional, defaulted fail-closed by the harness when omitted. + */ + readMaterial?: CredentialMaterialResolver; toolRunner: ToolRunner; contextStore: ContextStore; onEvent: (event: ReactorEmittedEvent) => void; @@ -82,13 +89,6 @@ export type ReactorAssemblyConfig = { beforeToolExtensions?: BeforeToolExtension[]; toolResultTransforms?: ToolResultTransform[]; contextTransforms?: ContextTransform[]; - /** - * Liveness policy for the doom-loop guard's batch accounting. A direct - * value wins over the one riding `deps`. - * - * Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-poll-exemption - */ - isPollOnlyPendingBatch?: PollBatchLivenessPredicate; compactors?: Record; sizeCapMaxChars?: number; @@ -133,6 +133,7 @@ export function createReactorAssembly( source, failOverToNextSource, resetToPreferredSource, + readMaterial, toolRunner, contextStore, onEvent, @@ -142,7 +143,6 @@ export function createReactorAssembly( beforeToolExtensions: callerBeforeToolExtensions, toolResultTransforms: callerToolResultTransforms, contextTransforms, - isPollOnlyPendingBatch, compactors, sizeCapMaxChars, afterCheckpoint: callerAfterCheckpoint, @@ -242,23 +242,6 @@ export function createReactorAssembly( } : callerOnShutdown; - // Transforms arrive either directly on the assembly config or riding - // `deps` (the only channel the published `@intx/agent` forwards verbatim). - // A direct value wins so callers composing their own assembly are - // unaffected by whatever a shared deps object carries. - // - // Locally patched — see vendor/intx-inference/PATCHES.md#assembly-ts-deps-context-transforms - const resolvedContextTransforms = contextTransforms ?? deps.contextTransforms; - - // The liveness policy resolves the same way: directly on the assembly - // config, or riding `deps` (the only channel the published `@intx/agent` - // forwards verbatim). A direct value wins so callers composing their own - // assembly are unaffected by whatever a shared deps object carries. - // - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-poll-exemption - const resolvedIsPollOnlyPendingBatch = - isPollOnlyPendingBatch ?? deps.isPollOnlyPendingBatch; - // exactOptionalPropertyTypes is on: only set optional keys when defined. const reactorConfig: ReactorConfig = { sessionId, @@ -266,6 +249,7 @@ export function createReactorAssembly( source, ...(failOverToNextSource !== undefined ? { failOverToNextSource } : {}), ...(resetToPreferredSource !== undefined ? { resetToPreferredSource } : {}), + ...(readMaterial !== undefined ? { readMaterial } : {}), toolRunner, contextStore, onEvent: composedOnEvent, @@ -274,12 +258,7 @@ export function createReactorAssembly( ...(composedBeforeToolExtensions !== undefined ? { beforeToolExtensions: composedBeforeToolExtensions } : {}), - ...(resolvedContextTransforms !== undefined - ? { contextTransforms: resolvedContextTransforms } - : {}), - ...(resolvedIsPollOnlyPendingBatch !== undefined - ? { isPollOnlyPendingBatch: resolvedIsPollOnlyPendingBatch } - : {}), + ...(contextTransforms !== undefined ? { contextTransforms } : {}), ...(compactors !== undefined ? { compactors } : {}), ...(composedAfterCheckpoint !== undefined ? { afterCheckpoint: composedAfterCheckpoint } diff --git a/vendor/intx-inference/src/auth.test.ts b/vendor/intx-inference/src/auth.test.ts index c5b8ef2d8..d1258f389 100644 --- a/vendor/intx-inference/src/auth.test.ts +++ b/vendor/intx-inference/src/auth.test.ts @@ -1,13 +1,14 @@ // Credential-sentinel substitution. Adapters declare which credential // shape they want by placing one of the exported sentinel strings as // the header value; `injectCredentials` walks the header map and -// rewrites exact-match values with material derived from -// `InferenceSource.apiKey`. The harness uses this in place of the +// rewrites exact-match values with the secret the resolver returns for +// the source's `credentialId`. The harness uses this in place of the // previous per-header hardcoded branches so adding a new provider // requires no harness change. import { describe, expect, test } from "bun:test"; +import type { CredentialMaterialResolver } from "@intx/types"; import type { InferenceSource } from "@intx/types/runtime"; import { @@ -20,10 +21,16 @@ const SOURCE: InferenceSource = { id: "test:model", provider: "test", baseURL: "https://test.invalid", - apiKey: "sk-test-secret", + credentialId: "sk-test-secret", model: "test-model", }; +// Resolves the source's `credentialId` to its secret. SOURCE.credentialId +// is the secret literal the assertions expect in the rewritten headers. +const readMaterial: CredentialMaterialResolver = (credentialId) => ({ + secret: credentialId, +}); + describe("injectCredentials", () => { test("replaces CREDENTIAL_SENTINEL with apiKey verbatim", () => { const out = injectCredentials( @@ -32,6 +39,7 @@ describe("injectCredentials", () => { "content-type": "application/json", }, SOURCE, + readMaterial, ); expect(out["x-api-key"]).toBe("sk-test-secret"); expect(out["content-type"]).toBe("application/json"); @@ -44,6 +52,7 @@ describe("injectCredentials", () => { "content-type": "application/json", }, SOURCE, + readMaterial, ); expect(out["authorization"]).toBe("Bearer sk-test-secret"); expect(out["content-type"]).toBe("application/json"); @@ -57,6 +66,7 @@ describe("injectCredentials", () => { "user-agent": "test", }, SOURCE, + readMaterial, ); expect(out["content-type"]).toBe("application/json"); expect(out["anthropic-version"]).toBe("2023-06-01"); @@ -71,6 +81,7 @@ describe("injectCredentials", () => { const out = injectCredentials( { "x-goog-api-key": CREDENTIAL_SENTINEL }, SOURCE, + readMaterial, ); expect(out["x-goog-api-key"]).toBe("sk-test-secret"); }); @@ -81,7 +92,11 @@ describe("injectCredentials", () => { // Partial replacement would be surprising and no legitimate // adapter constructs composite values around the sentinel. const wrapped = `prefix ${CREDENTIAL_SENTINEL} suffix`; - const out = injectCredentials({ "x-weird-header": wrapped }, SOURCE); + const out = injectCredentials( + { "x-weird-header": wrapped }, + SOURCE, + readMaterial, + ); expect(out["x-weird-header"]).toBe(wrapped); }); @@ -89,7 +104,7 @@ describe("injectCredentials", () => { const input: Record = { "x-api-key": CREDENTIAL_SENTINEL, }; - const out = injectCredentials(input, SOURCE); + const out = injectCredentials(input, SOURCE, readMaterial); expect(input["x-api-key"]).toBe(CREDENTIAL_SENTINEL); expect(out["x-api-key"]).toBe("sk-test-secret"); expect(out).not.toBe(input); @@ -106,12 +121,13 @@ describe("injectCredentials", () => { authorization: BEARER_CREDENTIAL_SENTINEL, }, SOURCE, + readMaterial, ); expect(out["x-api-key"]).toBe("sk-test-secret"); expect(out["authorization"]).toBe("Bearer sk-test-secret"); }); test("empty headers in, empty headers out", () => { - expect(injectCredentials({}, SOURCE)).toEqual({}); + expect(injectCredentials({}, SOURCE, readMaterial)).toEqual({}); }); }); diff --git a/vendor/intx-inference/src/auth.ts b/vendor/intx-inference/src/auth.ts index dba0cd54c..a5625beb3 100644 --- a/vendor/intx-inference/src/auth.ts +++ b/vendor/intx-inference/src/auth.ts @@ -1,10 +1,11 @@ import type { InferenceSource } from "@intx/types/runtime"; +import type { CredentialMaterialResolver } from "@intx/types"; // Sentinel placeholder strings adapters use in their built request // headers to declare which credential the harness should fill at send // time. The harness scans every header value and replaces exact-match -// sentinels with material derived from `InferenceSource.apiKey`. Adapters -// never see the API key. +// sentinels with the secret resolved from the source's `credentialId` +// against the run's credential cell. Adapters never see the API key. // // Each new provider adds a new header name + sentinel choice in its // `buildRequest`; the harness needs no per-provider knowledge. The @@ -46,13 +47,23 @@ export const BEARER_CREDENTIAL_SENTINEL = ""; export function injectCredentials( headers: Record, source: InferenceSource, + readMaterial: CredentialMaterialResolver, ): Record { + // Resolve the source's secret lazily and once: only when a header actually + // carries a sentinel, so a request with no credential sentinel never touches + // the cell, and the fail-closed read (revoked/absent credential) surfaces only + // when the secret is genuinely needed. + let cachedSecret: string | undefined; + const secret = (): string => { + cachedSecret ??= readMaterial(source.credentialId).secret; + return cachedSecret; + }; const result: Record = {}; for (const [name, value] of Object.entries(headers)) { if (value === CREDENTIAL_SENTINEL) { - result[name] = source.apiKey; + result[name] = secret(); } else if (value === BEARER_CREDENTIAL_SENTINEL) { - result[name] = `Bearer ${source.apiKey}`; + result[name] = `Bearer ${secret()}`; } else { result[name] = value; } diff --git a/vendor/intx-inference/src/authz-extension.test.ts b/vendor/intx-inference/src/authz-extension.test.ts index b4e7260a9..5495304df 100644 --- a/vendor/intx-inference/src/authz-extension.test.ts +++ b/vendor/intx-inference/src/authz-extension.test.ts @@ -134,16 +134,6 @@ describe("createAuthzExtension", () => { expect(d.resolvedBy.id).toBe("grant-2"); }); - test("deny effect uses authorize reason when provided", async () => { - const ext = createAuthzExtension({ - authorize: async () => ({ ...denyResult(), reason: "write_file (probe.txt) blocked" }), - }); - const result = await ext.beforeTool(makeCall(), makeState(), signal); - expect(result.type).toBe("block"); - if (result.type !== "block") throw new Error("expected block"); - expect(result.reason).toBe("Denied by policy: write_file (probe.txt) blocked"); - }); - test("ask effect suspends with a minted correlation and pending operation", async () => { const decisions: AuthzDecision[] = []; diff --git a/vendor/intx-inference/src/authz-extension.ts b/vendor/intx-inference/src/authz-extension.ts index d7dfdf3b0..d5f8f8527 100644 --- a/vendor/intx-inference/src/authz-extension.ts +++ b/vendor/intx-inference/src/authz-extension.ts @@ -25,7 +25,6 @@ import type { ApprovalSnapshot, BeforeToolExtension, PendingOperation, - ToolCall, ToolDefinition, } from "@intx/types/runtime"; import type { Effect } from "@intx/types/authz"; @@ -49,8 +48,6 @@ export type AuthzCallResult = { effect: Effect | null; matchingGrants: AuthzMatchedGrant[]; resolvedBy: AuthzMatchedGrant | null; - // Locally patched — see vendor/intx-inference/PATCHES.md#authz-ts-deny-reason - reason?: string; }; export type AuthzDecision = { @@ -95,13 +92,10 @@ function formatBlockReason( effect: BlockEffect, resource: string, action: string, - detail?: string, ): string { switch (effect) { case "deny": - return detail !== undefined && detail.length > 0 - ? `Denied by policy: ${detail}` - : `Denied by policy: ${resource}/${action}`; + return `Denied by policy: ${resource}/${action}`; case null: return `No matching grants for ${resource}/${action}`; } @@ -124,11 +118,13 @@ function safeOnDecision( export function createAuthzExtension( opts: AuthzExtensionOptions, ): BeforeToolExtension { - // Per-call context for the authorize callback: the call itself, frozen. - // Locally patched — see vendor/intx-inference/PATCHES.md#authz-ts-authorize-call-context - const frozenCallContext = (call: ToolCall): Ctx => - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- shape-freeze hygiene; the call is the per-call identity the Ctx contract exists to carry - Object.freeze(call) as Ctx; + // The reactor does not know workflow concepts; per-call context is the + // caller's domain. The third arg is plumbing here -- if the caller + // needs to attach context (workflow step, tenant id, request id), they + // do so by closure on the authorize function. The empty object is the + // safe default at this layer. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the inference layer has no domain knowledge to construct a Ctx; callers that need a populated context use closure capture on the authorize function (see @intx/workflow's AuthorizeContext) + const emptyContext = Object.freeze({}) as Ctx; // One-shot bypass tokens, keyed on ToolCall.id. A token authorizes a single // re-dispatch of an already-approved call to skip the `ask` gate it would @@ -158,7 +154,7 @@ export function createAuthzExtension( let result: AuthzCallResult; try { - result = await opts.authorize(resource, action, frozenCallContext(call)); + result = await opts.authorize(resource, action, emptyContext); } catch (cause) { const msg = cause instanceof Error ? cause.message : String(cause); const decision: AuthzDecision = { @@ -183,12 +179,7 @@ export function createAuthzExtension( // are blocks. const blockReason = result.effect === "deny" || result.effect === null - ? formatBlockReason( - result.effect, - resource, - action, - result.effect === "deny" ? result.reason : undefined, - ) + ? formatBlockReason(result.effect, resource, action) : undefined; const decision: AuthzDecision = { diff --git a/vendor/intx-inference/src/errors.ts b/vendor/intx-inference/src/errors.ts index 93cc78c86..75207ce02 100644 --- a/vendor/intx-inference/src/errors.ts +++ b/vendor/intx-inference/src/errors.ts @@ -43,22 +43,8 @@ export function classifyNetworkError(cause: unknown): InferenceError { return { category: "retryable", message, raw: cause }; } -/** - * `origin` mirrors AbortSignal.reason from the send path - * (e.g. intercode `user-stop` / `internal-recovery` string literals). - * - * Locally patched — see vendor/intx-inference/PATCHES.md#errors-ts-classify-abort-reason - */ -export type ClassifiedAbortRaw = { origin: unknown }; - -export function classifyAbortError(reason?: unknown): InferenceError { - const raw: ClassifiedAbortRaw | undefined = - reason !== undefined ? { origin: reason } : undefined; - return { - category: "aborted", - message: "inference aborted", - ...(raw !== undefined ? { raw } : {}), - }; +export function classifyAbortError(): InferenceError { + return { category: "aborted", message: "inference aborted" }; } export function classifyTimeoutError( diff --git a/vendor/intx-inference/src/harness.test.ts b/vendor/intx-inference/src/harness.test.ts index ff7ee0bb8..10794b93e 100644 --- a/vendor/intx-inference/src/harness.test.ts +++ b/vendor/intx-inference/src/harness.test.ts @@ -24,7 +24,7 @@ const SOURCE: InferenceSource = { id: "anthropic:claude-3-5-sonnet-20240620", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "test", + credentialId: "test", model: "claude-3-5-sonnet-20240620", }; @@ -99,6 +99,7 @@ describe("runInference — Dependencies parameter", () => { source: SOURCE, nextSeq: () => ++seq, deps, + readMaterial: () => ({ secret: "test-secret" }), }), ); } finally { @@ -142,6 +143,7 @@ describe("runInference — Dependencies parameter", () => { source: SOURCE, nextSeq: () => ++seq, deps, + readMaterial: () => ({ secret: "test-secret" }), }), ); } finally { @@ -291,7 +293,7 @@ describe("runInference — source.defaults merge precedence", () => { id: "openai:gpt-test", provider: "openai", baseURL: "https://api.openai.test/v1", - apiKey: "test", + credentialId: "test", model: "gpt-test", }; @@ -326,6 +328,7 @@ describe("runInference — source.defaults merge precedence", () => { runInference({ turns: [userTurn("hi")], source: opts.source, + readMaterial: () => ({ secret: "test-secret" }), ...(opts.perCallMaxTokens !== undefined ? { inferenceOptions: { maxTokens: opts.perCallMaxTokens } } : {}), @@ -410,7 +413,7 @@ describe("runInference — providerOptions merge precedence", () => { id: `${providerName}:test-model`, provider: providerName, baseURL: "https://test.invalid", - apiKey: "test", + credentialId: "test", model: "test-model", ...(opts.sourceProviderOptions !== undefined ? { defaults: { providerOptions: opts.sourceProviderOptions } } @@ -544,6 +547,7 @@ describe("runInference — source-identity stamping", () => { source: SOURCE, nextSeq: () => ++seq, deps, + readMaterial: () => ({ secret: "test-secret" }), }), ); @@ -561,14 +565,14 @@ describe("runInference — source-identity stamping", () => { id: "anthropic:claude-A", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "test", + credentialId: "test", model: "claude-A", }; const sourceB: InferenceSource = { id: "openai:gpt-B", provider: "openai", baseURL: "https://api.openai.test/v1", - apiKey: "test", + credentialId: "test", model: "gpt-B", }; const deps: Dependencies = { @@ -590,6 +594,7 @@ describe("runInference — source-identity stamping", () => { source: sourceA, nextSeq: () => ++seq, deps, + readMaterial: () => ({ secret: "test-secret" }), }), ); const doneA = eventsA.find((e) => e.type === "inference.done"); @@ -607,6 +612,7 @@ describe("runInference — source-identity stamping", () => { source: sourceB, nextSeq: () => ++seq, deps, + readMaterial: () => ({ secret: "test-secret" }), }), ); const doneB = eventsB.find((e) => e.type === "inference.done"); @@ -628,7 +634,7 @@ describe("runInference — source-identity stamping", () => { id: "anthropic:claude-pre", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "test", + credentialId: "test", model: "claude-pre", }; @@ -643,7 +649,7 @@ describe("runInference — source-identity stamping", () => { activeSource.id = "openai:gpt-post"; activeSource.provider = "openai"; activeSource.baseURL = "https://api.openai.test/v1"; - activeSource.apiKey = "test-post"; + activeSource.credentialId = "test-post"; activeSource.model = "gpt-post"; return Promise.resolve( new Response("", { @@ -663,6 +669,7 @@ describe("runInference — source-identity stamping", () => { source: activeSource, nextSeq: () => ++seq, deps, + readMaterial: () => ({ secret: "test-secret" }), }), ); @@ -751,7 +758,7 @@ describe("runInference — non-streaming JSON responses", () => { id: "test-json:model-x", provider: "test-json", baseURL: "https://example.test", - apiKey: "test", + credentialId: "test", model: "model-x", }; diff --git a/vendor/intx-inference/src/harness.ts b/vendor/intx-inference/src/harness.ts index 84444dfe4..cdc3501d5 100644 --- a/vendor/intx-inference/src/harness.ts +++ b/vendor/intx-inference/src/harness.ts @@ -19,7 +19,6 @@ import type { CitationBlock, CodeExecutionRequestBlock, CodeExecutionResultBlock, - ContextTransform, ConversationTurn, ImageBlock, InferenceError, @@ -31,12 +30,12 @@ import type { RetryDecision, SafetyRatingBlock, TokenUsage, - ToolCall, - ToolResult, AssistantTurn, ContentBlock, } from "@intx/types/runtime"; +import type { CredentialMaterialResolver } from "@intx/types"; + import { getLogger } from "@intx/log"; import { @@ -78,19 +77,6 @@ export const DEFAULT_TOTAL_TIMEOUT_MS = 600_000; export const HarnessId: unique symbol = Symbol("HarnessId"); -/** - * Liveness policy for the doom-loop guard's batch accounting. Receives the - * executed calls of one tool turn with their results aligned by index and - * returns true when the batch is legitimate liveness rather than a runaway - * loop. First-party runtimes recognize still-pending polls (`wait_agents` - * timeouts and live wait statuses, `running` shell collects); terminal polls, - * non-poll calls, and mixed batches return false so real loops still trip. - */ -export type PollBatchLivenessPredicate = ( - calls: readonly ToolCall[], - results: readonly ToolResult[], -) => boolean; - /** * Runtime dependencies injected into `runInference`. Code-only — not part of * any persisted schema. Test harnesses substitute `fetch` (and stamp the @@ -131,39 +117,6 @@ export type Dependencies = { * built-in set, `@intx/inference/providers`' `createDefaultDependencies()`. */ readonly adapters: AdapterRegistry; - /** - * Pre-inference context transforms applied in order before every model - * call. Carried on Dependencies because the published `@intx/agent` - * forwards `deps` into reactor assembly verbatim while exposing no env - * field for transforms; riding `deps` reaches the vendored assembly - * without modifying the published package. - * - * Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts-context-transforms - */ - readonly contextTransforms?: ContextTransform[]; - /** - * Liveness policy for the doom-loop guard's batch accounting. When the - * batch about to be counted is a legitimate liveness signal (first-party - * runtimes recognize still-pending polls), the stale streak resets instead - * of counting. Optional — a custom `Dependencies` object without this - * field counts every batch, same as before. - * - * Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-poll-exemption - */ - readonly isPollOnlyPendingBatch?: PollBatchLivenessPredicate; - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-warning-turn - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-fail-run - /** Note appended to each tool result when a batch repeat hits threshold−1. */ - readonly doomLoopCorrectiveNote?: (repeat: { - calls: readonly ToolCall[]; - repeatCount: number; - threshold: number; - }) => string | undefined; - /** - * `"fail-run"` closes the tripped message run and returns the reactor to - * idle instead of shutting down. Unset is `"shutdown"`, same as upstream. - */ - readonly doomLoopPolicy?: "shutdown" | "fail-run"; readonly [HarnessId]?: symbol; }; @@ -226,9 +179,29 @@ export type InferenceHarnessOptions = { signal?: AbortSignal; // Sequence number allocator — called once per event to get the next seq. nextSeq: () => number; + // Resolves the source's credential secret by `source.credentialId` from the + // run's credential cell at send time, so the source config carries no inline + // secret. Read live per attempt, so a failover to a source with a different + // `credentialId` resolves that source's credential. Optional: a caller whose + // adapter emits no credential sentinel (a mock harness in a test) needs none; + // the harness installs a fail-closed default that throws only if a request + // actually reaches a credential sentinel without a resolver. + readMaterial?: CredentialMaterialResolver; deps: Dependencies; }; +// Fail-closed default resolver, installed when a caller supplies no +// `readMaterial`. It throws only if a request actually reaches a credential +// sentinel, so a sentinel-free mock harness runs without a resolver while a +// real credentialed request surfaces the missing wiring loudly. +const unconfiguredCredentialResolver: CredentialMaterialResolver = ( + credentialId, +) => { + throw new Error( + `no credential resolver supplied to the inference harness, but a request needs the secret for credential ${credentialId}`, + ); +}; + /** * Run one fetch lifecycle and yield its events. Ends on the first * `inference.error` or `inference.done`. The outer `runInference` @@ -242,7 +215,15 @@ export type InferenceHarnessOptions = { async function* runSingleAttempt( opts: InferenceHarnessOptions, ): AsyncIterable { - const { turns, source, inferenceOptions, signal, nextSeq, deps } = opts; + const { + turns, + source, + inferenceOptions, + signal, + nextSeq, + readMaterial, + deps, + } = opts; // Per-call options override source-bound defaults. The merge happens // here, once, so the adapter and timeout-resolution paths below all // see the effective option set without having to remember the @@ -316,8 +297,6 @@ async function* runSingleAttempt( // capture). Appended to the finalized turn after indexed blocks. const unindexedSafetyRatings: SafetyRatingBlock[] = []; let usageSeen: TokenUsage | null = null; - // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call - let stopReason: string | undefined; // Tool call state: keyed by callId (or index for OpenAI). type ToolCallState = { @@ -333,7 +312,7 @@ async function* runSingleAttempt( yield { type: "inference.error", seq: nextSeq(), - data: { error: classifyAbortError(signal?.reason), partial: snapshotPartial(partial) }, + data: { error: classifyAbortError(), partial: snapshotPartial(partial) }, }; return; } @@ -376,7 +355,11 @@ async function* runSingleAttempt( // Resolve the full URL and inject credentials. const url = resolveURL(builtRequest.url, source.baseURL); - const headers = injectCredentials(builtRequest.headers, source); + const headers = injectCredentials( + builtRequest.headers, + source, + readMaterial ?? unconfiguredCredentialResolver, + ); // Per-call timeouts. The inactivity timer fires when the harness // hasn't yielded an event for `inactivityTimeoutMs`; the total timer @@ -451,7 +434,7 @@ async function* runSingleAttempt( type: "inference.error", seq: nextSeq(), data: { - error: classifyAbortError(signal?.reason), + error: classifyAbortError(), partial: snapshotPartial(partial), }, }; @@ -569,28 +552,9 @@ async function* runSingleAttempt( return; } for await (const sseData of parseSSE(responseBody)) { - const rawEvents = adapter.parseResponse(sseData); - // Reset the inactivity watchdog only on semantic progress — events the - // adapter actually parsed out of this chunk (content, thinking, tool - // calls, usage). Provider keep-alives and lifecycle envelopes parse to - // zero events; letting raw bytes re-arm the timer means a stream that - // trickles keep-alives forever without a terminal event never trips the - // watchdog and pins the caller indefinitely. - // - // Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts-inactivity-on-semantic-progress - if (rawEvents.length > 0) { - armInactivity(); - } - yield rawEvents; - // Protocols whose end-of-turn is a semantic event (OpenAI Responses) - // rather than `[DONE]` or a socket close would otherwise block on the - // next read forever. Stop once the terminal event's own events (e.g. - // its usage) have been processed above. - // - // Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts-is-stream-terminal - if (adapter.isStreamTerminal?.(sseData)) { - return; - } + // Reset inactivity timer — we just got something from the wire. + armInactivity(); + yield adapter.parseResponse(sseData); } }; @@ -619,7 +583,7 @@ async function* runSingleAttempt( type: "inference.error", seq: nextSeq(), data: { - error: classifyAbortError(signal?.reason), + error: classifyAbortError(), partial: snapshotPartial(partial), }, }; @@ -1060,18 +1024,10 @@ async function* runSingleAttempt( // synthesizes its own descriptor cannot drift from the // call-start identity the rest of the harness commits to. usageSeen = mergeUsage(usageSeen, raw.data.usage); - // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call - if (raw.data.stopReason !== undefined) { - stopReason = raw.data.stopReason; - } yield { type: "inference.usage", seq: nextSeq(), - data: { - usage: usageSeen, - ...(stopReason === undefined ? {} : { stopReason }), - source: lastCycleSource, - }, + data: { usage: usageSeen, source: lastCycleSource }, }; break; } @@ -1102,7 +1058,7 @@ async function* runSingleAttempt( type: "inference.error", seq: nextSeq(), data: { - error: classifyAbortError(signal?.reason), + error: classifyAbortError(), partial: snapshotPartial(partial), }, }; @@ -1120,70 +1076,18 @@ async function* runSingleAttempt( } // Finalize any open tool calls that never received an explicit end event. - // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call: - // validate every open call before emitting any of them, and never - // dispatch a call whose arguments are incomplete or unparseable. A turn - // cut at max_tokens with calls still open is unambiguous truncation; - // anything else unparseable is still not a normal call. Both yield an - // inference.error (category retryable) naming the call; post-commit the - // harness surfaces it terminally rather than mechanically retrying, so - // the message guides the model's next attempt. - const finalizedToolCalls: { - tc: ToolCallState; - parsedArgs: Record; - }[] = []; + const completedToolCalls: ContentBlock[] = []; for (const tc of openToolCalls.values()) { - if (stopReason === "max_tokens") { - yield { - type: "inference.error", - seq: nextSeq(), - data: { - error: { - category: "retryable", - message: - `Tool call '${tc.name}' (${tc.callId}) was not executed: the provider ended the turn ` + - `at max_tokens while its arguments were still streaming (truncated input). ` + - `Retry the turn with a larger max_tokens budget or a smaller request so the full tool call fits.`, - }, - partial: snapshotPartial(partial), - }, - }; - return; - } - let parsed: unknown; + let parsedArgs: Record; try { const raw = tc.argsBuffer.trim() === "" ? "{}" : tc.argsBuffer; - parsed = JSON.parse(raw); + const parsed = JSON.parse(raw); + const validated = ParsedToolArgs(parsed); + parsedArgs = validated instanceof type.errors ? {} : validated; } catch { - const tail = - tc.argsBuffer.length > 200 - ? `…${tc.argsBuffer.slice(-200)}` - : tc.argsBuffer; - yield { - type: "inference.error", - seq: nextSeq(), - data: { - error: { - category: "retryable", - message: - `Tool call '${tc.name}' (${tc.callId}) was not executed: its streamed arguments are not ` + - `valid JSON and cannot be dispatched as a normal call. Re-issue the turn; ` + - `partial argument text ends with: ${JSON.stringify(tail)}.`, - }, - partial: snapshotPartial(partial), - }, - }; - return; + parsedArgs = { _raw: tc.argsBuffer }; } - const validated = ParsedToolArgs(parsed); - finalizedToolCalls.push({ - tc, - parsedArgs: validated instanceof type.errors ? {} : validated, - }); - } - const completedToolCalls: ContentBlock[] = []; - for (const { tc, parsedArgs } of finalizedToolCalls) { completedToolCalls.push({ type: "tool_call", id: tc.callId, @@ -1431,43 +1335,29 @@ async function* runSingleAttempt( * `runSingleAttempt` and consults the configured `RetryPolicy` (or the * default from `createDefaultRetryPolicy`) on every `inference.error`. * - * Commitment boundary. An attempt is "uncommitted" until it yields its - * first content-bearing event — the first `inference.text.delta`, - * `inference.thinking.delta`, tool-call event, or any other block - * event (see `isCommitting`). Up to that point the only events an - * attempt produces are `inference.start` and any message-start - * `inference.usage`; those are held in a small pre-commit buffer. The - * moment the first committing event arrives the wrapper flushes that - * buffer and, from then on, streams every event straight to the caller - * as it arrives — token-by-token, no terminal burst. - * - * Retry is only possible while an attempt is uncommitted: nothing - * visible has reached the caller yet, so discarding a failed - * uncommitted attempt leaks no events. A retryable failure that lands - * *after* commitment cannot un-emit the deltas already delivered, so - * retry is suppressed and the `inference.error` is surfaced on the one - * live stream — the caller sees a coherent prefix followed by the - * error rather than a silently restarted response. This is the - * deliberate cost of incremental delivery. + * Events from each attempt are buffered until the attempt terminates; + * the wrapper only flushes them to the caller once it knows whether + * the attempt resolved (`inference.done` or a policy-approved abort) + * or whether the attempt's events should be discarded in favour of a + * retry. The buffer-and-flush model is what guarantees the caller + * sees a single clean event stream — exactly one `inference.start`, + * no orphaned partial deltas, no leaked `inference.error`s from + * attempts the policy chose to retry. The cost is that no events + * reach the caller until the wrapper knows the attempt's terminal + * shape, even on a successful first attempt. That trade-off is the + * deliberate consequence of making "one clean stream" a hard contract + * rather than a best-effort one. Consumers that need token-by-token + * partials must pin a custom non-buffering wrapper — no streaming- + * partials emission API exists today. * - * The pre-commit buffer holds at most the handful of metadata events - * an attempt emits before its first token, so it does not grow with - * output length: a long response streams through without the wrapper - * ever retaining a per-token snapshot, keeping memory linear in output - * size rather than quadratic. - * - * The single-clean-stream contract still holds: exactly one - * `inference.start`, no orphaned partial deltas, and no leaked - * `inference.error` from an attempt the policy chose to retry (only - * uncommitted attempts are ever retried). - * - * Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts-commitment-boundary-streaming + * The buffer is per-call and bounded by the size of one attempt's + * event stream — no cross-call accumulation. * * Caller-visible seqs stay contiguous across retries. Each attempt - * runs against a private seq allocator; the wrapper re-stamps every - * event with a seq from the caller's `nextSeq` as it is emitted, so a - * retry that discards an attempt does not leave a gap in the - * consumer's seq stream. + * runs against a private seq allocator; on flush the wrapper + * re-stamps the buffered events with seqs from the caller's + * `nextSeq`, so a retry that discards an attempt does not leave a + * gap in the consumer's seq stream. * * Between attempts the wrapper emits one `inference.retry` event with * the failed attempt's number, the policy-chosen `delayMs`, and the @@ -1490,12 +1380,11 @@ async function* runSingleAttempt( * * Synchronous throws from `runSingleAttempt` (`ProtocolMismatchError` * raised by the streaming parse or the finalization walk, etc.) - * propagate out of `runInference`. Any events already streamed for the - * committed prefix stay delivered; any still-buffered pre-commit - * events are dropped along with the throw. These represent protocol - * bugs the policy mechanism is not equipped to absorb, so the caller's - * `for await` rejects and the failure surfaces rather than being - * silently swallowed. + * propagate out of `runInference`. The current attempt's buffered + * events are discarded along with the throw — those represent + * protocol bugs the policy mechanism is not equipped to absorb, and + * the caller's `for await` rejects so the failure surfaces rather + * than being silently buffered. */ export async function* runInference( opts: InferenceHarnessOptions, @@ -1541,15 +1430,8 @@ export async function* runInference( const signal = opts.signal; for (let attempt = 1; ; attempt++) { - // Metadata an attempt emits before it commits (see `isCommitting`): - // `inference.start` and any message-start `inference.usage`. This - // buffer never accumulates per-token deltas — committed content - // streams straight to the caller — so it stays bounded regardless - // of output length, and a discarded pre-commit retry has nothing - // visible to retract. - const preCommit: InferenceEvent[] = []; - let committed = false; - let failure: { event: InferenceEvent; error: InferenceError } | undefined; + const buffered: InferenceEvent[] = []; + let terminalError: InferenceError | undefined; // Per-attempt private allocator. `runSingleAttempt` allocates a // seq for every event it yields; if the attempt is discarded on @@ -1557,66 +1439,31 @@ export async function* runInference( // gap in the consumer's stream — indistinguishable from the // "missed events during brief disconnection" the seq stream is // documented to expose. Allocate from a private counter here and - // re-stamp each event with a caller-visible seq as it is emitted. + // re-stamp the buffer with caller-visible seqs at flush time. let attemptSeq = 0; const attemptOpts: InferenceHarnessOptions = { ...opts, nextSeq: () => attemptSeq++, }; for await (const event of runSingleAttempt(attemptOpts)) { - if (event.type === "inference.done") { - // A `done` on an uncommitted attempt (e.g. an empty response) - // still needs its buffered metadata flushed ahead of it. - if (!committed) { - for (const buffered of preCommit) { - yield { ...buffered, seq: opts.nextSeq() }; - } - } - yield { ...event, seq: opts.nextSeq() }; - return; - } - + buffered.push(event); if (event.type === "inference.error") { - if (committed) { - // Failure after visible output began. The deltas already - // delivered cannot be retracted, so retry is off the table: - // surface the error on the single live stream and stop. - yield { ...event, seq: opts.nextSeq() }; - return; - } - failure = { event, error: event.data.error }; + terminalError = event.data.error; break; } - - if (!committed && isCommitting(event)) { - committed = true; - for (const buffered of preCommit) { - yield { ...buffered, seq: opts.nextSeq() }; - } - preCommit.length = 0; - } - - if (committed) { - yield { ...event, seq: opts.nextSeq() }; - } else { - preCommit.push(event); + if (event.type === "inference.done") { + break; } } - // Only an uncommitted terminal error reaches here; the committed - // error path and every `done` path returned inside the loop. - if (failure === undefined) { - // `runSingleAttempt` always ends in error or done; an attempt - // that yields neither is an upstream contract violation. Flush - // whatever metadata buffered so nothing is silently swallowed. - for (const buffered of preCommit) { - yield { ...buffered, seq: opts.nextSeq() }; - } + if (terminalError === undefined) { + // Successful attempt. Re-stamp the buffer with caller-visible + // seqs (the private allocator's values are discarded) and + // flush in order. + for (const event of buffered) yield { ...event, seq: opts.nextSeq() }; return; } - const terminalError = failure.error; - // Consult the policy. Sync throws and Promise rejections both // resolve to an abort decision; the original inference.error // surfaces to the caller, not the policy's exception. The @@ -1638,13 +1485,10 @@ export async function* runInference( } if (decision.kind === "abort") { - // Flush the buffered pre-commit metadata, then the terminal - // `inference.error`, all with re-stamped caller-visible seqs, and - // return. No `inference.retry` event is emitted on the abort path. - for (const buffered of preCommit) { - yield { ...buffered, seq: opts.nextSeq() }; - } - yield { ...failure.event, seq: opts.nextSeq() }; + // Flush the buffer (including the terminal inference.error) + // with re-stamped caller-visible seqs and return. No + // `inference.retry` event is emitted on the abort path. + for (const event of buffered) yield { ...event, seq: opts.nextSeq() }; return; } @@ -1699,31 +1543,6 @@ export async function* runInference( } } -/** - * An attempt "commits" the moment it emits its first content-bearing - * event — anything the model actually produced (text, thinking, tool - * calls, images, code execution, citations, refusals). Once such an - * event has been streamed to the caller it cannot be un-emitted, so - * `runInference` may no longer retry that attempt. - * - * `inference.start` and `inference.usage` are metadata, not model - * output: they carry nothing the caller would notice as a restarted - * response, so they are buffered rather than committing. `inference.done`, - * `inference.error`, and `inference.retry` are terminal or wrapper-owned - * and are handled by `runInference` before this predicate is consulted. - * - * Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts-is-committing - */ -function isCommitting(event: InferenceEvent): boolean { - switch (event.type) { - case "inference.start": - case "inference.usage": - return false; - default: - return true; - } -} - /** * Combine an optional caller-supplied `AbortSignal` with the harness's * internal timeout-driven controller into a single signal the fetch diff --git a/vendor/intx-inference/src/index.ts b/vendor/intx-inference/src/index.ts index 2fba587bd..bd19dfd23 100644 --- a/vendor/intx-inference/src/index.ts +++ b/vendor/intx-inference/src/index.ts @@ -10,7 +10,6 @@ export { export type { Dependencies, InferenceHarnessOptions, - PollBatchLivenessPredicate, Scheduler, } from "./harness"; export type { @@ -48,12 +47,7 @@ export type { export { createInboundTurn, assertWellFormedToolSequence } from "./turns"; export { createReactor } from "./reactor"; -export type { - ExtendedInferenceOptions, - Reactor, - ReactorConfig, - ReactorEmittedEvent, -} from "./reactor"; +export type { Reactor, ReactorConfig, ReactorEmittedEvent } from "./reactor"; export { validateActions } from "./actions"; export type { ValidationResult } from "./actions"; export { createGateManager } from "./gates"; diff --git a/vendor/intx-inference/src/providers/anthropic.test.ts b/vendor/intx-inference/src/providers/anthropic.test.ts index 99886c11a..b96bcce39 100644 --- a/vendor/intx-inference/src/providers/anthropic.test.ts +++ b/vendor/intx-inference/src/providers/anthropic.test.ts @@ -817,47 +817,6 @@ describe("Anthropic adapter — responseFormat boundary", () => { }); }); -describe("Anthropic adapter — adaptive thinking request shape", () => { - const ThinkingBody = type({ - thinking: { - type: "string", - "budget_tokens?": "number", - }, - "output_config?": { effort: "string" }, - }); - - function parseThinkingBody(body: string) { - const parsed = ThinkingBody(JSON.parse(body)); - if (parsed instanceof type.errors) { - throw new Error(`unexpected request body shape: ${parsed.summary}`); - } - return parsed; - } - - test("claude-fable-5-1 with thinking.enabled uses type adaptive and output_config.effort", () => { - const req = createAnthropicAdapter(TEST_SOURCE).buildRequest( - [], - "claude-fable-5-1", - { thinking: { enabled: true } }, - ); - const body = parseThinkingBody(req.body); - expect(body.thinking).toEqual({ type: "adaptive" }); - expect(body.output_config?.effort).toBeDefined(); - }); - - test("a non-adaptive model with thinking.enabled uses type enabled and budget_tokens", () => { - const req = createAnthropicAdapter(TEST_SOURCE).buildRequest( - [], - "claude-haiku-4-5", - { thinking: { enabled: true } }, - ); - const body = parseThinkingBody(req.body); - expect(body.thinking.type).toBe("enabled"); - expect(body.thinking.budget_tokens).toBeDefined(); - expect(body.output_config).toBeUndefined(); - }); -}); - describe("Anthropic adapter — tool-name codec round-trip", () => { const PREFIXED = "@intx/tools-posix/sidecar-bundle:run_shell"; const ToolsBody = type({ tools: type({ name: "string" }).array() }); @@ -909,7 +868,7 @@ const JSON_SOURCE: InferenceSource = { id: "anthropic:claude-test", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "test", + credentialId: "test", model: "claude-test", }; @@ -948,6 +907,7 @@ async function driveTurn( source: JSON_SOURCE, nextSeq: () => ++seq, deps, + readMaterial: () => ({ secret: "test-secret" }), })) { events.push(ev); } @@ -1260,120 +1220,3 @@ describe("createAnthropicAdapter — streaming vs non-streaming parity", () => { expect(jdone?.data.usage).toEqual(sdone?.data.usage); }); }); - -describe("CL-7783 truncated tool_use", () => { - // The exact incident wire sequence: a tool_use block opens, one partial - // input_json_delta arrives, then message_delta reports stop_reason - // max_tokens and the stream stops — no content_block_stop ever closes - // the tool block, so its arguments are unparseable by construction. - const TRUNCATED_STREAM = sse([ - { - type: "content_block_start", - index: 0, - content_block: { type: "tool_use", id: "toolu_trunc", name: "Bash" }, - }, - { - type: "content_block_delta", - index: 0, - delta: { - type: "input_json_delta", - partial_json: '{"command":"rm -rf /tm', - }, - }, - { - type: "message_delta", - delta: { stop_reason: "max_tokens" }, - usage: { output_tokens: 12 }, - }, - { type: "message_stop" }, - ]); - - function errorEvents(events: InferenceEvent[]) { - return events.filter( - (e): e is Extract => - e.type === "inference.error", - ); - } - - function usageEvents(events: InferenceEvent[]) { - return events.filter( - (e): e is Extract => - e.type === "inference.usage", - ); - } - - test("message_delta stop_reason surfaces on the usage event", async () => { - const { events } = await driveTurn(TRUNCATED_STREAM, "text/event-stream"); - const usage = usageEvents(events); - expect(usage.length).toBeGreaterThan(0); - expect(usage[usage.length - 1]?.data.stopReason).toBe("max_tokens"); - }); - - test("truncated call fails the turn retryably; no tool_call is dispatched", async () => { - const { turn, events } = await driveTurn( - TRUNCATED_STREAM, - "text/event-stream", - ); - expect(turn).toBeUndefined(); - expect(events.some((e) => e.type === "inference.done")).toBe(false); - expect( - events.some((e) => e.type === "inference.tool_call.end"), - ).toBe(false); - const errors = errorEvents(events); - expect(errors).toHaveLength(1); - expect(errors[0]?.data.error.category).toBe("retryable"); - expect(errors[0]?.data.error.message).toContain("max_tokens"); - expect(errors[0]?.data.error.message).toContain("Bash"); - expect(errors[0]?.data.error.message).toContain("not executed"); - }); - - test("unparseable args with a non-truncation stop reason still never dispatch", async () => { - const body = sse([ - { - type: "content_block_start", - index: 0, - content_block: { type: "tool_use", id: "toolu_bad", name: "Bash" }, - }, - { - type: "content_block_delta", - index: 0, - delta: { - type: "input_json_delta", - partial_json: '{"command":', - }, - }, - { - type: "message_delta", - delta: { stop_reason: "end_turn" }, - usage: { output_tokens: 12 }, - }, - { type: "message_stop" }, - ]); - const { turn, events } = await driveTurn(body, "text/event-stream"); - expect(turn).toBeUndefined(); - expect(events.some((e) => e.type === "inference.done")).toBe(false); - expect( - events.some((e) => e.type === "inference.tool_call.end"), - ).toBe(false); - const errors = errorEvents(events); - expect(errors).toHaveLength(1); - expect(errors[0]?.data.error.category).toBe("retryable"); - expect(errors[0]?.data.error.message).toContain("not valid JSON"); - }); - - test("non-streaming message surfaces top-level stop_reason on usage", async () => { - const body = JSON.stringify({ - type: "message", - role: "assistant", - model: "claude-test", - content: [{ type: "text", text: "Done." }], - stop_reason: "end_turn", - usage: { input_tokens: 5, output_tokens: 3 }, - }); - const { events } = await driveTurn(body, "application/json"); - expect(events.some((e) => e.type === "inference.error")).toBe(false); - const usage = usageEvents(events); - expect(usage).toHaveLength(1); - expect(usage[0]?.data.stopReason).toBe("end_turn"); - }); -}); diff --git a/vendor/intx-inference/src/providers/anthropic.ts b/vendor/intx-inference/src/providers/anthropic.ts index fe259ec11..fe9fa2ab7 100644 --- a/vendor/intx-inference/src/providers/anthropic.ts +++ b/vendor/intx-inference/src/providers/anthropic.ts @@ -39,8 +39,6 @@ export const ADAPTIVE_THINKING_MODELS: ReadonlySet = new Set([ "claude-sonnet-5", "claude-opus-5", "claude-fable-5", - // Locally patched — see vendor/intx-inference/PATCHES.md#providers-ts-anthropic-adaptive-fable-5-1 - "claude-fable-5-1", "claude-opus-4-8", "claude-opus-4-6", "claude-opus-4-7", @@ -554,8 +552,6 @@ const ContentBlockStop = type({ const MessageDelta = type({ type: "'message_delta'", - // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call - "delta?": { "stop_reason?": "string" }, "usage?": { "output_tokens?": "number" }, }); @@ -574,12 +570,15 @@ const MessageStart = type({ const MessageStop = type({ type: "'message_stop'" }); const Ping = type({ type: "'ping'" }); -const AnthropicSSEEvent = ContentBlockDelta.or(ContentBlockStart) - .or(ContentBlockStop) - .or(MessageDelta) - .or(MessageStart) - .or(MessageStop) - .or(Ping); +const AnthropicSSEEvent = type.or( + ContentBlockDelta, + ContentBlockStart, + ContentBlockStop, + MessageDelta, + MessageStart, + MessageStop, + Ping, +); // Maps Anthropic's wire usage object onto the internal TokenUsage. Anthropic // never reports a distinct thinking-token count, so `thinking` is always 0. @@ -818,17 +817,11 @@ function parseResponse( cacheWrite: 0, thinking: 0, }; - // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call - const stopReason = event.delta?.stop_reason; return [ { type: "inference.usage", seq, - data: { - usage: inferenceUsage, - ...(stopReason === undefined ? {} : { stopReason }), - source, - }, + data: { usage: inferenceUsage, source }, }, ]; } @@ -877,8 +870,6 @@ const NonStreamingUsage = type({ const NonStreamingMessage = type({ type: "'message'", content: "unknown[]", - // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call - "stop_reason?": "string", usage: NonStreamingUsage, }); @@ -1074,13 +1065,7 @@ function parseJSONResponse( events.push({ type: "inference.usage", seq, - data: { - usage: toInferenceUsage(message.usage), - ...(message.stop_reason === undefined - ? {} - : { stopReason: message.stop_reason }), - source, - }, + data: { usage: toInferenceUsage(message.usage), source }, }); return events; diff --git a/vendor/intx-inference/src/providers/google-genai-files.ts b/vendor/intx-inference/src/providers/google-genai-files.ts index d1fe2e3e5..850e25460 100644 --- a/vendor/intx-inference/src/providers/google-genai-files.ts +++ b/vendor/intx-inference/src/providers/google-genai-files.ts @@ -169,9 +169,7 @@ export async function uploadGoogleGenAIFile( const init: RequestInit = { method: "POST", headers, - // DOM lib BodyInit is narrower than Node's Uint8Array typing; fetch accepts bytes. - // Locally patched — see vendor/intx-inference/PATCHES.md#google-genai-files-ts-body-init-cast - body: opts.bytes as unknown as BodyInit, + body: opts.bytes, }; // `RequestInit.signal` is typed as `AbortSignal | null` under // `exactOptionalPropertyTypes`; only attach the property when diff --git a/vendor/intx-inference/src/providers/google-genai.test.ts b/vendor/intx-inference/src/providers/google-genai.test.ts deleted file mode 100644 index 6bb76a54a..000000000 --- a/vendor/intx-inference/src/providers/google-genai.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { LastCycleSource } from "@intx/types/runtime"; -import { createGoogleGenAIAdapter } from "./google-genai"; - -const TEST_SOURCE: LastCycleSource = { - sourceId: "test-google-genai", - provider: "google-genai", - model: "test-gemini-model", -}; - -describe("google-genai adapter — finishReason forwarding (CL-7783)", () => { - test("terminal finishReason surfaces on the usage event", () => { - const adapter = createGoogleGenAIAdapter(TEST_SOURCE); - const events = adapter.parseResponse( - JSON.stringify({ - candidates: [ - { - content: { parts: [{ text: "partial" }], role: "model" }, - finishReason: "MAX_TOKENS", - index: 0, - }, - ], - usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 3 }, - }), - ); - const usage = events.filter((e) => e.type === "inference.usage"); - expect(usage).toHaveLength(1); - expect(usage[0]?.data.stopReason).toBe("MAX_TOKENS"); - }); - - test("non-terminal event without finishReason emits no usage", () => { - const adapter = createGoogleGenAIAdapter(TEST_SOURCE); - const events = adapter.parseResponse( - JSON.stringify({ - candidates: [ - { - content: { parts: [{ text: "partial" }], role: "model" }, - index: 0, - }, - ], - usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 3 }, - }), - ); - expect(events.some((e) => e.type === "inference.usage")).toBe(false); - }); -}); diff --git a/vendor/intx-inference/src/providers/google-genai.ts b/vendor/intx-inference/src/providers/google-genai.ts index af61f329f..3e4069a16 100644 --- a/vendor/intx-inference/src/providers/google-genai.ts +++ b/vendor/intx-inference/src/providers/google-genai.ts @@ -1461,14 +1461,7 @@ function parseResponse( out.push({ type: "inference.usage", seq, - // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call - data: { - usage: tokenUsage, - ...(candidate.finishReason === undefined - ? {} - : { stopReason: candidate.finishReason }), - source, - }, + data: { usage: tokenUsage, source }, }); // Terminal events seal the response. A still-pending diff --git a/vendor/intx-inference/src/reactor.test.ts b/vendor/intx-inference/src/reactor.test.ts index 420017318..6fdc56a3a 100644 --- a/vendor/intx-inference/src/reactor.test.ts +++ b/vendor/intx-inference/src/reactor.test.ts @@ -34,11 +34,7 @@ import type { } from "@intx/types/runtime"; import type { ReactorConfig, Reactor, ReactorEmittedEvent } from "./reactor"; -import type { - Dependencies, - InferenceHarnessOptions, - PollBatchLivenessPredicate, -} from "./harness"; +import type { Dependencies, InferenceHarnessOptions } from "./harness"; import type { CorrelationValidator } from "./correlation"; import type { AfterInferenceHook } from "./default-director"; @@ -289,7 +285,7 @@ function createTestReactor( id: "anthropic:test-model", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "test", + credentialId: "test", model: "test-model", }, toolRunner: overrides.toolRunner ?? noopToolRunner(), @@ -1008,7 +1004,7 @@ describe("createReactor — director exception", () => { id: "anthropic:test-model", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "test", + credentialId: "test", model: "test-model", }, toolRunner: noopToolRunner(), @@ -1793,336 +1789,11 @@ describe("createReactor — doom-loop detection", () => { "doom_loop", ); }); - - test("appends the corrective note once, on the batch before the trip", async () => { - const contents: string[] = []; - const noteCalls: number[] = []; - const { reactor, events, waitFor } = createTestReactor({ - deps: { - ...createDefaultDependencies(), - doomLoopCorrectiveNote: ({ calls, repeatCount }) => { - noteCalls.push(repeatCount); - return `loop note: ${calls.map((c) => c.name).join(",")}`; - }, - }, - toolRunner: makeToolRunner(async (call) => ({ - callId: call.id, - content: "spun", - })), - director: (() => { - let turn = 0; - const batch = () => [ - { id: `c${turn}`, name: "spin", arguments: { q: 1 } }, - ]; - return directorFromTable( - { - "message.received": (_e, _s, caps) => caps.executeTools(batch()), - "tool.done": (e, _s, caps) => { - contents.push( - typeof e.result.content === "string" - ? e.result.content - : JSON.stringify(e.result.content), - ); - turn += 1; - return turn < 8 ? caps.executeTools(batch()) : caps.done(); - }, - }, - "wait", - ); - })(), - }); - - reactor.start(); - reactor.deliver(makeInboundMessage()); - await waitFor("reactor.done"); - - // One warning turn at repeat count 2 (threshold 3 − 1): only that - // batch's result carried the note; the first ran clean and the third - // tripped the guard before its result could be consumed. - expect(noteCalls).toEqual([2]); - expect(contents).toEqual(["spun", "spun\n\nloop note: spin"]); - expect(getEvent(events, "message.run.ended").data.error?.kind).toBe( - "doom_loop", - ); - }); - - test("a threshold of 2 leaves no room for a warning turn", async () => { - let noteCalls = 0; - const { reactor, events, waitFor } = createTestReactor({ - doomLoopThreshold: 2, - deps: { - ...createDefaultDependencies(), - doomLoopCorrectiveNote: () => { - noteCalls += 1; - return "note"; - }, - }, - director: createBatchLoopDirector((turn) => - turn < 8 - ? [{ id: `c${turn}`, name: "spin", arguments: { q: 1 } }] - : null, - ), - }); - - reactor.start(); - reactor.deliver(makeInboundMessage()); - await waitFor("reactor.done"); - - expect(noteCalls).toBe(0); - expect(getEvent(events, "message.run.ended").data.error?.kind).toBe( - "doom_loop", - ); - }); - - test("fail-run policy ends the run but keeps the reactor for the next message", async () => { - const seen: string[] = []; - const { reactor, events, waitFor } = createTestReactor({ - deps: { - ...createDefaultDependencies(), - doomLoopPolicy: "fail-run", - }, - director: (() => { - let turn = 0; - const batch = () => [ - { id: `c${turn}`, name: "spin", arguments: { q: 1 } }, - ]; - return directorFromTable( - { - "message.received": (_e, _s, caps) => caps.executeTools(batch()), - "tool.done": (e, _s, caps) => { - seen.push(String(e.result.content)); - turn += 1; - return caps.executeTools(batch()); - }, - }, - "wait", - ); - })(), - }); - - reactor.start(); - reactor.deliver(makeInboundMessage()); - await waitFor("message.run.ended"); - - const ended = getEvent(events, "message.run.ended"); - expect(ended.data.status).toBe("failed"); - expect(ended.data.error?.kind).toBe("doom_loop"); - expect(getEvent(events, "reactor.error").data.fatal).toBe(true); - // The doomed batch's queued tool.done events were purged, not consumed: - // the director saw only the two repeats that ran before the trip. - expect(seen.length).toBe(2); - expect(events.some((e) => e.type === "reactor.done")).toBe(false); - - // The next inbound message opens a fresh run — which doom-loops again - // and fails the same way, proving the reactor kept working. - reactor.deliver(makeInboundMessage()); - await waitForEvent( - events, - (e) => - e.type === "message.run.ended" && - events.filter((x) => x.type === "message.run.ended").length >= 2, - ); - expect(events.filter((e) => e.type === "message.run.started").length).toBe( - 2, - ); - expect(events.some((e) => e.type === "reactor.done")).toBe(false); - }); }); -describe("createReactor — doom-loop poll exemption", () => { - // Production-faithful stand-in for the first-party liveness predicate (the - // predicate truth table itself is unit-tested beside the real - // implementation): exempt only when every call is a known poll and every - // result still shows pending. These tests lock the guard's reset-vs-count - // behavior around that verdict. - const pendingPollLiveness: PollBatchLivenessPredicate = (calls, results) => - calls.length > 0 && - calls.every((call, index) => { - const content = results[index]?.content; - if (typeof content !== "string") return false; - let payload: unknown; - try { - payload = JSON.parse(content) as unknown; - } catch { - return false; - } - if (typeof payload !== "object" || payload === null) return false; - if (call.name === "wait_agents") { - const { timed_out: timedOut, results: entries } = payload as { - timed_out?: unknown; - results?: { status?: unknown }[]; - }; - if (timedOut === true) return true; - return ( - Array.isArray(entries) && - entries.some( - (entry) => - entry.status === "running" || - entry.status === "queued" || - entry.status === "awaiting_director", - ) - ); - } - if (call.name === "shell_collect") { - return (payload as { status?: unknown }).status === "running"; - } - return false; - }); - - function depsWithLiveness(): Dependencies { - return { - ...createDefaultDependencies(), - isPollOnlyPendingBatch: pendingPollLiveness, - }; - } - - function pendingWaitResult(callId: string): { - callId: string; - content: string; - } { - return { - callId, - content: JSON.stringify({ - results: [{ agent_id: "w1", status: "running" }], - timed_out: true, - }), - }; - } - - function settledWaitResult(callId: string): { - callId: string; - content: string; - } { - return { - callId, - content: JSON.stringify({ - results: [{ agent_id: "w1", status: "done" }], - timed_out: false, - }), - }; - } - - test("does not trip on repeated still-pending poll batches", async () => { - const { reactor, events, waitFor } = createTestReactor({ - deps: depsWithLiveness(), - director: createBatchLoopDirector((turn) => - turn < 8 - ? [{ id: `c${turn}`, name: "wait_agents", arguments: { q: 1 } }] - : null, - ), - toolRunner: makeToolRunner(async (call) => pendingWaitResult(call.id)), - }); - - reactor.start(); - reactor.deliver(makeInboundMessage()); - await waitFor("reactor.done"); - - expect(events.some((e) => e.type === "reactor.error")).toBe(false); - expect(getEvent(events, "message.run.ended").data.status).toBe("completed"); - expect(events.filter((e) => e.type === "tool.start").length).toBe(8); - }); - - test("still trips on repeated non-poll batches when a policy is set", async () => { - const { reactor, events, waitFor } = createTestReactor({ - deps: depsWithLiveness(), - director: createBatchLoopDirector((turn) => - turn < 8 - ? [{ id: `c${turn}`, name: "spin", arguments: { q: 1 } }] - : null, - ), - }); - - reactor.start(); - reactor.deliver(makeInboundMessage()); - await waitFor("reactor.done"); - - expect(getEvent(events, "reactor.error").data.fatal).toBe(true); - expect(getEvent(events, "message.run.ended").data.error?.kind).toBe( - "doom_loop", - ); - }); - - test("mixed poll and non-poll batches count normally", async () => { - const { reactor, events, waitFor } = createTestReactor({ - deps: depsWithLiveness(), - director: createBatchLoopDirector((turn) => - turn < 8 - ? [ - { - id: `c${turn}-wait`, - name: "wait_agents", - arguments: { q: 1 }, - }, - { id: `c${turn}-spin`, name: "spin", arguments: {} }, - ] - : null, - ), - toolRunner: makeToolRunner(async (call) => - call.name === "wait_agents" - ? pendingWaitResult(call.id) - : { callId: call.id, content: "spun" }, - ), - }); - - reactor.start(); - reactor.deliver(makeInboundMessage()); - await waitFor("reactor.done"); - - expect(getEvent(events, "reactor.error").data.fatal).toBe(true); - expect(getEvent(events, "message.run.ended").data.error?.kind).toBe( - "doom_loop", - ); - }); - - test("a settled poll batch counts normally", async () => { - const { reactor, events, waitFor } = createTestReactor({ - deps: depsWithLiveness(), - director: createBatchLoopDirector((turn) => - turn < 8 - ? [{ id: `c${turn}`, name: "wait_agents", arguments: { q: 1 } }] - : null, - ), - toolRunner: makeToolRunner(async (call) => settledWaitResult(call.id)), - }); - - reactor.start(); - reactor.deliver(makeInboundMessage()); - await waitFor("reactor.done"); - - expect(getEvent(events, "reactor.error").data.fatal).toBe(true); - expect(getEvent(events, "message.run.ended").data.error?.kind).toBe( - "doom_loop", - ); - }); - - test("a pending poll batch clears a stale non-poll streak", async () => { - // spin x2 leaves a count of 2; a skip-only exemption would preserve it - // and the final spin would trip at 3. The reset clears it, so the run - // completes. - const batches: ToolCall[][] = [ - [{ id: "1", name: "spin", arguments: {} }], - [{ id: "2", name: "spin", arguments: {} }], - [{ id: "3", name: "wait_agents", arguments: { q: 1 } }], - [{ id: "4", name: "spin", arguments: {} }], - ]; - const { reactor, events, waitFor } = createTestReactor({ - deps: depsWithLiveness(), - director: createBatchLoopDirector((turn) => batches[turn] ?? null), - toolRunner: makeToolRunner(async (call) => - call.name === "wait_agents" - ? pendingWaitResult(call.id) - : { callId: call.id, content: "spun" }, - ), - }); - - reactor.start(); - reactor.deliver(makeInboundMessage()); - await waitFor("reactor.done"); - - expect(events.some((e) => e.type === "reactor.error")).toBe(false); - expect(getEvent(events, "message.run.ended").data.status).toBe("completed"); - }); -}); +// --------------------------------------------------------------------------- +// 8. Correlation matching +// --------------------------------------------------------------------------- describe("createReactor — correlation", () => { test("message with matching correlationId triggers message.correlated", async () => { @@ -3554,17 +3225,12 @@ describe("createReactor — state snapshot inspection", () => { if (event.type === "message.received") { messageCount++; if (messageCount === 1) { - // Mutate the snapshot's content block. Frozen turns throw; - // isolation still holds if the assignment is ignored. + // Mutate the snapshot's content block. const msg = state.turns[0]; if (msg !== undefined) { const block = msg.content[0]; if (block !== undefined && block.type === "text") { - try { - (block as { text: string }).text = "CORRUPTED"; - } catch { - /* deepFreeze */ - } + (block as { text: string }).text = "CORRUPTED"; } } return caps.wait(); @@ -5604,18 +5270,13 @@ function truncatingCompactor(name: string): Compactor { }; } -function makeRecordingContextStore(opts?: { - failCommit?: boolean; - failCommitRemaining?: { n: number }; - initialTurns?: ConversationTurn[]; -}): { +function makeRecordingContextStore(): { store: ContextStore; commits: { message: string; turns: ConversationTurn[] }[]; manifests: TransformRecord[][]; metadata: { pendingOperations: PendingOperation[]; tokenUsage: TokenUsage }[]; blobs: { key: string; bytes: Uint8Array; contentType?: string }[]; lastWrittenTurns: ConversationTurn[]; - writeTurnsCalls: ConversationTurn[][]; } { const commits: { message: string; turns: ConversationTurn[] }[] = []; const manifests: TransformRecord[][] = []; @@ -5624,13 +5285,12 @@ function makeRecordingContextStore(opts?: { tokenUsage: TokenUsage; }[] = []; const blobs: { key: string; bytes: Uint8Array; contentType?: string }[] = []; - const writeTurnsCalls: ConversationTurn[][] = []; let lastWrittenTurns: ConversationTurn[] = []; const store: ContextStore = { async load() { return { - turns: opts?.initialTurns !== undefined ? [...opts.initialTurns] : [], + turns: [], pendingOperations: [], tokenUsage: emptyUsage(), connectorState: null, @@ -5640,13 +5300,6 @@ function makeRecordingContextStore(opts?: { /* noop */ }, async commit(options) { - if (opts?.failCommit === true) { - throw new Error("commit failed"); - } - if (opts?.failCommitRemaining !== undefined && opts.failCommitRemaining.n > 0) { - opts.failCommitRemaining.n -= 1; - throw new Error("commit failed"); - } commits.push({ message: options.message, turns: [...lastWrittenTurns], @@ -5686,7 +5339,6 @@ function makeRecordingContextStore(opts?: { manifests.push([...records]); }, async writeTurns(turns) { - writeTurnsCalls.push([...turns]); lastWrittenTurns = [...turns]; }, async writeMetadata(m) { @@ -5706,7 +5358,6 @@ function makeRecordingContextStore(opts?: { manifests, metadata, blobs, - writeTurnsCalls, get lastWrittenTurns() { return lastWrittenTurns; }, @@ -5812,7 +5463,7 @@ function createDirectReactor(opts: { id: "anthropic:test-model", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "test", + credentialId: "test", model: "test-model", }, toolRunner: opts.toolRunner ?? noopToolRunner(), @@ -5970,102 +5621,6 @@ describe("createReactor — transform chain ordering and compact action", () => expect(flatRecords.some((r) => r.strategy === "tail-only")).toBe(true); }); - test("compact stages writeTurns and replaces memory only after commit", async () => { - const seed: ConversationTurn[] = [ - { role: "user", content: [{ type: "text", text: "a" }], timestamp: 1 }, - { role: "user", content: [{ type: "text", text: "b" }], timestamp: 2 }, - { role: "user", content: [{ type: "text", text: "c" }], timestamp: 3 }, - ]; - const recording = makeRecordingContextStore({ - failCommit: true, - initialTurns: seed, - }); - const seenLengths: number[] = []; - const director: ReactorDirector = { - async decide(event, state, caps) { - if (event.type === "message.received") { - seenLengths.push(state.turns.length); - if (seenLengths.length === 1) { - return caps.compact("tail-only", "explicit-test"); - } - return caps.done(); - } - return caps.done(); - }, - }; - const { reactor, waitFor } = createDirectReactor({ - contextStore: recording.store, - director, - compactors: { "tail-only": truncatingCompactor("tail-only") }, - }); - reactor.start(); - reactor.deliver(makeInboundMessage()); - setTimeout(() => reactor.deliver(makeInboundMessage()), 30); - await waitFor("reactor.done"); - - expect(recording.writeTurnsCalls.some((turns) => turns.length === 1)).toBe(true); - expect(recording.commits).toHaveLength(0); - expect(seenLengths[1]).toBeGreaterThan(1); - }); - - test("failed compact commit does not replaceTurns stale output on a later infer cycle", async () => { - const seed: ConversationTurn[] = [ - { role: "user", content: [{ type: "text", text: "a" }], timestamp: 1 }, - { role: "user", content: [{ type: "text", text: "b" }], timestamp: 2 }, - { role: "user", content: [{ type: "text", text: "c" }], timestamp: 3 }, - ]; - const recording = makeRecordingContextStore({ - failCommitRemaining: { n: 1 }, - initialTurns: seed, - }); - let inspectLength = 0; - let messages = 0; - const director: ReactorDirector = { - async decide(event, state, caps) { - if (event.type === "message.received") { - messages++; - if (messages === 1) { - return caps.compact("tail-only", "explicit-test"); - } - if (messages === 2) { - return caps.infer(); - } - inspectLength = state.turns.length; - return caps.done(); - } - if (event.type === "inference.done") { - return caps.wait(); - } - return caps.done(); - }, - }; - const { reactor, waitFor } = createDirectReactor({ - contextStore: recording.store, - director, - compactors: { "tail-only": truncatingCompactor("tail-only") }, - inferenceRunner: mockInferenceRunner("live-after-failed-compact"), - }); - reactor.start(); - reactor.deliver(makeInboundMessage()); - setTimeout(() => reactor.deliver(makeInboundMessage()), 30); - setTimeout(() => reactor.deliver(makeInboundMessage()), 80); - await waitFor("reactor.done"); - - const liveWrites = recording.writeTurnsCalls.filter((turns) => - turns.some( - (turn) => - turn.role === "assistant" && - turn.content.some((b) => b.type === "text" && b.text === "live-after-failed-compact"), - ), - ); - expect(liveWrites.length).toBeGreaterThan(0); - expect(liveWrites.some((turns) => turns.length === 1)).toBe(false); - expect(inspectLength).toBeGreaterThan(1); - const inferCommit = recording.commits.find((c) => c.message.startsWith("Cycle: inferred")); - expect(inferCommit).toBeDefined(); - expect(inferCommit?.turns.length).toBeGreaterThan(1); - }); - test("compact for an unknown name emits a fatal error and shuts down", async () => { const recording = makeRecordingContextStore(); const { reactor, events, waitFor } = createDirectReactor({ @@ -6518,7 +6073,7 @@ describe("createReactor — source failover", () => { id, provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: `key-${id}`, + credentialId: `key-${id}`, model: "test-model", })); const head = sources[0]; diff --git a/vendor/intx-inference/src/reactor.ts b/vendor/intx-inference/src/reactor.ts index d77954aa7..c1a6a442c 100644 --- a/vendor/intx-inference/src/reactor.ts +++ b/vendor/intx-inference/src/reactor.ts @@ -42,14 +42,11 @@ import type { import { getLogger } from "@intx/log"; import { ApprovalDecision, signalKindToGateType } from "@intx/types"; +import type { CredentialMaterialResolver } from "@intx/types"; import { canonicalJsonStringify } from "@intx/types/wire-definition-hash"; import { type } from "arktype"; import { runInference } from "./harness"; -import type { - Dependencies, - InferenceHarnessOptions, - PollBatchLivenessPredicate, -} from "./harness"; +import type { Dependencies, InferenceHarnessOptions } from "./harness"; import { createCapabilities } from "./director"; import { createGateManager } from "./gates"; import { createCorrelationRegistry } from "./correlation"; @@ -76,37 +73,25 @@ function assertNever(x: never): never { throw new Error(`Unhandled resume case: ${JSON.stringify(x)}`); } -/** - * `InferenceOptions` plus vendored-only fields the published `@intx/types` - * does not carry. `ephemeralTurns` are appended to the materialized prompt - * for one inference only and never written to durable history, so transient - * director guidance leaves the cached transcript prefix untouched. - * - * Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-ephemeral-turns - */ -export type ExtendedInferenceOptions = InferenceOptions & { - ephemeralTurns?: ConversationTurn[]; -}; - function buildHarnessOpts( turns: ConversationTurn[], source: InferenceSource, options: InferenceOptions | undefined, signal: AbortSignal, nextSeq: () => number, + readMaterial: CredentialMaterialResolver | undefined, deps: Dependencies, ): InferenceHarnessOptions { - if (options !== undefined) { - return { - turns, - source, - inferenceOptions: options, - signal, - nextSeq, - deps, - }; - } - return { turns, source, signal, nextSeq, deps }; + // exactOptionalPropertyTypes is on: only set the optional keys when defined. + return { + turns, + source, + ...(options !== undefined ? { inferenceOptions: options } : {}), + signal, + nextSeq, + ...(readMaterial !== undefined ? { readMaterial } : {}), + deps, + }; } export type ReactorEmittedEvent = @@ -129,6 +114,13 @@ export type ReactorConfig = { failOverToNextSource?: () => boolean; /** Reset `source` to the most-preferred source, in place. */ resetToPreferredSource?: () => void; + /** + * Resolves the active source's credential secret by `credentialId` from the + * run's credential cell at send time. Read live per attempt, so a failover to + * a source with a different `credentialId` resolves that source's credential. + * Optional: the harness installs a fail-closed default when it is omitted. + */ + readMaterial?: CredentialMaterialResolver; toolRunner: ToolRunner; contextStore: ContextStore; correlationValidator?: CorrelationValidator; @@ -140,14 +132,6 @@ export type ReactorConfig = { beforeToolExtensions?: BeforeToolExtension[]; toolResultTransforms?: ToolResultTransform[]; contextTransforms?: ContextTransform[]; - /** - * Liveness policy for the doom-loop guard's batch accounting. A direct - * value wins over the one riding `deps`; when neither is set every batch - * counts, same as before. - * - * Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-poll-exemption - */ - isPollOnlyPendingBatch?: PollBatchLivenessPredicate; compactors?: Record; afterCheckpoint?: () => Promise; onShutdown?: () => Promise; @@ -254,18 +238,6 @@ export function createReactor(config: ReactorConfig): Reactor { // downstream comparison reads this binding, never the raw config value. const doomLoopThreshold = resolveDoomLoopThreshold(config.doomLoopThreshold); - // Liveness policy for the doom-loop guard's batch accounting, resolved - // direct-wins-over-deps at the construction edge: a value composed straight - // into the reactor config wins over one riding a shared `deps` object, and - // an absent policy counts every batch, same as before. - const isPollOnlyPendingBatch = - config.isPollOnlyPendingBatch ?? deps.isPollOnlyPendingBatch; - - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-warning-turn - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-fail-run - const doomLoopCorrectiveNote = deps.doomLoopCorrectiveNote; - const doomLoopPolicy = deps.doomLoopPolicy ?? "shutdown"; - // Monotonic sequence counter, scoped to this session. let seq = 0; function nextSeq(): number { @@ -431,8 +403,6 @@ export function createReactor(config: ReactorConfig): Reactor { let cycleInferred = false; let cycleToolCallsExecuted = 0; let cycleCompactorName: string | null = null; - // Compacted turns stay off reactor memory until the cycle commit publishes. - let pendingCompactOutput: ConversationTurn[] | null = null; // A suspension registers a gate and may persist a pending operation. That is // a durable state change even when the cycle ran no inference and completed // no tool call, so it must force the cycle commit. @@ -569,106 +539,104 @@ export function createReactor(config: ReactorConfig): Reactor { if (pending === undefined) return false; correlatingIds.add(correlationId); - // A finally clears the in-flight marker on every exit — success included. - // The success path used to leave the id in the set forever, leaking one - // entry per correlated message for the life of the session. - // - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-correlating-ids-leak - try { - if (correlationValidator !== undefined) { - let valid: boolean; - try { - valid = await correlationValidator.validate(pending, message); - } catch (cause) { - logger.warn`Correlation validator threw for ${correlationId}: ${cause}`; - return false; - } - if (!valid) { - return false; - } + + if (correlationValidator !== undefined) { + let valid: boolean; + try { + valid = await correlationValidator.validate(pending, message); + } catch (cause) { + logger.warn`Correlation validator threw for ${correlationId}: ${cause}`; + correlatingIds.delete(correlationId); + return false; } + if (!valid) { + correlatingIds.delete(correlationId); + return false; + } + } - // Capture the operation before removal so the resume dispatch can read - // its kind and suspended call. Removal happens only after the dispatch - // is decided, all inside this correlatingIds-guarded critical section - // so a double-deliver early-returns rather than double-dispatching. - const op = pending; - - const dispatch = resumePendingOperation(op, message); - - const gate = gates.findByCorrelationId(correlationId); - switch (dispatch.mode) { - case "redispatch": { - // Clear the gate WITHOUT enqueuing gate.cleared: the re-dispatched - // call is the resumption, so a gate.cleared-driven re-infer would - // double the continuation. The re-dispatch's own tool.done drives - // the re-infer. - if (gate !== undefined) { - gates.clearSilently(gate.gateId); - if (stateManager !== null) { - stateManager.setGatesSnapshot(gates.snapshot()); - } - } - correlations.remove(correlationId); + // Capture the operation before removal so the resume dispatch can read its + // kind and suspended call. Removal happens only after the dispatch is + // decided, all inside this correlatingIds-guarded critical section so a + // double-deliver early-returns rather than double-dispatching. + const op = pending; + + let dispatch: ResumeDispatch; + try { + dispatch = resumePendingOperation(op, message); + } catch (cause) { + correlatingIds.delete(correlationId); + throw cause; + } + + const gate = gates.findByCorrelationId(correlationId); + switch (dispatch.mode) { + case "redispatch": { + // Clear the gate WITHOUT enqueuing gate.cleared: the re-dispatched call + // is the resumption, so a gate.cleared-driven re-infer would double the + // continuation. The re-dispatch's own tool.done drives the re-infer. + if (gate !== undefined) { + gates.clearSilently(gate.gateId); if (stateManager !== null) { - stateManager.removePendingOperation(correlationId); + stateManager.setGatesSnapshot(gates.snapshot()); } - // The grant is already recorded (synchronously, in - // resumePendingOperation) with no await since; enqueue the - // re-dispatch so it runs on the loop with normal event ordering. - // The director seeds its outstanding-result count off this event - // before the call's tool.done arrives. - enqueue({ type: "resume.execute_tools", calls: dispatch.calls }); - break; } - case "error_result": { - // The approver denied the call. Clear the gate SILENTLY (like the - // approved redispatch) so it cannot also trip onGateCleared and - // enqueue a second continuation. The synthetic error result - // answers the parked call; the director appends it and re-infers - // once. - if (gate !== undefined) { - gates.clearSilently(gate.gateId); - if (stateManager !== null) { - stateManager.setGatesSnapshot(gates.snapshot()); - } - } - correlations.remove(correlationId); + correlations.remove(correlationId); + if (stateManager !== null) { + stateManager.removePendingOperation(correlationId); + } + // The grant is already recorded (synchronously, in + // resumePendingOperation) with no await since; enqueue the re-dispatch + // so it runs on the loop with normal event ordering. The director seeds + // its outstanding-result count off this event before the call's + // tool.done arrives. + enqueue({ type: "resume.execute_tools", calls: dispatch.calls }); + break; + } + case "error_result": { + // The approver denied the call. Clear the gate SILENTLY (like the + // approved redispatch) so it cannot also trip onGateCleared and enqueue + // a second continuation. The synthetic error result answers the parked + // call; the director appends it and re-infers once. + if (gate !== undefined) { + gates.clearSilently(gate.gateId); if (stateManager !== null) { - stateManager.removePendingOperation(correlationId); + stateManager.setGatesSnapshot(gates.snapshot()); } - enqueue({ type: "resume.tool_result", result: dispatch.result }); - break; } - case "gate-cleared": { - // Async-tool resumption: clear the gate normally so the director - // re-infers, and append the correlated response to history so the - // model sees the content it was waiting on. - if (gate !== undefined) { - gates.clear(gate.gateId); - } - correlations.remove(correlationId); - if (stateManager !== null) { - stateManager.removePendingOperation(correlationId); - const msg = createInboundTurn(message); - if (msg !== null) { - stateManager.appendTurn(msg); - } + correlations.remove(correlationId); + if (stateManager !== null) { + stateManager.removePendingOperation(correlationId); + } + enqueue({ type: "resume.tool_result", result: dispatch.result }); + break; + } + case "gate-cleared": { + // Async-tool resumption: clear the gate normally so the director + // re-infers, and append the correlated response to history so the model + // sees the content it was waiting on. + if (gate !== undefined) { + gates.clear(gate.gateId); + } + correlations.remove(correlationId); + if (stateManager !== null) { + stateManager.removePendingOperation(correlationId); + const msg = createInboundTurn(message); + if (msg !== null) { + stateManager.appendTurn(msg); } - break; } + break; } + } - emit({ - type: "message.correlated", - seq: nextSeq(), - data: { message, correlationId }, - }); + emit({ + type: "message.correlated", + seq: nextSeq(), + data: { message, correlationId }, + }); - return true; - } finally { - correlatingIds.delete(correlationId); - } + return true; } // ------------------------------------------------------------------------- @@ -694,7 +662,7 @@ export function createReactor(config: ReactorConfig): Reactor { } async function executeInfer( - options: ExtendedInferenceOptions | undefined, + options: InferenceOptions | undefined, ): Promise { if (stateManager === null) return; @@ -727,11 +695,6 @@ export function createReactor(config: ReactorConfig): Reactor { await persistBlobs(result.blobs); } - const ephemeral = options?.ephemeralTurns; - if (ephemeral !== undefined && ephemeral.length > 0) { - prompt = [...prompt, ...ephemeral]; - } - // Tripwire: a malformed tool sequence is invalid in a coherent tool // conversation and would otherwise surface as an opaque provider rejection. // Catch it here, before the prompt is persisted or sent, so the corruption @@ -761,6 +724,7 @@ export function createReactor(config: ReactorConfig): Reactor { options, signal, nextSeq, + config.readMaterial, deps, ); @@ -855,7 +819,7 @@ export function createReactor(config: ReactorConfig): Reactor { } })(); - track(p); + void track(p); await p; } @@ -959,13 +923,13 @@ export function createReactor(config: ReactorConfig): Reactor { let outcomes: (ToolResult | typeof SUSPENDED)[]; if (parallel) { const p = Promise.all(calls.map((c) => runOne(c))); - track(p); + void track(p); outcomes = await p; } else { outcomes = []; for (const call of calls) { const p = runOne(call); - track(p); + void track(p); outcomes.push(await p); } } @@ -982,18 +946,7 @@ export function createReactor(config: ReactorConfig): Reactor { // when it reaches the threshold. A `null` threshold means detection is // disabled, so the accounting is skipped entirely. const ranCalls = calls.filter((_call, i) => outcomes[i] !== SUSPENDED); - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-poll-exemption - // A still-pending poll batch is liveness, not a loop: reset the streak - // (a skip would preserve a stale count and false-positive later) while - // mixed and terminal batches count normally. - const isLivePollBatch = - doomLoopThreshold !== null && - ranCalls.length > 0 && - isPollOnlyPendingBatch?.(ranCalls, results) === true; - if (isLivePollBatch) { - lastToolBatchSignature = null; - toolBatchRepeatCount = 0; - } else if (doomLoopThreshold !== null && ranCalls.length > 0) { + if (doomLoopThreshold !== null && ranCalls.length > 0) { const signature = toolBatchSignature(ranCalls); if (signature === lastToolBatchSignature) { toolBatchRepeatCount += 1; @@ -1004,55 +957,15 @@ export function createReactor(config: ReactorConfig): Reactor { lastToolBatchNames = ranCalls.map((call) => call.name); } - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-warning-turn - // One warning turn before the fatal trip: a repeat at count threshold-1 - // gets a corrective note appended so the model can see it is looping. - // `>= 2` keeps threshold 2 from annotating a batch's first execution. - let annotatedResults = results; - if ( - doomLoopThreshold !== null && - toolBatchRepeatCount >= 2 && - toolBatchRepeatCount === doomLoopThreshold - 1 && - doomLoopCorrectiveNote !== undefined - ) { - const note = doomLoopCorrectiveNote({ - calls: ranCalls, - repeatCount: toolBatchRepeatCount, - threshold: doomLoopThreshold, - }); - if (note !== undefined && note.length > 0) { - annotatedResults = results.map((result) => ({ - ...result, - content: - typeof result.content === "string" - ? `${result.content}\n\n${note}` - : { ...result.content, doom_loop_warning: note }, - })); - } - } - - cycleToolCallsExecuted += annotatedResults.length; + cycleToolCallsExecuted += results.length; - if (addToHistory && stateManager !== null && annotatedResults.length > 0) { - stateManager.appendTurn(createToolResultTurn(annotatedResults)); + if (addToHistory && stateManager !== null && results.length > 0) { + stateManager.appendTurn(createToolResultTurn(results)); } - for (const result of annotatedResults) { + for (const result of results) { enqueue({ type: "tool.done", result }); } - - // Checkpoint the completed tool cycle (the assistant tool_call turn plus - // its results) so an interrupt that rebuilds the agent from the store - // reloads the full exchange. Otherwise context commits only at cycle - // terminals and an uncommitted tool turn vanishes on rebuild. Guarded on - // addToHistory: only then does history end with the tool_result turn, so - // the persisted prefix is well-formed rather than an assistant turn with - // unanswered tool calls. - // - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-checkpoint-after-tool-cycle - if (addToHistory) { - await commitCycle(); - } } async function executeCompact( @@ -1073,10 +986,9 @@ export function createReactor(config: ReactorConfig): Reactor { }; const result = await compactor.apply(stateManager.getTurns(), ctx); - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-compact-publish-then-memory - await persistBlobs(result.blobs); + stateManager.replaceTurns(result.output); await contextStore.writeTurns(result.output); - pendingCompactOutput = result.output; + await persistBlobs(result.blobs); manifestBuffer.push(result.record); cycleCompactorName = compactor.name; @@ -1137,48 +1049,24 @@ export function createReactor(config: ReactorConfig): Reactor { const message = buildCycleMessage(); try { - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-skip-unchanged-history - const currentRevision = stateManager.getTurnsRevision(); - // A staged compact already wrote the new generation. Do not writeTurns - // live memory over that staging — memory still holds the old turns. - if (pendingCompactOutput === null && currentRevision !== lastWrittenTurnsRevision) { - await contextStore.writeTurns(stateManager.getTurns()); - lastWrittenTurnsRevision = currentRevision; - } + await contextStore.writeTurns(stateManager.getTurns()); await contextStore.writeManifest(manifestBuffer); await writeMetadata(); const commit = await contextStore.commit({ message }); lastCheckpointHash = commit.hash; - if (pendingCompactOutput !== null) { - stateManager.replaceTurns(pendingCompactOutput); - lastWrittenTurnsRevision = stateManager.getTurnsRevision(); - pendingCompactOutput = null; - } } catch (cause) { logger.error`Cycle commit failed: ${cause}`; emitError( `Cycle commit failed: ${cause instanceof Error ? cause.message : String(cause)}`, false, ); - // A staged compact must not leak into a later infer/tools cycle: skip-write - // plus replaceTurns would publish stale compact output over live memory. - pendingCompactOutput = null; resetCycleAccumulators(); return; } resetCycleAccumulators(); - // Fire only for commits the director actually asked to checkpoint. - // A hasWork-only commit (e.g. the auto-commit after execute_tools with - // addToHistory) is internal durability plumbing, not a checkpoint the - // caller requested — without this guard, a director that checkpoints - // in a later decide() call (as opposed to pairing checkpoint with the - // action that produced the work) gets afterCheckpoint invoked twice - // for what is, from the director's perspective, a single checkpoint. - // - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-after-checkpoint-director-only - if (afterCheckpoint !== undefined && hasOverride) { + if (afterCheckpoint !== undefined) { try { await afterCheckpoint(); } catch (cause) { @@ -1627,22 +1515,6 @@ export function createReactor(config: ReactorConfig): Reactor { `${String(doomLoopThreshold)} times consecutively`; emitError(message, true); closeMessageRun("failed", { message, kind: "doom_loop" }); - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-fail-run - if (doomLoopPolicy === "fail-run") { - // The run is dead but the reactor is not: drop the doomed batch's - // queued cycle events so the director never sees a tool.done that - // would re-infer, then return to idle. Non-cycle events already - // queued (inbound mail, gate clears) still process normally, and - // the next message.received opens a fresh run bracket. - for (let i = queue.length - 1; i >= 0; i--) { - const queued = queue[i]; - if (queued !== undefined && CYCLE_EVENT_TYPES.has(queued.type)) { - queue.splice(i, 1); - } - } - pendingContinuations = 0; - continue; - } done = true; await initiateShutdown(); break; @@ -1666,14 +1538,6 @@ export function createReactor(config: ReactorConfig): Reactor { let lastCheckpointHash: string | undefined; - // Turns revision most recently serialized to the context store. A checkpoint - // whose history has not changed since this revision skips writeTurns rather - // than re-serializing the entire (potentially large) conversation and its - // historical tool-output blobs. - // - // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-last-written-turns-revision - let lastWrittenTurnsRevision = 0; - async function initiateShutdown(): Promise { if (shutdownStarted) return; shutdownStarted = true; diff --git a/vendor/intx-inference/src/sse.ts b/vendor/intx-inference/src/sse.ts index b8dcf5b02..0feeba22a 100644 --- a/vendor/intx-inference/src/sse.ts +++ b/vendor/intx-inference/src/sse.ts @@ -11,17 +11,6 @@ const decoder = new TextDecoder(); -// A single SSE line has no defined upper bound, but an unbounded run of -// characters with no newline is indistinguishable from a stuck or hostile -// stream and would grow `buffer` until the process runs out of memory. Cap the -// unterminated tail generously — real `data:` lines, even large tool-call -// payloads, sit far below this — and fail loudly instead of consuming all -// memory. The limit is on `buffer.length` (UTF-16 code units), which bounds the -// retained string regardless of the source encoding's bytes-per-character. -// -// Locally patched — see vendor/intx-inference/PATCHES.md#sse-ts-max-line-length -const MAX_LINE_LENGTH = 16 * 1024 * 1024; - export async function* parseSSE( stream: ReadableStream, ): AsyncIterable { @@ -71,15 +60,6 @@ export async function* parseSSE( yield payload; } - - // After draining complete lines, `buffer` holds only the unterminated - // tail. A tail past the cap means the stream is emitting bytes without a - // newline delimiter unboundedly — abort rather than accumulate to OOM. - if (buffer.length > MAX_LINE_LENGTH) { - throw new Error( - `SSE line exceeded ${String(MAX_LINE_LENGTH)} characters without a newline delimiter`, - ); - } } } finally { reader.releaseLock(); diff --git a/vendor/intx-inference/src/state.ts b/vendor/intx-inference/src/state.ts index 642c5471e..867f81e1c 100644 --- a/vendor/intx-inference/src/state.ts +++ b/vendor/intx-inference/src/state.ts @@ -17,23 +17,6 @@ import type { GateSnapshot } from "./gates"; export type ReactorStateManager = ReturnType; -/** - * Recursively freezes a turn so snapshots can share its reference instead of - * deep-cloning the whole history on every director decision. Freezing costs - * O(turn size) once at append; cloning cost O(total history) per snapshot. - * - * Locally patched — see vendor/intx-inference/PATCHES.md#state-ts-deep-freeze-turns-revision - */ -function deepFreeze(value: T): T { - if (value === null || typeof value !== "object" || Object.isFrozen(value)) { - return value; - } - for (const key of Object.getOwnPropertyNames(value)) { - deepFreeze((value as Record)[key]); - } - return Object.freeze(value); -} - /** * Creates a mutable state container. All mutations go through explicit methods; * the `snapshot()` method produces an immutable view for the director. @@ -44,11 +27,7 @@ export function createStateManager( initialOps: PendingOperation[], initialUsage: TokenUsage, ) { - let turns: ConversationTurn[] = initialTurns.map(deepFreeze); - // Monotonic counter bumped whenever `turns` changes. Persistence compares it - // against the revision it last wrote so an unchanged history is never - // re-serialized on a checkpoint (INFERENCE.md § Cycle boundary commit). - let turnsRevision = 0; + let turns: ConversationTurn[] = [...initialTurns]; const pendingOperations = new Map( initialOps.map((op) => [op.correlationId, op]), ); @@ -59,13 +38,11 @@ export function createStateManager( const activeForks: { forkId: string; mode: "independent" | "child" }[] = []; function appendTurn(msg: ConversationTurn): void { - turns.push(deepFreeze(msg)); - turnsRevision += 1; + turns.push(msg); } function replaceTurns(next: ConversationTurn[]): void { - turns = next.map(deepFreeze); - turnsRevision += 1; + turns = [...next]; } function addPendingOperation(op: PendingOperation): void { @@ -106,13 +83,7 @@ export function createStateManager( } function getTurns(): ConversationTurn[] { - // Copy so a caller mutating the result cannot corrupt reactor state, the - // same guarantee snapshot() gives for the turns it exposes. - return turns.slice(); - } - - function getTurnsRevision(): number { - return turnsRevision; + return turns; } function getPendingOperations(): PendingOperation[] { @@ -124,25 +95,15 @@ export function createStateManager( } function snapshot(): ReactorState { - // `turns` is a lazy, memoized getter: high-frequency events (tool.done, - // inference.error) reach directors that never inspect it, so paying an - // O(history) copy on every decision would make per-event cost scale with - // session length. Deferring to first access keeps those decisions cheap. - // - // The remaining collections are small, so they are copied eagerly here to - // stay true point-in-time snapshots: a mutation between snapshot() and a - // later read must not leak into the view. Only `turns` trades that guarantee - // for the perf win, and its elements are deep-frozen at append, so a - // deferred read still cannot observe a mutated turn. - let turnsView: ConversationTurn[] | undefined; return { sessionId, - get turns() { - return (turnsView ??= turns.slice()); - }, - pendingOperations: Array.from(pendingOperations.values()).map((op) => ({ - ...op, + turns: turns.map((m) => ({ + ...m, + content: m.content.map((b) => structuredClone(b)), })), + pendingOperations: Array.from(pendingOperations.values()).map((op) => + structuredClone(op), + ), activeGates: activeGatesSnapshot.map((g) => ({ gateId: g.gateId, type: g.type, @@ -167,7 +128,6 @@ export function createStateManager( addFork, removeFork, getTurns, - getTurnsRevision, getPendingOperations, getTokenUsage, snapshot, diff --git a/vendor/intx-mailbox/src/fetch.test.ts b/vendor/intx-mailbox/src/fetch.test.ts index 33b91eefa..224ca44e3 100644 --- a/vendor/intx-mailbox/src/fetch.test.ts +++ b/vendor/intx-mailbox/src/fetch.test.ts @@ -1,10 +1,12 @@ import { describe, test, expect } from "bun:test"; +import type { CryptoProvider } from "@intx/types/runtime"; import { createInMemoryMailboxStore, executeSearch, fetchHeaders, fetchStructure, fetchPart, + fetchFull, type MailboxStore, type StoredEnvelope, } from "./index"; @@ -215,4 +217,26 @@ describe("async fetch projections route through readRaw", () => { fetchHeaders({ uid: 42, mailbox: "INBOX" }, store), ).rejects.toThrow(/not found/); }); + + test("fetchFull propagates a sender whose getPublicKey throws", async () => { + // A CryptoProvider that cannot produce its own public key is a local + // fault, not a bad signature: the error surfaces rather than being + // masked as a signature status. The key is resolved for every inbound + // from a known sender, so even this non-signed message reaches it. + const store = createInMemoryMailboxStore(); + const uid = store.append(rawMessage("Hello", "b"), envelopeFor(), []); + + const brokenSender: CryptoProvider = { + sign: () => Promise.reject(new Error("unused")), + signSSH: () => Promise.reject(new Error("unused")), + verify: () => Promise.resolve(false), + getPublicKey: () => { + throw new Error("no public key"); + }, + }; + + await expect( + fetchFull({ uid, mailbox: "INBOX" }, store, () => brokenSender), + ).rejects.toThrow(/no public key/); + }); }); diff --git a/vendor/intx-mailbox/src/fetch.ts b/vendor/intx-mailbox/src/fetch.ts index 792c7d6e9..6e9b08fae 100644 --- a/vendor/intx-mailbox/src/fetch.ts +++ b/vendor/intx-mailbox/src/fetch.ts @@ -22,7 +22,7 @@ import { extractAttachments, } from "@intx/mime"; import { buildMessageHeaders } from "./headers"; -import { verifyDetachedSignature } from "@intx/crypto"; +import { verifyMimeSignature } from "./verify-signature"; const MessagePayload = type({ type: InterchangeType, @@ -188,42 +188,7 @@ async function verifyMessageSignature( return "unknown"; } - try { - const { headers, bodyOffset } = parseHeaderSection(raw); - const body = raw.slice(bodyOffset); - const contentType = headers.get("content-type") ?? ""; - - if (!contentType.toLowerCase().includes("multipart/signed")) { - return "missing"; - } - - const boundary = extractBoundary(contentType); - if (boundary === undefined) return "missing"; - - const parts = parseMultipart(body, boundary); - if (parts.length < 2) return "missing"; - - const signedContentBytes = parts[0]!; - const sigPartBytes = parts[1]!; - const sigPart = parseMimePart(sigPartBytes); - - if ( - !sigPart.contentType.toLowerCase().includes("application/pgp-signature") - ) { - return "missing"; - } - - const publicKey = senderCrypto.getPublicKey(); - const valid = await verifyDetachedSignature( - signedContentBytes, - sigPart.body, - publicKey, - ); - - return valid ? "valid" : "invalid"; - } catch { - return "invalid"; - } + return verifyMimeSignature(raw, senderCrypto.getPublicKey()); } function buildStructure(body: Uint8Array, contentType: string): BodyStructure { diff --git a/vendor/intx-mailbox/src/index.ts b/vendor/intx-mailbox/src/index.ts index efc103b74..15d11b1a7 100644 --- a/vendor/intx-mailbox/src/index.ts +++ b/vendor/intx-mailbox/src/index.ts @@ -9,3 +9,4 @@ export { executeSearch } from "./search"; export { executeThread } from "./thread"; export { fetchHeaders, fetchStructure, fetchPart, fetchFull } from "./fetch"; export { buildMessageHeaders } from "./headers"; +export { verifyMimeSignature } from "./verify-signature"; diff --git a/vendor/intx-mailbox/src/verify-signature.test.ts b/vendor/intx-mailbox/src/verify-signature.test.ts new file mode 100644 index 000000000..f4418521d --- /dev/null +++ b/vendor/intx-mailbox/src/verify-signature.test.ts @@ -0,0 +1,102 @@ +import { describe, test, expect } from "bun:test"; +import { generateKeyPair, createEd25519Crypto } from "@intx/crypto"; +import { + assembleSignedContent, + assembleMessage, + createDetachedSignatureFromProvider, + generateMessageId, + type MessageHeaders, +} from "@intx/mime"; +import { verifyMimeSignature } from "./verify-signature"; + +function conversationHeaders(): MessageHeaders { + return { + from: "alpha@test.interchange", + to: ["beta@test.interchange"], + cc: undefined, + date: new Date("2026-01-15T12:00:00Z"), + messageId: generateMessageId("alpha@test.interchange"), + subject: undefined, + inReplyTo: undefined, + references: undefined, + mimeVersion: "1.0", + interchangeType: "conversation.message", + interchangeCorrelationId: undefined, + interchangeTenantId: undefined, + interchangeAgentId: undefined, + interchangeSessionId: undefined, + interchangeOfferingId: undefined, + interchangeSchemaVersion: undefined, + traceparent: undefined, + tracestate: undefined, + }; +} + +/** Build a validly-signed `multipart/signed` conversation message. */ +async function signedMessage( + crypto: Awaited>, + text: string, +): Promise { + const content = assembleSignedContent({ kind: "conversation", text }); + const sig = await createDetachedSignatureFromProvider(content, crypto); + return assembleMessage(conversationHeaders(), content, sig); +} + +async function makeCrypto() { + return createEd25519Crypto(await generateKeyPair()); +} + +describe("verifyMimeSignature", () => { + test("returns valid for a message signed by the given key", async () => { + const crypto = await makeCrypto(); + const raw = await signedMessage(crypto, "hello world"); + + const status = await verifyMimeSignature(raw, crypto.getPublicKey()); + expect(status).toBe("valid"); + }); + + test("returns invalid when checked against a different key", async () => { + const signer = await makeCrypto(); + const other = await makeCrypto(); + const raw = await signedMessage(signer, "hello world"); + + const status = await verifyMimeSignature(raw, other.getPublicKey()); + expect(status).toBe("invalid"); + }); + + test("returns invalid, not a throw, for a corrupt signature part", async () => { + // Signature verification must never let a malformed signature escape as an + // exception: a corrupt `application/pgp-signature` part is a verdict + // ("invalid"), not an error the caller has to catch. + const crypto = await makeCrypto(); + const content = assembleSignedContent({ + kind: "conversation", + text: "hello world", + }); + const raw = assembleMessage( + conversationHeaders(), + content, + new TextEncoder().encode("not a valid pgp signature block"), + ); + + const status = await verifyMimeSignature(raw, crypto.getPublicKey()); + expect(status).toBe("invalid"); + }); + + test("returns missing for a message that is not multipart/signed", async () => { + const crypto = await makeCrypto(); + const raw = new TextEncoder().encode( + [ + "From: alpha@test.interchange", + "To: beta@test.interchange", + "Subject: plain", + "Content-Type: text/plain", + "", + "not signed", + ].join("\r\n"), + ); + + const status = await verifyMimeSignature(raw, crypto.getPublicKey()); + expect(status).toBe("missing"); + }); +}); diff --git a/vendor/intx-mailbox/src/verify-signature.ts b/vendor/intx-mailbox/src/verify-signature.ts new file mode 100644 index 000000000..565387075 --- /dev/null +++ b/vendor/intx-mailbox/src/verify-signature.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- MIME multipart parsing with bounds checks */ +import { + parseHeaderSection, + parseMimePart, + extractBoundary, + parseMultipart, +} from "@intx/mime"; +import { verifyDetachedSignature } from "@intx/crypto"; + +/** + * Verify a PGP/MIME `multipart/signed` message against a public key. + * + * Extracts the signed-content part and the detached + * `application/pgp-signature` part from the raw message bytes, then checks + * the signature with `verifyDetachedSignature`. + * + * - `valid` — the detached signature verified against `publicKey` + * - `invalid` — the signature check failed, or the message could not be + * parsed as a signed message + * - `missing` — the message is not `multipart/signed`, or carries no + * `application/pgp-signature` part + * + * `raw` must be the original, unmodified message bytes: the signature is + * recomputed over the exact canonical bytes of the signed part, so a + * re-serialized message will not verify. `publicKey` is the raw Ed25519 + * public key bytes, as returned by `CryptoProvider.getPublicKey()`. + */ +export async function verifyMimeSignature( + raw: Uint8Array, + publicKey: Uint8Array, +): Promise<"valid" | "invalid" | "missing"> { + try { + const { headers, bodyOffset } = parseHeaderSection(raw); + const body = raw.slice(bodyOffset); + const contentType = headers.get("content-type") ?? ""; + + if (!contentType.toLowerCase().includes("multipart/signed")) { + return "missing"; + } + + const boundary = extractBoundary(contentType); + if (boundary === undefined) return "missing"; + + const parts = parseMultipart(body, boundary); + if (parts.length < 2) return "missing"; + + const signedContentBytes = parts[0]!; + const sigPartBytes = parts[1]!; + const sigPart = parseMimePart(sigPartBytes); + + if ( + !sigPart.contentType.toLowerCase().includes("application/pgp-signature") + ) { + return "missing"; + } + + const valid = await verifyDetachedSignature( + signedContentBytes, + sigPart.body, + publicKey, + ); + + return valid ? "valid" : "invalid"; + } catch { + return "invalid"; + } +} diff --git a/vendor/intx-mime/src/index.test.ts b/vendor/intx-mime/src/index.test.ts index 874f69883..29fffe6de 100644 --- a/vendor/intx-mime/src/index.test.ts +++ b/vendor/intx-mime/src/index.test.ts @@ -135,6 +135,14 @@ describe("extractAddrSpec", () => { ).toThrow(); }); + test("throws on a trailing comment in a bare form", () => { + expect(() => extractAddrSpec("alice@example.com (comment)")).toThrow(); + }); + + test("throws on a trailing token in a bare form", () => { + expect(() => extractAddrSpec("alice@example.com foo")).toThrow(); + }); + test("throws on a quoted local-part", () => { expect(() => extractAddrSpec('"a@b"@example.com')).toThrow(); }); diff --git a/vendor/intx-mime/src/mime.ts b/vendor/intx-mime/src/mime.ts index adb9021f0..f842b2594 100644 --- a/vendor/intx-mime/src/mime.ts +++ b/vendor/intx-mime/src/mime.ts @@ -156,6 +156,9 @@ export function generateMessageId(address: string): string { * - content after the closing `>` in an angle-bracketed form * (e.g. `Name (comment)`) — would silently fall through to a * misparsed bare-form attempt, so we refuse instead + * - trailing content in a bare form (e.g. `a@b (comment)`) — a well-formed + * bare addr-spec has no internal whitespace, so we refuse rather than + * fold the trailing token into the domain * * Per RFC 5321 §2.4 the local-part is technically case-sensitive, but no * production system honors that; matching case-insensitively is the @@ -180,6 +183,16 @@ export function extractAddrSpec(addressLine: string): string { } candidate = trimmed.slice(angleOpen + 1, -1).trim(); } else { + // Bare form. An unquoted addr-spec carries no internal whitespace, so + // treat any as trailing content (e.g. `a@b (comment)`) and refuse rather + // than mangle the domain. Domain literals carry no internal whitespace, so + // the only bare inputs this rejects are malformed or quoted local-parts, + // both of which the function refuses by design anyway. + if (/\s/.test(trimmed)) { + throw new Error( + `extractAddrSpec: trailing content in bare address ${JSON.stringify(addressLine)}`, + ); + } candidate = trimmed; } diff --git a/vendor/intx-storage-isogit/src/store.test.ts b/vendor/intx-storage-isogit/src/store.test.ts index 5983509a6..112e17809 100644 --- a/vendor/intx-storage-isogit/src/store.test.ts +++ b/vendor/intx-storage-isogit/src/store.test.ts @@ -692,38 +692,6 @@ describe("error store", () => { ), ).toHaveLength(1); }); - - test("loadErrors round-trips records ordered by seq", async () => { - const dir = await tempDir(); - const store = await createAuditStore(dir); - const later = makeErrorRecord({ seq: 2, category: "retryable" }); - const earlier = makeErrorRecord({ seq: 1, category: "credential_failure" }); - - await store.commitErrors([later]); - await store.commitErrors([earlier]); - - expect(await store.loadErrors("session-1")).toEqual([earlier, later]); - }); - - test("loadErrors returns empty array for nonexistent session", async () => { - const dir = await tempDir(); - const store = await createAuditStore(dir); - - expect(await store.loadErrors("no-such-session")).toEqual([]); - }); - - test("rejects sessionId with path traversal on loadErrors", async () => { - const dir = await tempDir(); - const store = await createAuditStore(dir); - - let thrown: Error | undefined; - try { - await store.loadErrors("../escape"); - } catch (cause) { - thrown = cause instanceof Error ? cause : new Error(String(cause)); - } - expect(thrown?.message).toContain("unsafe characters"); - }); }); describe("audit and error durability retries", () => { diff --git a/vendor/intx-storage-isogit/src/store.ts b/vendor/intx-storage-isogit/src/store.ts index c01ff9dcc..3cbb096e5 100644 --- a/vendor/intx-storage-isogit/src/store.ts +++ b/vendor/intx-storage-isogit/src/store.ts @@ -17,9 +17,8 @@ import { import { type } from "arktype"; import { AuditRecord, - ErrorRecord, type AuditRecord as AuditRecordType, - type ErrorRecord as ErrorRecordType, + type ErrorRecord, } from "@intx/types/audit"; import { AUTHOR } from "./init"; import type { CommitSigner } from "./signer"; @@ -789,7 +788,7 @@ export class IsogitStore } async commitErrors( - records: ErrorRecordType[], + records: ErrorRecord[], _signal?: AbortSignal, ): Promise { if (records.length === 0) return; @@ -863,43 +862,4 @@ export class IsogitStore records.sort((a, b) => a.seq - b.seq); return records; } - - // Locally patched — see vendor/intx-storage-isogit/PATCHES.md#store-ts-load-errors - async loadErrors( - sessionId: string, - _signal?: AbortSignal, - ): Promise { - assertSafeSegment(sessionId, "sessionId"); - const sessionDir = this.runtime.path.join(this.dir, ERRORS_DIR, sessionId); - - let entries: string[]; - try { - entries = await this.runtime.fs.readdir(sessionDir); - } catch (cause) { - if ( - cause instanceof Error && - "code" in cause && - cause.code === "ENOENT" - ) { - return []; - } - throw cause; - } - - const records: ErrorRecordType[] = []; - for (const entry of entries) { - if (!entry.endsWith(".json")) continue; - const fullPath = this.runtime.path.join(sessionDir, entry); - const raw = await this.runtime.fs.readTextFile(fullPath); - const parsed = JSON.parse(raw) as unknown; - const result = ErrorRecord(parsed); - if (result instanceof type.errors) { - throw new Error(`Invalid error record in ${entry}: ${result.summary}`); - } - records.push(result); - } - - records.sort((a, b) => a.seq - b.seq); - return records; - } } diff --git a/vendor/intx-tools-posix/src/sidecar-bundle-toolcwd.test.ts b/vendor/intx-tools-posix/src/sidecar-bundle-toolcwd.test.ts new file mode 100644 index 000000000..9aeace8ea --- /dev/null +++ b/vendor/intx-tools-posix/src/sidecar-bundle-toolcwd.test.ts @@ -0,0 +1,86 @@ +// Behavior guard: the sidecar bundle must scope its filesystem tools to +// `env.toolCwd`, not `env.workdir`. Every other test keeps the two keys +// equal, so a regression that reads `env.workdir` again would pass the +// suite silently. This test forces the two directories apart and proves a +// relative write lands under `toolCwd` while `workdir` stays untouched. + +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { mkdtemp, rm, readFile, access } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { createDefaultDirectorRegistry } from "@intx/agent"; +import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing"; +import { createIsogitStore } from "@intx/storage-isogit/node"; +import type { InferenceSource } from "@intx/types/runtime"; + +import { posix, type PosixToolEnv } from "./sidecar-bundle"; + +const SOURCE: InferenceSource = { + id: "anthropic:mock-model", + provider: "anthropic", + baseURL: "https://api.anthropic.com", + credentialId: "sk-test", + model: "mock-model", +}; + +function neverAbort(): AbortSignal { + return new AbortController().signal; +} + +let toolDir: string; +let workDir: string; +let env: PosixToolEnv; + +beforeAll(async () => { + toolDir = await mkdtemp(join(tmpdir(), "tools-posix-toolcwd-")); + workDir = await mkdtemp(join(tmpdir(), "tools-posix-workdir-")); + const storage = await createIsogitStore(workDir); + env = { + sources: [SOURCE], + defaultSource: SOURCE.id, + storage, + workdir: workDir, + toolCwd: toolDir, + audit: noopAuditStore(), + authorize: permissiveAuthorize(), + directors: createDefaultDirectorRegistry(), + }; +}); + +afterAll(async () => { + await Promise.all([ + toolDir !== undefined + ? rm(toolDir, { recursive: true, force: true }) + : undefined, + workDir !== undefined + ? rm(workDir, { recursive: true, force: true }) + : undefined, + ]); +}); + +describe("posix sidecar-bundle working directory", () => { + test("resolves a relative write against toolCwd, not workdir", async () => { + const bundle = posix(env); + try { + const result = await bundle.run( + { + id: "w1", + name: "write_file", + arguments: { path: "sentinel.txt", content: "hello" }, + }, + neverAbort(), + ); + expect(result.isError).toBeFalsy(); + + const written = await readFile(join(toolDir, "sentinel.txt"), "utf8"); + expect(written).toBe("hello"); + + await expect(access(join(workDir, "sentinel.txt"))).rejects.toThrow(); + } finally { + if (bundle.dispose !== undefined) { + await bundle.dispose(); + } + } + }); +}); diff --git a/vendor/intx-tools-posix/src/sidecar-bundle.test.ts b/vendor/intx-tools-posix/src/sidecar-bundle.test.ts index 86ac5758c..a8e492e42 100644 --- a/vendor/intx-tools-posix/src/sidecar-bundle.test.ts +++ b/vendor/intx-tools-posix/src/sidecar-bundle.test.ts @@ -14,24 +14,24 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createDefaultDirectorRegistry, type BaseEnv } from "@intx/agent"; +import { createDefaultDirectorRegistry } from "@intx/agent"; import { noopAuditStore, permissiveAuthorize } from "@intx/agent/testing"; import { createIsogitStore } from "@intx/storage-isogit/node"; import type { InferenceSource } from "@intx/types/runtime"; -import { posix } from "./sidecar-bundle"; +import { posix, type PosixToolEnv } from "./sidecar-bundle"; import { TOOL_NAMES } from "./registry"; const SOURCE: InferenceSource = { id: "anthropic:mock-model", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "sk-test", + credentialId: "sk-test", model: "mock-model", }; let tmpDir: string; -let env: BaseEnv; +let env: PosixToolEnv; beforeAll(async () => { tmpDir = await mkdtemp(join(tmpdir(), "tools-posix-sidecar-bundle-test-")); @@ -41,6 +41,7 @@ beforeAll(async () => { defaultSource: SOURCE.id, storage, workdir: tmpDir, + toolCwd: tmpDir, audit: noopAuditStore(), authorize: permissiveAuthorize(), directors: createDefaultDirectorRegistry(), diff --git a/vendor/intx-tools-posix/src/sidecar-bundle.ts b/vendor/intx-tools-posix/src/sidecar-bundle.ts index c3024f592..87ba931b5 100644 --- a/vendor/intx-tools-posix/src/sidecar-bundle.ts +++ b/vendor/intx-tools-posix/src/sidecar-bundle.ts @@ -1,20 +1,36 @@ // Sidecar-bundle entry for `@intx/tools-posix` — the convention-compliant // factory the tool-package loader invokes. // -// The factory uses `BaseEnv` fields (`workdir`, `storage`) and the +// The factory reads the working tree it operates on from `env.toolCwd` +// and the blob store from the `BaseEnv` `storage` field, plus the // optional `plugins` slot. Plugins are filtered by shape: any element // of `env.plugins` that has a `tools` array, a `middleware` function, // or a `dispose` function is treated as a `ToolPlugin` and handed to // `createPosixTools`. This is how LSP (a plugin factory) plugs into // posix without posix needing to know about LSP by name. -import { defineTool, isToolPluginInstance } from "@intx/agent"; +import { defineTool, isToolPluginInstance, type BaseEnv } from "@intx/agent"; import { createBlobReader } from "@intx/types/runtime"; import { createPosixTools } from "./index"; import type { ToolPlugin } from "./plugin"; import { GATED_TOOL_NAMES, TOOL_DEFINITIONS } from "./registry"; +/** + * Env contract for the posix sidecar bundle. `toolCwd` is the working + * tree the posix filesystem tools operate on: read, write, edit, shell, + * search, and grep resolve relative paths against it. + * + * It is independent of the `BaseEnv` `workdir` lock and storage + * boundary. Two agents may share one `toolCwd` while holding distinct + * `workdir` values; the posix tools apply no lock to `toolCwd`, so + * concurrent writes to a shared tree are the caller's corruption risk + * to own. + */ +export interface PosixToolEnv extends BaseEnv { + toolCwd: string; +} + function isToolPlugin(value: unknown): value is ToolPlugin { // Require the `kind: "tool-plugin"` marker minted by definePlugin // before any shape check. A foreign object that happens to expose @@ -34,8 +50,9 @@ function isToolPlugin(value: unknown): value is ToolPlugin { * Named export the loader picks up. The id is package-namespaced per * the convention. */ -export const posix = defineTool({ +export const posix = defineTool({ id: "@intx/tools-posix/sidecar-bundle", + requires: ["toolCwd"], definitions: TOOL_DEFINITIONS.map((def) => ({ name: def.name, ...(GATED_TOOL_NAMES.has(def.name) ? { approval: "ask" as const } : {}), @@ -44,7 +61,7 @@ export const posix = defineTool({ const blobReader = createBlobReader(env.storage); const plugins = (env.plugins ?? []).filter(isToolPlugin); const tools = createPosixTools({ - cwd: env.workdir, + cwd: env.toolCwd, blobReader, plugins, }); diff --git a/vendor/intx-types/src/agent-address.test.ts b/vendor/intx-types/src/agent-address.test.ts index 43ba62f80..3eda7524a 100644 --- a/vendor/intx-types/src/agent-address.test.ts +++ b/vendor/intx-types/src/agent-address.test.ts @@ -3,6 +3,7 @@ import { describe, test, expect } from "bun:test"; import { formatRunAddress, isRunAddress, + parseAddress, parseRunAddress, } from "./agent-address"; @@ -14,6 +15,38 @@ describe("formatRunAddress", () => { }); }); +describe("parseAddress", () => { + test("splits any local part from its domain without a prefix check", () => { + expect(parseAddress("usr_alice@tenant.example")).toEqual({ + localPart: "usr_alice", + domain: "tenant.example", + }); + expect(parseAddress("run_abc123@tenant.example")).toEqual({ + localPart: "run_abc123", + domain: "tenant.example", + }); + }); + + test("returns null when the @ is missing", () => { + expect(parseAddress("no-at-sign")).toBeNull(); + }); + + test("returns null when the local part is empty", () => { + expect(parseAddress("@tenant.example")).toBeNull(); + }); + + test("returns null when the domain part is empty", () => { + expect(parseAddress("usr_alice@")).toBeNull(); + }); + + test("splits on the first @ and treats the rest as the domain", () => { + expect(parseAddress("usr_alice@foo@bar")).toEqual({ + localPart: "usr_alice", + domain: "foo@bar", + }); + }); +}); + describe("parseRunAddress", () => { test("splits a well-formed address", () => { expect(parseRunAddress("run_abc123@tenant.example")).toEqual({ diff --git a/vendor/intx-types/src/agent-address.ts b/vendor/intx-types/src/agent-address.ts index 2af45e560..56610d931 100644 --- a/vendor/intx-types/src/agent-address.ts +++ b/vendor/intx-types/src/agent-address.ts @@ -17,16 +17,30 @@ export function formatRunAddress(runId: string, domain: string): string { return `${runId}@${domain}`; } -export function parseRunAddress( +/** + * Split an `@` address into its two halves, or `null` when it is + * not a well-formed address (no `@`, an empty local part, or an empty domain). + * The single owner of the `@`-split so a run address and any other address + * validate the same way; callers branch on the local part after this returns. + */ +export function parseAddress( address: string, -): { runId: string; domain: string } | null { +): { localPart: string; domain: string } | null { const atIdx = address.indexOf("@"); if (atIdx <= 0) return null; - const runId = address.slice(0, atIdx); + const localPart = address.slice(0, atIdx); const domain = address.slice(atIdx + 1); - if (!runId.startsWith(RUN_PREFIX)) return null; if (domain.length === 0) return null; - return { runId, domain }; + return { localPart, domain }; +} + +export function parseRunAddress( + address: string, +): { runId: string; domain: string } | null { + const parsed = parseAddress(address); + if (parsed === null) return null; + if (!parsed.localPart.startsWith(RUN_PREFIX)) return null; + return { runId: parsed.localPart, domain: parsed.domain }; } export function isRunAddress(address: string): boolean { diff --git a/vendor/intx-types/src/credential-cipher.ts b/vendor/intx-types/src/credential-cipher.ts index 1e3c25350..9f475c7de 100644 --- a/vendor/intx-types/src/credential-cipher.ts +++ b/vendor/intx-types/src/credential-cipher.ts @@ -40,3 +40,16 @@ export interface CredentialCipher { export function credentialAad(id: string, column: string): string { return JSON.stringify(["credential-secret", id, column]); } + +/** + * Build the additional-authenticated-data string binding a principal signing + * key's sealed private material to the `principal_key` row and column it belongs + * to. Shares the AEAD primitive and encoding rules with `credentialAad` but uses + * a distinct `"principal-key"` tag domain, so a credential-secret ciphertext and + * a principal-key ciphertext are never interchangeable even under the same key. + * The mint (write) and sign (read) sites MUST build the `aad` through this one + * function so the value matches. + */ +export function principalKeyAad(id: string, column: string): string { + return JSON.stringify(["principal-key", id, column]); +} diff --git a/vendor/intx-types/src/hex.ts b/vendor/intx-types/src/hex.ts index 15b3add26..cf1187ca1 100644 --- a/vendor/intx-types/src/hex.ts +++ b/vendor/intx-types/src/hex.ts @@ -1,8 +1,8 @@ // Hex codec for byte strings. // -// Used across the codebase for Ed25519 key serialization, challenge -// nonces, and signatures on the wire. Centralizing here keeps the -// encoding stable and the error wording consistent. +// Used across the codebase for Ed25519 key and signature serialization on the +// wire. Centralizing here keeps the encoding stable and the error wording +// consistent. export function hexEncode(bytes: Uint8Array): string { return Array.from(bytes) diff --git a/vendor/intx-types/src/index.ts b/vendor/intx-types/src/index.ts index d851f51cd..755229df8 100644 --- a/vendor/intx-types/src/index.ts +++ b/vendor/intx-types/src/index.ts @@ -16,6 +16,7 @@ export * from "./providers"; export * from "./oauth-clients"; export * from "./credentials"; export * from "./credential-cipher"; +export * from "./signer-identity"; export * from "./mediated-credential"; export * from "./assets"; export * from "./offerings"; @@ -33,5 +34,5 @@ export * from "./base64url"; export * from "./concat"; export * from "./has-code"; export * from "./audit"; -export * from "./sidecar-placement"; export * from "./sidecar-allocation"; +export * from "./sidecar-capabilities"; diff --git a/vendor/intx-types/src/mediated-credential.ts b/vendor/intx-types/src/mediated-credential.ts index 21538f0b6..63461665f 100644 --- a/vendor/intx-types/src/mediated-credential.ts +++ b/vendor/intx-types/src/mediated-credential.ts @@ -33,6 +33,21 @@ export interface CredentialMaterial { */ export type CredentialMaterialSource = () => CredentialMaterial; +/** + * Resolves the current material for a credential BY id from the run's credential + * cell. Inference uses this to fill a request's credential from + * `InferenceSource.credentialId` at send time -- the same cell tool credentials + * resolve from, so neither rail holds an inline secret. Keyed by `credentialId` + * (not bound to one, unlike `CredentialMaterialSource`) because a source's + * forward-only failover chain carries a distinct credential per entry. Reads + * live, so a rotation of the cell is picked up on the next call; fails closed + * when the credential is absent (revoked or never delivered). This is the single + * seam a future mode swaps to keep the raw secret out of the child entirely. + */ +export type CredentialMaterialResolver = ( + credentialId: string, +) => CredentialMaterial; + /** What a provider plugin is given to shape a mediated credential. */ export interface CredentialShapeContext { /** diff --git a/vendor/intx-types/src/runtime.ts b/vendor/intx-types/src/runtime.ts index 2a796fa7e..c71574348 100644 --- a/vendor/intx-types/src/runtime.ts +++ b/vendor/intx-types/src/runtime.ts @@ -247,6 +247,68 @@ export const SignatureStatus = type.enumerated( ); export type SignatureStatus = typeof SignatureStatus.infer; +/** + * The admission outcome of an inbound message. This is the single vocabulary a + * delivery decision keys on, distinct from the two-axis signature verdict that + * produces it. + * + * - `clean` — nothing suspect; always admitted + * - `untrustedFrom` — the visible `From` cannot be trusted, either because it is + * present but unparseable or because a valid signature is worn under a + * mismatched sender identity + * - `invalid` — the signature check failed (tampering or the wrong key) + * - `missing` — the message carried no signature + * - `unknown` — no key was available to verify against + * - `error` — a fault stopped the check from running at all; always rejected + */ +export const InboundMailOutcome = type.enumerated( + "clean", + "untrustedFrom", + "invalid", + "missing", + "unknown", + "error", +); +export type InboundMailOutcome = typeof InboundMailOutcome.infer; + +/** + * The subset of {@link InboundMailOutcome} a workflow author may relax to admit + * a message that would otherwise be rejected. It omits `clean` (which always + * admits, so there is nothing to relax) and `error` (pinned to reject, since a + * fault we could not check through is never something an author should be able + * to wave past). A per-workflow policy keys on exactly these outcomes. + */ +export const AuthorControllableOutcome = type.enumerated( + "untrustedFrom", + "invalid", + "missing", + "unknown", +); +export type AuthorControllableOutcome = typeof AuthorControllableOutcome.infer; + +/** + * A per-workflow inbound-mail admission policy: for each admission outcome the + * author may control, whether a message that resolved to that outcome is + * `reject`ed or `admit`ted. The key set is exactly the + * {@link AuthorControllableOutcome} values -- `clean` (always admitted) and + * `error` (pinned to reject) are deliberately not keys. + * + * The object is SPARSE: every key is optional, and an omitted key is NOT a + * default of any kind here. It is left for a later resolution step to interpret + * an absent outcome. Keeping it sparse means the content hash covers only what + * the author actually declared, so a definition that omits the policy hashes + * identically to one authored before the field existed. Undeclared keys are + * rejected so a typo such as `clean` or `errror` fails at the wire boundary + * rather than riding through as an inert unknown key. + */ +export const InboundMailPolicy = type({ + "untrustedFrom?": "'reject' | 'admit'", + "invalid?": "'reject' | 'admit'", + "missing?": "'reject' | 'admit'", + "unknown?": "'reject' | 'admit'", +}).onUndeclaredKey("reject"); +export type InboundMailPolicy = typeof InboundMailPolicy.infer; + /** * A parsed MIME part. `content` is the DECODED bytes in memory (the * transfer-encoding has already been undone). `filename` and `disposition` are @@ -881,7 +943,9 @@ const MediaSourceUrl = type({ url: "string", }); -export const MediaSource = MediaSourceBase64.or(MediaSourceFileReference).or( +export const MediaSource = type.or( + MediaSourceBase64, + MediaSourceFileReference, MediaSourceUrl, ); export type MediaSource = typeof MediaSource.infer; @@ -1166,28 +1230,29 @@ const ToolResultBlock = type({ // SafetyRatingBlocks (safety signals annotate model/request // filtering), and not CodeExecution blocks (server-side code // execution is a distinct lifecycle from the user-tool round-trip). - content: TextBlock.or(ImageBlock) - .or(AudioBlock) - .or(VideoBlock) - .or(DocumentBlock) + content: type + .or(TextBlock, ImageBlock, AudioBlock, VideoBlock, DocumentBlock) .array(), "detail?": "unknown", "isError?": "boolean", }); -export const ContentBlock = TextBlock.or(ThinkingBlock) - .or(RedactedThinkingBlock) - .or(RefusalBlock) - .or(ImageBlock) - .or(AudioBlock) - .or(VideoBlock) - .or(DocumentBlock) - .or(CitationBlock) - .or(SafetyRatingBlock) - .or(CodeExecutionRequestBlock) - .or(CodeExecutionResultBlock) - .or(ToolCallBlock) - .or(ToolResultBlock); +export const ContentBlock = type.or( + TextBlock, + ThinkingBlock, + RedactedThinkingBlock, + RefusalBlock, + ImageBlock, + AudioBlock, + VideoBlock, + DocumentBlock, + CitationBlock, + SafetyRatingBlock, + CodeExecutionRequestBlock, + CodeExecutionResultBlock, + ToolCallBlock, + ToolResultBlock, +); export type ContentBlock = typeof ContentBlock.infer; /** @@ -1303,12 +1368,13 @@ const WireInboundMessage = type({ * * (INFERENCE.md § Event Protocol) */ -export const InferenceEvent = type({ - type: "'inference.start'", - seq: "number", - data: { model: "string" }, -}) - .or({ +export const InferenceEvent = type.or( + { + type: "'inference.start'", + seq: "number", + data: { model: "string" }, + }, + { type: "'inference.thinking.delta'", seq: "number", data: { @@ -1316,18 +1382,18 @@ export const InferenceEvent = type({ partial: PartialMessage, "index?": "number", }, - }) - .or({ + }, + { type: "'inference.block.signature'", seq: "number", data: { signature: "string", "index?": "number" }, - }) - .or({ + }, + { type: "'inference.thinking.redacted'", seq: "number", data: { redactedThinking: RedactedThinkingBlock, "index?": "number" }, - }) - .or({ + }, + { type: "'inference.text.delta'", seq: "number", data: { @@ -1335,8 +1401,8 @@ export const InferenceEvent = type({ partial: PartialMessage, "index?": "number", }, - }) - .or({ + }, + { type: "'inference.refusal.delta'", seq: "number", data: { @@ -1344,8 +1410,8 @@ export const InferenceEvent = type({ partial: PartialMessage, "index?": "number", }, - }) - .or({ + }, + { type: "'inference.tool_call.start'", seq: "number", data: { @@ -1354,8 +1420,8 @@ export const InferenceEvent = type({ partial: PartialMessage, "index?": "number", }, - }) - .or({ + }, + { type: "'inference.tool_call.delta'", seq: "number", data: { @@ -1364,8 +1430,8 @@ export const InferenceEvent = type({ partial: PartialMessage, "index?": "number", }, - }) - .or({ + }, + { type: "'inference.tool_call.end'", seq: "number", data: { @@ -1375,14 +1441,13 @@ export const InferenceEvent = type({ partial: PartialMessage, "index?": "number", }, - }) - .or({ + }, + { type: "'inference.usage'", seq: "number", - // Locally patched — see vendor/intx-types/PATCHES.md#types-ts-usage-stop-reason - data: { usage: TokenUsage, source: LastCycleSource, "stopReason?": "string" }, - }) - .or({ + data: { usage: TokenUsage, source: LastCycleSource }, + }, + { type: "'inference.done'", seq: "number", data: { @@ -1391,13 +1456,13 @@ export const InferenceEvent = type({ source: LastCycleSource, "pacingDelayMs?": "number", }, - }) - .or({ + }, + { type: "'inference.error'", seq: "number", data: { error: InferenceError, partial: PartialMessage }, - }) - .or({ + }, + { type: "'inference.retry'", seq: "number", data: { @@ -1405,8 +1470,8 @@ export const InferenceEvent = type({ delayMs: "number", previousError: InferenceError, }, - }) - .or({ + }, + { type: "'inference.citation'", seq: "number", // `index`, when present, names the source content block (typically @@ -1418,8 +1483,8 @@ export const InferenceEvent = type({ // `content[]` and consumers attribute them to the nearest // preceding TextBlock per the CitationBlock docstring. data: { citation: CitationBlock, "index?": "number" }, - }) - .or({ + }, + { type: "'inference.safety_rating'", seq: "number", // Prompt-level structured safety signal (observed Gemini @@ -1427,13 +1492,13 @@ export const InferenceEvent = type({ // capture has zero candidates. Harness appends the block to the // finalized turn's `content[]`. data: { safetyRating: SafetyRatingBlock }, - }) - .or({ + }, + { type: "'inference.code_execution.start'", seq: "number", data: { request: CodeExecutionRequestBlock, "index?": "number" }, - }) - .or({ + }, + { type: "'inference.code_execution.delta'", seq: "number", // requestId correlates fragments back to the originating @@ -1448,13 +1513,13 @@ export const InferenceEvent = type({ codeFragment: "string", "index?": "number", }, - }) - .or({ + }, + { type: "'inference.code_execution.result'", seq: "number", data: { result: CodeExecutionResultBlock, "index?": "number" }, - }) - .or({ + }, + { type: "'inference.image_output'", seq: "number", // Fires mid-stream when an adapter finalizes an image-output @@ -1465,28 +1530,28 @@ export const InferenceEvent = type({ // ~1MB inline blobs); consumers that subscribe to this event // should treat it as a non-trivial transport size. data: { image: ImageBlock, "index?": "number" }, - }) - .or({ + }, + { type: "'tool.start'", seq: "number", data: { call: ToolCall }, - }) - .or({ + }, + { type: "'tool.update'", seq: "number", data: { callId: "string", partial: "string" }, - }) - .or({ + }, + { type: "'tool.done'", seq: "number", data: { result: ToolResult }, - }) - .or({ + }, + { type: "'message.queued'", seq: "number", data: { message: WireInboundMessage }, - }) - .or({ + }, + { type: "'message.run.started'", seq: "number", data: { @@ -1494,8 +1559,8 @@ export const InferenceEvent = type({ messageRunId: "string", receivedAt: "number", }, - }) - .or({ + }, + { type: "'message.run.ended'", seq: "number", data: { @@ -1507,23 +1572,23 @@ export const InferenceEvent = type({ "kind?": "string", }, }, - }) - .or({ + }, + { type: "'message.correlated'", seq: "number", data: { message: WireInboundMessage, correlationId: "string" }, - }) - .or({ + }, + { type: "'connector.reply'", seq: "number", data: { content: "string", "checkpointHash?": "string" }, - }) - .or({ + }, + { type: "'reactor.start'", seq: "number", data: "object", - }) - .or({ + }, + { type: "'reactor.gate.blocked'", seq: "number", data: { @@ -1532,50 +1597,51 @@ export const InferenceEvent = type({ "correlationId?": "string", "approvalSnapshot?": ApprovalSnapshot, }, - }) - .or({ + }, + { type: "'reactor.gate.cleared'", seq: "number", data: { gateId: "string", reason: type.enumerated("resolved", "timeout", "shutdown"), }, - }) - .or({ + }, + { type: "'reactor.done'", seq: "number", data: "object", - }) - .or({ + }, + { type: "'reactor.error'", seq: "number", data: { error: "string", fatal: "boolean" }, - }) - .or({ + }, + { type: "'fork.created'", seq: "number", data: { forkId: "string", parentId: "string", mode: ForkMode }, - }) - .or({ + }, + { type: "'fork.done'", seq: "number", data: { forkId: "string", "result?": "unknown" }, - }) - .or({ + }, + { type: "'fork.error'", seq: "number", data: { forkId: "string", error: "string" }, - }) - .or({ + }, + { type: "'fork.aborted'", seq: "number", data: { forkId: "string" }, - }) - .or({ + }, + { type: /^custom\./, seq: "number", data: "Record", - }); + }, +); // The TypeScript type is defined manually rather than inferred from the // validator because the `custom.*` variant uses a regex pattern which // arktype infers as `string`. A bare `string` in the discriminant position @@ -1644,8 +1710,7 @@ export type InferenceEvent = | { type: "inference.usage"; seq: number; - // Locally patched — see vendor/intx-types/PATCHES.md#types-ts-usage-stop-reason - data: { usage: TokenUsage; source: LastCycleSource; stopReason?: string }; + data: { usage: TokenUsage; source: LastCycleSource }; } | { type: "inference.done"; @@ -2422,7 +2487,11 @@ export const InferenceSource = type({ id: "string", provider: "string", baseURL: "string", - apiKey: "string", + // Reference into the run's credential-material cell. The provider's secret + // (formerly an inline `apiKey`) is resolved from that cell by `credentialId` + // at call time, so the source config carries no secret and the child never + // holds the key inline. The same cell backs tool credentials. + credentialId: "string", model: "string", "defaults?": InferenceSourceDefaults, "capabilities?": "string[]", @@ -2449,7 +2518,7 @@ export function applyInferenceSourceFields( active.id = next.id; active.provider = next.provider; active.baseURL = next.baseURL; - active.apiKey = next.apiKey; + active.credentialId = next.credentialId; active.model = next.model; if (next.defaults !== undefined) { active.defaults = next.defaults; @@ -2475,7 +2544,7 @@ export function applyInferenceSourceFields( id: true, provider: true, baseURL: true, - apiKey: true, + credentialId: true, model: true, defaults: true, capabilities: true, @@ -2839,14 +2908,6 @@ export interface AuditStore { * and shutdown with all error records accumulated since the last flush. */ commitErrors(records: ErrorRecord[], signal?: AbortSignal): Promise; - - /** - * Load error records for a session. Returns all records matching - * the given sessionId, ordered by seq. - * - * Locally patched — see vendor/intx-types/PATCHES.md#runtime-ts-audit-store-load-errors - */ - loadErrors(sessionId: string, signal?: AbortSignal): Promise; } // --------------------------------------------------------------------------- diff --git a/vendor/intx-types/src/sessions.ts b/vendor/intx-types/src/sessions.ts index 1917f6789..8296b76ea 100644 --- a/vendor/intx-types/src/sessions.ts +++ b/vendor/intx-types/src/sessions.ts @@ -96,35 +96,37 @@ export type MailResponse = typeof MailResponse.infer; // needs to locate and explain the rejection, alongside a human-readable // `message`. This is the wire contract for the route's attachment 400s; the // route handler is the single producer. -export const AttachmentError = type({ - code: "'oversize_attachment'", - message: "string", - attachmentIndex: "number", - byteLength: "number", - limitBytes: "number", -}) - .or({ +export const AttachmentError = type.or( + { + code: "'oversize_attachment'", + message: "string", + attachmentIndex: "number", + byteLength: "number", + limitBytes: "number", + }, + { code: "'disallowed_mime_type'", message: "string", attachmentIndex: "number", mimeType: "string", - }) - .or({ + }, + { code: "'invalid_attachment_name'", message: "string", attachmentIndex: "number", - }) - .or({ + }, + { code: "'malformed_base64'", message: "string", attachmentIndex: "number", - }) - .or({ + }, + { code: "'oversize_total'", message: "string", totalBytes: "number", limitBytes: "number", - }); + }, +); export type AttachmentError = typeof AttachmentError.infer; export const AttachmentErrorResponse = type({ error: AttachmentError }); diff --git a/vendor/intx-types/src/sidecar-allocation.ts b/vendor/intx-types/src/sidecar-allocation.ts index ccbf59cf4..6a5a4cf7e 100644 --- a/vendor/intx-types/src/sidecar-allocation.ts +++ b/vendor/intx-types/src/sidecar-allocation.ts @@ -4,6 +4,7 @@ export const sidecarAllocationStatuses = [ "allocated", "replacing", "releasing", + "destroy_failed", "released", "failed", ] as const; @@ -21,6 +22,7 @@ export function isSidecarAllocationDispatchable( case "replacing": return true; case "releasing": + case "destroy_failed": case "released": case "failed": return false; diff --git a/vendor/intx-types/src/sidecar-capabilities.test.ts b/vendor/intx-types/src/sidecar-capabilities.test.ts new file mode 100644 index 000000000..a5c46e82b --- /dev/null +++ b/vendor/intx-types/src/sidecar-capabilities.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; +import { type } from "arktype"; + +import { + parseSidecarCapabilitySelector, + SidecarCapabilityDeclaration, + SidecarCapabilityRule, +} from "./sidecar-capabilities"; +import { UpdateTenant } from "./tenants"; + +describe("sidecar capabilities", () => { + test("accepts exact, namespace-prefix, and global selectors", () => { + expect(parseSidecarCapabilitySelector("runtime:browser")).toEqual({ + kind: "exact", + segments: ["runtime", "browser"], + }); + expect(parseSidecarCapabilitySelector("network:production:*")).toEqual({ + kind: "prefix", + segments: ["network", "production"], + }); + expect(parseSidecarCapabilitySelector("*")).toEqual({ + kind: "prefix", + segments: [], + }); + }); + + test("rejects malformed selectors", () => { + for (const capability of [ + "network:*:external", + "network:production*", + "network:**", + "runtime:", + "a::b", + ":*", + ]) { + expect( + SidecarCapabilityRule({ capability, effect: "block" }) instanceof + type.errors, + ).toBe(true); + expect( + SidecarCapabilityDeclaration({ + capability, + state: "blocked", + }) instanceof type.errors, + ).toBe(true); + } + }); + + test("rejects undeclared tenant policy fields", () => { + expect( + UpdateTenant({ + config: { + sidecarPlacement: { + capabilites: [{ capability: "network:outbound", effect: "block" }], + }, + }, + }) instanceof type.errors, + ).toBe(true); + expect( + UpdateTenant({ + config: { + sidecarPlacement: { + capabilities: [{ capability: "network:outbound", effect: "block" }], + }, + }, + }) instanceof type.errors, + ).toBe(false); + }); +}); diff --git a/vendor/intx-types/src/sidecar-capabilities.ts b/vendor/intx-types/src/sidecar-capabilities.ts new file mode 100644 index 000000000..d39196df0 --- /dev/null +++ b/vendor/intx-types/src/sidecar-capabilities.ts @@ -0,0 +1,61 @@ +import { type } from "arktype"; + +export type ParsedSidecarCapabilitySelector = { + readonly kind: "exact" | "prefix"; + readonly segments: readonly string[]; +}; + +export function parseSidecarCapabilitySelector( + value: string, +): ParsedSidecarCapabilitySelector | null { + if (value.length === 0) return null; + if (value === "*") return { kind: "prefix", segments: [] }; + if (!value.includes("*")) { + const segments = value.split(":"); + return segments.some((segment) => segment.length === 0) + ? null + : { kind: "exact", segments }; + } + + if (!value.endsWith(":*") || value.indexOf("*") !== value.length - 1) { + return null; + } + const segments = value.slice(0, -2).split(":"); + if (segments.some((segment) => segment.length === 0)) return null; + return { + kind: "prefix", + segments, + }; +} + +export const SidecarCapabilitySelector = type("string > 0").narrow( + (value, ctx) => + parseSidecarCapabilitySelector(value) !== null || + ctx.mustBe( + "an exact capability, a trailing namespace selector such as runtime:*, or *", + ), +); +export type SidecarCapabilitySelector = typeof SidecarCapabilitySelector.infer; + +export const SidecarCapabilityRule = type({ + capability: SidecarCapabilitySelector, + effect: "'require' | 'block'", +}); +export type SidecarCapabilityRule = typeof SidecarCapabilityRule.infer; + +export const SidecarCapabilityDeclaration = type({ + capability: SidecarCapabilitySelector, + state: "'available' | 'blocked'", +}); +export type SidecarCapabilityDeclaration = + typeof SidecarCapabilityDeclaration.infer; + +export const SidecarCapabilityPolicy = type({ + "capabilities?": SidecarCapabilityRule.array(), +}).onUndeclaredKey("reject"); +export type SidecarCapabilityPolicy = typeof SidecarCapabilityPolicy.infer; + +export type TenantSidecarCapabilityPolicy = { + readonly tenantId: string; + readonly rules: readonly SidecarCapabilityRule[]; +}; diff --git a/vendor/intx-types/src/sidecar-placement.ts b/vendor/intx-types/src/sidecar-placement.ts deleted file mode 100644 index 4f91c32cb..000000000 --- a/vendor/intx-types/src/sidecar-placement.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type } from "arktype"; - -/** - * Requires a workflow to use a sidecar that is not shared with unrelated - * workflows or ordinary work while its allocation is active. - */ -export const SidecarPlacementRequirement = type({ - sharing: "'exclusive'", - "reuse?": "'never' | 'same-deployment'", -}); -export type SidecarPlacementRequirement = - typeof SidecarPlacementRequirement.infer; diff --git a/vendor/intx-types/src/sidecar.test.ts b/vendor/intx-types/src/sidecar.test.ts index b3f41f0ce..6964ac33d 100644 --- a/vendor/intx-types/src/sidecar.test.ts +++ b/vendor/intx-types/src/sidecar.test.ts @@ -5,13 +5,47 @@ import { AgentDeployFrame, CredentialsUpdateFrame, DeployApplyErrorCategory, + HubFrame, + MAX_AGENT_ADDRESSES_FRAME, + MAX_CACHED_SENDER_ADDRESSES_FRAME, + MAX_CREDENTIAL_REVOCATIONS_FRAME, + MAX_MAIL_ADDRESSES_FRAME, + MAX_MAIL_OUTBOUND_BODY_BYTES, + MAX_PROBE_GRANTS_FRAME, + MAX_SIDECAR_FRAME_BYTES, + MailOutboundFrame, PackRejectFrame, PackRejectReason, + ReconnectFrame, + RegisterFrame, + RunGrantsFrame, + SenderKeyEvictFrame, SidecarFrame, SignalCorrelationRegisterFrame, SourcesUpdateFrame, + WorkflowProbeResultFrame, } from "./sidecar"; +describe("MailOutboundFrame sender ownership claim", () => { + const frame = { + type: "mail.outbound", + senderAddress: "run_sender@example.test", + rawMessage: "bWFpbA==", + recipients: ["recipient@example.test"], + }; + + test("accepts a frame with a sender address", () => { + expect(MailOutboundFrame(frame) instanceof type.errors).toBe(false); + expect(SidecarFrame(frame) instanceof type.errors).toBe(false); + }); + + test("rejects a frame with no sender address", () => { + const { senderAddress: _, ...missingSender } = frame; + expect(MailOutboundFrame(missingSender) instanceof type.errors).toBe(true); + expect(SidecarFrame(missingSender) instanceof type.errors).toBe(true); + }); +}); + describe("DeployApplyErrorCategory", () => { const allCategories = [ "tarball.missing", @@ -101,7 +135,7 @@ describe("AgentDeployFrame", () => { id: "src_default", provider: "openai", baseURL: "https://api.openai.test", - apiKey: "sk-test", + credentialId: "sk-test", model: "gpt-test", }, ], @@ -120,7 +154,7 @@ describe("AgentDeployFrame", () => { id: "src_step", provider: "openai", baseURL: "https://api.openai.test", - apiKey: "sk-step", + credentialId: "sk-step", model: "gpt-step", }; @@ -188,7 +222,7 @@ describe("SourcesUpdateFrame", () => { id: "src_a", provider: "openai", baseURL: "https://api.openai.test", - apiKey: "sk-a", + credentialId: "sk-a", model: "gpt-a", }; const base = { @@ -319,3 +353,401 @@ describe("SignalCorrelationRegisterFrame snapshot requirement", () => { expect(SidecarFrame(frame) instanceof type.errors).toBe(true); }); }); + +describe("CredentialsUpdateFrame revoke", () => { + // `revoke` is the whole point of removal-capable rotation. It must survive + // the wire-frame validation the hub applies on send (the HubFrame union) and + // the sidecar applies on receive (CredentialsUpdateFrame). An arktype narrow + // that dropped it would silently defeat every revoke while every unit test + // above the wire still passed. + const pureRevoke = { + type: "credentials.update", + requestId: "req_1", + agentAddress: "dep@integration.interchange", + delivery: { bindings: [], materials: [] }, + revoke: ["cred_x"], + }; + + test("the HubFrame union preserves a pure-revoke frame's revoke list", () => { + const out = HubFrame(pureRevoke); + if (out instanceof type.errors) { + throw new Error(`expected a valid HubFrame: ${out.summary}`); + } + if (out.type !== "credentials.update") { + throw new Error(`expected a credentials.update frame, got ${out.type}`); + } + expect(out.revoke).toEqual(["cred_x"]); + }); + + test("CredentialsUpdateFrame accepts an empty delivery paired with revoke", () => { + const out = CredentialsUpdateFrame(pureRevoke); + if (out instanceof type.errors) { + throw new Error(`expected a valid frame: ${out.summary}`); + } + expect(out.revoke).toEqual(["cred_x"]); + }); + + test("a frame with no revoke validates and omits the key", () => { + const out = CredentialsUpdateFrame({ + type: "credentials.update", + requestId: "req_1", + agentAddress: "dep@integration.interchange", + delivery: { bindings: [], materials: [] }, + }); + if (out instanceof type.errors) { + throw new Error(`expected a valid frame: ${out.summary}`); + } + expect("revoke" in out).toBe(false); + }); + + test("a non-string revoke entry is rejected", () => { + const bad = { ...pureRevoke, revoke: [123] }; + expect(CredentialsUpdateFrame(bad) instanceof type.errors).toBe(true); + }); +}); + +describe("RunGrantsFrame senderIdentities co-delivery", () => { + const base = { + type: "run.grants" as const, + agentAddress: "dep@integration.interchange", + runId: "run_1", + stepGrants: [], + }; + const identities = [ + { + address: "run_sender@integration.interchange", + publicKey: "aa".repeat(32), + }, + ]; + + test("the HubFrame union admits a run.grants frame carrying identities", () => { + // The sidecar parses inbound frames through the HubFrame union, so the + // co-delivered keys must reach the run.grants member and round-trip. + const out = HubFrame({ ...base, senderIdentities: identities }); + if (out instanceof type.errors) { + throw new Error(`expected a valid HubFrame: ${out.summary}`); + } + if (out.type !== "run.grants") { + throw new Error(`expected a run.grants frame, got ${out.type}`); + } + expect(out.senderIdentities).toEqual(identities); + }); + + test("the HubFrame union rejects a malformed identity entry", () => { + // arktype passes undeclared keys through unchanged, so a valid-input + // round-trip alone cannot prove the field is declared on the wire path: + // it would survive even if senderIdentities were dropped from the schema. + // A malformed entry rejected THROUGH the union is the real guard -- were + // the field undeclared, the bad entry would ride the union as a harmless + // passthrough key and this parse would succeed, silently starving the + // recipient's key cache. + const bad = { + ...base, + senderIdentities: [{ address: "run_sender@integration.interchange" }], + }; + expect(HubFrame(bad) instanceof type.errors).toBe(true); + }); + + test("a frame with no senderIdentities validates and omits the key", () => { + const out = RunGrantsFrame(base); + if (out instanceof type.errors) { + throw new Error(`expected a valid frame: ${out.summary}`); + } + expect("senderIdentities" in out).toBe(false); + }); + + test("an identity entry missing its public key is rejected", () => { + const bad = { + ...base, + senderIdentities: [{ address: "run_sender@integration.interchange" }], + }; + expect(RunGrantsFrame(bad) instanceof type.errors).toBe(true); + }); + + test("an identity entry with a non-string public key is rejected", () => { + const bad = { + ...base, + senderIdentities: [ + { address: "run_sender@integration.interchange", publicKey: 123 }, + ], + }; + expect(RunGrantsFrame(bad) instanceof type.errors).toBe(true); + }); + + test("an identity entry missing its address is rejected", () => { + const bad = { ...base, senderIdentities: [{ publicKey: "aa".repeat(32) }] }; + expect(RunGrantsFrame(bad) instanceof type.errors).toBe(true); + }); +}); + +describe("frame array-length ceilings", () => { + const addresses = (n: number) => + Array.from({ length: n }, (_, i) => `addr-${String(i)}@example.test`); + + describe("RegisterFrame agentAddresses", () => { + const base = { type: "register", sidecarId: "sc-1", token: "tok" }; + + test("accepts a frame at the ceiling", () => { + const frame = { + ...base, + agentAddresses: addresses(MAX_AGENT_ADDRESSES_FRAME), + }; + expect(RegisterFrame(frame) instanceof type.errors).toBe(false); + expect(SidecarFrame(frame) instanceof type.errors).toBe(false); + }); + + test("rejects a frame past the ceiling through the union", () => { + const frame = { + ...base, + agentAddresses: addresses(MAX_AGENT_ADDRESSES_FRAME + 1), + }; + expect(RegisterFrame(frame) instanceof type.errors).toBe(true); + expect(SidecarFrame(frame) instanceof type.errors).toBe(true); + }); + }); + + describe("RegisterFrame cachedSenderAddresses", () => { + const base = { + type: "register", + sidecarId: "sc-1", + token: "tok", + agentAddresses: ["wf@example.test"], + }; + + test("accepts a count above the resync handler cap but within the ceiling", () => { + // The ceiling sits far above the hub-sessions `MAX_RESYNC_SENDER_ADDRESSES` + // handler cap (2048) so a report over that cap still parses and reaches the + // handler's graceful "resync the first N, log the overflow" degrade rather + // than dropping the whole register frame and stalling the reconnect. + const frame = { ...base, cachedSenderAddresses: addresses(2049) }; + expect(RegisterFrame(frame) instanceof type.errors).toBe(false); + expect(SidecarFrame(frame) instanceof type.errors).toBe(false); + }); + + test("rejects a report past the ceiling through the union", () => { + const frame = { + ...base, + cachedSenderAddresses: addresses(MAX_CACHED_SENDER_ADDRESSES_FRAME + 1), + }; + expect(RegisterFrame(frame) instanceof type.errors).toBe(true); + expect(SidecarFrame(frame) instanceof type.errors).toBe(true); + }); + }); + + describe("MailOutboundFrame recipients", () => { + const base = { + type: "mail.outbound", + senderAddress: "sender@example.test", + rawMessage: "bWFpbA==", + }; + + test("accepts a frame at the ceiling", () => { + const frame = { + ...base, + recipients: addresses(MAX_MAIL_ADDRESSES_FRAME), + }; + expect(MailOutboundFrame(frame) instanceof type.errors).toBe(false); + }); + + test("rejects recipients past the ceiling through the union", () => { + const frame = { + ...base, + recipients: addresses(MAX_MAIL_ADDRESSES_FRAME + 1), + }; + expect(MailOutboundFrame(frame) instanceof type.errors).toBe(true); + expect(SidecarFrame(frame) instanceof type.errors).toBe(true); + }); + + test("rejects a cc list past the ceiling", () => { + const frame = { + ...base, + recipients: ["recipient@example.test"], + cc: addresses(MAX_MAIL_ADDRESSES_FRAME + 1), + }; + expect(MailOutboundFrame(frame) instanceof type.errors).toBe(true); + }); + }); + + describe("WorkflowProbeResultFrame grants", () => { + const projection = { + id: "wf-probe", + triggers: [], + stepOrder: ["s1"], + steps: { s1: { kind: "step", id: "s1" } }, + }; + const grantWalkSnapshot = { + perStep: [{ stepId: "s1", grants: [], grantEffects: {} }], + grantRequirements: [], + }; + const base = { + type: "workflow.probe.result", + requestId: "req_1", + projection, + grantWalkSnapshot, + wireHash: "abc123", + }; + + test("accepts a frame at the ceiling", () => { + const frame = { + ...base, + grants: Array.from( + { length: MAX_PROBE_GRANTS_FRAME }, + (_, i) => `grant-${String(i)}`, + ), + }; + expect(WorkflowProbeResultFrame(frame) instanceof type.errors).toBe( + false, + ); + }); + + test("rejects grants past the ceiling through the union", () => { + const frame = { + ...base, + grants: Array.from( + { length: MAX_PROBE_GRANTS_FRAME + 1 }, + (_, i) => `grant-${String(i)}`, + ), + }; + expect(WorkflowProbeResultFrame(frame) instanceof type.errors).toBe(true); + expect(SidecarFrame(frame) instanceof type.errors).toBe(true); + }); + }); + + // The bounded optional fields moved from the `"string[]"` DSL to a chained + // `type("string").array().atMostLength(n)` value under a `"key?"` key. The + // regression that mechanical change risks is losing optionality (the key + // becomes required) or gaining a lower bound (an empty array is rejected). + describe("bounded optional fields stay optional", () => { + test("RegisterFrame validates with cachedSenderAddresses omitted or empty", () => { + const base = { + type: "register", + sidecarId: "sc-1", + token: "tok", + agentAddresses: ["wf@example.test"], + }; + expect(RegisterFrame(base) instanceof type.errors).toBe(false); + expect( + RegisterFrame({ ...base, cachedSenderAddresses: [] }) instanceof + type.errors, + ).toBe(false); + }); + + test("ReconnectFrame validates with cachedSenderAddresses omitted or empty", () => { + const base = { + type: "reconnect", + sidecarId: "sc-1", + token: "tok", + agentAddresses: ["wf@example.test"], + }; + expect(ReconnectFrame(base) instanceof type.errors).toBe(false); + expect( + ReconnectFrame({ ...base, cachedSenderAddresses: [] }) instanceof + type.errors, + ).toBe(false); + }); + + test("MailOutboundFrame validates with empty to and cc lists", () => { + const frame = { + type: "mail.outbound", + senderAddress: "sender@example.test", + rawMessage: "bWFpbA==", + recipients: ["recipient@example.test"], + to: [], + cc: [], + }; + expect(MailOutboundFrame(frame) instanceof type.errors).toBe(false); + }); + + test("CredentialsUpdateFrame validates with an empty revoke list", () => { + const frame = { + type: "credentials.update", + requestId: "req_1", + agentAddress: "dep@example.test", + delivery: { bindings: [], materials: [] }, + revoke: [], + }; + expect(CredentialsUpdateFrame(frame) instanceof type.errors).toBe(false); + }); + }); + + describe("CredentialsUpdateFrame revoke", () => { + const base = { + type: "credentials.update", + requestId: "req_1", + agentAddress: "dep@example.test", + delivery: { bindings: [], materials: [] }, + }; + + test("accepts a revoke list at the ceiling", () => { + const frame = { + ...base, + revoke: Array.from( + { length: MAX_CREDENTIAL_REVOCATIONS_FRAME }, + (_, i) => `cred-${String(i)}`, + ), + }; + expect(CredentialsUpdateFrame(frame) instanceof type.errors).toBe(false); + }); + + test("rejects a revoke list past the ceiling through the union", () => { + const frame = { + ...base, + revoke: Array.from( + { length: MAX_CREDENTIAL_REVOCATIONS_FRAME + 1 }, + (_, i) => `cred-${String(i)}`, + ), + }; + expect(CredentialsUpdateFrame(frame) instanceof type.errors).toBe(true); + expect(HubFrame(frame) instanceof type.errors).toBe(true); + }); + }); +}); + +describe("frame payload byte limits", () => { + test("the sidecar frame ceiling stays above the mail body cap", () => { + // maxPayloadLength must clear the largest legit received frame -- a + // mail.outbound whose rawMessage sits at the body cap, plus framing + // overhead -- or Bun would close the sidecar's control socket on a + // legitimate max-size mail. This pins that ordering, which the whole + // payload-limit design depends on. + expect(MAX_SIDECAR_FRAME_BYTES).toBeGreaterThan( + MAX_MAIL_OUTBOUND_BODY_BYTES, + ); + }); +}); + +describe("SenderKeyEvictFrame", () => { + const frame = { + type: "sender.key.evict", + address: "usr_deleted@tenant.test", + }; + + test("the HubFrame union admits an evict frame and round-trips it", () => { + // The sidecar parses inbound frames through the HubFrame union, so the + // evict must reach its member and keep its address. + const out = HubFrame(frame); + if (out instanceof type.errors) { + throw new Error(`expected a valid HubFrame: ${out.summary}`); + } + if (out.type !== "sender.key.evict") { + throw new Error(`expected a sender.key.evict frame, got ${out.type}`); + } + expect(out.address).toBe("usr_deleted@tenant.test"); + }); + + test("carries no publicKey (it is not a refresh)", () => { + // The evict frame is deliberately keyless; a stray publicKey is an + // undeclared key arktype passes through, so assert the parsed frame's shape + // holds only the address. + const out = SenderKeyEvictFrame(frame); + if (out instanceof type.errors) { + throw new Error(`expected a valid frame: ${out.summary}`); + } + expect("publicKey" in out).toBe(false); + }); + + test("rejects a frame with no address", () => { + const out = SenderKeyEvictFrame({ type: "sender.key.evict" }); + expect(out instanceof type.errors).toBe(true); + }); +}); diff --git a/vendor/intx-types/src/sidecar.ts b/vendor/intx-types/src/sidecar.ts index 5eca340eb..ae0180a60 100644 --- a/vendor/intx-types/src/sidecar.ts +++ b/vendor/intx-types/src/sidecar.ts @@ -21,6 +21,89 @@ import { SignalKind } from "./signals"; import { ToolPackageManifest } from "./tool-packages"; import { WorkflowDefinitionSource } from "./workflow-sources"; +// --------------------------------------------------------------------------- +// Frame array-length ceilings +// --------------------------------------------------------------------------- +// +// Hostile-absurdity upper bounds on the unbounded `string[]` fields of the wire +// frames below. They bound element COUNT, not byte size: a peer that sends a +// `string[]` of millions of tiny elements costs little in bytes but forces the +// receiver to allocate, iterate, dedup, or map over an absurd count. A total +// payload byte limit is the weakest defense exactly here -- many one-character +// elements are a huge count at a small byte cost -- so element-count caps are +// the right tool for `string[]`. The object-typed frame arrays are out of scope +// for these caps: their elements each carry many bytes, so an absurd count of +// them is far costlier on the wire, and a payload-size limit is the right +// backstop for that byte-heavy dimension. An over-count frame fails this parse +// and routes through the existing invalid-frame drop+log path; no handler change +// is needed. + +// A sidecar's reported agent addresses. The register/reconnect handler already +// gates each reported address against the allocation's single minted workflow +// address, so the legitimate count is ~1; this is a generous absurdity backstop. +export const MAX_AGENT_ADDRESSES_FRAME = 512; + +// A sidecar's reported cached sender addresses. This MUST stay well above the +// `MAX_RESYNC_SENDER_ADDRESSES` handler cap (currently 2048 in the hub-sessions +// sidecar-handler): that cap drives a graceful "resync the first N, log the +// overflow" degrade rather than dropping the frame, so a schema ceiling at or +// below it would turn the degrade into a hard reconnect outage -- the whole +// register frame would fail this parse and drop, and the sidecar could not +// reconnect. The `@intx/types` package must not import from `@intx/hub-sessions`, +// so the coupling is a documented invariant guarded by a test in that package. +export const MAX_CACHED_SENDER_ADDRESSES_FRAME = 65536; + +// A mail frame's recipient / To / Cc address lists. `recipients` is the routing +// set; `to`/`cc` are audit-only header metadata. A modest ceiling far above any +// real recipient list. +export const MAX_MAIL_ADDRESSES_FRAME = 1024; + +// A workflow probe result's flattened grant strings (the deduped union of every +// step's grants). No enforced workflow step-count or per-step grant-count cap +// exists to derive this from, so it is a reasonable absurdity ceiling rather +// than a computed bound. +export const MAX_PROBE_GRANTS_FRAME = 8192; + +// A credentials-update frame's revoked credential ids. A modest ceiling far +// above any real credential set. +export const MAX_CREDENTIAL_REVOCATIONS_FRAME = 1024; + +// --------------------------------------------------------------------------- +// Frame payload byte limits +// --------------------------------------------------------------------------- +// +// Byte-size ceilings on the control socket, complementary to the element-count +// ceilings above. One layer owns each dimension: the hub sidecar websocket's +// maxPayloadLength owns the whole-frame byte size, and the mail body cap owns +// one mail's rawMessage. + +// The largest rawMessage (base64-encoded MIME) a `mail.outbound` frame may +// carry. A shared-policy ceiling: it holds the SAME number as `@intx/hub-api`'s +// `MAX_MAIL_BODY_BYTES`, which caps the inbound HTTP mail route's whole request +// body, so the frame path and the HTTP path enforce the same body ceiling. The +// two measure different quantities -- an HTTP whole request body vs the frame's +// rawMessage alone -- so they are deliberately separate constants held equal by +// a guard test rather than one constant conflating two policies. +// Enforced symmetrically: the hub drops an over-cap received frame (the DoS +// backstop) and the sidecar refuses to send one. +export const MAX_MAIL_OUTBOUND_BODY_BYTES = 44 * 1024 * 1024; + +// Headroom above the largest legit received frame for its base64/JSON framing +// and its (separately count-capped) address arrays, so `maxPayloadLength` never +// closes the socket on a legitimate mail frame whose rawMessage sits at the body +// cap. +const FRAME_OVERHEAD_BYTES = 20 * 1024 * 1024; + +// The ceiling wired as the hub sidecar websocket's `maxPayloadLength`. Bun +// closes the connection on a RECEIVED message larger than this, so it must clear +// the largest legit received frame -- the `mail.outbound` frame, whose +// rawMessage is bounded by `MAX_MAIL_OUTBOUND_BODY_BYTES`, plus framing +// overhead. maxPayloadLength gates incoming messages only; it does NOT limit +// what the hub sends, so the hub->sidecar inline-asset deploy does not factor +// into this number. +export const MAX_SIDECAR_FRAME_BYTES = + MAX_MAIL_OUTBOUND_BODY_BYTES + FRAME_OVERHEAD_BYTES; + // --------------------------------------------------------------------------- // Sidecar → Hub // --------------------------------------------------------------------------- @@ -34,40 +117,48 @@ export const RegisterFrame = type({ type: "'register'", sidecarId: "string", token: "string", - agentAddresses: "string[]", + agentAddresses: type("string") + .array() + .atMostLength(MAX_AGENT_ADDRESSES_FRAME), + // The rotatable (non-run) sender addresses this sidecar holds cached keys + // for. The hub re-resolves each current key and re-pushes it on a + // `sender.key.refresh`, so a user-principal rotation that landed while the + // sidecar was disconnected reaches its cache. Additive-optional and omitted + // when empty: a sidecar with no cached senders (or a pre-upgrade one) sends + // no field, and the hub treats absence as "nothing to refresh". + "cachedSenderAddresses?": type("string") + .array() + .atMostLength(MAX_CACHED_SENDER_ADDRESSES_FRAME), }); export type RegisterFrame = typeof RegisterFrame.infer; /** - * Sent on connect when the sidecar has agent repositories or deployments - * from a previous run. Lists the addresses it can serve, triggering the - * challenge/response ownership-verification flow for every one of them -- - * launched agents and workflow deployments alike, so both are proven, not - * routed on trust. + * Sent on connect after a provisioned sidecar restores its deployment. + * The bearer token binds the connection to one allocation generation, so the + * Hub accepts only that allocation's workflow address. */ export const ReconnectFrame = type({ type: "'reconnect'", sidecarId: "string", token: "string", - agentAddresses: "string[]", - "deployRefs?": "Record", + agentAddresses: type("string") + .array() + .atMostLength(MAX_AGENT_ADDRESSES_FRAME), + // The rotatable (non-run) sender addresses this sidecar holds cached keys + // for; see `RegisterFrame`. Carried on both frames because the register vs + // reconnect choice turns on workflow-address presence, not sender-cache + // presence -- a sidecar that restored no workflow substrate still reports its + // cached senders on a register frame. Additive-optional, omitted when empty. + "cachedSenderAddresses?": type("string") + .array() + .atMostLength(MAX_CACHED_SENDER_ADDRESSES_FRAME), }); export type ReconnectFrame = typeof ReconnectFrame.infer; -/** - * Response to a challenge frame. Contains a signature per run address - * proving the sidecar holds the private key. Each signature is computed - * over `nonce || utf8(agentAddress)`. - */ -export const ChallengeResponseFrame = type({ - type: "'challenge.response'", - responses: type({ address: "string", signature: "string" }).array(), -}); -export type ChallengeResponseFrame = typeof ChallengeResponseFrame.infer; - /** * Acknowledges a successful agent deployment. Includes the agent's Ed25519 - * public key (hex-encoded) so the hub can verify ownership on reconnect. + * public key (hex-encoded) for published identity and content provenance. + * Reconnect authority comes from the allocation credential. */ export const AgentDeployAckFrame = type({ type: "'agent.deploy.ack'", @@ -98,12 +189,12 @@ export type AgentErrorFrame = typeof AgentErrorFrame.infer; export const MailOutboundFrame = type({ type: "'mail.outbound'", rawMessage: "string", - recipients: "string[]", - "senderAddress?": "string", + recipients: type("string").array().atMostLength(MAX_MAIL_ADDRESSES_FRAME), + senderAddress: "string", "sessionId?": "string", "messageId?": "string", - "to?": "string[]", - "cc?": "string[]", + "to?": type("string").array().atMostLength(MAX_MAIL_ADDRESSES_FRAME), + "cc?": type("string").array().atMostLength(MAX_MAIL_ADDRESSES_FRAME), "delivered?": "boolean", }); export type MailOutboundFrame = typeof MailOutboundFrame.infer; @@ -241,11 +332,23 @@ export type SignalCorrelationRegisterAckFrame = * makes at-least-once effectively-once. Present only on hub-originated mail * that participates in the ack/retry handshake (workflow trigger mail, session * conversation mail); agent-to-agent relayed mail omits it. + * + * `authenticatedSender` is the hub-verified sender ADDRESS of this message. + * The hub assigns it at the frame's construction site from a value it has + * itself verified -- the ownership-gated sender of a relayed mail, the + * address persisted at enqueue for a durable dispatch, or the triggering + * principal's address for hub-originated mail -- NEVER from the message's + * own (spoofable) MIME `From`. The recipient's signature check takes the + * sender of record from this hub-verified value rather than the forgeable + * `From`, resolves the sender's key from its local cache, verifies the + * signature, and gates delivery on the resulting admission outcome per the + * recipient's inbound-mail policy. */ export const MailInboundFrame = type({ type: "'mail.inbound'", agentAddress: "string", rawMessage: "string", + authenticatedSender: "string", "messageId?": "string", }); export type MailInboundFrame = typeof MailInboundFrame.infer; @@ -294,6 +397,17 @@ export const SignalDeliverFrame = type({ }); export type SignalDeliverFrame = typeof SignalDeliverFrame.infer; +/** + * A sender address bound to the public key the hub vouches for. `publicKey` + * is the hex-encoded raw 32-byte Ed25519 key. `address` is the full + * domain-qualified sender address. + */ +export const SenderIdentity = type({ + address: "string", + publicKey: "string", +}); +export type SenderIdentity = typeof SenderIdentity.infer; + /** * Deliver a run's authorization grants to a multi-step deployment's * supervisor. The hub forwards the frame to the sidecar that hosts the @@ -307,15 +421,71 @@ export type SignalDeliverFrame = typeof SignalDeliverFrame.infer; * frame's `config.grants` ships, so the run's grants ride the same * validated grant encoding as the deploy-time step grants rather than a * new one. + * + * `senderIdentities` carries the resolved public keys of the run's + * authorized senders, co-delivered on the same `run.grants` barrier as the + * authorization grant so a recipient can bind each sender address to the + * key the hub vouches for. A sender with no resolvable key is omitted rather + * than carried as null, so every entry has a concrete key. The field is + * optional: a producer that does not co-deliver keys omits it entirely. */ export const RunGrantsFrame = type({ type: "'run.grants'", agentAddress: "string", runId: "string", stepGrants: WireGrantRule.array(), + "senderIdentities?": SenderIdentity.array(), }); export type RunGrantsFrame = typeof RunGrantsFrame.infer; +/** + * Re-push the current public key the hub vouches for a cached sender, keyed by + * the sender's `address`. `publicKey` is the hex-encoded raw 32-byte Ed25519 + * key, exactly as `SenderIdentity` carries it. The sidecar overwrites its cached + * key for `address` and touches nothing else -- no grants, no per-run state. + * + * The hub sends one per rotatable sender the sidecar reported on (re)connect, + * after re-resolving the sender's current key: a user-principal rotation that + * happened while the sidecar was disconnected lands on the sidecar this way. + * + * It is a dedicated frame rather than a reuse of two shapes it resembles. + * Not `SenderIdentity` (whose shape it currently matches): that type is a fact + * embedded in `run.grants`, so composing it would couple this command's wire + * contract to a grants-owned type. Not `run.grants`: a rotated key is + * address-keyed and cross-run, whereas grants are run-keyed, and routing this + * through the grants barrier would poison a healthy idle run on a transient + * cache-write fault and do a per-run durable write for a change that alters no + * grants. One address per frame keeps each key's cache write independently + * fallible -- a fault on one sender never fails the refresh of another -- which + * is the property a batched frame would give up. + */ +export const SenderKeyRefreshFrame = type({ + type: "'sender.key.refresh'", + address: "string", + publicKey: "string", +}); +export type SenderKeyRefreshFrame = typeof SenderKeyRefreshFrame.infer; + +/** + * Evict a cached sender key, keyed by the sender's `address`. The sidecar + * durably removes its cached key for `address` and touches nothing else. The + * hub sends it during reconnect reconciliation for a reported cached sender it + * re-resolves to NO durable key -- a sender whose principal was deleted while + * the sidecar was disconnected -- so the sidecar stops verifying that sender's + * mail against a key the hub no longer vouches for. + * + * A dedicated sibling of `sender.key.refresh` rather than a mode on it: that + * frame's doc argues against a mode-dependent shape, and a refresh always + * carries a key whereas an evict never does, so a shared frame would make + * `publicKey` conditionally present. One address per frame keeps each eviction + * independently fallible, the same property the refresh frame preserves. + */ +export const SenderKeyEvictFrame = type({ + type: "'sender.key.evict'", + address: "string", +}); +export type SenderKeyEvictFrame = typeof SenderKeyEvictFrame.infer; + /** * Deliver a workflow-host drain control payload to a multi-step * deployment's supervisor. The hub forwards the frame to the sidecar @@ -411,9 +581,9 @@ export type SourceRefPin = typeof SourceRefPin.infer; /** * The frozen, fully-serializable record of a code-sourced workflow approval, * persisted at prepare time and rehydrated to deploy the exact same definition - * later. It is the recovery input for an exclusively-placed workflow: the probe - * runs once on shared capacity at request time, its result is frozen here, and a - * ready allocation deploys THIS bundle verbatim with no re-probe. + * later. It is the recovery input for a provisioned workflow: the probe runs + * once on probe-scoped capacity, its result is frozen here, and a ready + * allocation deploys THIS bundle verbatim with no re-probe. * * Every field is inert, secret-free data. `source`/`entry` name where the * definition's bytes come from and the entry module the probe evaluated; @@ -481,12 +651,13 @@ export const AgentDeployWorkflow = type({ "approvedWireHash?": "string > 0", // Extracted trigger bodies -- onTrigger sections and childWorkflow children, // lifted transitively. Each entry carries the body's inert definition, its own - // per-step inference-source pins, and its approved wire hash. The sidecar - // stages each body's `sources.json` so a body child -- in-process, its env - // lost across a restart -- resolves inference durably; the body definition - // itself is resolved in-memory from the parent's re-verified closure. - // Optional: only a deploy that carries an inline onTrigger section or - // childWorkflow child populates it. + // per-step inference-source pins, and its approved wire hash. The sidecar seals + // each body's sources into the per-run record and delivers the plaintext to the + // run child through the spawn env, so a body child -- in-process, its env lost + // across a restart -- resolves inference durably without holding the cipher + // key; the body definition itself is resolved in-memory from the parent's + // re-verified closure. Optional: only a deploy that carries an inline onTrigger + // section or childWorkflow child populates it. "referencedDefinitions?": WorkflowProjectionWithSources.array(), // Initial credential material for the deployment's tools, decrypted hub-side // and delivered on the deploy frame so it is resident before any step runs @@ -546,27 +717,6 @@ export const AgentUndeployFrame = type({ }); export type AgentUndeployFrame = typeof AgentUndeployFrame.infer; -/** - * Per-address cryptographic challenge. The sidecar must sign - * `nonce || utf8(address)` with each agent's private key and respond - * with a challenge.response frame. - */ -export const ChallengeFrame = type({ - type: "'challenge'", - challenges: type({ address: "string", nonce: "string" }).array(), -}); -export type ChallengeFrame = typeof ChallengeFrame.infer; - -/** - * Sent when challenge verification fails for a specific address. - */ -export const ChallengeFailedFrame = type({ - type: "'challenge.failed'", - address: "string", - reason: "string", -}); -export type ChallengeFailedFrame = typeof ChallengeFailedFrame.infer; - /** * Keepalive pong sent by the hub in response to a ping frame. * If the sidecar stops receiving pongs, it considers the hub dead. @@ -593,16 +743,24 @@ export const SourcesUpdateFrame = type({ export type SourcesUpdateFrame = typeof SourcesUpdateFrame.infer; /** - * Push refreshed credential material to a running deployment (a rotation, or a - * revocation delivered by omitting the revoked credential's material so the - * child evicts it). Mirrors `SourcesUpdateFrame`: the sidecar routes it to the - * deployment's supervisor, which forwards it to the child's in-memory cell. + * Push refreshed credential material to a running deployment. Mirrors + * `SourcesUpdateFrame`: the sidecar routes it to the deployment's supervisor, + * which forwards it to the child's in-memory cell. The child MERGES `delivery` + * (materials upsert by credentialId, bindings by consumer-and-handle) and drops + * each credentialId in `revoke` plus any binding referencing it. Removal is + * explicit through `revoke` -- omitting a material does not evict it, because + * the cell has several independently-scoped producers and a wholesale swap + * would let one evict another's credentials. A pure revocation carries an empty + * `delivery` and the revoked ids in `revoke`. */ export const CredentialsUpdateFrame = type({ type: "'credentials.update'", requestId: "string", agentAddress: "string", delivery: CredentialDelivery, + "revoke?": type("string") + .array() + .atMostLength(MAX_CREDENTIAL_REVOCATIONS_FRAME), }); export type CredentialsUpdateFrame = typeof CredentialsUpdateFrame.infer; @@ -949,7 +1107,7 @@ export const WorkflowProbeResultFrame = type({ type: "'workflow.probe.result'", requestId: "string", projection: WorkflowProjectionDefinition, - grants: "string[]", + grants: type("string").array().atMostLength(MAX_PROBE_GRANTS_FRAME), grantWalkSnapshot: GrantWalkSnapshot, wireHash: "string", }); @@ -972,45 +1130,50 @@ export type WorkflowProbeErrorFrame = typeof WorkflowProbeErrorFrame.infer; // --------------------------------------------------------------------------- /** All frame types the sidecar sends to the hub. */ -export const SidecarFrame = RegisterFrame.or(ReconnectFrame) - .or(ChallengeResponseFrame) - .or(AgentDeployAckFrame) - .or(AgentErrorFrame) - .or(MailOutboundFrame) - .or(AgentEventFrame) - .or(ConnectorStateChangedFrame) - .or(PingFrame) - .or(SessionAckFrame) - .or(SessionErrorFrame) - .or(AgentUndeployAckFrame) - .or(SignalCorrelationRegisterFrame) - .or(PackPushFrame) - .or(PackDoneFrame) - .or(PackAckFrame) - .or(PackRejectFrame) - .or(MailInboundAckFrame) - .or(WorkflowProbeResultFrame) - .or(WorkflowProbeErrorFrame); +export const SidecarFrame = type.or( + RegisterFrame, + ReconnectFrame, + AgentDeployAckFrame, + AgentErrorFrame, + MailOutboundFrame, + AgentEventFrame, + ConnectorStateChangedFrame, + PingFrame, + SessionAckFrame, + SessionErrorFrame, + AgentUndeployAckFrame, + SignalCorrelationRegisterFrame, + PackPushFrame, + PackDoneFrame, + PackAckFrame, + PackRejectFrame, + MailInboundAckFrame, + WorkflowProbeResultFrame, + WorkflowProbeErrorFrame, +); export type SidecarFrame = typeof SidecarFrame.infer; /** All frame types the hub sends to the sidecar. */ -export const HubFrame = MailInboundFrame.or(AgentDeployFrame) - .or(AgentUndeployFrame) - .or(ChallengeFrame) - .or(ChallengeFailedFrame) - .or(PongFrame) - .or(SourcesUpdateFrame) - .or(CredentialsUpdateFrame) - .or(PackPushFrame) - .or(PackDoneFrame) - .or(PackAckFrame) - .or(PackRejectFrame) - .or(SyncRequestFrame) - .or(SignalDeliverFrame) - .or(RunGrantsFrame) - .or(SignalCorrelationRegisterAckFrame) - .or(DrainDeliverFrame) - .or(WorkflowProbeRequestFrame); +export const HubFrame = type.or( + MailInboundFrame, + AgentDeployFrame, + AgentUndeployFrame, + PongFrame, + SourcesUpdateFrame, + CredentialsUpdateFrame, + PackPushFrame, + PackDoneFrame, + PackAckFrame, + PackRejectFrame, + SyncRequestFrame, + SignalDeliverFrame, + RunGrantsFrame, + SenderKeyRefreshFrame, + SenderKeyEvictFrame, + SignalCorrelationRegisterAckFrame, + DrainDeliverFrame, + WorkflowProbeRequestFrame, +); export type HubFrame = typeof HubFrame.infer; /** Any frame on the wire, regardless of direction. */ diff --git a/vendor/intx-types/src/signer-identity.ts b/vendor/intx-types/src/signer-identity.ts new file mode 100644 index 000000000..3737d06e0 --- /dev/null +++ b/vendor/intx-types/src/signer-identity.ts @@ -0,0 +1,33 @@ +// How the signer behind a signature is identified. +// +// The only signer today is a principal whose Ed25519 private key the hub +// custodies (`local-principal`). The union is keyed on `kind` so a future +// signer flavour (say a hub-held key, or an externally-held key) is added with +// `.or()` and every by-value consumer that switches on `kind` gains a compile +// error for the unhandled variant. + +import { type } from "arktype"; + +/** + * A signer whose private key the hub custodies on a principal's behalf. + * + * `publicKey` is the RESOLVED, hex-encoded Ed25519 public key read from the + * hub's own principal-key store -- it is the trusted key for `principalId`. It + * MUST NOT be populated from untrusted input (e.g. a public key claimed on an + * inbound message): a verifier resolves the key from the store by `principalId` + * and checks the signature against that, never against a key from the wire. + */ +export const LocalPrincipalSigner = type({ + kind: "'local-principal'", + principalId: "string", + publicKey: "string", +}); +export type LocalPrincipalSigner = typeof LocalPrincipalSigner.infer; + +/** + * Discriminated union over how a signature's signer is identified, keyed on + * `kind`. Only the hub-custodied `local-principal` signer exists today; widen + * it here with `.or()` and every by-value consumer follows. + */ +export const SignerIdentity = LocalPrincipalSigner; +export type SignerIdentity = typeof SignerIdentity.infer; diff --git a/vendor/intx-types/src/tenants.ts b/vendor/intx-types/src/tenants.ts index 537070dfc..bf1ffa783 100644 --- a/vendor/intx-types/src/tenants.ts +++ b/vendor/intx-types/src/tenants.ts @@ -1,10 +1,10 @@ import { type } from "arktype"; -import { SidecarPlacementRequirement } from "./sidecar-placement"; +import { SidecarCapabilityPolicy } from "./sidecar-capabilities"; export const TenantConfig = type({ + "sidecarPlacement?": SidecarCapabilityPolicy, "[string]": "unknown", - "sidecarPlacement?": SidecarPlacementRequirement, }); export type TenantConfig = typeof TenantConfig.infer; diff --git a/vendor/intx-types/src/wire-workflow.ts b/vendor/intx-types/src/wire-workflow.ts index f52d9779a..015cca33f 100644 --- a/vendor/intx-types/src/wire-workflow.ts +++ b/vendor/intx-types/src/wire-workflow.ts @@ -7,7 +7,8 @@ import { type } from "arktype"; import { CredentialBinding } from "./credentials"; -import { InferenceSource } from "./runtime"; +import { InboundMailPolicy, InferenceSource } from "./runtime"; +import { SidecarCapabilityPolicy } from "./sidecar-capabilities"; /** * Fields every wire step carries regardless of `kind`. All other keys pass @@ -112,6 +113,15 @@ export const WorkflowProjectionDefinition = type({ // credential request surface (no secret material), so they belong in the // hashed projection. "credentialBindings?": CredentialBinding.array(), + "sidecarPlacement?": SidecarCapabilityPolicy, + // The author-declared inbound-mail admission policy, projected verbatim by + // the live->inert projector. This MUST stay in sync with that projector: the + // `"+": "delete"` below strips any undeclared key, so a policy the projector + // emits but this schema omits would be silently stripped at the wire + // boundary and never reach the sidecar. The policy is part of the hashed + // surface, so a stripped policy would also desync the sidecar's re-verify + // from the hub-approved hash. + "inboundMailPolicy?": InboundMailPolicy, "+": "delete", }).narrow((value, ctx) => { // Every `stepOrder` entry must name a defined step. A legitimately projected diff --git a/vendor/intx-types/src/workflows.ts b/vendor/intx-types/src/workflows.ts index 7677f9ccf..014ed7d66 100644 --- a/vendor/intx-types/src/workflows.ts +++ b/vendor/intx-types/src/workflows.ts @@ -46,3 +46,26 @@ export const WorkflowDefinitionResponse = type({ export const WorkflowRollbackRequest = type({ version: "string", }); + +export const WorkflowDeploymentStatus = type.enumerated( + "deployed", + "pending", + "recovering", + "releasing", + "released", + "failed", + "destroy_failed", +); +export type WorkflowDeploymentStatus = typeof WorkflowDeploymentStatus.infer; + +export const WorkflowDeploymentResponse = type({ + id: "string", + tenantId: "string", + definitionAssetId: "string", + status: WorkflowDeploymentStatus.describe( + "Deployment lifecycle status. `failed` is a terminal failure with no infrastructure. `destroy_failed` is a permanent cleanup failure where infrastructure may remain and require operator cleanup.", + ), + createdAt: "string", +}); +export type WorkflowDeploymentResponse = + typeof WorkflowDeploymentResponse.infer; diff --git a/vendor/intx-workflow-host/workflow-definition-loader.ts b/vendor/intx-workflow-host/workflow-definition-loader.ts new file mode 100644 index 000000000..63619bb4b --- /dev/null +++ b/vendor/intx-workflow-host/workflow-definition-loader.ts @@ -0,0 +1,674 @@ +// Workflow-definition loader: the code-evaluation step the sidecar +// child performs during probe and deploy. +// +// The closure-materialization machinery in `@intx/tool-packaging` +// fetches, verifies, extracts, and lays out an installed workflow +// package (and its dependency closure) into a resolvable +// `node_modules/` tree. This module takes that materialized package +// directory, reads its `package.json`, imports the module named by the +// `interchange.workflow` field, and evaluates it: the module's +// `defineWorkflow(...)` call produces a `WorkflowDefinition`, which is +// validated at this boundary before being returned. +// +// Materialization is deliberately NOT done here. `@intx/workflow-host` +// stays free of a `@intx/tool-packaging` dependency (the sidecar owns +// that layer, see `apps/sidecar/src/tool-materialization.ts`), so the +// caller runs the closure machinery and hands the resulting package +// directory in. This module only performs the import + evaluate + +// validate step, which is the part that must run inside the child's +// address space because it evaluates author code. + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { type } from "arktype"; +import { getLogger } from "@intx/log"; +import { + createDefaultDirectorRegistry, + createWorkflowDirectorRegistry, + isAnnotatedDirectorFactory, + isAnnotatedPluginFactory, + type AnnotatedPluginFactory, + type DirectorRegistry, + type ToolDeclaration, +} from "@intx/agent"; +import { PackageJSON, isContainedEntryPath } from "@intx/types/package-json"; +import { workflowDefinitionEnvelopeSchema } from "@intx/hub-sessions/substrate"; +import type { WorkflowDefinition } from "@intx/workflow/definition"; +import type { ActionHandler, LoopFn, LoopFnRegistry } from "@intx/workflow"; + +const logger = getLogger(["workflow-host", "definition-loader"]); + +export interface LoadWorkflowDefinitionFromClosureArgs { + /** + * Directory of the materialized workflow package within the closure: + * the directory holding the package's `package.json`, with its + * `node_modules/` already laid out by the closure-materialization + * machinery so the entry module's bare-specifier imports resolve. + */ + readonly packageDir: string; + /** + * Optional token mixed into the import URL's query string to bust + * Node's ESM module cache. Node keys the ESM cache by resolved + * URL/path, not by content: a process that imports the same package + * directory twice with different bytes underneath (a rare re-apply in + * a reused child) would otherwise resolve to the first-imported module + * instance. Passing a per-materialization token (the closure's + * integrity SRI is the natural choice) makes each materialization a + * distinct ESM cache entry. Omit it when the process imports a given + * package directory at most once. + */ + readonly importCacheKey?: string; + /** + * Test seam for dynamic import. Production omits this and the loader + * uses the native dynamic-import expression. The argument is the + * `file://` URL the loader resolves for the `interchange.workflow` + * entry. + */ + readonly importModule?: (importUrl: string) => Promise; +} + +/** + * Import the `interchange.workflow` entry from a materialized workflow + * package closure, evaluate it, and return the validated + * `WorkflowDefinition` its `defineWorkflow(...)` call produced. + * + * @param args - the materialized package directory plus optional import + * seams + * @returns the validated `WorkflowDefinition` + * @throws if the package.json is missing/malformed, declares no + * `interchange.workflow` entry, the entry path escapes the package + * directory, the module cannot be imported, or its evaluation does not + * produce exactly one value that validates as a `WorkflowDefinition` + */ +export async function loadWorkflowDefinitionFromClosure( + args: LoadWorkflowDefinitionFromClosureArgs, +): Promise { + const importModule = + args.importModule ?? ((url: string) => import(url) as Promise); + + const pkgJson = await readPackageJSON(args.packageDir); + const entryRel = pkgJson.interchange?.workflow; + if (entryRel === undefined) { + throw new Error( + `workflow package at ${args.packageDir} has no "interchange.workflow" field in package.json`, + ); + } + + const entryAbs = await resolveContainedEntry( + args.packageDir, + entryRel, + "interchange.workflow", + ); + + const importUrl = + args.importCacheKey === undefined + ? pathToFileURL(entryAbs).href + : `${pathToFileURL(entryAbs).href}?importCacheKey=${encodeURIComponent(args.importCacheKey)}`; + + let mod: unknown; + try { + mod = await importModule(importUrl); + } catch (cause) { + throw new Error( + `failed to import interchange.workflow entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir}`, + { cause }, + ); + } + if (mod === null || typeof mod !== "object") { + throw new Error( + `interchange.workflow entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} did not evaluate to a module object`, + ); + } + + const definition = selectWorkflowDefinition(mod, args.packageDir, entryRel); + logger.debug`loaded workflow definition ${definition.id} from ${args.packageDir}`; + return definition; +} + +export interface LoadWorkflowDirectorRegistryFromClosureArgs { + /** + * Directory of the materialized workflow package within the closure -- + * the same directory `loadWorkflowDefinitionFromClosure` reads. Both the + * approval-time probe and the run-child call this over the SAME frozen + * closure, so the director set they compose cannot drift. + */ + readonly packageDir: string; + /** See `LoadWorkflowDefinitionFromClosureArgs.importCacheKey`. */ + readonly importCacheKey?: string; + /** Test seam for dynamic import; see the definition loader's variant. */ + readonly importModule?: (importUrl: string) => Promise; +} + +/** + * Compose the `DirectorRegistry` for a workflow closure from the closure + * package's OWN `interchange.directors` module (if any), alongside the + * built-in default director. A package with no `interchange.directors` + * field composes to the built-ins-only registry -- absence is valid, a + * workflow need not ship a director. A present-but-empty directors module + * is malformed and throws, matching the tool-package loader. + * + * Only the workflow's OWN package directors are loaded here. Directors + * shipped by PINNED dependency packages are deliberately not resolved on + * the source-ref path yet: the airlocked probe does not materialize pinned + * packages, so loading them here would let the runtime resolve a director + * the probe never advertised for approval. A workflow referencing a + * pinned-package director fails closed (the capability walk reports it as + * unresolved). + * + * @throws if the directors entry path escapes the package, the module + * cannot be imported, or it exports no `AnnotatedDirectorFactory` value + */ +export async function loadWorkflowDirectorRegistryFromClosure( + args: LoadWorkflowDirectorRegistryFromClosureArgs, +): Promise { + const importModule = + args.importModule ?? ((url: string) => import(url) as Promise); + + const pkgJson = await readPackageJSON(args.packageDir); + const entryRel = pkgJson.interchange?.directors; + if (entryRel === undefined) { + // No custom directors: built-ins only. + return createDefaultDirectorRegistry(); + } + + const entryAbs = await resolveContainedEntry( + args.packageDir, + entryRel, + "interchange.directors", + ); + + const importUrl = + args.importCacheKey === undefined + ? pathToFileURL(entryAbs).href + : `${pathToFileURL(entryAbs).href}?importCacheKey=${encodeURIComponent(args.importCacheKey)}`; + + let mod: unknown; + try { + mod = await importModule(importUrl); + } catch (cause) { + throw new Error( + `failed to import interchange.directors entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir}`, + { cause }, + ); + } + if (mod === null || typeof mod !== "object") { + throw new Error( + `interchange.directors entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} did not evaluate to a module object`, + ); + } + + const loaded = Object.values(mod).filter(isAnnotatedDirectorFactory); + if (loaded.length === 0) { + throw new Error( + `interchange.directors entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} exported no AnnotatedDirectorFactory values`, + ); + } + logger.debug`loaded ${String(loaded.length)} custom director(s) from ${args.packageDir}`; + return createWorkflowDirectorRegistry(loaded); +} + +export interface LoadWorkflowLoopFnsFromClosureArgs { + /** + * Directory of the materialized workflow package within the closure -- + * the same directory `loadWorkflowDefinitionFromClosure` reads. + */ + readonly packageDir: string; + /** See `LoadWorkflowDefinitionFromClosureArgs.importCacheKey`. */ + readonly importCacheKey?: string; + /** Test seam for dynamic import; see the definition loader's variant. */ + readonly importModule?: (importUrl: string) => Promise; +} + +/** + * Compose the `LoopFnRegistry` for a workflow closure from the closure + * package's OWN `interchange.loops` module. A `loop` primitive's `while` and + * `carry` refs resolve by EXPORT NAME against that module's exports. + * + * Unlike directors there is NO built-in default: a package with no + * `interchange.loops` field composes to an EMPTY registry that throws on any + * ref lookup. A workflow that declares a `loop` but ships no loops module thus + * fails closed when its refs are resolved (eagerly, at establish); a workflow + * with no `loop` primitive never resolves a ref, so an absent field is valid + * there. Loading OUTSIDE the definition-hash re-verify is safe: the approved + * hash pins each ref string, and the closure's SRI pins the module bytes. + * + * @throws (from the returned registry) if a requested ref names no export, or + * names an export that is not a function. + * @throws if the loops entry path escapes the package or cannot be imported. + */ +export async function loadWorkflowLoopFnsFromClosure( + args: LoadWorkflowLoopFnsFromClosureArgs, +): Promise { + const importModule = + args.importModule ?? ((url: string) => import(url) as Promise); + + const pkgJson = await readPackageJSON(args.packageDir); + const entryRel = pkgJson.interchange?.loops; + if (entryRel === undefined) { + // No loops module. A workflow with no loop primitive never calls this; one + // that declares a loop fails closed here when its ref is resolved. + return (ref: string): LoopFn => { + throw new Error( + `loop fn ${JSON.stringify(ref)} was requested, but the workflow package at ${args.packageDir} declares no interchange.loops module`, + ); + }; + } + + const entryAbs = await resolveContainedEntry( + args.packageDir, + entryRel, + "interchange.loops", + ); + + const importUrl = + args.importCacheKey === undefined + ? pathToFileURL(entryAbs).href + : `${pathToFileURL(entryAbs).href}?importCacheKey=${encodeURIComponent(args.importCacheKey)}`; + + let mod: unknown; + try { + mod = await importModule(importUrl); + } catch (cause) { + throw new Error( + `failed to import interchange.loops entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir}`, + { cause }, + ); + } + if (mod === null || typeof mod !== "object") { + throw new Error( + `interchange.loops entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} did not evaluate to a module object`, + ); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- module namespace object: loop fns resolve by export name + const loopModule = mod as Record; + logger.debug`loaded interchange.loops module from ${args.packageDir}`; + return (ref: string): LoopFn => { + const fn = loopModule[ref]; + if (typeof fn !== "function") { + throw new Error( + `interchange.loops entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} exports no loop fn named ${JSON.stringify(ref)}`, + ); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- resolved by export name; the loop runtime applies it as a pure (childOutput, carryState) fn + return fn as LoopFn; + }; +} + +export interface LoadWorkflowActionHandlersFromClosureArgs { + /** Directory of the materialized workflow package within the closure. */ + readonly packageDir: string; + /** See `LoadWorkflowDefinitionFromClosureArgs.importCacheKey`. */ + readonly importCacheKey?: string; + /** Test seam for dynamic import; see the definition loader's variant. */ + readonly importModule?: (importUrl: string) => Promise; +} + +/** + * Compose the action-handler resolver for a workflow closure from the closure + * package's OWN `interchange.actions` module. An `action` primitive's `handler` + * ref resolves by EXPORT NAME against that module's exports. + * + * Mirrors {@link loadWorkflowLoopFnsFromClosure}: there is NO built-in default, + * so a package with no `interchange.actions` field composes to a resolver that + * throws on any lookup. A workflow that declares an `action` but ships no + * actions module fails closed when its handler is resolved (eagerly, at + * establish); a workflow with no `action` primitive never resolves a handler. + * Loading OUTSIDE the definition-hash re-verify is safe: the approved hash pins + * each handler ref string, and the closure's SRI pins the module bytes. + * + * @throws (from the returned resolver) if a requested ref names no export, or an + * export that is not a function. + * @throws if the actions entry path escapes the package or cannot be imported. + */ +export async function loadWorkflowActionHandlersFromClosure( + args: LoadWorkflowActionHandlersFromClosureArgs, +): Promise<(ref: string) => ActionHandler> { + const importModule = + args.importModule ?? ((url: string) => import(url) as Promise); + + const pkgJson = await readPackageJSON(args.packageDir); + const entryRel = pkgJson.interchange?.actions; + if (entryRel === undefined) { + return (ref: string): ActionHandler => { + throw new Error( + `action handler ${JSON.stringify(ref)} was requested, but the workflow package at ${args.packageDir} declares no interchange.actions module`, + ); + }; + } + + const entryAbs = await resolveContainedEntry( + args.packageDir, + entryRel, + "interchange.actions", + ); + + const importUrl = + args.importCacheKey === undefined + ? pathToFileURL(entryAbs).href + : `${pathToFileURL(entryAbs).href}?importCacheKey=${encodeURIComponent(args.importCacheKey)}`; + + let mod: unknown; + try { + mod = await importModule(importUrl); + } catch (cause) { + throw new Error( + `failed to import interchange.actions entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir}`, + { cause }, + ); + } + if (mod === null || typeof mod !== "object") { + throw new Error( + `interchange.actions entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} did not evaluate to a module object`, + ); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- module namespace object: action handlers resolve by export name + const actionModule = mod as Record; + logger.debug`loaded interchange.actions module from ${args.packageDir}`; + return (ref: string): ActionHandler => { + const fn = actionModule[ref]; + if (typeof fn !== "function") { + throw new Error( + `interchange.actions entry ${JSON.stringify(entryRel)} for workflow package at ${args.packageDir} exports no action handler named ${JSON.stringify(ref)}`, + ); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- resolved by export name; invoked as an ActionHandler (input, ctx, signal) by createDefaultActionInvoker + return fn as ActionHandler; + }; +} + +export interface LoadWorkflowPluginsFromClosureArgs { + /** + * Directory of the materialized workflow package within the closure -- + * the same directory `loadWorkflowDefinitionFromClosure` reads. Each + * declared plugin package is resolved from this package's laid-out + * `node_modules/`, exactly as the workflow entry's own bare-specifier + * imports resolve. + */ + readonly packageDir: string; + /** + * Plugin-package names the workflow's agents declare via + * `AgentDefinition.plugins` (`["@intx/tools-lsp"]`). Each MUST be a + * direct dependency of the workflow package so it is laid out under the + * workflow package's `node_modules/`. Empty is valid (no plugins). + */ + readonly plugins: readonly string[]; + /** See `LoadWorkflowDefinitionFromClosureArgs.importCacheKey`. */ + readonly importCacheKey?: string; + /** Test seam for dynamic import; see the definition loader's variant. */ + readonly importModule?: (importUrl: string) => Promise; +} + +/** + * Import each declared plugin package's `interchange.tools` module from the + * materialized workflow closure and collect the `AnnotatedPluginFactory` + * values it exports. This is the run-child counterpart to the tool-package + * loader's plugin channel: a source-ref workflow contributes no plugin factory + * through its agent definition (a plugin has no agent slot), so the child + * materializes the declared plugins straight from the already-laid-out closure + * -- no re-download, no manifest -- and feeds them into the existing per-step + * plugin chain. The closure bytes were SRI-verified when the deploy applied the + * frozen closure, and resolution walks the same `node_modules/` graph the + * workflow entry's imports use. + * + * @throws if a declared plugin package cannot be resolved, declares no + * `interchange.tools` entry, the entry escapes the package, cannot be + * imported, or exports no `AnnotatedPluginFactory` value + */ +export async function loadWorkflowPluginFactoriesFromClosure( + args: LoadWorkflowPluginsFromClosureArgs, +): Promise { + const importModule = + args.importModule ?? ((url: string) => import(url) as Promise); + const out: AnnotatedPluginFactory[] = []; + for (const pluginName of args.plugins) { + const factories = await loadPluginPackageFactories({ + workflowPackageDir: args.packageDir, + pluginName, + importModule, + ...(args.importCacheKey !== undefined + ? { importCacheKey: args.importCacheKey } + : {}), + }); + out.push(...factories); + } + return out; +} + +/** + * Read the static tool `definitions` each declared plugin package + * contributes, keyed by plugin-package name, WITHOUT retaining the plugin + * factory (so the caller never instantiates a plugin, which for LSP would + * start a subprocess). This is the probe/capability-walk counterpart to + * `loadWorkflowPluginFactoriesFromClosure`: it loads the SAME plugin module + * from the SAME frozen closure so the tool grant surface the walk approves + * matches the plugin the run-child materializes. + * + * A plugin package that exports plugin factories but declares no tool + * definitions (a middleware-only plugin) maps to an empty array -- valid, + * it contributes no tool grant. + * + * @throws under the same conditions as `loadWorkflowPluginFactoriesFromClosure` + */ +export async function loadWorkflowPluginToolDefinitionsFromClosure( + args: LoadWorkflowPluginsFromClosureArgs, +): Promise> { + const importModule = + args.importModule ?? ((url: string) => import(url) as Promise); + const byPackage = new Map(); + for (const pluginName of args.plugins) { + const factories = await loadPluginPackageFactories({ + workflowPackageDir: args.packageDir, + pluginName, + importModule, + ...(args.importCacheKey !== undefined + ? { importCacheKey: args.importCacheKey } + : {}), + }); + const definitions: ToolDeclaration[] = []; + for (const factory of factories) { + definitions.push(...factory.definitions); + } + byPackage.set(pluginName, definitions); + } + return byPackage; +} + +async function loadPluginPackageFactories(args: { + workflowPackageDir: string; + pluginName: string; + importCacheKey?: string; + importModule: (importUrl: string) => Promise; +}): Promise { + // Resolve the plugin package from the workflow package's laid-out + // `node_modules/`. The closure materializer symlinks each direct + // dependency into the requirer's `node_modules/`, so a declared plugin + // package (which must be a workflow dependency) sits here. Realpath it so + // a plugin whose entry-path containment is checked below compares + // realpath-vs-realpath. + const linkedDir = path.join( + args.workflowPackageDir, + "node_modules", + args.pluginName, + ); + let pluginPkgDir: string; + try { + pluginPkgDir = await fs.realpath(linkedDir); + } catch (cause) { + throw new Error( + `plugin package ${JSON.stringify(args.pluginName)} could not be resolved from the workflow closure at ${args.workflowPackageDir}; it must be a direct dependency of the workflow package`, + { cause }, + ); + } + + const pkgJson = await readPackageJSON(pluginPkgDir); + const entryRel = pkgJson.interchange?.tools; + if (entryRel === undefined) { + throw new Error( + `plugin package ${JSON.stringify(args.pluginName)} at ${pluginPkgDir} declares no "interchange.tools" entry; it is not a tool package`, + ); + } + + const entryAbs = await resolveContainedEntry( + pluginPkgDir, + entryRel, + "interchange.tools", + ); + + const importUrl = + args.importCacheKey === undefined + ? pathToFileURL(entryAbs).href + : `${pathToFileURL(entryAbs).href}?importCacheKey=${encodeURIComponent(args.importCacheKey)}`; + + let mod: unknown; + try { + mod = await args.importModule(importUrl); + } catch (cause) { + throw new Error( + `failed to import interchange.tools entry ${JSON.stringify(entryRel)} for plugin package ${JSON.stringify(args.pluginName)} at ${pluginPkgDir}`, + { cause }, + ); + } + if (mod === null || typeof mod !== "object") { + throw new Error( + `interchange.tools entry ${JSON.stringify(entryRel)} for plugin package ${JSON.stringify(args.pluginName)} at ${pluginPkgDir} did not evaluate to a module object`, + ); + } + + const factories = Object.values(mod).filter(isAnnotatedPluginFactory); + if (factories.length === 0) { + throw new Error( + `interchange.tools entry ${JSON.stringify(entryRel)} for plugin package ${JSON.stringify(args.pluginName)} at ${pluginPkgDir} exported no AnnotatedPluginFactory values; a package named in an agent's plugins list must export a definePlugin factory`, + ); + } + logger.debug`loaded ${String(factories.length)} plugin factory(ies) from ${args.pluginName} at ${pluginPkgDir}`; + return factories; +} + +async function readPackageJSON(packageDir: string): Promise { + const pkgJsonPath = path.join(packageDir, "package.json"); + let raw: string; + try { + raw = await fs.readFile(pkgJsonPath, "utf8"); + } catch (cause) { + throw new Error( + `cannot read package.json for workflow package at ${packageDir}`, + { cause }, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (cause) { + throw new Error( + `malformed package.json for workflow package at ${packageDir}`, + { cause }, + ); + } + const validated = PackageJSON(parsed); + if (validated instanceof type.errors) { + throw new Error( + `package.json for workflow package at ${packageDir} failed validation: ${validated.summary}`, + ); + } + return validated; +} + +/** + * Resolve `entryRel` against `packageDir` and confine the result to the + * package's own directory. `entryRel` originates from the package's + * `package.json` and crosses the trust boundary into the child process, + * so a `..`-traversal, an absolute path, or a `node_modules` symlink + * escape would let a malicious package import any file the child can + * read. The string-level check rejects `..`/absolute paths; the + * realpath check rejects an escape through a symlink in the closure's + * `node_modules` layout. Both sides are realpath'd so the comparison + * holds even when the closure lives under a symlinked temp root (macOS + * resolves `/tmp` to `/private/tmp`). + */ +async function resolveContainedEntry( + packageDir: string, + entryRel: string, + fieldLabel: string, +): Promise { + // String-level containment, shared with the push-time asset validator so the + // two boundaries agree on what "contained" means. + if (!isContainedEntryPath(entryRel)) { + throw new Error( + `${fieldLabel} entry path ${JSON.stringify(entryRel)} escapes the workflow package directory ${packageDir}`, + ); + } + const entryAbs = path.resolve(packageDir, entryRel); + + let realPackageDir: string; + let realEntryAbs: string; + try { + realPackageDir = await fs.realpath(packageDir); + realEntryAbs = await fs.realpath(entryAbs); + } catch (cause) { + throw new Error( + `${fieldLabel} entry path ${JSON.stringify(entryRel)} for workflow package at ${packageDir} could not be resolved`, + { cause }, + ); + } + const realContainmentRoot = realPackageDir.endsWith(path.sep) + ? realPackageDir + : realPackageDir + path.sep; + if ( + realEntryAbs !== realPackageDir && + !realEntryAbs.startsWith(realContainmentRoot) + ) { + throw new Error( + `${fieldLabel} entry path ${JSON.stringify(entryRel)} for workflow package at ${packageDir} escapes the package directory via a symlink`, + ); + } + return entryAbs; +} + +/** + * Pick the single `WorkflowDefinition` the entry module produces. A + * workflow package's entry evaluates one `defineWorkflow(...)` call and + * exposes its result as an export (by convention `export default`, but a + * named export is accepted too). Every export is validated against the + * envelope schema; exactly one must pass. Zero or more than one is a + * malformed workflow package and fails loudly rather than guessing. + */ +function selectWorkflowDefinition( + mod: object, + packageDir: string, + entryRel: string, +): WorkflowDefinition { + const matches: WorkflowDefinition[] = []; + for (const value of Object.values(mod)) { + const validated = workflowDefinitionEnvelopeSchema(value); + if (validated instanceof type.errors) { + continue; + } + // The envelope schema enforces the cross-cutting structural shape + // (`id`, `triggers`, `steps`, `stepOrder`); the per-primitive narrow + // lives downstream in the runtime that hydrates the definition. This + // mirrors the boundary the repo's other `WorkflowDefinition` readers + // use (see `run-child.ts`, `spawn-child.ts`). + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- envelope schema enforces structural shape; primitive narrows live downstream in the runtime body + matches.push(validated as unknown as WorkflowDefinition); + } + + if (matches.length === 0) { + throw new Error( + `interchange.workflow entry ${JSON.stringify(entryRel)} for workflow package at ${packageDir} exported no value that validates as a WorkflowDefinition`, + ); + } + if (matches.length > 1) { + throw new Error( + `interchange.workflow entry ${JSON.stringify(entryRel)} for workflow package at ${packageDir} exported ${String(matches.length)} WorkflowDefinition values; the entry must produce exactly one`, + ); + } + const [definition] = matches; + if (definition === undefined) { + throw new Error( + `interchange.workflow entry ${JSON.stringify(entryRel)} for workflow package at ${packageDir} produced no WorkflowDefinition`, + ); + } + return definition; +} From 1d4beb897d3121a3f60cd05a74f0348bd513ce8d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 22:11:39 -0700 Subject: [PATCH 2/4] Re-apply ledgered Interchange patches onto main HEAD Every PATCHES.md entry re-carried against 1ad0104 with an as-is / adapt triage recorded in the ledgers; none was subsumed upstream. Assembly resolves direct-wins-over-deps for the new contextTransforms field, harness/reactor adaptations follow the rewritten usage emission and tryCorrelate regions, and VENDORING.md records the new pin, the loader provenance row, and the tool-packaging decision. --- docs/VENDORING.md | 88 +++-- src/config.test.ts | 8 +- src/config/index.ts | 71 ++-- src/config/source-credentials.ts | 48 +++ src/context-compactor.test.ts | 2 +- src/exec/runner.ts | 27 +- src/session/assemble-runtime.test.ts | 2 +- src/session/assemble-runtime.ts | 5 + src/session/summarizer.ts | 4 + src/subagent/refresh-inference-source.test.ts | 18 +- src/subagent/refresh-inference-source.ts | 7 +- src/subagent/run-source.test.ts | 8 +- src/subagent/run.ts | 5 + src/tui/runner/exit.test.ts | 2 +- src/tui/runner/exit.ts | 20 +- src/tui/runner/session.ts | 4 +- tests/integration/harness.ts | 12 +- tests/integration/vendored-carry.test.ts | 6 + tests/unit/inference-response-kind.test.ts | 8 +- tests/unit/summarizer.test.ts | 6 +- vendor/intx-agent/PATCHES.md | 7 + vendor/intx-agent/src/agent.ts | 57 ++- .../intx-agent/src/audit-integration.test.ts | 3 + vendor/intx-agent/src/flush-errors.test.ts | 317 +++++++++++++++++ vendor/intx-agent/src/testing/audit-noop.ts | 4 + vendor/intx-inference/PATCHES.md | 52 +++ vendor/intx-inference/src/adapter.ts | 15 + vendor/intx-inference/src/assembly.ts | 34 +- vendor/intx-inference/src/authz-extension.ts | 29 +- vendor/intx-inference/src/errors.ts | 18 +- vendor/intx-inference/src/harness.ts | 329 +++++++++++++++--- vendor/intx-inference/src/index.ts | 8 +- .../intx-inference/src/providers/anthropic.ts | 22 +- .../src/providers/google-genai-files.ts | 4 +- .../src/providers/google-genai.ts | 9 +- vendor/intx-inference/src/reactor.test.ts | 11 +- vendor/intx-inference/src/reactor.ts | 283 +++++++++++---- vendor/intx-inference/src/sse.ts | 20 ++ vendor/intx-inference/src/state.ts | 60 +++- vendor/intx-storage-isogit/PATCHES.md | 5 + vendor/intx-storage-isogit/src/store.test.ts | 32 ++ vendor/intx-storage-isogit/src/store.ts | 42 ++- vendor/intx-types/PATCHES.md | 8 + vendor/intx-types/src/runtime.ts | 14 +- 44 files changed, 1472 insertions(+), 262 deletions(-) create mode 100644 src/config/source-credentials.ts diff --git a/docs/VENDORING.md b/docs/VENDORING.md index 42bf768d3..e8e072b52 100644 --- a/docs/VENDORING.md +++ b/docs/VENDORING.md @@ -22,42 +22,43 @@ points straight at `./src/*.ts` files rather than a `dist/` build. ## What's vendored -| Package | Vendor path | License | Synced from upstream commit | Retrieved | Local patches | -| -------------------------------------------------- | ------------------------------------- | ------------- | ------------------------------------------ | ---------- | ------------------------------------------------- | -| `@intx/inference` | `vendor/intx-inference/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | Yes — see `vendor/intx-inference/PATCHES.md` | -| `@intx/types` | `vendor/intx-types/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | Yes — see `vendor/intx-types/PATCHES.md` | -| `@intx/storage-isogit` | `vendor/intx-storage-isogit/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | Yes — see `vendor/intx-storage-isogit/PATCHES.md` | -| `@intx/agent` | `vendor/intx-agent/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | Yes — see `vendor/intx-agent/PATCHES.md` | -| `@intx/authz` | `vendor/intx-authz/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | -| `@intx/log` | `vendor/intx-log/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | -| `@intx/tools-posix` | `vendor/intx-tools-posix/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | -| `@intx/mailbox` | `vendor/intx-mailbox/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | -| `@intx/harness` | `vendor/intx-harness/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | -| `@intx/mime` | `vendor/intx-mime/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | -| `@intx/workflow-host` (partial — `adapters/` only) | `vendor/intx-workflow-host/adapters/` | LGPL-2.1-only | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | 2026-09-07 | None — verbatim | +| Package | Vendor path | License | Synced from upstream commit | Retrieved | Local patches | +| ------------------------------------------------------------------------------- | --------------------------------------------------------------- | ------------- | ------------------------------------------ | ---------- | ------------------------------------------------- | +| `@intx/inference` | `vendor/intx-inference/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-14 | Yes — see `vendor/intx-inference/PATCHES.md` | +| `@intx/types` | `vendor/intx-types/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-14 | Yes — see `vendor/intx-types/PATCHES.md` | +| `@intx/storage-isogit` | `vendor/intx-storage-isogit/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-14 | Yes — see `vendor/intx-storage-isogit/PATCHES.md` | +| `@intx/agent` | `vendor/intx-agent/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-14 | Yes — see `vendor/intx-agent/PATCHES.md` | +| `@intx/authz` | `vendor/intx-authz/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-14 | None — verbatim | +| `@intx/log` | `vendor/intx-log/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-14 | None — verbatim | +| `@intx/tools-posix` | `vendor/intx-tools-posix/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-14 | None — verbatim | +| `@intx/mailbox` | `vendor/intx-mailbox/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-14 | None — verbatim | +| `@intx/harness` | `vendor/intx-harness/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-14 | None — verbatim | +| `@intx/mime` | `vendor/intx-mime/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-14 | None — verbatim | +| `@intx/workflow-host` (partial — `adapters/` + `workflow-definition-loader.ts`) | `vendor/intx-workflow-host/` (two paths, not the whole package) | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-14 | None — verbatim | ## Provenance, ownership, and kill dates Every vendored path with its upstream source, why the published npm package did not cover the need, its owner, and its kill date. A kill date is a proposal the operator ratifies on review; each ties an observable condition -to a hard backstop date (2027-03-07, six months after this sync). When the +to a hard backstop date (2027-03-14, six months after this sync). When the condition is met the vendored tree is dropped in favour of the published package; the date is the deadline even if it is not. -| Vendor path | Upstream repo | Upstream commit | Patched | Why not the published package | Owner | Proposed kill date | -| ------------------------------------- | ----------------------- | ------------------------------------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------ | -| `vendor/intx-inference/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | Yes — `PATCHES.md` | Local fixes not yet upstream | runtime | 2027-03-07 or when patches land upstream and publish | -| `vendor/intx-types/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | Yes — `PATCHES.md` | Cross-package coupling with `@intx/inference` | runtime | 2027-03-07 or when the coupled trio publishes past `0.3.0` | -| `vendor/intx-storage-isogit/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | Yes — `PATCHES.md` | Cross-package coupling with `@intx/inference` | runtime | 2027-03-07 or when the coupled trio publishes past `0.3.0` | -| `vendor/intx-agent/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | Yes — `PATCHES.md` | Vendored at Interchange head ahead of npm | runtime | 2027-03-07 or when `@intx/agent@>=0.4.0` publishes | -| `vendor/intx-authz/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-07 or when `@intx/authz@>=0.4.0` publishes | -| `vendor/intx-log/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-07 or when `@intx/log@>=0.4.0` publishes | -| `vendor/intx-tools-posix/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-07 or when `@intx/tools-posix@>=0.4.0` publishes | -| `vendor/intx-mailbox/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | Never published to npm (verified 2026-09-07: registry 404 for all versions) | step-1 | 2027-03-07 or when any `@intx/mailbox` version publishes to npm | -| `vendor/intx-harness/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | `driveConnectorReplies`/`AgentEventStream` (`src/reply-drain.ts`) is past npm `0.3.0` (verified 2026-09-07: absent from the published tarball) | step-1 | 2027-03-07 or when a published `@intx/harness` exports `driveConnectorReplies` | -| `vendor/intx-mime/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | `buildMessageHeaders` is past npm `0.3.0` (verified 2026-09-07: absent from the published tarball); `@intx/mailbox` re-exports it | step-1 | 2027-03-07 or when a published `@intx/mime` exports `buildMessageHeaders` | -| `vendor/intx-workflow-host/adapters/` | `faremeter/interchange` | `0205b07b64d03f0fec2e4be3593c764070a9ba8a` | No | App-internal: `substrate-mailbox-store.ts` has never been published in any `@intx/workflow-host` release (verified 2026-09-07: absent from the `0.3.0` tarball) | step-1 | 2027-03-07 or when a published `@intx/workflow-host` exports `createSubstrateMailboxStore` | +| Vendor path | Upstream repo | Upstream commit | Patched | Why not the published package | Owner | Proposed kill date | +| --------------------------------------------------------- | ----------------------- | ------------------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------ | +| `vendor/intx-inference/` | `faremeter/interchange` | `1ad010463a6bce6034cded3e078b14db482882a8` | Yes — `PATCHES.md` | Local fixes not yet upstream | runtime | 2027-03-14 or when patches land upstream and publish | +| `vendor/intx-types/` | `faremeter/interchange` | `1ad010463a6bce6034cded3e078b14db482882a8` | Yes — `PATCHES.md` | Cross-package coupling with `@intx/inference` | runtime | 2027-03-14 or when the coupled trio publishes past `0.3.0` | +| `vendor/intx-storage-isogit/` | `faremeter/interchange` | `1ad010463a6bce6034cded3e078b14db482882a8` | Yes — `PATCHES.md` | Cross-package coupling with `@intx/inference` | runtime | 2027-03-14 or when the coupled trio publishes past `0.3.0` | +| `vendor/intx-agent/` | `faremeter/interchange` | `1ad010463a6bce6034cded3e078b14db482882a8` | Yes — `PATCHES.md` | Vendored at Interchange head ahead of npm | runtime | 2027-03-14 or when `@intx/agent@>=0.4.0` publishes | +| `vendor/intx-authz/` | `faremeter/interchange` | `1ad010463a6bce6034cded3e078b14db482882a8` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-14 or when `@intx/authz@>=0.4.0` publishes | +| `vendor/intx-log/` | `faremeter/interchange` | `1ad010463a6bce6034cded3e078b14db482882a8` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-14 or when `@intx/log@>=0.4.0` publishes | +| `vendor/intx-tools-posix/` | `faremeter/interchange` | `1ad010463a6bce6034cded3e078b14db482882a8` | No | Vendored at Interchange head ahead of npm | runtime | 2027-03-14 or when `@intx/tools-posix@>=0.4.0` publishes | +| `vendor/intx-mailbox/` | `faremeter/interchange` | `1ad010463a6bce6034cded3e078b14db482882a8` | No | Never published to npm (verified 2026-09-07: registry 404 for all versions) | step-1 | 2027-03-14 or when any `@intx/mailbox` version publishes to npm | +| `vendor/intx-harness/` | `faremeter/interchange` | `1ad010463a6bce6034cded3e078b14db482882a8` | No | `driveConnectorReplies`/`AgentEventStream` (`src/reply-drain.ts`) is past npm `0.3.0` (verified 2026-09-07: absent from the published tarball) | step-1 | 2027-03-14 or when a published `@intx/harness` exports `driveConnectorReplies` | +| `vendor/intx-mime/` | `faremeter/interchange` | `1ad010463a6bce6034cded3e078b14db482882a8` | No | `buildMessageHeaders` is past npm `0.3.0` (verified 2026-09-07: absent from the published tarball); `@intx/mailbox` re-exports it | step-1 | 2027-03-14 or when a published `@intx/mime` exports `buildMessageHeaders` | +| `vendor/intx-workflow-host/adapters/` | `faremeter/interchange` | `1ad010463a6bce6034cded3e078b14db482882a8` | No | App-internal: `substrate-mailbox-store.ts` has never been published in any `@intx/workflow-host` release (verified 2026-09-07: absent from the `0.3.0` tarball) | step-1 | 2027-03-14 or when a published `@intx/workflow-host` exports `createSubstrateMailboxStore` | +| `vendor/intx-workflow-host/workflow-definition-loader.ts` | `faremeter/interchange` | `1ad010463a6bce6034cded3e078b14db482882a8` | No | Director loading from a workflow closure (`loadWorkflowDirectorRegistryFromClosure`); never published in any `@intx/workflow-host` release (verified 2026-09-14: absent from the `0.3.0` tarball) | step-1 | 2027-03-14 or when a published `@intx/workflow-host` exports `loadWorkflowDirectorRegistryFromClosure` | ### The 2026-09-07 step-1 vendor pass @@ -77,7 +78,7 @@ signingKey })` (local disk + keypair, no hub, no database) is present in opts.onEvent)` wiring). Root `dependencies` pins `0.3.0`. Everything vendored in this pass sits at the same upstream commit -`0205b07b64d03f0fec2e4be3593c764070a9ba8a` as the existing trees; no +`1ad010463a6bce6034cded3e078b14db482882a8` as the existing trees; no vendored tree mixes pins. `vendor/intx-workflow-host/adapters/` is a partial-package vendor: upstream @@ -96,6 +97,37 @@ and `@intx/storage-isogit`; the gap did not regress the coupling — the `PendingOperation` shape was byte-identical across it — but it was a staged exception, not the steady state. +### The 2026-09-14 re-sync to `1ad0104` + +All eleven paths moved `0205b07b` → `1ad0104` (retrieved 2026-09-14) as a +pristine-then-patches pair: the first commit overwrites every tree +verbatim (including the two upstream deletions, +`packages/types/src/sidecar-placement.ts` and +`packages/inference/src/providers/google-genai.test.ts` — neither carried +a ledger entry), and the second re-applies each `PATCHES.md` entry with an +as-is / adapt / subsumed triage recorded in the ledgers. No entry was +subsumed upstream. Upstream replaced inline provider `apiKey` plumbing +with a `credentialId` + credential-cell model; no ledger entry touches +auth and no first-party caller passes provider credentials into the +vendored trees, so no migration was needed. + +`vendor/intx-workflow-host/workflow-definition-loader.ts` is new in this +sync: a second partial-tree path alongside `adapters/`, carrying +`loadWorkflowDirectorRegistryFromClosure` verbatim (674 lines, no local +patches). Like the adapter, it is deliberately **not** a workspace member +and nothing in `src/` imports it — wiring callers is a later ticket's +work, so no workspace/overrides wiring was added; its `@intx/workflow` +and `@intx/hub-sessions/substrate` imports resolve once that wiring +exists. Until then it is inert provenance, not dead weight. + +`@intx/tool-packaging` evaluated 2026-09: closure-wide collection +(loader.ts:389) serves tool-package establishment; director-loader need +fully covered by vendored workflow-definition-loader.ts + shared +isAnnotatedDirectorFactory predicate from already-vendored @intx/agent. +No first-party consumer imports tool-packaging; stays on transitive +published 0.3.0. Revisit if a future ticket establishes tool packages +from closures at runtime. + The license column records what each package declares in its own `package.json`; the corresponding `LICENSE` file travels with every vendored tree and is never edited during a sync. Corbits Code is distributed under diff --git a/src/config.test.ts b/src/config.test.ts index 0312a28e4..54c2d170e 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -23,6 +23,7 @@ import { SOURCE_MAX_TOKENS, } from "./config/index.js"; import { DIRECTOR_IDS } from "./agent/directors/types.js"; +import { peekSourceCredentialSecret } from "./config/source-credentials.js"; import type { Config, UnconfiguredConfig } from "./config/index.js"; import { mergeProviderIntoSettings, @@ -1505,13 +1506,16 @@ describe("buildOpenAISource", () => { expect(source.baseURL).toBe("http://localhost:11434/v1"); }); - test("substitutes a placeholder apiKey when none is provided (keyless)", () => { + test("registers the keyless placeholder in the credential cell when none is provided", () => { const source = buildOpenAISource({ id: "local", baseURL: "http://localhost:8080/v1", model: "local-model", }); - expect(source.apiKey).toBe(KEYLESS_API_KEY); + expect(source.credentialId).toBe("local"); + expect(peekSourceCredentialSecret(source.credentialId)).toBe( + KEYLESS_API_KEY, + ); }); }); diff --git a/src/config/index.ts b/src/config/index.ts index a8696abb8..49927e3be 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -29,6 +29,7 @@ import { import type { CodexProfile } from "../auth/codex/store.js"; import type { XaiProfile } from "../auth/xai/store.js"; import { listCodexProfiles, listXaiProfiles } from "./oauth-stores.js"; +import { registerSourceCredential } from "./source-credentials.js"; import { codexProfilesToCatalogEntries, codexProvidersAsSettings, @@ -105,12 +106,22 @@ import { resolveProfile } from "./profiles.js"; // revert the ceiling. export const SOURCE_MAX_TOKENS = 16384; -// Placeholder sent in the Authorization header for keyless local providers -// (e.g. Ollama). The runtime's InferenceSource type requires a non-empty -// apiKey string; the value is injected as `Bearer ` by the harness but -// keyless servers ignore it entirely. +// Placeholder resolved from the credential cell for keyless local providers +// (e.g. Ollama). Sources that need no secret register this sentinel; the +// harness still sends it as `Bearer ` but keyless servers ignore it. export const KEYLESS_API_KEY = "keyless"; +// Registers the secret behind a source id in the credential cell (see +// ./source-credentials.ts), falling back to the keyless sentinel when no key +// was configured. Every buildXSource below calls this so the vendored +// credentialId auth model resolves the secret at send time. +function registerSourceSecret(id: string, apiKey: string | undefined): void { + registerSourceCredential( + id, + apiKey !== undefined && apiKey.length > 0 ? apiKey : KEYLESS_API_KEY, + ); +} + function applyPersistedOAuthDefaults( settings: Settings | null, projected: Record, @@ -253,16 +264,14 @@ export function buildOpenAISource(fields: { fields.reasoningEffort !== undefined ? { providerOptions: { reasoning_effort: fields.reasoningEffort } } : {}; + registerSourceSecret(fields.id, fields.apiKey); return { id: fields.id, provider: "openai-compatible", baseURL: isOllamaProviderId(fields.id) ? ollamaOpenAIBaseURL(fields.baseURL) : normalizeOpenAICompatibleBaseURL(fields.baseURL), - apiKey: - fields.apiKey !== undefined && fields.apiKey.length > 0 - ? fields.apiKey - : KEYLESS_API_KEY, + credentialId: fields.id, model: fields.model, defaults: { maxTokens: SOURCE_MAX_TOKENS, ...overrides }, ...(fields.quirks !== undefined ? { quirks: fields.quirks } : {}), @@ -318,7 +327,8 @@ export type ProviderCatalogEntry = Omit< // "codex-responses" adapter (the Codex backend speaks the Responses API, not // Chat Completions) and carries the account id + a session id through // providerOptions, where the adapter lifts them into request headers. The -// access token is the apiKey; the harness injects it as the bearer credential. +// access token is registered in the credential cell under the source id; the +// harness resolves it as the bearer credential at send time. export function buildCodexSource(fields: { id: string; apiKey: string; @@ -334,11 +344,12 @@ export function buildCodexSource(fields: { providerOptions[CODEX_ACCOUNT_ID_OPTION] = fields.accountId; if (fields.reasoningEffort !== undefined) providerOptions["reasoning_effort"] = fields.reasoningEffort; + registerSourceSecret(fields.id, fields.apiKey); return { id: fields.id, provider: CODEX_RESPONSES_PROVIDER, baseURL: CODEX_BASE_URL, - apiKey: fields.apiKey, + credentialId: fields.id, model: fields.model, defaults: { maxTokens: SOURCE_MAX_TOKENS, providerOptions }, }; @@ -346,7 +357,8 @@ export function buildCodexSource(fields: { // Build the InferenceSource for an xAI/Grok OAuth profile. Routes to the // "grok-responses" adapter (the grok-cli proxy speaks the Responses API, not -// Chat Completions). The access token is the apiKey; the caller's user id is +// Chat Completions). The access token is registered in the credential cell +// under the source id; the caller's user id is // decoded from it and lifted into the x-grok-user-id header by the adapter. // The session id becomes the request's prompt_cache_key so every call in the // thread routes to the same cache shard (store:false has no other signal). @@ -364,11 +376,12 @@ export function buildXaiSource(fields: { if (userId !== undefined) providerOptions[GROK_USER_ID_OPTION] = userId; if (fields.reasoningEffort !== undefined) providerOptions["reasoning_effort"] = fields.reasoningEffort; + registerSourceSecret(fields.id, fields.apiKey); return { id: fields.id, provider: GROK_RESPONSES_PROVIDER, baseURL: XAI_BASE_URL, - apiKey: fields.apiKey, + credentialId: fields.id, model: fields.model, defaults: { maxTokens: SOURCE_MAX_TOKENS, providerOptions }, }; @@ -388,14 +401,12 @@ export function buildBifrostSource(fields: { fields.reasoningEffort !== undefined ? { providerOptions: { reasoning_effort: fields.reasoningEffort } } : {}; + registerSourceSecret(fields.id, fields.apiKey); return { id: fields.id, provider: BIFROST_PROVIDER, baseURL: normalizeOpenAICompatibleBaseURL(fields.baseURL), - apiKey: - fields.apiKey !== undefined && fields.apiKey.length > 0 - ? fields.apiKey - : KEYLESS_API_KEY, + credentialId: fields.id, model: fields.model, defaults: { maxTokens: SOURCE_MAX_TOKENS, ...overrides }, }; @@ -408,14 +419,12 @@ export function buildAnthropicSource(fields: { apiKey?: string; model: string; }): InferenceSource { + registerSourceSecret(fields.id, fields.apiKey); return { id: fields.id, provider: "anthropic", baseURL: fields.baseURL.replace(/\/+$/, ""), - apiKey: - fields.apiKey !== undefined && fields.apiKey.length > 0 - ? fields.apiKey - : KEYLESS_API_KEY, + credentialId: fields.id, model: fields.model, defaults: { maxTokens: SOURCE_MAX_TOKENS }, }; @@ -431,16 +440,13 @@ export function buildGoSource(fields: { reasoningEffort?: ReasoningEffort; }): InferenceSource { const endpoint = resolveGoEndpoint(fields.model); - const apiKey = - fields.apiKey !== undefined && fields.apiKey.length > 0 - ? fields.apiKey - : KEYLESS_API_KEY; + registerSourceSecret(fields.id, fields.apiKey); if (endpoint.adapter === "anthropic") { return { id: fields.id, provider: OPENCODE_GO_MESSAGES_PROVIDER, baseURL: endpoint.baseURL, - apiKey, + credentialId: fields.id, model: fields.model, defaults: { maxTokens: SOURCE_MAX_TOKENS, @@ -455,7 +461,7 @@ export function buildGoSource(fields: { id: fields.id, provider: OPENAI_RESPONSES_PROVIDER, baseURL: endpoint.baseURL, - apiKey, + credentialId: fields.id, model: fields.model, defaults: { maxTokens: SOURCE_MAX_TOKENS, @@ -471,7 +477,7 @@ export function buildGoSource(fields: { id: fields.id, baseURL: endpoint.baseURL.length > 0 ? endpoint.baseURL : OPENCODE_GO_BASE_URL, - apiKey, + ...(fields.apiKey !== undefined ? { apiKey: fields.apiKey } : {}), model: fields.model, ...(fields.reasoningEffort !== undefined ? { reasoningEffort: fields.reasoningEffort } @@ -500,16 +506,13 @@ export function buildZenSource(fields: { reasoningEffort?: ReasoningEffort; }): InferenceSource { const endpoint = resolveZenEndpoint(fields.model); - const apiKey = - fields.apiKey !== undefined && fields.apiKey.length > 0 - ? fields.apiKey - : KEYLESS_API_KEY; + registerSourceSecret(fields.id, fields.apiKey); if (endpoint.adapter === "anthropic") { return { id: fields.id, provider: ZEN_MESSAGES_PROVIDER, baseURL: endpoint.baseURL, - apiKey, + credentialId: fields.id, model: fields.model, defaults: { maxTokens: SOURCE_MAX_TOKENS, @@ -524,7 +527,7 @@ export function buildZenSource(fields: { id: fields.id, provider: OPENAI_RESPONSES_PROVIDER, baseURL: endpoint.baseURL, - apiKey, + credentialId: fields.id, model: fields.model, defaults: { maxTokens: SOURCE_MAX_TOKENS, @@ -540,7 +543,7 @@ export function buildZenSource(fields: { id: fields.id, baseURL: endpoint.baseURL.length > 0 ? endpoint.baseURL : ZEN_DEFAULT_BASE_URL, - apiKey, + ...(fields.apiKey !== undefined ? { apiKey: fields.apiKey } : {}), model: fields.model, ...(fields.reasoningEffort !== undefined ? { reasoningEffort: fields.reasoningEffort } diff --git a/src/config/source-credentials.ts b/src/config/source-credentials.ts new file mode 100644 index 000000000..a3d0c17fb --- /dev/null +++ b/src/config/source-credentials.ts @@ -0,0 +1,48 @@ +// First-party credential cell backing the vendored inference auth model. +// +// Since the 1ad0104 re-vendor, `InferenceSource` carries no inline secret: +// it names a `credentialId` and every send resolves the secret through a +// `CredentialMaterialResolver` ("credential cell" in upstream terms — read +// `CredentialMaterialResolver`'s doc comment in the vendored +// `@intx/types`). This module is that cell for first-party API-key and +// OAuth access-token sources: each `buildXSource` in `./index.ts` registers +// the secret it was built with under the source id, and the inference entry +// points (`assemble-runtime`, subagent run, summarizer fallback) hand +// `readSourceCredentialMaterial` to the vendored trees as their resolver. +// +// Keyed by source id because ids are unique per live source within a +// process. The map lives at module scope so sources built in one layer +// (config) resolve in another (agent env, reactor options) without threading +// secrets through every intermediate shape. +import type { CredentialMaterialResolver } from "@intx/types"; + +const cell = new Map(); + +export function registerSourceCredential( + credentialId: string, + secret: string, +): void { + cell.set(credentialId, secret); +} + +/** The resolver handed to vendored inference calls. Fails closed. */ +export const readSourceCredentialMaterial: CredentialMaterialResolver = ( + credentialId: string, +) => { + const secret = cell.get(credentialId); + if (secret === undefined) + throw new Error(`Unknown inference credential "${credentialId}".`); + return { secret }; +}; + +/** Non-throwing read for "did the token change?" comparisons. */ +export function peekSourceCredentialSecret( + credentialId: string, +): string | undefined { + return cell.get(credentialId); +} + +/** Test seam: empties the cell between cases. */ +export function clearSourceCredentials(): void { + cell.clear(); +} diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index 352befbc1..8ea8122f9 100644 --- a/src/context-compactor.test.ts +++ b/src/context-compactor.test.ts @@ -953,7 +953,7 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => { provider: "openai", model: "test-model", baseURL: "http://localhost:1", - apiKey: "k", + credentialId: "test", }; let calls = 0; const summarize = createModelSummarizer({ diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 9ee7aa48c..eb53ade0d 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -14,6 +14,10 @@ import { isCodexProviderName, } from "../config/codex-providers.js"; import { xaiProfileFromProviderName } from "../config/xai-providers.js"; +import { + peekSourceCredentialSecret, + registerSourceCredential, +} from "../config/source-credentials.js"; import { formatDirectorSystemPrompt } from "../agent/directors/identity.js"; import { DIRECTOR_REGISTRY } from "../agent/directors/registry.js"; import type { DirectorId, DirectorPackage } from "../agent/directors/types.js"; @@ -772,7 +776,7 @@ export async function runExec(config: Config): Promise { const { access } = await refreshSelectedProviderCredential(() => getValidCodexToken(initialCodexProfile), ); - liveSource = { ...liveSource, apiKey: access }; + registerSourceCredential(liveSource.credentialId, access); liveSubAgentProvider.current = { ...liveSubAgentProvider.current, apiKey: access, @@ -782,7 +786,7 @@ export async function runExec(config: Config): Promise { const { access } = await refreshSelectedProviderCredential(() => getValidXaiToken(initialXaiProfile), ); - liveSource = { ...liveSource, apiKey: access }; + registerSourceCredential(liveSource.credentialId, access); liveSubAgentProvider.current = { ...liveSubAgentProvider.current, apiKey: access, @@ -798,12 +802,13 @@ export async function runExec(config: Config): Promise { // A 401 here usually means the shared OAuth file rotated under another // process; re-read it so the retry runs on the fresh token. refreshAuth: async () => { + const before = peekSourceCredentialSecret(liveSource.credentialId); const fresh = await ensureFreshInferenceSource( liveSource, config.providers, ); - if (fresh.apiKey === liveSource.apiKey) return; liveSource = fresh; + if (peekSourceCredentialSecret(fresh.credentialId) === before) return; if (currentAgent !== null) setAgentSourceUnlessClosed(currentAgent, fresh); }, @@ -859,10 +864,10 @@ export async function runExec(config: Config): Promise { inferenceDeps, getSources: () => { const sources = liveSources.length > 0 ? liveSources : [liveSource]; - // Prefer liveSource credentials on the active id when OAuth was refreshed. - return sources.map((s) => - s.id === liveSource.id ? { ...s, apiKey: liveSource.apiKey } : s, - ); + // OAuth refreshes land in the shared credential cell (keyed by source + // id), so every source already resolves the live secret — no per-send + // credential copy is needed. + return sources; }, getDefaultSource: () => liveDefaultSource.length > 0 ? liveDefaultSource : liveSource.id, @@ -1005,15 +1010,15 @@ export async function runExec(config: Config): Promise { // Final OAuth refresh immediately before send (token may have aged during MCP). if (initialCodexProfile !== undefined) { const { access } = await getValidCodexToken(initialCodexProfile); - if (access !== liveSource.apiKey) { - liveSource = { ...liveSource, apiKey: access }; + if (access !== peekSourceCredentialSecret(liveSource.credentialId)) { + registerSourceCredential(liveSource.credentialId, access); setAgentSourceUnlessClosed(activeAgent, liveSource); } } if (initialXaiProfile !== undefined) { const { access } = await getValidXaiToken(initialXaiProfile); - if (access !== liveSource.apiKey) { - liveSource = { ...liveSource, apiKey: access }; + if (access !== peekSourceCredentialSecret(liveSource.credentialId)) { + registerSourceCredential(liveSource.credentialId, access); setAgentSourceUnlessClosed(activeAgent, liveSource); } } diff --git a/src/session/assemble-runtime.test.ts b/src/session/assemble-runtime.test.ts index 18690e4c2..00cbe6e20 100644 --- a/src/session/assemble-runtime.test.ts +++ b/src/session/assemble-runtime.test.ts @@ -219,7 +219,7 @@ function stubChatAgentWiring( id: "s", provider: "test", baseURL: "http://localhost", - apiKey: "k", + credentialId: "s", model: "m", }, ], diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index 12772c109..9f939a4cd 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -40,6 +40,7 @@ import { type LocalSettings, } from "../config/settings.js"; import type { SessionMode } from "../config/session-mode.js"; +import { readSourceCredentialMaterial } from "../config/source-credentials.js"; import { advertisedTools, advertisedToolNamesForSessionMode, @@ -580,6 +581,10 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { defaultSource: wiring.getDefaultSource(), storage: storageForAgent, workdir, + // Secrets resolve out of the first-party credential cell (see + // ../config/source-credentials.ts): sources name a credentialId and the + // vendored harness reads the secret through this resolver at send time. + readCurrentMaterial: readSourceCredentialMaterial, // contextTransforms ride deps: the published @intx/agent forwards deps // into reactor assembly verbatim, and the vendored assembly picks the // transforms up from there. diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index 4d8718f65..f6162797f 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -24,6 +24,7 @@ import { buildArchiveSummaryExcerpt, type SummaryExcerptArchive, } from "./summary-excerpt.js"; +import { readSourceCredentialMaterial } from "../config/source-credentials.js"; const logger = getLogger([LOG_NAMESPACE_ROOT, "session", "summarizer"]); @@ -200,6 +201,9 @@ function defaultComplete(deps: Dependencies, timeoutMs: number): CompletionFn { signal, nextSeq: () => seq++, deps, + // The source names a credentialId; resolve its secret from the + // first-party cell (see ../config/source-credentials.ts). + readMaterial: readSourceCredentialMaterial, inferenceOptions: { totalTimeoutMs: timeoutMs, retryPolicy: NO_HARNESS_RETRY, diff --git a/src/subagent/refresh-inference-source.test.ts b/src/subagent/refresh-inference-source.test.ts index 7ef9c5712..04cb21d6c 100644 --- a/src/subagent/refresh-inference-source.test.ts +++ b/src/subagent/refresh-inference-source.test.ts @@ -3,12 +3,13 @@ import * as codexSession from "../auth/codex/session.js"; import * as xaiSession from "../auth/xai/session.js"; import type { InferenceSource } from "@intx/types/runtime"; +import { peekSourceCredentialSecret } from "../config/source-credentials.js"; -const baseSource = (id: string, apiKey = "stale"): InferenceSource => ({ +const baseSource = (id: string): InferenceSource => ({ id, provider: "openai", baseURL: "https://api.openai.com/v1", - apiKey, + credentialId: id, model: "gpt-4o", }); @@ -18,15 +19,18 @@ describe("refresh-inference-source", () => { spyOn(xaiSession, "getValidXaiToken").mockRestore(); }); - test("ensureFreshInferenceSource replaces stale Codex apiKey after refresh", async () => { + test("ensureFreshInferenceSource registers the fresh Codex token in the credential cell", async () => { spyOn(codexSession, "getValidCodexToken").mockResolvedValue({ access: "fresh-codex-token", }); const { ensureFreshInferenceSource } = await import("./refresh-inference-source.js"); - const source = baseSource("codex/default", "stale"); + const source = baseSource("codex/default"); const out = await ensureFreshInferenceSource(source, []); - expect(out.apiKey).toBe("fresh-codex-token"); + expect(out).toBe(source); + expect(peekSourceCredentialSecret(source.credentialId)).toBe( + "fresh-codex-token", + ); }); test("refreshInferenceSourceBundle refreshes each leg", async () => { @@ -44,7 +48,7 @@ describe("refresh-inference-source", () => { test("ensureFreshInferenceSource leaves non-OAuth sources unchanged", async () => { const { ensureFreshInferenceSource } = await import("./refresh-inference-source.js"); - const source = baseSource("custom-gateway", "key-abc"); + const source = baseSource("custom-gateway"); const out = await ensureFreshInferenceSource(source, [ { name: "custom-gateway", @@ -53,6 +57,6 @@ describe("refresh-inference-source", () => { apiKey: "key-abc", }, ]); - expect(out.apiKey).toBe("key-abc"); + expect(out).toBe(source); }); }); diff --git a/src/subagent/refresh-inference-source.ts b/src/subagent/refresh-inference-source.ts index 272f8a36a..9af1fe5d1 100644 --- a/src/subagent/refresh-inference-source.ts +++ b/src/subagent/refresh-inference-source.ts @@ -3,6 +3,7 @@ import type { InferenceSource } from "@intx/types/runtime"; import { getValidCodexToken } from "../auth/codex/session.js"; import { getValidXaiToken } from "../auth/xai/session.js"; import type { ProviderCatalogEntry } from "../config/index.js"; +import { registerSourceCredential } from "../config/source-credentials.js"; import { codexProfileFromProviderName } from "../config/codex-providers.js"; import { xaiProfileFromProviderName } from "../config/xai-providers.js"; @@ -18,12 +19,14 @@ export async function ensureFreshInferenceSource( entry?.codexProfile ?? codexProfileFromProviderName(source.id); if (codexProfile !== undefined) { const { access } = await getValidCodexToken(codexProfile); - return { ...source, apiKey: access }; + registerSourceCredential(source.credentialId, access); + return source; } const xaiProfile = entry?.xaiProfile ?? xaiProfileFromProviderName(source.id); if (xaiProfile !== undefined) { const { access } = await getValidXaiToken(xaiProfile); - return { ...source, apiKey: access }; + registerSourceCredential(source.credentialId, access); + return source; } return source; } diff --git a/src/subagent/run-source.test.ts b/src/subagent/run-source.test.ts index 2d2659a4c..0ea2489e8 100644 --- a/src/subagent/run-source.test.ts +++ b/src/subagent/run-source.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { KEYLESS_API_KEY } from "../config/index.js"; +import { peekSourceCredentialSecret } from "../config/source-credentials.js"; import { OPENCODE_GO_BASE_URL } from "../../packages/opencode-go/src/index.js"; import { buildSubAgentPrimarySource } from "./run.js"; @@ -19,9 +19,10 @@ describe("buildSubAgentPrimarySource", () => { id: "ollama/default", provider: "openai-compatible", baseURL: "http://localhost:11434/v1", - apiKey: KEYLESS_API_KEY, + credentialId: "ollama/default", model: "qwen3", }); + expect(peekSourceCredentialSecret("ollama/default")).toBe("keyless"); }); test.each([ @@ -43,9 +44,10 @@ describe("buildSubAgentPrimarySource", () => { expect(source).toMatchObject({ id: providerName, provider: "openai-responses", - apiKey: "sk-go", + credentialId: providerName, model: "gpt-5.6-luna", }); + expect(peekSourceCredentialSecret(providerName)).toBe("sk-go"); expect(typeof sessionId).toBe("string"); expect(sessionId).not.toHaveLength(0); expect(source?.defaults?.providerOptions?.openaiSessionId).toBe( diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 5d3dd94b3..605f16aa3 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -45,6 +45,7 @@ import { buildInferenceSourceForRef, buildSubagentSources, } from "../config/inference-sources.js"; +import { readSourceCredentialMaterial } from "../config/source-credentials.js"; import { assembleInferenceBase } from "../session/assemble-runtime.js"; import { advertiseShellGuardTimeout } from "../plugins/shell-guard-plugin.js"; import { advertiseEditFileLineRange } from "../plugins/edit-file-line-range.js"; @@ -1148,6 +1149,10 @@ async function runSubAgentInner( defaultSource: bundle.defaultSource, storage, workdir, + // Secrets resolve out of the first-party credential cell (see + // ../config/source-credentials.ts): sources name a credentialId and the + // vendored harness reads the secret through this resolver at send time. + readCurrentMaterial: readSourceCredentialMaterial, // contextTransforms ride deps: the published @intx/agent forwards deps // into reactor assembly verbatim, and the vendored assembly picks the // transforms up from there. diff --git a/src/tui/runner/exit.test.ts b/src/tui/runner/exit.test.ts index f93258925..b99eb139f 100644 --- a/src/tui/runner/exit.test.ts +++ b/src/tui/runner/exit.test.ts @@ -129,7 +129,7 @@ const liveSource: InferenceSource = { id: "codex/work", provider: "openai", baseURL: "https://example.test", - apiKey: "old-token", + credentialId: "codex/work", model: "m", }; diff --git a/src/tui/runner/exit.ts b/src/tui/runner/exit.ts index 6c00d0ad2..0738872bd 100644 --- a/src/tui/runner/exit.ts +++ b/src/tui/runner/exit.ts @@ -41,6 +41,10 @@ import { suppressProviderFailurePresentation } from "../provider/failure-attempt import { normalizeInferenceErrorForTerminal } from "../../inference-gateway-error.js"; import { codexProfileFromProviderName } from "../../config/codex-providers.js"; import { xaiProfileFromProviderName } from "../../config/xai-providers.js"; +import { + peekSourceCredentialSecret, + registerSourceCredential, +} from "../../config/source-credentials.js"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; import { cancelFeedbackCapture } from "../../telemetry/feedback.js"; import { @@ -403,10 +407,10 @@ export async function createRunLifecycle( const active = state.activeCodexSource; if (active === undefined) return; const { access } = await getValidCodexToken(active.profile); - const source: InferenceSource = - access === active.source.apiKey - ? active.source - : { ...active.source, apiKey: access }; + const source: InferenceSource = active.source; + if (access !== peekSourceCredentialSecret(source.credentialId)) { + registerSourceCredential(source.credentialId, access); + } state.activeCodexSource = { profile: active.profile, source }; state.liveSource = source; setAgentSourceUnlessClosed(liveAgent(state), source); @@ -416,10 +420,10 @@ export async function createRunLifecycle( const active = state.activeXaiSource; if (active === undefined) return; const { access } = await getValidXaiToken(active.profile); - const source: InferenceSource = - access === active.source.apiKey - ? active.source - : { ...active.source, apiKey: access }; + const source: InferenceSource = active.source; + if (access !== peekSourceCredentialSecret(source.credentialId)) { + registerSourceCredential(source.credentialId, access); + } state.activeXaiSource = { profile: active.profile, source }; state.liveSource = source; setAgentSourceUnlessClosed(liveAgent(state), source); diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index dae36d013..c6326a8cd 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -17,6 +17,7 @@ import { toolWatchdogFromSettings, } from "../../config/settings.js"; import { isCodexProviderName } from "../../config/codex-providers.js"; +import { peekSourceCredentialSecret } from "../../config/source-credentials.js"; import { createGlobalSettingsWriter, createLocalSettingsWriter, @@ -572,12 +573,13 @@ export async function assembleTUISession( // process; re-read it so the retry runs on the fresh token, and keep the // live source in step so the summarizer picks it up. refreshAuth: async () => { + const before = peekSourceCredentialSecret(state.liveSource.credentialId); const fresh = await ensureFreshInferenceSource( state.liveSource, state.config.providers, ); - if (fresh.apiKey === state.liveSource.apiKey) return; state.liveSource = fresh; + if (peekSourceCredentialSecret(fresh.credentialId) === before) return; if (state.currentAgent !== undefined) setAgentSourceUnlessClosed(state.currentAgent, fresh); }, diff --git a/tests/integration/harness.ts b/tests/integration/harness.ts index 01f86caf8..7f057872f 100644 --- a/tests/integration/harness.ts +++ b/tests/integration/harness.ts @@ -32,6 +32,10 @@ import { type } from "arktype"; import { createAgentWithLiveToolDispatch } from "../../src/agent/live-tool-dispatch.js"; import { createChatDirector } from "../../src/agent/director.js"; +import { + readSourceCredentialMaterial, + registerSourceCredential, +} from "../../src/config/source-credentials.js"; import { createAgentToolset } from "../../src/agent/tools.js"; import { ID_PREFIX } from "../../src/branding.js"; import type { PermissionGate } from "../../src/permission/gate.js"; @@ -59,10 +63,14 @@ export const INTEGRATION_SOURCE: InferenceSource = { id: "anthropic:claude-integration", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "sk-integration-test", + credentialId: "anthropic:claude-integration", model: "claude-integration", }; +// The mock inference stack still resolves the secret through the credential +// cell, so the shared fixture registers its dummy key on session setup. +const INTEGRATION_SECRET = "integration-test-key"; + export interface IntegrationSession { harness: Harness; cwd: string; @@ -92,6 +100,7 @@ export async function openIntegrationSession( opts: OpenIntegrationSessionOpts, ): Promise { const harness = setupHarness(); + registerSourceCredential(INTEGRATION_SOURCE.id, INTEGRATION_SECRET); const cwd = mkdtempSync(join(tmpdir(), "corbits-integration-cwd-")); const workdir = join(cwd, ".agent-state", "integration-session"); const evidenceArchiveHolder: { current: CompactionArchive | undefined } = { @@ -207,6 +216,7 @@ export async function openIntegrationSession( defaultSource: INTEGRATION_SOURCE.id, storage: storageForAgent, workdir, + readCurrentMaterial: readSourceCredentialMaterial, deps: { ...harness.deps, ...(opts.contextTransforms !== undefined diff --git a/tests/integration/vendored-carry.test.ts b/tests/integration/vendored-carry.test.ts index 5ad8479af..4859cdba3 100644 --- a/tests/integration/vendored-carry.test.ts +++ b/tests/integration/vendored-carry.test.ts @@ -17,6 +17,10 @@ import type { ContextTransform } from "@intx/types/runtime"; import { type } from "arktype"; import { ID_PREFIX } from "../../src/branding.js"; +import { + readSourceCredentialMaterial, + registerSourceCredential, +} from "../../src/config/source-credentials.js"; import { createPermissionGate } from "../../src/permission/gate.js"; import { createOptimizedContextStore } from "../../src/session/optimized-context-store.js"; import { @@ -148,11 +152,13 @@ describe("integration — vendored feature carry", () => { }); const storage = await createOptimizedContextStore(workdir); + registerSourceCredential(INTEGRATION_SOURCE.id, "integration-test-key"); const agent = await createAgent(def, { sources: [INTEGRATION_SOURCE], defaultSource: INTEGRATION_SOURCE.id, storage, workdir, + readCurrentMaterial: readSourceCredentialMaterial, deps: harness.deps, audit: noopAuditStore(), authorize: permissiveAuthorize(), diff --git a/tests/unit/inference-response-kind.test.ts b/tests/unit/inference-response-kind.test.ts index 9752ea569..b35c08395 100644 --- a/tests/unit/inference-response-kind.test.ts +++ b/tests/unit/inference-response-kind.test.ts @@ -24,6 +24,10 @@ import { withCodexContentTypeRepair, } from "../../src/provider/codex-responses.js"; import { CODEX_RESPONSES_PATH } from "../../src/auth/codex/constants.js"; +import { + readSourceCredentialMaterial, + registerSourceCredential, +} from "../../src/config/source-credentials.js"; const CODEX_URL = `https://chatgpt.com/backend-api${CODEX_RESPONSES_PATH}`; @@ -31,7 +35,7 @@ const CODEX_SOURCE: InferenceSource = { id: "codex/default", provider: CODEX_RESPONSES_PROVIDER, baseURL: "https://chatgpt.com/backend-api", - apiKey: "test-token", + credentialId: "codex/default", model: "gpt-5.6-sol", }; @@ -90,11 +94,13 @@ async function runCodexTurn( scheduler: createDefaultScheduler(), }; let seq = 0; + registerSourceCredential(CODEX_SOURCE.credentialId, "test-token"); return collect( runInference({ turns: [userTurn("hi")], source: CODEX_SOURCE, nextSeq: () => ++seq, + readMaterial: readSourceCredentialMaterial, deps, }), ); diff --git a/tests/unit/summarizer.test.ts b/tests/unit/summarizer.test.ts index 8f63acf11..f0bcc6d5f 100644 --- a/tests/unit/summarizer.test.ts +++ b/tests/unit/summarizer.test.ts @@ -8,13 +8,14 @@ import { DEFAULT_SUMMARIZER_TIMEOUT_MS, } from "../../src/session/summarizer.js"; import type { Telemetry, TelemetryEvent } from "../../src/telemetry/index.js"; +import { registerSourceCredential } from "../../src/config/source-credentials.js"; const source: InferenceSource = { id: "test", provider: "openai", model: "test-model", baseURL: "http://localhost:1", - apiKey: "k", + credentialId: "test", }; function turns(): ConversationTurn[] { @@ -195,13 +196,14 @@ test("summarizer timeout is honoured independently of the director total timeout try { // The stream parks forever; only the summarizer's own timer can end the call. harness.scenario.stall(); + registerSourceCredential("anthropic", "k"); const summarize = createModelSummarizer({ getSource: () => ({ id: "anthropic", provider: "anthropic", model: "claude-test", baseURL: "https://api.anthropic.com", - apiKey: "k", + credentialId: "anthropic", }), deps: harness.deps, timeoutMs: 30_000, diff --git a/vendor/intx-agent/PATCHES.md b/vendor/intx-agent/PATCHES.md index d4c1bc3e8..378195731 100644 --- a/vendor/intx-agent/PATCHES.md +++ b/vendor/intx-agent/PATCHES.md @@ -8,6 +8,13 @@ which lines are ours — run `bin/vendor-patch-diff` to produce it. The `Locally patched — see …#` comments and the entries below are signposts that point into that diff; they do not define its extent. +### 2026-09-14 re-sync (upstream `1ad0104`) + +All three entries re-carried as-is; upstream has no `loadErrors` +(`store.ts` grep is clean) and no error-seq resumption in `createAgent`. +No adaptation needed — upstream did not touch these regions. Upstream's +`credentialId` auth-model rewrite does not intersect these patches. + ## agent-ts-resume-error-seq `agent.ts` — `createAgent` resumes `errorSeq` from `auditStore.loadErrors` diff --git a/vendor/intx-agent/src/agent.ts b/vendor/intx-agent/src/agent.ts index 30d462ed9..8ae10f11b 100644 --- a/vendor/intx-agent/src/agent.ts +++ b/vendor/intx-agent/src/agent.ts @@ -514,10 +514,31 @@ export async function createAgent( // the next flush, so a fourth caller arriving after the follow-up // begins still observes a clean state and starts its own flush. const accumulatedErrors: ErrorRecord[] = []; + // Resume from durable records so a rebuilt agent does not reuse seq 0 + // and collide with files the previous assembly already committed. + // Locally patched — see vendor/intx-agent/PATCHES.md#agent-ts-resume-error-seq let errorSeq = 0; + try { + for (const record of await auditStore.loadErrors(sessionId)) { + if (record.seq >= errorSeq) errorSeq = record.seq + 1; + } + } catch { + logger.warn`loadErrors failed during assembly; starting error seq at 0`; + } let flushInProgress: Promise | undefined; let pendingFollowUp: Promise | undefined; + // File key mirror of the isogit store's error filename scheme + // (`state/errors//-.json`, seq padded to + // 8, unsafe category chars replaced). Maps a + // `Duplicate error record: ` collision back to the batch member + // that caused it so only that record is dropped. + function errorFileKey(record: ErrorRecord): string { + const seq = String(record.seq).padStart(8, "0"); + const category = record.category.replace(/[^a-zA-Z0-9_-]/g, "_"); + return `${record.sessionId}/${seq}-${category}`; + } + function flushErrors(): Promise { if (flushInProgress !== undefined) { // If another caller already arranged a follow-up flush after @@ -551,8 +572,40 @@ export async function createAgent( // expectation is that commitErrors failures are transient. flushInProgress = (async () => { try { - await auditStore.commitErrors(batch); - accumulatedErrors.splice(0, count); + let remaining = batch; + for (;;) { + try { + await auditStore.commitErrors(remaining); + } catch (cause) { + // Locally patched — see vendor/intx-agent/PATCHES.md#agent-ts-duplicate-error-flush + if ( + cause instanceof Error && + cause.message.startsWith("Duplicate error record:") + ) { + const key = cause.message + .slice("Duplicate error record:".length) + .trim(); + const index = remaining.findIndex( + (record) => errorFileKey(record) === key, + ); + if (index === -1) { + logger.warn`duplicate error record already stored; dropping the colliding batch`; + accumulatedErrors.splice(0, count); + return; + } + remaining.splice(index, 1); + logger.warn`duplicate error record already stored; dropping the colliding record`; + if (remaining.length === 0) { + accumulatedErrors.splice(0, count); + return; + } + continue; + } + throw cause; + } + accumulatedErrors.splice(0, count); + return; + } } finally { flushInProgress = undefined; } diff --git a/vendor/intx-agent/src/audit-integration.test.ts b/vendor/intx-agent/src/audit-integration.test.ts index a245f0aba..a3a910ba8 100644 --- a/vendor/intx-agent/src/audit-integration.test.ts +++ b/vendor/intx-agent/src/audit-integration.test.ts @@ -71,6 +71,9 @@ function makeRecordingAuditStore(): RecordingAuditStore { async loadAudit(_sessionId: string): Promise { return committedAudit.flat(); }, + async loadErrors(_sessionId: string): Promise { + return committedErrors.flat(); + }, getCommittedAudit() { return committedAudit; }, diff --git a/vendor/intx-agent/src/flush-errors.test.ts b/vendor/intx-agent/src/flush-errors.test.ts index 81783a97e..187cd5cb5 100644 --- a/vendor/intx-agent/src/flush-errors.test.ts +++ b/vendor/intx-agent/src/flush-errors.test.ts @@ -14,6 +14,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { type } from "arktype"; +import { createDefaultDependencies } from "@intx/inference/providers"; import { createInboundMessage } from "@intx/mime"; import { createIsogitStore } from "@intx/storage-isogit/node"; import type { AuditRecord, ErrorRecord } from "@intx/types/audit"; @@ -59,6 +60,9 @@ function makeRecordingAuditStore(): RecordingAuditStore { async loadAudit(_sessionId: string): Promise { return []; }, + async loadErrors(_sessionId: string): Promise { + return committedErrors.flat(); + }, getCommittedErrors() { return committedErrors; }, @@ -91,6 +95,72 @@ function makeFailFirstAuditStore(): FailingAuditStore { async loadAudit(_sessionId: string): Promise { return []; }, + async loadErrors(_sessionId: string): Promise { + return committedErrors.flat(); + }, + getCommittedErrors() { + return committedErrors; + }, + }; +} + +// Audit store that always throws `Duplicate error record` for the +// leading batch record, simulating a rebuilt assembly flushing an +// already-durable seq. +function makeDuplicateErrorAuditStore(): FailingAuditStore { + return { + async commitAudit(_records: AuditRecord[]): Promise { + // No-op. + }, + async commitErrors(records: ErrorRecord[]): Promise { + throw new Error( + `Duplicate error record: ${records[0]?.sessionId ?? "session"}/00000000-credential_failure`, + ); + }, + async loadAudit(_sessionId: string): Promise { + return []; + }, + async loadErrors(_sessionId: string): Promise { + return []; + }, + getCommittedErrors() { + return []; + }, + }; +} + +// Audit store that collides on the first batch's leading record only, +// simulating a stale-seq assembly flushing [seq0/dup, seq1/fresh]: the +// first `commitErrors` throws `Duplicate error record` naming the +// colliding record's file key, and the retry succeeds. +function makePartialDuplicateAuditStore(): FailingAuditStore { + const committedErrors: ErrorRecord[][] = []; + let firstAttempt = true; + return { + async commitAudit(_records: AuditRecord[]): Promise { + // No-op. + }, + async commitErrors(records: ErrorRecord[]): Promise { + if (firstAttempt) { + firstAttempt = false; + const colliding = records[0]; + const seq = String(colliding?.seq ?? 0).padStart(8, "0"); + const category = (colliding?.category ?? "").replace( + /[^a-zA-Z0-9_-]/g, + "_", + ); + throw new Error( + `Duplicate error record: ${colliding?.sessionId ?? "session"}/${seq}-${category}`, + ); + } + committedErrors.push([...records]); + }, + async loadAudit(_sessionId: string): Promise { + return []; + }, + async loadErrors(_sessionId: string): Promise { + return []; + }, getCommittedErrors() { return committedErrors; }, @@ -154,6 +224,87 @@ async function waitForReactorDone( } } +const FORBIDDEN_DEPS = { + ...createDefaultDependencies(), + fetch: async () => + new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }), +}; + +function credentialFailureDirectors(): BaseEnv["directors"] { + return makeDirectorRegistry( + async ( + event: ReactorInboundEvent, + _state: ReactorState, + caps: ReactorCapabilities, + ) => { + if (event.type === "message.received") return caps.infer(); + if (event.type === "inference.error") { + return [caps.checkpoint("after-error"), caps.done()]; + } + return caps.done(); + }, + ); +} + +function forbiddenAgentDef(id: string) { + return defineAgent({ + id, + systemPrompt: "test", + tools: [], + capabilities: [], + inference: { + sources: [ + { + provider: UNREACHABLE_SOURCE.provider, + model: UNREACHABLE_SOURCE.model, + }, + ], + }, + }); +} + +function duplicateFlushFailures( + events: ReadonlyArray<{ type: string; data?: unknown }>, +): ReadonlyArray<{ type: string; data?: unknown }> { + return events.filter((event) => { + if (event.type !== "reactor.error") return false; + return JSON.stringify(event.data ?? {}).includes("Duplicate error record"); + }); +} + +async function runForbiddenCycle(opts: { + workdir: string; + sessionId: string; + agentId: string; +}): Promise<{ events: Array<{ type: string; data?: unknown }> }> { + const store = await createIsogitStore(opts.workdir); + const env: BaseEnv = { + sources: [UNREACHABLE_SOURCE], + defaultSource: UNREACHABLE_SOURCE.id, + readCurrentMaterial: (credentialId) => ({ secret: credentialId }), + storage: store, + workdir: opts.workdir, + audit: store, + authorize: permissiveAuthorize(), + directors: credentialFailureDirectors(), + sessionId: opts.sessionId, + deps: FORBIDDEN_DEPS, + }; + const agent = await createAgent(forbiddenAgentDef(opts.agentId), env); + const events: Array<{ type: string; data?: unknown }> = []; + const stream = agent.stream(); + try { + agent.deliver(inboundConversation()); + for await (const event of stream) { + events.push(event); + if (event.type === "reactor.done") break; + } + } finally { + await agent.close(); + } + return { events }; +} + describe("agent error flushing", () => { let workDir: string; @@ -471,4 +622,170 @@ describe("agent error flushing", () => { expect(batches.length).toBe(1); expect(batches[0]?.[0]?.source).toBe("reactor"); }); + + test("two credential_failure errors in one session persist without failing the run", async () => { + const sessionId = "session-credential-once"; + let inferenceErrors = 0; + const store = await createIsogitStore(workDir); + const env: BaseEnv = { + sources: [UNREACHABLE_SOURCE], + defaultSource: UNREACHABLE_SOURCE.id, + readCurrentMaterial: (credentialId) => ({ secret: credentialId }), + storage: store, + workdir: workDir, + audit: store, + authorize: permissiveAuthorize(), + directors: makeDirectorRegistry( + async ( + event: ReactorInboundEvent, + _state: ReactorState, + caps: ReactorCapabilities, + ) => { + if (event.type === "message.received") return caps.infer(); + if (event.type === "inference.error") { + inferenceErrors += 1; + if (inferenceErrors === 1) { + return [caps.checkpoint("after-first"), caps.infer()]; + } + return [caps.checkpoint("after-second"), caps.done()]; + } + return caps.done(); + }, + ), + sessionId, + deps: FORBIDDEN_DEPS, + }; + const agent = await createAgent(forbiddenAgentDef("cred-flush-once"), env); + const events: Array<{ type: string; data?: unknown }> = []; + const stream = agent.stream(); + try { + agent.deliver(inboundConversation()); + for await (const event of stream) { + events.push(event); + if (event.type === "reactor.done") break; + } + } finally { + await agent.close(); + } + + expect(duplicateFlushFailures(events)).toEqual([]); + const records = (await store.loadErrors(sessionId)).filter( + (record) => record.category === "credential_failure", + ); + expect(records).toHaveLength(2); + expect(new Set(records.map((record) => record.seq)).size).toBe(2); + }); + + test("two credential_failure errors persist across re-assembly without failing the session", async () => { + const sessionId = "session-credential"; + const first = await runForbiddenCycle({ + workdir: workDir, + sessionId, + agentId: "cred-flush-1", + }); + const second = await runForbiddenCycle({ + workdir: workDir, + sessionId, + agentId: "cred-flush-2", + }); + + expect(duplicateFlushFailures(first.events)).toEqual([]); + expect(duplicateFlushFailures(second.events)).toEqual([]); + const store = await createIsogitStore(workDir); + const records = (await store.loadErrors(sessionId)).filter( + (record) => record.category === "credential_failure", + ); + expect(records).toHaveLength(2); + expect(new Set(records.map((record) => record.seq)).size).toBe(2); + }); + + test("a duplicate error record from commitErrors does not fail the session", async () => { + const audit = makeDuplicateErrorAuditStore(); + const directors = credentialFailureDirectors(); + const def = forbiddenAgentDef("cred-flush-duplicate"); + const env = await buildAgentEnv({ workdir: workDir, audit, directors }); + const agent = await createAgent(def, { ...env, deps: FORBIDDEN_DEPS }); + const events: Array<{ type: string; data?: unknown }> = []; + const stream = agent.stream(); + try { + agent.deliver(inboundConversation()); + for await (const event of stream) { + events.push(event); + if (event.type === "reactor.done") break; + } + } finally { + await agent.close(); + } + + expect(duplicateFlushFailures(events)).toEqual([]); + expect(events.some((event) => event.type === "reactor.done")).toBe(true); + }); + + test("a partial duplicate collision drops only the colliding record", async () => { + // A stale-seq assembly flushing [seq0/dup, seq1/fresh] must persist + // the fresh record: the first commit names only the colliding key, + // so the flush drops that record and retries the rest. + const audit = makePartialDuplicateAuditStore(); + let inferenceErrors = 0; + const directors = makeDirectorRegistry( + async ( + event: ReactorInboundEvent, + _state: ReactorState, + caps: ReactorCapabilities, + ) => { + if (event.type === "message.received") return caps.infer(); + if (event.type === "inference.error") { + inferenceErrors += 1; + if (inferenceErrors === 1) return caps.infer(); + return [caps.checkpoint("after-second"), caps.done()]; + } + return caps.done(); + }, + ); + const def = forbiddenAgentDef("cred-flush-partial-duplicate"); + const env = await buildAgentEnv({ workdir: workDir, audit, directors }); + const agent = await createAgent(def, { ...env, deps: FORBIDDEN_DEPS }); + const events: Array<{ type: string; data?: unknown }> = []; + const stream = agent.stream(); + try { + agent.deliver(inboundConversation()); + for await (const event of stream) { + events.push(event); + if (event.type === "reactor.done") break; + } + } finally { + await agent.close(); + } + + expect(duplicateFlushFailures(events)).toEqual([]); + expect(events.some((event) => event.type === "reactor.done")).toBe(true); + const persisted = audit.getCommittedErrors().flat(); + expect(persisted).toHaveLength(1); + expect(persisted[0]?.seq).toBe(1); + }); + + test("createAgent still assembles when loadErrors throws", async () => { + const audit = makeRecordingAuditStore(); + audit.loadErrors = async () => { + throw new Error("simulated loadErrors failure"); + }; + const directors = credentialFailureDirectors(); + const def = forbiddenAgentDef("cred-flush-load-errors"); + const env = await buildAgentEnv({ workdir: workDir, audit, directors }); + const agent = await createAgent(def, { ...env, deps: FORBIDDEN_DEPS }); + const events: Array<{ type: string; data?: unknown }> = []; + const stream = agent.stream(); + try { + agent.deliver(inboundConversation()); + for await (const event of stream) { + events.push(event); + if (event.type === "reactor.done") break; + } + } finally { + await agent.close(); + } + + expect(events.some((event) => event.type === "reactor.done")).toBe(true); + expect(audit.getCommittedErrors().flat().length).toBeGreaterThan(0); + }); }); diff --git a/vendor/intx-agent/src/testing/audit-noop.ts b/vendor/intx-agent/src/testing/audit-noop.ts index 612d682a7..0b1b42adc 100644 --- a/vendor/intx-agent/src/testing/audit-noop.ts +++ b/vendor/intx-agent/src/testing/audit-noop.ts @@ -25,5 +25,9 @@ export function noopAuditStore(): AuditStore { async loadAudit(_sessionId: string): Promise { return []; }, + // Locally patched — see vendor/intx-agent/PATCHES.md#testing-audit-noop-ts-load-errors + async loadErrors(_sessionId: string): Promise { + return []; + }, }; } diff --git a/vendor/intx-inference/PATCHES.md b/vendor/intx-inference/PATCHES.md index 8ee373d1d..07bab9bb6 100644 --- a/vendor/intx-inference/PATCHES.md +++ b/vendor/intx-inference/PATCHES.md @@ -49,6 +49,40 @@ the carried patches. No entry's disposition changed. Every entry below carries a **Re-carry:** note recording the merge cost and the survivability risk going into the next sync. +### 2026-09-14 re-sync (upstream `1ad0104`) + +Every entry below was re-carried against the new pin; none was dropped as +upstream-absorbed (verified by grepping the new upstream tree for each +patch's concept — `isStreamTerminal`, `isPollOnlyPendingBatch`, +`doomLoopPolicy`/`fail-run`, `ephemeralTurns`, `pendingCompactOutput`, +`lastWrittenTurnsRevision`, `deepFreeze`, `MAX_LINE_LENGTH`, +`loadErrors`, `stopReason` — all absent upstream). Adaptations where the +new upstream moved under the patch: + +- `assembly.ts` now carries a direct `contextTransforms` field; the + deps-riding patch now resolves direct-wins-over-deps + (`resolvedContextTransforms`, `resolvedIsPollOnlyPendingBatch`). Kill + condition still open: upstream `@intx/agent` 0.3.0 forwards no + `contextTransforms`, so the `deps` channel remains the only path. +- `harness.ts` usage emission is now object-literal style; the + stop-reason spreads were rewritten to match. The four + `classifyAbortError()` sites are unchanged upstream, so the + `signal?.reason` adaptation re-applied as-is. +- `reactor.ts` `tryCorrelate` region was restructured upstream; the + correlating-ids `try/finally` was re-wrapped around the larger block. + The fail-run queue purge composes with upstream's `CYCLE_EVENT_TYPES` + set. `ExtendedInferenceOptions` is defined in `reactor.ts` with its + marker and re-exported from `index.ts` alongside + `PollBatchLivenessPredicate`. +- Upstream deleted `packages/types/src/sidecar-placement.ts` and + `packages/inference/src/providers/google-genai.test.ts`; no ledger + entry lived in either file, so nothing was triaged out with them. +- Upstream replaced inline `apiKey` with a `credentialId` + credential-cell + auth model. No entry touches auth, and first-party callers needed no + migration: provider auth resolves inside the vendored trees + (`createDefaultDependencies`), and no `src/` caller passes provider + credentials into them. + ## adapter-ts-stream-terminal-detector `adapter.ts` — Adds `StreamTerminalDetector`/`ProviderAdapter.isStreamTerminal`. @@ -394,6 +428,24 @@ with revision tracking. **Re-carry:** clean three-way at `0205b07b`, zero conflicts. Low risk — `state.ts` sees little upstream churn. +## reactor-test-frozen-turns-mutation + +`reactor.test.ts` — the "director cannot corrupt reactor state" test wraps +its snapshot-mutation probe in try/catch: upstream's `snapshot()` returns +mutable clones so the assignment succeeds silently, but with +`state-ts-deep-freeze-turns-revision` carried the same assignment throws on +the frozen turn, which the reactor treats as a fatal director exception and +which would fail the test before it reaches its isolation assertion. +Isolation still holds when the throw is ignored. + +**Disposition:** Re-carryable — sits inside the one test that probes snapshot +mutability and must track the deep-freeze patch. +**Removal path:** Upstream PR freezing snapshots (or upstream test tolerating +the throw) subsumes it. +**Re-carry:** previously unledgered at `0205b07b` (local-only test hunk, found +by diffing the base tree); now ledgered with a marker. Re-applied by hand at +the new pin — upstream's probe is still the bare assignment. + ## google-genai-files-ts-body-init-cast `providers/google-genai-files.ts` — Casts `opts.bytes as unknown as BodyInit` diff --git a/vendor/intx-inference/src/adapter.ts b/vendor/intx-inference/src/adapter.ts index 04c9efc52..608f5cf69 100644 --- a/vendor/intx-inference/src/adapter.ts +++ b/vendor/intx-inference/src/adapter.ts @@ -72,12 +72,27 @@ export type RetryAfterExtractor = (headers: Headers) => number | undefined; // wait before the next request, or undefined if no pacing is needed. export type PacingExtractor = (headers: Headers) => number | undefined; +// Reports whether an SSE data payload is the protocol's end-of-turn signal. +// Most protocols end the stream with the `[DONE]` sentinel (stripped by +// `parseSSE`) or by closing the socket, and need no such predicate. The +// OpenAI Responses protocol does neither: it marks completion with a semantic +// `response.completed` event and holds the connection open, so a client that +// waits for socket close hangs. Adapters for those protocols implement this so +// the harness stops reading once the terminal event is processed. +// +// Locally patched — see vendor/intx-inference/PATCHES.md#adapter-ts-stream-terminal-detector +export type StreamTerminalDetector = (sseData: string) => boolean; + export type ProviderAdapter = { buildRequest: RequestBuilder; parseResponse: ResponseParser; parseJSONResponse: JSONResponseParser; extractRetryAfterMs?: RetryAfterExtractor; extractPacingDelayMs?: PacingExtractor; + // When present, the harness stops reading the SSE stream after processing + // the events from the chunk this returns true for. Absent means the stream + // ends only on `[DONE]` or socket close. + isStreamTerminal?: StreamTerminalDetector; }; // Builds a fresh adapter for one inference call. Invoked per call so the diff --git a/vendor/intx-inference/src/assembly.ts b/vendor/intx-inference/src/assembly.ts index 538483e90..a118fefa9 100644 --- a/vendor/intx-inference/src/assembly.ts +++ b/vendor/intx-inference/src/assembly.ts @@ -31,7 +31,7 @@ import { type AuthzExtensionOptions, } from "./authz-extension"; import type { CorrelationValidator } from "./correlation"; -import type { Dependencies } from "./harness"; +import type { Dependencies, PollBatchLivenessPredicate } from "./harness"; import { createReactor, type Reactor, @@ -89,6 +89,13 @@ export type ReactorAssemblyConfig = { beforeToolExtensions?: BeforeToolExtension[]; toolResultTransforms?: ToolResultTransform[]; contextTransforms?: ContextTransform[]; + /** + * Liveness policy for the doom-loop guard's batch accounting. A direct + * value wins over the one riding `deps`. + * + * Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-poll-exemption + */ + isPollOnlyPendingBatch?: PollBatchLivenessPredicate; compactors?: Record; sizeCapMaxChars?: number; @@ -143,6 +150,7 @@ export function createReactorAssembly( beforeToolExtensions: callerBeforeToolExtensions, toolResultTransforms: callerToolResultTransforms, contextTransforms, + isPollOnlyPendingBatch, compactors, sizeCapMaxChars, afterCheckpoint: callerAfterCheckpoint, @@ -242,6 +250,23 @@ export function createReactorAssembly( } : callerOnShutdown; + // Transforms arrive either directly on the assembly config or riding + // `deps` (the only channel the published `@intx/agent` forwards verbatim). + // A direct value wins so callers composing their own assembly are + // unaffected by whatever a shared deps object carries. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#assembly-ts-deps-context-transforms + const resolvedContextTransforms = contextTransforms ?? deps.contextTransforms; + + // The liveness policy resolves the same way: directly on the assembly + // config, or riding `deps` (the only channel the published `@intx/agent` + // forwards verbatim). A direct value wins so callers composing their own + // assembly are unaffected by whatever a shared deps object carries. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-poll-exemption + const resolvedIsPollOnlyPendingBatch = + isPollOnlyPendingBatch ?? deps.isPollOnlyPendingBatch; + // exactOptionalPropertyTypes is on: only set optional keys when defined. const reactorConfig: ReactorConfig = { sessionId, @@ -258,7 +283,12 @@ export function createReactorAssembly( ...(composedBeforeToolExtensions !== undefined ? { beforeToolExtensions: composedBeforeToolExtensions } : {}), - ...(contextTransforms !== undefined ? { contextTransforms } : {}), + ...(resolvedContextTransforms !== undefined + ? { contextTransforms: resolvedContextTransforms } + : {}), + ...(resolvedIsPollOnlyPendingBatch !== undefined + ? { isPollOnlyPendingBatch: resolvedIsPollOnlyPendingBatch } + : {}), ...(compactors !== undefined ? { compactors } : {}), ...(composedAfterCheckpoint !== undefined ? { afterCheckpoint: composedAfterCheckpoint } diff --git a/vendor/intx-inference/src/authz-extension.ts b/vendor/intx-inference/src/authz-extension.ts index d5f8f8527..d7dfdf3b0 100644 --- a/vendor/intx-inference/src/authz-extension.ts +++ b/vendor/intx-inference/src/authz-extension.ts @@ -25,6 +25,7 @@ import type { ApprovalSnapshot, BeforeToolExtension, PendingOperation, + ToolCall, ToolDefinition, } from "@intx/types/runtime"; import type { Effect } from "@intx/types/authz"; @@ -48,6 +49,8 @@ export type AuthzCallResult = { effect: Effect | null; matchingGrants: AuthzMatchedGrant[]; resolvedBy: AuthzMatchedGrant | null; + // Locally patched — see vendor/intx-inference/PATCHES.md#authz-ts-deny-reason + reason?: string; }; export type AuthzDecision = { @@ -92,10 +95,13 @@ function formatBlockReason( effect: BlockEffect, resource: string, action: string, + detail?: string, ): string { switch (effect) { case "deny": - return `Denied by policy: ${resource}/${action}`; + return detail !== undefined && detail.length > 0 + ? `Denied by policy: ${detail}` + : `Denied by policy: ${resource}/${action}`; case null: return `No matching grants for ${resource}/${action}`; } @@ -118,13 +124,11 @@ function safeOnDecision( export function createAuthzExtension( opts: AuthzExtensionOptions, ): BeforeToolExtension { - // The reactor does not know workflow concepts; per-call context is the - // caller's domain. The third arg is plumbing here -- if the caller - // needs to attach context (workflow step, tenant id, request id), they - // do so by closure on the authorize function. The empty object is the - // safe default at this layer. - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the inference layer has no domain knowledge to construct a Ctx; callers that need a populated context use closure capture on the authorize function (see @intx/workflow's AuthorizeContext) - const emptyContext = Object.freeze({}) as Ctx; + // Per-call context for the authorize callback: the call itself, frozen. + // Locally patched — see vendor/intx-inference/PATCHES.md#authz-ts-authorize-call-context + const frozenCallContext = (call: ToolCall): Ctx => + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- shape-freeze hygiene; the call is the per-call identity the Ctx contract exists to carry + Object.freeze(call) as Ctx; // One-shot bypass tokens, keyed on ToolCall.id. A token authorizes a single // re-dispatch of an already-approved call to skip the `ask` gate it would @@ -154,7 +158,7 @@ export function createAuthzExtension( let result: AuthzCallResult; try { - result = await opts.authorize(resource, action, emptyContext); + result = await opts.authorize(resource, action, frozenCallContext(call)); } catch (cause) { const msg = cause instanceof Error ? cause.message : String(cause); const decision: AuthzDecision = { @@ -179,7 +183,12 @@ export function createAuthzExtension( // are blocks. const blockReason = result.effect === "deny" || result.effect === null - ? formatBlockReason(result.effect, resource, action) + ? formatBlockReason( + result.effect, + resource, + action, + result.effect === "deny" ? result.reason : undefined, + ) : undefined; const decision: AuthzDecision = { diff --git a/vendor/intx-inference/src/errors.ts b/vendor/intx-inference/src/errors.ts index 75207ce02..93cc78c86 100644 --- a/vendor/intx-inference/src/errors.ts +++ b/vendor/intx-inference/src/errors.ts @@ -43,8 +43,22 @@ export function classifyNetworkError(cause: unknown): InferenceError { return { category: "retryable", message, raw: cause }; } -export function classifyAbortError(): InferenceError { - return { category: "aborted", message: "inference aborted" }; +/** + * `origin` mirrors AbortSignal.reason from the send path + * (e.g. intercode `user-stop` / `internal-recovery` string literals). + * + * Locally patched — see vendor/intx-inference/PATCHES.md#errors-ts-classify-abort-reason + */ +export type ClassifiedAbortRaw = { origin: unknown }; + +export function classifyAbortError(reason?: unknown): InferenceError { + const raw: ClassifiedAbortRaw | undefined = + reason !== undefined ? { origin: reason } : undefined; + return { + category: "aborted", + message: "inference aborted", + ...(raw !== undefined ? { raw } : {}), + }; } export function classifyTimeoutError( diff --git a/vendor/intx-inference/src/harness.ts b/vendor/intx-inference/src/harness.ts index cdc3501d5..45bf6cbd8 100644 --- a/vendor/intx-inference/src/harness.ts +++ b/vendor/intx-inference/src/harness.ts @@ -19,6 +19,7 @@ import type { CitationBlock, CodeExecutionRequestBlock, CodeExecutionResultBlock, + ContextTransform, ConversationTurn, ImageBlock, InferenceError, @@ -30,6 +31,8 @@ import type { RetryDecision, SafetyRatingBlock, TokenUsage, + ToolCall, + ToolResult, AssistantTurn, ContentBlock, } from "@intx/types/runtime"; @@ -77,6 +80,21 @@ export const DEFAULT_TOTAL_TIMEOUT_MS = 600_000; export const HarnessId: unique symbol = Symbol("HarnessId"); +/** + * Liveness policy for the doom-loop guard's batch accounting. Receives the + * executed calls of one tool turn with their results aligned by index and + * returns true when the batch is legitimate liveness rather than a runaway + * loop. First-party runtimes recognize still-pending polls (`wait_agents` + * timeouts and live wait statuses, `running` shell collects); terminal polls, + * non-poll calls, and mixed batches return false so real loops still trip. + * + * Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-poll-exemption + */ +export type PollBatchLivenessPredicate = ( + calls: readonly ToolCall[], + results: readonly ToolResult[], +) => boolean; + /** * Runtime dependencies injected into `runInference`. Code-only — not part of * any persisted schema. Test harnesses substitute `fetch` (and stamp the @@ -117,6 +135,39 @@ export type Dependencies = { * built-in set, `@intx/inference/providers`' `createDefaultDependencies()`. */ readonly adapters: AdapterRegistry; + /** + * Pre-inference context transforms applied in order before every model + * call. Carried on Dependencies because the published `@intx/agent` + * forwards `deps` into reactor assembly verbatim while exposing no env + * field for transforms; riding `deps` reaches the vendored assembly + * without modifying the published package. + * + * Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts-context-transforms + */ + readonly contextTransforms?: ContextTransform[]; + /** + * Liveness policy for the doom-loop guard's batch accounting. When the + * batch about to be counted is a legitimate liveness signal (first-party + * runtimes recognize still-pending polls), the stale streak resets instead + * of counting. Optional — a custom `Dependencies` object without this + * field counts every batch, same as before. + * + * Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-poll-exemption + */ + readonly isPollOnlyPendingBatch?: PollBatchLivenessPredicate; + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-warning-turn + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-fail-run + /** Note appended to each tool result when a batch repeat hits threshold−1. */ + readonly doomLoopCorrectiveNote?: (repeat: { + calls: readonly ToolCall[]; + repeatCount: number; + threshold: number; + }) => string | undefined; + /** + * `"fail-run"` closes the tripped message run and returns the reactor to + * idle instead of shutting down. Unset is `"shutdown"`, same as upstream. + */ + readonly doomLoopPolicy?: "shutdown" | "fail-run"; readonly [HarnessId]?: symbol; }; @@ -297,6 +348,8 @@ async function* runSingleAttempt( // capture). Appended to the finalized turn after indexed blocks. const unindexedSafetyRatings: SafetyRatingBlock[] = []; let usageSeen: TokenUsage | null = null; + // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call + let stopReason: string | undefined; // Tool call state: keyed by callId (or index for OpenAI). type ToolCallState = { @@ -312,7 +365,7 @@ async function* runSingleAttempt( yield { type: "inference.error", seq: nextSeq(), - data: { error: classifyAbortError(), partial: snapshotPartial(partial) }, + data: { error: classifyAbortError(signal?.reason), partial: snapshotPartial(partial) }, }; return; } @@ -434,7 +487,7 @@ async function* runSingleAttempt( type: "inference.error", seq: nextSeq(), data: { - error: classifyAbortError(), + error: classifyAbortError(signal?.reason), partial: snapshotPartial(partial), }, }; @@ -552,9 +605,28 @@ async function* runSingleAttempt( return; } for await (const sseData of parseSSE(responseBody)) { - // Reset inactivity timer — we just got something from the wire. - armInactivity(); - yield adapter.parseResponse(sseData); + const rawEvents = adapter.parseResponse(sseData); + // Reset the inactivity watchdog only on semantic progress — events the + // adapter actually parsed out of this chunk (content, thinking, tool + // calls, usage). Provider keep-alives and lifecycle envelopes parse to + // zero events; letting raw bytes re-arm the timer means a stream that + // trickles keep-alives forever without a terminal event never trips the + // watchdog and pins the caller indefinitely. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts-inactivity-on-semantic-progress + if (rawEvents.length > 0) { + armInactivity(); + } + yield rawEvents; + // Protocols whose end-of-turn is a semantic event (OpenAI Responses) + // rather than `[DONE]` or a socket close would otherwise block on the + // next read forever. Stop once the terminal event's own events (e.g. + // its usage) have been processed above. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts-is-stream-terminal + if (adapter.isStreamTerminal?.(sseData)) { + return; + } } }; @@ -583,7 +655,7 @@ async function* runSingleAttempt( type: "inference.error", seq: nextSeq(), data: { - error: classifyAbortError(), + error: classifyAbortError(signal?.reason), partial: snapshotPartial(partial), }, }; @@ -1024,10 +1096,18 @@ async function* runSingleAttempt( // synthesizes its own descriptor cannot drift from the // call-start identity the rest of the harness commits to. usageSeen = mergeUsage(usageSeen, raw.data.usage); + // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call + if (raw.data.stopReason !== undefined) { + stopReason = raw.data.stopReason; + } yield { type: "inference.usage", seq: nextSeq(), - data: { usage: usageSeen, source: lastCycleSource }, + data: { + usage: usageSeen, + ...(stopReason === undefined ? {} : { stopReason }), + source: lastCycleSource, + }, }; break; } @@ -1058,7 +1138,7 @@ async function* runSingleAttempt( type: "inference.error", seq: nextSeq(), data: { - error: classifyAbortError(), + error: classifyAbortError(signal?.reason), partial: snapshotPartial(partial), }, }; @@ -1076,18 +1156,70 @@ async function* runSingleAttempt( } // Finalize any open tool calls that never received an explicit end event. - const completedToolCalls: ContentBlock[] = []; + // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call: + // validate every open call before emitting any of them, and never + // dispatch a call whose arguments are incomplete or unparseable. A turn + // cut at max_tokens with calls still open is unambiguous truncation; + // anything else unparseable is still not a normal call. Both yield an + // inference.error (category retryable) naming the call; post-commit the + // harness surfaces it terminally rather than mechanically retrying, so + // the message guides the model's next attempt. + const finalizedToolCalls: { + tc: ToolCallState; + parsedArgs: Record; + }[] = []; for (const tc of openToolCalls.values()) { - let parsedArgs: Record; + if (stopReason === "max_tokens") { + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: { + category: "retryable", + message: + `Tool call '${tc.name}' (${tc.callId}) was not executed: the provider ended the turn ` + + `at max_tokens while its arguments were still streaming (truncated input). ` + + `Retry the turn with a larger max_tokens budget or a smaller request so the full tool call fits.`, + }, + partial: snapshotPartial(partial), + }, + }; + return; + } + let parsed: unknown; try { const raw = tc.argsBuffer.trim() === "" ? "{}" : tc.argsBuffer; - const parsed = JSON.parse(raw); - const validated = ParsedToolArgs(parsed); - parsedArgs = validated instanceof type.errors ? {} : validated; + parsed = JSON.parse(raw); } catch { - parsedArgs = { _raw: tc.argsBuffer }; + const tail = + tc.argsBuffer.length > 200 + ? `…${tc.argsBuffer.slice(-200)}` + : tc.argsBuffer; + yield { + type: "inference.error", + seq: nextSeq(), + data: { + error: { + category: "retryable", + message: + `Tool call '${tc.name}' (${tc.callId}) was not executed: its streamed arguments are not ` + + `valid JSON and cannot be dispatched as a normal call. Re-issue the turn; ` + + `partial argument text ends with: ${JSON.stringify(tail)}.`, + }, + partial: snapshotPartial(partial), + }, + }; + return; } + const validated = ParsedToolArgs(parsed); + finalizedToolCalls.push({ + tc, + parsedArgs: validated instanceof type.errors ? {} : validated, + }); + } + const completedToolCalls: ContentBlock[] = []; + for (const { tc, parsedArgs } of finalizedToolCalls) { completedToolCalls.push({ type: "tool_call", id: tc.callId, @@ -1335,29 +1467,43 @@ async function* runSingleAttempt( * `runSingleAttempt` and consults the configured `RetryPolicy` (or the * default from `createDefaultRetryPolicy`) on every `inference.error`. * - * Events from each attempt are buffered until the attempt terminates; - * the wrapper only flushes them to the caller once it knows whether - * the attempt resolved (`inference.done` or a policy-approved abort) - * or whether the attempt's events should be discarded in favour of a - * retry. The buffer-and-flush model is what guarantees the caller - * sees a single clean event stream — exactly one `inference.start`, - * no orphaned partial deltas, no leaked `inference.error`s from - * attempts the policy chose to retry. The cost is that no events - * reach the caller until the wrapper knows the attempt's terminal - * shape, even on a successful first attempt. That trade-off is the - * deliberate consequence of making "one clean stream" a hard contract - * rather than a best-effort one. Consumers that need token-by-token - * partials must pin a custom non-buffering wrapper — no streaming- - * partials emission API exists today. + * Commitment boundary. An attempt is "uncommitted" until it yields its + * first content-bearing event — the first `inference.text.delta`, + * `inference.thinking.delta`, tool-call event, or any other block + * event (see `isCommitting`). Up to that point the only events an + * attempt produces are `inference.start` and any message-start + * `inference.usage`; those are held in a small pre-commit buffer. The + * moment the first committing event arrives the wrapper flushes that + * buffer and, from then on, streams every event straight to the caller + * as it arrives — token-by-token, no terminal burst. + * + * Retry is only possible while an attempt is uncommitted: nothing + * visible has reached the caller yet, so discarding a failed + * uncommitted attempt leaks no events. A retryable failure that lands + * *after* commitment cannot un-emit the deltas already delivered, so + * retry is suppressed and the `inference.error` is surfaced on the one + * live stream — the caller sees a coherent prefix followed by the + * error rather than a silently restarted response. This is the + * deliberate cost of incremental delivery. + * + * The pre-commit buffer holds at most the handful of metadata events + * an attempt emits before its first token, so it does not grow with + * output length: a long response streams through without the wrapper + * ever retaining a per-token snapshot, keeping memory linear in output + * size rather than quadratic. + * + * The single-clean-stream contract still holds: exactly one + * `inference.start`, no orphaned partial deltas, and no leaked + * `inference.error` from an attempt the policy chose to retry (only + * uncommitted attempts are ever retried). * - * The buffer is per-call and bounded by the size of one attempt's - * event stream — no cross-call accumulation. + * Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts-commitment-boundary-streaming * * Caller-visible seqs stay contiguous across retries. Each attempt - * runs against a private seq allocator; on flush the wrapper - * re-stamps the buffered events with seqs from the caller's - * `nextSeq`, so a retry that discards an attempt does not leave a - * gap in the consumer's seq stream. + * runs against a private seq allocator; the wrapper re-stamps every + * event with a seq from the caller's `nextSeq` as it is emitted, so a + * retry that discards an attempt does not leave a gap in the + * consumer's seq stream. * * Between attempts the wrapper emits one `inference.retry` event with * the failed attempt's number, the policy-chosen `delayMs`, and the @@ -1380,11 +1526,12 @@ async function* runSingleAttempt( * * Synchronous throws from `runSingleAttempt` (`ProtocolMismatchError` * raised by the streaming parse or the finalization walk, etc.) - * propagate out of `runInference`. The current attempt's buffered - * events are discarded along with the throw — those represent - * protocol bugs the policy mechanism is not equipped to absorb, and - * the caller's `for await` rejects so the failure surfaces rather - * than being silently buffered. + * propagate out of `runInference`. Any events already streamed for the + * committed prefix stay delivered; any still-buffered pre-commit + * events are dropped along with the throw. These represent protocol + * bugs the policy mechanism is not equipped to absorb, so the caller's + * `for await` rejects and the failure surfaces rather than being + * silently swallowed. */ export async function* runInference( opts: InferenceHarnessOptions, @@ -1430,8 +1577,17 @@ export async function* runInference( const signal = opts.signal; for (let attempt = 1; ; attempt++) { - const buffered: InferenceEvent[] = []; - let terminalError: InferenceError | undefined; + // Metadata an attempt emits before it commits (see `isCommitting`): + // `inference.start` and any message-start `inference.usage`. This + // buffer never accumulates per-token deltas — committed content + // streams straight to the caller — so it stays bounded regardless + // of output length, and a discarded pre-commit retry has nothing + // visible to retract. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts-commitment-boundary-streaming + const preCommit: InferenceEvent[] = []; + let committed = false; + let failure: { event: InferenceEvent; error: InferenceError } | undefined; // Per-attempt private allocator. `runSingleAttempt` allocates a // seq for every event it yields; if the attempt is discarded on @@ -1439,31 +1595,66 @@ export async function* runInference( // gap in the consumer's stream — indistinguishable from the // "missed events during brief disconnection" the seq stream is // documented to expose. Allocate from a private counter here and - // re-stamp the buffer with caller-visible seqs at flush time. + // re-stamp each event with a caller-visible seq as it is emitted. let attemptSeq = 0; const attemptOpts: InferenceHarnessOptions = { ...opts, nextSeq: () => attemptSeq++, }; for await (const event of runSingleAttempt(attemptOpts)) { - buffered.push(event); + if (event.type === "inference.done") { + // A `done` on an uncommitted attempt (e.g. an empty response) + // still needs its buffered metadata flushed ahead of it. + if (!committed) { + for (const buffered of preCommit) { + yield { ...buffered, seq: opts.nextSeq() }; + } + } + yield { ...event, seq: opts.nextSeq() }; + return; + } + if (event.type === "inference.error") { - terminalError = event.data.error; + if (committed) { + // Failure after visible output began. The deltas already + // delivered cannot be retracted, so retry is off the table: + // surface the error on the single live stream and stop. + yield { ...event, seq: opts.nextSeq() }; + return; + } + failure = { event, error: event.data.error }; break; } - if (event.type === "inference.done") { - break; + + if (!committed && isCommitting(event)) { + committed = true; + for (const buffered of preCommit) { + yield { ...buffered, seq: opts.nextSeq() }; + } + preCommit.length = 0; + } + + if (committed) { + yield { ...event, seq: opts.nextSeq() }; + } else { + preCommit.push(event); } } - if (terminalError === undefined) { - // Successful attempt. Re-stamp the buffer with caller-visible - // seqs (the private allocator's values are discarded) and - // flush in order. - for (const event of buffered) yield { ...event, seq: opts.nextSeq() }; + // Only an uncommitted terminal error reaches here; the committed + // error path and every `done` path returned inside the loop. + if (failure === undefined) { + // `runSingleAttempt` always ends in error or done; an attempt + // that yields neither is an upstream contract violation. Flush + // whatever metadata buffered so nothing is silently swallowed. + for (const buffered of preCommit) { + yield { ...buffered, seq: opts.nextSeq() }; + } return; } + const terminalError = failure.error; + // Consult the policy. Sync throws and Promise rejections both // resolve to an abort decision; the original inference.error // surfaces to the caller, not the policy's exception. The @@ -1485,10 +1676,13 @@ export async function* runInference( } if (decision.kind === "abort") { - // Flush the buffer (including the terminal inference.error) - // with re-stamped caller-visible seqs and return. No - // `inference.retry` event is emitted on the abort path. - for (const event of buffered) yield { ...event, seq: opts.nextSeq() }; + // Flush the buffered pre-commit metadata, then the terminal + // `inference.error`, all with re-stamped caller-visible seqs, and + // return. No `inference.retry` event is emitted on the abort path. + for (const buffered of preCommit) { + yield { ...buffered, seq: opts.nextSeq() }; + } + yield { ...failure.event, seq: opts.nextSeq() }; return; } @@ -1543,6 +1737,31 @@ export async function* runInference( } } +/** + * An attempt "commits" the moment it emits its first content-bearing + * event — anything the model actually produced (text, thinking, tool + * calls, images, code execution, citations, refusals). Once such an + * event has been streamed to the caller it cannot be un-emitted, so + * `runInference` may no longer retry that attempt. + * + * `inference.start` and `inference.usage` are metadata, not model + * output: they carry nothing the caller would notice as a restarted + * response, so they are buffered rather than committing. `inference.done`, + * `inference.error`, and `inference.retry` are terminal or wrapper-owned + * and are handled by `runInference` before this predicate is consulted. + * + * Locally patched — see vendor/intx-inference/PATCHES.md#harness-ts-is-committing + */ +function isCommitting(event: InferenceEvent): boolean { + switch (event.type) { + case "inference.start": + case "inference.usage": + return false; + default: + return true; + } +} + /** * Combine an optional caller-supplied `AbortSignal` with the harness's * internal timeout-driven controller into a single signal the fetch diff --git a/vendor/intx-inference/src/index.ts b/vendor/intx-inference/src/index.ts index bd19dfd23..2fba587bd 100644 --- a/vendor/intx-inference/src/index.ts +++ b/vendor/intx-inference/src/index.ts @@ -10,6 +10,7 @@ export { export type { Dependencies, InferenceHarnessOptions, + PollBatchLivenessPredicate, Scheduler, } from "./harness"; export type { @@ -47,7 +48,12 @@ export type { export { createInboundTurn, assertWellFormedToolSequence } from "./turns"; export { createReactor } from "./reactor"; -export type { Reactor, ReactorConfig, ReactorEmittedEvent } from "./reactor"; +export type { + ExtendedInferenceOptions, + Reactor, + ReactorConfig, + ReactorEmittedEvent, +} from "./reactor"; export { validateActions } from "./actions"; export type { ValidationResult } from "./actions"; export { createGateManager } from "./gates"; diff --git a/vendor/intx-inference/src/providers/anthropic.ts b/vendor/intx-inference/src/providers/anthropic.ts index fe9fa2ab7..7888aa844 100644 --- a/vendor/intx-inference/src/providers/anthropic.ts +++ b/vendor/intx-inference/src/providers/anthropic.ts @@ -39,6 +39,8 @@ export const ADAPTIVE_THINKING_MODELS: ReadonlySet = new Set([ "claude-sonnet-5", "claude-opus-5", "claude-fable-5", + // Locally patched — see vendor/intx-inference/PATCHES.md#providers-ts-anthropic-adaptive-fable-5-1 + "claude-fable-5-1", "claude-opus-4-8", "claude-opus-4-6", "claude-opus-4-7", @@ -552,6 +554,8 @@ const ContentBlockStop = type({ const MessageDelta = type({ type: "'message_delta'", + // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call + "delta?": { "stop_reason?": "string" }, "usage?": { "output_tokens?": "number" }, }); @@ -817,11 +821,17 @@ function parseResponse( cacheWrite: 0, thinking: 0, }; + // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call + const stopReason = event.delta?.stop_reason; return [ { type: "inference.usage", seq, - data: { usage: inferenceUsage, source }, + data: { + usage: inferenceUsage, + ...(stopReason === undefined ? {} : { stopReason }), + source, + }, }, ]; } @@ -870,6 +880,8 @@ const NonStreamingUsage = type({ const NonStreamingMessage = type({ type: "'message'", content: "unknown[]", + // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call + "stop_reason?": "string", usage: NonStreamingUsage, }); @@ -1065,7 +1077,13 @@ function parseJSONResponse( events.push({ type: "inference.usage", seq, - data: { usage: toInferenceUsage(message.usage), source }, + data: { + usage: toInferenceUsage(message.usage), + ...(message.stop_reason === undefined + ? {} + : { stopReason: message.stop_reason }), + source, + }, }); return events; diff --git a/vendor/intx-inference/src/providers/google-genai-files.ts b/vendor/intx-inference/src/providers/google-genai-files.ts index 850e25460..d1fe2e3e5 100644 --- a/vendor/intx-inference/src/providers/google-genai-files.ts +++ b/vendor/intx-inference/src/providers/google-genai-files.ts @@ -169,7 +169,9 @@ export async function uploadGoogleGenAIFile( const init: RequestInit = { method: "POST", headers, - body: opts.bytes, + // DOM lib BodyInit is narrower than Node's Uint8Array typing; fetch accepts bytes. + // Locally patched — see vendor/intx-inference/PATCHES.md#google-genai-files-ts-body-init-cast + body: opts.bytes as unknown as BodyInit, }; // `RequestInit.signal` is typed as `AbortSignal | null` under // `exactOptionalPropertyTypes`; only attach the property when diff --git a/vendor/intx-inference/src/providers/google-genai.ts b/vendor/intx-inference/src/providers/google-genai.ts index 3e4069a16..af61f329f 100644 --- a/vendor/intx-inference/src/providers/google-genai.ts +++ b/vendor/intx-inference/src/providers/google-genai.ts @@ -1461,7 +1461,14 @@ function parseResponse( out.push({ type: "inference.usage", seq, - data: { usage: tokenUsage, source }, + // Locally patched — see vendor/intx-inference/PATCHES.md#inference-ts-cl-7783-truncated-tool-call + data: { + usage: tokenUsage, + ...(candidate.finishReason === undefined + ? {} + : { stopReason: candidate.finishReason }), + source, + }, }); // Terminal events seal the response. A still-pending diff --git a/vendor/intx-inference/src/reactor.test.ts b/vendor/intx-inference/src/reactor.test.ts index 6fdc56a3a..dc8e36242 100644 --- a/vendor/intx-inference/src/reactor.test.ts +++ b/vendor/intx-inference/src/reactor.test.ts @@ -3225,12 +3225,19 @@ describe("createReactor — state snapshot inspection", () => { if (event.type === "message.received") { messageCount++; if (messageCount === 1) { - // Mutate the snapshot's content block. + // Mutate the snapshot's content block. Frozen turns throw; + // isolation still holds if the assignment is ignored. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-test-frozen-turns-mutation const msg = state.turns[0]; if (msg !== undefined) { const block = msg.content[0]; if (block !== undefined && block.type === "text") { - (block as { text: string }).text = "CORRUPTED"; + try { + (block as { text: string }).text = "CORRUPTED"; + } catch { + /* deepFreeze */ + } } } return caps.wait(); diff --git a/vendor/intx-inference/src/reactor.ts b/vendor/intx-inference/src/reactor.ts index c1a6a442c..379adc5cc 100644 --- a/vendor/intx-inference/src/reactor.ts +++ b/vendor/intx-inference/src/reactor.ts @@ -46,7 +46,11 @@ import type { CredentialMaterialResolver } from "@intx/types"; import { canonicalJsonStringify } from "@intx/types/wire-definition-hash"; import { type } from "arktype"; import { runInference } from "./harness"; -import type { Dependencies, InferenceHarnessOptions } from "./harness"; +import type { + Dependencies, + InferenceHarnessOptions, + PollBatchLivenessPredicate, +} from "./harness"; import { createCapabilities } from "./director"; import { createGateManager } from "./gates"; import { createCorrelationRegistry } from "./correlation"; @@ -73,6 +77,18 @@ function assertNever(x: never): never { throw new Error(`Unhandled resume case: ${JSON.stringify(x)}`); } +/** + * `InferenceOptions` plus vendored-only fields the published `@intx/types` + * does not carry. `ephemeralTurns` are appended to the materialized prompt + * for one inference only and never written to durable history, so transient + * director guidance leaves the cached transcript prefix untouched. + * + * Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-ephemeral-turns + */ +export type ExtendedInferenceOptions = InferenceOptions & { + ephemeralTurns?: ConversationTurn[]; +}; + function buildHarnessOpts( turns: ConversationTurn[], source: InferenceSource, @@ -132,6 +148,14 @@ export type ReactorConfig = { beforeToolExtensions?: BeforeToolExtension[]; toolResultTransforms?: ToolResultTransform[]; contextTransforms?: ContextTransform[]; + /** + * Liveness policy for the doom-loop guard's batch accounting. A direct + * value wins over the one riding `deps`; when neither is set every batch + * counts, same as before. + * + * Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-poll-exemption + */ + isPollOnlyPendingBatch?: PollBatchLivenessPredicate; compactors?: Record; afterCheckpoint?: () => Promise; onShutdown?: () => Promise; @@ -238,6 +262,18 @@ export function createReactor(config: ReactorConfig): Reactor { // downstream comparison reads this binding, never the raw config value. const doomLoopThreshold = resolveDoomLoopThreshold(config.doomLoopThreshold); + // Liveness policy for the doom-loop guard's batch accounting, resolved + // direct-wins-over-deps at the construction edge: a value composed straight + // into the reactor config wins over one riding a shared `deps` object, and + // an absent policy counts every batch, same as before. + const isPollOnlyPendingBatch = + config.isPollOnlyPendingBatch ?? deps.isPollOnlyPendingBatch; + + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-warning-turn + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-fail-run + const doomLoopCorrectiveNote = deps.doomLoopCorrectiveNote; + const doomLoopPolicy = deps.doomLoopPolicy ?? "shutdown"; + // Monotonic sequence counter, scoped to this session. let seq = 0; function nextSeq(): number { @@ -399,6 +435,9 @@ export function createReactor(config: ReactorConfig): Reactor { // contextStore.writeManifest at cycle boundaries. let manifestBuffer: TransformRecord[] = []; + // Compacted turns stay off reactor memory until the cycle commit publishes. + let pendingCompactOutput: ConversationTurn[] | null = null; + // Tracks how the current cycle should be summarized in the commit message. let cycleInferred = false; let cycleToolCallsExecuted = 0; @@ -561,73 +600,76 @@ export function createReactor(config: ReactorConfig): Reactor { // double-deliver early-returns rather than double-dispatching. const op = pending; - let dispatch: ResumeDispatch; + // A finally clears the in-flight marker on every exit — success included. + // Without it the success path leaves the id in the set forever, leaking + // one entry per correlated message for the life of the session. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-correlating-ids-leak try { - dispatch = resumePendingOperation(op, message); - } catch (cause) { - correlatingIds.delete(correlationId); - throw cause; - } - - const gate = gates.findByCorrelationId(correlationId); - switch (dispatch.mode) { - case "redispatch": { - // Clear the gate WITHOUT enqueuing gate.cleared: the re-dispatched call - // is the resumption, so a gate.cleared-driven re-infer would double the - // continuation. The re-dispatch's own tool.done drives the re-infer. - if (gate !== undefined) { - gates.clearSilently(gate.gateId); + const dispatch = resumePendingOperation(op, message); + + const gate = gates.findByCorrelationId(correlationId); + switch (dispatch.mode) { + case "redispatch": { + // Clear the gate WITHOUT enqueuing gate.cleared: the re-dispatched call + // is the resumption, so a gate.cleared-driven re-infer would double the + // continuation. The re-dispatch's own tool.done drives the re-infer. + if (gate !== undefined) { + gates.clearSilently(gate.gateId); + if (stateManager !== null) { + stateManager.setGatesSnapshot(gates.snapshot()); + } + } + correlations.remove(correlationId); if (stateManager !== null) { - stateManager.setGatesSnapshot(gates.snapshot()); + stateManager.removePendingOperation(correlationId); } + // The grant is already recorded (synchronously, in + // resumePendingOperation) with no await since; enqueue the re-dispatch + // so it runs on the loop with normal event ordering. The director seeds + // its outstanding-result count off this event before the call's + // tool.done arrives. + enqueue({ type: "resume.execute_tools", calls: dispatch.calls }); + break; } - correlations.remove(correlationId); - if (stateManager !== null) { - stateManager.removePendingOperation(correlationId); - } - // The grant is already recorded (synchronously, in - // resumePendingOperation) with no await since; enqueue the re-dispatch - // so it runs on the loop with normal event ordering. The director seeds - // its outstanding-result count off this event before the call's - // tool.done arrives. - enqueue({ type: "resume.execute_tools", calls: dispatch.calls }); - break; - } - case "error_result": { - // The approver denied the call. Clear the gate SILENTLY (like the - // approved redispatch) so it cannot also trip onGateCleared and enqueue - // a second continuation. The synthetic error result answers the parked - // call; the director appends it and re-infers once. - if (gate !== undefined) { - gates.clearSilently(gate.gateId); + case "error_result": { + // The approver denied the call. Clear the gate SILENTLY (like the + // approved redispatch) so it cannot also trip onGateCleared and enqueue + // a second continuation. The synthetic error result answers the parked + // call; the director appends it and re-infers once. + if (gate !== undefined) { + gates.clearSilently(gate.gateId); + if (stateManager !== null) { + stateManager.setGatesSnapshot(gates.snapshot()); + } + } + correlations.remove(correlationId); if (stateManager !== null) { - stateManager.setGatesSnapshot(gates.snapshot()); + stateManager.removePendingOperation(correlationId); } + enqueue({ type: "resume.tool_result", result: dispatch.result }); + break; } - correlations.remove(correlationId); - if (stateManager !== null) { - stateManager.removePendingOperation(correlationId); - } - enqueue({ type: "resume.tool_result", result: dispatch.result }); - break; - } - case "gate-cleared": { - // Async-tool resumption: clear the gate normally so the director - // re-infers, and append the correlated response to history so the model - // sees the content it was waiting on. - if (gate !== undefined) { - gates.clear(gate.gateId); - } - correlations.remove(correlationId); - if (stateManager !== null) { - stateManager.removePendingOperation(correlationId); - const msg = createInboundTurn(message); - if (msg !== null) { - stateManager.appendTurn(msg); + case "gate-cleared": { + // Async-tool resumption: clear the gate normally so the director + // re-infers, and append the correlated response to history so the model + // sees the content it was waiting on. + if (gate !== undefined) { + gates.clear(gate.gateId); } + correlations.remove(correlationId); + if (stateManager !== null) { + stateManager.removePendingOperation(correlationId); + const msg = createInboundTurn(message); + if (msg !== null) { + stateManager.appendTurn(msg); + } + } + break; } - break; } + } finally { + correlatingIds.delete(correlationId); } emit({ @@ -662,7 +704,7 @@ export function createReactor(config: ReactorConfig): Reactor { } async function executeInfer( - options: InferenceOptions | undefined, + options: ExtendedInferenceOptions | undefined, ): Promise { if (stateManager === null) return; @@ -695,6 +737,11 @@ export function createReactor(config: ReactorConfig): Reactor { await persistBlobs(result.blobs); } + const ephemeral = options?.ephemeralTurns; + if (ephemeral !== undefined && ephemeral.length > 0) { + prompt = [...prompt, ...ephemeral]; + } + // Tripwire: a malformed tool sequence is invalid in a coherent tool // conversation and would otherwise surface as an opaque provider rejection. // Catch it here, before the prompt is persisted or sent, so the corruption @@ -946,7 +993,18 @@ export function createReactor(config: ReactorConfig): Reactor { // when it reaches the threshold. A `null` threshold means detection is // disabled, so the accounting is skipped entirely. const ranCalls = calls.filter((_call, i) => outcomes[i] !== SUSPENDED); - if (doomLoopThreshold !== null && ranCalls.length > 0) { + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-poll-exemption + // A still-pending poll batch is liveness, not a loop: reset the streak + // (a skip would preserve a stale count and false-positive later) while + // mixed and terminal batches count normally. + const isLivePollBatch = + doomLoopThreshold !== null && + ranCalls.length > 0 && + isPollOnlyPendingBatch?.(ranCalls, results) === true; + if (isLivePollBatch) { + lastToolBatchSignature = null; + toolBatchRepeatCount = 0; + } else if (doomLoopThreshold !== null && ranCalls.length > 0) { const signature = toolBatchSignature(ranCalls); if (signature === lastToolBatchSignature) { toolBatchRepeatCount += 1; @@ -957,15 +1015,55 @@ export function createReactor(config: ReactorConfig): Reactor { lastToolBatchNames = ranCalls.map((call) => call.name); } - cycleToolCallsExecuted += results.length; + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-warning-turn + // One warning turn before the fatal trip: a repeat at count threshold-1 + // gets a corrective note appended so the model can see it is looping. + // `>= 2` keeps threshold 2 from annotating a batch's first execution. + let annotatedResults = results; + if ( + doomLoopThreshold !== null && + toolBatchRepeatCount >= 2 && + toolBatchRepeatCount === doomLoopThreshold - 1 && + doomLoopCorrectiveNote !== undefined + ) { + const note = doomLoopCorrectiveNote({ + calls: ranCalls, + repeatCount: toolBatchRepeatCount, + threshold: doomLoopThreshold, + }); + if (note !== undefined && note.length > 0) { + annotatedResults = results.map((result) => ({ + ...result, + content: + typeof result.content === "string" + ? `${result.content}\n\n${note}` + : { ...result.content, doom_loop_warning: note }, + })); + } + } + + cycleToolCallsExecuted += annotatedResults.length; - if (addToHistory && stateManager !== null && results.length > 0) { - stateManager.appendTurn(createToolResultTurn(results)); + if (addToHistory && stateManager !== null && annotatedResults.length > 0) { + stateManager.appendTurn(createToolResultTurn(annotatedResults)); } - for (const result of results) { + for (const result of annotatedResults) { enqueue({ type: "tool.done", result }); } + + // Checkpoint the completed tool cycle (the assistant tool_call turn plus + // its results) so an interrupt that rebuilds the agent from the store + // reloads the full exchange. Otherwise context commits only at cycle + // terminals and an uncommitted tool turn vanishes on rebuild. Guarded on + // addToHistory: only then does history end with the tool_result turn, so + // the persisted prefix is well-formed rather than an assistant turn with + // unanswered tool calls. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-checkpoint-after-tool-cycle + if (addToHistory) { + await commitCycle(); + } } async function executeCompact( @@ -986,9 +1084,10 @@ export function createReactor(config: ReactorConfig): Reactor { }; const result = await compactor.apply(stateManager.getTurns(), ctx); - stateManager.replaceTurns(result.output); - await contextStore.writeTurns(result.output); + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-compact-publish-then-memory await persistBlobs(result.blobs); + await contextStore.writeTurns(result.output); + pendingCompactOutput = result.output; manifestBuffer.push(result.record); cycleCompactorName = compactor.name; @@ -1049,24 +1148,48 @@ export function createReactor(config: ReactorConfig): Reactor { const message = buildCycleMessage(); try { - await contextStore.writeTurns(stateManager.getTurns()); + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-skip-unchanged-history + const currentRevision = stateManager.getTurnsRevision(); + // A staged compact already wrote the new generation. Do not writeTurns + // live memory over that staging — memory still holds the old turns. + if (pendingCompactOutput === null && currentRevision !== lastWrittenTurnsRevision) { + await contextStore.writeTurns(stateManager.getTurns()); + lastWrittenTurnsRevision = currentRevision; + } await contextStore.writeManifest(manifestBuffer); await writeMetadata(); const commit = await contextStore.commit({ message }); lastCheckpointHash = commit.hash; + if (pendingCompactOutput !== null) { + stateManager.replaceTurns(pendingCompactOutput); + lastWrittenTurnsRevision = stateManager.getTurnsRevision(); + pendingCompactOutput = null; + } } catch (cause) { logger.error`Cycle commit failed: ${cause}`; emitError( `Cycle commit failed: ${cause instanceof Error ? cause.message : String(cause)}`, false, ); + // A staged compact must not leak into a later infer/tools cycle: skip-write + // plus replaceTurns would publish stale compact output over live memory. + pendingCompactOutput = null; resetCycleAccumulators(); return; } resetCycleAccumulators(); - if (afterCheckpoint !== undefined) { + // Fire only for commits the director actually asked to checkpoint. + // A hasWork-only commit (e.g. the auto-commit after execute_tools with + // addToHistory) is internal durability plumbing, not a checkpoint the + // caller requested — without this guard, a director that checkpoints + // in a later decide() call (as opposed to pairing checkpoint with the + // action that produced the work) gets afterCheckpoint invoked twice + // for what is, from the director's perspective, a single checkpoint. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-after-checkpoint-director-only + if (afterCheckpoint !== undefined && hasOverride) { try { await afterCheckpoint(); } catch (cause) { @@ -1515,6 +1638,22 @@ export function createReactor(config: ReactorConfig): Reactor { `${String(doomLoopThreshold)} times consecutively`; emitError(message, true); closeMessageRun("failed", { message, kind: "doom_loop" }); + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-doom-loop-fail-run + if (doomLoopPolicy === "fail-run") { + // The run is dead but the reactor is not: drop the doomed batch's + // queued cycle events so the director never sees a tool.done that + // would re-infer, then return to idle. Non-cycle events already + // queued (inbound mail, gate clears) still process normally, and + // the next message.received opens a fresh run bracket. + for (let i = queue.length - 1; i >= 0; i--) { + const queued = queue[i]; + if (queued !== undefined && CYCLE_EVENT_TYPES.has(queued.type)) { + queue.splice(i, 1); + } + } + pendingContinuations = 0; + continue; + } done = true; await initiateShutdown(); break; @@ -1538,6 +1677,14 @@ export function createReactor(config: ReactorConfig): Reactor { let lastCheckpointHash: string | undefined; + // Turns revision most recently serialized to the context store. A checkpoint + // whose history has not changed since this revision skips writeTurns rather + // than re-serializing the entire (potentially large) conversation and its + // historical tool-output blobs. + // + // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-last-written-turns-revision + let lastWrittenTurnsRevision = 0; + async function initiateShutdown(): Promise { if (shutdownStarted) return; shutdownStarted = true; diff --git a/vendor/intx-inference/src/sse.ts b/vendor/intx-inference/src/sse.ts index 0feeba22a..b8dcf5b02 100644 --- a/vendor/intx-inference/src/sse.ts +++ b/vendor/intx-inference/src/sse.ts @@ -11,6 +11,17 @@ const decoder = new TextDecoder(); +// A single SSE line has no defined upper bound, but an unbounded run of +// characters with no newline is indistinguishable from a stuck or hostile +// stream and would grow `buffer` until the process runs out of memory. Cap the +// unterminated tail generously — real `data:` lines, even large tool-call +// payloads, sit far below this — and fail loudly instead of consuming all +// memory. The limit is on `buffer.length` (UTF-16 code units), which bounds the +// retained string regardless of the source encoding's bytes-per-character. +// +// Locally patched — see vendor/intx-inference/PATCHES.md#sse-ts-max-line-length +const MAX_LINE_LENGTH = 16 * 1024 * 1024; + export async function* parseSSE( stream: ReadableStream, ): AsyncIterable { @@ -60,6 +71,15 @@ export async function* parseSSE( yield payload; } + + // After draining complete lines, `buffer` holds only the unterminated + // tail. A tail past the cap means the stream is emitting bytes without a + // newline delimiter unboundedly — abort rather than accumulate to OOM. + if (buffer.length > MAX_LINE_LENGTH) { + throw new Error( + `SSE line exceeded ${String(MAX_LINE_LENGTH)} characters without a newline delimiter`, + ); + } } } finally { reader.releaseLock(); diff --git a/vendor/intx-inference/src/state.ts b/vendor/intx-inference/src/state.ts index 867f81e1c..642c5471e 100644 --- a/vendor/intx-inference/src/state.ts +++ b/vendor/intx-inference/src/state.ts @@ -17,6 +17,23 @@ import type { GateSnapshot } from "./gates"; export type ReactorStateManager = ReturnType; +/** + * Recursively freezes a turn so snapshots can share its reference instead of + * deep-cloning the whole history on every director decision. Freezing costs + * O(turn size) once at append; cloning cost O(total history) per snapshot. + * + * Locally patched — see vendor/intx-inference/PATCHES.md#state-ts-deep-freeze-turns-revision + */ +function deepFreeze(value: T): T { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) { + return value; + } + for (const key of Object.getOwnPropertyNames(value)) { + deepFreeze((value as Record)[key]); + } + return Object.freeze(value); +} + /** * Creates a mutable state container. All mutations go through explicit methods; * the `snapshot()` method produces an immutable view for the director. @@ -27,7 +44,11 @@ export function createStateManager( initialOps: PendingOperation[], initialUsage: TokenUsage, ) { - let turns: ConversationTurn[] = [...initialTurns]; + let turns: ConversationTurn[] = initialTurns.map(deepFreeze); + // Monotonic counter bumped whenever `turns` changes. Persistence compares it + // against the revision it last wrote so an unchanged history is never + // re-serialized on a checkpoint (INFERENCE.md § Cycle boundary commit). + let turnsRevision = 0; const pendingOperations = new Map( initialOps.map((op) => [op.correlationId, op]), ); @@ -38,11 +59,13 @@ export function createStateManager( const activeForks: { forkId: string; mode: "independent" | "child" }[] = []; function appendTurn(msg: ConversationTurn): void { - turns.push(msg); + turns.push(deepFreeze(msg)); + turnsRevision += 1; } function replaceTurns(next: ConversationTurn[]): void { - turns = [...next]; + turns = next.map(deepFreeze); + turnsRevision += 1; } function addPendingOperation(op: PendingOperation): void { @@ -83,7 +106,13 @@ export function createStateManager( } function getTurns(): ConversationTurn[] { - return turns; + // Copy so a caller mutating the result cannot corrupt reactor state, the + // same guarantee snapshot() gives for the turns it exposes. + return turns.slice(); + } + + function getTurnsRevision(): number { + return turnsRevision; } function getPendingOperations(): PendingOperation[] { @@ -95,15 +124,25 @@ export function createStateManager( } function snapshot(): ReactorState { + // `turns` is a lazy, memoized getter: high-frequency events (tool.done, + // inference.error) reach directors that never inspect it, so paying an + // O(history) copy on every decision would make per-event cost scale with + // session length. Deferring to first access keeps those decisions cheap. + // + // The remaining collections are small, so they are copied eagerly here to + // stay true point-in-time snapshots: a mutation between snapshot() and a + // later read must not leak into the view. Only `turns` trades that guarantee + // for the perf win, and its elements are deep-frozen at append, so a + // deferred read still cannot observe a mutated turn. + let turnsView: ConversationTurn[] | undefined; return { sessionId, - turns: turns.map((m) => ({ - ...m, - content: m.content.map((b) => structuredClone(b)), + get turns() { + return (turnsView ??= turns.slice()); + }, + pendingOperations: Array.from(pendingOperations.values()).map((op) => ({ + ...op, })), - pendingOperations: Array.from(pendingOperations.values()).map((op) => - structuredClone(op), - ), activeGates: activeGatesSnapshot.map((g) => ({ gateId: g.gateId, type: g.type, @@ -128,6 +167,7 @@ export function createStateManager( addFork, removeFork, getTurns, + getTurnsRevision, getPendingOperations, getTokenUsage, snapshot, diff --git a/vendor/intx-storage-isogit/PATCHES.md b/vendor/intx-storage-isogit/PATCHES.md index cc1791a24..8a4320bce 100644 --- a/vendor/intx-storage-isogit/PATCHES.md +++ b/vendor/intx-storage-isogit/PATCHES.md @@ -8,6 +8,11 @@ proof of which lines are ours — run `bin/vendor-patch-diff` to produce it. The `Locally patched — see …#` comments and the entries below are signposts that point into that diff; they do not define its extent. +### 2026-09-14 re-sync (upstream `1ad0104`) + +Re-carried as-is with its three tests; upstream `store.ts` has no +`loadErrors`. No adaptation needed. + ## store-ts-load-errors `store.ts` — Implements `AuditStore.loadErrors` by reading diff --git a/vendor/intx-storage-isogit/src/store.test.ts b/vendor/intx-storage-isogit/src/store.test.ts index 112e17809..5983509a6 100644 --- a/vendor/intx-storage-isogit/src/store.test.ts +++ b/vendor/intx-storage-isogit/src/store.test.ts @@ -692,6 +692,38 @@ describe("error store", () => { ), ).toHaveLength(1); }); + + test("loadErrors round-trips records ordered by seq", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + const later = makeErrorRecord({ seq: 2, category: "retryable" }); + const earlier = makeErrorRecord({ seq: 1, category: "credential_failure" }); + + await store.commitErrors([later]); + await store.commitErrors([earlier]); + + expect(await store.loadErrors("session-1")).toEqual([earlier, later]); + }); + + test("loadErrors returns empty array for nonexistent session", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + expect(await store.loadErrors("no-such-session")).toEqual([]); + }); + + test("rejects sessionId with path traversal on loadErrors", async () => { + const dir = await tempDir(); + const store = await createAuditStore(dir); + + let thrown: Error | undefined; + try { + await store.loadErrors("../escape"); + } catch (cause) { + thrown = cause instanceof Error ? cause : new Error(String(cause)); + } + expect(thrown?.message).toContain("unsafe characters"); + }); }); describe("audit and error durability retries", () => { diff --git a/vendor/intx-storage-isogit/src/store.ts b/vendor/intx-storage-isogit/src/store.ts index 3cbb096e5..968edf019 100644 --- a/vendor/intx-storage-isogit/src/store.ts +++ b/vendor/intx-storage-isogit/src/store.ts @@ -17,8 +17,9 @@ import { import { type } from "arktype"; import { AuditRecord, + ErrorRecord, type AuditRecord as AuditRecordType, - type ErrorRecord, + type ErrorRecord as ErrorRecordType, } from "@intx/types/audit"; import { AUTHOR } from "./init"; import type { CommitSigner } from "./signer"; @@ -862,4 +863,43 @@ export class IsogitStore records.sort((a, b) => a.seq - b.seq); return records; } + + // Locally patched — see vendor/intx-storage-isogit/PATCHES.md#store-ts-load-errors + async loadErrors( + sessionId: string, + _signal?: AbortSignal, + ): Promise { + assertSafeSegment(sessionId, "sessionId"); + const sessionDir = this.runtime.path.join(this.dir, ERRORS_DIR, sessionId); + + let entries: string[]; + try { + entries = await this.runtime.fs.readdir(sessionDir); + } catch (cause) { + if ( + cause instanceof Error && + "code" in cause && + cause.code === "ENOENT" + ) { + return []; + } + throw cause; + } + + const records: ErrorRecordType[] = []; + for (const entry of entries) { + if (!entry.endsWith(".json")) continue; + const fullPath = this.runtime.path.join(sessionDir, entry); + const raw = await this.runtime.fs.readTextFile(fullPath); + const parsed = JSON.parse(raw) as unknown; + const result = ErrorRecord(parsed); + if (result instanceof type.errors) { + throw new Error(`Invalid error record in ${entry}: ${result.summary}`); + } + records.push(result); + } + + records.sort((a, b) => a.seq - b.seq); + return records; + } } diff --git a/vendor/intx-types/PATCHES.md b/vendor/intx-types/PATCHES.md index 9efb3616e..e0b1cdafd 100644 --- a/vendor/intx-types/PATCHES.md +++ b/vendor/intx-types/PATCHES.md @@ -8,6 +8,14 @@ which lines are ours — run `bin/vendor-patch-diff` to produce it. The `Locally patched — see …#` comments and the entries below are signposts that point into that diff; they do not define its extent. +### 2026-09-14 re-sync (upstream `1ad0104`) + +Both entries re-carried; the `stopReason` spread sites in the Anthropic +and Gemini adapters were rewritten to match upstream's new object-literal +usage emission. Upstream deleted `packages/types/src/sidecar-placement.ts`; +no entry lived there. Unaffected by upstream's `credentialId` auth-model +rewrite. + ## runtime-ts-audit-store-load-errors `runtime.ts` — `AuditStore` grows `loadErrors(sessionId, signal?)` so a diff --git a/vendor/intx-types/src/runtime.ts b/vendor/intx-types/src/runtime.ts index c71574348..c1957825f 100644 --- a/vendor/intx-types/src/runtime.ts +++ b/vendor/intx-types/src/runtime.ts @@ -1445,7 +1445,8 @@ export const InferenceEvent = type.or( { type: "'inference.usage'", seq: "number", - data: { usage: TokenUsage, source: LastCycleSource }, + // Locally patched — see vendor/intx-types/PATCHES.md#types-ts-usage-stop-reason + data: { usage: TokenUsage, source: LastCycleSource, "stopReason?": "string" }, }, { type: "'inference.done'", @@ -1710,7 +1711,8 @@ export type InferenceEvent = | { type: "inference.usage"; seq: number; - data: { usage: TokenUsage; source: LastCycleSource }; + // Locally patched — see vendor/intx-types/PATCHES.md#types-ts-usage-stop-reason + data: { usage: TokenUsage; source: LastCycleSource; stopReason?: string }; } | { type: "inference.done"; @@ -2908,6 +2910,14 @@ export interface AuditStore { * and shutdown with all error records accumulated since the last flush. */ commitErrors(records: ErrorRecord[], signal?: AbortSignal): Promise; + + /** + * Load error records for a session. Returns all records matching + * the given sessionId, ordered by seq. + * + * Locally patched — see vendor/intx-types/PATCHES.md#runtime-ts-audit-store-load-errors + */ + loadErrors(sessionId: string, signal?: AbortSignal): Promise; } // --------------------------------------------------------------------------- From 7a6ba42c0ee16b535cbecc25eec8f71122b2ff06 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 22:58:31 -0700 Subject: [PATCH 3/4] Clarify credential-model migration and clear credential cell in tests --- docs/VENDORING.md | 6 ++++-- src/config.test.ts | 3 ++- src/config/inference-sources.test.ts | 2 ++ src/subagent/refresh-inference-source.test.ts | 6 +++++- src/subagent/run-source.test.ts | 11 +++++++++-- vendor/intx-inference/PATCHES.md | 9 +++++---- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/docs/VENDORING.md b/docs/VENDORING.md index e8e072b52..6d1e339b8 100644 --- a/docs/VENDORING.md +++ b/docs/VENDORING.md @@ -108,8 +108,10 @@ a ledger entry), and the second re-applies each `PATCHES.md` entry with an as-is / adapt / subsumed triage recorded in the ledgers. No entry was subsumed upstream. Upstream replaced inline provider `apiKey` plumbing with a `credentialId` + credential-cell model; no ledger entry touches -auth and no first-party caller passes provider credentials into the -vendored trees, so no migration was needed. +auth so the vendored trees needed no migration, but first-party callers +were migrated to the new model (each built source registers its secret +in `src/config/source-credentials.ts`, handed to the vendored trees as +their resolver). `vendor/intx-workflow-host/workflow-definition-loader.ts` is new in this sync: a second partial-tree path alongside `adapters/`, carrying diff --git a/src/config.test.ts b/src/config.test.ts index 54c2d170e..17066f834 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -23,7 +23,7 @@ import { SOURCE_MAX_TOKENS, } from "./config/index.js"; import { DIRECTOR_IDS } from "./agent/directors/types.js"; -import { peekSourceCredentialSecret } from "./config/source-credentials.js"; +import { clearSourceCredentials, peekSourceCredentialSecret } from "./config/source-credentials.js"; import type { Config, UnconfiguredConfig } from "./config/index.js"; import { mergeProviderIntoSettings, @@ -70,6 +70,7 @@ afterEach(() => { resetGoModelDiscoveryForTests(); resetZenModelDiscoveryForTests(); setProviderContextWindowOverrides(undefined); + clearSourceCredentials(); }); function assertConfigured( diff --git a/src/config/inference-sources.test.ts b/src/config/inference-sources.test.ts index f9efc58e9..6001e84a6 100644 --- a/src/config/inference-sources.test.ts +++ b/src/config/inference-sources.test.ts @@ -13,6 +13,7 @@ import { } from "../provider/context-window.js"; import { createOpenAICompatibleAdapter } from "../provider/openai-compatible-adapter.js"; import { createInferenceDependencies } from "../provider/inference-dependencies.js"; +import { clearSourceCredentials } from "./source-credentials.js"; import { OPENAI_RESPONSES_PROVIDER } from "../provider/openai-responses.js"; import { ZEN_MESSAGES_PROVIDER } from "../provider/zen-anthropic-adapter.js"; import { firstClassProviderById } from "../../packages/first-class-providers/src/index.js"; @@ -81,6 +82,7 @@ function settingsWithWindow(): Settings { afterEach(() => { setProviderContextWindowOverrides(undefined); globalThis.fetch = originalFetch; + clearSourceCredentials(); }); describe("contextWindow / maxTokens split (CL-7784)", () => { diff --git a/src/subagent/refresh-inference-source.test.ts b/src/subagent/refresh-inference-source.test.ts index 04cb21d6c..c7fbea0f6 100644 --- a/src/subagent/refresh-inference-source.test.ts +++ b/src/subagent/refresh-inference-source.test.ts @@ -3,7 +3,10 @@ import * as codexSession from "../auth/codex/session.js"; import * as xaiSession from "../auth/xai/session.js"; import type { InferenceSource } from "@intx/types/runtime"; -import { peekSourceCredentialSecret } from "../config/source-credentials.js"; +import { + clearSourceCredentials, + peekSourceCredentialSecret, +} from "../config/source-credentials.js"; const baseSource = (id: string): InferenceSource => ({ id, @@ -15,6 +18,7 @@ const baseSource = (id: string): InferenceSource => ({ describe("refresh-inference-source", () => { afterEach(() => { + clearSourceCredentials(); spyOn(codexSession, "getValidCodexToken").mockRestore(); spyOn(xaiSession, "getValidXaiToken").mockRestore(); }); diff --git a/src/subagent/run-source.test.ts b/src/subagent/run-source.test.ts index 0ea2489e8..2663d2aa9 100644 --- a/src/subagent/run-source.test.ts +++ b/src/subagent/run-source.test.ts @@ -1,9 +1,16 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test, afterEach } from "bun:test"; -import { peekSourceCredentialSecret } from "../config/source-credentials.js"; +import { + clearSourceCredentials, + peekSourceCredentialSecret, +} from "../config/source-credentials.js"; import { OPENCODE_GO_BASE_URL } from "../../packages/opencode-go/src/index.js"; import { buildSubAgentPrimarySource } from "./run.js"; +afterEach(() => { + clearSourceCredentials(); +}); + describe("buildSubAgentPrimarySource", () => { test("projects an Ollama root into the subagent OpenAI-compatible source", () => { const bundle = buildSubAgentPrimarySource({ diff --git a/vendor/intx-inference/PATCHES.md b/vendor/intx-inference/PATCHES.md index 07bab9bb6..3d7b8b897 100644 --- a/vendor/intx-inference/PATCHES.md +++ b/vendor/intx-inference/PATCHES.md @@ -78,10 +78,11 @@ new upstream moved under the patch: `packages/inference/src/providers/google-genai.test.ts`; no ledger entry lived in either file, so nothing was triaged out with them. - Upstream replaced inline `apiKey` with a `credentialId` + credential-cell - auth model. No entry touches auth, and first-party callers needed no - migration: provider auth resolves inside the vendored trees - (`createDefaultDependencies`), and no `src/` caller passes provider - credentials into them. + auth model. No entry touches auth, so the vendored trees needed no + migration; first-party callers were migrated to the new model instead + (each built source registers its secret in + `src/config/source-credentials.ts`, handed to the vendored trees as + their resolver). ## adapter-ts-stream-terminal-detector From 5ab66fbfb320ad3d9f2fc5eb13be08bea3260a42 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 23:01:53 -0700 Subject: [PATCH 4/4] Format config test teardown edits --- src/config.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/config.test.ts b/src/config.test.ts index 17066f834..80c8b3c27 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -23,7 +23,10 @@ import { SOURCE_MAX_TOKENS, } from "./config/index.js"; import { DIRECTOR_IDS } from "./agent/directors/types.js"; -import { clearSourceCredentials, peekSourceCredentialSecret } from "./config/source-credentials.js"; +import { + clearSourceCredentials, + peekSourceCredentialSecret, +} from "./config/source-credentials.js"; import type { Config, UnconfiguredConfig } from "./config/index.js"; import { mergeProviderIntoSettings,