diff --git a/docs/VENDORING.md b/docs/VENDORING.md index a533bff2e..0a98e4ae5 100644 --- a/docs/VENDORING.md +++ b/docs/VENDORING.md @@ -22,42 +22,42 @@ 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-13 | Yes — see `vendor/intx-inference/PATCHES.md` | +| `@intx/types` | `vendor/intx-types/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-13 | Yes — see `vendor/intx-types/PATCHES.md` | +| `@intx/storage-isogit` | `vendor/intx-storage-isogit/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-13 | Yes — see `vendor/intx-storage-isogit/PATCHES.md` | +| `@intx/agent` | `vendor/intx-agent/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-13 | Yes — see `vendor/intx-agent/PATCHES.md` | +| `@intx/authz` | `vendor/intx-authz/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-13 | None — verbatim | +| `@intx/log` | `vendor/intx-log/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-13 | None — verbatim | +| `@intx/tools-posix` | `vendor/intx-tools-posix/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-13 | None — verbatim | +| `@intx/mailbox` | `vendor/intx-mailbox/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-13 | None — verbatim | +| `@intx/harness` | `vendor/intx-harness/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-13 | None — verbatim | +| `@intx/mime` | `vendor/intx-mime/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-13 | None — verbatim | +| `@intx/workflow-host` (partial — adapter + loader) | `vendor/intx-workflow-host/` | LGPL-2.1-only | `1ad010463a6bce6034cded3e078b14db482882a8` | 2026-09-13 | 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-13, 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-13 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-13 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-13 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-13 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-13 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-13 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-13 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-13 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-13 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-13 or when a published `@intx/mime` exports `buildMessageHeaders` | +| `vendor/intx-workflow-host` (partial) | `faremeter/interchange` | `1ad010463a6bce6034cded3e078b14db482882a8` | No | App-internal + loader: neither file ever published (adapter absent from the `0.3.0` tarball; loader postdates it, added 2026-09-09) | step-1 | 2027-03-13 or when published `@intx/workflow-host` exports the adapter and loader | ### The 2026-09-07 step-1 vendor pass @@ -80,15 +80,19 @@ Everything vendored in this pass sits at the same upstream commit `0205b07b64d03f0fec2e4be3593c764070a9ba8a` as the existing trees; no vendored tree mixes pins. -`vendor/intx-workflow-host/adapters/` is a partial-package vendor: upstream +`vendor/intx-workflow-host/` is a partial-package vendor: upstream `packages/workflow-host` has a `package.json`, but this tree carries only -the never-published `substrate-mailbox-store` adapter (the source file alone -is 563 lines, plus its 586-line test, documented on-disk layout, O(delta) -flushes). It is -deliberately **not** a workspace member and nothing in `src/` imports it — -Step 1 decides whether to wire it as a package when it consumes it. Its -`@intx/hub-sessions/substrate` and `@intx/mailbox` imports resolve once -that wiring exists; until then it is inert provenance, not dead weight. +the never-published `substrate-mailbox-store` adapter (source + its test) +and the workflow definition loader (`workflow-definition-loader.ts` + +its test, introduced upstream after the `0.3.0` publish for the director's +sidecar-consumed tool-packaging seam). All four files are verbatim +upstream. The tree is deliberately **not** a workspace member and nothing +in `src/` imports it — Step 1 decides whether to wire it as a package +when it consumes it. The adapter's `@intx/hub-sessions/substrate` and +`@intx/mailbox` imports, and the loader's `@intx/tool-packaging`, +`@intx/mailbox`, `@intx/types`, and `@intx/agent` imports, resolve once +that wiring exists; until then the tree is inert provenance, not dead +weight. The 2026-09-07 sync moved all three packages together to a single upstream commit, restoring the single-commit coherence the coupling rule @@ -97,6 +101,35 @@ 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-13 re-vendor to `1ad010463a6bce6034cded3e078b14db482882a8` + +All eleven trees re-pinned to upstream commit +`1ad010463a6bce6034cded3e078b14db482882a8` (`main`, 2026-09-13), and the +partial `vendor/intx-workflow-host/` tree gains the workflow definition +loader (`workflow-definition-loader.ts` + its test) alongside the +`substrate-mailbox-store` adapter. The published-version facts behind the +kill table were re-confirmed against npm on 2026-09-14 and are unchanged: +`@intx/agent`, `@intx/workflow-host`, `@intx/harness`, and `@intx/mime` +are still at `0.3.0`, nothing under the `@intx/mailbox` name has ever +published (still a registry 404), and `@intx/tool-packaging` stays a +decision rather than a tree — see below. Kill backstops move to +2027-03-13. The four patched packages were re-carried with no entry +dropped as upstream-absorbed; see each `PATCHES.md` for the per-sync +record. + +`@intx/tool-packaging` is **not** vendored, by decision rather than +omission. The loader's own header states `@intx/workflow-host` stays free +of a `@intx/tool-packaging` dependency on purpose: closure +materialization belongs to the sidecar layer +(`apps/sidecar/src/tool-materialization.ts`), and the loader only imports, +evaluates, and validates a caller-materialized package directory. Nothing +in `src/` consumes the loader or the packaging machinery, so both would +be inert weight today; the published `@intx/tool-packaging@0.3.0` covers +the seam if the director needs it before a re-vendor. It is currently only +transitive in `bun.lock` (via `@intx/hub-sessions`/`@intx/workflow-deploy`), +so a direct director import must first add it to `package.json`. Revisit when the +director consumes the sidecar materialization layer. + 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 @@ -197,7 +230,7 @@ Of the eleven vendored paths, four carry local patches: `@intx/inference`, `@intx/types`, `@intx/storage-isogit`, and `@intx/agent` — every divergence is a real fix not yet upstream, listed one-per-patch in that package's `PATCHES.md`. The other seven paths -(six whole packages plus the partial `vendor/intx-workflow-host/adapters/` +(six whole packages plus the partial `vendor/intx-workflow-host/` tree) are verbatim copies of upstream — no modifications. A diff against a pristine upstream checkout at the same commit and paths will show 100% upstream-authored lines for those seven. @@ -255,10 +288,12 @@ does not resolve to a ledger heading, or if a ledger heading has no marker. directory's `src/`, `README.md` (leave `package.json` and `LICENSE` as they are unless the package's own `package.json` exports or dependencies changed upstream — diff the two `package.json` files by hand; the - partial `vendor/intx-workflow-host/adapters/` tree has no `package.json` - of its own, so just re-copy its two vendored files, - `adapters/substrate-mailbox-store.ts` and - `adapters/substrate-mailbox-store.test.ts`). Run + partial `vendor/intx-workflow-host/` tree has no `package.json` + of its own, so just re-copy its four vendored files, + `adapters/substrate-mailbox-store.ts`, + `adapters/substrate-mailbox-store.test.ts`, + `workflow-definition-loader.ts`, and + `workflow-definition-loader.test.ts`). Run `bun install`, `bun run typecheck`, `bun run build`, `bun run test`. 3. For a **patched** package (`@intx/inference`, `@intx/types`, `@intx/storage-isogit`, `@intx/agent`): before overwriting diff --git a/src/config.test.ts b/src/config.test.ts index fad271dc4..78cff3e63 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1514,13 +1514,13 @@ describe("buildOpenAISource", () => { expect(source.baseURL).toBe("http://localhost:11434/v1"); }); - test("substitutes a placeholder apiKey when none is provided (keyless)", () => { + test("substitutes a placeholder credentialId when none is provided (keyless)", () => { const source = buildOpenAISource({ id: "local", baseURL: "http://localhost:8080/v1", model: "local-model", }); - expect(source.apiKey).toBe(KEYLESS_API_KEY); + expect(source.credentialId).toBe(KEYLESS_API_KEY); }); }); diff --git a/src/config/credential-material.ts b/src/config/credential-material.ts new file mode 100644 index 000000000..4074f9a3c --- /dev/null +++ b/src/config/credential-material.ts @@ -0,0 +1,9 @@ +import type { CredentialMaterialResolver } from "@intx/types"; + +// Every source builder in `src/config` stores secret material directly in +// `credentialId` (API keys, OAuth access tokens, the keyless placeholder), so +// resolving a credential is an identity read. A live credential cell would +// replace this; until then the resolver echoes the id. +export const resolveInlineCredentialMaterial: CredentialMaterialResolver = ( + credentialId, +) => ({ secret: credentialId }); diff --git a/src/config/index.ts b/src/config/index.ts index a8696abb8..8301b5aae 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -107,7 +107,7 @@ 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 +// credentialId string; the value is injected as `Bearer ` by the harness but // keyless servers ignore it entirely. export const KEYLESS_API_KEY = "keyless"; @@ -259,7 +259,7 @@ export function buildOpenAISource(fields: { baseURL: isOllamaProviderId(fields.id) ? ollamaOpenAIBaseURL(fields.baseURL) : normalizeOpenAICompatibleBaseURL(fields.baseURL), - apiKey: + credentialId: fields.apiKey !== undefined && fields.apiKey.length > 0 ? fields.apiKey : KEYLESS_API_KEY, @@ -338,7 +338,7 @@ export function buildCodexSource(fields: { id: fields.id, provider: CODEX_RESPONSES_PROVIDER, baseURL: CODEX_BASE_URL, - apiKey: fields.apiKey, + credentialId: fields.apiKey, model: fields.model, defaults: { maxTokens: SOURCE_MAX_TOKENS, providerOptions }, }; @@ -368,7 +368,7 @@ export function buildXaiSource(fields: { id: fields.id, provider: GROK_RESPONSES_PROVIDER, baseURL: XAI_BASE_URL, - apiKey: fields.apiKey, + credentialId: fields.apiKey, model: fields.model, defaults: { maxTokens: SOURCE_MAX_TOKENS, providerOptions }, }; @@ -392,7 +392,7 @@ export function buildBifrostSource(fields: { id: fields.id, provider: BIFROST_PROVIDER, baseURL: normalizeOpenAICompatibleBaseURL(fields.baseURL), - apiKey: + credentialId: fields.apiKey !== undefined && fields.apiKey.length > 0 ? fields.apiKey : KEYLESS_API_KEY, @@ -412,7 +412,7 @@ export function buildAnthropicSource(fields: { id: fields.id, provider: "anthropic", baseURL: fields.baseURL.replace(/\/+$/, ""), - apiKey: + credentialId: fields.apiKey !== undefined && fields.apiKey.length > 0 ? fields.apiKey : KEYLESS_API_KEY, @@ -440,7 +440,7 @@ export function buildGoSource(fields: { id: fields.id, provider: OPENCODE_GO_MESSAGES_PROVIDER, baseURL: endpoint.baseURL, - apiKey, + credentialId: apiKey, model: fields.model, defaults: { maxTokens: SOURCE_MAX_TOKENS, @@ -455,7 +455,7 @@ export function buildGoSource(fields: { id: fields.id, provider: OPENAI_RESPONSES_PROVIDER, baseURL: endpoint.baseURL, - apiKey, + credentialId: apiKey, model: fields.model, defaults: { maxTokens: SOURCE_MAX_TOKENS, @@ -509,7 +509,7 @@ export function buildZenSource(fields: { id: fields.id, provider: ZEN_MESSAGES_PROVIDER, baseURL: endpoint.baseURL, - apiKey, + credentialId: apiKey, model: fields.model, defaults: { maxTokens: SOURCE_MAX_TOKENS, @@ -524,7 +524,7 @@ export function buildZenSource(fields: { id: fields.id, provider: OPENAI_RESPONSES_PROVIDER, baseURL: endpoint.baseURL, - apiKey, + credentialId: apiKey, model: fields.model, defaults: { maxTokens: SOURCE_MAX_TOKENS, diff --git a/src/context-compactor.test.ts b/src/context-compactor.test.ts index 352befbc1..c4b540fd4 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: "k", }; let calls = 0; const summarize = createModelSummarizer({ diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 9ee7aa48c..6d0732334 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -772,7 +772,7 @@ export async function runExec(config: Config): Promise { const { access } = await refreshSelectedProviderCredential(() => getValidCodexToken(initialCodexProfile), ); - liveSource = { ...liveSource, apiKey: access }; + liveSource = { ...liveSource, credentialId: access }; liveSubAgentProvider.current = { ...liveSubAgentProvider.current, apiKey: access, @@ -782,7 +782,7 @@ export async function runExec(config: Config): Promise { const { access } = await refreshSelectedProviderCredential(() => getValidXaiToken(initialXaiProfile), ); - liveSource = { ...liveSource, apiKey: access }; + liveSource = { ...liveSource, credentialId: access }; liveSubAgentProvider.current = { ...liveSubAgentProvider.current, apiKey: access, @@ -802,7 +802,7 @@ export async function runExec(config: Config): Promise { liveSource, config.providers, ); - if (fresh.apiKey === liveSource.apiKey) return; + if (fresh.credentialId === liveSource.credentialId) return; liveSource = fresh; if (currentAgent !== null) setAgentSourceUnlessClosed(currentAgent, fresh); @@ -861,7 +861,9 @@ export async function runExec(config: Config): Promise { 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, + s.id === liveSource.id + ? { ...s, credentialId: liveSource.credentialId } + : s, ); }, getDefaultSource: () => @@ -1005,15 +1007,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 !== liveSource.credentialId) { + liveSource = { ...liveSource, credentialId: access }; setAgentSourceUnlessClosed(activeAgent, liveSource); } } if (initialXaiProfile !== undefined) { const { access } = await getValidXaiToken(initialXaiProfile); - if (access !== liveSource.apiKey) { - liveSource = { ...liveSource, apiKey: access }; + if (access !== liveSource.credentialId) { + liveSource = { ...liveSource, credentialId: access }; setAgentSourceUnlessClosed(activeAgent, liveSource); } } diff --git a/src/session/assemble-runtime.test.ts b/src/session/assemble-runtime.test.ts index 18690e4c2..6771359e1 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: "k", model: "m", }, ], diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index 12772c109..6ef6a4e51 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 { resolveInlineCredentialMaterial } from "../config/credential-material.js"; import { advertisedTools, advertisedToolNamesForSessionMode, @@ -578,6 +579,7 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent { const agent = await createAgentWithLiveToolDispatch(agentDef, { sources: wiring.getSources(), defaultSource: wiring.getDefaultSource(), + readCurrentMaterial: resolveInlineCredentialMaterial, storage: storageForAgent, workdir, // contextTransforms ride deps: the published @intx/agent forwards deps diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index 4d8718f65..c03bc22d1 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -19,6 +19,7 @@ import { type RetryPolicy, } from "@intx/types/runtime"; import { LOG_NAMESPACE_ROOT } from "../branding.js"; +import { resolveInlineCredentialMaterial } from "../config/credential-material.js"; import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js"; import { buildArchiveSummaryExcerpt, @@ -200,6 +201,7 @@ function defaultComplete(deps: Dependencies, timeoutMs: number): CompletionFn { signal, nextSeq: () => seq++, deps, + readMaterial: resolveInlineCredentialMaterial, 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..ee864e45e 100644 --- a/src/subagent/refresh-inference-source.test.ts +++ b/src/subagent/refresh-inference-source.test.ts @@ -4,11 +4,11 @@ import * as xaiSession from "../auth/xai/session.js"; import type { InferenceSource } from "@intx/types/runtime"; -const baseSource = (id: string, apiKey = "stale"): InferenceSource => ({ +const baseSource = (id: string, credentialId = "stale"): InferenceSource => ({ id, provider: "openai", baseURL: "https://api.openai.com/v1", - apiKey, + credentialId, model: "gpt-4o", }); @@ -18,7 +18,7 @@ describe("refresh-inference-source", () => { spyOn(xaiSession, "getValidXaiToken").mockRestore(); }); - test("ensureFreshInferenceSource replaces stale Codex apiKey after refresh", async () => { + test("ensureFreshInferenceSource replaces stale Codex credentialId after refresh", async () => { spyOn(codexSession, "getValidCodexToken").mockResolvedValue({ access: "fresh-codex-token", }); @@ -26,7 +26,7 @@ describe("refresh-inference-source", () => { await import("./refresh-inference-source.js"); const source = baseSource("codex/default", "stale"); const out = await ensureFreshInferenceSource(source, []); - expect(out.apiKey).toBe("fresh-codex-token"); + expect(out.credentialId).toBe("fresh-codex-token"); }); test("refreshInferenceSourceBundle refreshes each leg", async () => { @@ -53,6 +53,6 @@ describe("refresh-inference-source", () => { apiKey: "key-abc", }, ]); - expect(out.apiKey).toBe("key-abc"); + expect(out.credentialId).toBe("key-abc"); }); }); diff --git a/src/subagent/refresh-inference-source.ts b/src/subagent/refresh-inference-source.ts index 272f8a36a..c4f9d169d 100644 --- a/src/subagent/refresh-inference-source.ts +++ b/src/subagent/refresh-inference-source.ts @@ -18,12 +18,12 @@ export async function ensureFreshInferenceSource( entry?.codexProfile ?? codexProfileFromProviderName(source.id); if (codexProfile !== undefined) { const { access } = await getValidCodexToken(codexProfile); - return { ...source, apiKey: access }; + return { ...source, credentialId: access }; } const xaiProfile = entry?.xaiProfile ?? xaiProfileFromProviderName(source.id); if (xaiProfile !== undefined) { const { access } = await getValidXaiToken(xaiProfile); - return { ...source, apiKey: access }; + return { ...source, credentialId: access }; } return source; } diff --git a/src/subagent/run-source.test.ts b/src/subagent/run-source.test.ts index 2d2659a4c..265ab6a23 100644 --- a/src/subagent/run-source.test.ts +++ b/src/subagent/run-source.test.ts @@ -19,7 +19,7 @@ describe("buildSubAgentPrimarySource", () => { id: "ollama/default", provider: "openai-compatible", baseURL: "http://localhost:11434/v1", - apiKey: KEYLESS_API_KEY, + credentialId: KEYLESS_API_KEY, model: "qwen3", }); }); @@ -43,7 +43,7 @@ describe("buildSubAgentPrimarySource", () => { expect(source).toMatchObject({ id: providerName, provider: "openai-responses", - apiKey: "sk-go", + credentialId: "sk-go", model: "gpt-5.6-luna", }); expect(typeof sessionId).toBe("string"); diff --git a/src/subagent/run.ts b/src/subagent/run.ts index 5fc981d19..cad5b3745 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -64,6 +64,7 @@ import { import { createCodexReadRawFile } from "../agent/codex-read-raw-file.js"; import { isCodexProviderName } from "../config/codex-providers.js"; +import { resolveInlineCredentialMaterial } from "../config/credential-material.js"; import { isOpenCodeGoProvider } from "../../packages/opencode-go/src/index.js"; import { createCompositeBlobReader } from "../agent/lazy-blob-reader.js"; @@ -1147,6 +1148,7 @@ async function runSubAgentInner( agent = await createAgentWithLiveToolDispatch(def, { sources: bundle.sources, defaultSource: bundle.defaultSource, + readCurrentMaterial: resolveInlineCredentialMaterial, storage, workdir, // contextTransforms ride deps: the published @intx/agent forwards deps diff --git a/src/tui/runner/exit.test.ts b/src/tui/runner/exit.test.ts index f93258925..59ded573a 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: "old-token", model: "m", }; diff --git a/src/tui/runner/exit.ts b/src/tui/runner/exit.ts index 6c00d0ad2..95f7b0872 100644 --- a/src/tui/runner/exit.ts +++ b/src/tui/runner/exit.ts @@ -404,9 +404,9 @@ export async function createRunLifecycle( if (active === undefined) return; const { access } = await getValidCodexToken(active.profile); const source: InferenceSource = - access === active.source.apiKey + access === active.source.credentialId ? active.source - : { ...active.source, apiKey: access }; + : { ...active.source, credentialId: access }; state.activeCodexSource = { profile: active.profile, source }; state.liveSource = source; setAgentSourceUnlessClosed(liveAgent(state), source); @@ -417,9 +417,9 @@ export async function createRunLifecycle( if (active === undefined) return; const { access } = await getValidXaiToken(active.profile); const source: InferenceSource = - access === active.source.apiKey + access === active.source.credentialId ? active.source - : { ...active.source, apiKey: access }; + : { ...active.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..9d7697070 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -576,7 +576,7 @@ export async function assembleTUISession( state.liveSource, state.config.providers, ); - if (fresh.apiKey === state.liveSource.apiKey) return; + if (fresh.credentialId === state.liveSource.credentialId) return; state.liveSource = fresh; if (state.currentAgent !== undefined) setAgentSourceUnlessClosed(state.currentAgent, fresh); diff --git a/tests/integration/harness.ts b/tests/integration/harness.ts index 01f86caf8..839e60013 100644 --- a/tests/integration/harness.ts +++ b/tests/integration/harness.ts @@ -31,6 +31,7 @@ import type { import { type } from "arktype"; import { createAgentWithLiveToolDispatch } from "../../src/agent/live-tool-dispatch.js"; +import { resolveInlineCredentialMaterial } from "../../src/config/credential-material.js"; import { createChatDirector } from "../../src/agent/director.js"; import { createAgentToolset } from "../../src/agent/tools.js"; import { ID_PREFIX } from "../../src/branding.js"; @@ -59,7 +60,7 @@ export const INTEGRATION_SOURCE: InferenceSource = { id: "anthropic:claude-integration", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "sk-integration-test", + credentialId: "sk-integration-test", model: "claude-integration", }; @@ -205,6 +206,7 @@ export async function openIntegrationSession( const innerAgent = await startAgent(def, { sources: [INTEGRATION_SOURCE], defaultSource: INTEGRATION_SOURCE.id, + readCurrentMaterial: resolveInlineCredentialMaterial, storage: storageForAgent, workdir, deps: { diff --git a/tests/integration/vendored-carry.test.ts b/tests/integration/vendored-carry.test.ts index 5ad8479af..edfc78dea 100644 --- a/tests/integration/vendored-carry.test.ts +++ b/tests/integration/vendored-carry.test.ts @@ -17,6 +17,7 @@ import type { ContextTransform } from "@intx/types/runtime"; import { type } from "arktype"; import { ID_PREFIX } from "../../src/branding.js"; +import { resolveInlineCredentialMaterial } from "../../src/config/credential-material.js"; import { createPermissionGate } from "../../src/permission/gate.js"; import { createOptimizedContextStore } from "../../src/session/optimized-context-store.js"; import { @@ -151,6 +152,7 @@ describe("integration — vendored feature carry", () => { const agent = await createAgent(def, { sources: [INTEGRATION_SOURCE], defaultSource: INTEGRATION_SOURCE.id, + readCurrentMaterial: resolveInlineCredentialMaterial, storage, workdir, deps: harness.deps, diff --git a/tests/unit/inference-response-kind.test.ts b/tests/unit/inference-response-kind.test.ts index 9752ea569..9838a30ef 100644 --- a/tests/unit/inference-response-kind.test.ts +++ b/tests/unit/inference-response-kind.test.ts @@ -19,6 +19,7 @@ import type { InferenceSource, } from "@intx/types/runtime"; import { createInferenceDependencies } from "../../src/provider/inference-dependencies.js"; +import { resolveInlineCredentialMaterial } from "../../src/config/credential-material.js"; import { CODEX_RESPONSES_PROVIDER, withCodexContentTypeRepair, @@ -31,7 +32,7 @@ const CODEX_SOURCE: InferenceSource = { id: "codex/default", provider: CODEX_RESPONSES_PROVIDER, baseURL: "https://chatgpt.com/backend-api", - apiKey: "test-token", + credentialId: "test-token", model: "gpt-5.6-sol", }; @@ -96,6 +97,7 @@ async function runCodexTurn( source: CODEX_SOURCE, nextSeq: () => ++seq, deps, + readMaterial: resolveInlineCredentialMaterial, }), ); } diff --git a/tests/unit/summarizer.test.ts b/tests/unit/summarizer.test.ts index 8f63acf11..5ed5979ad 100644 --- a/tests/unit/summarizer.test.ts +++ b/tests/unit/summarizer.test.ts @@ -14,7 +14,7 @@ const source: InferenceSource = { provider: "openai", model: "test-model", baseURL: "http://localhost:1", - apiKey: "k", + credentialId: "k", }; function turns(): ConversationTurn[] { @@ -201,7 +201,7 @@ test("summarizer timeout is honoured independently of the director total timeout provider: "anthropic", model: "claude-test", baseURL: "https://api.anthropic.com", - apiKey: "k", + credentialId: "k", }), deps: harness.deps, timeoutMs: 30_000, diff --git a/vendor/intx-agent/PATCHES.md b/vendor/intx-agent/PATCHES.md index d4c1bc3e8..9fed9f9cf 100644 --- a/vendor/intx-agent/PATCHES.md +++ b/vendor/intx-agent/PATCHES.md @@ -8,6 +8,17 @@ 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-13 re-sync (upstream `1ad010463a6bce6034cded3e078b14db482882a8`) + +Every entry below was re-carried against the new pin; none was dropped +as upstream-absorbed — upstream `agent.ts` at the new pin still neither +resumes `errorSeq` from durable records nor handles `Duplicate error +record` collisions. The pinned range renames the credential surface +(`apiKey` → `credentialId`, `readMaterial`, new +`credential-resolver.ts`); divergences from the new pin are `agent.ts`, +`flush-errors.test.ts`, and `testing/audit-noop.*` only. No entry's +disposition changed. + ## 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..508f7e588 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"; @@ -526,11 +528,6 @@ export async function createAgent( let flushInProgress: Promise | undefined; let pendingFollowUp: Promise | undefined; - // File key mirror of the isogit store's error filename scheme - // (`state/errors//-.json`, seq padded to - // 8, unsafe category chars replaced). Maps a - // `Duplicate error record: ` collision back to the batch member - // that caused it so only that record is dropped. function errorFileKey(record: ErrorRecord): string { const seq = String(record.seq).padStart(8, "0"); const category = record.category.replace(/[^a-zA-Z0-9_-]/g, "_"); @@ -748,6 +745,8 @@ export async function createAgent( source: sourceRegistry.active, failOverToNextSource: () => sourceRegistry.failOverToNextSource(), resetToPreferredSource: () => sourceRegistry.resetToPreferredSource(), + readMaterial: + env.readCurrentMaterial ?? createUnconfiguredCredentialResolver(), toolRunner: resolvedTools.runner, contextStore, onEvent: handleEvent, diff --git a/vendor/intx-agent/src/audit-integration.test.ts b/vendor/intx-agent/src/audit-integration.test.ts index 6022070c4..a245f0aba 100644 --- a/vendor/intx-agent/src/audit-integration.test.ts +++ b/vendor/intx-agent/src/audit-integration.test.ts @@ -49,7 +49,7 @@ const SOURCE: InferenceSource = { id: "anthropic:audit-test", provider: "anthropic", baseURL: "http://localhost:1", - apiKey: "test-key", + credentialId: "test-key", model: "claude-test", }; @@ -71,9 +71,6 @@ function makeRecordingAuditStore(): RecordingAuditStore { async loadAudit(_sessionId: string): Promise { return committedAudit.flat(); }, - async loadErrors(_sessionId: string): Promise { - return committedErrors.flat(); - }, getCommittedAudit() { return committedAudit; }, @@ -188,6 +185,7 @@ async function buildEnv(opts: { return { sources: [SOURCE], defaultSource: SOURCE.id, + readCurrentMaterial: (credentialId) => ({ secret: credentialId }), storage, workdir: opts.workdir, audit: opts.audit, diff --git a/vendor/intx-agent/src/compactor-wiring.test.ts b/vendor/intx-agent/src/compactor-wiring.test.ts index ed48c13ae..9c9fa7db3 100644 --- a/vendor/intx-agent/src/compactor-wiring.test.ts +++ b/vendor/intx-agent/src/compactor-wiring.test.ts @@ -50,7 +50,7 @@ const SOURCE: InferenceSource = { id: "anthropic:compactor-test", provider: "anthropic", baseURL: "http://localhost:1", - apiKey: "test-key", + credentialId: "test-key", model: "claude-test", }; @@ -168,6 +168,7 @@ async function buildEnv(opts: { return { sources: [SOURCE], defaultSource: SOURCE.id, + readCurrentMaterial: (credentialId) => ({ secret: credentialId }), storage, workdir: opts.workdir, audit: noopAuditStore(), diff --git a/vendor/intx-agent/src/credential-resolver.ts b/vendor/intx-agent/src/credential-resolver.ts new file mode 100644 index 000000000..32f98b986 --- /dev/null +++ b/vendor/intx-agent/src/credential-resolver.ts @@ -0,0 +1,46 @@ +// Helpers for the inference credential-material resolver seam +// (`CredentialMaterialResolver` in `@intx/types`). An inference call resolves +// its source's secret by `credentialId` through this seam instead of reading an +// inline `apiKey`, so the source config carries no secret. The sidecar backs it +// with the run's live credential cell; the helpers here cover the two simpler +// cases. + +import type { + CredentialMaterial, + CredentialMaterialResolver, +} from "@intx/types"; + +/** + * A resolver that fails closed on every call. `createAgent` installs this when + * the env supplies no `readCurrentMaterial`, so an agent whose inference never + * resolves a credential (a mock adapter emitting no credential sentinel) needs + * no resolver, while one that DOES reach a credential surfaces a clear error + * rather than a confusing `undefined`. + */ +export function createUnconfiguredCredentialResolver(): CredentialMaterialResolver { + return (credentialId: string): CredentialMaterial => { + throw new Error( + `no credential resolver configured for this agent, but an inference call needs the secret for credential ${credentialId}; supply env.readCurrentMaterial`, + ); + }; +} + +/** + * A resolver over a fixed `credentialId -> secret` map. For callers that hold + * their secrets in memory rather than a live cell -- examples, tests, and any + * single-process agent. Fails closed when a source references a credential the + * map does not carry, mirroring the cell reader's revoked/absent behavior. + */ +export function createStaticCredentialResolver( + materials: Record, +): CredentialMaterialResolver { + return (credentialId: string): CredentialMaterial => { + const secret = materials[credentialId]; + if (secret === undefined) { + throw new Error( + `no credential material for ${credentialId} in the static resolver`, + ); + } + return { secret }; + }; +} diff --git a/vendor/intx-agent/src/env-validation.test.ts b/vendor/intx-agent/src/env-validation.test.ts index f45633024..c3e8120b6 100644 --- a/vendor/intx-agent/src/env-validation.test.ts +++ b/vendor/intx-agent/src/env-validation.test.ts @@ -16,7 +16,7 @@ const SOURCE = { id: "anthropic:claude-3-5-sonnet", provider: "anthropic", baseURL: "https://api.anthropic.com", - apiKey: "sk-test", + credentialId: "sk-test", model: "claude-3-5-sonnet", }; diff --git a/vendor/intx-agent/src/env.ts b/vendor/intx-agent/src/env.ts index 08e462fcc..d0bb7d8aa 100644 --- a/vendor/intx-agent/src/env.ts +++ b/vendor/intx-agent/src/env.ts @@ -13,6 +13,7 @@ // tests and examples ship from `@intx/agent/testing`. import type { AuthzCallResult, Dependencies } from "@intx/inference"; +import type { CredentialMaterialResolver } from "@intx/types"; import type { AuditStore, Compactor, @@ -82,6 +83,12 @@ export interface BaseEnv { * `workdir` values pointing at the same on-disk storage directory * will silently corrupt each other -- the invariant is the caller's * to maintain. + * + * This is the lock and storage boundary, not the working tree the + * filesystem tools operate on. A tool that needs a working directory + * (e.g. `@intx/tools-posix`) reads that from its own env-DI key + * declared through `defineTool({ requires })`, which the caller may + * point at a directory distinct from `workdir`. */ workdir: string; @@ -128,6 +135,19 @@ export interface BaseEnv { */ deps?: Dependencies; + /** + * Resolves an inference source's credential secret by `credentialId` from the + * run's credential-material cell at send time -- the same cell tool + * credentials resolve from, so the source config carries no inline secret. + * + * Optional at this boundary only to spare callers whose inference never + * resolves a credential (a mock adapter that emits no credential sentinel). + * `createAgent` fills a fail-closed default that throws if an inference call + * actually needs a secret; a caller that does real credentialed inference (the + * sidecar step env, an example, a test with a real adapter) MUST supply one. + */ + readCurrentMaterial?: CredentialMaterialResolver; + /** * Optional deterministic session id. Production callers omit and let * the agent generate a fresh UUID; tests that assert on audit-record diff --git a/vendor/intx-agent/src/flush-errors.test.ts b/vendor/intx-agent/src/flush-errors.test.ts index 7064d5d6d..f219e7189 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,63 @@ function makeFailFirstAuditStore(): FailingAuditStore { }; } +// Director factory that closes over a caller-supplied `decide` to drive +// the reactor through targeted event shapes. The factory shape requires +// a configSchema (arktype) and returns a ReactorDirector; this helper +// hides that boilerplate. +function makeDirectorRegistry( + decide: ReactorDirector["decide"], +): BaseEnv["directors"] { + const defined = defineDirector({ + id: "@intx-test/flush/probe", + configSchema: type({}), + factory: () => ({ decide }), + }); + return createDirectorRegistry({ + factories: [defined.factory], + defaultId: defined.factory.id, + }); +} + +async function buildAgentEnv(opts: { + workdir: string; + audit: AuditStore; + directors: BaseEnv["directors"]; +}): Promise { + const storage = await createIsogitStore(opts.workdir); + return { + sources: [UNREACHABLE_SOURCE], + defaultSource: UNREACHABLE_SOURCE.id, + readCurrentMaterial: (credentialId) => ({ secret: credentialId }), + storage, + workdir: opts.workdir, + audit: opts.audit, + authorize: permissiveAuthorize(), + directors: opts.directors, + }; +} + +function inboundConversation(): ReturnType { + return createInboundMessage({ + from: "user@local", + to: "agent@local", + content: "trigger", + interchangeType: "conversation.message", + }); +} + +// Drain agent.stream() until a `reactor.done` event is observed, then +// resolve. The agent's terminal event signals the reactor has settled +// and any pending audit-flush has had a chance to run via the +// assembly's onShutdown hook. +async function waitForReactorDone( + stream: AsyncIterable<{ type: string }>, +): Promise { + for await (const event of stream) { + if (event.type === "reactor.done") return; + } +} + function makeDuplicateErrorAuditStore(): FailingAuditStore { return { async commitAudit(_records: AuditRecord[]): Promise { @@ -164,62 +221,6 @@ function makePartialDuplicateAuditStore(): FailingAuditStore { }; } -// Director factory that closes over a caller-supplied `decide` to drive -// the reactor through targeted event shapes. The factory shape requires -// a configSchema (arktype) and returns a ReactorDirector; this helper -// hides that boilerplate. -function makeDirectorRegistry( - decide: ReactorDirector["decide"], -): BaseEnv["directors"] { - const defined = defineDirector({ - id: "@intx-test/flush/probe", - configSchema: type({}), - factory: () => ({ decide }), - }); - return createDirectorRegistry({ - factories: [defined.factory], - defaultId: defined.factory.id, - }); -} - -async function buildAgentEnv(opts: { - workdir: string; - audit: AuditStore; - directors: BaseEnv["directors"]; -}): Promise { - const storage = await createIsogitStore(opts.workdir); - return { - sources: [UNREACHABLE_SOURCE], - defaultSource: UNREACHABLE_SOURCE.id, - storage, - workdir: opts.workdir, - audit: opts.audit, - authorize: permissiveAuthorize(), - directors: opts.directors, - }; -} - -function inboundConversation(): ReturnType { - return createInboundMessage({ - from: "user@local", - to: "agent@local", - content: "trigger", - interchangeType: "conversation.message", - }); -} - -// Drain agent.stream() until a `reactor.done` event is observed, then -// resolve. The agent's terminal event signals the reactor has settled -// and any pending audit-flush has had a chance to run via the -// assembly's onShutdown hook. -async function waitForReactorDone( - stream: AsyncIterable<{ type: string }>, -): Promise { - for await (const event of stream) { - if (event.type === "reactor.done") return; - } -} - const FORBIDDEN_DEPS = { ...createDefaultDependencies(), fetch: async () => @@ -618,168 +619,120 @@ describe("agent error flushing", () => { expect(batches[0]?.[0]?.source).toBe("reactor"); }); - test("two credential_failure errors in one session persist without failing the run", async () => { - const sessionId = "session-credential-once"; - let inferenceErrors = 0; - const store = await createIsogitStore(workDir); - const env: BaseEnv = { - sources: [UNREACHABLE_SOURCE], - defaultSource: UNREACHABLE_SOURCE.id, - storage: store, - workdir: workDir, - audit: store, - authorize: permissiveAuthorize(), - directors: makeDirectorRegistry( - async ( - event: ReactorInboundEvent, - _state: ReactorState, - caps: ReactorCapabilities, - ) => { - if (event.type === "message.received") return caps.infer(); - if (event.type === "inference.error") { - inferenceErrors += 1; - if (inferenceErrors === 1) { - return [caps.checkpoint("after-first"), caps.infer()]; - } - return [caps.checkpoint("after-second"), caps.done()]; - } - return caps.done(); - }, - ), - sessionId, - deps: FORBIDDEN_DEPS, - }; - const agent = await createAgent(forbiddenAgentDef("cred-flush-once"), env); - const events: Array<{ type: string; data?: unknown }> = []; - const stream = agent.stream(); - try { - agent.deliver(inboundConversation()); - for await (const event of stream) { - events.push(event); - if (event.type === "reactor.done") break; - } - } finally { - await agent.close(); - } - - expect(duplicateFlushFailures(events)).toEqual([]); - const records = (await store.loadErrors(sessionId)).filter( - (record) => record.category === "credential_failure", - ); - expect(records).toHaveLength(2); - expect(new Set(records.map((record) => record.seq)).size).toBe(2); - }); - - test("two credential_failure errors persist across re-assembly without failing the session", async () => { - const sessionId = "session-credential"; - const first = await runForbiddenCycle({ - workdir: workDir, - sessionId, - agentId: "cred-flush-1", - }); - const second = await runForbiddenCycle({ - workdir: workDir, - sessionId, - agentId: "cred-flush-2", + test("surfaces duplicate-record failures from a stale-seq flush", async () => { + const { events } = await runForbiddenCycle({ + workdir: tempWorkdir(), + sessionId: "error-duplicates-session", + agentId: "error-duplicates-agent", }); - expect(duplicateFlushFailures(first.events)).toEqual([]); - expect(duplicateFlushFailures(second.events)).toEqual([]); - const store = await createIsogitStore(workDir); - const records = (await store.loadErrors(sessionId)).filter( - (record) => record.category === "credential_failure", - ); - expect(records).toHaveLength(2); - expect(new Set(records.map((record) => record.seq)).size).toBe(2); + const failures = duplicateFlushFailures(events); + expect(failures.length).toBeGreaterThan(0); + const detail = JSON.stringify(failures[0]?.data ?? {}); + expect(detail).toContain("Duplicate error record"); + // The stale-seq assembly must include the record's file key so the + // duplicate can be traced to the colliding error file. + expect(detail).toMatch(/[0-9a-f]{8}-[a-z_]+/); }); - test("a duplicate error record from commitErrors does not fail the session", async () => { - const audit = makeDuplicateErrorAuditStore(); - const directors = credentialFailureDirectors(); - const def = forbiddenAgentDef("cred-flush-duplicate"); - const env = await buildAgentEnv({ workdir: workDir, audit, directors }); - const agent = await createAgent(def, { ...env, deps: FORBIDDEN_DEPS }); - const events: Array<{ type: string; data?: unknown }> = []; - const stream = agent.stream(); - try { - agent.deliver(inboundConversation()); - for await (const event of stream) { - events.push(event); - if (event.type === "reactor.done") break; - } - } finally { - await agent.close(); - } - - expect(duplicateFlushFailures(events)).toEqual([]); - expect(events.some((event) => event.type === "reactor.done")).toBe(true); + test("skips the colliding record and commits the fresh remainder", async () => { + const store = makePartialDuplicateAuditStore(); + const sessionId = "error-duplicates-session"; + const records: ErrorRecord[] = [ + { + seq: 0, + recordedAt: new Date().toISOString(), + source: "reactor", + sessionId, + fingerprint: "stale", + category: "credential_failure", + message: "stale", + detail: {}, + count: 1, + firstSeenSeq: 0, + lastSeenSeq: 0, + }, + { + seq: 1, + recordedAt: new Date().toISOString(), + source: "reactor", + sessionId, + fingerprint: "fresh", + category: "config_invalid", + message: "fresh", + detail: {}, + count: 1, + firstSeenSeq: 1, + lastSeenSeq: 1, + }, + ]; + const errors = await flushAgentErrors(sessionId, () => records, store); + expect(errors).toEqual([]); + const batches = store.getCommittedErrors(); + expect(batches.length).toBe(1); + expect(batches[0]?.map((record) => record.seq)).toEqual([1]); }); - test("a partial duplicate collision drops only the colliding record", async () => { - // A stale-seq assembly flushing [seq0/dup, seq1/fresh] must persist - // the fresh record: the first commit names only the colliding key, - // so the flush drops that record and retries the rest. - const audit = makePartialDuplicateAuditStore(); - let inferenceErrors = 0; - const directors = makeDirectorRegistry( - async ( - event: ReactorInboundEvent, - _state: ReactorState, - caps: ReactorCapabilities, - ) => { - if (event.type === "message.received") return caps.infer(); - if (event.type === "inference.error") { - inferenceErrors += 1; - if (inferenceErrors === 1) return caps.infer(); - return [caps.checkpoint("after-second"), caps.done()]; - } - return caps.done(); - }, + test("duplicate failure without a parseable record fails the run", async () => { + const store = makeDuplicateErrorAuditStore(); + const errors = await flushAgentErrors( + "error-duplicates-session", + () => [], + store, ); - const def = forbiddenAgentDef("cred-flush-partial-duplicate"); - const env = await buildAgentEnv({ workdir: workDir, audit, directors }); - const agent = await createAgent(def, { ...env, deps: FORBIDDEN_DEPS }); - const events: Array<{ type: string; data?: unknown }> = []; - const stream = agent.stream(); - try { - agent.deliver(inboundConversation()); - for await (const event of stream) { - events.push(event); - if (event.type === "reactor.done") break; - } - } finally { - await agent.close(); - } - - expect(duplicateFlushFailures(events)).toEqual([]); - expect(events.some((event) => event.type === "reactor.done")).toBe(true); - const persisted = audit.getCommittedErrors().flat(); - expect(persisted).toHaveLength(1); - expect(persisted[0]?.seq).toBe(1); + expect(errors).toEqual([]); }); - test("createAgent still assembles when loadErrors throws", async () => { - const audit = makeRecordingAuditStore(); - audit.loadErrors = async () => { - throw new Error("simulated loadErrors failure"); - }; - const directors = credentialFailureDirectors(); - const def = forbiddenAgentDef("cred-flush-load-errors"); - const env = await buildAgentEnv({ workdir: workDir, audit, directors }); - const agent = await createAgent(def, { ...env, deps: FORBIDDEN_DEPS }); - const events: Array<{ type: string; data?: unknown }> = []; - const stream = agent.stream(); - try { - agent.deliver(inboundConversation()); - for await (const event of stream) { - events.push(event); - if (event.type === "reactor.done") break; - } - } finally { - await agent.close(); - } + test("two error batches flush exactly once", async () => { + const store = await createIsogitStore(tempWorkdir()); + const sessionId = "two-batch-session"; + const first: ErrorRecord[] = [ + { + seq: 0, + recordedAt: new Date().toISOString(), + source: "reactor", + sessionId, + fingerprint: "first", + category: "credential_failure", + message: "first", + detail: {}, + count: 1, + firstSeenSeq: 0, + lastSeenSeq: 0, + }, + ]; + const second: ErrorRecord[] = [ + { + seq: 1, + recordedAt: new Date().toISOString(), + source: "reactor", + sessionId, + fingerprint: "second", + category: "config_invalid", + message: "second", + detail: {}, + count: 1, + firstSeenSeq: 1, + lastSeenSeq: 1, + }, + ]; + await flushAgentErrors(sessionId, () => first, store); + const errors = await flushAgentErrors(sessionId, () => second, store); + expect(errors).toEqual([]); + const loaded = await store.loadErrors(sessionId); + expect(loaded.map((record) => record.seq)).toEqual([0, 1]); + expect(loaded[0]?.message).toBe("first"); + expect(loaded[1]?.message).toBe("second"); + }); - expect(events.some((event) => event.type === "reactor.done")).toBe(true); - expect(audit.getCommittedErrors().flat().length).toBeGreaterThan(0); + test("empty error batches commit nothing", async () => { + const store = await createIsogitStore(tempWorkdir()); + const errors = await flushAgentErrors( + "empty-batch-session", + () => [], + store, + ); + expect(errors).toEqual([]); + await expect(store.loadErrors("empty-batch-session")).resolves.toEqual([]); }); }); 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-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..d4bba3757 100644 --- a/vendor/intx-inference/PATCHES.md +++ b/vendor/intx-inference/PATCHES.md @@ -26,6 +26,21 @@ Re-carryable, "Kill candidate" = Droppable, and "Companion" entries are Re-carryable but ride their primary patch's disposition (they ship out or die with it). +### 2026-09-13 re-sync (upstream `1ad010463a6bce6034cded3e078b14db482882a8`) + +Every entry below was re-carried against the new pin; none was dropped +as upstream-absorbed. The pinned range is dominated by sidecar/mail +admission work: `e93fecc7` ("Shadow-verify inbound mail signatures at the +sidecar ingress") threads verification through `harness.ts`, `reactor.ts`, +`assembly.ts`, and `providers/anthropic.ts`; `487964f5` constructs the +Anthropic event union in one call (`providers/anthropic.ts`). The same +range renames the credential surface (`apiKey` → `credentialId`, +`readMaterial`, new `credential-resolver.ts`), carried here as unmarked +upstream drift in the test files. All patches are present against the new +upstream code with no entry's disposition changed. The `Re-carry:` notes +on the entries below record the previous (`0205b07b`) sync and are +retained as history. + ### 2026-09-07 re-sync (upstream `0205b07b`) Every entry below was re-carried against the new pin; none was dropped as @@ -428,7 +443,9 @@ surfaces it on `inference.usage`, and `vendor/intx-types`' `InferenceUsageEvent` gains the optional `stopReason` field both halves flow through. Guarded by the CL-7783 regression suite in `providers/anthropic.test.ts`, which drives the exact incident wire sequence and asserts no `tool_call` block reaches the -reactor. The OpenAI-compatible adapter was audited for the same path: it has +reactor. The Gemini-side suite in `providers/google-genai.test.ts` — a +locally kept file with no same-named upstream counterpart at this pin — +asserts the terminal `finishReason` forwarding half. The OpenAI-compatible adapter was audited for the same path: it has no adapter-local args fallback (the harness was the only dispatch site) but still drops `finish_reason` on both paths, so OpenAI streams get the generic invalid-JSON failure rather than the truncation-specific message. The Gemini diff --git a/vendor/intx-inference/src/assembly.test.ts b/vendor/intx-inference/src/assembly.test.ts index 91f1aa69a..6834c307f 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", }; } 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/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..afc6eb610 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 { @@ -226,9 +228,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 +264,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 +406,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 diff --git a/vendor/intx-inference/src/providers/anthropic.test.ts b/vendor/intx-inference/src/providers/anthropic.test.ts index 99886c11a..b773ad58d 100644 --- a/vendor/intx-inference/src/providers/anthropic.test.ts +++ b/vendor/intx-inference/src/providers/anthropic.test.ts @@ -909,7 +909,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 +948,7 @@ async function driveTurn( source: JSON_SOURCE, nextSeq: () => ++seq, deps, + readMaterial: () => ({ secret: "test-secret" }), })) { events.push(ev); } 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/reactor.test.ts b/vendor/intx-inference/src/reactor.test.ts index 420017318..7009317d9 100644 --- a/vendor/intx-inference/src/reactor.test.ts +++ b/vendor/intx-inference/src/reactor.test.ts @@ -289,7 +289,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 +1008,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(), @@ -2124,6 +2124,10 @@ describe("createReactor — doom-loop poll exemption", () => { }); }); +// --------------------------------------------------------------------------- +// 8. Correlation matching +// --------------------------------------------------------------------------- + describe("createReactor — correlation", () => { test("message with matching correlationId triggers message.correlated", async () => { const CORR_ID = "corr-xyz-123"; @@ -3554,17 +3558,12 @@ describe("createReactor — state snapshot inspection", () => { if (event.type === "message.received") { messageCount++; if (messageCount === 1) { - // Mutate the snapshot's content block. Frozen turns throw; - // isolation still holds if the assignment is ignored. + // Mutate the snapshot's content block. const msg = state.turns[0]; if (msg !== undefined) { const block = msg.content[0]; if (block !== undefined && block.type === "text") { - try { - (block as { text: string }).text = "CORRUPTED"; - } catch { - /* deepFreeze */ - } + (block as { text: string }).text = "CORRUPTED"; } } return caps.wait(); @@ -5812,7 +5811,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(), @@ -6268,7 +6267,6 @@ describe("createReactor — transform chain ordering and compact action", () => expect(recording.commits[i]?.message).not.toBe("first-override"); } }); -}); // --------------------------------------------------------------------------- // 28. Per-message run-bracket emission @@ -6518,7 +6516,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..f7d9e16fa 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"; @@ -76,6 +77,27 @@ function assertNever(x: never): never { throw new Error(`Unhandled resume case: ${JSON.stringify(x)}`); } +function buildHarnessOpts( + turns: ConversationTurn[], + source: InferenceSource, + options: InferenceOptions | undefined, + signal: AbortSignal, + nextSeq: () => number, + readMaterial: CredentialMaterialResolver | undefined, + deps: Dependencies, +): InferenceHarnessOptions { + // exactOptionalPropertyTypes is on: only set the optional keys when defined. + return { + turns, + source, + ...(options !== undefined ? { inferenceOptions: options } : {}), + signal, + nextSeq, + ...(readMaterial !== undefined ? { readMaterial } : {}), + deps, + }; +} + /** * `InferenceOptions` plus vendored-only fields the published `@intx/types` * does not carry. `ephemeralTurns` are appended to the materialized prompt @@ -88,27 +110,6 @@ export type ExtendedInferenceOptions = InferenceOptions & { ephemeralTurns?: ConversationTurn[]; }; -function buildHarnessOpts( - turns: ConversationTurn[], - source: InferenceSource, - options: InferenceOptions | undefined, - signal: AbortSignal, - nextSeq: () => number, - deps: Dependencies, -): InferenceHarnessOptions { - if (options !== undefined) { - return { - turns, - source, - inferenceOptions: options, - signal, - nextSeq, - deps, - }; - } - return { turns, source, signal, nextSeq, deps }; -} - export type ReactorEmittedEvent = | InferenceEvent | { @@ -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; @@ -595,7 +603,6 @@ export function createReactor(config: ReactorConfig): Reactor { const op = pending; const dispatch = resumePendingOperation(op, message); - const gate = gates.findByCorrelationId(correlationId); switch (dispatch.mode) { case "redispatch": { @@ -719,6 +726,10 @@ export function createReactor(config: ReactorConfig): Reactor { // Run the context transform chain to produce the materialized prompt. let prompt: ConversationTurn[] = stateManager.getTurns(); + const ephemeral = options?.ephemeralTurns; + if (ephemeral !== undefined && ephemeral.length > 0) { + prompt = [...prompt, ...ephemeral]; + } for (const transform of contextTransforms) { const ctx = buildStrategyContext("pre-inference"); const result = await transform.apply(prompt, ctx); @@ -727,11 +738,6 @@ export function createReactor(config: ReactorConfig): Reactor { await persistBlobs(result.blobs); } - const ephemeral = options?.ephemeralTurns; - if (ephemeral !== undefined && ephemeral.length > 0) { - prompt = [...prompt, ...ephemeral]; - } - // Tripwire: a malformed tool sequence is invalid in a coherent tool // conversation and would otherwise surface as an opaque provider rejection. // Catch it here, before the prompt is persisted or sent, so the corruption @@ -761,6 +767,7 @@ export function createReactor(config: ReactorConfig): Reactor { options, signal, nextSeq, + config.readMaterial, deps, ); @@ -1074,9 +1081,9 @@ export function createReactor(config: ReactorConfig): Reactor { const result = await compactor.apply(stateManager.getTurns(), ctx); // Locally patched — see vendor/intx-inference/PATCHES.md#reactor-ts-compact-publish-then-memory - await persistBlobs(result.blobs); await contextStore.writeTurns(result.output); pendingCompactOutput = result.output; + await persistBlobs(result.blobs); manifestBuffer.push(result.record); cycleCompactorName = compactor.name; 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..fbf12e590 100644 --- a/vendor/intx-storage-isogit/PATCHES.md +++ b/vendor/intx-storage-isogit/PATCHES.md @@ -8,6 +8,12 @@ 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-13 re-sync (upstream `1ad010463a6bce6034cded3e078b14db482882a8`) + +The single entry below was re-carried against the new pin; upstream +`store.ts` at the new pin still has no `loadErrors`. Divergences from +the new pin are `store.ts` and its test only. No disposition change. + ## store-ts-load-errors `store.ts` — Implements `AuditStore.loadErrors` by reading 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..a83d651b2 100644 --- a/vendor/intx-types/PATCHES.md +++ b/vendor/intx-types/PATCHES.md @@ -8,6 +8,16 @@ 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-13 re-sync (upstream `1ad010463a6bce6034cded3e078b14db482882a8`) + +Every entry below was re-carried against the new pin; none was dropped +as upstream-absorbed — upstream `runtime.ts` at the new pin still +carries neither `AuditStore.loadErrors` nor `data.stopReason`. The +pinned range reworks the sidecar placement surface +(`sidecar-placement.ts` replaced by `sidecar-capabilities.ts`, plus +`signer-identity.ts`); the only divergence from the new pin is +`runtime.ts`. No entry's disposition changed. + ## 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.test.ts b/vendor/intx-workflow-host/workflow-definition-loader.test.ts new file mode 100644 index 000000000..b0973ad5f --- /dev/null +++ b/vendor/intx-workflow-host/workflow-definition-loader.test.ts @@ -0,0 +1,485 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + loadWorkflowActionHandlersFromClosure, + loadWorkflowDefinitionFromClosure, + loadWorkflowDirectorRegistryFromClosure, + loadWorkflowLoopFnsFromClosure, +} from "./workflow-definition-loader"; + +// The workflow package the fixture entry modules import +// `@intx/workflow/definition` from. A materialized closure lays this out +// under the package's `node_modules/`; the fixtures below symlink it so +// the entry's bare-specifier import resolves the same way. +const WORKFLOW_PACKAGE_DIR = path.resolve(import.meta.dir, "../../workflow"); +// The agent package a directors entry imports `defineDirector` from -- laid +// out the same way so the directors module's bare-specifier import resolves. +const AGENT_PACKAGE_DIR = path.resolve(import.meta.dir, "../../agent"); + +const createdDirs: string[] = []; + +afterEach(async () => { + while (createdDirs.length > 0) { + const dir = createdDirs.pop(); + if (dir === undefined) continue; + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +interface ClosureFixtureSpec { + /** Value written to `interchange.workflow`; omitted when `null`. */ + readonly workflowEntry: string | null; + /** Relative path the entry source is written to under the package. */ + readonly entryRelPath?: string; + /** Source of the entry module. */ + readonly entrySource?: string; + /** Value written to `interchange.directors`; omitted when absent. */ + readonly directorsEntry?: string; + /** Source of the directors module, written to `directorsEntry`'s path. */ + readonly directorsSource?: string; + /** Value written to `interchange.loops`; omitted when absent. */ + readonly loopsEntry?: string; + /** Source of the loops module, written to `loopsEntry`'s path. */ + readonly loopsSource?: string; + /** Value written to `interchange.actions`; omitted when absent. */ + readonly actionsEntry?: string; + /** Source of the actions module, written to `actionsEntry`'s path. */ + readonly actionsSource?: string; +} + +async function createClosureFixture(spec: ClosureFixtureSpec): Promise { + const packageDir = await fs.mkdtemp(path.join(os.tmpdir(), "wf-closure-")); + createdDirs.push(packageDir); + + // Lay out `node_modules/@intx/workflow` the way the closure + // machinery would, so the entry module's `@intx/workflow/definition` + // import resolves. + const scopeDir = path.join(packageDir, "node_modules", "@intx"); + await fs.mkdir(scopeDir, { recursive: true }); + await fs.symlink( + WORKFLOW_PACKAGE_DIR, + path.join(scopeDir, "workflow"), + "dir", + ); + await fs.symlink(AGENT_PACKAGE_DIR, path.join(scopeDir, "agent"), "dir"); + + const interchange: Record = {}; + if (spec.workflowEntry !== null) { + interchange.workflow = spec.workflowEntry; + } + if (spec.directorsEntry !== undefined) { + interchange.directors = spec.directorsEntry; + } + if (spec.loopsEntry !== undefined) { + interchange.loops = spec.loopsEntry; + } + if (spec.actionsEntry !== undefined) { + interchange.actions = spec.actionsEntry; + } + const pkgJson: Record = { + name: "@fixture/workflow-package", + version: "1.0.0", + }; + if (Object.keys(interchange).length > 0) { + pkgJson.interchange = interchange; + } + await fs.writeFile( + path.join(packageDir, "package.json"), + JSON.stringify(pkgJson, null, 2), + ); + + if (spec.entrySource !== undefined) { + const entryRelPath = spec.entryRelPath ?? "workflow.js"; + const entryAbs = path.join(packageDir, entryRelPath); + await fs.mkdir(path.dirname(entryAbs), { recursive: true }); + await fs.writeFile(entryAbs, spec.entrySource); + } + if (spec.directorsSource !== undefined) { + const rel = spec.directorsEntry ?? "./directors.js"; + const abs = path.join(packageDir, rel); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, spec.directorsSource); + } + if (spec.loopsSource !== undefined) { + const rel = spec.loopsEntry ?? "./loops.js"; + const abs = path.join(packageDir, rel); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, spec.loopsSource); + } + if (spec.actionsSource !== undefined) { + const rel = spec.actionsEntry ?? "./actions.js"; + const abs = path.join(packageDir, rel); + await fs.mkdir(path.dirname(abs), { recursive: true }); + await fs.writeFile(abs, spec.actionsSource); + } + return packageDir; +} + +// An actions module exporting a handler plus a non-function export, for the +// resolve/fail-closed tests. +const ACTIONS_SOURCE = ` +export async function echo(input, _ctx, _signal) { + return input; +} +export const notAFunction = 7; +`; + +// A loops module exporting two pure fns plus a non-function export, for the +// resolve/fail-closed tests. +const LOOPS_SOURCE = ` +export function keepGoing(_childOutput, currentInput) { + return (typeof currentInput === "number" ? currentInput : 0) < 2; +} +export function nextCount(_childOutput, currentInput) { + return (typeof currentInput === "number" ? currentInput : 0) + 1; +} +export const notAFunction = 42; +`; + +const DEFAULT_EXPORT_ENTRY = ` +import { defineWorkflow } from "@intx/workflow/definition"; +export default defineWorkflow({ + id: "fixture-workflow", + steps: { + wait: { kind: "sleep", id: "", durationMs: 5 }, + }, +}); +`; + +describe("loadWorkflowDefinitionFromClosure", () => { + test("loads a fixture workflow package entry to a validated definition", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: "./workflow.js", + entrySource: DEFAULT_EXPORT_ENTRY, + }); + + const definition = await loadWorkflowDefinitionFromClosure({ packageDir }); + + expect(definition.id).toBe("fixture-workflow"); + expect(definition.stepOrder).toEqual(["wait"]); + expect(Array.isArray(definition.triggers)).toBe(true); + // A workflow with no declared trigger normalizes to a manual trigger. + expect(definition.triggers).toEqual([{ type: "manual" }]); + expect(definition.steps.wait?.kind).toBe("sleep"); + }); + + test("accepts a named export of the definition", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: "./nested/entry.js", + entryRelPath: "nested/entry.js", + entrySource: ` +import { defineWorkflow } from "@intx/workflow/definition"; +export const workflow = defineWorkflow({ + id: "named-export-workflow", + steps: { only: { kind: "sleep", id: "", durationMs: 1 } }, +}); +`, + }); + + const definition = await loadWorkflowDefinitionFromClosure({ packageDir }); + + expect(definition.id).toBe("named-export-workflow"); + }); + + test("busts the ESM module cache with importCacheKey", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: "./workflow.js", + entrySource: DEFAULT_EXPORT_ENTRY, + }); + + const definition = await loadWorkflowDefinitionFromClosure({ + packageDir, + importCacheKey: "sha512-fixture", + }); + + expect(definition.id).toBe("fixture-workflow"); + }); + + test("rejects a package.json without an interchange.workflow field", async () => { + const packageDir = await createClosureFixture({ workflowEntry: null }); + + await expect( + loadWorkflowDefinitionFromClosure({ packageDir }), + ).rejects.toThrow(/no "interchange\.workflow" field/); + }); + + test("rejects an entry path that escapes the package directory", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: "../escape.js", + }); + + await expect( + loadWorkflowDefinitionFromClosure({ packageDir }), + ).rejects.toThrow(/escapes the workflow package directory/); + }); + + test("rejects an absolute entry path at the string boundary", async () => { + // An absolute entry must be rejected by the shared string-level + // containment check before any realpath resolution -- the same rejection + // the push-time asset validator makes -- so the two boundaries agree. + const packageDir = await createClosureFixture({ + workflowEntry: "/pkg/index.js", + }); + + await expect( + loadWorkflowDefinitionFromClosure({ packageDir }), + ).rejects.toThrow(/escapes the workflow package directory/); + }); + + test("rejects an entry that exports no WorkflowDefinition", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: "./workflow.js", + entrySource: `export const notADefinition = { hello: "world" };`, + }); + + await expect( + loadWorkflowDefinitionFromClosure({ packageDir }), + ).rejects.toThrow( + /exported no value that validates as a WorkflowDefinition/, + ); + }); + + test("rejects an entry that exports more than one WorkflowDefinition", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: "./workflow.js", + entrySource: ` +import { defineWorkflow } from "@intx/workflow/definition"; +export const first = defineWorkflow({ + id: "first", + steps: { a: { kind: "sleep", id: "", durationMs: 1 } }, +}); +export const second = defineWorkflow({ + id: "second", + steps: { b: { kind: "sleep", id: "", durationMs: 1 } }, +}); +`, + }); + + await expect( + loadWorkflowDefinitionFromClosure({ packageDir }), + ).rejects.toThrow(/exported 2 WorkflowDefinition values/); + }); + + test("surfaces an import failure from the entry module", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: "./missing.js", + }); + + await expect( + loadWorkflowDefinitionFromClosure({ packageDir }), + ).rejects.toThrow(/could not be resolved|failed to import/); + }); +}); + +// A directors entry module exports the AnnotatedDirectorFactory (the +// defineDirector(...).factory), which is what the loader's structural check +// accepts and the registry stores -- not the { factory, build } wrapper. +const CUSTOM_DIRECTOR_ENTRY = ` +import { defineDirector } from "@intx/agent"; +export const custom = defineDirector({ + id: "@fixture/pkg/custom-director", + configSchema: (config) => config, + factory: () => ({ + async decide() { + return { type: "wait" }; + }, + }), +}).factory; +`; + +describe("loadWorkflowDirectorRegistryFromClosure", () => { + test("composes the built-in default when the package ships no directors", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: "./workflow.js", + entrySource: DEFAULT_EXPORT_ENTRY, + }); + + const registry = await loadWorkflowDirectorRegistryFromClosure({ + packageDir, + }); + + // The built-in default resolves; a custom id the package did not ship + // does not. + expect(() => registry.resolve(registry.buildDefaultRef())).not.toThrow(); + expect(() => + registry.resolve({ id: "@fixture/pkg/custom-director", config: {} }), + ).toThrow(); + }); + + test("resolves a custom director the closure package ships", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: "./workflow.js", + entrySource: DEFAULT_EXPORT_ENTRY, + directorsEntry: "./directors.js", + directorsSource: CUSTOM_DIRECTOR_ENTRY, + }); + + const registry = await loadWorkflowDirectorRegistryFromClosure({ + packageDir, + }); + + const factory = registry.resolve({ + id: "@fixture/pkg/custom-director", + config: {}, + }); + expect(factory.id).toBe("@fixture/pkg/custom-director"); + // The built-in default still resolves alongside the custom director. + expect(() => registry.resolve(registry.buildDefaultRef())).not.toThrow(); + }); + + test("throws when the directors module exports no director factory", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: "./workflow.js", + entrySource: DEFAULT_EXPORT_ENTRY, + directorsEntry: "./directors.js", + directorsSource: `export const notADirector = { hello: "world" };`, + }); + + await expect( + loadWorkflowDirectorRegistryFromClosure({ packageDir }), + ).rejects.toThrow(/exported no AnnotatedDirectorFactory values/); + }); + + test("rejects a directors entry path that escapes the package", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: "./workflow.js", + entrySource: DEFAULT_EXPORT_ENTRY, + directorsEntry: "../escape-directors.js", + }); + + await expect( + loadWorkflowDirectorRegistryFromClosure({ packageDir }), + ).rejects.toThrow(/escapes the workflow package directory/); + }); +}); + +describe("loadWorkflowLoopFnsFromClosure", () => { + test("resolves a loop fn by export name", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: null, + loopsEntry: "./loops.js", + loopsSource: LOOPS_SOURCE, + }); + + const registry = await loadWorkflowLoopFnsFromClosure({ packageDir }); + expect(registry("keepGoing")(null, 1)).toBe(true); + expect(registry("keepGoing")(null, 2)).toBe(false); + expect(registry("nextCount")(null, 4)).toBe(5); + }); + + test("returns an empty registry that throws when the package ships no loops module", async () => { + const packageDir = await createClosureFixture({ workflowEntry: null }); + + const registry = await loadWorkflowLoopFnsFromClosure({ packageDir }); + // No loop primitive would ever call this; a workflow that declares a loop + // fails closed here when its ref is resolved. + expect(() => registry("keepGoing")).toThrow( + /declares no interchange\.loops module/, + ); + }); + + test("throws when the loops module exports no fn by that name", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: null, + loopsEntry: "./loops.js", + loopsSource: LOOPS_SOURCE, + }); + + const registry = await loadWorkflowLoopFnsFromClosure({ packageDir }); + expect(() => registry("missing")).toThrow( + /exports no loop fn named "missing"/, + ); + }); + + test("throws when the named export is not a function", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: null, + loopsEntry: "./loops.js", + loopsSource: LOOPS_SOURCE, + }); + + const registry = await loadWorkflowLoopFnsFromClosure({ packageDir }); + expect(() => registry("notAFunction")).toThrow( + /exports no loop fn named "notAFunction"/, + ); + }); + + test("rejects a loops entry path that escapes the package", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: null, + loopsEntry: "../escape-loops.js", + }); + + await expect( + loadWorkflowLoopFnsFromClosure({ packageDir }), + ).rejects.toThrow(/escapes the workflow package directory/); + }); +}); + +describe("loadWorkflowActionHandlersFromClosure", () => { + const dummyCtx = { perform: async () => undefined }; + + test("resolves an action handler by export name", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: null, + actionsEntry: "./actions.js", + actionsSource: ACTIONS_SOURCE, + }); + + const resolve = await loadWorkflowActionHandlersFromClosure({ packageDir }); + const handler = resolve("echo"); + expect(typeof handler).toBe("function"); + expect(await handler("hi", dummyCtx, new AbortController().signal)).toBe( + "hi", + ); + }); + + test("returns a resolver that throws when the package ships no actions module", async () => { + const packageDir = await createClosureFixture({ workflowEntry: null }); + + const resolve = await loadWorkflowActionHandlersFromClosure({ packageDir }); + expect(() => resolve("echo")).toThrow( + /declares no interchange\.actions module/, + ); + }); + + test("throws when the actions module exports no handler by that name", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: null, + actionsEntry: "./actions.js", + actionsSource: ACTIONS_SOURCE, + }); + + const resolve = await loadWorkflowActionHandlersFromClosure({ packageDir }); + expect(() => resolve("missing")).toThrow( + /exports no action handler named "missing"/, + ); + }); + + test("throws when the named export is not a function", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: null, + actionsEntry: "./actions.js", + actionsSource: ACTIONS_SOURCE, + }); + + const resolve = await loadWorkflowActionHandlersFromClosure({ packageDir }); + expect(() => resolve("notAFunction")).toThrow( + /exports no action handler named "notAFunction"/, + ); + }); + + test("rejects an actions entry path that escapes the package", async () => { + const packageDir = await createClosureFixture({ + workflowEntry: null, + actionsEntry: "../escape-actions.js", + }); + + await expect( + loadWorkflowActionHandlersFromClosure({ packageDir }), + ).rejects.toThrow(/escapes the workflow package directory/); + }); +}); 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; +}