diff --git a/docs/VENDORING.md b/docs/VENDORING.md index 42bf768d3..6d1e339b8 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,39 @@ 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 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 +`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..80c8b3c27 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -23,6 +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 type { Config, UnconfiguredConfig } from "./config/index.js"; import { mergeProviderIntoSettings, @@ -69,6 +73,7 @@ afterEach(() => { resetGoModelDiscoveryForTests(); resetZenModelDiscoveryForTests(); setProviderContextWindowOverrides(undefined); + clearSourceCredentials(); }); function assertConfigured( @@ -1505,13 +1510,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/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/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..c7fbea0f6 100644 --- a/src/subagent/refresh-inference-source.test.ts +++ b/src/subagent/refresh-inference-source.test.ts @@ -3,30 +3,38 @@ import * as codexSession from "../auth/codex/session.js"; import * as xaiSession from "../auth/xai/session.js"; import type { InferenceSource } from "@intx/types/runtime"; +import { + clearSourceCredentials, + 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", }); describe("refresh-inference-source", () => { afterEach(() => { + clearSourceCredentials(); spyOn(codexSession, "getValidCodexToken").mockRestore(); 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 +52,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 +61,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..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 { KEYLESS_API_KEY } from "../config/index.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({ @@ -19,9 +26,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 +51,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.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..8ae10f11b 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"; @@ -537,13 +539,6 @@ export async function createAgent( 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 @@ -595,19 +590,20 @@ export async function createAgent( ); if (index === -1) { logger.warn`duplicate error record already stored; dropping the colliding batch`; - dropFromAccumulator(remaining); + accumulatedErrors.splice(0, count); return; } - const [colliding] = remaining.splice(index, 1); - if (colliding !== undefined) - dropFromAccumulator([colliding]); + remaining.splice(index, 1); logger.warn`duplicate error record already stored; dropping the colliding record`; - if (remaining.length === 0) return; + if (remaining.length === 0) { + accumulatedErrors.splice(0, count); + return; + } continue; } throw cause; } - dropFromAccumulator(remaining); + accumulatedErrors.splice(0, count); return; } } finally { @@ -748,6 +744,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..a3a910ba8 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", }; @@ -188,6 +188,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..187cd5cb5 100644 --- a/vendor/intx-agent/src/flush-errors.test.ts +++ b/vendor/intx-agent/src/flush-errors.test.ts @@ -40,7 +40,7 @@ const UNREACHABLE_SOURCE: InferenceSource = { id: "anthropic:test-error", provider: "anthropic", baseURL: "http://localhost:1", - apiKey: "test-key", + credentialId: "test-key", model: "claude-test", }; @@ -104,6 +104,9 @@ function makeFailFirstAuditStore(): FailingAuditStore { }; } +// 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 { @@ -191,6 +194,7 @@ async function buildAgentEnv(opts: { return { sources: [UNREACHABLE_SOURCE], defaultSource: UNREACHABLE_SOURCE.id, + readCurrentMaterial: (credentialId) => ({ secret: credentialId }), storage, workdir: opts.workdir, audit: opts.audit, @@ -277,6 +281,7 @@ async function runForbiddenCycle(opts: { const env: BaseEnv = { sources: [UNREACHABLE_SOURCE], defaultSource: UNREACHABLE_SOURCE.id, + readCurrentMaterial: (credentialId) => ({ secret: credentialId }), storage: store, workdir: opts.workdir, audit: store, @@ -625,6 +630,7 @@ describe("agent error flushing", () => { const env: BaseEnv = { sources: [UNREACHABLE_SOURCE], defaultSource: UNREACHABLE_SOURCE.id, + readCurrentMaterial: (credentialId) => ({ secret: credentialId }), storage: store, workdir: workDir, audit: store, 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-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/PATCHES.md b/vendor/intx-inference/PATCHES.md index 8ee373d1d..3d7b8b897 100644 --- a/vendor/intx-inference/PATCHES.md +++ b/vendor/intx-inference/PATCHES.md @@ -49,6 +49,41 @@ 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, 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 `adapter.ts` — Adds `StreamTerminalDetector`/`ProviderAdapter.isStreamTerminal`. @@ -394,6 +429,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/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..a118fefa9 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, @@ -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; @@ -133,6 +140,7 @@ export function createReactorAssembly( source, failOverToNextSource, resetToPreferredSource, + readMaterial, toolRunner, contextStore, onEvent, @@ -266,6 +274,7 @@ export function createReactorAssembly( source, ...(failOverToNextSource !== undefined ? { failOverToNextSource } : {}), ...(resetToPreferredSource !== undefined ? { resetToPreferredSource } : {}), + ...(readMaterial !== undefined ? { readMaterial } : {}), toolRunner, contextStore, onEvent: composedOnEvent, 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/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..45bf6cbd8 100644 --- a/vendor/intx-inference/src/harness.ts +++ b/vendor/intx-inference/src/harness.ts @@ -37,6 +37,8 @@ import type { ContentBlock, } from "@intx/types/runtime"; +import type { CredentialMaterialResolver } from "@intx/types"; + import { getLogger } from "@intx/log"; import { @@ -85,6 +87,8 @@ export const HarnessId: unique symbol = Symbol("HarnessId"); * 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[], @@ -226,9 +230,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 +266,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 @@ -376,7 +408,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 @@ -1547,6 +1583,8 @@ export async function* runInference( // 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; 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..7888aa844 100644 --- a/vendor/intx-inference/src/providers/anthropic.ts +++ b/vendor/intx-inference/src/providers/anthropic.ts @@ -574,12 +574,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. 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/reactor.test.ts b/vendor/intx-inference/src/reactor.test.ts index 420017318..dc8e36242 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 () => { @@ -3556,6 +3227,8 @@ describe("createReactor — state snapshot inspection", () => { if (messageCount === 1) { // 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]; @@ -5604,18 +5277,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 +5292,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 +5307,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 +5346,6 @@ function makeRecordingContextStore(opts?: { manifests.push([...records]); }, async writeTurns(turns) { - writeTurnsCalls.push([...turns]); lastWrittenTurns = [...turns]; }, async writeMetadata(m) { @@ -5706,7 +5365,6 @@ function makeRecordingContextStore(opts?: { manifests, metadata, blobs, - writeTurnsCalls, get lastWrittenTurns() { return lastWrittenTurns; }, @@ -5812,7 +5470,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 +5628,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 +6080,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..379adc5cc 100644 --- a/vendor/intx-inference/src/reactor.ts +++ b/vendor/intx-inference/src/reactor.ts @@ -42,6 +42,7 @@ 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"; @@ -94,19 +95,19 @@ function buildHarnessOpts( 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 +130,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; @@ -427,12 +435,13 @@ 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; 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,40 +578,42 @@ export function createReactor(config: ReactorConfig): Reactor { if (pending === undefined) return false; correlatingIds.add(correlationId); + + 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; + // 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. + // 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 { - 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; - } - } - - // 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. + // 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) { @@ -614,19 +625,18 @@ export function createReactor(config: ReactorConfig): Reactor { 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. + // 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. + // 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) { @@ -642,8 +652,8 @@ export function createReactor(config: ReactorConfig): Reactor { } 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. + // 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); } @@ -658,17 +668,17 @@ export function createReactor(config: ReactorConfig): Reactor { break; } } - - emit({ - type: "message.correlated", - seq: nextSeq(), - data: { message, correlationId }, - }); - - return true; } finally { correlatingIds.delete(correlationId); } + + emit({ + type: "message.correlated", + seq: nextSeq(), + data: { message, correlationId }, + }); + + return true; } // ------------------------------------------------------------------------- @@ -761,6 +771,7 @@ export function createReactor(config: ReactorConfig): Reactor { options, signal, nextSeq, + config.readMaterial, deps, ); @@ -855,7 +866,7 @@ export function createReactor(config: ReactorConfig): Reactor { } })(); - track(p); + void track(p); await p; } @@ -959,13 +970,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); } } 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/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.ts b/vendor/intx-storage-isogit/src/store.ts index c01ff9dcc..968edf019 100644 --- a/vendor/intx-storage-isogit/src/store.ts +++ b/vendor/intx-storage-isogit/src/store.ts @@ -789,7 +789,7 @@ export class IsogitStore } async commitErrors( - records: ErrorRecordType[], + records: ErrorRecord[], _signal?: AbortSignal, ): Promise { if (records.length === 0) return; 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/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/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..c1957825f 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,14 @@ 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({ + }, + { type: "'inference.done'", seq: "number", data: { @@ -1391,13 +1457,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 +1471,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 +1484,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 +1493,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 +1514,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 +1531,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 +1560,8 @@ export const InferenceEvent = type({ messageRunId: "string", receivedAt: "number", }, - }) - .or({ + }, + { type: "'message.run.ended'", seq: "number", data: { @@ -1507,23 +1573,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 +1598,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 @@ -2422,7 +2489,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 +2520,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 +2546,7 @@ export function applyInferenceSourceFields( id: true, provider: true, baseURL: true, - apiKey: true, + credentialId: true, model: true, defaults: true, capabilities: true, 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; +}