diff --git a/VENDORED.md b/VENDORED.md index 1a20711224..6d5e167a7c 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -24,38 +24,55 @@ never a convenience. | Vendored path | What was copied | Upstream repo @ commit | Why not a published package | Owner | Kill date | Kill-date test | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------- | ----------------- | -| `apps/sidecar` | Derived from upstream's own `apps/sidecar`: six modules byte-identical (`default-harness.ts`, `signing-keypair.ts`, `source-asset-delivery.ts`, `workflow-closure-apply.ts`, `workflow-probe-handler.ts`, `workflow-run-pack-restore.ts`), three near-verbatim (`atomic-write.ts` differs only by the repo-wide logger namespace), the rest (`index.ts`, `config.ts`, `tool-materialization.ts`, `step-agent-tools.ts`, `workflow-host-wiring/`, `workflow-substrate-factory/`, …) substantially rewritten, plus workbench-only modules. A living fork, not a frozen copy, so this row carries no tree hash. | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | An app is never npm-published, so no publish can cover the execution host; retired by consuming an upstream-published host, or by renewing this row deliberately | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/agent` | `@intx/agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 predates the operator-configurable doom-loop threshold (`afd0c82b`, `c421c092`) the re-vendored `workflow-host` configures; one local delta (CL-7190): `ToolBundle.beforeToolExtension` (`tool.ts`) and its composition into `ResolvedTools.beforeToolExtensions` (`agent.ts`), so a tool package can contribute its own suspend-capable extension without the reactor or director special-casing it by name; retired by the next `@intx/agent` publish | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/db` | `@intx/db` source (`src/`, `migrations/`, drizzle config, manifest, tsconfigs) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `c1b5bb73` (origin/main, 2026-09-06) | npm 0.3.0 covers the base package but not the `wire_projection` column/loader delta (CL-6324) or the `workflow_definition.origin` column separating a definition from the per-run record of one folded run's deploy (CL-6452), shipped as migrations `0089`/`0090` behind upstream's `0088`, plus `0091` rewriting the retired `onBodyFailure: "continue"` literal to upstream's `"tolerate"` in stored wire projections, plus `0092` adding `workflow_definition.schedule_claimed_minute` for the hub's native ScheduleTrigger poller (CL-4455); the pin also carries upstream's unpublished per-principal signing-key series (`principal_key` migration renumbered to `0093`, the principal/principal-key stores, and the backfill), which npm 0.3.0 lacks; and a CL-7505 serving-time refresh delta (`model-source-resolution.ts`: `buildSource`/`resolveModelSources`/`resolveInstanceModelSources` accept an optional `servingRefresh` hook, exposing `buildSource`, with a new `credential_needs_reauth` skip so an unrefreshable `oauth_token` credential is never served); one more local delta (CL-7510): `"openai-responses"` added to `schema/catalog.ts`'s `modelProvider.plugin` enum, in lockstep with the `@intx/types` delta, and an adapter registry-key dispatch delta in `model-source-resolution.ts` (`buildSource` rewrites both the launched `InferenceSource.provider` and the served credential material's `providerKey` to the provider's own registry key — `ollama`, `codex`, `xai-oauth` — while the catalog `plugin` column keeps the wire-format id, so the sidecar manifest's custom factories actually resolve); retired when upstream absorbs the deltas or publishes the signing-key series | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/harness` | `@intx/harness` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 predates the connector reply drain (`driveConnectorReplies`, `ConnectorReplyDrain`, `AgentEventStream`; `11590e66`) the sidecar's warm mail loop drives; no local delta; retired by the next `@intx/harness` publish | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/hub-agent` | `@intx/hub-agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 predates the `agentDir` path export (`927556de`) the sidecar's deploy-tree lookup uses, and its own `@intx/mail-memory`/`@intx/harness` pins must resolve the vendored copies; one local delta (CL-6396): `SessionEventSink` / `publishWorkflowInferenceEvent` stamp optional `childRunId` on `agent.event`; another (CL-7508): the `OAuthLoginExecutor` link option (`ws/hub-link.ts`) routing inbound `oauth.login.start` frames to the sidecar host's loopback login executor and answering every path with `oauth.login.result` frames (`started`, then one terminal arm), with a fail-closed placeholder executor so an unwired host still errors instead of hanging the hub, threaded through `sidecar-orchestrator.ts`'s config, plus cancellation: `oauth.login.cancel` frames and link disconnects close the staged login's pinned-port listener via the executor's `cancel` handle; the pin also carries the package's first colocated test (`ws/hub-link-oauth-login.test.ts` with a `bun test` script) pinning the cancel dispatch against a local ws server; retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/hub-api` | `@intx/hub-api` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `c1b5bb73` (origin/main, 2026-09-06) | npm 0.3.0 covers the base package but not the null-principal `resolveApproval` for policy-resolved decisions (CL-6345), the bearer-authenticated workflow-deploy mirror (`middleware/workflow-run-deploy-auth.ts`, CL-workflow-deploy-bearer), or the upstream per-principal signing-key seam this pin re-vendors (`principalKeyStore` threaded through the tenant/invite/run/workflow routes, the grant materializer, and `createApp`); and a CL-7505 serving-time refresh delta (`run-source-resolution.ts`: `resolveDefinitionSources` accepts and threads an optional `servingRefresh` hook); retired when upstream absorbs the deltas or publishes the signing-key series | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 covers the base package but not the pack-acceptance fixes (`ownsWorkflowRunRepo`, `anchorAddressForPackSource`, `decideTerminalRunFlip`), the wire-projection writer (CL-6324), malformed tool-call-name sanitization (CL-6478), the sealed-run terminal-status backfill (CL-6595), the CL-7190 `registerSignalCorrelation` approval-only guard (fails loud on a future `SignalKind` this RPC has no persistence for, rather than silently mis-persisting one), or the CL-6396 `childRunId` forward on `agent.event`; and a CL-7505 serving-time refresh delta (`credential-push.ts`: `pushInstanceSourceUpdate`/`pushSourceUpdates`/`pushSourceUpdatesSubtree` accept and thread an optional `servingRefresh` hook); and a CL-7508 loopback-login delta (`ws/sidecar-handler.ts`: the `oauth.login.result` frame classified as a bypass/terminal correlated response, the `pendingOAuthLogins` request map with a whole-login timeout that also sends `oauth.login.cancel` so the sidecar tears its pinned-port listener down, plus a disconnect sweep, the `requestOAuthLogin` router method gated behind a new `oauthLogin.isLocalSidecar` locality policy, and the typed gate outcome when no connected sidecar is local; review fix: the terminal `completed` arm resolves the pending login with its tokens instead of falling into the error arm); and a corbitsdev/workbench#709 prepared-deploy delta (`session-service.ts` threads `deployContent` + `toolPackagePins` through the prepared-deploy params and fans every top-level step out to `stageWorkflowStep` before the deployment frame, `workflow-allocation-service.ts` rehydrating both from the frozen launch spec with a fail-closed prompt check); retired when upstream absorbs the deltas | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/inference` | `@intx/inference` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 predates doom-loop detection (`8da4c827`, `afd0c82b`, `c421c092`); local deltas: `providers/google-genai-files.ts` builds its upload body as `new Uint8Array(bytes)` because TS 6's lib.dom `BodyInit` rejects `Uint8Array` (upstream compiles ESNext-only under TS 5.9); and CL-7190's `message_response` resume branch in `reactor.ts`'s `resumePendingOperation`/`timeoutMessageFor`, plus its `reactor.test.ts`/`testing/fakes.ts` regression harness (this package previously had zero tests); retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/mail-memory` | `@intx/mail-memory` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 predates the `@intx/mailbox` extraction (`af03bb90`), on-demand body reads (`54f7c239`) and `expunge` returning the swept uids (`bcabb1f8`) that the re-vendored `workflow-host` binds against; no local delta; retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/mailbox` | `@intx/mailbox` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | Never published: a new package at the target pin (`af03bb90`) that `workflow-host`'s substrate mailbox store and supervisor-backed transport import; no local delta; retired by its first publish | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/mime` | `@intx/mime` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 predates the non-RFC message-id guard `isMessageId` (`d97e1832`), the full `References` chain (`65c6fe70`) and the lossless `decodeMail` decoder (`3b6d06b2`) that `mailbox`/`mail-memory` at the same pin import; no local delta; retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/types` | `@intx/types` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `c1b5bb73` (origin/main, 2026-09-06) | npm 0.3.0 predates the type surface the re-vendored trees compile against: `expunge` returning `expungedUids` (`bcabb1f8`), plain-string `PackRejectReason` (`7b42f405`), the run authorization/approvals REST types (`71ad6c08`), the decoded-mail `Mail`/`MailPartReader` model (`3b6d06b2`) and the `interchange.actions`/`loops` package-json refs (`3bd5b837`, `1ea2f39b`); one local delta (CL-7190): `"message_response"` added to `signals.ts`'s `signalKinds`, alongside `signalKindToGateType`; another (CL-6396): optional `childRunId` on `AgentEventFrame`; the pin also carries the unpublished `signer-identity` contract and `principalKeyAad` domain separator from the signing-key series; one more local delta (CL-7510): `"openai-responses"` added to `catalog.ts`'s `modelProviderPlugins` so the workbench's loopback-OAuth providers (Codex, xai-oauth) can create model-provider rows; another (CL-7508): the `oauth.login.start` / `oauth.login.result` / `oauth.login.cancel` frame family (`OAuthLoginStartFrame`/`OAuthLoginResultFrame`/`OAuthLoginCancelFrame` plus the `OAuthLoginOutcome`/`OAuthLoginTokens` arms, in `sidecar.ts`) threaded through the sidecar ws channel for sidecar-hosted loopback logins, with a parse/reject test (`sidecar-oauth-login.test.ts`); retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/hub-common` | `@intx/hub-common` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `c1b5bb73` (origin/main, 2026-09-06) | npm 0.3.0 predates `generateId`'s `pky_` principal-key prefix from the signing-key series, which the re-vendored `db`/`hub-api` import; no local delta; retired by the next `@intx/hub-common` publish | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/workflow` | `@intx/workflow` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | One local delta (corbitsdev/workbench#709): `run.ts` treats the per-step timeout as an execution budget — trigger-idle input parks disarm the step timer across the wait and re-arm a fresh budget on resume, while the approval park stays armed; npm 0.3.0 predates the `onBodyFailure: "tolerate"` section policy (`b977ade6`) that `@corbits/agent-runtime` authors and the action/loop primitives (`3bd5b837`, `1ea2f39b`) the re-vendored `workflow-host` runs; retired by the next `@intx/workflow` publish | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/workflow-deploy` | `@intx/workflow-deploy` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | No local delta: npm 0.3.0 predates `inertLoopBody` and the loop-body source pin (`1ea2f39b`) that the re-vendored `hub-sessions` imports; retired by the next `@intx/workflow-deploy` publish | sawyer | 2026-11-03 | `check:killdates` | -| `vendor/intx/workflow-host` | `@intx/workflow-host` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `692c3106` (origin/main, 2026-09-03) | npm 0.3.0 covers the base package but not the body-spawn authorize/mail-part-reader threading and grants head-collapse (CL-6448) -- the credential thread is retired at this pin, upstream now passing the run's live credential-material cell into every body spawn natively -- that let the fork run tool-bearing onTrigger bodies, or the CL-6396 event-channel `childRunId` stamp so overlapping turns HMAC-bind the occurrence they belong to; retired when upstream absorbs the deltas | sawyer | 2026-11-03 | `check:killdates` | +| `apps/sidecar` | Derived from upstream's own `apps/sidecar`: six modules byte-identical (`default-harness.ts`, `signing-keypair.ts`, `source-asset-delivery.ts`, `workflow-closure-apply.ts`, `workflow-probe-handler.ts`, `workflow-run-pack-restore.ts`), three near-verbatim (`atomic-write.ts` differs only by the repo-wide logger namespace), the rest (`index.ts`, `config.ts`, `tool-materialization.ts`, `step-agent-tools.ts`, `workflow-host-wiring/`, `workflow-substrate-factory/`, …) substantially rewritten, plus workbench-only modules. A living fork, not a frozen copy, so this row carries no tree hash. | [faremeter/interchange](https://github.com/faremeter/interchange) @ `79adc433` (origin/main, 2026-09-17) | An app is never npm-published, so no publish can cover the execution host; retired by consuming an upstream-published host, or by renewing this row deliberately | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/agent` | `@intx/agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `79adc433` (origin/main, 2026-09-17) | npm 0.3.0 predates the operator-configurable doom-loop threshold (`afd0c82b`, `c421c092`) the re-vendored `workflow-host` configures; the CL-7190 `beforeToolExtension` delta is absorbed upstream at this pin — no local delta; retired by the next `@intx/agent` publish | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/db` | `@intx/db` source (`src/`, `migrations/`, drizzle config, manifest, tsconfigs) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `79adc433` (origin/main, 2026-09-17) | npm 0.3.0 predates this pin's schema; the CL-6324 `wire_projection` column/loader and the CL-6452 `workflow_definition.origin` column are GONE upstream at this pin (CL-8206) — every hub-side reader of either is a hard cutover in this change, not a port — and the unpublished per-principal signing-key series (`principal_key`) this pin previously re-vendored is now upstream-native, so that delta is dropped; the pin keeps a CL-7505 serving-time refresh delta (`model-source-resolution.ts`: `buildSource`/`resolveModelSources`/`resolveInstanceModelSources` accept an optional `servingRefresh` hook, with a `credential_needs_reauth` skip so an unrefreshable `oauth_token` credential is never served) and a CL-7510 delta (`"openai-responses"` in `schema/catalog.ts`'s `modelProvider.plugin` enum, and an adapter registry-key dispatch rewrite in `model-source-resolution.ts`'s `buildSource` so `ollama`/`codex`/`xai-oauth` resolve their own registry key while the catalog `plugin` column keeps the wire-format id); retired when upstream absorbs the deltas | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/harness` | `@intx/harness` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `79adc433` (origin/main, 2026-09-17) | npm 0.3.0 predates the connector reply drain (`driveConnectorReplies`, `ConnectorReplyDrain`, `AgentEventStream`; `11590e66`) the sidecar's warm mail loop drives; no local delta; retired by the next `@intx/harness` publish | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/hub-agent` | `@intx/hub-agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `79adc433` (origin/main, 2026-09-17) | npm 0.3.0 predates the `agentDir` path export (`927556de`) the sidecar's deploy-tree lookup uses, and its own `@intx/mail-memory`/`@intx/harness` pins must resolve the vendored copies; one local delta (CL-6396): `SessionEventSink` / `publishWorkflowInferenceEvent` stamp optional `childRunId` on `agent.event`; another (CL-7508): the `OAuthLoginExecutor` link option (`ws/hub-link.ts`) routing inbound `oauth.login.start` frames to the sidecar host's loopback login executor and answering every path with `oauth.login.result` frames (`started`, then one terminal arm), with a fail-closed placeholder executor so an unwired host still errors instead of hanging the hub, threaded through `sidecar-orchestrator.ts`'s config, plus cancellation: `oauth.login.cancel` frames and link disconnects close the staged login's pinned-port listener via the executor's `cancel` handle; the pin also carries the package's first colocated test (`ws/hub-link-oauth-login.test.ts` with a `bun test` script) pinning the cancel dispatch against a local ws server; retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/hub-api` | `@intx/hub-api` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `79adc433` (origin/main, 2026-09-17) | npm 0.3.0 predates this pin; the null-principal `resolveApproval` (CL-6345) and the bearer-authenticated workflow-deploy mirror (CL-workflow-deploy-bearer) are still local, but the per-principal signing-key seam (`principalKeyStore`) this pin previously re-vendored is now upstream-native and dropped; keeps the CL-7505 serving-time refresh delta (`run-source-resolution.ts`: `resolveDefinitionSources` accepts and threads an optional `servingRefresh` hook); retired when upstream absorbs the remaining deltas | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `79adc433` (origin/main, 2026-09-17) | npm 0.3.0 covers the base package but not the pack-acceptance fixes (`ownsWorkflowRunRepo`, `anchorAddressForPackSource`, `decideTerminalRunFlip`), malformed tool-call-name sanitization (CL-6478), or the sealed-run terminal-status backfill (CL-6595); the CL-6324 wire-projection writer, the CL-7190 `registerSignalCorrelation` guard, and the CL-6396 `childRunId` forward are absorbed upstream at this pin (`SessionService.sendUserMessage` is gone entirely — every caller now delivers through `SidecarRouter.routeMail`) and dropped; keeps a CL-7505 serving-time refresh delta (`credential-push.ts`: `pushInstanceSourceUpdate`/`pushSourceUpdates`/`pushSourceUpdatesSubtree` accept and thread an optional `servingRefresh` hook); a CL-7508 loopback-login delta (`ws/sidecar-handler.ts`: the `oauth.login.result` frame classified as a bypass/terminal correlated response, the `pendingOAuthLogins` request map with a whole-login timeout that also sends `oauth.login.cancel` so the sidecar tears its pinned-port listener down, plus a disconnect sweep, the `requestOAuthLogin` router method gated behind a new `oauthLogin.isLocalSidecar` locality policy, and the typed gate outcome when no connected sidecar is local; review fix: the terminal `completed` arm resolves the pending login with its tokens instead of falling into the error arm); and a corbitsdev/workbench#709 prepared-deploy delta (`session-service.ts` threads `deployContent` + `toolPackagePins` through the prepared-deploy params and fans every top-level step out to `stageWorkflowStep` before the deployment frame, `workflow-allocation-service.ts` rehydrating both from the frozen launch spec with a fail-closed prompt check); retired when upstream absorbs the remaining deltas | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/inference` | `@intx/inference` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `79adc433` (origin/main, 2026-09-17) | npm 0.3.0 predates doom-loop detection (`8da4c827`, `afd0c82b`, `c421c092`); local delta: `providers/google-genai-files.ts` builds its upload body as `new Uint8Array(bytes)` because TS 6's lib.dom `BodyInit` rejects `Uint8Array` (upstream compiles ESNext-only under TS 5.9); the CL-7190 `message_response` resume-branch delta is absorbed upstream at this pin and dropped; retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/mail-memory` | `@intx/mail-memory` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `79adc433` (origin/main, 2026-09-17) | npm 0.3.0 predates the `@intx/mailbox` extraction (`af03bb90`), on-demand body reads (`54f7c239`) and `expunge` returning the swept uids (`bcabb1f8`) that the re-vendored `workflow-host` binds against; no local delta; retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/mailbox` | `@intx/mailbox` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `79adc433` (origin/main, 2026-09-17) | Never published: a new package at the target pin (`af03bb90`) that `workflow-host`'s substrate mailbox store and supervisor-backed transport import; no local delta; retired by its first publish | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/mime` | `@intx/mime` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `79adc433` (origin/main, 2026-09-17) | npm 0.3.0 predates the non-RFC message-id guard `isMessageId` (`d97e1832`), the full `References` chain (`65c6fe70`) and the lossless `decodeMail` decoder (`3b6d06b2`) that `mailbox`/`mail-memory` at the same pin import; no local delta; retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/types` | `@intx/types` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `79adc433` (origin/main, 2026-09-17) | npm 0.3.0 predates the type surface the re-vendored trees compile against; the CL-7190 `"message_response"` signal-kind delta and the CL-6396 `childRunId` delta are absorbed upstream at this pin and dropped, as is the unpublished `signer-identity`/`principalKeyAad` signing-key surface (now upstream-native); keeps one local delta (CL-7510): `"openai-responses"` added to `catalog.ts`'s `modelProviderPlugins`; retired by the next publish | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/hub-common` | `@intx/hub-common` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `79adc433` (origin/main, 2026-09-17) | npm 0.3.0 predates `generateId`'s `pky_` principal-key prefix, now upstream-native at this pin; no local delta; retired by the next `@intx/hub-common` publish | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/workflow` | `@intx/workflow` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `79adc433` (origin/main, 2026-09-17) | One local delta (corbitsdev/workbench#709): `run.ts` treats the per-step timeout as an execution budget — trigger-idle input parks disarm the step timer across the wait and re-arm a fresh budget on resume, while the approval park stays armed; npm 0.3.0 predates the `onBodyFailure: "tolerate"` section policy (`b977ade6`) that `@corbits/agent-runtime` authors and the action/loop primitives (`3bd5b837`, `1ea2f39b`) the re-vendored `workflow-host` runs; retired by the next `@intx/workflow` publish | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/workflow-deploy` | `@intx/workflow-deploy` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `79adc433` (origin/main, 2026-09-17) | No local delta: npm 0.3.0 predates `inertLoopBody` and the loop-body source pin (`1ea2f39b`) that the re-vendored `hub-sessions` imports; retired by the next `@intx/workflow-deploy` publish | sawyer | 2026-11-03 | `check:killdates` | +| `vendor/intx/workflow-host` | `@intx/workflow-host` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `79adc433` (origin/main, 2026-09-17) | npm 0.3.0 covers the base package but not this pin; the CL-6396 event-channel `childRunId` stamp is absorbed upstream and dropped; keeps one local delta (CL-6448): `run-child.ts`'s `createCredentialsBackedAuthorize` re-applies `findStepGrantsEntry`, which upstream at this pin does not have — for a single-step deployment, an `onTrigger` body step's own id (keyed by the PARENT step's stepOrder in the credentials snapshot) collapses to the deployment's sole grants entry instead of throwing, mirroring the head/step collapse the body's tool materialization already uses; a multi-step snapshot stays strict; retired when upstream absorbs it | sawyer | 2026-11-03 | `check:killdates` | -The pin is `692c3106` (upstream `origin/main`, 2026-09-03), 90 commits past -the `v0.3.0` release tag `b5580a02`. npm is still `0.3.0`, so every tree a +The pin is `79adc433` (upstream `origin/main`, 2026-09-17), re-pinned from +`692c3106`/`c1b5bb73` (CL-8206). npm is still `0.3.0`, so every tree a re-pinned tree imports at a newer API is vendored too, at the same commit — a vendored tree never mixes pins: `agent`, `db`, `harness`, `hub-agent`, -`hub-api`, `hub-sessions`, `inference`, `mail-memory`, `mailbox`, `mime`, -`types`, `workflow`, `workflow-deploy`, and `workflow-host`, plus -`apps/sidecar`. The npm-consumed names whose source is byte-identical -between `v0.3.0` and `692c3106` stay on npm `0.3.0`: `authz`, `crypto`, -`hub-common`, `log`, `pack-transport`, `storage-isogit`, `tool-packaging`, +`hub-api`, `hub-common`, `hub-sessions`, `inference`, `mail-memory`, +`mailbox`, `mime`, `types`, `workflow`, `workflow-deploy`, and +`workflow-host`, plus `apps/sidecar`. The npm-consumed names whose source +is still byte-identical at this pin stay on npm `0.3.0`: `authz`, `crypto`, +`log`, `pack-transport`, `storage-isogit`, `tool-packaging`, `inference-catalog`. The root `package.json` `overrides` point every vendored name at `workspace:*` (so the published `@intx/harness`, `@intx/hub-agent`, `@intx/tool-packaging`, `@intx/authz`, … resolve their own `@intx/*` dependencies onto the vendored copies instead of a second npm copy) and every unchanged name at `0.3.0`. +CL-8206 re-vendored every `vendor/intx/*` tree at `79adc433` and dropped +every delta upstream has since absorbed: the per-principal signing-key +series (`vendor/intx/db`, `hub-api`, `hub-common`), CL-7190's +`beforeToolExtension` (`vendor/intx/agent`) and `"message_response"` signal +kind (`vendor/intx/types`, `vendor/intx/inference`), and CL-6396's +`childRunId` forward (`vendor/intx/hub-sessions`, `vendor/intx/types`). It +is also a hard cutover, not a port, for two capabilities upstream deleted +outright with no replacement: `@intx/db`'s frozen wire-projection storage +(the `workflow_definition.origin` column and +`workflow_definition_version.wire_projection`, CL-6324/CL-6452) and +`@intx/hub-sessions`' `SessionService.sendUserMessage` (every caller now +delivers through `SidecarRouter.routeMail`, mirroring +`apps/hub/src/native-workflow-routine-launch.ts`'s existing pattern). See +each row's own cell and the retired call sites in `packages/workflows`, +`packages/agent-directory`, `packages/webhook-triggers`, `packages/chat`, +and `apps/hub` for what was deleted rather than reimplemented. + Local modifications (all surviving `vendor/intx/*` rows): each package's exports map is repointed from the upstream `intx-src` resolve condition to direct TypeScript source resolution (`types`/`default` → `./src/...`), with diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 129c1a74a7..79eb53e7ea 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -174,12 +174,7 @@ import { createProviderHealthPort, createProviderHealthStore, } from "@corbits/connections/provider-health"; -import { - createWorkflowAuthorRegistry, - createWorkflowAuthorRoutes, - listDeployedCronDefinitions, - SCHEDULE_TICK_CONTENT, -} from "@corbits/workflows"; +import { createWorkflowAuthorRegistry, createWorkflowAuthorRoutes } from "@corbits/workflows"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { type Context, Hono, type Next } from "hono"; @@ -193,8 +188,6 @@ import { type SidecarProvisionerConfig, } from "./config"; import type { SidecarProvisioner } from "@intx/hub-sessions"; -import { triggerNativeWorkflowRoutineRun } from "./native-workflow-routine-launch"; -import { createCronEmitter } from "@corbits/workflow-schedule/emitter"; // Host policy constants, not configuration. const MAX_TARBALL_BYTES = 10 * 1024 * 1024; @@ -789,8 +782,8 @@ export async function createHub(config: HubConfig) { plugins: sidecarPlugins, router: sidecarRouter, hubWebSocketUrl, - onReady: async (allocation) => { - await workflowAllocationService.deployReadyAllocation(allocation); + onReady: async (allocation, reconciliation) => { + await workflowAllocationService.deployReadyAllocation(allocation, reconciliation); await workflowDispatchService.requeueForReadyAllocation(allocation.anchorRunId); }, }); @@ -1439,7 +1432,7 @@ export async function createHub(config: HubConfig) { launchWebhookTrigger( { db, - sessionService, + sidecarRouter, repoStore: agentRepoStore.repoStore, workflowAllocationService, credentialCipher, @@ -1645,41 +1638,14 @@ export async function createHub(config: HubConfig) { // Snooze is dropped (CL-8185): the unsnooze sweep and its `inbox.snooze` // table are gone — see `packages/inbox/src/migrations.ts`'s forward-drop. - // Recurring auto-fire for schedule-triggered deployments. The cron and - // the tick arithmetic belong to `@corbits/workflow-schedule`; this root - // only supplies the two host bindings the emitter cannot own — which - // deployments are live, and how a tick fires. CL-8160 deleted the - // hub-mounted run-now route (`triggerNativeWorkflowRoutineRun`'s only - // other caller); this cron fire is the sole remaining caller now. - const cronEmitter = createCronEmitter({ - listCronDeployments: async () => - (await listDeployedCronDefinitions(db)).map((definition) => ({ - ...definition, - deploymentId: definition.definitionId, - })), - fire: async (definition) => { - await triggerNativeWorkflowRoutineRun( - { db, sidecarRouter }, - { - tenantId: definition.tenantId, - definitionId: definition.definitionId, - principalId: definition.creatorPrincipalId, - fromDomain: definition.tenantDomain, - content: SCHEDULE_TICK_CONTENT, - }, - ); - }, - onError: (error, deployment) => { - reportError(error, { - operation: "workflow-schedule.cron-emitter", - extra: { deploymentId: deployment.deploymentId }, - }); - }, - clock: () => new Date(), - }); - void cronEmitter.rescan().catch((error: unknown) => { - reportError(error, { operation: "workflow-schedule.cron-emitter.boot" }); - }); + // CL-8206: the hub-computed cron auto-fire over a definition's frozen + // wire projection is gone — Interchange 79adc433 retired the + // projection storage `listDeployedCronDefinitions` read the cron + // trigger off of, with no replacement column. Per CL-8181's own + // direction (see the notify-to-reconnect comment above), a + // schedule-triggered routine is deployed like any other workflow now + // rather than driven by a hub-owned periodic loop; this hard cutover + // has no port to a stock equivalent. app.get("/*", createStaticHandler(path.resolve(config.hubStaticDir))); @@ -1715,7 +1681,6 @@ export async function createHub(config: HubConfig) { relaunchSweepSeries += 1; clearTimeout(relaunchSweepTimer); chatOrchestrator.dispose(); - cronEmitter.stop(); await closeMailbox(); // The pool end waits on in-flight queries; a query whose socket // died with the process must never stall shutdown, so bound it. diff --git a/apps/hub/src/native-workflow-routine-launch.test.ts b/apps/hub/src/native-workflow-routine-launch.test.ts index 41f4287184..df75812eea 100644 --- a/apps/hub/src/native-workflow-routine-launch.test.ts +++ b/apps/hub/src/native-workflow-routine-launch.test.ts @@ -66,8 +66,13 @@ describe("triggerNativeWorkflowRoutineRun", () => { { db: createFakeDb([LIVE_ANCHOR]) as never, sidecarRouter: { - routeMail: (address: string, base64: string, messageId: string) => { - routeMailCalls.push({ address, base64, messageId }); + routeMail: ( + address: string, + base64: string, + authenticatedSender: string, + messageId: string, + ) => { + routeMailCalls.push({ address, base64, authenticatedSender, messageId }); return true; }, } as never, @@ -80,8 +85,11 @@ describe("triggerNativeWorkflowRoutineRun", () => { address: "wfr_anchor1@acme.hub.test", }); expect(routeMailCalls).toHaveLength(1); - const [call] = routeMailCalls as [{ address: string; base64: string; messageId: string }]; + const [call] = routeMailCalls as [ + { address: string; base64: string; authenticatedSender: string; messageId: string }, + ]; expect(call.address).toBe("wfr_anchor1@acme.hub.test"); + expect(call.authenticatedSender).toBe("usr_1@acme.hub.test"); expect(typeof call.base64).toBe("string"); expect(call.base64.length).toBeGreaterThan(0); }); diff --git a/apps/hub/src/native-workflow-routine-launch.ts b/apps/hub/src/native-workflow-routine-launch.ts index c332372f1d..b4a2e13677 100644 --- a/apps/hub/src/native-workflow-routine-launch.ts +++ b/apps/hub/src/native-workflow-routine-launch.ts @@ -166,7 +166,7 @@ export async function triggerNativeWorkflowRoutineRun( const rawMessage = assembleMessage(headers, signedContent, signature); const base64 = base64Encode(rawMessage); - const delivered = deps.sidecarRouter.routeMail(address, base64, messageId); + const delivered = deps.sidecarRouter.routeMail(address, base64, headers.from, messageId); if (!delivered) { throw new Error( `native workflow deployment ${address} is not routable; cannot deliver routine's trigger mail`, diff --git a/apps/sidecar/src/atomic-write.ts b/apps/sidecar/src/atomic-write.ts index cb609c4a1b..f700c48c07 100644 --- a/apps/sidecar/src/atomic-write.ts +++ b/apps/sidecar/src/atomic-write.ts @@ -7,6 +7,7 @@ // exposing a torn record. import { open, rename, unlink } from "node:fs/promises"; +import { existsSync } from "node:fs"; import { dirname } from "node:path"; import { getLogger } from "@intx/log"; @@ -77,3 +78,28 @@ export async function writeFileAtomicDurable( logger.warn`parent-dir fsync failed for ${path}; durability is degraded but the file is renamed and fsynced — ${err instanceof Error ? err.message : String(err)}`; } } + +/** + * Durably remove `path`: unlink, then fsync the parent directory so the + * removal survives a power loss (an unlink with no directory fsync can be + * resurrected after a crash, re-staling a cache the removal was meant to + * invalidate). A missing file is a no-op, not an error -- the caller may be + * evicting an entry that never made it to disk. + */ +export async function removeFileAtomicDurable(path: string): Promise { + if (!existsSync(path)) return; + await unlink(path).catch(() => undefined); + try { + const dirHandle = await open(dirname(path), "r"); + try { + await dirHandle.sync(); + } finally { + await dirHandle.close(); + } + } catch (err) { + // report-error-ignore: mirrors writeFileAtomicDurable's own degraded-durability + // warn above — the file is already unlinked, so this is a best-effort fsync, + // not a failed operation, and the caller has nothing actionable to catch. + logger.warn`parent-dir fsync failed removing ${path}; durability is degraded but the file is unlinked — ${err instanceof Error ? err.message : String(err)}`; + } +} diff --git a/apps/sidecar/src/index.ts b/apps/sidecar/src/index.ts index 173155628b..ba1baa0f8e 100644 --- a/apps/sidecar/src/index.ts +++ b/apps/sidecar/src/index.ts @@ -19,15 +19,23 @@ import path from "node:path"; import { createEd25519Crypto, generateKeyPair, verifySSHSignature } from "@intx/crypto"; -import { createSidecarOrchestrator, type HubLink } from "@intx/hub-agent"; +import { + createSidecarOrchestrator, + createSenderKeyCache, + createSenderCryptoResolver, + createInboundMailPolicyRegistry, + createInboundMailPolicyLookup, + type HubLink, +} from "@intx/hub-agent"; import { createAgentRepoStore } from "@intx/hub-sessions"; import { loadAdapterRegistry } from "@intx/inference/providers"; import { getLogger, setup } from "@intx/log"; import { createInMemoryTransport } from "@intx/mail-memory"; import { createTarballCache } from "@intx/tool-packaging"; -import { hexEncode } from "@intx/types"; +import { hexDecode, hexEncode } from "@intx/types"; import { reportError } from "@corbits/error-sink"; +import { removeFileAtomicDurable, writeFileAtomicDurable } from "./atomic-write"; import { readSidecarConfig } from "./config"; import { DEFAULT_TOOL_REGISTRIES_JSON, parseToolRegistries } from "./tool-materialization"; import { createWorkflowProbeExecutor } from "./workflow-probe-handler"; @@ -205,6 +213,21 @@ const workflowProbeExecutor = createWorkflowProbeExecutor({ // PKCE login for codex/xai-oauth on this machine when the hub requests it. const oauthLoopbackLogin = createOAuthLoopbackLoginService(); +// Sender-key cache backing inbound-mail signature verification: durably +// persists the hub-vouched public key per sender address so a later inbound +// frame can be checked against a locally cached key, not only the key the +// hub stamped on the frame. The registry starts empty; a deployment +// registers its resolved inbound-mail policy when it hydrates and +// unregisters it on teardown, so an unregistered (or torn-down) address +// falls through to the fully-closed default via `createInboundMailPolicyLookup`. +const senderKeyCache = await createSenderKeyCache({ + dataDir: config.dataDir, + writeFileDurable: (writePath, contents) => + writeFileAtomicDurable(writePath, contents, { mode: 0o600 }), + removeFileDurable: (removePath) => removeFileAtomicDurable(removePath), +}); +const inboundMailPolicyRegistry = createInboundMailPolicyRegistry(); + const watchdogLog = getLogger(["sidecar", "hub-link-watchdog"]); const watchdog = createHubLinkWatchdog({ stallDeadlineMs: 60_000, @@ -242,6 +265,14 @@ const orchestrator = createSidecarOrchestrator({ applyWorkflowRunPack: restoreWorkflowRunPack, workflowProbeExecutor, oauthLoginExecutor: oauthLoopbackLogin.start, + resolveSenderCrypto: createSenderCryptoResolver(senderKeyCache), + lookupInboundMailPolicy: createInboundMailPolicyLookup(inboundMailPolicyRegistry), + cacheSenderKey: async (address, publicKey) => { + await senderKeyCache.put(address, hexDecode(publicKey)); + }, + evictSenderKey: async (address) => { + await senderKeyCache.evict(address); + }, // Called from every connection's open handler -- the watchdog's // aliveness signal -- and from the close path, which immediately // re-schedules a reconnect that re-arms the deadline. diff --git a/apps/sidecar/src/workflow-host-wiring/index.ts b/apps/sidecar/src/workflow-host-wiring/index.ts index 92b6abd49b..52a8d55d80 100644 --- a/apps/sidecar/src/workflow-host-wiring/index.ts +++ b/apps/sidecar/src/workflow-host-wiring/index.ts @@ -1112,7 +1112,7 @@ export function createSidecarDeployRouter(deps: { stepOrder, definitionHash, warmKeep, - onInferenceEvent: (event, childRunId) => { + onInferenceEvent: (event) => { // The event arrives HMAC-verified over the child's event channel. // Re-narrow it to the hub's `InferenceEvent` union; a parse // failure means upstream corruption, so drop it loudly rather @@ -1122,7 +1122,11 @@ export function createSidecarDeployRouter(deps: { logger.warn`dropping workflow inference event for ${spec.agentAddress}: ${validated.summary}`; return; } - publishInferenceEvent(spec.agentAddress, validated, spec.sessionId, childRunId); + // This hook fires for the workflow's own top-level step events, + // never a spawned body's -- a body's events carry their own + // childRunId through the separate RunSuspendableChild onEvent + // seam, not this per-deployment onInferenceEvent. + publishInferenceEvent(spec.agentAddress, validated, spec.sessionId); }, }; diff --git a/bun.lock b/bun.lock index ac9768f841..4e67f99599 100644 --- a/bun.lock +++ b/bun.lock @@ -1357,6 +1357,7 @@ "@intx/harness": "workspace:*", "@intx/log": "0.3.0", "@intx/mail-memory": "workspace:*", + "@intx/mailbox": "workspace:*", "@intx/pack-transport": "0.3.0", "@intx/storage-isogit": "0.3.0", "@intx/types": "workspace:*", diff --git a/packages/agent-directory/src/one-shot-prompt.test.ts b/packages/agent-directory/src/one-shot-prompt.test.ts index 497940261b..bc9ec6a354 100644 --- a/packages/agent-directory/src/one-shot-prompt.test.ts +++ b/packages/agent-directory/src/one-shot-prompt.test.ts @@ -18,24 +18,6 @@ function firstCall(calls: readonly T[]): T { return call; } -// The inert projection the deploy freeze persists onto the definition's -// version row — the launch body's only hub-side source under the -// `workflow.json` retirement. -const AGENT_WIRE_PROJECTION = { - id: "wfd_planner", - triggers: [], - stepOrder: ["agent"], - steps: { - agent: { - kind: "step", - agent: { - systemPrompt: "You are Myra.", - modelSources: [{ provider: "anthropic", model: "declared-default-model" }], - }, - }, - }, -}; - const DEFINITION_ROW = { id: "wfd_planner", tenantId: "tnt_1", @@ -45,22 +27,12 @@ const DEFINITION_ROW = { }; const TENANT_ROW = { id: "tnt_1", domain: "acme.example" }; -/** A `db` double covering both the row reads and the drizzle - * `select().from().where().limit()` chain `loadFrozenWireProjection` - * runs for the version row's stored projection. */ function fakeDb() { return { query: { workflowDefinition: { findFirst: async () => DEFINITION_ROW }, tenant: { findFirst: async () => TENANT_ROW }, }, - select: () => ({ - from: () => ({ - where: () => ({ - limit: async () => [{ wireProjection: AGENT_WIRE_PROJECTION }], - }), - }), - }), }; } @@ -142,7 +114,7 @@ function createBaseDeps() { db: fakeDb(), repoStore: { resolveRef: async () => "sha_test" }, workflowAllocationService: {}, - sessionService: {}, + sidecarRouter: {}, isRoutable: () => true, cryptoProviders: { async get() { diff --git a/packages/agent-directory/src/one-shot-prompt.ts b/packages/agent-directory/src/one-shot-prompt.ts index 4f90e4e65d..52baaa86fe 100644 --- a/packages/agent-directory/src/one-shot-prompt.ts +++ b/packages/agent-directory/src/one-shot-prompt.ts @@ -10,8 +10,6 @@ import { deliverWhenRoutable, endAgentSessionForRun, isAgentUnreachableError, - readDefinitionProjection, - readFoldedBody, recordAgentSessionAtProvision, WORKFLOW_SOURCE_ENTRY, type EventCollectorPort, @@ -19,15 +17,21 @@ import { import { listVisibleOfferings, type DB } from "@intx/db"; import { tenant as tenantTable, workflowDefinition } from "@intx/db/schema"; import { generateId } from "@intx/hub-common"; +import { + assembleMessage, + assembleSignedContent, + createDetachedSignatureFromProvider, + type MessageHeaders, +} from "@intx/mime"; +import { base64Encode } from "@intx/types"; import { DEFAULT_ASSET_REF, type RepoStore, - type SessionService, type SidecarEventEmitter, + type SidecarRouter, type WorkflowAllocationService, } from "@intx/hub-sessions"; import type { CryptoProvider } from "@intx/types/runtime"; -import type { FoldedBody } from "@intx/workflow-deploy"; export type CryptoProviderCache = { get(key: string): Promise; @@ -54,7 +58,7 @@ export type OneShotRunnerDeps = { WorkflowAllocationService, "prepareProvisionedDeployment" >; - readonly sessionService: Pick; + readonly sidecarRouter: Pick; readonly eventCollectors: EventCollectorPort; /** * Reads the hub's live sidecar routing table (the same source the @@ -76,13 +80,12 @@ export type OneShotRunnerDeps = { }; /** * Test seam only. Production never sets these; they default to - * Interchange `prepareProvisionedDeployment` and `sendUserMessage`. + * Interchange `prepareProvisionedDeployment` and `SidecarRouter.routeMail`. */ readonly provision?: (input: { readonly tenantId: string; readonly principalId: string; readonly definitionId: string; - readonly foldedBody: FoldedBody; readonly domain: string; }) => Promise; readonly sendMail?: (input: { @@ -146,7 +149,6 @@ async function provisionOnAsset( readonly tenantId: string; readonly principalId: string; readonly definitionAssetId: string; - readonly foldedBody: FoldedBody; readonly domain: string; }, ): Promise { @@ -184,9 +186,6 @@ async function provisionOnAsset( sourceOfferingIds, defaultSourceOfferingId, deployContent: { systemPrompt: "" }, - ...(input.foldedBody.toolPackagePins.length > 0 - ? { toolPackagePins: input.foldedBody.toolPackagePins } - : {}), }); await recordAgentSessionAtProvision({ db: deps.db, @@ -233,22 +232,17 @@ export async function runOneShotPrompt( throw new Error(`No tenant "${input.tenantId}"`); } - const projection = await readDefinitionProjection(deps.db, definitionRow); - const foldedBody = readFoldedBody(projection, definitionRow.grantRequirements); - const launched = await (deps.provision !== undefined ? deps.provision({ tenantId: input.tenantId, principalId: input.principalId, definitionId: input.definitionId, - foldedBody, domain: tenantRow.domain, }) : provisionOnAsset(deps, { tenantId: input.tenantId, principalId: input.principalId, definitionAssetId: definitionRow.assetId, - foldedBody, domain: tenantRow.domain, })); @@ -324,16 +318,46 @@ export async function runOneShotPrompt( cryptoProvider, }); } else { - await deps.sessionService.sendUserMessage({ - agentAddress: launched.address, - from: `${input.principalId}@${tenantRow.domain}`, - messageId: `<${crypto.randomUUID()}@${tenantRow.domain}>`, + const from = `${input.principalId}@${tenantRow.domain}`; + const messageId = `<${crypto.randomUUID()}@${tenantRow.domain}>`; + const headers: MessageHeaders = { + from, + to: [launched.address], + cc: undefined, date: new Date(), - content: input.prompt, - sessionId: launched.sessionId, - tenantId: input.tenantId, - cryptoProvider, + messageId, + subject: undefined, + inReplyTo: undefined, + references: undefined, + mimeVersion: "1.0", + interchangeType: "conversation.message", + interchangeCorrelationId: undefined, + interchangeTenantId: input.tenantId, + interchangeAgentId: undefined, + interchangeSessionId: launched.sessionId, + interchangeOfferingId: undefined, + interchangeSchemaVersion: undefined, + traceparent: undefined, + tracestate: undefined, + }; + const signedContent = assembleSignedContent({ + kind: "conversation", + text: input.prompt, }); + const signature = await createDetachedSignatureFromProvider( + signedContent, + cryptoProvider, + ); + const rawMessage = assembleMessage(headers, signedContent, signature); + const delivered = deps.sidecarRouter.routeMail( + launched.address, + base64Encode(rawMessage), + from, + messageId, + ); + if (!delivered) { + throw new Error(`agent is unreachable: ${launched.address} is not routable`); + } } }, isRoutable: () => deps.isRoutable(launched.address), diff --git a/packages/chat/src/platform-adapter.ts b/packages/chat/src/platform-adapter.ts index fac13578b7..a051cdebe2 100644 --- a/packages/chat/src/platform-adapter.ts +++ b/packages/chat/src/platform-adapter.ts @@ -10,15 +10,12 @@ import { reportError } from "@corbits/error-sink"; import { and, desc, eq, inArray } from "drizzle-orm"; import { - authoredDefinitionCandidates, - DefinitionProjectionMissingError, deliverWhenRoutable, + DefinitionProjectionMissingError, endAgentSessionForRun, ensureRunSession, isAgentUnreachableError, - readFoldedBody, recordAgentSessionAtProvision, - resolveNewestProjectedDefinition, WORKFLOW_SOURCE_ENTRY, } from "@corbits/workflows"; import { domainOf as addressDomainOf } from "./agent-address"; @@ -523,97 +520,24 @@ export function createHubChatPlatform(deps: CreateHubChatPlatformDeps): HubChatP return { scanned: dead.length, relaunched }; } - /** - * A JSON encoding with every object's keys sorted, recursively — - * order-independent, so two objects with the same key/value pairs - * built by different code paths (a fresh `readFoldedBody()` call vs. - * whatever `workbench_launch.foldedBody` already holds) always - * encode identically regardless of construction order. - */ - function canonicalJSON(value: unknown): string { - return JSON.stringify(value, function replacer(_key, val) { - if (val === null || typeof val !== "object" || Array.isArray(val)) { - return val; - } - const sorted: Record = {}; - for (const k of Object.keys(val as Record).sort()) { - sorted[k] = (val as Record)[k]; - } - return sorted; - }); - } - - /** - * Whether two folded bodies are the same deployable content. - * - * Deliberately NOT a wire-hash comparison. The first version of this - * check compared CL-6452's per-deploy clone's `wireHash` against the - * asset's current hub-authored `wireHash` — but that clone's hash is - * unique to the run BY DESIGN (`launch.ts`'s `markRunDeployClone`: - * "a folded run's deployed bytes carry per-run values (`wf_`, - * the run's trigger address), so their wire hash is unique to the - * run"). Comparing it to the authored hash therefore reads every - * live run as "drifted" on every single send, regardless of whether - * anything actually changed — the PR #298 regression that broke - * three chat e2e tests by relaunching a healthy run mid-flight. - * `foldedBody` itself carries no per-run values, so comparing its - * content directly is the correct, stable signal. - * - * A second, subtler regression on the way to this version: a raw - * `JSON.stringify(a) === JSON.stringify(b)` comparison is NOT the - * same as content equality — a freshly-built `readFoldedBody()` - * result and the object already stored on `workbench_launch` can - * hold identical key/value pairs in different insertion order (e.g. - * `model` last vs. first), which `JSON.stringify` renders as - * different strings. Confirmed live against the real echo-agent e2e - * fixture: `fresh`/`current` were byte-for-byte the same data, - * reordered, and every send relaunched a perfectly healthy run. - * `canonicalJSON` above sorts keys at every level before comparing, - * so construction order can never manufacture a false drift signal. - */ - function foldedBodyContentEquals(a: FoldedBody, b: FoldedBody): boolean { - return canonicalJSON(a) === canonicalJSON(b); - } - /** * Whether a routable run's deployed content has drifted from its * definition's current hub-authored projection, and the folded body * it should redeploy with if so. * - * CL-6588: a launch (and every wake/relaunch since) renders a run's - * `foldedBody` once and never re-reads the definition's asset again - * on its own — `refreshAgentInstanceFromDefinition` above is the - * existing, explicitly-triggered lever for that, fired only when a - * human saves an edit. This is the same recompute, fired automatically - * ahead of a send instead of waiting for someone to click refresh, so - * a definition that changed for a reason the room's occupants never - * caused (a platform code fix, a redeployed default agent package) - * still reaches an already-launched instance. + * CL-8206: Interchange 79adc433 retired the frozen wire-projection + * `resolveAuthoredProjectedDefinition` used to read a launch body + * back off a definition — see its own comment. Drift detection has + * no data source left, so this always reports "no drift" instead of + * silently doing something wrong; chat itself is retiring under + * CL-8175. */ async function resolveDriftedFoldedBody( - tenantId: string, - run: LiveAgent["run"], - currentFoldedBody: FoldedBody, - ) { - if (run.definitionId === null) return undefined; - const definitionRow = await deps.db.query.workflowDefinition.findFirst({ - where: and( - eq(workflowDefinition.id, run.definitionId), - eq(workflowDefinition.tenantId, tenantId), - ), - }); - if (definitionRow === undefined || definitionRow.assetId === null) { - return undefined; - } - const { row: authoredRow, projection } = await resolveAuthoredProjectedDefinition(tenantId, { - assetId: definitionRow.assetId, - name: definitionRow.name, - }); - const freshFoldedBody = readFoldedBody(projection, authoredRow.grantRequirements); - if (foldedBodyContentEquals(freshFoldedBody, currentFoldedBody)) { - return undefined; - } - return freshFoldedBody; + _tenantId: string, + _run: LiveAgent["run"], + _currentFoldedBody: FoldedBody, + ): Promise { + return undefined; } /** @@ -1042,41 +966,19 @@ export function createHubChatPlatform(deps: CreateHubChatPlatformDeps): HubChatP } } - // CL-6452: every run deploy ensures a same-named sibling definition - // over the agent's asset under its per-run wire hash — a frozen - // deploy record carrying whatever projection was current at that - // deploy. Launch bodies resolve only from the hub-authored row(s) of - // the asset, so a skill pin or instructions save (which refreezes - // the authored row in place) reaches every later launch instead of - // being shadowed by the newest clone. Raises the named - // `DefinitionProjectionMissingError` — mapped to a 4xx at the route - // boundary, never an unhandled 500 — when the asset has no authored - // definition or none of its authored rows carries a projection. + // CL-8206: Interchange 79adc433 retired the frozen wire-projection + // storage this used to read a launch body back off a definition's + // version row (`@corbits/workflows`' `readDefinitionProjection` and + // the `workflow_definition.origin`/`workflow_definition_version.wire_projection` + // columns are gone). Chat itself is retiring under CL-8175, so this is + // a hard cutover, not a reimplementation: every caller below now fails + // loud with a named error instead of silently launching a stale or + // empty body. async function resolveAuthoredProjectedDefinition( - tenantId: string, + _tenantId: string, definitionAsset: { assetId: string; name: string }, - ) { - const assetSiblingRows = await deps.db.query.workflowDefinition.findMany({ - where: and( - eq(workflowDefinition.tenantId, tenantId), - eq(workflowDefinition.assetId, definitionAsset.assetId), - eq(workflowDefinition.status, "deployed"), - ), - orderBy: desc(workflowDefinition.createdAt), - }); - const candidates = authoredDefinitionCandidates(assetSiblingRows); - if (candidates.length === 0) { - throw new DefinitionProjectionMissingError(definitionAsset.name); - } - const resolved = await resolveNewestProjectedDefinition(deps.db, candidates); - const row = candidates.find((candidate) => candidate.id === resolved.definitionId); - if (row === undefined) { - throw new Error( - `resolved definition "${resolved.definitionId}" is not among the ` + - `authored candidates for asset "${definitionAsset.assetId}"`, - ); - } - return { row, projection: resolved.projection }; + ): Promise { + throw new DefinitionProjectionMissingError(definitionAsset.name); } async function resolveDefinitionAssetId(definitionId: string): Promise { @@ -1124,15 +1026,10 @@ export function createHubChatPlatform(deps: CreateHubChatPlatformDeps): HubChatP return { instanceId: standing.stableId, address: standing.roomAddress }; } - const { row: resolvedDefinitionRow, projection } = await resolveAuthoredProjectedDefinition( - input.tenantId, - { - assetId: definitionAssetId, - name: definitionRow.name, - }, - ); - - const foldedBody = readFoldedBody(projection, resolvedDefinitionRow.grantRequirements); + const foldedBody: FoldedBody = await resolveAuthoredProjectedDefinition(input.tenantId, { + assetId: definitionAssetId, + name: definitionRow.name, + }); if (foldedBody.systemPrompt === "") { throw new Error( `Definition "${input.definitionId}" cannot be launched without ` + @@ -1189,12 +1086,10 @@ export function createHubChatPlatform(deps: CreateHubChatPlatformDeps): HubChatP ); const distanceOf = (row: { readonly tenantId: string }): number => distanceByTenantId.get(row.tenantId) ?? Number.MAX_SAFE_INTEGER; - // Only hub-authored definitions are invitable: the run-deploy - // clones sharing an agent's name are deploy records, and listing - // them would offer N stale copies of every agent that has run. - const candidates = authoredDefinitionCandidates(rows).filter( - (row) => !isWorkbenchHostDefinitionName(row.name), - ); + // CL-8206: Interchange 79adc433 dropped `workflow_definition.origin` + // along with it — every row this query returns is now the only kind + // there is, so the prior "hub-authored only" filter is a no-op. + const candidates = rows.filter((row) => !isWorkbenchHostDefinitionName(row.name)); const nearestByName = new Map(); for (const row of candidates) { const distance = distanceOf(row); @@ -1257,11 +1152,10 @@ export function createHubChatPlatform(deps: CreateHubChatPlatformDeps): HubChatP // repointed it to; the refresh recomputes from the hub-authored // sibling so the saved edit — not the clone's frozen snapshot — // is what the next wake replays. - const { row: authoredRow, projection } = await resolveAuthoredProjectedDefinition(tenantId, { + const foldedBody: FoldedBody = await resolveAuthoredProjectedDefinition(tenantId, { assetId: definitionRow.assetId, name: definitionRow.name, }); - const foldedBody = readFoldedBody(projection, authoredRow.grantRequirements); await deps.db .update(workbenchLaunch) diff --git a/packages/interaction-tools/package.json b/packages/interaction-tools/package.json index 9cec3114bb..2f4daef054 100644 --- a/packages/interaction-tools/package.json +++ b/packages/interaction-tools/package.json @@ -1,6 +1,6 @@ { "name": "@corbits/interaction-tools", - "version": "0.0.10", + "version": "0.0.11", "description": "The ask_user tool: an @intx/agent bundle that poses an interview question in-thread as a question block, then ends the turn — the user's answer arrives as the agent's next message, not this call's result", "license": "LGPL-2.1-or-later", "repository": { diff --git a/packages/interaction-tools/src/tool.test.ts b/packages/interaction-tools/src/tool.test.ts index 371d802932..a1752f5cc5 100644 --- a/packages/interaction-tools/src/tool.test.ts +++ b/packages/interaction-tools/src/tool.test.ts @@ -29,11 +29,6 @@ test("requires the sanctioned env keys", () => { expect(interactionTools.requires).toEqual(["hubChatUrl", "sidecarToken", "address"]); }); -test("interactionTools contributes no beforeToolExtension: ask_user never suspends", () => { - const bundle = interactionTools(testEnv()); - expect(bundle.beforeToolExtension).toBeUndefined(); -}); - test("ask_user posts a question block and ends the turn with the answer arriving as the next message", async () => { let posted = false; const fetchImpl = (async () => { diff --git a/packages/webhook-triggers/src/launch.ts b/packages/webhook-triggers/src/launch.ts index 36981a06b8..208611ff41 100644 --- a/packages/webhook-triggers/src/launch.ts +++ b/packages/webhook-triggers/src/launch.ts @@ -4,7 +4,7 @@ // the definition asset HEAD and asks Interchange to provision that // existing source; it does not mint the run row itself. // -// Opening mail is one `sessionService.sendUserMessage`. Launch is +// Opening mail is one signed `SidecarRouter.routeMail` delivery. Launch is // async: mail sent before ready is queued. A delivery already accepted // (202) has already committed a real run by the time this send runs, so // a failed mail must not throw past `createWebhookIngressRoutes` — that @@ -15,8 +15,6 @@ import { reportError } from "@corbits/error-sink"; import { deliverWhenRoutable, - readDefinitionProjection, - readFoldedBody, recordAgentSessionAtProvision, WORKFLOW_SOURCE_ENTRY, type EventCollectorPort, @@ -24,13 +22,19 @@ import { import { listVisibleOfferings, type DB } from "@intx/db"; import { tenant as tenantTable, workflowDefinition } from "@intx/db/schema"; import { generateId } from "@intx/hub-common"; +import { + assembleMessage, + assembleSignedContent, + createDetachedSignatureFromProvider, + type MessageHeaders, +} from "@intx/mime"; +import { base64Encode, type CredentialCipher } from "@intx/types"; import { DEFAULT_ASSET_REF, type RepoStore, - type SessionService, + type SidecarRouter, type WorkflowAllocationService, } from "@intx/hub-sessions"; -import type { CredentialCipher } from "@intx/types"; import type { CryptoProvider } from "@intx/types/runtime"; import { and, eq } from "drizzle-orm"; @@ -45,7 +49,7 @@ export type LaunchWebhookTriggerDeps = { db: DB["db"]; repoStore: Pick; workflowAllocationService: Pick; - sessionService: Pick; + sidecarRouter: Pick; /** * The same wrapped `EventCollectorRegistry` every native launcher * threads through (`apps/hub/src/index.ts`'s `eventCollectors`) — this @@ -120,15 +124,6 @@ export async function launchWebhookTrigger( throw new Error(`no tenant "${trigger.tenantId}"`); } - const projection = await readDefinitionProjection(deps.db, definitionRow); - const foldedBody = readFoldedBody(projection, definitionRow.grantRequirements); - if (foldedBody.systemPrompt === "") { - throw new Error( - `workflow definition "${trigger.workflowDefinitionId}" cannot be ` + - "launched without a system prompt configured", - ); - } - const offerings = [...(await listVisibleOfferings(deps.db, trigger.tenantId))].sort( (a, b) => a.offering.priority - b.offering.priority, ); @@ -166,9 +161,6 @@ export async function launchWebhookTrigger( sourceOfferingIds, defaultSourceOfferingId, deployContent: { systemPrompt: "" }, - ...(foldedBody.toolPackagePins.length > 0 - ? { toolPackagePins: foldedBody.toolPackagePins } - : {}), }); await recordAgentSessionAtProvision({ @@ -183,17 +175,42 @@ export async function launchWebhookTrigger( const cryptoProvider = await deps.cryptoProviderCache.get(prepared.anchorRunId); try { await deliverWhenRoutable({ - send: () => - deps.sessionService.sendUserMessage({ - agentAddress: prepared.deploymentAddress, - from: `webhook-trigger:${trigger.id}`, - messageId: `<${crypto.randomUUID()}@${tenantRow.domain}>`, + send: async () => { + const from = `webhook-trigger:${trigger.id}`; + const messageId = `<${crypto.randomUUID()}@${tenantRow.domain}>`; + const headers: MessageHeaders = { + from, + to: [prepared.deploymentAddress], + cc: undefined, date: new Date(), - content, - sessionId, - tenantId: trigger.tenantId, - cryptoProvider, - }), + messageId, + subject: undefined, + inReplyTo: undefined, + references: undefined, + mimeVersion: "1.0", + interchangeType: "conversation.message", + interchangeCorrelationId: undefined, + interchangeTenantId: trigger.tenantId, + interchangeAgentId: undefined, + interchangeSessionId: sessionId, + interchangeOfferingId: undefined, + interchangeSchemaVersion: undefined, + traceparent: undefined, + tracestate: undefined, + }; + const signedContent = assembleSignedContent({ kind: "conversation", text: content }); + const signature = await createDetachedSignatureFromProvider(signedContent, cryptoProvider); + const rawMessage = assembleMessage(headers, signedContent, signature); + const delivered = deps.sidecarRouter.routeMail( + prepared.deploymentAddress, + base64Encode(rawMessage), + from, + messageId, + ); + if (!delivered) { + throw new Error(`agent is unreachable: ${prepared.deploymentAddress} is not routable`); + } + }, isRoutable: () => deps.isRoutable(prepared.deploymentAddress), }); } catch (error) { diff --git a/packages/webhook-triggers/test/launch.test.ts b/packages/webhook-triggers/test/launch.test.ts index 56c3999886..519650e234 100644 --- a/packages/webhook-triggers/test/launch.test.ts +++ b/packages/webhook-triggers/test/launch.test.ts @@ -1,31 +1,16 @@ // Proves `launchWebhookTrigger` provisions through Interchange and // hardens the opening-mail send: a delivery already accepted has -// already committed a real run, so a failed `sendUserMessage` must not +// already committed a real run, so a failed `routeMail` must not // throw past this function (or `createWebhookIngressRoutes` would // reject an already-launched delivery, and a retried webhook client // would then mint a duplicate run for the same event). import { describe, expect, mock, test } from "bun:test"; import { WORKFLOW_SOURCE_ENTRY } from "@corbits/workflows"; import { DEFAULT_ASSET_REF } from "@intx/hub-sessions"; +import { base64Decode } from "@intx/types"; const actualDb = await import("@intx/db"); -const INERT_PROJECTION = { - id: "wfd_1", - stepOrder: ["host"], - steps: { - host: { - kind: "step", - agent: { - systemPrompt: "you are a webhook-triggered agent", - toolPackagePins: [], - modelSources: [{ provider: "anthropic", model: "claude-sonnet-5" }], - }, - }, - }, - credentialBindings: [], -}; - const DEFAULT_VISIBLE_OFFERINGS = [ { offering: { id: "off_1", priority: 0 }, @@ -40,12 +25,10 @@ const DEFAULT_VISIBLE_OFFERINGS = [ ]; let visibleOfferings: typeof DEFAULT_VISIBLE_OFFERINGS = [...DEFAULT_VISIBLE_OFFERINGS]; -let frozenProjection: unknown = INERT_PROJECTION; mock.module("@intx/db", () => ({ ...actualDb, listVisibleOfferings: async () => visibleOfferings, - loadFrozenWireProjection: async () => frozenProjection, })); let reportErrorCalls: unknown[] = []; @@ -144,10 +127,17 @@ type PrepareArgs = { readonly toolPackagePins?: readonly unknown[]; }; +type RouteMailCall = { + readonly address: string; + readonly base64: string; + readonly authenticatedSender: string; + readonly messageId: string; +}; + let resolveRefCalls: unknown[] = []; let prepareCalls: PrepareArgs[] = []; -let sendUserMessageCalls: unknown[] = []; -let sendUserMessageImpl: () => Promise = async () => new Uint8Array([1]); +let routeMailCalls: RouteMailCall[] = []; +let routeMailImpl: () => boolean = () => true; let cryptoGetKeys: string[] = []; let isRoutableForTest = true; @@ -158,7 +148,7 @@ function baseDeps() { cryptoProviderCache: { get: async (key: string) => { cryptoGetKeys.push(key); - return {} as never; + return { sign: async (input: Uint8Array) => input } as never; }, }, repoStore: { @@ -178,10 +168,15 @@ function baseDeps() { }; }, }, - sessionService: { - sendUserMessage: async (args: unknown) => { - sendUserMessageCalls.push(args); - return sendUserMessageImpl(); + sidecarRouter: { + routeMail: ( + address: string, + base64: string, + authenticatedSender: string, + messageId?: string, + ) => { + routeMailCalls.push({ address, base64, authenticatedSender, messageId: messageId ?? "" }); + return routeMailImpl(); }, }, isRoutable: () => isRoutableForTest, @@ -192,23 +187,23 @@ function baseDeps() { function resetLaunchSpies() { resolveRefCalls = []; prepareCalls = []; - sendUserMessageCalls = []; + routeMailCalls = []; cryptoGetKeys = []; reportErrorCalls = []; eventCollectorCreateCalls = []; visibleOfferings = [...DEFAULT_VISIBLE_OFFERINGS]; - frozenProjection = INERT_PROJECTION; - sendUserMessageImpl = async () => new Uint8Array([1]); + routeMailImpl = () => true; isRoutableForTest = true; } +function decodedContent(call: RouteMailCall): string { + return new TextDecoder().decode(base64Decode(call.base64)); +} + describe("launchWebhookTrigger", () => { test("still returns the Interchange run when input delivery fails after prepare", async () => { resetLaunchSpies(); - const deliveryError = new Error("sidecar unreachable"); - sendUserMessageImpl = async () => { - throw deliveryError; - }; + routeMailImpl = () => false; const result = await launchWebhookTrigger(baseDeps(), TRIGGER, { status: "ok", @@ -220,7 +215,7 @@ describe("launchWebhookTrigger", () => { }); expect(prepareCalls).toHaveLength(1); expect(resolveRefCalls).toHaveLength(1); - expect(sendUserMessageCalls).toHaveLength(1); + expect(routeMailCalls).toHaveLength(1); }); // CL-7476: a freshly provisioned run's sidecar takes several seconds to @@ -232,12 +227,9 @@ describe("launchWebhookTrigger", () => { resetLaunchSpies(); isRoutableForTest = false; let sendAttempts = 0; - sendUserMessageImpl = async () => { + routeMailImpl = () => { sendAttempts += 1; - if (sendAttempts === 1) { - throw new Error("agent is unreachable: ins_1@ten1.workbench.test"); - } - return new Uint8Array([1]); + return sendAttempts !== 1; }; const deps = baseDeps(); @@ -252,16 +244,13 @@ describe("launchWebhookTrigger", () => { instanceId: INTERCHANGE_RUN_ID, triggerAddress: INTERCHANGE_ADDRESS, }); - expect(sendUserMessageCalls).toHaveLength(2); + expect(routeMailCalls).toHaveLength(2); expect(reportErrorCalls).toHaveLength(0); }); test("reports the delivery failure with the run's context", async () => { resetLaunchSpies(); - const deliveryError = new Error("sidecar unreachable"); - sendUserMessageImpl = async () => { - throw deliveryError; - }; + routeMailImpl = () => false; const result = await launchWebhookTrigger(baseDeps(), TRIGGER, { status: "ok", @@ -277,7 +266,7 @@ describe("launchWebhookTrigger", () => { extra: Record; }, ]; - expect(cause).toBe(deliveryError); + expect(cause).toBeInstanceOf(Error); expect(context.operation).toBe("webhookTriggers.launch.deliverInput"); expect(context.tenantId).toBe(TRIGGER.tenantId); expect(context.agentId).toBe(result.triggerAddress); @@ -306,21 +295,18 @@ describe("launchWebhookTrigger", () => { instanceId: INTERCHANGE_RUN_ID, triggerAddress: INTERCHANGE_ADDRESS, }); - expect(sendUserMessageCalls).toHaveLength(1); - const params = sendUserMessageCalls[0] as { - content: string; - sessionId: string; - from: string; - agentAddress: string; - }; + expect(routeMailCalls).toHaveLength(1); + const call = routeMailCalls[0]; + if (call === undefined) { + throw new Error("expected routeMail to be called"); + } const preparedArgs = prepareCalls[0]; if (preparedArgs === undefined) { throw new Error("expected prepareProvisionedDeployment to be called"); } - expect(params.content).toBe("deployed: ok"); - expect(params.sessionId).toBe(preparedArgs.sessionId); - expect(params.from).toBe(`webhook-trigger:${TRIGGER.id}`); - expect(params.agentAddress).toBe(INTERCHANGE_ADDRESS); + expect(decodedContent(call)).toContain("deployed: ok"); + expect(call.authenticatedSender).toBe(`webhook-trigger:${TRIGGER.id}`); + expect(call.address).toBe(INTERCHANGE_ADDRESS); }); test("resolves the definition asset HEAD and prepares through Interchange", async () => { @@ -365,7 +351,7 @@ describe("launchWebhookTrigger", () => { expect(result.instanceId).toBe(INTERCHANGE_RUN_ID); expect(prepareCalls).toHaveLength(1); - expect(sendUserMessageCalls).toHaveLength(1); + expect(routeMailCalls).toHaveLength(1); expect(cryptoGetKeys).toEqual([INTERCHANGE_RUN_ID]); }); }); diff --git a/packages/workflows/src/definition-projection.test.ts b/packages/workflows/src/definition-projection.test.ts deleted file mode 100644 index a2edd9321e..0000000000 --- a/packages/workflows/src/definition-projection.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -// Proves the two launch-body readers and the projection resolver. -// -// `readFoldedBody` reads the INERT projection the deploy freeze -// persisted (`agent.modelSources`, no `grantRequirements` — the -// projector drops it, so the definition row supplies it), and fails -// loud on a malformed projection, a multi-step one, or a step that is -// not a step primitive. -// -// `resolveNewestProjectedDefinition` is the DB-side successor to -// CL-6357's asset-drift walk: a pre-cutover sibling carrying no stored -// projection must never win over a healthy newer one, and exhausting -// every candidate raises the named `DefinitionProjectionMissingError`. -import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; -import * as intxDb from "@intx/db"; -import { WorkflowProjectionDefinition } from "@intx/types/sidecar"; - -const projectionsById: Record = {}; -beforeEach(() => { - spyOn(intxDb, "loadFrozenWireProjection").mockImplementation(async (_db, definitionId) => { - const projection = projectionsById[definitionId]; - return projection === undefined ? null : WorkflowProjectionDefinition.assert(projection); - }); -}); -afterEach(() => mock.restore()); - -const { - authoredDefinitionCandidates, - readFoldedBody, - readDefinitionProjection, - resolveNewestProjectedDefinition, - DefinitionProjectionMissingError, - MultiStepFoldUnsupportedError, -} = await import("./definition-projection"); - -function inertProjection(overrides: Partial> = {}) { - return { - id: "wfd_1", - triggers: [], - stepOrder: ["host"], - steps: { - host: { - kind: "step", - agent: { - systemPrompt: "you are a workbench host", - toolPackagePins: [], - modelSources: [{ provider: "ollama", model: "qwen3:8b" }], - }, - }, - }, - credentialBindings: [], - ...overrides, - }; -} - -function sectionProjection(overrides: Partial> = {}) { - return { - id: "wfd_2", - stepOrder: ["turn"], - steps: { - turn: { - kind: "onTrigger", - id: "turn", - on: { type: "mail", to: "agent@example.com" }, - onBodyFailure: "tolerate", - body: { - inline: { - id: "wfd_2_body", - stepOrder: ["reply"], - steps: { - reply: { - kind: "step", - agent: { - systemPrompt: "you are the invited agent", - toolPackagePins: [], - modelSources: [{ provider: "ollama", model: "qwen3:8b" }], - }, - }, - }, - }, - }, - }, - }, - credentialBindings: [], - ...overrides, - }; -} - -function liveDefinition(overrides: Partial> = {}) { - return { - id: "wfd_live", - stepOrder: ["host"], - steps: { - host: { - kind: "step", - agent: { - systemPrompt: "you are a workbench host", - toolPackagePins: [], - inference: { sources: [{ model: "qwen3:8b" }] }, - }, - }, - }, - grantRequirements: [], - credentialBindings: [], - ...overrides, - }; -} - -describe("readFoldedBody", () => { - test("extracts the launch body from an inert projection plus the row's grant requirements", () => { - expect(readFoldedBody(inertProjection(), [])).toEqual({ - systemPrompt: "you are a workbench host", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - model: "qwen3:8b", - }); - }); - - test("takes grant requirements from the definition row, which the projection never carries", () => { - const requirement = { - resource: "tool:search", - action: "invoke", - source: "creator" as const, - }; - const body = readFoldedBody(inertProjection(), [requirement]); - expect(body.grantRequirements).toEqual([requirement]); - }); - - test("fails loud on a malformed projection", () => { - expect(() => readFoldedBody({ not: "a projection" }, [])).toThrow( - /inert projection is malformed/, - ); - }); - - test("fails loud on a multi-step projection", () => { - expect(() => readFoldedBody(inertProjection({ stepOrder: ["host", "second"] }), [])).toThrow( - /not single-step/, - ); - }); - - // The launch target (`@corbits/agent-runtime`'s - // `AgentRuntimeConfig`) renders exactly one `systemPrompt` into one - // mailbox-triggered turn -- it has no notion of step order, so a - // multi-step definition must raise the named, guidance-bearing error - // rather than a bare `Error` an HTTP boundary can't distinguish from - // any other bug (see `hubErrorHandler` in `apps/hub`). - test("raises the named MultiStepFoldUnsupportedError on a multi-step projection", () => { - try { - readFoldedBody(inertProjection({ stepOrder: ["host", "second"] }), []); - throw new Error("expected readFoldedBody to throw"); - } catch (err) { - expect(err).toBeInstanceOf(MultiStepFoldUnsupportedError); - const typed = err as InstanceType; - expect(typed.definitionId).toBe("wfd_1"); - expect(typed.stepCount).toBe(2); - expect(typed.guidance.length).toBeGreaterThan(0); - } - }); - - test("fails loud when the named step is not a step primitive", () => { - expect(() => - readFoldedBody(inertProjection({ steps: { host: { kind: "not-a-step" } } }), []), - ).toThrow(/is not a step primitive/); - }); - - test("rejects a LIVE definition, whose inference chain the projection flattens away", () => { - expect(() => readFoldedBody(liveDefinition(), [])).toThrow(/is not a step primitive/); - }); - - test("extracts the launch body from a section-shaped (CL-6329 onTrigger) projection", () => { - expect(readFoldedBody(sectionProjection(), [])).toEqual({ - systemPrompt: "you are the invited agent", - toolPackagePins: [], - grantRequirements: [], - credentialBindings: [], - model: "qwen3:8b", - }); - }); - - test("fails loud when a section's inline body is not single-step", () => { - expect(() => - readFoldedBody( - sectionProjection({ - steps: { - turn: { - kind: "onTrigger", - body: { - inline: { - id: "wfd_2_body", - stepOrder: ["reply", "second"], - steps: {}, - }, - }, - }, - }, - }), - [], - ), - ).toThrow(/body is not single-step/); - }); - - test("fails loud when a section's inline body step is not a step primitive", () => { - expect(() => - readFoldedBody( - sectionProjection({ - steps: { - turn: { - kind: "onTrigger", - body: { - inline: { - id: "wfd_2_body", - stepOrder: ["reply"], - steps: { reply: { kind: "not-a-step" } }, - }, - }, - }, - }, - }), - [], - ), - ).toThrow(/body step reply is not a step primitive/); - }); -}); - -// CL-6452: every run deploy mints a same-named, same-asset sibling -// definition row (its per-run rendered bytes carry a per-run wire hash), -// frozen with the projection current AT THAT DEPLOY. Those run clones -// are deploy records, never launch candidates — only the hub-authored -// row, whose projection a skill pin or instructions save refreezes in -// place, may resolve a launch. -describe("authoredDefinitionCandidates", () => { - test("keeps only the hub-authored row, dropping run-deploy clones", () => { - const authored = { - id: "wfd_authored", - name: "fact-checker", - origin: "authored", - } as const; - expect( - authoredDefinitionCandidates([ - // Newest first: the clones every run deploy minted after the - // agent was authored. - { id: "wfd_run_2", name: "fact-checker", origin: "run" }, - { id: "wfd_run_1", name: "fact-checker", origin: "run" }, - authored, - ]), - ).toEqual([authored]); - }); - - test("N run deploys never grow the authoritative candidate set", () => { - const authored = { - id: "wfd_authored", - name: "fact-checker", - origin: "authored", - } as const; - const clones = Array.from({ length: 5 }, (_, index) => ({ - id: `wfd_run_${String(index + 1)}`, - name: "fact-checker", - origin: "run" as const, - })); - expect(authoredDefinitionCandidates([...clones, authored])).toHaveLength(1); - }); -}); - -describe("resolveNewestProjectedDefinition", () => { - test("prefers the newest definition that actually carries a projection over a pre-cutover one", async () => { - const healthy = inertProjection({ id: "wfd_new" }); - projectionsById["wfd_new"] = healthy; - - const resolved = await resolveNewestProjectedDefinition({} as never, [ - // Newest first, as the caller orders candidates by createdAt desc. - { id: "wfd_pre_cutover", name: "assistant" }, - { id: "wfd_new", name: "assistant" }, - ]); - - expect(resolved.definitionId).toBe("wfd_new"); - expect(resolved.projection).toEqual(healthy); - }); - - test("raises DefinitionProjectionMissingError when no candidate carries one", async () => { - await expect( - resolveNewestProjectedDefinition({} as never, [ - { id: "wfd_dead_1", name: "assistant" }, - { id: "wfd_dead_2", name: "assistant" }, - ]), - ).rejects.toThrow(DefinitionProjectionMissingError); - }); - - test("raises with consumer-language recovery guidance when there are no candidates at all", async () => { - await expect(resolveNewestProjectedDefinition({} as never, [])).rejects.toThrow( - /save its instructions/, - ); - }); -}); - -describe("readDefinitionProjection", () => { - test("names the definition in the error a pre-cutover row raises", async () => { - await expect( - readDefinitionProjection({} as never, { id: "wfd_none", name: "myra" }), - ).rejects.toThrow(/"myra"/); - }); -}); diff --git a/packages/workflows/src/definition-projection.ts b/packages/workflows/src/definition-projection.ts index 0a7f24102d..831d3c3966 100644 --- a/packages/workflows/src/definition-projection.ts +++ b/packages/workflows/src/definition-projection.ts @@ -1,23 +1,21 @@ -// Reads a `WorkflowDefinition`'s launch body back out of the hub's -// own record of the definition. +// Reads a `WorkflowDefinition`'s launch body back out of an already-resolved +// inert wire projection. // -// Under the `workflow.json` retirement a deployed definition's body is -// whatever its source closure evaluates to on the sidecar, and a -// source-format workflow asset carries no envelope to read it back from. -// The hub-side record of that body is the inert wire projection the -// approval freeze hashed, persisted on the definition's version row -// beside the hash that addresses it -// (`vendor/intx/db/src/workflow-definition-store.ts`'s -// `loadFrozenWireProjection`). This module is the single reader of that -// projection for launch purposes. +// CL-8206: Interchange 79adc433 retired the frozen-wire-projection storage +// this module used to read directly (`loadFrozenWireProjection`, the +// `workflow_definition.origin` / `workflow_definition_version.wire_projection` +// columns) — there is no longer a hub-side row to read a launch body back +// off of. What survives here is the pure half: the named errors and the +// schema-validated reader that turns an already-in-hand inert projection +// into a `FoldedBody`, still needed by chat (retiring separately under +// CL-8175) and anything else holding a projection value from elsewhere. +// Nothing in this file queries a database. // // One field of the launch body is deliberately NOT in the projection: // `grantRequirements` does not survive the live->inert projector and is // therefore outside the wire hash. Its hub-side home is the // `workflow_definition.grant_requirements` column, so it is passed in // alongside the projection rather than read off it. -import type { DB } from "@intx/db"; -import { loadFrozenWireProjection } from "@intx/db"; import type { FoldedBody } from "@intx/workflow-deploy"; import { GrantRequirement, CredentialBinding } from "@intx/types"; import { ToolPackagePin } from "@intx/types/tool-packages"; @@ -45,21 +43,6 @@ export class DefinitionProjectionMissingError extends Error { } } -/** - * Read one definition's frozen inert projection, failing with the named - * error above rather than a raw miss. - */ -export async function readDefinitionProjection( - db: DB["db"], - definition: { id: string; name: string }, -): Promise { - const projection = await loadFrozenWireProjection(db, definition.id); - if (projection === null) { - throw new DefinitionProjectionMissingError(definition.name); - } - return projection; -} - /** One definition candidate, ordered newest-first by the caller * (typically `createdAt desc`). */ export type DefinitionCandidate = { @@ -67,20 +50,6 @@ export type DefinitionCandidate = { readonly name: string; }; -/** - * The launch-authoritative subset of an asset's definition rows: only - * the hub-authored row(s), whose projection a skill pin or - * instructions save refreezes in place. Every code-sourced run deploy - * ensures a same-named sibling over the same asset under its per-run - * wire hash — a frozen deploy record carrying whatever projection was - * current at that deploy, which must never resolve a launch (CL-6452). - */ -export function authoredDefinitionCandidates( - rows: readonly T[], -): T[] { - return rows.filter((row) => row.origin === "authored"); -} - /** * Thrown when a definition's frozen projection carries more than one * step. The launch target (`@corbits/agent-runtime`'s @@ -113,28 +82,6 @@ export class MultiStepFoldUnsupportedError extends Error { } } -/** - * Resolves a definition's launch body by trying its candidates - * newest-first and returning the first one that actually carries a - * frozen projection — a stale pre-cutover sibling never wins over a - * healthy newer one (the DB-side successor to CL-6357's asset-drift - * walk). Raises `DefinitionProjectionMissingError` only once every - * candidate has come back empty. - */ -export async function resolveNewestProjectedDefinition( - db: DB["db"], - candidates: readonly DefinitionCandidate[], -): Promise<{ definitionId: string; projection: unknown }> { - for (const candidate of candidates) { - const projection = await loadFrozenWireProjection(db, candidate.id); - if (projection !== null) { - return { definitionId: candidate.id, projection }; - } - } - const definitionName = candidates[0]?.name ?? "unknown"; - throw new DefinitionProjectionMissingError(definitionName); -} - /** * The launch-relevant subset of an inert projection's single `step` * primitive. The projector reifies the live `AgentDefinition` into plain diff --git a/packages/workflows/src/index.ts b/packages/workflows/src/index.ts index 031b80d6b5..23bbf4dd2a 100644 --- a/packages/workflows/src/index.ts +++ b/packages/workflows/src/index.ts @@ -9,16 +9,13 @@ export * from "./detail/index"; export * from "./authoring/index"; export { pickLaunchableDefinition, - resolveLaunchableDefinition, - listLaunchableDefinitions, routineTargetRejection, RoutineTargetUnresolvableError, - type LaunchableDefinition, type LaunchableDefinitionCandidate, type LaunchableDefinitionRejection, type LaunchableDefinitionResolution, type LaunchableDefinitionResolver, -} from "./launchable/target"; +} from "./launchable/target-rule"; export { WorkflowTriggerField, WORKFLOW_CATALOG, @@ -31,16 +28,6 @@ export { type WorkflowCatalogEntry, type TriggerFieldsValidation, } from "./catalog"; -export { - authoredDefinitionCandidates, - readDefinitionProjection, - readFoldedBody, - resolveNewestProjectedDefinition, - DefinitionProjectionMissingError, - MultiStepFoldUnsupportedError, - FoldedBodySchema, - type DefinitionCandidate, -} from "./definition-projection"; export { CRON_FIELD_RANGES, cronExpressionCanFire, @@ -67,13 +54,9 @@ export { type DeliverWhenRoutableOptions, } from "./deliver-when-routable"; export { - listScheduledWorkflowDefinitions, - scheduledDefinitionsFromRows, - type ScheduledWorkflowDefinition, - type ScheduledWorkflowDefinitionRow, -} from "./schedule/list-scheduled"; -export { - listDeployedCronDefinitions, - SCHEDULE_TICK_CONTENT, - type DeployedCronDefinition, -} from "./schedule/deployed-cron-deployments"; + readFoldedBody, + DefinitionProjectionMissingError, + MultiStepFoldUnsupportedError, + FoldedBodySchema, + type DefinitionCandidate, +} from "./definition-projection"; diff --git a/packages/workflows/src/launchable/target.test.ts b/packages/workflows/src/launchable/target.test.ts deleted file mode 100644 index 77cf1a332e..0000000000 --- a/packages/workflows/src/launchable/target.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -// The follow-latest rule, proven without a database: `pickLaunchableDefinition` -// is the pure half of `resolveLaunchableDefinition`, and these are the -// orderings that matter — the newest FROZEN deployment wins even when a -// newer unfrozen redeploy exists, and each rejection reason is the most -// specific one the rows support. -import { describe, expect, test } from "bun:test"; - -import { - pickLaunchableDefinition, - routineTargetRejection, - type LaunchableDefinitionCandidate, -} from "./target"; - -const TENANT = "tnt_1"; - -function candidate( - overrides: Partial & { id: string }, -): LaunchableDefinitionCandidate { - return { - tenantId: TENANT, - status: "deployed", - approvedWireHash: `hash_${overrides.id}`, - grantSnapshot: { grants: [] }, - wireProjection: { steps: [] }, - createdAt: new Date("2026-09-01T00:00:00Z"), - ...overrides, - }; -} - -describe("pickLaunchableDefinition", () => { - test("the newest frozen, deployed row wins regardless of input order", () => { - const older = candidate({ - id: "wfd_v1", - createdAt: new Date("2026-08-01T00:00:00Z"), - }); - const newer = candidate({ - id: "wfd_v2", - createdAt: new Date("2026-08-15T00:00:00Z"), - }); - expect(pickLaunchableDefinition([older, newer], TENANT)).toEqual({ - ok: true, - definitionId: "wfd_v2", - wireHash: "hash_wfd_v2", - }); - expect(pickLaunchableDefinition([newer, older], TENANT)).toEqual({ - ok: true, - definitionId: "wfd_v2", - wireHash: "hash_wfd_v2", - }); - }); - - test("a newer redeploy that is not yet frozen is skipped in favour of the newest frozen one", () => { - const frozen = candidate({ - id: "wfd_v1", - createdAt: new Date("2026-08-01T00:00:00Z"), - }); - const unfrozenNewer = candidate({ - id: "wfd_v2", - createdAt: new Date("2026-08-15T00:00:00Z"), - approvedWireHash: null, - grantSnapshot: null, - wireProjection: null, - }); - const partiallyFrozenNewer = candidate({ - id: "wfd_v3", - createdAt: new Date("2026-08-20T00:00:00Z"), - wireProjection: null, - }); - const picked = pickLaunchableDefinition([unfrozenNewer, frozen, partiallyFrozenNewer], TENANT); - expect(picked).toEqual({ - ok: true, - definitionId: "wfd_v1", - wireHash: "hash_wfd_v1", - }); - }); - - test("a stopped row is never picked even when it is newest and frozen", () => { - const live = candidate({ - id: "wfd_v1", - createdAt: new Date("2026-08-01T00:00:00Z"), - }); - const stopped = candidate({ - id: "wfd_v2", - status: "stopped", - createdAt: new Date("2026-08-15T00:00:00Z"), - }); - expect(pickLaunchableDefinition([stopped, live], TENANT)).toEqual({ - ok: true, - definitionId: "wfd_v1", - wireHash: "hash_wfd_v1", - }); - }); - - test("rejection reasons are the most specific the rows support", () => { - expect(pickLaunchableDefinition([], TENANT)).toEqual({ - ok: false, - reason: "not_found", - }); - expect( - pickLaunchableDefinition([candidate({ id: "wfd_theirs", tenantId: "tnt_other" })], TENANT), - ).toEqual({ ok: false, reason: "cross_tenant" }); - expect( - pickLaunchableDefinition( - [ - candidate({ id: "wfd_stopped", status: "stopped" }), - candidate({ id: "wfd_theirs", tenantId: "tnt_other" }), - ], - TENANT, - ), - ).toEqual({ ok: false, reason: "not_deployed" }); - expect( - pickLaunchableDefinition( - [ - candidate({ id: "wfd_pending", approvedWireHash: null }), - candidate({ id: "wfd_stopped", status: "stopped" }), - ], - TENANT, - ), - ).toEqual({ ok: false, reason: "unfrozen" }); - }); -}); - -describe("routineTargetRejection", () => { - test("a cross-tenant asset is reported exactly like a missing one", () => { - expect(routineTargetRejection("cross_tenant")).toEqual(routineTargetRejection("not_found")); - expect(routineTargetRejection("not_found").status).toBe(404); - }); - - test("every reason carries a distinct code a UI or Myra can branch on", () => { - const codes = new Set( - (["not_found", "unfrozen", "not_deployed"] as const).map( - (reason) => routineTargetRejection(reason).code, - ), - ); - expect(codes.size).toBe(3); - }); -}); diff --git a/packages/workflows/src/launchable/target.ts b/packages/workflows/src/launchable/target.ts deleted file mode 100644 index 9ba88d7898..0000000000 --- a/packages/workflows/src/launchable/target.ts +++ /dev/null @@ -1,146 +0,0 @@ -// The one place a launch target (a workflow asset id) becomes the -// definition that actually runs. Interchange keys `workflow_definition` -// on `(asset_id, wire_hash)` and has no "newest approved deployment of -// this asset" indirection of its own (docs/workflow-model.md), so this -// module supplies exactly that query — and nothing else: no search by -// name, no fallback to an unfrozen row, no pinning. Every caller -// (create, retarget, launch, and the target list) resolves through here -// so a fire can never run, or offer, a definition this rule would not -// have picked. -// -// "What is launchable" is definition-domain logic in `@corbits/workflows`. -// The pure follow-latest rule lives in -// `./target-rule.ts` (no `drizzle-orm`/`@intx/db`, so it is safe on -// `@corbits/workflows/client`); this file is the DB-touching half, and -// both `resolveLaunchableDefinition` and `listLaunchableDefinitions` -// below share the one row query and the one `pickLaunchableDefinition` -// reduction. -import { and, desc, eq } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import { workflowDefinition, workflowDefinitionVersion } from "@intx/db/schema"; - -import { pickLaunchableDefinition } from "./target-rule"; -import type { LaunchableDefinitionCandidate, LaunchableDefinitionResolution } from "./target-rule"; - -export * from "./target-rule"; - -type LaunchableDb = PostgresJsDatabase>; - -type LaunchableRow = LaunchableDefinitionCandidate & { - readonly definitionAssetId: string | null; - readonly name: string; - readonly description: string | null; -}; - -/** The newest launchable definition per source asset in a tenant: an - * `authored` row with `status = 'deployed'` whose current version row is - * frozen. The row shape `listLaunchableDefinitions` (below) returns once - * `pickLaunchableDefinition` has picked the winner per asset. */ -export type LaunchableDefinition = { - readonly definitionId: string; - readonly definitionAssetId: string; - readonly name: string; - readonly description: string | null; - readonly wireHash: string; - readonly wireProjection: unknown; -}; - -/** Every `authored` `workflow_definition` row in a tenant with an asset - * id, joined to its current version's freeze columns — unfiltered by - * status or freeze state, so a caller can tell "no such asset" apart - * from "not deployed" apart from "not yet frozen" via - * `pickLaunchableDefinition`'s rejection reasons. The one query both - * `resolveLaunchableDefinition` and `listLaunchableDefinitions` reduce - * through `pickLaunchableDefinition` to reach the same rule. - */ -async function fetchLaunchableRows( - db: LaunchableDb, - extraWhere: ReturnType, -): Promise { - const rows = await db - .select({ - id: workflowDefinition.id, - tenantId: workflowDefinition.tenantId, - status: workflowDefinition.status, - createdAt: workflowDefinition.createdAt, - definitionAssetId: workflowDefinition.assetId, - name: workflowDefinition.name, - description: workflowDefinition.description, - approvedWireHash: workflowDefinitionVersion.approvedWireHash, - grantSnapshot: workflowDefinitionVersion.grantSnapshot, - wireProjection: workflowDefinitionVersion.wireProjection, - }) - .from(workflowDefinition) - .leftJoin( - workflowDefinitionVersion, - and( - eq(workflowDefinitionVersion.definitionId, workflowDefinition.id), - eq(workflowDefinitionVersion.version, workflowDefinition.currentVersion), - ), - ) - .where(and(eq(workflowDefinition.origin, "authored"), extraWhere)) - .orderBy(desc(workflowDefinition.createdAt)); - return rows.map((row) => ({ - ...row, - approvedWireHash: row.approvedWireHash ?? null, - grantSnapshot: row.grantSnapshot ?? null, - wireProjection: row.wireProjection ?? null, - })); -} - -/** - * Resolves the definition a routine targeting `definitionAssetId` would - * run right now, per `pickLaunchableDefinition`. One query, read at the - * moment of use — a create/retarget validates through it, and a launch - * re-resolves through it rather than trusting anything stored. - */ -export async function resolveLaunchableDefinition(input: { - db: LaunchableDb; - tenantId: string; - definitionAssetId: string; -}): Promise { - const rows = await fetchLaunchableRows( - input.db, - eq(workflowDefinition.assetId, input.definitionAssetId), - ); - return pickLaunchableDefinition(rows, input.tenantId); -} - -/** - * The newest launchable definition per source asset in a tenant, per - * `pickLaunchableDefinition` — callers (the web picker, schedule list) - * reduce further by catalog/authorization; not authorized itself, so - * callers gate what leaves. - */ -export async function listLaunchableDefinitions( - db: LaunchableDb, - tenantId: string, -): Promise { - const rows = await fetchLaunchableRows(db, eq(workflowDefinition.tenantId, tenantId)); - const byAsset = new Map(); - for (const row of rows) { - if (row.definitionAssetId === null) continue; - const bucket = byAsset.get(row.definitionAssetId); - if (bucket === undefined) { - byAsset.set(row.definitionAssetId, [row]); - } else { - bucket.push(row); - } - } - const result: LaunchableDefinition[] = []; - for (const [definitionAssetId, candidates] of byAsset) { - const picked = pickLaunchableDefinition(candidates, tenantId); - if (!picked.ok) continue; - const winner = candidates.find((row) => row.id === picked.definitionId); - if (winner === undefined) continue; - result.push({ - definitionId: picked.definitionId, - definitionAssetId, - name: winner.name, - description: winner.description, - wireHash: picked.wireHash, - wireProjection: winner.wireProjection, - }); - } - return result; -} diff --git a/packages/workflows/src/schedule/available-catalog.drizzle.test.ts b/packages/workflows/src/schedule/available-catalog.drizzle.test.ts deleted file mode 100644 index 134f6978ef..0000000000 --- a/packages/workflows/src/schedule/available-catalog.drizzle.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout -// still runs the unit gates), mirroring -// `packages/granola-tools/test/credential-delivery.drizzle.test.ts`. Runs -// against its own Postgres schema, never the developer's or the -// walking-skeleton suite's. -// -// Proves the CL-7073 critique's finding 2: a catalog workflow's required -// connection reads as satisfied when the CONNECTED provider lives on an -// ancestor tenant, not only the exact deploying tenant — the same -// ancestor-chain precedence Plugins' `connectorStatus` and -// `@intx/db`'s credential resolution both use (child shadows parent). -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { createDB, runMigrations, dropSchema } from "@intx/db"; -import { schema } from "@intx/db"; - -import { dbTargetFromUrl } from "../../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../../scripts/e2e/harness"; -import { dbGate } from "../../../../scripts/e2e/db-gate"; -import { listAvailableCatalogWorkflows } from "./available-catalog"; - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const SCHEMA = "workflows_available_catalog_test"; - -describeIfDb("listAvailableCatalogWorkflows: ancestor-chain connection satisfaction", () => { - const target = dbTargetFromUrl(databaseUrl ?? "postgres://localhost:5432/unused"); - - beforeAll(async () => { - await runMigrations(target, { schema: SCHEMA }); - }); - - afterAll(async () => { - await dropSchema(target, { schema: SCHEMA }); - }); - - test("a child workbench sees code-review as connected via its parent bench's github connection", async () => { - const { db, close } = createDB({ ...target, schema: SCHEMA }); - try { - await db.insert(schema.tenant).values({ - id: "tnt_parent_bench", - name: "Parent Bench", - slug: "parent-bench", - domain: "parent-bench.workbench.test", - }); - await db.insert(schema.tenant).values({ - id: "tnt_child_workbench", - name: "Child Workbench", - slug: "child-workbench", - domain: "child-workbench.workbench.test", - parentId: "tnt_parent_bench", - }); - await db.insert(schema.provider).values({ - id: "prov_parent_github", - tenantId: "tnt_parent_bench", - name: "github", - plugin: "github", - }); - await db.insert(schema.credential).values({ - id: "cred_parent_github", - tenantId: "tnt_parent_bench", - providerId: "prov_parent_github", - name: "github-default", - type: "oauth_token", - secret: "encrypted-not-decrypted-by-this-path", - status: "active", - }); - - const result = await listAvailableCatalogWorkflows({ - db, - tenantId: "tnt_child_workbench", - catalogAssetNames: ["code-review"], - }); - - const codeReview = result.find((entry) => entry.assetName === "code-review"); - expect(codeReview?.connectionsSatisfied).toBe(true); - expect(codeReview?.missingConnections).toEqual([]); - } finally { - await close(); - } - }); - - test("a bench with no ancestor connection sees code-review as not yet connected", async () => { - const { db, close } = createDB({ ...target, schema: SCHEMA }); - try { - await db.insert(schema.tenant).values({ - id: "tnt_unconnected_bench", - name: "Unconnected Bench", - slug: "unconnected-bench", - domain: "unconnected-bench.workbench.test", - }); - - const result = await listAvailableCatalogWorkflows({ - db, - tenantId: "tnt_unconnected_bench", - catalogAssetNames: ["code-review"], - }); - - const codeReview = result.find((entry) => entry.assetName === "code-review"); - expect(codeReview?.connectionsSatisfied).toBe(false); - expect(codeReview?.missingConnections).toEqual(["github"]); - } finally { - await close(); - } - }); -}); diff --git a/packages/workflows/src/schedule/available-catalog.test.ts b/packages/workflows/src/schedule/available-catalog.test.ts deleted file mode 100644 index f3323c586e..0000000000 --- a/packages/workflows/src/schedule/available-catalog.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { availableCatalogWorkflowsFrom } from "./available-catalog"; - -describe("availableCatalogWorkflowsFrom", () => { - test("excludes a catalog name that already has a deployed definition", () => { - const result = availableCatalogWorkflowsFrom({ - catalogAssetNames: ["echo", "code-review"], - deployedNames: new Set(["echo"]), - isConnectorSatisfied: () => true, - }); - expect(result.map((r) => r.assetName)).toEqual(["code-review"]); - }); - - test("skips a catalog name with no WORKFLOW_CATALOG entry rather than throwing", () => { - const result = availableCatalogWorkflowsFrom({ - catalogAssetNames: ["not-a-real-workflow"], - deployedNames: new Set(), - isConnectorSatisfied: () => true, - }); - expect(result).toEqual([]); - }); - - test("a workflow with no required connections is always satisfied", () => { - const result = availableCatalogWorkflowsFrom({ - catalogAssetNames: ["echo"], - deployedNames: new Set(), - isConnectorSatisfied: () => false, - }); - expect(result).toHaveLength(1); - expect(result[0]?.requiredConnections).toEqual([]); - expect(result[0]?.missingConnections).toEqual([]); - expect(result[0]?.connectionsSatisfied).toBe(true); - }); - - test("a workflow whose required connection is not satisfied names it as missing", () => { - const result = availableCatalogWorkflowsFrom({ - catalogAssetNames: ["code-review"], - deployedNames: new Set(), - isConnectorSatisfied: () => false, - }); - expect(result).toHaveLength(1); - expect(result[0]?.requiredConnections).toEqual(["github"]); - expect(result[0]?.missingConnections).toEqual(["github"]); - expect(result[0]?.connectionsSatisfied).toBe(false); - }); - - test("a workflow whose required connection is satisfied carries no missing connections", () => { - const result = availableCatalogWorkflowsFrom({ - catalogAssetNames: ["code-review"], - deployedNames: new Set(), - isConnectorSatisfied: (id) => id === "github", - }); - expect(result).toHaveLength(1); - expect(result[0]?.missingConnections).toEqual([]); - expect(result[0]?.connectionsSatisfied).toBe(true); - }); - - test("carries the display name and description straight from WORKFLOW_CATALOG", () => { - const result = availableCatalogWorkflowsFrom({ - catalogAssetNames: ["echo"], - deployedNames: new Set(), - isConnectorSatisfied: () => true, - }); - expect(result[0]?.displayName.length).toBeGreaterThan(0); - expect(result[0]?.description.length).toBeGreaterThan(0); - }); -}); diff --git a/packages/workflows/src/schedule/available-catalog.ts b/packages/workflows/src/schedule/available-catalog.ts deleted file mode 100644 index 3385b8e342..0000000000 --- a/packages/workflows/src/schedule/available-catalog.ts +++ /dev/null @@ -1,174 +0,0 @@ -// The Routines page's "Available" section (CL-7073): every catalog -// workflow (`@workbench/onboarding`'s `tenant-seed` `CATALOG_WORKFLOWS` — -// injected here by asset name so this package never depends back on -// `@workbench/onboarding`, which already depends on `@corbits/workflows`) that has no deployed -// definition of that asset name on the caller's bench yet, alongside -// this package's own `WORKFLOW_CATALOG` display metadata and whether the -// tenant already satisfies its required connections. -// -// Connection satisfaction mirrors `@corbits/settings-ui`'s -// `connectorStatus` (same active-credential rule) but, like Plugins' -// own connector status and credential resolution, walks the tenant's -// ancestor chain (`@intx/db`'s `getAncestorChain`) rather than the exact -// tenant alone: a connector reads "satisfied" when the nearest ancestor -// (child shadows parent, same precedence `resolveProviderByName` uses) -// carrying a `provider` row named after the connector id either has no -// credential yet (nothing to fail) or its newest credential is `active`. -// An `expired`/`error`/`revoked` credential reads as unsatisfied, the -// same "needs attention" case `connectorStatus` reports. Every -// connector this tenant's catalog might need is resolved with exactly -// one `IN` query per table (`provider`, `credential`) rather than one -// round trip per connector. -import { and, desc, eq, inArray } from "drizzle-orm"; -import { getAncestorChain, type DB } from "@intx/db"; -import { credential, provider, workflowDefinition } from "@intx/db/schema"; - -import { workflowCatalogEntry } from "../catalog"; - -export type AvailableCatalogWorkflow = { - readonly assetName: string; - readonly displayName: string; - readonly description: string; - readonly requiredConnections: readonly string[]; - readonly missingConnections: readonly string[]; - readonly connectionsSatisfied: boolean; -}; - -/** - * The read-only-of-a-database-round-trip core: given which asset names - * are already deployed and a synchronous "is this connector satisfied" - * lookup, computes the available list. - * Split out from `listAvailableCatalogWorkflows` so this — the actual - * per-entry filtering and shaping logic — has a test that needs no - * Postgres. - */ -export function availableCatalogWorkflowsFrom(args: { - readonly catalogAssetNames: readonly string[]; - readonly deployedNames: ReadonlySet; - readonly isConnectorSatisfied: (connectorId: string) => boolean; -}): readonly AvailableCatalogWorkflow[] { - const { catalogAssetNames, deployedNames, isConnectorSatisfied } = args; - const available: AvailableCatalogWorkflow[] = []; - for (const assetName of catalogAssetNames) { - if (deployedNames.has(assetName)) continue; - const entry = workflowCatalogEntry(assetName); - if (entry === undefined) continue; - - const missingConnections = entry.requiredConnections.filter( - (connectorId) => !isConnectorSatisfied(connectorId), - ); - available.push({ - assetName, - displayName: entry.displayName, - description: entry.whatItDoes, - requiredConnections: entry.requiredConnections, - missingConnections, - connectionsSatisfied: missingConnections.length === 0, - }); - } - return available; -} - -/** - * Resolves every named connector's satisfaction for one tenant in exactly - * one `provider` query and one `credential` query, walking the tenant's - * ancestor chain (child shadows parent) the same way Plugins' own - * connector status and credential resolution do — rather than one query - * pair per connector. - */ -async function connectionSatisfactionByConnector( - db: DB["db"], - tenantId: string, - connectorIds: ReadonlySet, -): Promise> { - const satisfaction = new Map(); - if (connectorIds.size === 0) return satisfaction; - - const chain = await getAncestorChain(db, tenantId); - const providerRows = await db.query.provider.findMany({ - where: and(inArray(provider.tenantId, chain), inArray(provider.name, [...connectorIds])), - columns: { id: true, tenantId: true, name: true }, - }); - - const credentialRows = - providerRows.length === 0 - ? [] - : await db.query.credential.findMany({ - where: inArray( - credential.providerId, - providerRows.map((row) => row.id), - ), - columns: { providerId: true, status: true, createdAt: true }, - orderBy: [desc(credential.createdAt)], - }); - const newestCredentialByProviderId = new Map(); - for (const row of credentialRows) { - if (!newestCredentialByProviderId.has(row.providerId)) { - newestCredentialByProviderId.set(row.providerId, row); - } - } - - for (const connectorId of connectorIds) { - let satisfied = false; - for (const tenantIdInChain of chain) { - const providerRow = providerRows.find( - (row) => row.tenantId === tenantIdInChain && row.name === connectorId, - ); - if (providerRow === undefined) continue; - const newest = newestCredentialByProviderId.get(providerRow.id); - satisfied = newest === undefined || newest.status === "active"; - break; - } - satisfaction.set(connectorId, satisfied); - } - return satisfaction; -} - -/** - * Every catalog asset name with no deployed `workflow_definition` on this - * tenant yet, enriched with `WORKFLOW_CATALOG` metadata and connection - * satisfaction. `catalogAssetNames` names the full - * deployable-through-the-catalog-instantiate-route set (`@workbench/onboarding`'s - * `tenant-seed` `CATALOG_WORKFLOWS`, by asset name) — the caller's job, not this - * package's, since importing that package here would cycle back through - * its own dependency on `@corbits/workflows`. A - * catalog name with no `WORKFLOW_CATALOG` entry is skipped rather than - * thrown on: every catalog name is asserted to have an entry in the - * onboarding (`packages/onboarding/test/tenant-seed-catalog.test.ts`) - * suite, so this is defense against the caller - * passing a stale name, not an expected path. - */ -export async function listAvailableCatalogWorkflows(args: { - readonly db: DB["db"]; - readonly tenantId: string; - readonly catalogAssetNames: readonly string[]; -}): Promise { - const { db, tenantId, catalogAssetNames } = args; - - const deployed = await db.query.workflowDefinition.findMany({ - where: and( - eq(workflowDefinition.tenantId, tenantId), - eq(workflowDefinition.origin, "authored"), - eq(workflowDefinition.status, "deployed"), - ), - columns: { name: true }, - }); - const deployedNames = new Set(deployed.map((row) => row.name)); - - const allConnectorIds = new Set( - catalogAssetNames.flatMap( - (assetName) => workflowCatalogEntry(assetName)?.requiredConnections ?? [], - ), - ); - const connectorSatisfaction = await connectionSatisfactionByConnector( - db, - tenantId, - allConnectorIds, - ); - - return availableCatalogWorkflowsFrom({ - catalogAssetNames, - deployedNames, - isConnectorSatisfied: (connectorId) => connectorSatisfaction.get(connectorId) ?? false, - }); -} diff --git a/packages/workflows/src/schedule/deployed-cron-deployments.ts b/packages/workflows/src/schedule/deployed-cron-deployments.ts deleted file mode 100644 index eaf42786ce..0000000000 --- a/packages/workflows/src/schedule/deployed-cron-deployments.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Every deployed, authored definition whose frozen projection carries a -// valid `ScheduleTrigger` cron — what `@corbits/workflow-schedule`'s cron -// emitter arms a tick for. The ops-table sibling -// (`listScheduledWorkflowDefinitions`) keeps `stopped` rows so a paused -// digest still renders; this one is the firing list, so it does not. -// -// `creatorPrincipalId` is the principal a tick's trigger mail is sent as, -// and `tenantDomain` its sending domain: a row missing either cannot be -// fired and is left out rather than launched under a guessed identity. -import { and, eq } from "drizzle-orm"; -import type { DB } from "@intx/db"; -import { - tenant as tenantTable, - workflowDefinition, - workflowDefinitionVersion, -} from "@intx/db/schema"; - -import { scheduleCronFromProjection } from "./from-projection"; - -export type DeployedCronDefinition = { - readonly definitionId: string; - readonly tenantId: string; - readonly tenantDomain: string; - readonly creatorPrincipalId: string; - readonly name: string; - readonly cron: string; -}; - -export async function listDeployedCronDefinitions( - db: DB["db"], -): Promise { - const rows = await db - .select({ - definitionId: workflowDefinition.id, - tenantId: workflowDefinition.tenantId, - tenantDomain: tenantTable.domain, - creatorPrincipalId: workflowDefinition.creatorPrincipalId, - name: workflowDefinition.name, - wireProjection: workflowDefinitionVersion.wireProjection, - }) - .from(workflowDefinition) - .innerJoin( - workflowDefinitionVersion, - and( - eq(workflowDefinitionVersion.definitionId, workflowDefinition.id), - eq(workflowDefinitionVersion.version, workflowDefinition.currentVersion), - ), - ) - .innerJoin(tenantTable, eq(tenantTable.id, workflowDefinition.tenantId)) - .where( - and(eq(workflowDefinition.origin, "authored"), eq(workflowDefinition.status, "deployed")), - ); - - const deployed: DeployedCronDefinition[] = []; - for (const row of rows) { - if (row.creatorPrincipalId === null) continue; - const cron = scheduleCronFromProjection(row.wireProjection); - if (cron === undefined) continue; - deployed.push({ - definitionId: row.definitionId, - tenantId: row.tenantId, - tenantDomain: row.tenantDomain, - creatorPrincipalId: row.creatorPrincipalId, - name: row.name, - cron, - }); - } - return deployed; -} - -/** The body of a scheduled tick's trigger mail. */ -export const SCHEDULE_TICK_CONTENT = "Scheduled tick."; diff --git a/packages/workflows/src/schedule/list-scheduled.test.ts b/packages/workflows/src/schedule/list-scheduled.test.ts deleted file mode 100644 index d60fa0f5af..0000000000 --- a/packages/workflows/src/schedule/list-scheduled.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { scheduledDefinitionsFromRows } from "./list-scheduled"; - -const AT = new Date("2026-01-01T00:00:00.000Z"); - -const digestProjection = { - id: "wf_digest", - stepOrder: ["digest"], - steps: { digest: { kind: "step" } }, - triggers: [{ type: "schedule", cron: "0 9 * * *" }], -}; - -const mailOnlyProjection = { - id: "wf_mail", - stepOrder: ["step"], - steps: { step: { kind: "step" } }, - triggers: [{ type: "mail", to: "x@example.com" }], -}; - -describe("scheduledDefinitionsFromRows", () => { - test("includes a stopped scheduled definition", () => { - const listed = scheduledDefinitionsFromRows([ - { - definitionId: "wfd_digest", - tenantId: "tnt_1", - assetId: "ast_1", - name: "workbench-digest", - status: "stopped", - createdAt: AT, - updatedAt: AT, - wireProjection: digestProjection, - }, - ]); - expect(listed).toEqual([ - { - definitionId: "wfd_digest", - assetId: "ast_1", - name: "workbench-digest", - tenantId: "tnt_1", - status: "stopped", - cron: "0 9 * * *", - createdAt: AT, - updatedAt: AT, - }, - ]); - }); - - test("includes a deployed scheduled definition", () => { - const listed = scheduledDefinitionsFromRows([ - { - definitionId: "wfd_digest", - tenantId: "tnt_1", - assetId: "ast_1", - name: "workbench-digest", - status: "deployed", - createdAt: AT, - updatedAt: AT, - wireProjection: digestProjection, - }, - ]); - expect(listed).toHaveLength(1); - expect(listed[0]?.status).toBe("deployed"); - }); - - test("drops a definition with no ScheduleTrigger", () => { - expect( - scheduledDefinitionsFromRows([ - { - definitionId: "wfd_mail", - tenantId: "tnt_1", - assetId: "ast_2", - name: "assistant", - status: "deployed", - createdAt: AT, - updatedAt: AT, - wireProjection: mailOnlyProjection, - }, - ]), - ).toEqual([]); - }); - - test("drops a row with no asset id", () => { - expect( - scheduledDefinitionsFromRows([ - { - definitionId: "wfd_digest", - tenantId: "tnt_1", - assetId: null, - name: "workbench-digest", - status: "deployed", - createdAt: AT, - updatedAt: AT, - wireProjection: digestProjection, - }, - ]), - ).toEqual([]); - }); -}); diff --git a/packages/workflows/src/schedule/list-scheduled.ts b/packages/workflows/src/schedule/list-scheduled.ts deleted file mode 100644 index 441fdab859..0000000000 --- a/packages/workflows/src/schedule/list-scheduled.ts +++ /dev/null @@ -1,89 +0,0 @@ -// Authored workflow definitions whose frozen projection carries a -// ScheduleTrigger, of any status — the Routines page's list. The hub -// poller uses a deployed-only sibling (`listScheduledDefinitionsFromDb`); -// this helper is the ops table, so a paused (`stopped`) digest still -// appears. -import { and, eq } from "drizzle-orm"; -import type { DB } from "@intx/db"; -import { workflowDefinition, workflowDefinitionVersion } from "@intx/db/schema"; - -import { scheduleCronFromProjection } from "./from-projection"; - -export type ScheduledWorkflowDefinition = { - definitionId: string; - assetId: string; - name: string; - tenantId: string; - status: "deployed" | "stopped"; - cron: string; - createdAt: Date; - updatedAt: Date; -}; - -export type ScheduledWorkflowDefinitionRow = { - definitionId: string; - tenantId: string; - assetId: string | null; - name: string; - status: "deployed" | "stopped"; - createdAt: Date; - updatedAt: Date; - wireProjection: unknown; -}; - -/** - * Keep authored rows whose frozen projection has a valid ScheduleTrigger - * cron, including `stopped`. Exported so a test can prove the page list - * includes paused schedules without a live Postgres. - */ -export function scheduledDefinitionsFromRows( - rows: readonly ScheduledWorkflowDefinitionRow[], -): ScheduledWorkflowDefinition[] { - const scheduled: ScheduledWorkflowDefinition[] = []; - for (const row of rows) { - if (row.assetId === null) continue; - const cron = scheduleCronFromProjection(row.wireProjection); - if (cron === undefined) continue; - scheduled.push({ - definitionId: row.definitionId, - assetId: row.assetId, - name: row.name, - tenantId: row.tenantId, - status: row.status, - cron, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }); - } - return scheduled; -} - -export async function listScheduledWorkflowDefinitions( - db: DB["db"], - tenantId: string, -): Promise { - const rows = await db - .select({ - definitionId: workflowDefinition.id, - tenantId: workflowDefinition.tenantId, - assetId: workflowDefinition.assetId, - name: workflowDefinition.name, - status: workflowDefinition.status, - createdAt: workflowDefinition.createdAt, - updatedAt: workflowDefinition.updatedAt, - wireProjection: workflowDefinitionVersion.wireProjection, - }) - .from(workflowDefinition) - .innerJoin( - workflowDefinitionVersion, - and( - eq(workflowDefinitionVersion.definitionId, workflowDefinition.id), - eq(workflowDefinitionVersion.version, workflowDefinition.currentVersion), - ), - ) - .where( - and(eq(workflowDefinition.tenantId, tenantId), eq(workflowDefinition.origin, "authored")), - ); - - return scheduledDefinitionsFromRows(rows); -} diff --git a/packages/workflows/tsconfig.json b/packages/workflows/tsconfig.json index d7611c1229..54fd3da3be 100644 --- a/packages/workflows/tsconfig.json +++ b/packages/workflows/tsconfig.json @@ -1,8 +1,38 @@ { - "extends": "../../tsconfig.base.json", - "include": ["src", "test"], + "extends": "./tsconfig.src.json", "compilerOptions": { - "types": ["bun"], - "noEmit": true - } + "composite": false, + "noEmit": true, + "disableSourceOfProjectReferenceRedirect": true, + "declaration": false, + "declarationMap": false, + "emitDeclarationOnly": false, + "tsBuildInfoFile": "tsconfig.tsbuildinfo", + "rootDir": "../.." + }, + "include": ["src", "test"], + "exclude": [], + "references": [ + { + "path": "../../vendor/intx/db/tsconfig.src.json" + }, + { + "path": "../../vendor/intx/hub-api/tsconfig.src.json" + }, + { + "path": "../../vendor/intx/hub-sessions/tsconfig.src.json" + }, + { + "path": "../../vendor/intx/types/tsconfig.src.json" + }, + { + "path": "../../vendor/intx/workflow-deploy/tsconfig.src.json" + }, + { + "path": "../error-sink/tsconfig.src.json" + }, + { + "path": "../workflow-schedule/tsconfig.src.json" + } + ] } diff --git a/packages/workflows/tsconfig.src.json b/packages/workflows/tsconfig.src.json new file mode 100644 index 0000000000..93fe7de3e0 --- /dev/null +++ b/packages/workflows/tsconfig.src.json @@ -0,0 +1,36 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src", "package.json", "src/**/*.json"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], + "compilerOptions": { + "types": ["bun"], + "noEmit": true, + "composite": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" + }, + "references": [ + { + "path": "../../vendor/intx/db/tsconfig.src.json" + }, + { + "path": "../../vendor/intx/hub-api/tsconfig.src.json" + }, + { + "path": "../../vendor/intx/hub-sessions/tsconfig.src.json" + }, + { + "path": "../../vendor/intx/types/tsconfig.src.json" + }, + { + "path": "../../vendor/intx/workflow-deploy/tsconfig.src.json" + }, + { + "path": "../error-sink/tsconfig.src.json" + }, + { + "path": "../workflow-schedule/tsconfig.src.json" + } + ] +} diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index 9fd18f70d7..73f984fe5f 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -14,19 +14,19 @@ # drift: editing a vendored tree means updating this hash and the # package's VENDORED-FROM delta line in the same change. apps/sidecar | sawyer | 2026-11-03 -vendor/intx/agent | sawyer | 2026-11-03 | 9dd132fda8d7aef9d70395de2d289abeb0361416b18a5079f219372df69d81a0 -vendor/intx/db | sawyer | 2026-11-03 | bfb556bd624b590c364e11f1495a6e8c14d6d9cce7fef02917e79ec5a8372808 +vendor/intx/agent | sawyer | 2026-11-03 | 6aa9a129b7bd9fad2071889dbcbe9d4693f77d2ede90199fefb08bd5e2abd52f +vendor/intx/db | sawyer | 2026-11-03 | 056628aa03bda8c05171a616759649e35afcb11494cc081c90e18972f74eaa94 vendor/intx/harness | sawyer | 2026-11-03 | 399b4e8b3b18e8969e815427625e1bfc6746f31e791fb92cdbc2aaf2c5080dbf -vendor/intx/hub-agent | sawyer | 2026-11-03 | b0f3b0bd760b1a397e00ff47cce5b9701273bdb04e294748afe650c4d142aa5a -vendor/intx/hub-api | sawyer | 2026-11-03 | 90aa5cddef828d77b62adb63186dcf82b5465926cafea1ab6b64bca263031b57 +vendor/intx/hub-agent | sawyer | 2026-11-03 | f0d61f41ff5502df3aea0207b794ed84aa8f941c7914a9cbfd3ebdcf6b0fdcf4 +vendor/intx/hub-api | sawyer | 2026-11-03 | b8d78972b2f86a97aeba0c9392b1122356ede77a638c53502d3e37c15f8d0bca vendor/intx/hub-common | sawyer | 2026-11-03 | 2fe74684593083f422023b2d6b337e414b5b319c6bfe02c7fa157130d7162e41 -vendor/intx/hub-sessions | sawyer | 2026-11-03 | 69eb524e236b5c26d543551bd64cb8b3f8c50bc09c7e91b5ef6c41b9280b54e1 -vendor/intx/inference | sawyer | 2026-11-03 | 81e1b31947cd8e6cac4e336d21d8a87ba063b638e0ac4ada1b0f375ec6861bf5 +vendor/intx/hub-sessions | sawyer | 2026-11-03 | 903a55c58293e40111daadb58223dfd9bfbf0dfa2c0d5ebef2ef1ee73f21f305 +vendor/intx/inference | sawyer | 2026-11-03 | 4227655a064d1aaa953e31d352e9e2382fe0e5172d4fe267bf14a8bbe8a3a1b1 vendor/intx/mail-memory | sawyer | 2026-11-03 | e281498352adbc2face631744ce9a02cb838c60a6c318961022904f0fd05d192 -vendor/intx/mailbox | sawyer | 2026-11-03 | 926bfef1b60a4ba7e600252e9ad6e37c17d387a200f79c2cbc0b3ceb612000ea -vendor/intx/mime | sawyer | 2026-11-03 | ddf7e626172350c6a447890affc6c746b7938e04ee53af8f1d9c8cffd97d1954 -vendor/intx/types | sawyer | 2026-11-03 | f1c246c159318778082bb50de90477c46f54a38eef1f6d584ce9f16cd510c9da -vendor/intx/workflow | sawyer | 2026-11-03 | 8b5cb3f4085c2e400a0eeafe69caa695769a613a699a25f88f0c2886aa98a00b -vendor/intx/workflow-deploy | sawyer | 2026-11-03 | 72a4f931ba5c404a091dc46a7ed60f5b0530a63535f7cacacfe31f95c397e473 -vendor/intx/workflow-host | sawyer | 2026-11-03 | b9964010920cef377ba4be777c179d414c430c1709e665bbf8c5e4a413e249e9 +vendor/intx/mailbox | sawyer | 2026-11-03 | f0525d9facb17e68556f1032cac42c0bf51b403c0b6a31b2bc3320dbb11b8e41 +vendor/intx/mime | sawyer | 2026-11-03 | e8986e3a315bc7a202c2dae1affd4af46e2a76075c55e9cb123ac9a84fda61f7 +vendor/intx/types | sawyer | 2026-11-03 | 1fdd7cccab3b434fb77c4252a243b4634ee189f6ae63e239b61b0bfcd9c64f50 +vendor/intx/workflow | sawyer | 2026-11-03 | e158bcea0a934edeee47104c20e22e09795f80ee1fd1444702058381ff4c0a70 +vendor/intx/workflow-deploy | sawyer | 2026-11-03 | d1a47505548d4e551ea92296bf7d9f2fa8d2d0b495871686aafcbf687ba7e5dc +vendor/intx/workflow-host | sawyer | 2026-11-03 | 47bac48fd2fc078f1bca73adb144dcf3c713f7a1f1021e3ea00a11916feb650d diff --git a/tsconfig.build.json b/tsconfig.build.json index ff463cfea1..e33db8dc61 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -163,6 +163,9 @@ { "path": "./packages/workflow-schedule/tsconfig.src.json" }, + { + "path": "./packages/workflows/tsconfig.src.json" + }, { "path": "./packages/xai-provider/tsconfig.src.json" }, diff --git a/vendor/intx/agent/src/agent.ts b/vendor/intx/agent/src/agent.ts index a657469614..30d462ed92 100644 --- a/vendor/intx/agent/src/agent.ts +++ b/vendor/intx/agent/src/agent.ts @@ -55,7 +55,6 @@ import type { ErrorRecord } from "@intx/types/audit"; import type { ApprovalSnapshot, AssistantTurn, - BeforeToolExtension, BlobReader, ContextCommit, ContextStore, @@ -250,14 +249,6 @@ interface ResolvedTools { * standard `ToolBundle` contract. */ readonly bundles: readonly ToolBundle[]; - /** - * Before-tool extensions contributed by bundles via `ToolBundle.beforeToolExtension`, - * in factory order. Composed with the authz extension by `createReactorAssembly` - * (authz first, so policy denial/ask still takes precedence over a tool's own - * gate) so a tool package can suspend on its own gate without the reactor or - * any app-level wiring knowing the tool's name. - */ - readonly beforeToolExtensions: readonly BeforeToolExtension[]; } /** @@ -355,11 +346,7 @@ function resolveTools( }, }; - const beforeToolExtensions = constructed - .map((bundle) => bundle.beforeToolExtension) - .filter((ext): ext is BeforeToolExtension => ext !== undefined); - - return { definitions, runner, bundles: constructed, beforeToolExtensions }; + return { definitions, runner, bundles: constructed }; } function resolveDirector( @@ -712,9 +699,6 @@ export async function createAgent( auditStore, authorize, toolDefinitions: resolvedTools.definitions, - ...(resolvedTools.beforeToolExtensions.length > 0 - ? { beforeToolExtensions: [...resolvedTools.beforeToolExtensions] } - : {}), onShutdown: async () => { try { await flushErrors(); diff --git a/vendor/intx/agent/src/tool.ts b/vendor/intx/agent/src/tool.ts index 3cebe2450d..d4467b7c21 100644 --- a/vendor/intx/agent/src/tool.ts +++ b/vendor/intx/agent/src/tool.ts @@ -34,7 +34,6 @@ import type { GrantEffect } from "@intx/types"; import type { - BeforeToolExtension, ToolCall, ToolDefinition, ToolResult, @@ -128,15 +127,6 @@ export interface ToolBundle { readonly definitions: readonly ToolDefinition[]; run(call: ToolCall, signal: AbortSignal): Promise; dispose?(): Promise; - /** - * A before-tool extension this bundle contributes to the reactor, checked - * ahead of dispatch for every call regardless of which bundle owns the - * call's name (mirrors how the authz extension inspects every call). Use - * this when a tool's own semantics -- not a cross-cutting policy like - * authz -- require suspending on a gate before the tool ever runs. Absent - * for the common case of a bundle whose tools always answer synchronously. - */ - readonly beforeToolExtension?: BeforeToolExtension; } /** diff --git a/vendor/intx/db/src/client.ts b/vendor/intx/db/src/client.ts index cc6c0b5788..47a0760dbd 100644 --- a/vendor/intx/db/src/client.ts +++ b/vendor/intx/db/src/client.ts @@ -1,10 +1,40 @@ import { type } from "arktype"; +import type { ExtractTablesWithRelations } from "drizzle-orm"; +import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core"; import { drizzle } from "drizzle-orm/postgres-js"; import { DBConfig } from "./config"; import { createConnection } from "./connection"; import * as schema from "./schema"; +/** + * A drizzle database over the hub schema, typed against the driver-agnostic + * `PgDatabase` base rather than a single driver. `createDB` builds one over + * postgres-js; a caller can equally build one over pglite -- `drizzle(handle, + * { schema })` from `drizzle-orm/pglite`, with the schema re-exported from + * `@intx/db/schema` -- and pass it to the store factories. The required + * `$client` keeps a bare `PgTransaction` unassignable (a transaction carries no + * `$client`), so a parameter typed against this still rejects a tx where only a + * top-level database belongs. + */ +export type AnyPgDatabase = PgDatabase< + PgQueryResultHKT, + typeof schema, + ExtractTablesWithRelations +> & { $client: unknown }; + +/** + * The hub database handle: the database itself, plus `transaction` and + * `close`. `createDB` returns the postgres-js instantiation, but the handle is + * typed against `AnyPgDatabase` so the stores accept any pg driver -- a + * caller-built pglite database included. + */ +export interface DB { + db: AnyPgDatabase; + transaction: AnyPgDatabase["transaction"]; + close: () => Promise; +} + export function createDB(raw: unknown) { const config = DBConfig(raw); if (config instanceof type.errors) { @@ -21,7 +51,12 @@ export function createDB(raw: unknown) { }; } -export type DB = ReturnType; +// `createDB` returns the concrete postgres-js handle so its own callers keep +// the driver's precise result types (e.g. a typed `db.execute`). This assures +// at the definition site that the concrete handle still satisfies `DB`, the +// driver-agnostic contract the stores consume. +const _createDBReturnsDB: (raw: unknown) => DB = createDB; +void _createDBReturnsDB; /** * A handle that can execute queries: either the top-level `db` or a diff --git a/vendor/intx/db/src/index.ts b/vendor/intx/db/src/index.ts index f4d236aab7..ebfde66ba7 100644 --- a/vendor/intx/db/src/index.ts +++ b/vendor/intx/db/src/index.ts @@ -1,4 +1,9 @@ -export { createDB, type DB, type DBExecutor } from "./client"; +export { + createDB, + type AnyPgDatabase, + type DB, + type DBExecutor, +} from "./client"; export { pgErrorCode, PG_UNIQUE_VIOLATION, @@ -22,6 +27,13 @@ export { type PrincipalKeyStore, } from "./principal-key-store"; export { lookupLocalPrincipalSigner } from "./signer-identity"; +export { + resolveSenderKey, + resolveFrameSenderKey, + auditSenderKeys, + type SenderKeyResolution, + type SenderKeyAuditReport, +} from "./sender-key-resolver"; export { createApprovalStore, type ApprovalStore, @@ -63,6 +75,7 @@ export { type MarkSidecarAllocatedArgs, type MarkSidecarConnectionLostArgs, type MarkSidecarConnectionReadyArgs, + type MarkSidecarDestroyFailedArgs, type MarkSidecarReleasedArgs, type ParkSidecarReconciliationPolicy, type ScheduleSidecarAllocationRetryArgs, @@ -79,7 +92,6 @@ export { export { createWorkflowDefinitionStore, loadFrozenGrantSnapshot, - loadFrozenWireProjection, resolveDefinitionIdForAsset, type WorkflowDefinitionRollbackResult, type WorkflowDefinitionSelector, @@ -127,7 +139,6 @@ export { type ResolvedOffering, } from "./catalog-resolution"; export { - buildSource, resolveModelSources, resolveInferencePreferences, resolveInstanceModelSources, diff --git a/vendor/intx/db/src/migrate.test.ts b/vendor/intx/db/src/migrate.test.ts deleted file mode 100644 index 07b8f73aa9..0000000000 --- a/vendor/intx/db/src/migrate.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -// WORKBENCH DELTA (see VENDORED.md): a fresh replay of the shipped SQL, -// sorted by filename, must land upstream's newest migration and both of -// ours. DB-gated: skipped when DATABASE_URL is unset, matching the hub -// tests' convention. -import { afterAll, describe, expect, test } from "bun:test"; -import { userInfo } from "node:os"; -import postgres from "postgres"; - -import { dropSchema, runMigrations } from "./migrate"; - -const databaseUrl = process.env["DATABASE_URL"] ?? ""; -const describeIfDb = databaseUrl === "" ? describe.skip : describe; - -function configFromUrl(url: string) { - const parsed = new URL(url); - return { - host: parsed.hostname, - port: Number(parsed.port || 5432), - user: decodeURIComponent(parsed.username) || userInfo().username, - password: decodeURIComponent(parsed.password), - database: parsed.pathname.slice(1), - }; -} - -describeIfDb("runMigrations replays the shipped SQL into a fresh schema", () => { - const schema = `migrate_test_${Date.now().toString(36)}`; - const config = configFromUrl(databaseUrl); - afterAll(() => dropSchema(config, { schema })); - - test("lands upstream's approval run index and workbench columns", async () => { - await runMigrations(config, { schema }); - const sql = postgres({ ...config, max: 1, onnotice: () => undefined }); - try { - const columns = await sql<{ table_name: string; column_name: string }[]>` - SELECT table_name, column_name FROM information_schema.columns - WHERE table_schema = ${schema} - AND (table_name, column_name) IN ( - ('workflow_definition_version', 'wire_projection'), - ('workflow_definition', 'origin'), - ('workflow_definition', 'schedule_claimed_minute'))`; - // `schedule_claimed_minute` is dropped by 0094 (CL-8162): the replay - // must not leave it behind. - expect(columns.map((row) => row.column_name).sort()).toEqual([ - "origin", - "wire_projection", - ]); - const indexes = await sql<{ indexname: string }[]>` - SELECT indexname FROM pg_indexes - WHERE schemaname = ${schema} AND indexname = 'approval_run_idx'`; - expect(indexes).toHaveLength(1); - } finally { - await sql.end(); - } - }); -}); diff --git a/vendor/intx/db/src/migrations-journal.test.ts b/vendor/intx/db/src/migrations-journal.test.ts deleted file mode 100644 index 5337d0f265..0000000000 --- a/vendor/intx/db/src/migrations-journal.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -// WORKBENCH DELTA (see VENDORED.md): our two migrations ride behind -// upstream's in the drizzle-kit journal. Renumbering them at each re-pin -// is a hand edit, so pin the invariants drizzle-kit relies on: one journal -// entry per SQL file, in filename order. (`idx`/`when` are not asserted: -// upstream's own journal has an idx gap and non-monotonic timestamps.) -import { expect, test } from "bun:test"; -import { readdirSync, readFileSync } from "node:fs"; -import path from "node:path"; - -const MIGRATIONS_DIR = path.resolve(import.meta.dir, "..", "migrations"); - -test("every migration file has a journal entry in filename order", () => { - const files = readdirSync(MIGRATIONS_DIR) - .filter((file) => file.endsWith(".sql")) - .sort() - .map((file) => file.slice(0, -".sql".length)); - const journal = JSON.parse( - readFileSync(path.join(MIGRATIONS_DIR, "meta", "_journal.json"), "utf8"), - ) as { entries: { tag: string }[] }; - - expect(journal.entries.map((entry) => entry.tag)).toEqual(files); -}); diff --git a/vendor/intx/db/src/model-source-resolution.test.ts b/vendor/intx/db/src/model-source-resolution.test.ts deleted file mode 100644 index ff00fcea1c..0000000000 --- a/vendor/intx/db/src/model-source-resolution.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -// CL-7505 local delta: the serving-time refresh seam in `buildSource`. A -// provider-backed `oauth_token` credential is offered to the caller's -// `servingRefresh` hook before its secret is served: a hook that refreshed -// it in place re-reads the row and serves the fresh secret; a hook that -// reports the credential past saving skips the offering outright (never a -// dead secret in the material cell); an `api_key` credential never reaches -// the hook at all — its path is byte-for-byte what it was before the delta. -import { describe, expect, mock, test } from "bun:test"; -import type { CredentialCipher } from "@intx/types"; - -const resolvedRows: Record< - string, - { - id: string; - tenantId: string; - providerId: string; - principalId: string | null; - type: string; - status: string; - secret: string; - refreshSecret: string | null; - expiresAt: Date | null; - } -> = {}; - -mock.module("./credential-resolution", () => ({ - resolveCredentialById: async (_db: unknown, _tenantId: string, id: string) => - resolvedRows[id] ?? null, -})); - -mock.module("./parse-row", () => ({ - parseModelOfferingRow: (row: Record) => ({ - ...row, - capabilities: [], - quirks: (row.quirks as unknown) ?? null, - }), -})); - -import { buildSource, type ServingRefresh } from "./model-source-resolution"; - -const offering = { - offering: { - id: "off_1", - priority: 0, - capabilities: [], - }, - provider: { - id: "prov_1", - name: "test-provider", - plugin: "test-plugin", - baseURL: "https://inference.invalid", - credentialId: "cred_1", - walletId: null, - }, - model: { canonicalName: "test-model" }, -} as unknown as Parameters[2]; - -const cipher = { - decrypt: async (secret: string) => `plain:${secret}`, -} as unknown as CredentialCipher; - -function row(type: string) { - return { - id: "cred_1", - tenantId: "ten_1", - providerId: "prov_1", - principalId: null, - type, - status: "active", - secret: "enc:old", - refreshSecret: type === "oauth_token" ? "enc:refresh" : null, - expiresAt: type === "oauth_token" ? new Date(0) : null, - }; -} - -describe("buildSource servingRefresh seam (CL-7505)", () => { - test("an expiring oauth_token is refreshed and the fresh secret served", async () => { - resolvedRows["cred_1"] = row("oauth_token"); - const hook: ServingRefresh = async () => { - resolvedRows["cred_1"] = { - ...resolvedRows["cred_1"]!, - secret: "enc:new", - expiresAt: new Date(9_999), - }; - return { ok: true }; - }; - const built = await buildSource({} as never, "ten_1", offering, cipher, { - servingRefresh: hook, - }); - expect(built.ok).toBe(true); - if (built.ok) expect(built.material.secret).toBe("plain:enc:new"); - }); - - test("a credential the hook cannot save is skipped, never served", async () => { - resolvedRows["cred_1"] = row("oauth_token"); - const hook: ServingRefresh = async () => ({ - ok: false, - message: "refresh_token revoked", - }); - const built = await buildSource({} as never, "ten_1", offering, cipher, { - servingRefresh: hook, - }); - expect(built.ok).toBe(false); - if (!built.ok) { - expect(built.skip.reason).toBe("credential_needs_reauth"); - } - }); - - test("an api_key credential never reaches the hook and serves as before", async () => { - resolvedRows["cred_1"] = row("api_key"); - let hookCalls = 0; - const hook: ServingRefresh = async () => { - hookCalls += 1; - return { ok: true }; - }; - const built = await buildSource({} as never, "ten_1", offering, cipher, { - servingRefresh: hook, - }); - expect(hookCalls).toBe(0); - expect(built.ok).toBe(true); - if (built.ok) expect(built.material.secret).toBe("plain:enc:old"); - }); -}); - -// CL-7510 local delta: provider-name → adapter-registry-key dispatch. The -// catalog `plugin` column stays the wire-format id ("openai-responses" for -// the loopback-OAuth providers, "openai-compatible" for Ollama); only the -// launched InferenceSource.provider is rewritten to the key the sidecar's -// DEFAULT_ADAPTER_MANIFEST registers the custom factory under. -describe("buildSource adapter registry-key dispatch (CL-7510)", () => { - resolvedRows["cred_1"] = row("api_key"); - - test("a codex offering dispatches under the codex registry key with its quirks threaded", async () => { - const codexOffering = { - offering: { - id: "off_codex", - priority: 0, - capabilities: [], - quirks: { - productName: "Codex", - environmentTagName: "codex_environment", - }, - }, - provider: { - id: "prov_codex", - name: "codex", - plugin: "openai-responses", - baseURL: "https://chatgpt.com/backend-api", - credentialId: "cred_1", - walletId: null, - }, - model: { canonicalName: "gpt-5.5" }, - } as unknown as Parameters[2]; - - const built = await buildSource({} as never, "ten_1", codexOffering, cipher); - expect(built.ok).toBe(true); - if (built.ok) { - expect(built.source.provider).toBe("codex"); - expect(built.source.quirks).toEqual({ - productName: "Codex", - environmentTagName: "codex_environment", - }); - expect(built.source.baseURL).toBe("https://chatgpt.com/backend-api"); - } - }); - - test("an xai-oauth offering dispatches under the xai-oauth registry key", async () => { - const xaiOffering = { - offering: { id: "off_xai", priority: 0, capabilities: [] }, - provider: { - id: "prov_xai", - name: "xai-oauth", - plugin: "openai-responses", - baseURL: "https://cli-chat-proxy.grok.com/v1", - credentialId: "cred_1", - walletId: null, - }, - model: { canonicalName: "grok-4.6" }, - } as unknown as Parameters[2]; - - const built = await buildSource({} as never, "ten_1", xaiOffering, cipher); - expect(built.ok).toBe(true); - if (built.ok) expect(built.source.provider).toBe("xai-oauth"); - }); - - test("every other provider keeps its plugin as the dispatch key", async () => { - const built = await buildSource({} as never, "ten_1", offering, cipher); - expect(built.ok).toBe(true); - if (built.ok) { - expect(built.source.provider).toBe("test-plugin"); - expect(built.material.providerKey).toBe("test-plugin"); - } - }); -}); diff --git a/vendor/intx/db/src/model-source-resolution.ts b/vendor/intx/db/src/model-source-resolution.ts index 2370e5cffd..22bfa078a0 100644 --- a/vendor/intx/db/src/model-source-resolution.ts +++ b/vendor/intx/db/src/model-source-resolution.ts @@ -20,19 +20,6 @@ import { resolveCredentialById } from "./credential-resolution"; import { parseModelOfferingRow } from "./parse-row"; import { workflowDefinition } from "./schema/workflow-definitions"; -// CL-7510 local delta: providers whose serving adapter is a workbench-side -// custom factory registered under the provider's own name -// (`apps/sidecar/src/config.ts`'s DEFAULT_ADAPTER_MANIFEST). A catalog -// provider row named here launches under that registry key instead of its -// wire-format `plugin`, the same correction `withOllamaAdapterKey` used to -// make at the retired folded-runs launch seam. -const ADAPTER_REGISTRY_KEY_BY_PROVIDER_NAME: Readonly> = - { - ollama: "ollama", - codex: "codex", - "xai-oauth": "xai-oauth", - }; - /** * Why a single offering could not be turned into a launchable source. * `wallet_backed` providers are storable in the catalog but not launchable @@ -156,7 +143,7 @@ function applyPreference( return preference.mode === "pin" ? named : [...named, ...rest]; } -export async function buildSource( +async function buildSource( db: DB["db"], tenantId: string, resolved: ResolvedOffering, @@ -270,19 +257,11 @@ export async function buildSource( credentialAad(credential.id, "secret"), ); const parsed = parseModelOfferingRow(offering); - // CL-7510 local delta: adapter registry-key dispatch. The catalog - // `plugin` column stays the wire-format id; a provider whose custom - // factory is registered under its own name (the ollama precedent, - // `apps/sidecar/src/config.ts`'s DEFAULT_ADAPTER_MANIFEST — codex and - // xai-oauth ride the same mechanism) launches under that registry key, - // so an offering's `quirks` reaches the adapter that understands them - // instead of the built-in wire-format adapter. - const registryKey = ADAPTER_REGISTRY_KEY_BY_PROVIDER_NAME[provider.name] ?? provider.plugin; return { ok: true, source: { id: offering.id, - provider: registryKey, + provider: provider.plugin, baseURL: provider.baseURL, credentialId: provider.credentialId, model: model.canonicalName, @@ -291,7 +270,7 @@ export async function buildSource( }, material: { credentialId: provider.credentialId, - providerKey: registryKey, + providerKey: provider.plugin, origin: provider.baseURL, secret, }, diff --git a/vendor/intx/db/src/schema/sidecar-allocation.ts b/vendor/intx/db/src/schema/sidecar-allocation.ts index 1ba54a6042..fb1ead6d6a 100644 --- a/vendor/intx/db/src/schema/sidecar-allocation.ts +++ b/vendor/intx/db/src/schema/sidecar-allocation.ts @@ -58,7 +58,7 @@ export const sidecarAllocation = pgTable( uniqueIndex("sidecar_allocation_active_sidecar_idx") .on(t.sidecarId) .where( - sql`${t.status} in ('provisioning', 'allocated', 'replacing', 'releasing')`, + sql`${t.status} in ('provisioning', 'allocated', 'replacing', 'releasing', 'destroy_failed')`, ), index("sidecar_allocation_sidecar_idx").on(t.sidecarId), index("sidecar_allocation_reconciliation_idx") @@ -68,7 +68,7 @@ export const sidecarAllocation = pgTable( ), check( "sidecar_allocation_status_check", - sql`${t.status} in ('pending', 'provisioning', 'allocated', 'replacing', 'releasing', 'released', 'failed')`, + sql`${t.status} in ('pending', 'provisioning', 'allocated', 'replacing', 'releasing', 'destroy_failed', 'released', 'failed')`, ), check("sidecar_allocation_generation_check", sql`${t.generation} >= 0`), check( diff --git a/vendor/intx/db/src/schema/tenants.ts b/vendor/intx/db/src/schema/tenants.ts index b10f4f1d7d..61d53de377 100644 --- a/vendor/intx/db/src/schema/tenants.ts +++ b/vendor/intx/db/src/schema/tenants.ts @@ -1,3 +1,4 @@ +import { sql } from "drizzle-orm"; import { foreignKey, jsonb, @@ -5,6 +6,7 @@ import { text, timestamp, unique, + uniqueIndex, } from "drizzle-orm/pg-core"; export const tenant = pgTable( @@ -13,13 +15,22 @@ export const tenant = pgTable( id: text("id").primaryKey(), name: text("name").notNull(), slug: text("slug").notNull().unique(), - domain: text("domain").notNull().unique(), + // Unique on `lower(domain)`, not the raw column: an inbound mail `From` is + // lowercased when parsed, so a sender address must map to exactly one + // tenant. A case-sensitive unique would let case-variant domains coexist and + // let a case-variant registration shadow another tenant's senders. The + // creation boundary lowercases the domain; this index enforces it for every + // write path. + domain: text("domain").notNull(), parentId: text("parent_id"), config: jsonb("config"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }, - (t) => [foreignKey({ columns: [t.parentId], foreignColumns: [t.id] })], + (t) => [ + foreignKey({ columns: [t.parentId], foreignColumns: [t.id] }), + uniqueIndex("tenant_domain_lower_idx").on(sql`lower(${t.domain})`), + ], ); export const federationTrust = pgTable( diff --git a/vendor/intx/db/src/schema/workflow-definitions.ts b/vendor/intx/db/src/schema/workflow-definitions.ts index f93134c8f7..57cbdee38a 100644 --- a/vendor/intx/db/src/schema/workflow-definitions.ts +++ b/vendor/intx/db/src/schema/workflow-definitions.ts @@ -52,21 +52,6 @@ export const workflowDefinition = pgTable( wireHash: text("wire_hash"), name: text("name").notNull(), description: text("description"), - // WORKBENCH DELTA (see VENDORED.md): whether this row is a - // definition in its own right or the per-run record of one deploy. - // "authored" (the default: a definition someone deployed or the hub - // froze) is launch-authoritative — the row an agent's edits refreeze - // in place. "run" is the sibling a folded run's deploy ensures over - // that same asset under the wire hash of its per-run rendered bytes: - // a frozen deploy record, never a launch candidate. Without the - // distinction, resolution fell back to matching on `name` and every - // run's clone shadowed the agent's own definition (CL-6452). - origin: text("origin", { enum: ["authored", "run"] }) - .notNull() - .default("authored"), - // WORKBENCH DELTA (see VENDORED.md, CL-4455): last UTC minuteKey this - // definition's native ScheduleTrigger already claimed. Null means never. - // Not part of wire_hash. // Grant requirements manifest, resolved at launch into materialized grants. // Validated as GrantRequirement[] at parse time. grantRequirements: jsonb("grant_requirements"), @@ -125,18 +110,6 @@ export const workflowDefinitionVersion = pgTable( // parse time. Null before approval is a legitimate state, so the column // takes no NOT NULL constraint. grantSnapshot: jsonb("grant_snapshot"), - // WORKBENCH DELTA (see VENDORED.md): the inert wire projection the freeze - // hashed, stored beside the hash it is keyed to. Under the workflow.json - // retirement a definition's body is whatever its source closure evaluates - // to, and a source-format asset carries no envelope to read it back from -- - // so a launch that needs the body (a folded run reading its system prompt, - // tool pins, model, and credential bindings) has nowhere hub-side to get - // it. This column is that place: written in the same transaction as - // `approvedWireHash`, so a projection is never present without the hash - // that addresses it. Validated as `WorkflowProjectionDefinition` at read. - // Null before approval is a legitimate state, matching the two columns - // above. - wireProjection: jsonb("wire_projection"), createdAt: timestamp("created_at").notNull().defaultNow(), }, (t) => [ diff --git a/vendor/intx/db/src/schema/workflow-run-dispatch.ts b/vendor/intx/db/src/schema/workflow-run-dispatch.ts index 4cbca75d9c..f4738000a8 100644 --- a/vendor/intx/db/src/schema/workflow-run-dispatch.ts +++ b/vendor/intx/db/src/schema/workflow-run-dispatch.ts @@ -43,6 +43,7 @@ export const workflowRunDispatch = pgTable( .$type() .notNull() .default("mail"), + senderAddress: text("sender_address"), rawMessage: bytea("raw_message").notNull(), stepGrants: jsonb("step_grants").notNull(), status: text("status") @@ -97,5 +98,14 @@ export const workflowRunDispatch = pgTable( "workflow_run_dispatch_pending_schedule_check", sql`${t.status} <> 'pending' or ${t.nextAttemptAt} is not null`, ), + // A deliverable mail dispatch must carry the hub-verified sender that + // its inbound frame is stamped with; signals have no sender. Terminal + // (settled/failed) mail rows are exempt because they are never + // re-dispatched and so never reconstruct a frame -- this is what lets a + // migration fail legacy in-flight mail in place rather than delete it. + check( + "workflow_run_dispatch_mail_sender_check", + sql`${t.kind} <> 'mail' or ${t.status} in ('settled', 'failed') or ${t.senderAddress} is not null`, + ), ], ); diff --git a/vendor/intx/db/src/sender-key-resolver.ts b/vendor/intx/db/src/sender-key-resolver.ts new file mode 100644 index 0000000000..817cb74242 --- /dev/null +++ b/vendor/intx/db/src/sender-key-resolver.ts @@ -0,0 +1,251 @@ +import { and, eq, isNotNull, or, sql } from "drizzle-orm"; + +import { parseAddress } from "@intx/types"; +import { getLogger } from "@intx/log"; + +import type { DBExecutor } from "./client"; +import type { PrincipalKeyStore } from "./principal-key-store"; +import { principal } from "./schema/principals"; +import { tenant } from "./schema/tenants"; +import { workflowRun } from "./schema/workflow-run"; + +const RUN_PREFIX = "run_"; + +const logger = getLogger(["db", "sender-key-resolver"]); + +/** + * The durable public key that authenticates a signed mail sender, resolved from + * the hub's own storage. `source` records which store the key came from -- a + * run sender's key is the sidecar-minted key recorded on the deployment anchor, + * a user sender's key is the hub-custodied principal key -- so a consumer can + * bucket a resolution without re-parsing the address. + */ +export type SenderKeyResolution = + | { source: "run"; publicKey: string } + | { source: "user"; publicKey: string }; + +/** + * Resolve the durable public key a signed mail sender must verify against, + * unioning the two existing durable-key sources: a run address + * (`run_@`) resolves to the run's `workflow_run.public_key`; any + * other address (`@`) is treated as a user sender and resolves + * to that principal's hub-custodied key. + * + * Read-only. Returns `null` when the sender has no durable key to resolve -- a + * malformed address, an unknown run, a run whose deploy has not been acked yet, + * or an address that matches no user principal. A `null` is a legitimate + * "unresolvable sender" answer the caller acts on; it is NOT an error. + * + * Addresses are matched case-insensitively. Inbound `From` addresses are + * lowercased when parsed (see `@intx/mime` `extractAddrSpec`), so the address is + * normalized here and stored values are compared under `lower(...)`. Tenant + * domains are `lower(domain)`-unique (`tenant_domain_lower_idx`), so a + * normalized domain matches at most one tenant. A user `refId` is a + * case-sensitively-unique betterAuth id that can carry uppercase; lowercasing it + * to reconcile with the parser is lossy, so the resolver prefers the exact + * (already-lowercase) row and falls back to a case-insensitive match to find a + * mixed-case-stored refId. When two principals in one tenant hold case-variant + * refIds the case-insensitive match is not unique and no exact row disambiguates + * it: the resolver throws rather than silently return one, which would attribute + * the sender to the wrong principal. + */ +export async function resolveSenderKey( + db: DBExecutor, + principalKeyStore: PrincipalKeyStore, + address: string, +): Promise { + const normalized = address.toLowerCase(); + const parsed = parseAddress(normalized); + if (parsed === null) return null; + const { localPart, domain } = parsed; + + if (localPart.startsWith(RUN_PREFIX)) { + // A run sender resolves to the sidecar-minted key the hub recorded on the + // deployment anchor at `agent.deploy.ack`. `workflow_run.address` is unique + // among the runs that set it, so there is at most one row. A row whose + // `public_key` is still null is a deployed-but-not-yet-acked run -- the + // expected pre-ack state -- so it resolves to nothing rather than erroring, + // unlike the keyless-principal invariant break below. + const [row] = await db + .select({ publicKey: workflowRun.publicKey }) + .from(workflowRun) + .where(eq(sql`lower(${workflowRun.address})`, normalized)) + .limit(1); + if (row === undefined || row.publicKey === null) return null; + return { source: "run", publicKey: row.publicKey }; + } + + // A user sender resolves to its hub-custodied principal key, keyed by the + // `(tenant domain, user refId)` its From address carries. + const principalId = await resolveUserPrincipalId(db, domain, localPart); + if (principalId === null) return null; + // The principal exists; a principal with no active key violates INTR-164's + // invariant that every principal is minted with one, so `getPublicKey` throws + // rather than defaulting. Do not soften that to null -- unlike the pre-ack run + // key above, a keyless principal is a real breakage that must surface. + const publicKey = await principalKeyStore.getPublicKey(principalId, db); + return { source: "user", publicKey }; +} + +/** + * Resolve the hex-encoded public key to stamp on an outbound mail frame, as a + * BEST-EFFORT value that never blocks delivery. Returns the key, or `null` when + * the sender has no resolvable key -- OR when resolution FAILS. + * + * {@link resolveSenderKey} throws on a genuine fault (an ambiguous user address, + * or a principal with no active key -- an INTR-164 invariant break). Those + * throws must fail loud for {@link auditSenderKeys}, but they must not break + * the send path: the frame key is nullable, so a recipient with no co-delivered + * key resolves the sender as `unknown`, which its admission policy rejects by + * default (a workflow may relax `unknown` to admit). + * Coupling delivery to key resolution would let a data-integrity fault strand a + * run or drop mail. So a throw here degrades to `null` and is logged at ERROR + * with its cause -- a degraded fault, kept distinct from the ordinary + * unresolvable-sender `null`, which stays silent. + * + * The principal key store is real in production (INTR-164 mints every principal + * a key), so the throw path is a defensive safety net, not the common case: the + * normal outcome is a resolved, populated key. + */ +export async function resolveFrameSenderKey( + db: DBExecutor, + principalKeyStore: PrincipalKeyStore, + address: string, +): Promise { + try { + return ( + (await resolveSenderKey(db, principalKeyStore, address))?.publicKey ?? + null + ); + } catch (cause) { + logger.error`Degraded to a null frame sender key for ${address}: resolving its public key failed (a fault, not an unresolvable sender): ${cause instanceof Error ? cause.message : String(cause)}`; + return null; + } +} + +/** + * Resolve the user principal a normalized `@` address names, + * or `null` when none matches. `lower(domain)` is unique, so the domain selects + * at most one tenant; within it, prefer the principal whose `refId` is exactly + * the normalized localPart (the canonical lowercase refId). The domain is + * matched case-insensitively in both queries because an existing tenant's stored + * domain may be mixed-case (creation lowercases new ones, but legacy rows are + * left as stored). Only when no exact refId exists does it fall back to a + * case-insensitive refId match, which finds a mixed-case-stored refId. A + * non-unique case-insensitive match with no exact row means two principals in + * the tenant hold case-variant refIds; that is genuinely ambiguous, so it throws + * rather than return an arbitrary one and attribute the sender to the wrong + * principal. + */ +async function resolveUserPrincipalId( + db: DBExecutor, + domain: string, + localPart: string, +): Promise { + const [exact] = await db + .select({ principalId: principal.id }) + .from(principal) + .innerJoin(tenant, eq(principal.tenantId, tenant.id)) + .where( + and( + eq(sql`lower(${tenant.domain})`, domain), + eq(principal.kind, "user"), + eq(principal.refId, localPart), + ), + ) + .limit(1); + if (exact !== undefined) return exact.principalId; + + const caseInsensitive = await db + .select({ principalId: principal.id }) + .from(principal) + .innerJoin(tenant, eq(principal.tenantId, tenant.id)) + .where( + and( + eq(sql`lower(${tenant.domain})`, domain), + eq(principal.kind, "user"), + eq(sql`lower(${principal.refId})`, localPart), + ), + ) + .limit(2); + if (caseInsensitive.length === 0) return null; + if (caseInsensitive.length > 1) { + throw new Error( + `resolveSenderKey: user sender ${localPart}@${domain} is ambiguous; it ` + + `matches multiple principals with case-variant refIds and no canonical ` + + `row -- the colliding principal refIds must be reconciled`, + ); + } + const [only] = caseInsensitive; + return only === undefined ? null : only.principalId; +} + +/** + * The outcome of sweeping every sender that could sign a piece of mail: how many + * of each kind were checked, and any whose address did not resolve to a durable + * key. An empty `unresolved` is the "no unresolvable signed senders" state a + * durable-signature verifier depends on. + */ +export type SenderKeyAuditReport = { + runsChecked: number; + usersChecked: number; + unresolved: { address: string; kind: "run" | "user" }[]; +}; + +/** + * Sweep every mail sender that could sign and confirm each resolves to a durable + * public key via {@link resolveSenderKey}. Read-only. + * + * The senders that can sign are every workflow run that holds a signing key or + * is live ("running") and carries a sending address, plus every active user + * principal. A never-signed run -- deployed-but-not-yet-acked, or failed or + * cancelled before ack (address set, key null) -- is excluded. Any checked + * sender that does not resolve is collected in `unresolved`. A keyless user + * principal is an INTR-164 invariant break, so `resolveSenderKey` throws and the + * sweep fails loudly rather than folding it into `unresolved` -- it is a + * different, more serious fault than an unresolvable address. + */ +export async function auditSenderKeys( + db: DBExecutor, + principalKeyStore: PrincipalKeyStore, +): Promise { + const unresolved: { address: string; kind: "run" | "user" }[] = []; + + const runs = await db + .select({ address: workflowRun.address }) + .from(workflowRun) + .where( + and( + isNotNull(workflowRun.address), + // A run can only have signed if it holds a key, so every key-null row is + // a non-signer EXCEPT a live "running" run, which is the genuine + // can-sign-but-keyless hole to flag. This includes all key-bearing rows + // (any status -- a terminal run's in-flight mail is still verifiable) + // and excludes both the pre-ack "deployed" window and a run that failed + // or was cancelled before ack (address set, key null, never signed). + // Match "running" literally, NOT isLiveWorkflowRunStatus, which also + // admits "deployed" and would re-open the pre-ack false positive. + or(isNotNull(workflowRun.publicKey), eq(workflowRun.status, "running")), + ), + ); + for (const run of runs) { + if (run.address === null) continue; + if ((await resolveSenderKey(db, principalKeyStore, run.address)) === null) { + unresolved.push({ address: run.address, kind: "run" }); + } + } + + const users = await db + .select({ refId: principal.refId, domain: tenant.domain }) + .from(principal) + .innerJoin(tenant, eq(principal.tenantId, tenant.id)) + .where(and(eq(principal.kind, "user"), eq(principal.status, "active"))); + for (const user of users) { + const address = `${user.refId}@${user.domain}`; + if ((await resolveSenderKey(db, principalKeyStore, address)) === null) { + unresolved.push({ address, kind: "user" }); + } + } + + return { runsChecked: runs.length, usersChecked: users.length, unresolved }; +} diff --git a/vendor/intx/db/src/sidecar-allocation-store.ts b/vendor/intx/db/src/sidecar-allocation-store.ts index 2e9d0a2a71..9d2fb946b4 100644 --- a/vendor/intx/db/src/sidecar-allocation-store.ts +++ b/vendor/intx/db/src/sidecar-allocation-store.ts @@ -1,5 +1,16 @@ import { type } from "arktype"; -import { and, asc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm"; +import { + and, + asc, + eq, + gt, + inArray, + isNull, + lte, + notInArray, + or, + sql, +} from "drizzle-orm"; import { sidecarAllocationStatuses, @@ -77,6 +88,7 @@ export type CreateAdoptedSidecarAllocationArgs = }; export type ClaimSidecarAllocationArgs = { + readonly excludedAllocationIds?: readonly string[]; readonly leaseId: string; readonly leaseDurationMs: number; }; @@ -204,6 +216,11 @@ export type FailSidecarAllocationArgs = { readonly now?: Date; }; +export type MarkSidecarDestroyFailedArgs = Omit< + FailSidecarAllocationArgs, + "expectedStatus" +>; + function parseSidecarAllocationRow( row: SidecarAllocationRow, ): SidecarAllocation { @@ -251,7 +268,13 @@ function databaseTimestamp(override?: Date) { function leaseCondition(expectedLeaseId?: string) { return expectedLeaseId === undefined ? [] - : [eq(sidecarAllocation.reconciliationLeaseId, expectedLeaseId)]; + : [ + eq(sidecarAllocation.reconciliationLeaseId, expectedLeaseId), + gt( + sidecarAllocation.reconciliationLeaseExpiresAt, + sql`clock_timestamp()`, + ), + ]; } export function createSidecarAllocationStore(db: DBHandle) { @@ -451,7 +474,12 @@ export function createSidecarAllocationStore(db: DBHandle) { const [allocation] = await tx .select() .from(sidecarAllocation) - .where(eq(sidecarAllocation.id, args.allocationId)) + .where( + and( + eq(sidecarAllocation.id, args.allocationId), + ...leaseCondition(args.expectedLeaseId), + ), + ) .limit(1) .for("update"); if ( @@ -508,7 +536,12 @@ export function createSidecarAllocationStore(db: DBHandle) { const [allocation] = await tx .select() .from(sidecarAllocation) - .where(eq(sidecarAllocation.id, args.allocationId)) + .where( + and( + eq(sidecarAllocation.id, args.allocationId), + ...leaseCondition(args.expectedLeaseId), + ), + ) .limit(1) .for("update"); if ( @@ -717,6 +750,47 @@ export function createSidecarAllocationStore(db: DBHandle) { return updated === undefined ? null : parseSidecarAllocationRow(updated); }, + async markDestroyFailed( + args: MarkSidecarDestroyFailedArgs, + ): Promise { + const now = databaseTimestamp(args.now); + return db.transaction(async (tx) => { + const [updated] = await tx + .update(sidecarAllocation) + .set({ + status: "destroy_failed", + failureCode: args.code, + failureMessage: args.message, + nextAttemptAt: null, + reconciliationLeaseId: null, + reconciliationLeaseExpiresAt: null, + connectDeadline: null, + destroyAttempts: sql`${sidecarAllocation.destroyAttempts} + 1`, + updatedAt: now, + }) + .where( + and( + eq(sidecarAllocation.id, args.allocationId), + inArray(sidecarAllocation.status, ["replacing", "releasing"]), + eq(sidecarAllocation.generation, args.expectedGeneration), + ...leaseCondition(args.expectedLeaseId), + ), + ) + .returning(); + if (updated === undefined) return null; + + await failRunningRuns(tx, updated.anchorRunId, now); + await workflowRunDispatchStore.failUnsettled( + updated.anchorRunId, + args.code, + args.message, + now, + tx, + ); + return parseSidecarAllocationRow(updated); + }); + }, + async failWithoutInfrastructure( args: FailSidecarAllocationArgs, tx?: DBExecutor, @@ -788,6 +862,14 @@ export function createSidecarAllocationStore(db: DBHandle) { and( inArray(sidecarAllocation.status, activeStatuses), lte(sidecarAllocation.nextAttemptAt, sql`now()`), + ...(args.excludedAllocationIds !== undefined && + args.excludedAllocationIds.length > 0 + ? [ + notInArray(sidecarAllocation.id, [ + ...args.excludedAllocationIds, + ]), + ] + : []), or( isNull(sidecarAllocation.reconciliationLeaseExpiresAt), lte(sidecarAllocation.reconciliationLeaseExpiresAt, sql`now()`), @@ -823,18 +905,37 @@ export function createSidecarAllocationStore(db: DBHandle) { const [updated] = await db .update(sidecarAllocation) .set({ - reconciliationLeaseExpiresAt: sql`now() + (${leaseDurationMs} * interval '1 millisecond')`, + reconciliationLeaseExpiresAt: sql`clock_timestamp() + (${leaseDurationMs} * interval '1 millisecond')`, }) .where( and( eq(sidecarAllocation.id, allocationId), - eq(sidecarAllocation.reconciliationLeaseId, leaseId), + ...leaseCondition(leaseId), ), ) .returning({ id: sidecarAllocation.id }); return updated !== undefined; }, + async isReconciliationLeaseCurrent( + allocationId: string, + generation: number, + leaseId: string, + ): Promise { + const [allocation] = await db + .select({ id: sidecarAllocation.id }) + .from(sidecarAllocation) + .where( + and( + eq(sidecarAllocation.id, allocationId), + eq(sidecarAllocation.generation, generation), + ...leaseCondition(leaseId), + ), + ) + .limit(1); + return allocation !== undefined; + }, + async markConnectionReady( args: MarkSidecarConnectionReadyArgs, ): Promise { @@ -942,7 +1043,7 @@ export function createSidecarAllocationStore(db: DBHandle) { .where( and( eq(sidecarAllocation.id, allocationId), - eq(sidecarAllocation.reconciliationLeaseId, leaseId), + ...leaseCondition(leaseId), ), ) .returning({ id: sidecarAllocation.id }); @@ -955,7 +1056,9 @@ export function createSidecarAllocationStore(db: DBHandle) { ): Promise { const [updated] = await db .update(sidecarAllocation) - .set({ nextAttemptAt: sql`now()` }) + .set({ + nextAttemptAt: sql`now()`, + }) .where( and( eq(sidecarAllocation.id, allocationId), diff --git a/vendor/intx/db/src/tolerate-migration.test.ts b/vendor/intx/db/src/tolerate-migration.test.ts deleted file mode 100644 index d2abc16538..0000000000 --- a/vendor/intx/db/src/tolerate-migration.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -// WORKBENCH DELTA (see VENDORED.md): migration 0091 rewrites the retired -// `onBodyFailure: "continue"` literal to upstream's `"tolerate"` inside -// frozen wire projections. Exercised against a scratch table carrying the -// column the statement touches. DB-gated: skipped when DATABASE_URL is unset. -import { afterAll, describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { userInfo } from "node:os"; -import path from "node:path"; -import postgres from "postgres"; - -const databaseUrl = process.env["DATABASE_URL"] ?? ""; -const describeIfDb = databaseUrl === "" ? describe.skip : describe; - -const MIGRATION = path.resolve( - import.meta.dir, - "..", - "migrations", - "0091_workflow_definition_version_tolerate_body_failure.sql", -); - -describeIfDb("0091 rewrites onBodyFailure continue -> tolerate", () => { - const schema = `tolerate_test_${Date.now().toString(36)}`; - const parsed = new URL(databaseUrl); - const sql = postgres({ - host: parsed.hostname, - port: Number(parsed.port || 5432), - user: decodeURIComponent(parsed.username) || userInfo().username, - password: decodeURIComponent(parsed.password), - database: parsed.pathname.slice(1), - max: 1, - onnotice: () => undefined, - connection: { search_path: `"${schema}"` }, - }); - afterAll(async () => { - await sql.unsafe(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); - await sql.end(); - }); - - test("rewrites the literal at any depth and leaves other rows untouched", async () => { - await sql.unsafe(`CREATE SCHEMA "${schema}"`); - await sql.unsafe( - `CREATE TABLE "workflow_definition_version" (id text PRIMARY KEY, wire_projection jsonb)`, - ); - const nested = { - steps: { - section: { - kind: "onTrigger", - onBodyFailure: "continue", - body: { - inline: { - steps: { inner: { kind: "onTrigger", onBodyFailure: "continue" } }, - }, - }, - }, - }, - }; - const untouched = { steps: { s: { kind: "onTrigger", onBodyFailure: "end" } } }; - await sql`INSERT INTO "workflow_definition_version" VALUES - ('a', ${sql.json(nested)}), ('b', ${sql.json(untouched)}), ('c', NULL)`; - - for (const statement of readFileSync(MIGRATION, "utf8") - .split("--> statement-breakpoint") - .map((s) => s.trim()) - .filter((s) => s.length > 0)) { - await sql.unsafe(statement); - } - - const rows = await sql<{ id: string; wire_projection: unknown }[]>` - SELECT id, wire_projection FROM "workflow_definition_version" ORDER BY id`; - expect(JSON.stringify(rows[0]!.wire_projection)).not.toContain("continue"); - expect(rows[0]!.wire_projection).toEqual( - JSON.parse(JSON.stringify(nested).replaceAll('"continue"', '"tolerate"')), - ); - expect(rows[1]!.wire_projection).toEqual(untouched); - expect(rows[2]!.wire_projection).toBeNull(); - }); -}); diff --git a/vendor/intx/db/src/workflow-definition-store.ts b/vendor/intx/db/src/workflow-definition-store.ts index 00ce036d49..686ce8d4e9 100644 --- a/vendor/intx/db/src/workflow-definition-store.ts +++ b/vendor/intx/db/src/workflow-definition-store.ts @@ -1,7 +1,6 @@ import { and, eq } from "drizzle-orm"; import { GrantWalkSnapshot } from "@intx/types"; -import { WorkflowProjectionDefinition } from "@intx/types/sidecar"; import type { DB, DBExecutor } from "./client"; import { @@ -83,34 +82,6 @@ export async function loadFrozenGrantSnapshot( return GrantWalkSnapshot.assert(row.grantSnapshot); } -/** - * WORKBENCH DELTA (see VENDORED.md): read the inert wire projection frozen onto - * a definition's version row, validated at this boundary. Mirrors - * `loadFrozenGrantSnapshot` exactly — same version row, same null-means-not-yet- - * approved contract — because it is written by the same freeze transaction. - * Returns `null` when the version row is absent or its `wireProjection` column - * is still `null`; the caller fails closed with a named error, never a fallback - * read of a retired `workflow.json` envelope. - */ -export async function loadFrozenWireProjection( - db: DBExecutor, - definitionId: string, -): Promise { - const row = await db - .select({ wireProjection: workflowDefinitionVersion.wireProjection }) - .from(workflowDefinitionVersion) - .where( - and( - eq(workflowDefinitionVersion.definitionId, definitionId), - eq(workflowDefinitionVersion.version, FROZEN_VERSION), - ), - ) - .limit(1) - .then((rows) => rows[0]); - if (row === undefined || row.wireProjection === null) return null; - return WorkflowProjectionDefinition.assert(row.wireProjection); -} - export type WorkflowDefinitionRollbackResult = | { ok: true; definition: ParsedWorkflowDefinition } | { ok: false; reason: "definition_not_found" | "version_not_found" }; diff --git a/vendor/intx/db/src/workflow-probe-store.ts b/vendor/intx/db/src/workflow-probe-store.ts index 9f77cc111d..a968d9fab7 100644 --- a/vendor/intx/db/src/workflow-probe-store.ts +++ b/vendor/intx/db/src/workflow-probe-store.ts @@ -182,6 +182,14 @@ export function createWorkflowProbeStore(db: DBHandle) { return updated ?? null; }, + async get(probeId: string): Promise { + return ( + (await db.query.workflowProbe.findFirst({ + where: eq(workflowProbe.id, probeId), + })) ?? null + ); + }, + async listActive(): Promise { return db.query.workflowProbe.findMany({ where: inArray(workflowProbe.status, [...activeStatuses]), diff --git a/vendor/intx/db/src/workflow-run-dispatch-store.ts b/vendor/intx/db/src/workflow-run-dispatch-store.ts index 1a7bd2be37..0d0e058b00 100644 --- a/vendor/intx/db/src/workflow-run-dispatch-store.ts +++ b/vendor/intx/db/src/workflow-run-dispatch-store.ts @@ -30,6 +30,7 @@ export type EnqueueWorkflowRunDispatchArgs = { readonly id: string; readonly anchorRunId: string; readonly messageId: string; + readonly senderAddress: string; readonly rawMessage: Uint8Array; readonly stepGrants: RunGrantsFrameType["stepGrants"]; readonly now?: Date; @@ -53,6 +54,7 @@ type InsertOrReconcileDispatchArgs = { readonly anchorRunId: string; readonly messageId: string; readonly kind: ParsedDispatch["kind"]; + readonly senderAddress: string | null; readonly rawMessage: Uint8Array; readonly stepGrants: RunGrantsFrameType["stepGrants"]; readonly now?: Date; @@ -158,6 +160,7 @@ async function insertOrReconcileDispatch( anchorRunId: args.anchorRunId, messageId: args.messageId, kind: args.kind, + senderAddress: args.senderAddress, rawMessage: args.rawMessage, stepGrants: args.stepGrants, status: "pending", @@ -242,6 +245,7 @@ export function createWorkflowRunDispatchStore(db: DBHandle) { anchorRunId: args.anchorRunId, messageId: args.messageId, kind: "mail", + senderAddress: args.senderAddress, rawMessage: args.rawMessage, stepGrants, ...(args.now !== undefined ? { now: args.now } : {}), @@ -263,6 +267,7 @@ export function createWorkflowRunDispatchStore(db: DBHandle) { anchorRunId: args.anchorRunId, messageId: signal.signalId, kind: "signal", + senderAddress: null, rawMessage: encoded, stepGrants: [], ...(args.now !== undefined ? { now: args.now } : {}), diff --git a/vendor/intx/hub-agent/package.json b/vendor/intx/hub-agent/package.json index 786cf74ac7..ee9dca0860 100644 --- a/vendor/intx/hub-agent/package.json +++ b/vendor/intx/hub-agent/package.json @@ -22,6 +22,7 @@ "@intx/harness": "workspace:*", "@intx/log": "0.3.0", "@intx/mail-memory": "workspace:*", + "@intx/mailbox": "workspace:*", "@intx/pack-transport": "0.3.0", "@intx/storage-isogit": "0.3.0", "@intx/types": "workspace:*", diff --git a/vendor/intx/hub-agent/src/index.ts b/vendor/intx/hub-agent/src/index.ts index a2b298ec01..1ec88a3839 100644 --- a/vendor/intx/hub-agent/src/index.ts +++ b/vendor/intx/hub-agent/src/index.ts @@ -8,6 +8,25 @@ export { type AgentKeyStore, type AgentKeyStoreDeps, } from "./agent-key-store"; +export { + createSenderKeyCache, + type SenderKeyCache, + type SenderKeyCacheDeps, +} from "./sender-key-cache"; +export { + createPublicKeyCrypto, + createSenderCryptoResolver, +} from "./sender-crypto"; +export { + resolveInboundMailPolicy, + type ResolvedInboundMailPolicy, +} from "./ws/inbound-signature"; +export { + createInboundMailPolicyRegistry, + createInboundMailPolicyLookup, + FULLY_CLOSED_INBOUND_MAIL_POLICY, + type InboundMailPolicyRegistry, +} from "./ws/inbound-mail-policy-registry"; export type { HarnessBuilder } from "./harness-builder"; export { createSessionManager, diff --git a/vendor/intx/hub-agent/src/sender-crypto.ts b/vendor/intx/hub-agent/src/sender-crypto.ts new file mode 100644 index 0000000000..1e0c9b5bfb --- /dev/null +++ b/vendor/intx/hub-agent/src/sender-crypto.ts @@ -0,0 +1,58 @@ +// Read side of the sender-key cache: resolve a sender address to the crypto +// the inbound-mail verify seam consumes. +// +// The mailbox verify seam is typed as `(address) => CryptoProvider | undefined` +// (`fetchFull`'s `getCrypto`), and it uses only the provider's public key -- +// it reads `getPublicKey()` and hands the bytes to `verifyMimeSignature`. The +// cache holds a foreign sender's PUBLIC key alone, so the provider it wraps can +// answer `getPublicKey()` but cannot sign or verify with a private key it does +// not have. Those methods throw rather than return a wrong or empty answer. + +import type { CryptoProvider } from "@intx/types/runtime"; + +import type { SenderKeyCache } from "./sender-key-cache"; + +const NO_PRIVATE_KEY = + "public-key-only crypto holds no private key; verify an inbound signature " + + "with verifyMimeSignature over getPublicKey() bytes"; + +/** + * Wrap a raw Ed25519 public key as a `CryptoProvider` that can only report the + * key. `getPublicKey` returns the bytes; `sign`, `signSSH`, and `verify` throw, + * because none of them can be answered from a public key alone. This is the + * shape the mailbox verify seam expects for a sender whose key the hub vouches + * for but whose private key this process never holds. + */ +export function createPublicKeyCrypto(publicKey: Uint8Array): CryptoProvider { + return { + getPublicKey() { + return publicKey; + }, + async sign() { + throw new Error(NO_PRIVATE_KEY); + }, + async signSSH() { + throw new Error(NO_PRIVATE_KEY); + }, + async verify() { + throw new Error(NO_PRIVATE_KEY); + }, + }; +} + +/** + * Build a resolver that maps a sender address to a public-key-only + * `CryptoProvider`, or `undefined` when the cache holds no key for it. The + * signature matches the mailbox verify seam's `getCrypto`, so the resolver is a + * drop-in source of the sender's verification key. `undefined` is the seam's + * defined "no key for this address" sentinel, not a swallowed failure. + */ +export function createSenderCryptoResolver( + cache: SenderKeyCache, +): (address: string) => CryptoProvider | undefined { + return (address) => { + const publicKey = cache.get(address); + if (publicKey === undefined) return undefined; + return createPublicKeyCrypto(publicKey); + }; +} diff --git a/vendor/intx/hub-agent/src/sender-key-cache.ts b/vendor/intx/hub-agent/src/sender-key-cache.ts new file mode 100644 index 0000000000..224652981c --- /dev/null +++ b/vendor/intx/hub-agent/src/sender-key-cache.ts @@ -0,0 +1,210 @@ +// Local cache of the public keys the hub vouches for, keyed by sender +// address. +// +// A recipient sidecar verifies an inbound mail signature against the key +// the hub resolved for the message's authenticated sender. The hub +// co-delivers that key on the `run.grants` barrier (see the +// `senderIdentities` field on `RunGrantsFrame`); this cache is where the +// sidecar retains it so verification works locally and keeps working +// while the sidecar is disconnected from the hub. +// +// Peer to AgentKeyStore, but for a different custody role: AgentKeyStore +// holds this sidecar's OWN agents' key pairs, whereas this cache holds +// FOREIGN senders' public keys only. There is no private material here. +// +// The hub owns key freshness -- it re-pushes a rotated key on the next +// grant or reconnect -- so the cache has no TTL or generation. A second +// entry for an address overwrites the first; the latest push wins. +// Eviction is revocation-driven, not time-driven: the hub sends a +// `sender.key.evict` when it re-resolves a reported cached sender to no +// durable key (a deleted principal), and the cache durably removes it so a +// power loss cannot resurrect a key the hub no longer vouches for. +// +// The whole keyring is loaded into memory at construction, so `get` is a +// synchronous memory read on the inbound-verify path and a restart sees +// every previously-cached key. The on-disk filename is the hex-encoded +// address bytes: an injective, reversible handle, so two addresses never +// collide onto one file the way the lossy `sanitizeAddress` scheme would. + +import fsp from "node:fs/promises"; +import path from "node:path"; +import { type } from "arktype"; +import { getLogger } from "@intx/log"; +import { hasCode, hexDecode, hexEncode, isRunAddress } from "@intx/types"; + +const logger = getLogger(["interchange", "hub-agent", "sender-key-cache"]); + +// A raw Ed25519 public key is 32 bytes. +const ED25519_PUBLIC_KEY_BYTES = 32; + +const SENDER_KEYS_DIR_NAME = "sender-keys"; + +// The on-disk envelope carries the address alongside the key so a loaded +// file is self-describing and a filename/content mismatch is detectable. +const StoredSenderKey = type({ + address: "string", + publicKey: "string", +}); + +export type SenderKeyCacheDeps = { + dataDir: string; + /** + * Durably persist `contents` at `path` (atomic replace + fsync). Injected + * so the cache's write is at least as durable as the run-grants write it + * gates, without this package depending on the sidecar app that owns the + * durable-write primitive. + */ + writeFileDurable: (path: string, contents: string) => Promise; + /** + * Durably remove the file at `path` (unlink + parent-dir fsync). Injected + * from the same durable-primitive owner as `writeFileDurable`. A raw unlink + * can be resurrected by a power loss, re-staling the cache with a key the hub + * revoked -- the exact failure eviction exists to prevent -- so the removal + * must be as durable as the write it reverses. + */ + removeFileDurable: (path: string) => Promise; +}; + +export type SenderKeyCache = { + /** + * The cached public key for `address`, or `undefined` when none is known. + * A synchronous memory read: the keyring is loaded at construction and + * every `put` updates memory, so the map is authoritative for this + * single-process sidecar. + */ + get(address: string): Uint8Array | undefined; + /** + * Cache `publicKey` for `address`, persisting it durably before returning. + * Throws if the durable write fails, so a caller that gates a downstream + * durable write on this one can rely on "this key is on disk" once it + * resolves. + */ + put(address: string, publicKey: Uint8Array): Promise; + /** + * Remove the cached key for `address`, durably deleting its on-disk entry + * before dropping it from memory. Disk-first mirrors `put`'s write-then-set: + * the map is rebuilt from disk on construction, so if the durable removal + * fails and throws, both stores still hold the key (consistent, and the next + * reconnect retries) rather than a memory-evicted key resurrecting from disk + * on restart. A no-op for an address the cache does not hold. + */ + evict(address: string): Promise; + /** + * A snapshot of every address the cache currently holds a key for. Returns a + * fresh array, decoupled from the backing map, so a later `put` does not + * change an array a caller is still holding -- the reported set stays stable + * across an `await`. The cache reports addresses only; the key values stay + * inside because the refresh-on-reconnect flow re-resolves each current key + * hub-side rather than trusting the cached (possibly stale) one. + */ + addresses(): string[]; + /** + * The cached addresses whose key can rotate: `addresses()` minus run + * addresses. A run address resolves to the immutable + * `workflow_run.public_key`, so its cached key never changes; a user sender + * resolves to a hub principal key that can rotate. The reconnect reporter + * sends exactly this set so the hub re-resolves only the senders whose key + * could have changed while the sidecar was disconnected. This is a + * work-saving filter, not a trust boundary -- the hub resolves every reported + * address on its own. + */ + rotatableAddresses(): string[]; +}; + +export async function createSenderKeyCache( + deps: SenderKeyCacheDeps, +): Promise { + const { dataDir, writeFileDurable, removeFileDurable } = deps; + const dir = path.join(dataDir, SENDER_KEYS_DIR_NAME); + const keys = new Map(); + + function keyPath(address: string): string { + return path.join(dir, hexEncode(new TextEncoder().encode(address))); + } + + async function loadFromDisk(): Promise { + let entries: string[]; + try { + entries = await fsp.readdir(dir); + } catch (err: unknown) { + // A fresh sidecar has no keyring dir yet; that is an empty cache, not a + // fault. Any other failure (EACCES, EIO, ...) must surface rather than + // silently start with no cached keys. + if (hasCode(err) && err.code === "ENOENT") return; + throw err; + } + for (const entry of entries) { + const loaded = await loadEntry(entry); + if (loaded !== null) keys.set(loaded.address, loaded.publicKey); + } + } + + async function loadEntry( + entry: string, + ): Promise<{ address: string; publicKey: Uint8Array } | null> { + // A crashed atomic write can leave a `.tmp..` file; its + // name is not valid hex, so decoding the filename rejects it below. A + // corrupt or mismatched file is logged and skipped rather than failing the + // whole load -- one bad entry must not deny the sidecar its other keys. + try { + const filenameAddress = new TextDecoder().decode(hexDecode(entry)); + const raw = await fsp.readFile(path.join(dir, entry), "utf8"); + const parsed = StoredSenderKey(JSON.parse(raw)); + if (parsed instanceof type.errors) { + throw new Error(`envelope invalid: ${parsed.summary}`); + } + if (parsed.address !== filenameAddress) { + throw new Error( + `filename address ${filenameAddress} does not match envelope address ${parsed.address}`, + ); + } + const publicKey = hexDecode(parsed.publicKey); + if (publicKey.length !== ED25519_PUBLIC_KEY_BYTES) { + throw new Error( + `expected a ${ED25519_PUBLIC_KEY_BYTES}-byte key, got ${publicKey.length}`, + ); + } + return { address: parsed.address, publicKey }; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + logger.error`Skipping unreadable sender-key file ${entry}: ${message}`; + return null; + } + } + + function get(address: string): Uint8Array | undefined { + return keys.get(address); + } + + function addresses(): string[] { + return [...keys.keys()]; + } + + function rotatableAddresses(): string[] { + return addresses().filter((address) => !isRunAddress(address)); + } + + async function put(address: string, publicKey: Uint8Array): Promise { + if (publicKey.length !== ED25519_PUBLIC_KEY_BYTES) { + throw new Error( + `refusing to cache a ${publicKey.length}-byte key for ${address}; expected ${ED25519_PUBLIC_KEY_BYTES}`, + ); + } + const contents = JSON.stringify({ + address, + publicKey: hexEncode(publicKey), + }); + await fsp.mkdir(dir, { recursive: true }); + await writeFileDurable(keyPath(address), contents); + keys.set(address, publicKey); + } + + async function evict(address: string): Promise { + await removeFileDurable(keyPath(address)); + keys.delete(address); + } + + await loadFromDisk(); + + return { get, put, evict, addresses, rotatableAddresses }; +} diff --git a/vendor/intx/hub-agent/src/sidecar-orchestrator.ts b/vendor/intx/hub-agent/src/sidecar-orchestrator.ts index afce82b0a2..311b900db5 100644 --- a/vendor/intx/hub-agent/src/sidecar-orchestrator.ts +++ b/vendor/intx/hub-agent/src/sidecar-orchestrator.ts @@ -16,6 +16,7 @@ import type { HubTransport } from "@intx/mail-memory"; import type { SignalKind } from "@intx/types"; import type { ApprovalSnapshot, + CryptoProvider, InferenceEvent, KeyPair, } from "@intx/types/runtime"; @@ -38,6 +39,7 @@ import { type OAuthLoginExecutor, type ReconnectScheduler, } from "./ws/hub-link"; +import type { ResolvedInboundMailPolicy } from "./ws/inbound-signature"; const log = getLogger(["interchange", "hub-agent", "orchestrator"]); @@ -75,7 +77,6 @@ export type CreateDeployRouter = (deps: { agentAddress: string, event: InferenceEvent, sessionId: string | undefined, - childRunId?: string, ) => void; /** * Control-plane suspension sink the multi-step branch routes a @@ -102,6 +103,34 @@ export type SidecarOrchestratorConfig = { dataDir: string; transport: HubTransport; cryptoOps: SidecarCryptoOps; + /** + * Resolves a sender address to the crypto that verifies its inbound mail. + * The host builds this over the sidecar's sender-key cache and the + * orchestrator forwards it unchanged to `createHubLink`, where the inbound + * signature verify uses it. + */ + resolveSenderCrypto: (address: string) => CryptoProvider | undefined; + /** + * Resolves a recipient deployment address to its total inbound-mail + * admission policy. The host builds this over the sidecar's per-address + * policy registry and the orchestrator forwards it unchanged to + * `createHubLink`, where the `mail.inbound` seam enforces it. + */ + lookupInboundMailPolicy: (address: string) => ResolvedInboundMailPolicy; + /** + * Persists the hub-vouched public key for a sender address. The host builds + * it over the same sender-key cache as `resolveSenderCrypto` and the + * orchestrator forwards it unchanged to `createHubLink`, where an inbound + * `sender.key.refresh` frame drives it. + */ + cacheSenderKey: (address: string, publicKey: string) => Promise; + /** + * Durably removes a sender's cached key. The host builds it over the same + * sender-key cache as `cacheSenderKey` and the orchestrator forwards it + * unchanged to `createHubLink`, where an inbound `sender.key.evict` frame + * drives it. + */ + evictSenderKey: (address: string) => Promise; /** * Host-injected `DeployRouter` factory. The orchestrator calls it * once after `sessions` and `keyStore` are constructed; the @@ -171,10 +200,11 @@ export type SidecarOrchestratorConfig = { */ workflowProbeExecutor?: WorkflowProbeExecutor; /** - * Optional loopback-login executor (CL-7508). Forwarded unchanged to - * `createHubLink`, where it answers every inbound `oauth.login.start`; - * omitted, the link answers those requests with an error rather than - * hanging the hub. + * Optional loopback-login executor. The orchestrator forwards it + * unchanged to `createHubLink`, where it answers every inbound + * `oauth.login.start`. Production wires the sidecar host's local-machine + * PKCE login here; omitted, the link falls back to its rejecting + * placeholder so a login is answered with an error rather than hanging. */ oauthLoginExecutor?: OAuthLoginExecutor; /** @@ -185,6 +215,14 @@ export type SidecarOrchestratorConfig = { * `activeAddresses`; omitted, the link announces none. */ getWorkflowAddresses?: () => string[]; + /** + * Returns the rotatable (non-run) sender addresses this sidecar holds cached + * keys for. Forwarded to the hub link, which reports them on every + * (re)connect so the hub re-resolves and re-pushes each key. Production wires + * this to the sender-key cache's rotatable view; omitted, the link reports + * none. + */ + getCachedSenderAddresses?: () => string[]; /** * Invoked with the workflow-substrate addresses the link just announced in * an authenticated reconnect. Forwarded to the hub link so the workflow-run @@ -229,6 +267,10 @@ export function createSidecarOrchestrator( dataDir, transport, cryptoOps, + resolveSenderCrypto, + lookupInboundMailPolicy, + cacheSenderKey, + evictSenderKey, createDeployRouter, mailInboundRouter, signalInboundRouter, @@ -240,6 +282,7 @@ export function createSidecarOrchestrator( workflowProbeExecutor, oauthLoginExecutor, getWorkflowAddresses, + getCachedSenderAddresses, onWorkflowAddressesRoutable, onWorkflowAddressesUnroutable, pingIntervalMs, @@ -262,7 +305,6 @@ export function createSidecarOrchestrator( agentAddress: string, sessionId: string, event: InferenceEvent, - childRunId?: string, ) => void = () => { /* replaced after HubLink construction */ }; @@ -295,7 +337,7 @@ export function createSidecarOrchestrator( // is observed. A sessionless event is dropped rather than guessed // onto an arbitrary session -- the hub timeline is session-keyed and // a forged session id would mis-route the event. - publishWorkflowInferenceEvent: (agentAddress, event, sessionId, childRunId) => { + publishWorkflowInferenceEvent: (agentAddress, event, sessionId) => { if (sessionId === undefined) { log.warn( "Dropping workflow inference event for {agentAddress}: deploy carried no sessionId", @@ -303,7 +345,7 @@ export function createSidecarOrchestrator( ); return; } - dispatchEvent(agentAddress, sessionId, event, childRunId); + dispatchEvent(agentAddress, sessionId, event); }, // Route a supervisor's suspension registration up the hub-link so the // hub co-writes the parked run's routing + approval rows. @@ -321,6 +363,10 @@ export function createSidecarOrchestrator( transport, sessions, keyStore, + resolveSenderCrypto, + lookupInboundMailPolicy, + cacheSenderKey, + evictSenderKey, deployRouter, applyWorkflowRunPack, ...(mailInboundRouter !== undefined ? { mailInboundRouter } : {}), @@ -334,6 +380,9 @@ export function createSidecarOrchestrator( ...(workflowProbeExecutor !== undefined ? { workflowProbeExecutor } : {}), ...(oauthLoginExecutor !== undefined ? { oauthLoginExecutor } : {}), ...(getWorkflowAddresses !== undefined ? { getWorkflowAddresses } : {}), + ...(getCachedSenderAddresses !== undefined + ? { getCachedSenderAddresses } + : {}), ...(onWorkflowAddressesRoutable !== undefined ? { onWorkflowAddressesRoutable } : {}), diff --git a/vendor/intx/hub-agent/src/ws/hub-link-oauth-login.test.ts b/vendor/intx/hub-agent/src/ws/hub-link-oauth-login.test.ts deleted file mode 100644 index 14211912e7..0000000000 --- a/vendor/intx/hub-agent/src/ws/hub-link-oauth-login.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -// CL-7508 review F2b: the hub link must deliver an inbound -// `oauth.login.cancel` frame to the executor's cancel handle, and a -// subsequent `oauth.login.start` must re-invoke the executor — the -// frame-to-cancel half of the abandoned-login fix. This package previously -// had no colocated tests; this one runs the real link against a local -// Bun WebSocket server so no test infrastructure outside the package is -// needed. -import { afterAll, describe, expect, test } from "bun:test"; -import type { HubTransport } from "@intx/mail-memory"; -import type { SessionManager } from "../session-manager"; -import type { AgentKeyStore } from "../agent-key-store"; -import { createHubLink, type OAuthLoginExecutor } from "./hub-link"; - -function stubTransport(): HubTransport { - return { - register() { - return undefined; - }, - unregister() { - return undefined; - }, - getTransportFor() { - throw new Error("not used in this test"); - }, - setRemoteSendHandler() { - return undefined; - }, - addMessageSentHandler() { - return undefined; - }, - deliver() { - return undefined; - }, - }; -} - -const stubKeyStore: AgentKeyStore = { - loadOrGenerateKey: async () => { - throw new Error("not used in this test"); - }, - recordHubKey() { - return undefined; - }, - verifyDeployCommit: async () => false, - forgetAgent() { - return undefined; - }, -}; - -type ServerSocket = { send: (data: string) => void }; - -describe("hub-link oauth.login.cancel", () => { - let server: ReturnType; - - afterAll(() => { - server?.stop(true); - }); - - test("an inbound cancel frame reaches the executor and a later start re-invokes it", async () => { - const cancelled: string[] = []; - const started: string[] = []; - // The login the driver is currently asking for; the executor reads it - // because the wire frame alone does not carry which login the test is - // staging beyond its requestId (it does — this mirrors it for asserts). - let currentRequestId = ""; - - let serverSocket: ServerSocket | undefined; - const sockets = (): ServerSocket[] => - serverSocket === undefined ? [] : [serverSocket]; - server = Bun.serve({ - port: 0, - fetch(request, upgradeServer) { - if (upgradeServer.upgrade(request, { data: undefined })) { - return undefined; - } - return new Response("upgrade required", { status: 426 }); - }, - websocket: { - open(ws) { - serverSocket = ws as unknown as ServerSocket; - }, - message(ws, data) { - const frame = JSON.parse(String(data)) as { - type?: string; - requestId?: string; - }; - if (frame.type !== "oauth.login.start") return; - const requestId = frame.requestId ?? ""; - ws.send( - JSON.stringify({ - type: "oauth.login.result", - requestId, - outcome: { - status: "started", - authorizeUrl: "https://auth.example/authorize", - }, - }), - ); - }, - }, - }); - - const executor: OAuthLoginExecutor = (connectorId) => { - expect(connectorId).toBe("codex"); - const requestId = currentRequestId; - started.push(requestId); - return Promise.resolve({ - authorizeUrl: `https://auth.example/${requestId}`, - completed: new Promise(() => undefined), - cancel: () => { - cancelled.push(requestId); - }, - }); - }; - - const link = createHubLink({ - hubURL: `ws://localhost:${String(server.port)}`, - sidecarId: "sc-test", - token: "token", - transport: stubTransport(), - sessions: { - initRepo: async () => undefined, - } as unknown as SessionManager, - keyStore: stubKeyStore, - deployRouter: { - deploy: async () => ({ publicKey: "a".repeat(64) }), - }, - oauthLoginExecutor: executor, - pingIntervalMs: 60_000, - reconnectDelayMs: 60_000, - }); - link.connect(); - - // Give the socket time to open and the register handshake to land. - await new Promise((resolve) => setTimeout(resolve, 50)); - - async function driveLogin(requestId: string): Promise { - currentRequestId = requestId; - for (const socket of sockets()) { - socket.send( - JSON.stringify({ - type: "oauth.login.start", - requestId, - connectorId: "codex", - }), - ); - } - for (let i = 0; i < 100 && !startedRequests().includes(requestId); i++) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } - - function startedRequests(): string[] { - return [...started]; - } - - await driveLogin("req_1"); - expect(started).toEqual(["req_1"]); - expect(cancelled).toEqual([]); - - for (const socket of sockets()) { - socket.send( - JSON.stringify({ type: "oauth.login.cancel", requestId: "req_1" }), - ); - } - for (let i = 0; i < 100 && cancelled.length === 0; i++) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - expect(cancelled).toEqual(["req_1"]); - - await driveLogin("req_2"); - expect(started).toEqual(["req_1", "req_2"]); - - link.close(); - }); -}); diff --git a/vendor/intx/hub-agent/src/ws/hub-link.ts b/vendor/intx/hub-agent/src/ws/hub-link.ts index bd53dba914..573aaff3b9 100644 --- a/vendor/intx/hub-agent/src/ws/hub-link.ts +++ b/vendor/intx/hub-agent/src/ws/hub-link.ts @@ -11,6 +11,7 @@ import type { HubTransport } from "@intx/mail-memory"; import { type } from "arktype"; import { HubFrame, + MAX_MAIL_OUTBOUND_BODY_BYTES, type SidecarFrame, type RegisterFrame, type ReconnectFrame, @@ -26,6 +27,8 @@ import { RepoId, type SignalDeliverFrame, type RunGrantsFrame, + type SenderKeyRefreshFrame, + type SenderKeyEvictFrame, type SignalCorrelationRegisterFrame, type SignalCorrelationRegisterAckFrame, type DrainDeliverFrame, @@ -44,8 +47,17 @@ import { DEFAULT_REGISTER_ACK_MAX_ATTEMPTS, DEFAULT_REGISTER_ACK_TIMEOUT_MS, } from "./register-acker"; +import { + verifyInboundSignature, + outcomeForVerdict, + type ResolvedInboundMailPolicy, +} from "./inbound-signature"; import { base64Decode, base64Encode } from "@intx/types"; -import type { ApprovalSnapshot, InferenceEvent } from "@intx/types/runtime"; +import type { + ApprovalSnapshot, + CryptoProvider, + InferenceEvent, +} from "@intx/types/runtime"; import type { AgentKeyStore } from "../agent-key-store"; import type { SessionManager } from "../session-manager"; @@ -526,6 +538,46 @@ export type HubLinkConfig = { * it does not maintain its own copy of those tables. */ keyStore: AgentKeyStore; + /** + * Resolves a sender address to the crypto whose public key verifies that + * sender's inbound mail, or `undefined` when no key is known. The inbound + * signature verify uses it to check each frame's signature against the key a + * local cache holds, beside the check against the key the hub stamped on the + * frame. Source-opaque by design: the link never learns whether the key came + * from a local cache or a relayed foreign key. + */ + resolveSenderCrypto: (address: string) => CryptoProvider | undefined; + /** + * Resolves a recipient deployment address to the TOTAL inbound-mail + * admission policy the `mail.inbound` seam enforces for it. The host builds + * this over the sidecar's per-address policy registry: a hydrated deployment + * registers its resolved policy, and an address the registry does not hold + * resolves to a fully-closed policy that rejects every outcome. The seam + * indexes the returned map directly by the message's admission outcome, so + * this lookup owns the unknown-address default and the seam adds no fallback. + */ + lookupInboundMailPolicy: (address: string) => ResolvedInboundMailPolicy; + /** + * Persists the hub-vouched public key for a sender address, overwriting any + * previously cached key. The link calls it on an inbound `sender.key.refresh` + * frame, passing the frame's hex-encoded key through unchanged. Source-opaque, + * the write peer of `resolveSenderCrypto`: the host builds it over the + * sidecar's sender-key cache (decode the hex, `put` the bytes) so the link + * never touches key material or decoding. Required, not optional, because its + * read peer is required and every composition that runs the link already holds + * the same cache. + */ + cacheSenderKey: (address: string, publicKey: string) => Promise; + /** + * Durably removes the cached key for a sender address. The link calls it on + * an inbound `sender.key.evict` frame, when the hub has re-resolved a reported + * cached sender to no durable key (a deleted principal). The evicting peer of + * `cacheSenderKey`: the host builds it over the sidecar's sender-key cache + * (`evict` the address) so the link never touches the cache directly. Required + * for the same reason as `cacheSenderKey` -- every composition that can cache + * a key must be able to evict one, and both share the one cache. + */ + evictSenderKey: (address: string) => Promise; /** * Routes every inbound `agent.deploy` frame. Production wiring * supplies a router that stages each deploy through the workflow-run @@ -630,6 +682,19 @@ export type HubLinkConfig = { * substrate). */ getWorkflowAddresses?: () => string[]; + /** + * Returns the rotatable (non-run) sender addresses this sidecar holds cached + * keys for. Read at each (re)connect and reported on the register/reconnect + * frame so the Hub re-resolves and re-pushes each key, catching a rotation + * that landed while the sidecar was disconnected. + * + * Optional with an empty default, UNLIKE the required `cacheSenderKey`: this + * is a report-path reader whose empty result is a legitimate steady state (no + * cached senders, or a host with no sender substrate), the same shape as + * `getWorkflowAddresses`. `cacheSenderKey` sits on the receive path a sidecar + * must always be able to serve, so it is required; this one is not. + */ + getCachedSenderAddresses?: () => string[]; /** * Invoked after the allocation-authenticated reconnect frame is written. * The Hub serializes that frame ahead of later pack frames on the same @@ -728,6 +793,10 @@ export function createHubLink(config: HubLinkConfig): HubLink { transport, sessions, keyStore, + resolveSenderCrypto, + lookupInboundMailPolicy, + cacheSenderKey, + evictSenderKey, deployRouter, mailInboundRouter, signalInboundRouter, @@ -739,6 +808,7 @@ export function createHubLink(config: HubLinkConfig): HubLink { workflowProbeExecutor = defaultWorkflowProbeExecutor, oauthLoginExecutor = defaultOAuthLoginExecutor, getWorkflowAddresses = () => [], + getCachedSenderAddresses = () => [], onWorkflowAddressesRoutable, onWorkflowAddressesUnroutable, pingIntervalMs = DEFAULT_PING_INTERVAL_MS, @@ -868,6 +938,22 @@ export function createHubLink(config: HubLinkConfig): HubLink { // check so the hub can bind the claim to this authenticated connection. transport.setRemoteSendHandler( async (rawMessage, recipients, senderAddress) => { + // Derive the base64 payload length arithmetically (4 output characters + // per 3 input bytes, padded up) rather than materializing the encoding: + // an over-cap send is rejected before the encode allocates the ~59MB + // base64 string a 44MB body would produce, and the throw below stays + // byte-for-byte identical to the encoded-length check it replaces. + const bodyBytes = Math.ceil(rawMessage.byteLength / 3) * 4; + // Fail loud at the source: this send is awaited through the transport, so + // a throw surfaces to the producing agent as a real error rather than the + // silent hub-side drop it would otherwise get. The hub re-enforces the cap + // on receive -- that is the authoritative DoS backstop; this is the + // producer-facing error. + if (bodyBytes > MAX_MAIL_OUTBOUND_BODY_BYTES) { + throw new Error( + `refusing to send mail.outbound from ${senderAddress}: rawMessage of ${String(bodyBytes)} bytes exceeds the ${String(MAX_MAIL_OUTBOUND_BODY_BYTES)}-byte cap`, + ); + } const encoded = base64Encode(rawMessage); send({ type: "mail.outbound", @@ -883,6 +969,19 @@ export function createHubLink(config: HubLinkConfig): HubLink { // Remote sends are marked delivered: true as well — routing was already // handled by the RemoteSendHandler above. transport.addMessageSentHandler(async (ctx) => { + // Same arithmetic pre-check as the remote-send handler above: the audit + // forward skips an over-cap frame before the encode allocates the ~59MB + // base64 string, keeping the logged byte count identical. + const bodyBytes = Math.ceil(ctx.rawMessage.byteLength / 3) * 4; + // This is the post-delivery audit/projection forward; the mail already went + // out locally, so there is nothing left to fail. A throw here is swallowed + // (the transport runs message-sent handlers under Promise.allSettled), so + // an over-cap frame is logged loudly and skipped rather than thrown -- the + // hub would drop it on receive regardless. + if (bodyBytes > MAX_MAIL_OUTBOUND_BODY_BYTES) { + logger.error`Skipping delivered mail.outbound audit frame from ${ctx.senderAddress}: rawMessage of ${String(bodyBytes)} bytes exceeds the ${String(MAX_MAIL_OUTBOUND_BODY_BYTES)}-byte cap`; + return; + } const encoded = base64Encode(ctx.rawMessage); const sessionId = sessions.getSessionId(ctx.senderAddress); send({ @@ -1254,6 +1353,51 @@ export function createHubLink(config: HubLinkConfig): HubLink { } } + async function handleSenderKeyRefresh( + frame: SenderKeyRefreshFrame, + ): Promise { + // Address-keyed and cross-run: it caches one sender's current key and + // touches no run, so unlike `run.grants` a fault here has no run to poison. + // Swallow it after logging at ERROR -- there is no reply channel and the + // link must never wedge. A malformed key (bad hex, wrong length) is simply + // dropped. A transient cache-write fault is louder than it looks: the + // sidecar keeps the STALE key and verifies this sender's mail against it + // until the next reconnect re-pushes, so this push is best-effort, not + // delivery-guaranteed. + // + // Awaited inline on the message chain, NOT detached the way the + // `mail.inbound` durable write is: the co-resident `run.grants` handler also + // caches sender keys on this same chain, so serializing the refresh write + // keeps last-write-wins deterministic against a concurrent grants write for + // the same address. The fan-out is bounded (one frame per rotatable cached + // sender, once per reconnect), so the head-of-line cost stays far short of + // the heartbeat window; a detached write would trade that determinism for + // latency this low-volume path does not need. + try { + await cacheSenderKey(frame.address, frame.publicKey); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error`sender.key.refresh cache write failed for ${frame.address}: ${msg}`; + } + } + + async function handleSenderKeyEvict( + frame: SenderKeyEvictFrame, + ): Promise { + // Mirror of `handleSenderKeyRefresh` for the evict direction: address-keyed, + // cross-run, no reply channel, awaited inline on the message chain so it + // serializes deterministically against a concurrent refresh/grants write for + // the same address. A fault swallowed after logging at ERROR keeps the STALE + // key cached until the next reconnect re-evicts -- the evict is best-effort, + // not delivery-guaranteed, exactly like the refresh it complements. + try { + await evictSenderKey(frame.address); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error`sender.key.evict cache removal failed for ${frame.address}: ${msg}`; + } + } + async function handleSourcesUpdate(frame: SourcesUpdateFrame): Promise { // `sources.update` is request/ack (the hub awaits a reply within its // request timeout), so every path answers `session.ack` or @@ -1530,6 +1674,56 @@ export function createHubLink(config: HubLinkConfig): HubLink { switch (frame.type) { case "mail.inbound": { const rawBytes = base64Decode(frame.rawMessage); + // This ingress is the one place raw inbound bytes meet the hub-verified + // sender identity (`authenticatedSender` + its resolved key), and every + // producer -- relay, trigger, durable dispatch -- converges here, so the + // inbound-signature verify gates delivery here. Await it INLINE on the + // messageQueue chain: the verdict decides admission, so delivery must + // not race ahead of it. The verify is CPU-bound -- a synchronous cache + // read, an Ed25519 verify, and a MIME re-parse, with no I/O and no lock + // -- so it cannot hang and needs no timeout race around it. It never + // throws: a fault degrades to an `error` verdict. The in-process + // mail-memory transport (the standalone harness path) does not deliver + // through this seam and carries no hub-verified sender, so it is outside + // this path. + const verdict = await verifyInboundSignature( + { + raw: rawBytes, + authenticatedSender: frame.authenticatedSender, + messageId: frame.messageId, + agentAddress: frame.agentAddress, + }, + resolveSenderCrypto, + ); + const outcome = outcomeForVerdict(verdict); + // The recipient deployment's resolved admission policy. `frame. + // agentAddress` is the mail-router registration key, so an address + // with no registered deployment resolves to the fully-closed policy + // and rejects every outcome. The map is total, so index it directly. + // The seam admits ONLY an explicitly-admitted outcome and drops + // everything else: the fail direction is closed by construction, so a + // non-`admit` value (today only `reject`, but any future non-admit + // verdict too) drops rather than leaks through. + const policy = lookupInboundMailPolicy(frame.agentAddress); + if (policy[outcome] !== "admit") { + logger.warn( + "Rejecting inbound mail for {agentAddress}: outcome {outcome} is not admitted (authenticatedSender {authenticatedSender}, messageId {messageId})", + { + agentAddress: frame.agentAddress, + outcome, + authenticatedSender: frame.authenticatedSender, + messageId: frame.messageId ?? null, + }, + ); + // A rejected mail is simply not delivered -- the same drop as the + // no-handler path below: no ack, no reply frame, no dispatch-fail. + // The hub's redelivery and expiry machinery handles the rest. + break; + } + // Admitted: deliver exactly as an admitted frame always has, keeping + // the detached durable settlement and detached ack below off the + // messageQueue chain -- only the verify above is awaited inline. + // // Supervised deployments register the deployment-level mail // address on `mailInboundRouter` once their supervisor spawns; // that handler delivers the bytes to the supervisor's mail-bus @@ -1611,6 +1805,12 @@ export function createHubLink(config: HubLinkConfig): HubLink { case "run.grants": await handleRunGrants(frame); break; + case "sender.key.refresh": + await handleSenderKeyRefresh(frame); + break; + case "sender.key.evict": + await handleSenderKeyEvict(frame); + break; case "drain.deliver": await handleDrainDeliver(frame); break; @@ -1690,12 +1890,21 @@ export function createHubLink(config: HubLinkConfig): HubLink { // reconnect would expose a false empty inventory and let allocation // reconciliation restore Hub state over the live workflow. const restoredAddresses = getWorkflowAddresses(); + // Report the sidecar's cached rotatable senders on both frames: the + // register-vs-reconnect choice turns on workflow-address presence, so a + // sidecar with cached senders but no restored workflow substrate still + // announces them on a register frame. Omit the field when empty to honor + // its additive-optional wire shape. + const cachedSenderAddresses = getCachedSenderAddresses(); + const senderReport = + cachedSenderAddresses.length > 0 ? { cachedSenderAddresses } : {}; if (restoredAddresses.length === 0) { completeHandshake(connection, { type: "register", sidecarId, token, agentAddresses: [], + ...senderReport, }); } else { completeHandshake(connection, { @@ -1703,6 +1912,7 @@ export function createHubLink(config: HubLinkConfig): HubLink { sidecarId, token, agentAddresses: restoredAddresses, + ...senderReport, }); } }); diff --git a/vendor/intx/hub-agent/src/ws/inbound-mail-policy-registry.ts b/vendor/intx/hub-agent/src/ws/inbound-mail-policy-registry.ts new file mode 100644 index 0000000000..1498cc0e2d --- /dev/null +++ b/vendor/intx/hub-agent/src/ws/inbound-mail-policy-registry.ts @@ -0,0 +1,75 @@ +// Per-recipient-address registry of RESOLVED inbound-mail admission policies. +// +// The sidecar host resolves each hydrated deployment's authored +// `inboundMailPolicy` into a total `ResolvedInboundMailPolicy` ONCE, at the +// post-spawn registration window, and stores it here keyed by the deployment's +// mail address. The hub-link's `mail.inbound` seam reads it back to decide, per +// message, whether an inbound frame's admission outcome is admitted or rejected. +// +// The registry itself holds only what a live deployment registered. The +// fail-closed default for an address the registry does not hold lives in the +// lookup this module builds (`createInboundMailPolicyLookup`), so the seam sees +// a total policy for every address and never re-derives a default of its own. + +import type { ResolvedInboundMailPolicy } from "./inbound-signature"; + +/** + * The fully-closed resolved policy: EVERY inbound-mail outcome -- including + * `clean` -- maps to `reject`. It is the admission decision for an inbound frame + * whose recipient address has no registered policy: an address the sidecar + * never hydrated a deployment for, or already tore one down, has no author + * intent to honor, so it admits nothing. + * + * This is DISTINCT from `resolveInboundMailPolicy(undefined)`, which admits a + * `clean` message: that path has a live deployment whose author simply declared + * no policy, whereas this one has no deployment behind the address at all. + */ +export const FULLY_CLOSED_INBOUND_MAIL_POLICY: ResolvedInboundMailPolicy = { + clean: "reject", + error: "reject", + untrustedFrom: "reject", + invalid: "reject", + missing: "reject", + unknown: "reject", +}; + +/** + * Address-keyed store of resolved inbound-mail policies. The sidecar host + * `register`s a deployment's policy beside its mail-router registration and + * `unregister`s it in the same teardown, so a reused address never inherits a + * stale policy. `get` returns `undefined` for an unregistered address; the + * lookup built over this store maps that miss onto the fully-closed default. + */ +export type InboundMailPolicyRegistry = { + register(address: string, policy: ResolvedInboundMailPolicy): void; + unregister(address: string): void; + get(address: string): ResolvedInboundMailPolicy | undefined; +}; + +export function createInboundMailPolicyRegistry(): InboundMailPolicyRegistry { + const policies = new Map(); + return { + register(address, policy) { + policies.set(address, policy); + }, + unregister(address) { + policies.delete(address); + }, + get(address) { + return policies.get(address); + }, + }; +} + +/** + * Build the per-address lookup the hub-link seam consumes. It returns the + * registered policy for an address, or {@link FULLY_CLOSED_INBOUND_MAIL_POLICY} + * when the registry holds none. This is the single edge that owns the + * unknown-address default: the seam calls the lookup and indexes the returned + * total map directly, with no fallback of its own. + */ +export function createInboundMailPolicyLookup( + registry: InboundMailPolicyRegistry, +): (address: string) => ResolvedInboundMailPolicy { + return (address) => registry.get(address) ?? FULLY_CLOSED_INBOUND_MAIL_POLICY; +} diff --git a/vendor/intx/hub-agent/src/ws/inbound-signature.ts b/vendor/intx/hub-agent/src/ws/inbound-signature.ts new file mode 100644 index 0000000000..82fd8942a4 --- /dev/null +++ b/vendor/intx/hub-agent/src/ws/inbound-signature.ts @@ -0,0 +1,312 @@ +import { getLogger } from "@intx/log"; +import type { + CryptoProvider, + InboundMailOutcome, + InboundMailPolicy, +} from "@intx/types/runtime"; +import { verifyMimeSignature } from "@intx/mailbox"; +import { parseHeaderSection, extractAddrSpec } from "@intx/mime"; + +const logger = getLogger([ + "interchange", + "hub-agent", + "ws", + "inbound-signature", +]); + +/** + * The two-axis verdict of verifying one inbound mail frame. + * + * `signature` reuses the `SignatureStatus` vocabulary -- + * `valid | invalid | missing | unknown` -- with an added `error` for a fault in + * the verifier itself (verify throwing, an unparseable sender). It answers: does + * the message's detached signature verify against the key the recipient's local + * cache holds for `authenticatedSender`? `unknown` means the cache holds no key + * for the sender, so there is nothing to verify against. + * + * `fromMatch` is an orthogonal axis over the message's visible `From`, evaluated + * for EVERY non-error signature status (valid, invalid, missing, unknown): + * - `unchecked`: the message carries no `From`, OR it carries a parseable + * `From` but the signature is not `valid`, so a match is not meaningful. + * - `unparseable`: the message carries a `From` that cannot be reduced to one + * addr-spec. This is DISTINCT from `unchecked` (no `From` at all) -- a + * present but unparseable `From` is suspicious, not benign. + * - `match` / `mismatch`: only atop a VALID signature with a parseable `From`, + * whether that `From` binds to `authenticatedSender`. A valid signature over + * a `From` that names a different sender is an identity forgery -- a + * legitimate key signing under a borrowed display identity. + * + * The signature covers only the message's signed content part, NOT its + * top-level `From` header (see `@intx/mime` `assembleMessage`). The binding + * checked here is therefore the hub-stamped sender against the visible envelope + * `From`, not a `From` inside the signed bytes. + */ +export type InboundSignatureVerdict = { + signature: "valid" | "invalid" | "missing" | "unknown" | "error"; + fromMatch: "match" | "mismatch" | "unchecked" | "unparseable"; + authenticatedSender: string; + messageFrom: string | null; +}; + +/** + * Reduce a two-axis {@link InboundSignatureVerdict} to the single + * {@link InboundMailOutcome} a delivery decision keys on. + * + * The precedence is reject-dominant: when more than one axis is unhappy, the + * outcome is the one that most resists admission. Two outcomes dominate, for + * distinct reasons: + * - `error` outranks everything -- a fault stopped the check from running, so + * we could not check the message and can make no trust claim about it. + * - `untrustedFrom` outranks the signature axis -- a `From` we cannot trust + * (present but unparseable, or a valid signature worn under a mismatched + * identity) is a forgery signal that must not be masked by a merely + * unverifiable signature underneath it. + * + * `match`/`mismatch` only ever occur atop a `valid` signature (a guarantee of + * how the verdict is produced), while `unparseable` can accompany any non-error + * signature status; the ordering below reflects both facts. + */ +export function outcomeForVerdict( + verdict: InboundSignatureVerdict, +): InboundMailOutcome { + const { signature, fromMatch } = verdict; + if (signature === "error") return "error"; + if (fromMatch === "unparseable") return "untrustedFrom"; + if (signature === "valid" && fromMatch === "mismatch") return "untrustedFrom"; + if (signature === "invalid") return "invalid"; + if (signature === "missing") return "missing"; + if (signature === "unknown") return "unknown"; + return "clean"; +} + +/** + * A TOTAL admission decision map: for EVERY {@link InboundMailOutcome}, whether a + * message that resolved to that outcome is `reject`ed or `admit`ted. The per-mail + * delivery decision looks this up directly by the message's outcome with no + * fallback -- every key is present, so there is never an absent value for the + * lookup to default. + * + * This is the resolved counterpart of the SPARSE authored `InboundMailPolicy`: + * the sparse policy carries only what an author declared, and + * {@link resolveInboundMailPolicy} expands it into this total map ONCE. + */ +export type ResolvedInboundMailPolicy = Record< + InboundMailOutcome, + "reject" | "admit" +>; + +/** + * Resolve the SPARSE authored {@link InboundMailPolicy} into a TOTAL + * {@link ResolvedInboundMailPolicy}, applying every default HERE. This is the + * single place inbound-admission defaults live: the resolution runs once, and + * the per-mail delivery path looks up the resolved map directly -- it must never + * re-derive a default with a `?? "reject"` of its own. + * + * The two non-author-controllable outcomes are pinned regardless of what the + * author declared: + * - `clean` -> always `admit`: nothing about the message was suspect, so there + * is nothing to relax and no reason to reject. + * - `error` -> always `reject`: a fault stopped the check from running, so we + * could make no trust claim about the message. `error` is not even a key in + * {@link InboundMailPolicy}, so an authored policy cannot relax it -- a + * message we could not check through is never something an author waves past. + * + * The four author-controllable outcomes (`untrustedFrom`, `invalid`, `missing`, + * `unknown`) take the authored value where the author set that key, and default + * to `reject` otherwise. `reject` is the secure default: an outcome the author + * did not explicitly choose to admit stays rejected, so an omitted policy (or a + * policy that omits one outcome) fails closed rather than open. + */ +export function resolveInboundMailPolicy( + authored: InboundMailPolicy | undefined, +): ResolvedInboundMailPolicy { + return { + clean: "admit", + error: "reject", + untrustedFrom: authored?.untrustedFrom ?? "reject", + invalid: authored?.invalid ?? "reject", + missing: authored?.missing ?? "reject", + unknown: authored?.unknown ?? "reject", + }; +} + +export type InboundSignatureInput = { + raw: Uint8Array; + authenticatedSender: string; + messageId: string | undefined; + agentAddress: string; +}; + +/** + * Verify an inbound mail frame's signature against the key the recipient's + * local cache holds for `authenticatedSender` and LOG the verdict. The key is + * resolved through `resolveSenderCrypto` -- the cache-backed source populated by + * the hub's co-delivery on the run's grants barrier -- so the recipient verifies + * locally against the key the hub vouched for, never a key travelling on the + * message itself. + * + * This NEVER throws. A fault degrades to an `error` verdict, logged at ERROR + * and returned like any other verdict. The enforcement caller relies on this + * contract: it awaits this inline on the delivery path with no per-call catch, + * gates admission on the returned outcome, and drops the mail on a reject, so a + * throw that escaped here would wedge the delivery chain. Returns the verdict so + * the caller (or a test) can read it without scraping the log. + * + * A cache miss is a quiet `unknown` (an expected, benign state -- see below), + * not a fault. A genuine fault (the resolver throwing, the cached key being + * unreadable, or the verify throwing) degrades to an `error` verdict logged at + * ERROR -- surfaced loudly and kept distinct from `unknown`. + */ +export async function verifyInboundSignature( + input: InboundSignatureInput, + resolveSenderCrypto: (address: string) => CryptoProvider | undefined, +): Promise { + const { raw, authenticatedSender } = input; + + let verdict: InboundSignatureVerdict; + try { + // Resolve and read the cached key inside the try so ANY fault -- the + // resolver throwing, `getPublicKey` throwing, or the verify throwing -- + // is contained as a single `error` verdict rather than escaping. This is + // what keeps the "never throws" contract true. + const crypto = resolveSenderCrypto(authenticatedSender); + if (crypto === undefined) { + // Cache miss: the local keyring holds no key for this sender, so there + // is nothing to verify against. Expected for a sender whose key was + // never co-delivered (a run authorized before this shipped, relayed mail + // with no preceding grant co-delivery) or was unresolvable, or a + // rotation the cache has not yet refreshed. The mail is admitted as an + // unverifiable sender -- a quiet `unknown`, keyed by `authenticatedSender` + // in the log so the observation window stays legible. + verdict = { + signature: "unknown", + fromMatch: "unchecked", + authenticatedSender, + messageFrom: null, + }; + } else { + // The cached key is raw bytes, already validated 32-byte Ed25519 at cache + // write/load time, so it feeds `verifyMimeSignature` directly. + const signature = await verifyMimeSignature(raw, crypto.getPublicKey()); + verdict = { + signature, + fromMatch: "unchecked", + authenticatedSender, + messageFrom: null, + }; + } + // Evaluate the visible From for EVERY non-error status. It records + // `messageFrom` and, atop a VALID signature, resolves the `fromMatch` + // binding; a present-but-unparseable From is marked `unparseable` even + // under invalid/missing/unknown, so a later enforcement precedence can see + // it. `evaluateVisibleFrom` never throws, so it does not reach the `error` + // path below. + evaluateVisibleFrom(verdict, raw); + } catch (cause) { + logger.error( + "inbound mail signature verify FAULTED for {authenticatedSender} (messageId {messageId}, agentAddress {agentAddress}): {cause}", + { + // Carry the same `signature`/`fromMatch` keys the clean verdict logs, so + // a consumer counting the verdict corpus by `signature` sees faults too. + signature: "error", + fromMatch: "unchecked", + authenticatedSender, + messageId: input.messageId ?? null, + agentAddress: input.agentAddress, + cause: describeCause(cause), + }, + ); + return { + signature: "error", + fromMatch: "unchecked", + authenticatedSender, + messageFrom: null, + }; + } + + return logVerdict(verdict, input); +} + +/** + * Evaluate the message's visible `From` onto `verdict`, for any non-error + * signature status. Uses the tri-state of `readMessageFrom`: + * - no `From` (or empty): leaves the binding `unchecked`, `messageFrom` null. + * - `From` present but unparseable (`readMessageFrom` throws): marks the + * binding `unparseable` -- a present but malformed `From` is a distinct, + * suspicious state, kept separate from the benign no-`From` `unchecked`. + * - `From` present and parsed: records `messageFrom`, and ONLY atop a VALID + * signature compares it to `authenticatedSender` for `match`/`mismatch`. + * + * A stamped `authenticatedSender` that is not a bare addr-spec leaves the + * binding `unchecked` rather than clobbering the standing signature verdict -- + * the signature is the primary signal, and the check must not turn an unparseable + * header into a false verdict. + */ +function evaluateVisibleFrom( + verdict: InboundSignatureVerdict, + raw: Uint8Array, +): void { + let messageFrom: string | null; + try { + messageFrom = readMessageFrom(raw); + } catch (cause) { + // The message carries a `From` that `extractAddrSpec` refuses -- present + // but unparseable, distinct from no `From` at all. + verdict.fromMatch = "unparseable"; + logger.debug( + "inbound mail From-binding unparseable for {authenticatedSender}: {cause}", + { + authenticatedSender: verdict.authenticatedSender, + cause: describeCause(cause), + }, + ); + return; + } + if (messageFrom === null) return; + verdict.messageFrom = messageFrom; + // A From-binding is only meaningful atop a valid signature. + if (verdict.signature !== "valid") return; + try { + verdict.fromMatch = + messageFrom === extractAddrSpec(verdict.authenticatedSender) + ? "match" + : "mismatch"; + } catch (cause) { + logger.debug( + "inbound mail sender stamp {authenticatedSender} is not a parseable addr-spec; leaving From-binding unchecked: {cause}", + { + authenticatedSender: verdict.authenticatedSender, + cause: describeCause(cause), + }, + ); + } +} + +function readMessageFrom(raw: Uint8Array): string | null { + const { headers } = parseHeaderSection(raw); + const from = headers.get("from"); + if (from === undefined || from.trim() === "") return null; + return extractAddrSpec(from); +} + +function logVerdict( + verdict: InboundSignatureVerdict, + input: InboundSignatureInput, +): InboundSignatureVerdict { + logger.info( + "inbound mail signature verdict {signature}/{fromMatch} for {authenticatedSender} (from {messageFrom}, messageId {messageId}, agentAddress {agentAddress})", + { + signature: verdict.signature, + fromMatch: verdict.fromMatch, + authenticatedSender: verdict.authenticatedSender, + messageFrom: verdict.messageFrom, + messageId: input.messageId ?? null, + agentAddress: input.agentAddress, + }, + ); + return verdict; +} + +function describeCause(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} diff --git a/vendor/intx/hub-agent/tsconfig.json b/vendor/intx/hub-agent/tsconfig.json index c25053550a..14eedeec17 100644 --- a/vendor/intx/hub-agent/tsconfig.json +++ b/vendor/intx/hub-agent/tsconfig.json @@ -10,9 +10,7 @@ "tsBuildInfoFile": "tsconfig.tsbuildinfo", "rootDir": "../../.." }, - "include": [ - "src" - ], + "include": ["src"], "exclude": [], "references": [ { @@ -21,6 +19,9 @@ { "path": "../mail-memory/tsconfig.src.json" }, + { + "path": "../mailbox/tsconfig.src.json" + }, { "path": "../types/tsconfig.src.json" } diff --git a/vendor/intx/hub-agent/tsconfig.src.json b/vendor/intx/hub-agent/tsconfig.src.json index 769b380455..6a338ef0a9 100644 --- a/vendor/intx/hub-agent/tsconfig.src.json +++ b/vendor/intx/hub-agent/tsconfig.src.json @@ -1,18 +1,9 @@ { "extends": "../../../tsconfig.base.json", - "include": [ - "src/**/*.ts", - "package.json", - "src/**/*.json" - ], - "exclude": [ - "src/**/*.test.ts", - "src/**/*.test.tsx" - ], + "include": ["src/**/*.ts", "package.json", "src/**/*.json"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], "compilerOptions": { - "types": [ - "bun" - ], + "types": ["bun"], "composite": true, "emitDeclarationOnly": true, "outDir": "dist", @@ -25,6 +16,9 @@ { "path": "../mail-memory/tsconfig.src.json" }, + { + "path": "../mailbox/tsconfig.src.json" + }, { "path": "../types/tsconfig.src.json" } diff --git a/vendor/intx/hub-api/src/app.ts b/vendor/intx/hub-api/src/app.ts index 186aa8f2ba..4d71312891 100644 --- a/vendor/intx/hub-api/src/app.ts +++ b/vendor/intx/hub-api/src/app.ts @@ -295,12 +295,12 @@ export function mountHubRoutes( // the same endpoint. Must mount before `resolveTenant` so a bearer request // populates `principal`/`tenant` first and short-circuits the session // check; a request with no bearer credential falls through unchanged. - if (repoStore !== null && opts.workflowRunAuthenticator !== undefined) { + if (repoStore !== null && workflowRunAuthenticator !== undefined) { app.use( "/api/tenants/:tenantId/workflows/deployments", createWorkflowRunDeployAuth({ db, - authenticator: opts.workflowRunAuthenticator, + authenticator: workflowRunAuthenticator, }), ); } diff --git a/vendor/intx/hub-api/src/context.ts b/vendor/intx/hub-api/src/context.ts index 156dc7b765..6236464282 100644 --- a/vendor/intx/hub-api/src/context.ts +++ b/vendor/intx/hub-api/src/context.ts @@ -1,6 +1,7 @@ -import type { Env } from "hono"; +import type { Context, Env } from "hono"; import type { SessionInfo, SessionUser } from "./session"; +import { errorResponse } from "./error-response"; export type TenantRow = { id: string; @@ -36,3 +37,16 @@ export type TenantEnv = Env & { principal: PrincipalRow; }; }; + +/** + * The canonical 401 response for a request with no authenticated session + * user. Routes that read `c.get("user")` inline -- rather than mounting the + * `requireAuth` middleware -- return this so the "Authentication required" + * body lives in exactly one place instead of being re-encoded at every + * handler. The git smart-HTTP bearer middleware keeps its own private + * variant because it additionally stamps a `WWW-Authenticate: Basic` + * challenge header. + */ +export function unauthorizedResponse(c: Context): Response { + return errorResponse(c, "unauthorized", "Authentication required"); +} diff --git a/vendor/intx/hub-api/src/error-response.ts b/vendor/intx/hub-api/src/error-response.ts new file mode 100644 index 0000000000..d6d2fd7191 --- /dev/null +++ b/vendor/intx/hub-api/src/error-response.ts @@ -0,0 +1,51 @@ +import type { Context } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +// The hub's canonical error envelope: `{ error: { code, message } }`, with +// the HTTP status paired to the machine-readable code. Every route returns +// this shape for non-2xx responses so clients can switch on `code` without +// parsing free-form text; `STATUS_BY_CODE` is the single source of truth +// for the status each code implies. +const STATUS_BY_CODE: Readonly> = { + not_found: 404, + conflict: 409, + not_implemented: 501, + bad_request: 400, + forbidden: 403, + payload_too_large: 413, + invalid_workflow: 409, + sidecar_unavailable: 502, + workflow_run_not_running: 409, + deployment_unreachable: 409, + workflow_dispatch_unavailable: 503, + workflow_provisioning_unavailable: 409, + invalid_request: 400, + unavailable: 503, + unsupported_source: 400, + anchor_run_missing: 500, + reserved_signal_name: 400, + unaddressable_run: 400, + signal_id_conflict: 409, + path_violation: 400, + gone: 410, + already_resolved: 409, + unauthorized: 401, +}; + +/** A JSON error response in the canonical `{ error: { code, message } }` + * envelope, with the status `code` implies. Throws when `code` has no + * mapping so a newly introduced code fails loudly instead of silently + * returning the wrong status. */ +export function errorResponse( + c: Context, + code: string, + message: string, +): Response { + const status = STATUS_BY_CODE[code]; + if (status === undefined) { + throw new Error( + `errorResponse: no status mapping for error code "${code}"`, + ); + } + return c.json({ error: { code, message } }, status); +} diff --git a/vendor/intx/hub-api/src/index.ts b/vendor/intx/hub-api/src/index.ts index b99982dd46..887ac78a7b 100644 --- a/vendor/intx/hub-api/src/index.ts +++ b/vendor/intx/hub-api/src/index.ts @@ -41,7 +41,6 @@ export { type CreateApprovalRoutesDeps, type ReadRunLifecycles, } from "./routes/approvals"; -export { readDurableWorkflowRunLifecycles } from "./workflow-run-lifecycle"; export { createWorkflowRunDeployAuth, type CreateWorkflowRunDeployAuthDeps, diff --git a/vendor/intx/hub-api/src/middleware/git-token-auth.ts b/vendor/intx/hub-api/src/middleware/git-token-auth.ts index b046e2a8ce..0b57a1c84a 100644 --- a/vendor/intx/hub-api/src/middleware/git-token-auth.ts +++ b/vendor/intx/hub-api/src/middleware/git-token-auth.ts @@ -12,6 +12,7 @@ import type { RepoAction } from "@intx/hub-sessions"; import { base64Decode } from "@intx/types"; import type { AppEnv, PrincipalRow, TenantRow } from "../context"; +import { errorResponse } from "../error-response"; const log = getLogger(["hub", "git-token"]); @@ -292,7 +293,7 @@ async function resolvePrincipal( function unauthorized(c: Context, message: string): Response { c.header(WWW_AUTHENTICATE_HEADER, WWW_AUTHENTICATE_VALUE); - return c.json({ error: { code: "unauthorized", message } }, 401); + return errorResponse(c, "unauthorized", message); } function forbidden( diff --git a/vendor/intx/hub-api/src/middleware/grant.ts b/vendor/intx/hub-api/src/middleware/grant.ts index 0eca745d76..b741d2467a 100644 --- a/vendor/intx/hub-api/src/middleware/grant.ts +++ b/vendor/intx/hub-api/src/middleware/grant.ts @@ -6,6 +6,7 @@ import { getLogger } from "@intx/log"; import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; const log = getLogger(["hub", "middleware", "grant"]); @@ -75,14 +76,10 @@ export function createRequireGrant({ }, ); - return c.json( - { - error: { - code: "forbidden", - message: "You do not have permission to perform this action", - }, - }, - 403, + return errorResponse( + c, + "forbidden", + "You do not have permission to perform this action", ); }); }; diff --git a/vendor/intx/hub-api/src/middleware/tenant.ts b/vendor/intx/hub-api/src/middleware/tenant.ts index 5f599f2250..0fb4595b22 100644 --- a/vendor/intx/hub-api/src/middleware/tenant.ts +++ b/vendor/intx/hub-api/src/middleware/tenant.ts @@ -5,17 +5,15 @@ import { tenant, principal } from "@intx/db/schema"; import type { DB } from "@intx/db"; import { getLogger } from "@intx/log"; -import type { AppEnv, TenantEnv } from "../context"; +import { unauthorizedResponse, type AppEnv, type TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; const log = getLogger(["hub", "middleware", "tenant"]); export async function requireAuth(c: Context, next: Next) { const user = c.get("user"); if (!user) { - return c.json( - { error: { code: "unauthorized", message: "Authentication required" } }, - 401, - ); + return unauthorizedResponse(c); } await next(); } @@ -32,18 +30,12 @@ export function createResolveTenant({ const user = c.get("user"); if (!user) { - return c.json( - { error: { code: "unauthorized", message: "Authentication required" } }, - 401, - ); + return unauthorizedResponse(c); } const tenantId = c.req.param("tenantId"); if (!tenantId) { - return c.json( - { error: { code: "bad_request", message: "Missing tenantId" } }, - 400, - ); + return errorResponse(c, "bad_request", "Missing tenantId"); } const tenantRow = await db.query.tenant.findFirst({ @@ -51,10 +43,7 @@ export function createResolveTenant({ }); if (!tenantRow) { - return c.json( - { error: { code: "not_found", message: "Tenant not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Tenant not found"); } const principalRow = await db.query.principal.findFirst({ @@ -66,15 +55,7 @@ export function createResolveTenant({ }); if (!principalRow) { - return c.json( - { - error: { - code: "forbidden", - message: "Not a member of this tenant", - }, - }, - 403, - ); + return errorResponse(c, "forbidden", "Not a member of this tenant"); } if (principalRow.status !== "active") { @@ -82,14 +63,10 @@ export function createResolveTenant({ principalId: principalRow.id, status: principalRow.status, }); - return c.json( - { - error: { - code: "forbidden", - message: "Your membership in this tenant is not active", - }, - }, - 403, + return errorResponse( + c, + "forbidden", + "Your membership in this tenant is not active", ); } diff --git a/vendor/intx/hub-api/src/middleware/workflow-run-deploy-auth.test.ts b/vendor/intx/hub-api/src/middleware/workflow-run-deploy-auth.test.ts deleted file mode 100644 index 31996bebd7..0000000000 --- a/vendor/intx/hub-api/src/middleware/workflow-run-deploy-auth.test.ts +++ /dev/null @@ -1,367 +0,0 @@ -// DB-gated: skipped when DATABASE_URL is unreachable, matching this repo's -// existing convention for tests that talk to a real Postgres (see -// `apps/hub/src/memory-mount.test.ts`). Runs against the caller's own -// database and deletes every row it wrote in `afterAll`. -// -// This suite proves the bearer mirror (`createWorkflowRunDeployAuth`) reaches -// the EXACT SAME `createWorkflowRoutes` deploy handler a human session does -// -- same `requireGrant("workflow:*", "create")` gate, same asset-scoping, -// same install/probe/gate/freeze call into -// `workflowAllocationService.prepareProvisionedDeployment` -- rather than a -// parallel, weaker path. `prepareProvisionedDeployment` itself (the actual -// probe/provision/gate/freeze internals) is a fake here: those internals live -// in `@intx/hub-sessions` and are unchanged and already covered there; what is -// new, and under test, is only the auth wiring in front of it. -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { and, eq } from "drizzle-orm"; -import { Hono } from "hono"; - -import { - createDB, - createPrincipalKeyStore, - schema, - type DB, -} from "@intx/db"; -import { createInMemoryGrantStore } from "@intx/authz"; -import { createNoopCredentialCipher } from "@intx/crypto"; -import { generateId } from "@intx/hub-common"; -import type { - PrepareProvisionedWorkflowDeploymentArgs, - WorkflowAllocationService, -} from "@intx/hub-sessions"; - -import { createRequireGrant } from "../middleware/grant"; -import { createWorkflowRoutes } from "../routes/workflows"; -import { - createWorkflowRunDeployAuth, - type WorkflowRunAuthenticator, -} from "./workflow-run-deploy-auth"; -import type { TenantEnv } from "../context"; - -function dbConfigFromUrl(databaseUrl: string) { - const url = new URL(databaseUrl); - return { - host: url.hostname, - port: url.port === "" ? 5432 : Number(url.port), - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), - }; -} - -const databaseUrl = process.env["DATABASE_URL"]; -const describeIfDb = databaseUrl === undefined ? describe.skip : describe; - -describeIfDb("workflow deploy: bearer mirror of the session deploy route", () => { - let db: DB; - - const tenantId = generateId("tenant"); - const otherTenantId = generateId("tenant"); - const runPrincipalId = generateId("principal"); - const ungrantedPrincipalId = generateId("principal"); - const ownAssetId = generateId("asset"); - const otherTenantAssetId = generateId("asset"); - const SIDECAR_TOKEN = "sidecar-token-for-this-run"; - const RUN_ADDRESS = "workflow-run@tenant.example.test"; - - let deployCalls: unknown[] = []; - - beforeAll(async () => { - if (databaseUrl === undefined) return; - db = createDB(dbConfigFromUrl(databaseUrl)); - - for (const [id, domain] of [ - [tenantId, `deploy-bearer-${tenantId}.localhost`], - [otherTenantId, `deploy-bearer-${otherTenantId}.localhost`], - ] as const) { - await db.db.insert(schema.tenant).values({ - id, - name: "Deploy Bearer Test Tenant", - slug: `deploy-bearer-${id}`, - domain, - parentId: null, - config: null, - }); - } - - await db.db.insert(schema.principal).values([ - { - id: runPrincipalId, - tenantId, - kind: "workflow", - refId: RUN_ADDRESS, - status: "active", - }, - { - id: ungrantedPrincipalId, - tenantId, - kind: "workflow", - refId: "workflow-run-no-grant@tenant.example.test", - status: "active", - }, - ]); - - await db.db.insert(schema.asset).values([ - { - id: ownAssetId, - tenantId, - kind: "workflow", - name: "own-workflow", - displayName: "Own workflow", - }, - { - id: otherTenantAssetId, - tenantId: otherTenantId, - kind: "workflow", - name: "other-tenant-workflow", - displayName: "Other tenant workflow", - }, - ]); - }); - - afterAll(async () => { - if (databaseUrl === undefined) return; - await db.db - .delete(schema.workflowRun) - .where(eq(schema.workflowRun.tenantId, tenantId)); - await db.db - .delete(schema.workflowDefinition) - .where(eq(schema.workflowDefinition.tenantId, tenantId)); - await db.db - .delete(schema.asset) - .where( - and( - eq(schema.asset.tenantId, tenantId), - eq(schema.asset.id, ownAssetId), - ), - ); - await db.db - .delete(schema.asset) - .where(eq(schema.asset.id, otherTenantAssetId)); - await db.db - .delete(schema.principal) - .where(eq(schema.principal.tenantId, tenantId)); - await db.db.delete(schema.tenant).where(eq(schema.tenant.id, tenantId)); - await db.db - .delete(schema.tenant) - .where(eq(schema.tenant.id, otherTenantId)); - }); - - /** Resolves the fixed `SIDECAR_TOKEN`/`RUN_ADDRESS` pair to the run - * principal fixture above -- standing in for - * `@corbits/artifacts-hub`'s `createWorkflowRunAuthenticator`, which does - * the equivalent DB-backed sidecar-token + folded-run-address lookup. */ - const authenticator: WorkflowRunAuthenticator = { - async resolve(token, runAddress) { - if (token !== SIDECAR_TOKEN || runAddress !== RUN_ADDRESS) return null; - return { tenantId, principalId: runPrincipalId }; - }, - }; - - /** Stands in for `workflowAllocationService.prepareProvisionedDeployment`: - * the real probe/provision/gate/freeze internals live in - * `@intx/hub-sessions` and are untouched by this change, so this fake only - * records that it was called (proving the bearer path reaches the SAME call - * the session path does) and persists the anchor row the real implementation - * would have, under the route's own server-minted `anchorRunId`, so the - * route's read-back succeeds exactly as it does in production. */ - function fakeAllocationService(): WorkflowAllocationService { - return { - async prepareProvisionedDeployment( - args: PrepareProvisionedWorkflowDeploymentArgs, - ) { - deployCalls.push(args); - const definitionId = generateId("workflowDefinition"); - await db.db.insert(schema.workflowDefinition).values({ - id: definitionId, - tenantId: args.tenantId, - assetId: args.definitionAssetId, - name: "own-workflow", - }); - await db.db.insert(schema.workflowRun).values({ - id: args.anchorRunId, - definitionId, - anchorRunId: args.anchorRunId, - tenantId: args.tenantId, - principalId: runPrincipalId, - status: "deployed", - }); - return { - anchorRunId: args.anchorRunId, - deploymentAddress: `${args.anchorRunId}@${args.deploymentDomain}`, - allocationId: "alloc_deploy_bearer_test", - status: "pending" as const, - }; - }, - async deployReadyAllocation() { - throw new Error("not exercised by this suite"); - }, - }; - } - - function mountedApp(grantStore: ReturnType) { - const requireGrant = createRequireGrant({ grantStore, conditionRegistry: {} }); - const app = new Hono(); - app.use( - "/api/tenants/:tenantId/workflows/deployments", - createWorkflowRunDeployAuth({ db: db.db, authenticator }), - ); - app.route( - "/api/tenants/:tenantId/workflows", - createWorkflowRoutes({ - db: db.db, - principalKeyStore: createPrincipalKeyStore({ - db: db.db, - cipher: createNoopCredentialCipher(), - }), - workflowAllocationService: fakeAllocationService(), - sidecarRouter: {} as never, - repoStore: {} as never, - grantStore, - requireGrant, - }), - ); - return app; - } - - function deployRequest(assetId: string) { - return new Request( - `http://hub.test/api/tenants/${tenantId}/workflows/deployments`, - { - method: "POST", - headers: { - authorization: `Bearer ${SIDECAR_TOKEN}`, - "x-workflow-run-address": RUN_ADDRESS, - "content-type": "application/json", - }, - body: JSON.stringify({ - source: { - kind: "asset", - assetId, - package: { format: "tarball" }, - }, - entry: "interchange.workflow", - sourceOfferingIds: ["off_1"], - defaultSourceOfferingId: "off_1", - }), - }, - ); - } - - test("a workflow-run principal holding workflow:*/create deploys its own tenant's asset", async () => { - deployCalls = []; - const grantStore = createInMemoryGrantStore([ - { - id: generateId("grant"), - resource: "workflow:*", - action: "create", - effect: "allow", - origin: "system", - conditions: null, - expiresAt: null, - roleId: null, - principalId: runPrincipalId, - }, - ]); - const res = await mountedApp(grantStore).request(deployRequest(ownAssetId)); - expect(res.status).toBe(201); - const body = (await res.json()) as { definitionAssetId: string; tenantId: string }; - expect(body.definitionAssetId).toBe(ownAssetId); - expect(body.tenantId).toBe(tenantId); - // The real deploy call was reached -- not a shortcut -- scoped to the - // authenticated run's own tenant/principal, never anything from the body. - expect(deployCalls).toHaveLength(1); - expect((deployCalls[0] as { tenantId: string }).tenantId).toBe(tenantId); - }); - - test("a workflow-run principal without the grant is refused before the deploy call", async () => { - deployCalls = []; - const noGrantAuthenticator: WorkflowRunAuthenticator = { - async resolve(token, runAddress) { - if (token !== SIDECAR_TOKEN || runAddress !== RUN_ADDRESS) return null; - return { tenantId, principalId: ungrantedPrincipalId }; - }, - }; - const grantStore = createInMemoryGrantStore([]); - const requireGrant = createRequireGrant({ grantStore, conditionRegistry: {} }); - const app = new Hono(); - app.use( - "/api/tenants/:tenantId/workflows/deployments", - createWorkflowRunDeployAuth({ db: db.db, authenticator: noGrantAuthenticator }), - ); - app.route( - "/api/tenants/:tenantId/workflows", - createWorkflowRoutes({ - db: db.db, - principalKeyStore: createPrincipalKeyStore({ - db: db.db, - cipher: createNoopCredentialCipher(), - }), - workflowAllocationService: fakeAllocationService(), - sidecarRouter: {} as never, - repoStore: {} as never, - grantStore, - requireGrant, - }), - ); - const res = await app.request(deployRequest(ownAssetId)); - expect(res.status).toBe(403); - expect(deployCalls).toHaveLength(0); - }); - - test("an asset belonging to another tenant reads as not-found, never a confirming 403", async () => { - deployCalls = []; - const grantStore = createInMemoryGrantStore([ - { - id: generateId("grant"), - resource: "workflow:*", - action: "create", - effect: "allow", - origin: "system", - conditions: null, - expiresAt: null, - roleId: null, - principalId: runPrincipalId, - }, - ]); - const res = await mountedApp(grantStore).request( - deployRequest(otherTenantAssetId), - ); - expect(res.status).toBe(404); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("not_found"); - expect(deployCalls).toHaveLength(0); - }); - - test("an unrecognized bearer token is refused before any tenant/grant is resolved", async () => { - deployCalls = []; - const grantStore = createInMemoryGrantStore([ - { - id: generateId("grant"), - resource: "workflow:*", - action: "create", - effect: "allow", - origin: "system", - conditions: null, - expiresAt: null, - roleId: null, - principalId: runPrincipalId, - }, - ]); - const req = new Request( - `http://hub.test/api/tenants/${tenantId}/workflows/deployments`, - { - method: "POST", - headers: { - authorization: "Bearer not-the-right-token", - "x-workflow-run-address": RUN_ADDRESS, - "content-type": "application/json", - }, - body: JSON.stringify({}), - }, - ); - const res = await mountedApp(grantStore).request(req); - expect(res.status).toBe(401); - expect(deployCalls).toHaveLength(0); - }); -}); diff --git a/vendor/intx/hub-api/src/openapi.ts b/vendor/intx/hub-api/src/openapi.ts index 762e0e9207..75415c0336 100644 --- a/vendor/intx/hub-api/src/openapi.ts +++ b/vendor/intx/hub-api/src/openapi.ts @@ -63,4 +63,27 @@ export function resolver( } as ResolverResult; } +/** + * Builds a single OpenAPI response object whose body is a JSON document + * described by the given arktype schema. Collapses the repeated + * `content: { "application/json": { schema: resolver(...) } }` wrapper + * that every route's `responses` map carries. + * + * Uses the raw hono-openapi resolver (not the `$ref`-rewriting wrapper + * above): the docs generator matches response schemas against full JSON + * schemas, so a response schema must stay expanded. + */ +export function jsonResponse( + description: string, + schema: Parameters[0], +): { + description: string; + content: { "application/json": { schema: ResolverResult } }; +} { + return { + description, + content: { "application/json": { schema: baseResolver(schema) } }, + }; +} + export { describeRoute, validator }; diff --git a/vendor/intx/hub-api/src/routes/agent-data.ts b/vendor/intx/hub-api/src/routes/agent-data.ts index 7caac89b7e..de1073b39a 100644 --- a/vendor/intx/hub-api/src/routes/agent-data.ts +++ b/vendor/intx/hub-api/src/routes/agent-data.ts @@ -1,15 +1,17 @@ import { Hono } from "hono"; -import { describeRoute, resolver } from "hono-openapi"; +import { describeRoute } from "hono-openapi"; import { FileEntry, + ErrorResponse, FileContent, HistoryEntry, CommitDetail, BranchInfo, - ErrorResponse, } from "@intx/types"; import type { AppEnv } from "../context"; +import { errorResponse } from "../error-response"; +import { jsonResponse } from "../openapi"; export function createAgentDataRoutes(): Hono { const app = new Hono(); @@ -20,27 +22,11 @@ export function createAgentDataRoutes(): Hono { tags: ["Agent Data"], summary: "List files in agent working directory", responses: { - 200: { - description: "File listing", - content: { - "application/json": { - schema: resolver(FileEntry.array()), - }, - }, - }, - 404: { - description: "Agent not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("File listing", FileEntry.array()), + 404: jsonResponse("Agent not found", ErrorResponse), }, }), - (c) => - c.json( - { error: { code: "not_implemented", message: "Not implemented" } }, - 501, - ), + (c) => errorResponse(c, "not_implemented", "Not implemented"), ); app.get( @@ -50,25 +36,11 @@ export function createAgentDataRoutes(): Hono { summary: "Read a file from agent storage", description: "Reads a file by path from the agent's local storage.", responses: { - 200: { - description: "File content", - content: { - "application/json": { schema: resolver(FileContent) }, - }, - }, - 404: { - description: "File or agent not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("File content", FileContent), + 404: jsonResponse("File or agent not found", ErrorResponse), }, }), - (c) => - c.json( - { error: { code: "not_implemented", message: "Not implemented" } }, - 501, - ), + (c) => errorResponse(c, "not_implemented", "Not implemented"), ); app.get( @@ -79,21 +51,10 @@ export function createAgentDataRoutes(): Hono { description: "Returns the agent's change history with commit messages and timestamps.", responses: { - 200: { - description: "History entries", - content: { - "application/json": { - schema: resolver(HistoryEntry.array()), - }, - }, - }, + 200: jsonResponse("History entries", HistoryEntry.array()), }, }), - (c) => - c.json( - { error: { code: "not_implemented", message: "Not implemented" } }, - 501, - ), + (c) => errorResponse(c, "not_implemented", "Not implemented"), ); app.get( @@ -104,25 +65,11 @@ export function createAgentDataRoutes(): Hono { description: "Returns the files changed in a specific commit with additions/deletions counts.", responses: { - 200: { - description: "Commit details", - content: { - "application/json": { schema: resolver(CommitDetail) }, - }, - }, - 404: { - description: "Commit not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Commit details", CommitDetail), + 404: jsonResponse("Commit not found", ErrorResponse), }, }), - (c) => - c.json( - { error: { code: "not_implemented", message: "Not implemented" } }, - 501, - ), + (c) => errorResponse(c, "not_implemented", "Not implemented"), ); app.get( @@ -132,21 +79,10 @@ export function createAgentDataRoutes(): Hono { summary: "List branches", description: "Lists branches in the agent's data repository.", responses: { - 200: { - description: "List of branches", - content: { - "application/json": { - schema: resolver(BranchInfo.array()), - }, - }, - }, + 200: jsonResponse("List of branches", BranchInfo.array()), }, }), - (c) => - c.json( - { error: { code: "not_implemented", message: "Not implemented" } }, - 501, - ), + (c) => errorResponse(c, "not_implemented", "Not implemented"), ); app.post( @@ -160,19 +96,10 @@ export function createAgentDataRoutes(): Hono { 204: { description: "Data restored", }, - 404: { - description: "Commit not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 404: jsonResponse("Commit not found", ErrorResponse), }, }), - (c) => - c.json( - { error: { code: "not_implemented", message: "Not implemented" } }, - 501, - ), + (c) => errorResponse(c, "not_implemented", "Not implemented"), ); return app; diff --git a/vendor/intx/hub-api/src/routes/agent-state-git.ts b/vendor/intx/hub-api/src/routes/agent-state-git.ts index 5d17f8f36e..f45aeb4606 100644 --- a/vendor/intx/hub-api/src/routes/agent-state-git.ts +++ b/vendor/intx/hub-api/src/routes/agent-state-git.ts @@ -33,17 +33,10 @@ import { Hono, type Context } from "hono"; import { createMiddleware } from "hono/factory"; import type { MiddlewareHandler } from "hono"; -import { authorize } from "@intx/authz"; import { workflowRun } from "@intx/db/schema"; import type { DB } from "@intx/db"; -import { repoActionToGrantVerb } from "@intx/hub-common"; import { getLogger } from "@intx/log"; -import type { - RefEntry, - RepoId, - RepoStore, - UserPrincipal, -} from "@intx/hub-sessions"; +import type { RepoId, RepoStore, UserPrincipal } from "@intx/hub-sessions"; import type { RepoAction } from "@intx/types/sidecar"; import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; @@ -51,15 +44,16 @@ import type { GitTokenClaims, TenantGitTokenEnv, } from "../middleware/git-token-auth"; -import { - advertiseUploadPack, - type RefSource, -} from "../git-http/advertise-refs"; -import { - handleUploadPack, - type UploadPackRepoStore, -} from "../git-http/upload-pack"; +import { advertiseUploadPack } from "../git-http/advertise-refs"; +import { handleUploadPack } from "../git-http/upload-pack"; import { writePktLine, writeFlush } from "../git-http/pkt-line"; +import { errorResponse } from "../error-response"; +import { + buildUserPrincipal, + makeRefSource, + makeUploadPackStore, + resolveAuthzVerdict, +} from "./git-user-principal"; const log = getLogger(["hub", "agent-state-git"]); @@ -248,85 +242,8 @@ export function createAgentStateReceivePackDeny(): MiddlewareHandler { }); } -// ----- Pre-resolved authz + UserPrincipal construction -------------- - -function dateToNumber(d: Date): number { - return d.getTime(); -} - -async function resolveAuthzVerdict(args: { - grantStore: GrantStore; - conditionRegistry: ConditionRegistry; - principalId: string; - tenantId: string; - agentStateId: string; - action: RepoAction; -}): Promise { - const resource = `agent-state:${args.agentStateId}`; - const grantVerb = repoActionToGrantVerb(args.action); - const verdict = await authorize( - args.grantStore, - args.principalId, - args.tenantId, - resource, - grantVerb, - args.conditionRegistry, - ); - return { - effect: verdict.effect === "allow" ? "allow" : "deny", - resource, - grantVerb, - }; -} - -function buildUserPrincipal(args: { - principalId: string; - tenantId: string; - authz: UserPrincipal["authz"]; - claims: GitTokenClaims; -}): UserPrincipal { - return { - kind: "user", - principalId: args.principalId, - tenantId: args.tenantId, - authz: args.authz, - tokenClaims: { - refPattern: args.claims.refPattern, - actions: args.claims.actions, - expiresAt: dateToNumber(args.claims.expiresAt), - }, - }; -} - -// ----- Substrate adapters ------------------------------------------- - -function makeRefSource( - repoStore: RepoStore, - principal: UserPrincipal, -): RefSource { - return { - async listRefs(_p, repoId): Promise { - return repoStore.listRefs(principal, repoId); - }, - async resolveHead(_p, repoId) { - return repoStore.resolveHead(principal, repoId); - }, - }; -} - -function makeUploadPackStore( - repoStore: RepoStore, - principal: UserPrincipal, -): UploadPackRepoStore { - return { - async listRefs(_p, repoId): Promise { - return repoStore.listRefs(principal, repoId); - }, - async getRepoDir(_p, repoId): Promise { - return repoStore.getRepoDir(repoId); - }, - }; -} +// User-principal construction helpers shared with the asset smart-HTTP +// group live in ./git-user-principal. // ----- Resolver shape ----------------------------------------------- @@ -426,7 +343,7 @@ async function resolveSmartHttp( conditionRegistry: deps.conditionRegistry, principalId: principalRow.id, tenantId, - agentStateId: resolved.id, + resource: `agent-state:${resolved.id}`, action, }); if (authz.effect !== "allow") { @@ -474,14 +391,10 @@ export function createAgentStateRunGitRoutes( if (service !== "git-upload-pack") { // The receive-pack case is handled by the deny middleware above; // anything else is a bad request. - return c.json( - { - error: { - code: "bad_request", - message: "info/refs requires service=git-upload-pack", - }, - }, - 400, + return errorResponse( + c, + "bad_request", + "info/refs requires service=git-upload-pack", ); } const r = await resolveSmartHttp(deps, c, "resolveRef"); diff --git a/vendor/intx/hub-api/src/routes/approvals.ts b/vendor/intx/hub-api/src/routes/approvals.ts index d6813db8c9..50df0fab7c 100644 --- a/vendor/intx/hub-api/src/routes/approvals.ts +++ b/vendor/intx/hub-api/src/routes/approvals.ts @@ -1,7 +1,7 @@ import { and, eq } from "drizzle-orm"; import { Hono } from "hono"; import type { Context } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { authorize } from "@intx/authz"; import type { DB, ApprovalStore, SignalCorrelationStore } from "@intx/db"; @@ -16,10 +16,10 @@ import type { } from "@intx/hub-sessions"; import { ApprovalResponse, + ErrorResponse, ApprovalDecision, ApproveAction, RejectAction, - ErrorResponse, isSidecarAllocationDispatchable, paginatedSchema, signalName, @@ -28,6 +28,7 @@ import { import { getLogger } from "@intx/log"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { ts } from "../format"; import { approvalToolName, @@ -42,6 +43,7 @@ import { paginatedResponse, parsePageParams, } from "../pagination"; +import { jsonResponse } from "../openapi"; type ParsedApproval = ReturnType; @@ -74,10 +76,14 @@ async function propagateRunGrantsToSidecar( ); // A run with no committed per-run grants has nothing to push. if (committed === null) return; + // A standing-grant refresh carries no inbound sender, so there is no sender + // key to co-deliver on this barrier; the sender's key rides the run.grants + // frame that its triggering mail pushed. const delivered = deps.sidecarRouter.sendRunGrants( approval.agentAddress, approval.runId, committed.stepGrants, + undefined, ); if (!delivered) { log.warn`standing grant for run ${approval.runId} not pushed: deployment ${approval.agentAddress} is not routable; the next dispatch re-establishes it`; @@ -133,15 +139,7 @@ export type ReadRunLifecycles = ( export type ResolveApprovalRequest = { approvalId: string; tenantId: string; - /** - * The resolving principal, whose `approval:`/"resolve" - * grant is checked before the claim. `null` means the decision was - * already authorized by policy (a standing-grant allowance evaluated - * against the tenant's grant store) rather than by a principal's - * interactive resolve grant; the caller asserts that authorization and - * the principal gate is skipped. - */ - principalId: string | null; + principalId: string; status: "approved" | "rejected"; scope?: "once" | "always"; decisionPayload: ApprovalDecision; @@ -197,18 +195,16 @@ export async function resolveApproval( return { kind: "not_found" }; } - if (args.principalId !== null) { - const authz = await authorize( - grantStore, - args.principalId, - args.tenantId, - `approval:${approval.anchorRunId}`, - "resolve", - conditionRegistry, - ); - if (authz.effect !== "allow") { - return { kind: "forbidden" }; - } + const authz = await authorize( + grantStore, + args.principalId, + args.tenantId, + `approval:${approval.anchorRunId}`, + "resolve", + conditionRegistry, + ); + if (authz.effect !== "allow") { + return { kind: "forbidden" }; } // A standing resolution mutates the run's committed grant for the approved @@ -413,20 +409,14 @@ export function createApprovalRoutes( "Returns pending approval requests within this tenant, newest first.", parameters: [...pageParameters], responses: { - 200: { - description: "List of pending approvals", - content: { - "application/json": { - schema: resolver(paginatedSchema(ApprovalResponse)), - }, - }, - }, - 403: { - description: "Caller lacks the tenant-wide approval grant", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse( + "List of pending approvals", + paginatedSchema(ApprovalResponse), + ), + 403: jsonResponse( + "Caller lacks the tenant-wide approval grant", + ErrorResponse, + ), }, }), async (c) => { @@ -448,14 +438,10 @@ export function createApprovalRoutes( deps.conditionRegistry, ); if (authz.effect !== "allow") { - return c.json( - { - error: { - code: "forbidden", - message: "You do not have permission to list approvals", - }, - }, - 403, + return errorResponse( + c, + "forbidden", + "You do not have permission to list approvals", ); } @@ -495,24 +481,9 @@ export function createApprovalRoutes( description: "Returns the approver-facing tool snapshot, status, and originating deployment for one approval.", responses: { - 200: { - description: "Approval details", - content: { - "application/json": { schema: resolver(ApprovalResponse) }, - }, - }, - 403: { - description: "Caller lacks the approval grant", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 404: { - description: "Approval not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Approval details", ApprovalResponse), + 403: jsonResponse("Caller lacks the approval grant", ErrorResponse), + 404: jsonResponse("Approval not found", ErrorResponse), }, }), async (c) => { @@ -525,10 +496,7 @@ export function createApprovalRoutes( // learn that an approval id exists in another. Checked before authz so // the 403 below never leaks a foreign id. if (row === null || row.tenantId !== tenant.id) { - return c.json( - { error: { code: "not_found", message: "Approval not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Approval not found"); } // Same grant the approve/reject routes require: whoever can resolve this @@ -543,14 +511,10 @@ export function createApprovalRoutes( deps.conditionRegistry, ); if (authz.effect !== "allow") { - return c.json( - { - error: { - code: "forbidden", - message: "You do not have permission to read this approval", - }, - }, - 403, + return errorResponse( + c, + "forbidden", + "You do not have permission to read this approval", ); } @@ -566,37 +530,20 @@ export function createApprovalRoutes( description: "Approves the pending action. Scope 'once' authorizes only this suspended call. Scope 'always' additionally records a standing approval, so the same tool is not asked again for the rest of this run.", responses: { - 200: { - description: "Action approved", - content: { - "application/json": { schema: resolver(ApprovalResponse) }, - }, - }, - 404: { - description: "Approval not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 403: { - description: "Approver lacks the approval resolve grant", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 409: { - description: - "Approval already resolved (takes precedence on retries), workflow run no longer running, or workflow deployment unavailable", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 503: { - description: "Durable workflow dispatch unavailable", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Action approved", ApprovalResponse), + 404: jsonResponse("Approval not found", ErrorResponse), + 403: jsonResponse( + "Approver lacks the approval resolve grant", + ErrorResponse, + ), + 409: jsonResponse( + "Approval already resolved (takes precedence on retries), workflow run no longer running, or workflow deployment unavailable", + ErrorResponse, + ), + 503: jsonResponse( + "Durable workflow dispatch unavailable", + ErrorResponse, + ), }, }), validator("json", ApproveAction), @@ -627,37 +574,20 @@ export function createApprovalRoutes( description: "Rejects the pending action. An optional message provides feedback to the agent. Scope 'once' (the default) rejects only this call; scope 'always' additionally records a standing rejection, setting the tool to a standing deny so it is blocked without asking again for the rest of this run.", responses: { - 200: { - description: "Action rejected", - content: { - "application/json": { schema: resolver(ApprovalResponse) }, - }, - }, - 404: { - description: "Approval not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 403: { - description: "Approver lacks the approval resolve grant", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 409: { - description: - "Approval already resolved (takes precedence on retries), workflow run no longer running, or workflow deployment unavailable", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 503: { - description: "Durable workflow dispatch unavailable", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Action rejected", ApprovalResponse), + 404: jsonResponse("Approval not found", ErrorResponse), + 403: jsonResponse( + "Approver lacks the approval resolve grant", + ErrorResponse, + ), + 409: jsonResponse( + "Approval already resolved (takes precedence on retries), workflow run no longer running, or workflow deployment unavailable", + ErrorResponse, + ), + 503: jsonResponse( + "Durable workflow dispatch unavailable", + ErrorResponse, + ), }, }), validator("json", RejectAction), @@ -691,60 +621,36 @@ function respond(c: Context, result: ResolveApprovalOutcome) { case "resolved": return c.json(formatApproval(result.approval), 200); case "not_found": - return c.json( - { error: { code: "not_found", message: "Approval not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Approval not found"); case "forbidden": - return c.json( - { - error: { - code: "forbidden", - message: "You do not have permission to resolve this approval", - }, - }, - 403, + return errorResponse( + c, + "forbidden", + "You do not have permission to resolve this approval", ); case "already_resolved": - return c.json( - { - error: { - code: "already_resolved", - message: "Approval has already been resolved", - }, - }, - 409, + return errorResponse( + c, + "already_resolved", + "Approval has already been resolved", ); case "deployment_unavailable": - return c.json( - { - error: { - code: "deployment_unreachable", - message: "Workflow deployment allocation is no longer active", - }, - }, - 409, + return errorResponse( + c, + "deployment_unreachable", + "Workflow deployment allocation is no longer active", ); case "run_not_running": - return c.json( - { - error: { - code: "workflow_run_not_running", - message: "Workflow run is no longer running", - }, - }, - 409, + return errorResponse( + c, + "workflow_run_not_running", + "Workflow run is no longer running", ); case "dispatch_unavailable": - return c.json( - { - error: { - code: "workflow_dispatch_unavailable", - message: - "Durable workflow dispatch is unavailable for this provisioned deployment", - }, - }, - 503, + return errorResponse( + c, + "workflow_dispatch_unavailable", + "Durable workflow dispatch is unavailable for this provisioned deployment", ); } } diff --git a/vendor/intx/hub-api/src/routes/assets.ts b/vendor/intx/hub-api/src/routes/assets.ts index 27d5654651..3a3bf90ce8 100644 --- a/vendor/intx/hub-api/src/routes/assets.ts +++ b/vendor/intx/hub-api/src/routes/assets.ts @@ -26,12 +26,11 @@ import { and, eq } from "drizzle-orm"; import { Hono, type Context } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { type } from "arktype"; import ssri from "ssri"; -import { authorize } from "@intx/authz"; import { asset as assetTable } from "@intx/db/schema"; import { listAssetsForTenant, @@ -39,7 +38,6 @@ import { type AssetWithOrigin, type DB, } from "@intx/db"; -import { repoActionToGrantVerb } from "@intx/hub-common"; import { getLogger } from "@intx/log"; import { AssetServiceError, @@ -49,7 +47,6 @@ import { asTarballEntry, type AssetService, type Principal, - type RefEntry, type RepoId, type RepoStore, type UserPrincipal, @@ -58,11 +55,12 @@ import type { RepoAction, RepoKind } from "@intx/types/sidecar"; import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; import { AssetResponse, - AssetWithOriginResponse, ErrorResponse, + AssetWithOriginResponse, } from "@intx/types"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { ts } from "../format"; import type { GitTokenClaims, @@ -72,13 +70,16 @@ import { idResource, type RequireGrant } from "../middleware/grant"; import { advertiseReceivePack, advertiseUploadPack, - type RefSource, } from "../git-http/advertise-refs"; -import { - handleUploadPack, - type UploadPackRepoStore, -} from "../git-http/upload-pack"; +import { handleUploadPack } from "../git-http/upload-pack"; import { handleReceivePack } from "../git-http/receive-pack"; +import { + buildUserPrincipal, + makeRefSource, + makeUploadPackStore, + resolveAuthzVerdict, +} from "./git-user-principal"; +import { jsonResponse } from "../openapi"; const log = getLogger(["hub", "assets"]); @@ -158,7 +159,8 @@ function stripGitSuffix(raw: string): string | null { return raw.slice(0, -".git".length); } -// Pre-resolved authz + UserPrincipal construction ------------------ +// User-principal construction helpers shared with the agent-state +// smart-HTTP group live in ./git-user-principal. type AssetLookup = { id: string; @@ -167,85 +169,6 @@ type AssetLookup = { name: string; }; -function dateToNumber(d: Date): number { - return d.getTime(); -} - -async function resolveAuthzVerdict(args: { - grantStore: GrantStore; - conditionRegistry: ConditionRegistry; - principalId: string; - tenantId: string; - assetId: string; - action: RepoAction; -}): Promise { - const resource = `asset:${args.assetId}`; - const grantVerb = repoActionToGrantVerb(args.action); - const verdict = await authorize( - args.grantStore, - args.principalId, - args.tenantId, - resource, - grantVerb, - args.conditionRegistry, - ); - return { - effect: verdict.effect === "allow" ? "allow" : "deny", - resource, - grantVerb, - }; -} - -function buildUserPrincipal(args: { - principalId: string; - tenantId: string; - authz: UserPrincipal["authz"]; - claims: GitTokenClaims; -}): UserPrincipal { - return { - kind: "user", - principalId: args.principalId, - tenantId: args.tenantId, - authz: args.authz, - tokenClaims: { - refPattern: args.claims.refPattern, - actions: args.claims.actions, - expiresAt: dateToNumber(args.claims.expiresAt), - }, - }; -} - -// Substrate adapters: bridge the substrate's RepoStore to the narrow -// per-handler contracts that advertise-refs and upload-pack expose. - -function makeRefSource( - repoStore: RepoStore, - principal: UserPrincipal, -): RefSource { - return { - async listRefs(_p, repoId): Promise { - return repoStore.listRefs(principal, repoId); - }, - async resolveHead(_p, repoId) { - return repoStore.resolveHead(principal, repoId); - }, - }; -} - -function makeUploadPackStore( - repoStore: RepoStore, - principal: UserPrincipal, -): UploadPackRepoStore { - return { - async listRefs(_p, repoId): Promise { - return repoStore.listRefs(principal, repoId); - }, - async getRepoDir(_p, repoId): Promise { - return repoStore.getRepoDir(repoId); - }, - }; -} - // Routes ------------------------------------------------------------ export type CreateAssetRoutesDeps = { @@ -373,14 +296,7 @@ export function createAssetRoutes({ }, ], responses: { - 200: { - description: "List of assets", - content: { - "application/json": { - schema: resolver(AssetWithOriginResponse.array()), - }, - }, - }, + 200: jsonResponse("List of assets", AssetWithOriginResponse.array()), }, }), async (c) => { @@ -393,14 +309,10 @@ export function createAssetRoutes({ } else if (inheritedRaw === "false") { inherited = false; } else { - return c.json( - { - error: { - code: "bad_request", - message: `inherited must be "true" or "false", got ${JSON.stringify(inheritedRaw)}`, - }, - }, - 400, + return errorResponse( + c, + "bad_request", + `inherited must be "true" or "false", got ${JSON.stringify(inheritedRaw)}`, ); } @@ -438,18 +350,8 @@ export function createAssetRoutes({ description: "Returns asset metadata. Resolves through the tenant hierarchy: assets declared on the tenant or any ancestor are visible. Sibling-tenant assets return 404 so callers cannot probe for cross-tenant existence.", responses: { - 200: { - description: "Asset metadata", - content: { - "application/json": { schema: resolver(AssetResponse) }, - }, - }, - 404: { - description: "Asset not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Asset metadata", AssetResponse), + 404: jsonResponse("Asset not found", ErrorResponse), }, }), async (c) => { @@ -458,10 +360,7 @@ export function createAssetRoutes({ const row = await resolveAssetById(db, tenantCtx.id, assetId); if (row === null) { - return c.json( - { error: { code: "not_found", message: "Asset not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Asset not found"); } return c.json(formatAsset(row)); @@ -477,24 +376,9 @@ export function createAssetRoutes({ description: "Inserts an asset row and initializes the backing git repository with a hub-signed genesis commit and the asset-route .gitignore body.", responses: { - 201: { - description: "Asset created", - content: { - "application/json": { schema: resolver(AssetResponseSchema) }, - }, - }, - 400: { - description: "Validation error", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 409: { - description: "Asset already exists", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("Asset created", AssetResponseSchema), + 400: jsonResponse("Validation error", ErrorResponse), + 409: jsonResponse("Asset already exists", ErrorResponse), }, }), validator("json", CreateAsset), @@ -650,24 +534,12 @@ export function createAssetRoutes({ description: "Commits raw tarball bytes at tarballs/ in the package-registry asset's git tree. Overwrites permitted. The kind handler validates the tarball's package.json before the commit is accepted.", responses: { - 200: { - description: "Tarball stored", - content: { - "application/json": { schema: resolver(TarballPutResponse) }, - }, - }, - 400: { - description: "Invalid filename or rejected content", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 404: { - description: "Asset not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Tarball stored", TarballPutResponse), + 400: jsonResponse( + "Invalid filename or rejected content", + ErrorResponse, + ), + 404: jsonResponse("Asset not found", ErrorResponse), }, }), async (c) => { @@ -676,10 +548,7 @@ export function createAssetRoutes({ const filename = c.req.param("filename"); const filenameErr = validateTarballFilename(filename); if (filenameErr !== null) { - return c.json( - { error: { code: "bad_request", message: filenameErr } }, - 400, - ); + return errorResponse(c, "bad_request", filenameErr); } const lookup = await resolveRegistryAsset(tenantCtx.id, assetId); @@ -706,14 +575,10 @@ export function createAssetRoutes({ // value (`1e308`). Insist on the digit-only shape so the // header is exactly what RFC 9110 says it is. if (!/^\d+$/.test(declaredLengthRaw)) { - return c.json( - { - error: { - code: "bad_request", - message: "Content-Length must be a non-negative integer", - }, - }, - 400, + return errorResponse( + c, + "bad_request", + "Content-Length must be a non-negative integer", ); } const declaredLength = Number(declaredLengthRaw); @@ -721,28 +586,20 @@ export function createAssetRoutes({ !Number.isFinite(declaredLength) || declaredLength > maxTarballBytes ) { - return c.json( - { - error: { - code: "payload_too_large", - message: `tarball exceeds maximum size of ${String(maxTarballBytes)} bytes`, - }, - }, - 413, + return errorResponse( + c, + "payload_too_large", + `tarball exceeds maximum size of ${String(maxTarballBytes)} bytes`, ); } } const bytes = await readBodyWithLimit(c.req.raw, maxTarballBytes); if (bytes === null) { - return c.json( - { - error: { - code: "payload_too_large", - message: `tarball exceeds maximum size of ${String(maxTarballBytes)} bytes`, - }, - }, - 413, + return errorResponse( + c, + "payload_too_large", + `tarball exceeds maximum size of ${String(maxTarballBytes)} bytes`, ); } // Integrity is computed from the request bytes. The substrate @@ -790,10 +647,7 @@ export function createAssetRoutes({ } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.startsWith("path_violation:")) { - return c.json( - { error: { code: "path_violation", message: msg } }, - 400, - ); + return errorResponse(c, "path_violation", msg); } throw err; } @@ -809,18 +663,8 @@ export function createAssetRoutes({ description: "Returns the current set of tarballs under tarballs/ for the package-registry asset, with size and SRI integrity for each entry.", responses: { - 200: { - description: "Tarball list", - content: { - "application/json": { schema: resolver(TarballListResponse) }, - }, - }, - 404: { - description: "Asset not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Tarball list", TarballListResponse), + 404: jsonResponse("Asset not found", ErrorResponse), }, }), async (c) => { @@ -871,24 +715,9 @@ export function createAssetRoutes({ description: "Removes the named tarball from the package-registry asset and commits the resulting tree. Returns 404 if the asset or filename does not exist.", responses: { - 200: { - description: "Tarball removed", - content: { - "application/json": { schema: resolver(TarballDeleteResponse) }, - }, - }, - 400: { - description: "Invalid filename", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 404: { - description: "Asset or filename not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Tarball removed", TarballDeleteResponse), + 400: jsonResponse("Invalid filename", ErrorResponse), + 404: jsonResponse("Asset or filename not found", ErrorResponse), }, }), async (c) => { @@ -897,10 +726,7 @@ export function createAssetRoutes({ const filename = c.req.param("filename"); const filenameErr = validateTarballFilename(filename); if (filenameErr !== null) { - return c.json( - { error: { code: "bad_request", message: filenameErr } }, - 400, - ); + return errorResponse(c, "bad_request", filenameErr); } const lookup = await resolveRegistryAsset(tenantCtx.id, assetId); @@ -947,14 +773,10 @@ export function createAssetRoutes({ return c.json({ commit: commitSha }); } catch (err) { if (err instanceof TarballNotFoundError) { - return c.json( - { - error: { - code: "not_found", - message: `tarball ${err.filename} not found in asset ${err.assetId}`, - }, - }, - 404, + return errorResponse( + c, + "not_found", + `tarball ${err.filename} not found in asset ${err.assetId}`, ); } throw err; @@ -1117,7 +939,7 @@ export function createAssetRoutes({ conditionRegistry, principalId: principalRow.id, tenantId, - assetId: resolvedAsset.asset.id, + resource: `asset:${resolvedAsset.asset.id}`, action, }); if (authz.effect !== "allow") { @@ -1154,15 +976,10 @@ export function createAssetRoutes({ smartHttp.get("/:kind/:nameDotGit/info/refs", async (c) => { const service = c.req.query("service"); if (service !== "git-upload-pack" && service !== "git-receive-pack") { - return c.json( - { - error: { - code: "bad_request", - message: - "info/refs requires service=git-upload-pack or git-receive-pack", - }, - }, - 400, + return errorResponse( + c, + "bad_request", + "info/refs requires service=git-upload-pack or git-receive-pack", ); } // info/refs maps to the `resolveRef` RepoAction for the bearer diff --git a/vendor/intx/hub-api/src/routes/credentials.ts b/vendor/intx/hub-api/src/routes/credentials.ts index f8c60b18da..9c320e65ff 100644 --- a/vendor/intx/hub-api/src/routes/credentials.ts +++ b/vendor/intx/hub-api/src/routes/credentials.ts @@ -1,6 +1,6 @@ import { eq, and, isNull } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { credential, grant as grantTable, provider } from "@intx/db/schema"; import { @@ -11,15 +11,16 @@ import { import type { DB } from "@intx/db"; import { CreateCredential, + ErrorResponse, UpdateCredential, CredentialResponse, - ErrorResponse, paginatedSchema, credentialAad, } from "@intx/types"; import type { CredentialCipher } from "@intx/types"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { first, ts } from "../format"; import { generateId } from "@intx/hub-common"; import { isReferencedRowViolation } from "../pg-errors"; @@ -32,6 +33,7 @@ import { paginatedResponse, pageParameters, } from "../pagination"; +import { jsonResponse } from "../openapi"; import { pushCredentialRevoke, pushSourceUpdates, @@ -90,14 +92,10 @@ export function createCredentialRoutes({ ...pageParameters, ], responses: { - 200: { - description: "List of credentials", - content: { - "application/json": { - schema: resolver(paginatedSchema(CredentialResponse)), - }, - }, - }, + 200: jsonResponse( + "List of credentials", + paginatedSchema(CredentialResponse), + ), }, }), async (c) => { @@ -142,30 +140,13 @@ export function createCredentialRoutes({ description: "Stores a credential (API key, OAuth token, etc.). The secret is stored securely and never returned in subsequent reads. A provider must be specified.", responses: { - 201: { - description: "Credential stored", - content: { - "application/json": { schema: resolver(CredentialResponse) }, - }, - }, - 400: { - description: "Validation error", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 404: { - description: "Provider not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 409: { - description: "Credential name already exists in this tenant", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("Credential stored", CredentialResponse), + 400: jsonResponse("Validation error", ErrorResponse), + 404: jsonResponse("Provider not found", ErrorResponse), + 409: jsonResponse( + "Credential name already exists in this tenant", + ErrorResponse, + ), }, }), validator("json", CreateCredential), @@ -177,18 +158,12 @@ export function createCredentialRoutes({ where: eq(provider.id, body.providerId), }); if (!providerRow) { - return c.json( - { error: { code: "not_found", message: "Provider not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Provider not found"); } const chain = await getAncestorChain(db, tenantCtx.id); if (!chain.includes(providerRow.tenantId)) { - return c.json( - { error: { code: "not_found", message: "Provider not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Provider not found"); } const existing = await db.query.credential.findFirst({ @@ -198,14 +173,10 @@ export function createCredentialRoutes({ ), }); if (existing) { - return c.json( - { - error: { - code: "conflict", - message: "Credential name already exists in this tenant", - }, - }, - 409, + return errorResponse( + c, + "conflict", + "Credential name already exists in this tenant", ); } @@ -288,18 +259,8 @@ export function createCredentialRoutes({ description: "Resolves a credential by name, walking the tenant hierarchy. Returns metadata only (no secret). Useful for discovering which credential an agent would get.", responses: { - 200: { - description: "Credential metadata", - content: { - "application/json": { schema: resolver(CredentialResponse) }, - }, - }, - 404: { - description: "Credential not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Credential metadata", CredentialResponse), + 404: jsonResponse("Credential not found", ErrorResponse), }, }), async (c) => { @@ -309,10 +270,7 @@ export function createCredentialRoutes({ const row = await resolveCredentialByName(db, tenantCtx.id, name); if (!row) { - return c.json( - { error: { code: "not_found", message: "Credential not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Credential not found"); } return c.json(formatCredential(row)); @@ -328,18 +286,8 @@ export function createCredentialRoutes({ description: "Returns credential metadata. The secret is never included. Supports hierarchy-aware access.", responses: { - 200: { - description: "Credential metadata", - content: { - "application/json": { schema: resolver(CredentialResponse) }, - }, - }, - 404: { - description: "Credential not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Credential metadata", CredentialResponse), + 404: jsonResponse("Credential not found", ErrorResponse), }, }), async (c) => { @@ -351,18 +299,12 @@ export function createCredentialRoutes({ }); if (!row) { - return c.json( - { error: { code: "not_found", message: "Credential not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Credential not found"); } const chain = await getAncestorChain(db, tenantCtx.id); if (!chain.includes(row.tenantId)) { - return c.json( - { error: { code: "not_found", message: "Credential not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Credential not found"); } return c.json(formatCredential(row)); @@ -377,18 +319,8 @@ export function createCredentialRoutes({ summary: "Rotate or update a credential", description: "Only credentials owned by this tenant can be updated.", responses: { - 200: { - description: "Credential updated", - content: { - "application/json": { schema: resolver(CredentialResponse) }, - }, - }, - 404: { - description: "Credential not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Credential updated", CredentialResponse), + 404: jsonResponse("Credential not found", ErrorResponse), }, }), validator("json", UpdateCredential), @@ -434,10 +366,7 @@ export function createCredentialRoutes({ .returning(); if (!updated) { - return c.json( - { error: { code: "not_found", message: "Credential not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Credential not found"); } // If the secret was updated, push new inference sources to running @@ -483,18 +412,11 @@ export function createCredentialRoutes({ 204: { description: "Credential revoked", }, - 404: { - description: "Credential not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 409: { - description: "Credential is in use by a model provider", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 404: jsonResponse("Credential not found", ErrorResponse), + 409: jsonResponse( + "Credential is in use by a model provider", + ErrorResponse, + ), }, }), async (c) => { @@ -548,22 +470,15 @@ export function createCredentialRoutes({ if (!isReferencedRowViolation(err)) { throw err; } - return c.json( - { - error: { - code: "conflict", - message: "Credential is in use by a model provider", - }, - }, - 409, + return errorResponse( + c, + "conflict", + "Credential is in use by a model provider", ); } if (outcome === "not_found") { - return c.json( - { error: { code: "not_found", message: "Credential not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Credential not found"); } // The credential row is gone; evict its material from any running diff --git a/vendor/intx/hub-api/src/routes/git-tokens.ts b/vendor/intx/hub-api/src/routes/git-tokens.ts index b419024f72..b22bef2c40 100644 --- a/vendor/intx/hub-api/src/routes/git-tokens.ts +++ b/vendor/intx/hub-api/src/routes/git-tokens.ts @@ -1,6 +1,6 @@ import { and, eq } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { type } from "arktype"; import { sha256 } from "@intx/crypto"; @@ -17,9 +17,10 @@ import { } from "@intx/hub-common"; import { getLogger } from "@intx/log"; import type { RepoAction } from "@intx/types/sidecar"; -import { base64urlEncode, ErrorResponse, paginatedSchema } from "@intx/types"; +import { base64urlEncode, paginatedSchema, ErrorResponse } from "@intx/types"; -import type { AppEnv, TenantEnv } from "../context"; +import { unauthorizedResponse, type AppEnv, type TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { ts } from "../format"; import type { RequireGrant } from "../middleware/grant"; import { @@ -29,6 +30,7 @@ import { paginatedResponse, parsePageParams, } from "../pagination"; +import { jsonResponse } from "../openapi"; const log = getLogger(["hub", "git-token"]); @@ -336,14 +338,10 @@ export function createTenantGitTokenRoutes({ 'Lists service tokens (`kind: "svc"`) bound to this tenant. Secrets are never returned; the plaintext is shown only at mint time.', parameters: [...pageParameters], responses: { - 200: { - description: "List of git tokens", - content: { - "application/json": { - schema: resolver(paginatedSchema(GitTokenSummary)), - }, - }, - }, + 200: jsonResponse( + "List of git tokens", + paginatedSchema(GitTokenSummary), + ), }, }), async (c) => { @@ -386,18 +384,8 @@ export function createTenantGitTokenRoutes({ description: 'Mints a service token (`kind: "svc"`) bound to the requesting tenant. The plaintext secret is returned exactly once in the response and is never persisted in plaintext.', responses: { - 201: { - description: "Token minted", - content: { - "application/json": { schema: resolver(GitTokenMintResponse) }, - }, - }, - 400: { - description: "Validation error", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("Token minted", GitTokenMintResponse), + 400: jsonResponse("Validation error", ErrorResponse), }, }), validator("json", CreateTenantGitToken), @@ -406,12 +394,7 @@ export function createTenantGitTokenRoutes({ const principalCtx = c.get("principal"); const user = c.get("user"); if (!user) { - return c.json( - { - error: { code: "unauthorized", message: "Authentication required" }, - }, - 401, - ); + return unauthorizedResponse(c); } const body = c.req.valid("json"); @@ -468,12 +451,7 @@ export function createTenantGitTokenRoutes({ "Soft-revokes a tenant-bound git token by setting `revokedAt`. The row is retained for audit.", responses: { 204: { description: "Token revoked" }, - 404: { - description: "Token not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 404: jsonResponse("Token not found", ErrorResponse), }, }), async (c) => { @@ -491,10 +469,7 @@ export function createTenantGitTokenRoutes({ tenantId: tenantCtx.id, tokenId, }); - return c.json( - { error: { code: "not_found", message: "Token not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Token not found"); } if (existing.revokedAt === null) { @@ -534,31 +509,17 @@ export function createMeGitTokenRoutes({ 'Lists the authenticated user\'s personal access tokens (`kind: "pat"`). Secrets are never returned; the plaintext is shown only at mint time.', parameters: [...pageParameters], responses: { - 200: { - description: "List of git tokens", - content: { - "application/json": { - schema: resolver(paginatedSchema(GitTokenSummary)), - }, - }, - }, - 401: { - description: "Not authenticated", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse( + "List of git tokens", + paginatedSchema(GitTokenSummary), + ), + 401: jsonResponse("Not authenticated", ErrorResponse), }, }), async (c) => { const user = c.get("user"); if (!user) { - return c.json( - { - error: { code: "unauthorized", message: "Authentication required" }, - }, - 401, - ); + return unauthorizedResponse(c); } const { limit, cursor } = parsePageParams({ cursor: c.req.query("cursor"), @@ -600,36 +561,16 @@ export function createMeGitTokenRoutes({ description: 'Mints a personal access token (`kind: "pat"`) for the authenticated user. The plaintext secret is returned exactly once in the response. An optional `tenantId` restricts the token to a single tenant.', responses: { - 201: { - description: "Token minted", - content: { - "application/json": { schema: resolver(GitTokenMintResponse) }, - }, - }, - 400: { - description: "Validation error", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 401: { - description: "Not authenticated", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("Token minted", GitTokenMintResponse), + 400: jsonResponse("Validation error", ErrorResponse), + 401: jsonResponse("Not authenticated", ErrorResponse), }, }), validator("json", CreateMeGitToken), async (c) => { const user = c.get("user"); if (!user) { - return c.json( - { - error: { code: "unauthorized", message: "Authentication required" }, - }, - 401, - ); + return unauthorizedResponse(c); } const body = c.req.valid("json"); @@ -677,29 +618,17 @@ export function createMeGitTokenRoutes({ "Soft-revokes a personal access token by setting `revokedAt`. Only the owning user may revoke their own tokens.", responses: { 204: { description: "Token revoked" }, - 401: { - description: "Not authenticated", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 404: { - description: "Token not found or not owned by the authenticated user", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 401: jsonResponse("Not authenticated", ErrorResponse), + 404: jsonResponse( + "Token not found or not owned by the authenticated user", + ErrorResponse, + ), }, }), async (c) => { const user = c.get("user"); if (!user) { - return c.json( - { - error: { code: "unauthorized", message: "Authentication required" }, - }, - 401, - ); + return unauthorizedResponse(c); } const tokenId = c.req.param("tokenId"); @@ -720,10 +649,7 @@ export function createMeGitTokenRoutes({ tokenId, }, ); - return c.json( - { error: { code: "not_found", message: "Token not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Token not found"); } if (existing.revokedAt === null) { diff --git a/vendor/intx/hub-api/src/routes/git-user-principal.ts b/vendor/intx/hub-api/src/routes/git-user-principal.ts new file mode 100644 index 0000000000..5a2a4f549a --- /dev/null +++ b/vendor/intx/hub-api/src/routes/git-user-principal.ts @@ -0,0 +1,116 @@ +/** + * Shared smart-HTTP glue for the bearer-token git route groups + * (`assets.ts` and `agent-state-git.ts`). + * + * Both route groups resolve a URL to a `RepoId`, pre-resolve the + * authz verdict against the grant store, and construct a + * `UserPrincipal` carrying that verdict so the substrate's authorize + * gate only sanity-checks rather than re-querying. The verdict + * resolution, principal construction, and the two substrate adapters + * are identical between the groups — the only per-kind input is the + * verdict's `resource` string (`asset:` vs + * `agent-state:`). Keeping them here, rather than copy-pasted + * into each route file, stops the security-critical claim/verdict + * cross-check boundary from drifting between kinds. + * + * Bearer-claim `expiresAt` is a `Date` on the wire; the substrate's + * `UserPrincipal.tokenClaims.expiresAt` is a `number`. The Date → + * number conversion happens exactly once, at the route handler + * boundary, in `buildUserPrincipal`. + */ + +import { authorize } from "@intx/authz"; +import { repoActionToGrantVerb } from "@intx/hub-common"; +import type { RefEntry, RepoStore, UserPrincipal } from "@intx/hub-sessions"; +import type { RepoAction } from "@intx/types/sidecar"; +import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; + +import type { GitTokenClaims } from "../middleware/git-token-auth"; +import type { RefSource } from "../git-http/advertise-refs"; +import type { UploadPackRepoStore } from "../git-http/upload-pack"; + +function dateToNumber(d: Date): number { + return d.getTime(); +} + +/** + * Pre-resolve the grant verdict for a user-token-authenticated + * smart-HTTP request. `resource` is the full verdict resource string + * for this kind (`asset:`, `agent-state:`); the grant verb is + * derived from `action`. The route layer attaches the returned + * verdict to the constructed `UserPrincipal`; the substrate does NOT + * re-query the grant store. + */ +export async function resolveAuthzVerdict(args: { + grantStore: GrantStore; + conditionRegistry: ConditionRegistry; + principalId: string; + tenantId: string; + resource: string; + action: RepoAction; +}): Promise { + const grantVerb = repoActionToGrantVerb(args.action); + const verdict = await authorize( + args.grantStore, + args.principalId, + args.tenantId, + args.resource, + grantVerb, + args.conditionRegistry, + ); + return { + effect: verdict.effect === "allow" ? "allow" : "deny", + resource: args.resource, + grantVerb, + }; +} + +export function buildUserPrincipal(args: { + principalId: string; + tenantId: string; + authz: UserPrincipal["authz"]; + claims: GitTokenClaims; +}): UserPrincipal { + return { + kind: "user", + principalId: args.principalId, + tenantId: args.tenantId, + authz: args.authz, + tokenClaims: { + refPattern: args.claims.refPattern, + actions: args.claims.actions, + expiresAt: dateToNumber(args.claims.expiresAt), + }, + }; +} + +// Substrate adapters: bridge the substrate's RepoStore to the narrow +// per-handler contracts that advertise-refs and upload-pack expose. + +export function makeRefSource( + repoStore: RepoStore, + principal: UserPrincipal, +): RefSource { + return { + async listRefs(_p, repoId): Promise { + return repoStore.listRefs(principal, repoId); + }, + async resolveHead(_p, repoId) { + return repoStore.resolveHead(principal, repoId); + }, + }; +} + +export function makeUploadPackStore( + repoStore: RepoStore, + principal: UserPrincipal, +): UploadPackRepoStore { + return { + async listRefs(_p, repoId): Promise { + return repoStore.listRefs(principal, repoId); + }, + async getRepoDir(_p, repoId): Promise { + return repoStore.getRepoDir(repoId); + }, + }; +} diff --git a/vendor/intx/hub-api/src/routes/grants.ts b/vendor/intx/hub-api/src/routes/grants.ts index e2cf3041a3..fd6c44084b 100644 --- a/vendor/intx/hub-api/src/routes/grants.ts +++ b/vendor/intx/hub-api/src/routes/grants.ts @@ -1,6 +1,6 @@ import { eq, and } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { authorize } from "@intx/authz"; import { grant, principal } from "@intx/db/schema"; @@ -9,15 +9,16 @@ import type { DB } from "@intx/db"; import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; import { CreateGrant, + ErrorResponse, UpdateGrant, GrantResponse, EvaluateRequest, EvaluateResult, - ErrorResponse, paginatedSchema, } from "@intx/types"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { first, ts } from "../format"; import { generateId } from "@intx/hub-common"; import { idResource } from "../middleware/grant"; @@ -30,6 +31,7 @@ import { paginatedResponse, pageParameters, } from "../pagination"; +import { jsonResponse } from "../openapi"; type ResolvedNames = { roleNames: Map; @@ -156,14 +158,7 @@ export function createGrantRoutes({ ...pageParameters, ], responses: { - 200: { - description: "List of grants", - content: { - "application/json": { - schema: resolver(paginatedSchema(GrantResponse)), - }, - }, - }, + 200: jsonResponse("List of grants", paginatedSchema(GrantResponse)), }, }), async (c) => { @@ -215,18 +210,8 @@ export function createGrantRoutes({ description: "Creates a grant targeting either a role or a principal directly. Exactly one of roleId or principalId must be provided.", responses: { - 201: { - description: "Grant created", - content: { - "application/json": { schema: resolver(GrantResponse) }, - }, - }, - 400: { - description: "Validation error", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("Grant created", GrantResponse), + 400: jsonResponse("Validation error", ErrorResponse), }, }), validator("json", CreateGrant), @@ -269,18 +254,8 @@ export function createGrantRoutes({ tags: ["Grants"], summary: "Get grant details", responses: { - 200: { - description: "Grant details", - content: { - "application/json": { schema: resolver(GrantResponse) }, - }, - }, - 404: { - description: "Grant not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Grant details", GrantResponse), + 404: jsonResponse("Grant not found", ErrorResponse), }, }), async (c) => { @@ -292,10 +267,7 @@ export function createGrantRoutes({ }); if (!row) { - return c.json( - { error: { code: "not_found", message: "Grant not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Grant not found"); } return c.json(formatGrant(row)); @@ -310,18 +282,8 @@ export function createGrantRoutes({ summary: "Update a grant", description: "Update effect, conditions, or expiry on an existing grant.", responses: { - 200: { - description: "Grant updated", - content: { - "application/json": { schema: resolver(GrantResponse) }, - }, - }, - 404: { - description: "Grant not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Grant updated", GrantResponse), + 404: jsonResponse("Grant not found", ErrorResponse), }, }), validator("json", UpdateGrant), @@ -345,10 +307,7 @@ export function createGrantRoutes({ .returning(); if (!updated) { - return c.json( - { error: { code: "not_found", message: "Grant not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Grant not found"); } return c.json(formatGrant(updated)); @@ -365,12 +324,7 @@ export function createGrantRoutes({ 204: { description: "Grant revoked", }, - 404: { - description: "Grant not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 404: jsonResponse("Grant not found", ErrorResponse), }, }), async (c) => { @@ -383,10 +337,7 @@ export function createGrantRoutes({ .returning(); if (deleted.length === 0) { - return c.json( - { error: { code: "not_found", message: "Grant not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Grant not found"); } return c.body(null, 204); @@ -418,18 +369,8 @@ export function createEvaluateRoutes({ description: "Evaluates what would happen if a principal attempted an operation. Returns the resolved effect and all matching grants. Useful for debugging authorization.", responses: { - 200: { - description: "Evaluation result", - content: { - "application/json": { schema: resolver(EvaluateResult) }, - }, - }, - 404: { - description: "Principal not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Evaluation result", EvaluateResult), + 404: jsonResponse("Principal not found", ErrorResponse), }, }), validator("json", EvaluateRequest), @@ -445,10 +386,7 @@ export function createEvaluateRoutes({ }); if (!principalRow) { - return c.json( - { error: { code: "not_found", message: "Principal not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Principal not found"); } const result = await authorize( diff --git a/vendor/intx/hub-api/src/routes/me.ts b/vendor/intx/hub-api/src/routes/me.ts index cc41d1fd4a..15bbc1ae82 100644 --- a/vendor/intx/hub-api/src/routes/me.ts +++ b/vendor/intx/hub-api/src/routes/me.ts @@ -1,21 +1,21 @@ import { type SQL, eq, and, inArray, isNotNull, isNull } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute, resolver } from "hono-openapi"; +import { describeRoute } from "hono-openapi"; import { principal, workflowDefinition, workflowRun } from "@intx/db/schema"; import { parsePrincipalRow } from "@intx/db"; import type { DB } from "@intx/db"; import { UserProfile, + ErrorResponse, PrincipalSummary, WorkflowRunSummary, SessionSummary, ApprovalSummary, - ErrorResponse, paginatedSchema, } from "@intx/types"; -import type { AppEnv } from "../context"; +import { unauthorizedResponse, type AppEnv } from "../context"; import { ts } from "../format"; import { parsePageParams, @@ -24,6 +24,7 @@ import { paginatedResponse, pageParameters, } from "../pagination"; +import { jsonResponse } from "../openapi"; export type CreateMeRoutesDeps = { db: DB["db"]; @@ -38,29 +39,14 @@ export function createMeRoutes({ db }: CreateMeRoutesDeps): Hono { tags: ["User"], summary: "Get current user profile", responses: { - 200: { - description: "User profile", - content: { - "application/json": { schema: resolver(UserProfile) }, - }, - }, - 401: { - description: "Not authenticated", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("User profile", UserProfile), + 401: jsonResponse("Not authenticated", ErrorResponse), }, }), (c) => { const user = c.get("user"); if (!user) { - return c.json( - { - error: { code: "unauthorized", message: "Authentication required" }, - }, - 401, - ); + return unauthorizedResponse(c); } return c.json({ id: user.id, @@ -83,31 +69,17 @@ export function createMeRoutes({ db }: CreateMeRoutesDeps): Hono { "Returns all of the authenticated user's principals across tenants, with tenant name, roles, and status in each.", parameters: [...pageParameters], responses: { - 200: { - description: "List of principals across tenants", - content: { - "application/json": { - schema: resolver(paginatedSchema(PrincipalSummary)), - }, - }, - }, - 401: { - description: "Not authenticated", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse( + "List of principals across tenants", + paginatedSchema(PrincipalSummary), + ), + 401: jsonResponse("Not authenticated", ErrorResponse), }, }), async (c) => { const user = c.get("user"); if (!user) { - return c.json( - { - error: { code: "unauthorized", message: "Authentication required" }, - }, - 401, - ); + return unauthorizedResponse(c); } const { limit, cursor } = parsePageParams({ cursor: c.req.query("cursor"), @@ -195,25 +167,16 @@ export function createMeRoutes({ db }: CreateMeRoutesDeps): Hono { "Aggregates running workflow runs from all tenants the user belongs to. Each result is tagged with tenantId.", parameters: [...pageParameters], responses: { - 200: { - description: "Runs across tenants", - content: { - "application/json": { - schema: resolver(paginatedSchema(WorkflowRunSummary)), - }, - }, - }, + 200: jsonResponse( + "Runs across tenants", + paginatedSchema(WorkflowRunSummary), + ), }, }), async (c) => { const user = c.get("user"); if (!user) { - return c.json( - { - error: { code: "unauthorized", message: "Authentication required" }, - }, - 401, - ); + return unauthorizedResponse(c); } const { limit, cursor } = parsePageParams({ cursor: c.req.query("cursor"), @@ -299,14 +262,7 @@ export function createMeRoutes({ db }: CreateMeRoutesDeps): Hono { description: "Aggregates active sessions from all tenants the user belongs to. Each result is tagged with tenantId.", responses: { - 200: { - description: "Sessions across tenants", - content: { - "application/json": { - schema: resolver(SessionSummary.array()), - }, - }, - }, + 200: jsonResponse("Sessions across tenants", SessionSummary.array()), }, }), (_c) => { @@ -323,14 +279,7 @@ export function createMeRoutes({ db }: CreateMeRoutesDeps): Hono { description: "Aggregates pending approval requests from all tenants the user belongs to. Each result is tagged with tenantId.", responses: { - 200: { - description: "Approvals across tenants", - content: { - "application/json": { - schema: resolver(ApprovalSummary.array()), - }, - }, - }, + 200: jsonResponse("Approvals across tenants", ApprovalSummary.array()), }, }), (_c) => { diff --git a/vendor/intx/hub-api/src/routes/model-offerings.ts b/vendor/intx/hub-api/src/routes/model-offerings.ts index 21e6b28a98..6625382737 100644 --- a/vendor/intx/hub-api/src/routes/model-offerings.ts +++ b/vendor/intx/hub-api/src/routes/model-offerings.ts @@ -1,6 +1,6 @@ import { eq, and } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { model, @@ -12,11 +12,11 @@ import type { DB } from "@intx/db"; import { parseModelOfferingRow } from "@intx/db"; import { CreateModelOffering, + ErrorResponse, UpdateModelOffering, CreatePricingRow, ModelOfferingResponse, PricingRowResponse, - ErrorResponse, paginatedSchema, } from "@intx/types"; import { @@ -26,6 +26,7 @@ import { import type { CredentialCipher } from "@intx/types"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { first, ts } from "../format"; import { generateId } from "@intx/hub-common"; import { idResource } from "../middleware/grant"; @@ -37,6 +38,7 @@ import { paginatedResponse, pageParameters, } from "../pagination"; +import { jsonResponse } from "../openapi"; export function formatModelOffering(row: typeof modelOffering.$inferSelect) { // Validate the row once so the jsonb `quirks` is narrowed from `unknown` @@ -102,14 +104,10 @@ export function createModelOfferingRoutes({ "Lists the model offerings created directly on this tenant. Offerings inherited from ancestor tenants are not included; use the model discovery endpoint to see the resolved catalog.", parameters: [...pageParameters], responses: { - 200: { - description: "List of model offerings", - content: { - "application/json": { - schema: resolver(paginatedSchema(ModelOfferingResponse)), - }, - }, - }, + 200: jsonResponse( + "List of model offerings", + paginatedSchema(ModelOfferingResponse), + ), }, }), async (c) => { @@ -146,24 +144,15 @@ export function createModelOfferingRoutes({ description: "Pairs a tenant-owned model with a tenant-owned provider. To offer an inherited model or provider, first create a tenant-local copy of it (shadowing).", responses: { - 201: { - description: "Offering created", - content: { - "application/json": { schema: resolver(ModelOfferingResponse) }, - }, - }, - 404: { - description: "Model or provider not found in this tenant", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 409: { - description: "Offering already exists for this model and provider", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("Offering created", ModelOfferingResponse), + 404: jsonResponse( + "Model or provider not found in this tenant", + ErrorResponse, + ), + 409: jsonResponse( + "Offering already exists for this model and provider", + ErrorResponse, + ), }, }), validator("json", CreateModelOffering), @@ -178,15 +167,7 @@ export function createModelOfferingRoutes({ ), }); if (!modelRow) { - return c.json( - { - error: { - code: "not_found", - message: "Model not found in this tenant", - }, - }, - 404, - ); + return errorResponse(c, "not_found", "Model not found in this tenant"); } const providerRow = await db.query.modelProvider.findFirst({ @@ -196,14 +177,10 @@ export function createModelOfferingRoutes({ ), }); if (!providerRow) { - return c.json( - { - error: { - code: "not_found", - message: "Provider not found in this tenant", - }, - }, - 404, + return errorResponse( + c, + "not_found", + "Provider not found in this tenant", ); } @@ -215,14 +192,10 @@ export function createModelOfferingRoutes({ ), }); if (existing) { - return c.json( - { - error: { - code: "conflict", - message: "An offering for this model and provider already exists", - }, - }, - 409, + return errorResponse( + c, + "conflict", + "An offering for this model and provider already exists", ); } @@ -262,18 +235,8 @@ export function createModelOfferingRoutes({ tags: ["Catalog"], summary: "Get a model offering", responses: { - 200: { - description: "Offering details", - content: { - "application/json": { schema: resolver(ModelOfferingResponse) }, - }, - }, - 404: { - description: "Offering not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Offering details", ModelOfferingResponse), + 404: jsonResponse("Offering not found", ErrorResponse), }, }), async (c) => { @@ -286,10 +249,7 @@ export function createModelOfferingRoutes({ ), }); if (!row) { - return c.json( - { error: { code: "not_found", message: "Offering not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Offering not found"); } return c.json(formatModelOffering(row)); }, @@ -302,18 +262,8 @@ export function createModelOfferingRoutes({ tags: ["Catalog"], summary: "Update a model offering", responses: { - 200: { - description: "Offering updated", - content: { - "application/json": { schema: resolver(ModelOfferingResponse) }, - }, - }, - 404: { - description: "Offering not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Offering updated", ModelOfferingResponse), + 404: jsonResponse("Offering not found", ErrorResponse), }, }), validator("json", UpdateModelOffering), @@ -343,10 +293,7 @@ export function createModelOfferingRoutes({ .returning(); if (!updated) { - return c.json( - { error: { code: "not_found", message: "Offering not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Offering not found"); } void pushSourceUpdatesSubtree( @@ -369,12 +316,7 @@ export function createModelOfferingRoutes({ "Removes the offering and its pricing history. Running instances resolved through it fail over to the next eligible source.", responses: { 204: { description: "Offering removed" }, - 404: { - description: "Offering not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 404: jsonResponse("Offering not found", ErrorResponse), }, }), async (c) => { @@ -390,10 +332,7 @@ export function createModelOfferingRoutes({ ) .returning(); if (deleted.length === 0) { - return c.json( - { error: { code: "not_found", message: "Offering not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Offering not found"); } void pushSourceUpdatesSubtree( db, @@ -414,20 +353,8 @@ export function createModelOfferingRoutes({ description: "Returns the full append-only pricing history for an offering, every currency and effective-from date, newest first.", responses: { - 200: { - description: "Pricing rows", - content: { - "application/json": { - schema: resolver(PricingRowResponse.array()), - }, - }, - }, - 404: { - description: "Offering not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Pricing rows", PricingRowResponse.array()), + 404: jsonResponse("Offering not found", ErrorResponse), }, }), async (c) => { @@ -441,10 +368,7 @@ export function createModelOfferingRoutes({ ), }); if (!offeringRow) { - return c.json( - { error: { code: "not_found", message: "Offering not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Offering not found"); } const rows = await db.query.modelPricing.findMany({ @@ -464,31 +388,16 @@ export function createModelOfferingRoutes({ description: "Appends a pricing row. Pricing is append-only: a price change inserts a new row with a later effective-from rather than editing an existing one, so historical cost attribution stays accurate.", responses: { - 201: { - description: "Pricing row created", - content: { - "application/json": { schema: resolver(PricingRowResponse) }, - }, - }, - 400: { - description: "effectiveFrom is not a valid timestamp", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 404: { - description: "Offering not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 409: { - description: - "A pricing row already exists for this currency and effective-from", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("Pricing row created", PricingRowResponse), + 400: jsonResponse( + "effectiveFrom is not a valid timestamp", + ErrorResponse, + ), + 404: jsonResponse("Offering not found", ErrorResponse), + 409: jsonResponse( + "A pricing row already exists for this currency and effective-from", + ErrorResponse, + ), }, }), validator("json", CreatePricingRow), @@ -504,24 +413,17 @@ export function createModelOfferingRoutes({ ), }); if (!offeringRow) { - return c.json( - { error: { code: "not_found", message: "Offering not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Offering not found"); } let effectiveFrom = new Date(); if (body.effectiveFrom !== undefined) { effectiveFrom = new Date(body.effectiveFrom); if (Number.isNaN(effectiveFrom.valueOf())) { - return c.json( - { - error: { - code: "invalid_request", - message: "effectiveFrom must be a valid ISO-8601 timestamp", - }, - }, - 400, + return errorResponse( + c, + "invalid_request", + "effectiveFrom must be a valid ISO-8601 timestamp", ); } } @@ -534,15 +436,10 @@ export function createModelOfferingRoutes({ ), }); if (existing) { - return c.json( - { - error: { - code: "conflict", - message: - "A pricing row already exists for this currency and effective-from", - }, - }, - 409, + return errorResponse( + c, + "conflict", + "A pricing row already exists for this currency and effective-from", ); } diff --git a/vendor/intx/hub-api/src/routes/model-providers.ts b/vendor/intx/hub-api/src/routes/model-providers.ts index 75234dead1..39700f0c76 100644 --- a/vendor/intx/hub-api/src/routes/model-providers.ts +++ b/vendor/intx/hub-api/src/routes/model-providers.ts @@ -1,15 +1,15 @@ import { eq, and, ne } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { modelProvider } from "@intx/db/schema"; import { resolveTenantOwnedCredentialById } from "@intx/db"; import type { DB } from "@intx/db"; import { CreateModelProvider, + ErrorResponse, UpdateModelProvider, ModelProviderResponse, - ErrorResponse, paginatedSchema, } from "@intx/types"; import { @@ -19,6 +19,7 @@ import { import type { CredentialCipher } from "@intx/types"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { first, ts } from "../format"; import { generateId } from "@intx/hub-common"; import { idResource } from "../middleware/grant"; @@ -30,6 +31,7 @@ import { paginatedResponse, pageParameters, } from "../pagination"; +import { jsonResponse } from "../openapi"; export function formatModelProvider(row: typeof modelProvider.$inferSelect) { return { @@ -71,14 +73,10 @@ export function createModelProviderRoutes({ "Lists the model providers created directly on this tenant. Providers inherited from ancestor tenants are not included.", parameters: [...pageParameters], responses: { - 200: { - description: "List of model providers", - content: { - "application/json": { - schema: resolver(paginatedSchema(ModelProviderResponse)), - }, - }, - }, + 200: jsonResponse( + "List of model providers", + paginatedSchema(ModelProviderResponse), + ), }, }), async (c) => { @@ -115,30 +113,16 @@ export function createModelProviderRoutes({ description: "Registers an inference endpoint authenticated by exactly one of a credential or a wallet. Supplying both or neither is rejected.", responses: { - 201: { - description: "Provider created", - content: { - "application/json": { schema: resolver(ModelProviderResponse) }, - }, - }, - 400: { - description: "Exactly one of credentialId or walletId is required", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 404: { - description: "Credential not found in this tenant", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 409: { - description: "Provider name already exists in this tenant", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("Provider created", ModelProviderResponse), + 400: jsonResponse( + "Exactly one of credentialId or walletId is required", + ErrorResponse, + ), + 404: jsonResponse("Credential not found in this tenant", ErrorResponse), + 409: jsonResponse( + "Provider name already exists in this tenant", + ErrorResponse, + ), }, }), validator("json", CreateModelProvider), @@ -165,15 +149,10 @@ export function createModelProviderRoutes({ const hasCredential = credentialId !== null; const hasWallet = walletId !== null; if (hasCredential === hasWallet) { - return c.json( - { - error: { - code: "invalid_request", - message: - "Exactly one of credentialId or walletId must be provided", - }, - }, - 400, + return errorResponse( + c, + "invalid_request", + "Exactly one of credentialId or walletId must be provided", ); } @@ -194,15 +173,10 @@ export function createModelProviderRoutes({ credentialId, ); if (cred === null) { - return c.json( - { - error: { - code: "not_found", - message: - "Credential not found in this tenant, or not a tenant-owned credential (a catalog provider requires a tenant-owned credential)", - }, - }, - 404, + return errorResponse( + c, + "not_found", + "Credential not found in this tenant, or not a tenant-owned credential (a catalog provider requires a tenant-owned credential)", ); } } @@ -214,14 +188,10 @@ export function createModelProviderRoutes({ ), }); if (existing) { - return c.json( - { - error: { - code: "conflict", - message: "Provider name already exists in this tenant", - }, - }, - 409, + return errorResponse( + c, + "conflict", + "Provider name already exists in this tenant", ); } @@ -254,18 +224,8 @@ export function createModelProviderRoutes({ tags: ["Catalog"], summary: "Get a model provider", responses: { - 200: { - description: "Provider details", - content: { - "application/json": { schema: resolver(ModelProviderResponse) }, - }, - }, - 404: { - description: "Provider not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Provider details", ModelProviderResponse), + 404: jsonResponse("Provider not found", ErrorResponse), }, }), async (c) => { @@ -278,10 +238,7 @@ export function createModelProviderRoutes({ ), }); if (!row) { - return c.json( - { error: { code: "not_found", message: "Provider not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Provider not found"); } return c.json(formatModelProvider(row)); }, @@ -296,24 +253,12 @@ export function createModelProviderRoutes({ description: "Updates a provider's name, base URL, or disabled flag. Changing the authentication binding is not supported; delete and recreate the provider to repoint it.", responses: { - 200: { - description: "Provider updated", - content: { - "application/json": { schema: resolver(ModelProviderResponse) }, - }, - }, - 404: { - description: "Provider not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 409: { - description: "Provider name already exists in this tenant", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Provider updated", ModelProviderResponse), + 404: jsonResponse("Provider not found", ErrorResponse), + 409: jsonResponse( + "Provider name already exists in this tenant", + ErrorResponse, + ), }, }), validator("json", UpdateModelProvider), @@ -331,14 +276,10 @@ export function createModelProviderRoutes({ ), }); if (clash) { - return c.json( - { - error: { - code: "conflict", - message: "Provider name already exists in this tenant", - }, - }, - 409, + return errorResponse( + c, + "conflict", + "Provider name already exists in this tenant", ); } } @@ -360,10 +301,7 @@ export function createModelProviderRoutes({ .returning(); if (!updated) { - return c.json( - { error: { code: "not_found", message: "Provider not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Provider not found"); } void pushSourceUpdatesSubtree( @@ -386,12 +324,7 @@ export function createModelProviderRoutes({ "Removes the provider and cascades to the offerings that reference it. Running instances resolved through those offerings fail over to the next eligible source.", responses: { 204: { description: "Provider removed" }, - 404: { - description: "Provider not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 404: jsonResponse("Provider not found", ErrorResponse), }, }), async (c) => { @@ -407,10 +340,7 @@ export function createModelProviderRoutes({ ) .returning(); if (deleted.length === 0) { - return c.json( - { error: { code: "not_found", message: "Provider not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Provider not found"); } void pushSourceUpdatesSubtree( db, diff --git a/vendor/intx/hub-api/src/routes/models.ts b/vendor/intx/hub-api/src/routes/models.ts index 4211300d85..72134f66e7 100644 --- a/vendor/intx/hub-api/src/routes/models.ts +++ b/vendor/intx/hub-api/src/routes/models.ts @@ -1,16 +1,16 @@ import { eq, and, inArray } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { model, modelPricing } from "@intx/db/schema"; import { listVisibleOfferings, resolveActivePrice } from "@intx/db"; import type { DB, ModelPricingRow, ResolvedOffering } from "@intx/db"; import { CreateModel, + ErrorResponse, UpdateModel, ModelResponse, ModelInfo, - ErrorResponse, paginatedSchema, } from "@intx/types"; import { @@ -20,6 +20,7 @@ import { import type { CredentialCipher } from "@intx/types"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { first, ts } from "../format"; import { generateId } from "@intx/hub-common"; import { idResource } from "../middleware/grant"; @@ -32,6 +33,7 @@ import { pageParameters, } from "../pagination"; import { formatPricingRow } from "./model-offerings"; +import { jsonResponse } from "../openapi"; export function formatModel(row: typeof model.$inferSelect) { return { @@ -71,14 +73,7 @@ export function createModelCatalogRoutes({ "Lists the models created directly on this tenant. Models inherited from ancestor tenants are not included; use the model discovery endpoint to see the resolved catalog.", parameters: [...pageParameters], responses: { - 200: { - description: "List of models", - content: { - "application/json": { - schema: resolver(paginatedSchema(ModelResponse)), - }, - }, - }, + 200: jsonResponse("List of models", paginatedSchema(ModelResponse)), }, }), async (c) => { @@ -111,18 +106,11 @@ export function createModelCatalogRoutes({ description: "Creates a tenant-local model. Reusing the canonical name of an inherited model shadows that model for this tenant and its descendants.", responses: { - 201: { - description: "Model created", - content: { - "application/json": { schema: resolver(ModelResponse) }, - }, - }, - 409: { - description: "A model with this canonical name already exists", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("Model created", ModelResponse), + 409: jsonResponse( + "A model with this canonical name already exists", + ErrorResponse, + ), }, }), validator("json", CreateModel), @@ -137,14 +125,10 @@ export function createModelCatalogRoutes({ ), }); if (existing) { - return c.json( - { - error: { - code: "conflict", - message: "A model with this canonical name already exists", - }, - }, - 409, + return errorResponse( + c, + "conflict", + "A model with this canonical name already exists", ); } @@ -181,18 +165,8 @@ export function createModelCatalogRoutes({ tags: ["Catalog"], summary: "Get a model", responses: { - 200: { - description: "Model details", - content: { - "application/json": { schema: resolver(ModelResponse) }, - }, - }, - 404: { - description: "Model not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Model details", ModelResponse), + 404: jsonResponse("Model not found", ErrorResponse), }, }), async (c) => { @@ -202,10 +176,7 @@ export function createModelCatalogRoutes({ where: and(eq(model.id, modelId), eq(model.tenantId, tenantCtx.id)), }); if (!row) { - return c.json( - { error: { code: "not_found", message: "Model not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Model not found"); } return c.json(formatModel(row)); }, @@ -218,18 +189,8 @@ export function createModelCatalogRoutes({ tags: ["Catalog"], summary: "Update a model", responses: { - 200: { - description: "Model updated", - content: { - "application/json": { schema: resolver(ModelResponse) }, - }, - }, - 404: { - description: "Model not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Model updated", ModelResponse), + 404: jsonResponse("Model not found", ErrorResponse), }, }), validator("json", UpdateModel), @@ -252,10 +213,7 @@ export function createModelCatalogRoutes({ .returning(); if (!updated) { - return c.json( - { error: { code: "not_found", message: "Model not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Model not found"); } void pushSourceUpdatesSubtree( @@ -278,12 +236,7 @@ export function createModelCatalogRoutes({ "Removes the model and cascades to the offerings that reference it. Running instances resolved through those offerings fail over to the next eligible source.", responses: { 204: { description: "Model removed" }, - 404: { - description: "Model not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 404: jsonResponse("Model not found", ErrorResponse), }, }), async (c) => { @@ -294,10 +247,7 @@ export function createModelCatalogRoutes({ .where(and(eq(model.id, modelId), eq(model.tenantId, tenantCtx.id))) .returning(); if (deleted.length === 0) { - return c.json( - { error: { code: "not_found", message: "Model not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Model not found"); } void pushSourceUpdatesSubtree( db, @@ -405,12 +355,7 @@ export function createModelDiscoveryRoutes({ description: "Returns the tenant's resolved catalog: every model visible after applying inheritance, shadowing, and disable suppression, broken down by the providers that offer it with each offering's active price per currency.", responses: { - 200: { - description: "Resolved models", - content: { - "application/json": { schema: resolver(ModelInfo.array()) }, - }, - }, + 200: jsonResponse("Resolved models", ModelInfo.array()), }, }), async (c) => { diff --git a/vendor/intx/hub-api/src/routes/oauth-clients.ts b/vendor/intx/hub-api/src/routes/oauth-clients.ts index 541e7c3b5a..829663ffc8 100644 --- a/vendor/intx/hub-api/src/routes/oauth-clients.ts +++ b/vendor/intx/hub-api/src/routes/oauth-clients.ts @@ -1,21 +1,22 @@ import { eq, and } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { oauthClient, provider } from "@intx/db/schema"; import { getAncestorChain, parseOAuthClientRow } from "@intx/db"; import type { DB } from "@intx/db"; import { CreateOAuthClient, + ErrorResponse, UpdateOAuthClient, OAuthClientResponse, - ErrorResponse, paginatedSchema, credentialAad, } from "@intx/types"; import type { CredentialCipher } from "@intx/types"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { first, ts } from "../format"; import { generateId } from "@intx/hub-common"; import { idResource } from "../middleware/grant"; @@ -27,6 +28,7 @@ import { paginatedResponse, pageParameters, } from "../pagination"; +import { jsonResponse } from "../openapi"; function formatOAuthClient(row: typeof oauthClient.$inferSelect) { const parsed = parseOAuthClientRow(row); @@ -66,14 +68,10 @@ export function createOAuthClientRoutes({ "Lists OAuth client registrations for the tenant. Secrets are never returned.", parameters: [...pageParameters], responses: { - 200: { - description: "List of OAuth clients", - content: { - "application/json": { - schema: resolver(paginatedSchema(OAuthClientResponse)), - }, - }, - }, + 200: jsonResponse( + "List of OAuth clients", + paginatedSchema(OAuthClientResponse), + ), }, }), async (c) => { @@ -111,31 +109,13 @@ export function createOAuthClientRoutes({ description: "Registers an OAuth client (client_id/client_secret) for a provider. The provider must exist in the tenant or its ancestors.", responses: { - 201: { - description: "OAuth client registered", - content: { - "application/json": { schema: resolver(OAuthClientResponse) }, - }, - }, - 400: { - description: "Validation error", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 404: { - description: "Provider not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 409: { - description: - "OAuth client already exists for this provider in this tenant", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("OAuth client registered", OAuthClientResponse), + 400: jsonResponse("Validation error", ErrorResponse), + 404: jsonResponse("Provider not found", ErrorResponse), + 409: jsonResponse( + "OAuth client already exists for this provider in this tenant", + ErrorResponse, + ), }, }), validator("json", CreateOAuthClient), @@ -147,18 +127,12 @@ export function createOAuthClientRoutes({ where: eq(provider.id, body.providerId), }); if (!providerRow) { - return c.json( - { error: { code: "not_found", message: "Provider not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Provider not found"); } const chain = await getAncestorChain(db, tenantCtx.id); if (!chain.includes(providerRow.tenantId)) { - return c.json( - { error: { code: "not_found", message: "Provider not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Provider not found"); } const existing = await db.query.oauthClient.findFirst({ @@ -168,15 +142,10 @@ export function createOAuthClientRoutes({ ), }); if (existing) { - return c.json( - { - error: { - code: "conflict", - message: - "OAuth client already exists for this provider in this tenant", - }, - }, - 409, + return errorResponse( + c, + "conflict", + "OAuth client already exists for this provider in this tenant", ); } @@ -217,18 +186,8 @@ export function createOAuthClientRoutes({ summary: "Get OAuth client details", description: "Returns OAuth client metadata. Secrets are never included.", responses: { - 200: { - description: "OAuth client details", - content: { - "application/json": { schema: resolver(OAuthClientResponse) }, - }, - }, - 404: { - description: "OAuth client not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("OAuth client details", OAuthClientResponse), + 404: jsonResponse("OAuth client not found", ErrorResponse), }, }), async (c) => { @@ -243,10 +202,7 @@ export function createOAuthClientRoutes({ }); if (!row) { - return c.json( - { error: { code: "not_found", message: "OAuth client not found" } }, - 404, - ); + return errorResponse(c, "not_found", "OAuth client not found"); } return c.json(formatOAuthClient(row)); @@ -260,18 +216,8 @@ export function createOAuthClientRoutes({ tags: ["OAuth Clients"], summary: "Update an OAuth client registration", responses: { - 200: { - description: "OAuth client updated", - content: { - "application/json": { schema: resolver(OAuthClientResponse) }, - }, - }, - 404: { - description: "OAuth client not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("OAuth client updated", OAuthClientResponse), + 404: jsonResponse("OAuth client not found", ErrorResponse), }, }), validator("json", UpdateOAuthClient), @@ -306,10 +252,7 @@ export function createOAuthClientRoutes({ .returning(); if (!updated) { - return c.json( - { error: { code: "not_found", message: "OAuth client not found" } }, - 404, - ); + return errorResponse(c, "not_found", "OAuth client not found"); } return c.json(formatOAuthClient(updated)); @@ -324,12 +267,7 @@ export function createOAuthClientRoutes({ summary: "Remove an OAuth client registration", responses: { 204: { description: "OAuth client removed" }, - 404: { - description: "OAuth client not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 404: jsonResponse("OAuth client not found", ErrorResponse), }, }), async (c) => { @@ -347,10 +285,7 @@ export function createOAuthClientRoutes({ .returning(); if (deleted.length === 0) { - return c.json( - { error: { code: "not_found", message: "OAuth client not found" } }, - 404, - ); + return errorResponse(c, "not_found", "OAuth client not found"); } return c.body(null, 204); diff --git a/vendor/intx/hub-api/src/routes/observability.ts b/vendor/intx/hub-api/src/routes/observability.ts index f45e54a40b..a9208e445e 100644 --- a/vendor/intx/hub-api/src/routes/observability.ts +++ b/vendor/intx/hub-api/src/routes/observability.ts @@ -1,14 +1,16 @@ import { Hono } from "hono"; -import { describeRoute, resolver } from "hono-openapi"; +import { describeRoute } from "hono-openapi"; import { LogEntry, + ErrorResponse, MetricsResponse, TraceResponse, SpanResponse, - ErrorResponse, } from "@intx/types"; import type { AppEnv } from "../context"; +import { errorResponse } from "../error-response"; +import { jsonResponse } from "../openapi"; export function createObservabilityRoutes(): Hono { const app = new Hono(); @@ -30,27 +32,11 @@ export function createObservabilityRoutes(): Hono { { name: "endTime", in: "query", schema: { type: "string" } }, ], responses: { - 200: { - description: "Log entries", - content: { - "application/json": { - schema: resolver(LogEntry.array()), - }, - }, - }, - 404: { - description: "Agent not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Log entries", LogEntry.array()), + 404: jsonResponse("Agent not found", ErrorResponse), }, }), - (c) => - c.json( - { error: { code: "not_implemented", message: "Not implemented" } }, - 501, - ), + (c) => errorResponse(c, "not_implemented", "Not implemented"), ); app.get( @@ -61,25 +47,11 @@ export function createObservabilityRoutes(): Hono { description: "Returns throughput, latency, error rates, token usage, and cost metrics.", responses: { - 200: { - description: "Agent metrics", - content: { - "application/json": { schema: resolver(MetricsResponse) }, - }, - }, - 404: { - description: "Agent not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Agent metrics", MetricsResponse), + 404: jsonResponse("Agent not found", ErrorResponse), }, }), - (c) => - c.json( - { error: { code: "not_implemented", message: "Not implemented" } }, - 501, - ), + (c) => errorResponse(c, "not_implemented", "Not implemented"), ); app.get( @@ -97,21 +69,10 @@ export function createObservabilityRoutes(): Hono { { name: "endTime", in: "query", schema: { type: "string" } }, ], responses: { - 200: { - description: "List of traces", - content: { - "application/json": { - schema: resolver(SpanResponse.array()), - }, - }, - }, + 200: jsonResponse("List of traces", SpanResponse.array()), }, }), - (c) => - c.json( - { error: { code: "not_implemented", message: "Not implemented" } }, - 501, - ), + (c) => errorResponse(c, "not_implemented", "Not implemented"), ); app.get( @@ -121,25 +82,11 @@ export function createObservabilityRoutes(): Hono { summary: "Get a full trace", description: "Returns all spans in a trace across agent boundaries.", responses: { - 200: { - description: "Trace with spans", - content: { - "application/json": { schema: resolver(TraceResponse) }, - }, - }, - 404: { - description: "Trace not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Trace with spans", TraceResponse), + 404: jsonResponse("Trace not found", ErrorResponse), }, }), - (c) => - c.json( - { error: { code: "not_implemented", message: "Not implemented" } }, - 501, - ), + (c) => errorResponse(c, "not_implemented", "Not implemented"), ); return app; diff --git a/vendor/intx/hub-api/src/routes/offerings.ts b/vendor/intx/hub-api/src/routes/offerings.ts index d80259883d..07ee60569d 100644 --- a/vendor/intx/hub-api/src/routes/offerings.ts +++ b/vendor/intx/hub-api/src/routes/offerings.ts @@ -1,19 +1,20 @@ import { eq, and, ilike } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { offering, workflowDefinition } from "@intx/db/schema"; import { parseOfferingRow } from "@intx/db"; import type { DB } from "@intx/db"; import { CreateOffering, + ErrorResponse, UpdateOffering, OfferingDetail, - ErrorResponse, paginatedSchema, } from "@intx/types"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { first } from "../format"; import { generateId } from "@intx/hub-common"; import { idResource } from "../middleware/grant"; @@ -25,6 +26,7 @@ import { paginatedResponse, pageParameters, } from "../pagination"; +import { jsonResponse } from "../openapi"; export function formatOffering( row: typeof offering.$inferSelect, @@ -70,14 +72,7 @@ export function createOfferingRoutes({ ...pageParameters, ], responses: { - 200: { - description: "List of offerings", - content: { - "application/json": { - schema: resolver(paginatedSchema(OfferingDetail)), - }, - }, - }, + 200: jsonResponse("List of offerings", paginatedSchema(OfferingDetail)), }, }), async (c) => { @@ -134,24 +129,9 @@ export function createOfferingRoutes({ description: "Registers an offering for a workflow definition. The definition must belong to the tenant.", responses: { - 201: { - description: "Offering registered", - content: { - "application/json": { schema: resolver(OfferingDetail) }, - }, - }, - 400: { - description: "Validation error", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 404: { - description: "Workflow definition not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("Offering registered", OfferingDetail), + 400: jsonResponse("Validation error", ErrorResponse), + 404: jsonResponse("Workflow definition not found", ErrorResponse), }, }), validator("json", CreateOffering), @@ -168,14 +148,10 @@ export function createOfferingRoutes({ }); if (!definitionRow) { - return c.json( - { - error: { - code: "not_found", - message: "Workflow definition not found in this tenant", - }, - }, - 404, + return errorResponse( + c, + "not_found", + "Workflow definition not found in this tenant", ); } @@ -210,18 +186,8 @@ export function createOfferingRoutes({ description: "Returns pricing, definition info, and request/response type information.", responses: { - 200: { - description: "Offering details", - content: { - "application/json": { schema: resolver(OfferingDetail) }, - }, - }, - 404: { - description: "Offering not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Offering details", OfferingDetail), + 404: jsonResponse("Offering not found", ErrorResponse), }, }), async (c) => { @@ -236,10 +202,7 @@ export function createOfferingRoutes({ }); if (!row) { - return c.json( - { error: { code: "not_found", message: "Offering not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Offering not found"); } const definitionRow = await db.query.workflowDefinition.findFirst({ @@ -257,18 +220,8 @@ export function createOfferingRoutes({ tags: ["Discovery"], summary: "Update an offering", responses: { - 200: { - description: "Offering updated", - content: { - "application/json": { schema: resolver(OfferingDetail) }, - }, - }, - 404: { - description: "Offering not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Offering updated", OfferingDetail), + 404: jsonResponse("Offering not found", ErrorResponse), }, }), validator("json", UpdateOffering), @@ -293,10 +246,7 @@ export function createOfferingRoutes({ .returning(); if (!updated) { - return c.json( - { error: { code: "not_found", message: "Offering not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Offering not found"); } const definitionRow = await db.query.workflowDefinition.findFirst({ @@ -319,12 +269,7 @@ export function createOfferingRoutes({ 204: { description: "Offering removed", }, - 404: { - description: "Offering not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 404: jsonResponse("Offering not found", ErrorResponse), }, }), async (c) => { @@ -339,10 +284,7 @@ export function createOfferingRoutes({ .returning(); if (deleted.length === 0) { - return c.json( - { error: { code: "not_found", message: "Offering not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Offering not found"); } return c.body(null, 204); diff --git a/vendor/intx/hub-api/src/routes/principals.ts b/vendor/intx/hub-api/src/routes/principals.ts index cdf8ced236..74624f3a9c 100644 --- a/vendor/intx/hub-api/src/routes/principals.ts +++ b/vendor/intx/hub-api/src/routes/principals.ts @@ -1,19 +1,20 @@ import { eq, ne, and } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { principal, principalRole, role, user } from "@intx/db/schema"; import { createPrincipalStore, parsePrincipalRow } from "@intx/db"; import type { DB, PrincipalKeyStore } from "@intx/db"; import { PrincipalResponse, + ErrorResponse, UpdatePrincipal, InviteMember, - ErrorResponse, paginatedSchema, } from "@intx/types"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { ts } from "../format"; import { generateId } from "@intx/hub-common"; import { idResource } from "../middleware/grant"; @@ -26,6 +27,7 @@ import { paginatedResponse, pageParameters, } from "../pagination"; +import { jsonResponse } from "../openapi"; type ResolvedIdentity = { displayName: string; email?: string }; @@ -130,14 +132,10 @@ export function createPrincipalRoutes({ ...pageParameters, ], responses: { - 200: { - description: "List of principals", - content: { - "application/json": { - schema: resolver(paginatedSchema(PrincipalResponse)), - }, - }, - }, + 200: jsonResponse( + "List of principals", + paginatedSchema(PrincipalResponse), + ), }, }), async (c) => { @@ -231,18 +229,8 @@ export function createPrincipalRoutes({ description: "Returns principal details including kind, status, assigned roles, and effective grants.", responses: { - 200: { - description: "Principal details", - content: { - "application/json": { schema: resolver(PrincipalResponse) }, - }, - }, - 404: { - description: "Principal not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Principal details", PrincipalResponse), + 404: jsonResponse("Principal not found", ErrorResponse), }, }), async (c) => { @@ -257,10 +245,7 @@ export function createPrincipalRoutes({ }); if (!row) { - return c.json( - { error: { code: "not_found", message: "Principal not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Principal not found"); } const roles = await loadRolesForPrincipal(db, principalId); @@ -277,18 +262,8 @@ export function createPrincipalRoutes({ summary: "Update principal status", description: "Activate, suspend, or deactivate a principal.", responses: { - 200: { - description: "Principal updated", - content: { - "application/json": { schema: resolver(PrincipalResponse) }, - }, - }, - 403: { - description: "Insufficient grants", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Principal updated", PrincipalResponse), + 403: jsonResponse("Insufficient grants", ErrorResponse), }, }), validator("json", UpdatePrincipal), @@ -309,10 +284,7 @@ export function createPrincipalRoutes({ .returning(); if (!updated) { - return c.json( - { error: { code: "not_found", message: "Principal not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Principal not found"); } const roles = await loadRolesForPrincipal(db, principalId); @@ -334,12 +306,7 @@ export function createPrincipalRoutes({ 204: { description: "Principal removed", }, - 403: { - description: "Insufficient grants", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 403: jsonResponse("Insufficient grants", ErrorResponse), }, }), async (c) => { @@ -357,10 +324,7 @@ export function createPrincipalRoutes({ .returning(); if (deleted.length === 0) { - return c.json( - { error: { code: "not_found", message: "Principal not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Principal not found"); } return c.body(null, 204); @@ -394,18 +358,8 @@ export function createInviteRoutes({ description: "Invites a user by email. Creates a principal with invited status and optionally assigns a role.", responses: { - 201: { - description: "Invitation sent", - content: { - "application/json": { schema: resolver(PrincipalResponse) }, - }, - }, - 400: { - description: "Validation error", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("Invitation sent", PrincipalResponse), + 400: jsonResponse("Validation error", ErrorResponse), }, }), validator("json", InviteMember), @@ -418,15 +372,7 @@ export function createInviteRoutes({ }); if (!invitedUser) { - return c.json( - { - error: { - code: "not_found", - message: "No user found with that email", - }, - }, - 404, - ); + return errorResponse(c, "not_found", "No user found with that email"); } const existing = await db.query.principal.findFirst({ @@ -438,14 +384,10 @@ export function createInviteRoutes({ }); if (existing) { - return c.json( - { - error: { - code: "conflict", - message: "User is already a member of this tenant", - }, - }, - 409, + return errorResponse( + c, + "conflict", + "User is already a member of this tenant", ); } diff --git a/vendor/intx/hub-api/src/routes/providers.ts b/vendor/intx/hub-api/src/routes/providers.ts index e1e16a294e..124f6cf08e 100644 --- a/vendor/intx/hub-api/src/routes/providers.ts +++ b/vendor/intx/hub-api/src/routes/providers.ts @@ -1,19 +1,20 @@ import { eq, and } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { provider } from "@intx/db/schema"; import { getAncestorChain, parseProviderRow } from "@intx/db"; import type { DB } from "@intx/db"; import { CreateProvider, + ErrorResponse, UpdateProvider, ProviderResponse, - ErrorResponse, paginatedSchema, } from "@intx/types"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { first, ts } from "../format"; import { generateId } from "@intx/hub-common"; import { idResource } from "../middleware/grant"; @@ -25,6 +26,7 @@ import { paginatedResponse, pageParameters, } from "../pagination"; +import { jsonResponse } from "../openapi"; function formatProvider(row: typeof provider.$inferSelect) { const parsed = parseProviderRow(row); @@ -72,14 +74,10 @@ export function createProviderRoutes({ ...pageParameters, ], responses: { - 200: { - description: "List of providers", - content: { - "application/json": { - schema: resolver(paginatedSchema(ProviderResponse)), - }, - }, - }, + 200: jsonResponse( + "List of providers", + paginatedSchema(ProviderResponse), + ), }, }), async (c) => { @@ -136,24 +134,12 @@ export function createProviderRoutes({ description: "Defines a new service provider for the tenant. The plugin field determines how Interchange integrates with the service.", responses: { - 201: { - description: "Provider created", - content: { - "application/json": { schema: resolver(ProviderResponse) }, - }, - }, - 400: { - description: "Validation error", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 409: { - description: "Provider name already exists in this tenant", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("Provider created", ProviderResponse), + 400: jsonResponse("Validation error", ErrorResponse), + 409: jsonResponse( + "Provider name already exists in this tenant", + ErrorResponse, + ), }, }), validator("json", CreateProvider), @@ -168,14 +154,10 @@ export function createProviderRoutes({ ), }); if (existing) { - return c.json( - { - error: { - code: "conflict", - message: "Provider name already exists in this tenant", - }, - }, - 409, + return errorResponse( + c, + "conflict", + "Provider name already exists in this tenant", ); } @@ -211,18 +193,8 @@ export function createProviderRoutes({ tags: ["Providers"], summary: "Get provider details", responses: { - 200: { - description: "Provider details", - content: { - "application/json": { schema: resolver(ProviderResponse) }, - }, - }, - 404: { - description: "Provider not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Provider details", ProviderResponse), + 404: jsonResponse("Provider not found", ErrorResponse), }, }), async (c) => { @@ -234,18 +206,12 @@ export function createProviderRoutes({ }); if (!row) { - return c.json( - { error: { code: "not_found", message: "Provider not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Provider not found"); } const chain = await getAncestorChain(db, tenantCtx.id); if (!chain.includes(row.tenantId)) { - return c.json( - { error: { code: "not_found", message: "Provider not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Provider not found"); } return c.json(formatProvider(row)); @@ -260,18 +226,8 @@ export function createProviderRoutes({ summary: "Update a provider definition", description: "Only providers owned by this tenant can be updated.", responses: { - 200: { - description: "Provider updated", - content: { - "application/json": { schema: resolver(ProviderResponse) }, - }, - }, - 404: { - description: "Provider not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Provider updated", ProviderResponse), + 404: jsonResponse("Provider not found", ErrorResponse), }, }), validator("json", UpdateProvider), @@ -302,10 +258,7 @@ export function createProviderRoutes({ .returning(); if (!updated) { - return c.json( - { error: { code: "not_found", message: "Provider not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Provider not found"); } return c.json(formatProvider(updated)); @@ -321,12 +274,7 @@ export function createProviderRoutes({ description: "Only providers owned by this tenant can be removed.", responses: { 204: { description: "Provider removed" }, - 404: { - description: "Provider not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 404: jsonResponse("Provider not found", ErrorResponse), }, }), async (c) => { @@ -341,10 +289,7 @@ export function createProviderRoutes({ .returning(); if (deleted.length === 0) { - return c.json( - { error: { code: "not_found", message: "Provider not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Provider not found"); } return c.body(null, 204); diff --git a/vendor/intx/hub-api/src/routes/roles.ts b/vendor/intx/hub-api/src/routes/roles.ts index ca15406223..5db2c4b93d 100644 --- a/vendor/intx/hub-api/src/routes/roles.ts +++ b/vendor/intx/hub-api/src/routes/roles.ts @@ -1,18 +1,19 @@ import { eq, and } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { role, principalRole, principal } from "@intx/db/schema"; import type { DB } from "@intx/db"; import { CreateRole, + ErrorResponse, UpdateRole, RoleResponse, - ErrorResponse, paginatedSchema, } from "@intx/types"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { first, ts } from "../format"; import { generateId } from "@intx/hub-common"; import { idResource } from "../middleware/grant"; @@ -24,6 +25,7 @@ import { paginatedResponse, pageParameters, } from "../pagination"; +import { jsonResponse } from "../openapi"; function formatRole(row: typeof role.$inferSelect) { return { @@ -58,14 +60,7 @@ export function createRoleRoutes({ "Lists both system roles (owner, admin, member) and custom roles.", parameters: [...pageParameters], responses: { - 200: { - description: "List of roles", - content: { - "application/json": { - schema: resolver(paginatedSchema(RoleResponse)), - }, - }, - }, + 200: jsonResponse("List of roles", paginatedSchema(RoleResponse)), }, }), async (c) => { @@ -97,18 +92,8 @@ export function createRoleRoutes({ tags: ["Roles"], summary: "Create a custom role", responses: { - 201: { - description: "Role created", - content: { - "application/json": { schema: resolver(RoleResponse) }, - }, - }, - 400: { - description: "Validation error", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("Role created", RoleResponse), + 400: jsonResponse("Validation error", ErrorResponse), }, }), validator("json", CreateRole), @@ -144,18 +129,8 @@ export function createRoleRoutes({ summary: "Get role details", description: "Returns role details including attached grants.", responses: { - 200: { - description: "Role details", - content: { - "application/json": { schema: resolver(RoleResponse) }, - }, - }, - 404: { - description: "Role not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Role details", RoleResponse), + 404: jsonResponse("Role not found", ErrorResponse), }, }), async (c) => { @@ -167,10 +142,7 @@ export function createRoleRoutes({ }); if (!row) { - return c.json( - { error: { code: "not_found", message: "Role not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Role not found"); } return c.json(formatRole(row)); @@ -186,18 +158,8 @@ export function createRoleRoutes({ description: "Update name or description. System roles cannot be modified.", responses: { - 200: { - description: "Role updated", - content: { - "application/json": { schema: resolver(RoleResponse) }, - }, - }, - 403: { - description: "Cannot modify system role", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Role updated", RoleResponse), + 403: jsonResponse("Cannot modify system role", ErrorResponse), }, }), validator("json", UpdateRole), @@ -211,22 +173,11 @@ export function createRoleRoutes({ }); if (!existing) { - return c.json( - { error: { code: "not_found", message: "Role not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Role not found"); } if (existing.isSystem) { - return c.json( - { - error: { - code: "forbidden", - message: "Cannot modify system roles", - }, - }, - 403, - ); + return errorResponse(c, "forbidden", "Cannot modify system roles"); } const updates: Record = { updatedAt: new Date() }; @@ -258,18 +209,8 @@ export function createRoleRoutes({ 204: { description: "Role deleted", }, - 400: { - description: "Role still assigned to principals", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 403: { - description: "Cannot delete system role", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 400: jsonResponse("Role still assigned to principals", ErrorResponse), + 403: jsonResponse("Cannot delete system role", ErrorResponse), }, }), async (c) => { @@ -281,22 +222,11 @@ export function createRoleRoutes({ }); if (!existing) { - return c.json( - { error: { code: "not_found", message: "Role not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Role not found"); } if (existing.isSystem) { - return c.json( - { - error: { - code: "forbidden", - message: "Cannot delete system roles", - }, - }, - 403, - ); + return errorResponse(c, "forbidden", "Cannot delete system roles"); } const assignments = await db.query.principalRole.findMany({ @@ -304,14 +234,10 @@ export function createRoleRoutes({ }); if (assignments.length > 0) { - return c.json( - { - error: { - code: "bad_request", - message: `Role is still assigned to ${assignments.length} principal(s)`, - }, - }, - 400, + return errorResponse( + c, + "bad_request", + `Role is still assigned to ${assignments.length} principal(s)`, ); } @@ -348,12 +274,7 @@ export function createRoleAssignRoutes({ 204: { description: "Role assigned", }, - 404: { - description: "Principal or role not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 404: jsonResponse("Principal or role not found", ErrorResponse), }, }), async (c) => { @@ -368,20 +289,14 @@ export function createRoleAssignRoutes({ ), }); if (!principalRow) { - return c.json( - { error: { code: "not_found", message: "Principal not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Principal not found"); } const roleRow = await db.query.role.findFirst({ where: and(eq(role.id, roleId), eq(role.tenantId, tenantCtx.id)), }); if (!roleRow) { - return c.json( - { error: { code: "not_found", message: "Role not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Role not found"); } const existing = await db.query.principalRole.findFirst({ @@ -413,12 +328,7 @@ export function createRoleAssignRoutes({ 204: { description: "Role removed", }, - 404: { - description: "Assignment not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 404: jsonResponse("Assignment not found", ErrorResponse), }, }), async (c) => { @@ -437,20 +347,14 @@ export function createRoleAssignRoutes({ ), }); if (!principalRow) { - return c.json( - { error: { code: "not_found", message: "Principal not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Principal not found"); } const roleRow = await db.query.role.findFirst({ where: and(eq(role.id, roleId), eq(role.tenantId, tenantCtx.id)), }); if (!roleRow) { - return c.json( - { error: { code: "not_found", message: "Role not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Role not found"); } const deleted = await db @@ -464,12 +368,7 @@ export function createRoleAssignRoutes({ .returning(); if (deleted.length === 0) { - return c.json( - { - error: { code: "not_found", message: "Assignment not found" }, - }, - 404, - ); + return errorResponse(c, "not_found", "Assignment not found"); } return c.body(null, 204); diff --git a/vendor/intx/hub-api/src/routes/runs.ts b/vendor/intx/hub-api/src/routes/runs.ts index 05812ccfdf..a793494fb7 100644 --- a/vendor/intx/hub-api/src/routes/runs.ts +++ b/vendor/intx/hub-api/src/routes/runs.ts @@ -1,7 +1,7 @@ import { eq, and, inArray, isNotNull } from "drizzle-orm"; import { Hono, type Context } from "hono"; import { bodyLimit } from "hono/body-limit"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { offering, @@ -16,13 +16,13 @@ import { extractPartByPath } from "@intx/mime"; import { WorkflowRunResponse, + ErrorResponse, WorkflowRunHealth, RunAuthorizationResponse, RunApprovalsResponse, OfferingDetail, SendMessage, MailResponse, - ErrorResponse, paginatedSchema, } from "@intx/types"; import { @@ -47,6 +47,7 @@ import { import { WorkflowRunEventsResponse, formatRunEvent } from "./run-events-view"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { idResource } from "../middleware/grant"; import type { RequireGrant } from "../middleware/grant"; import { loadCommittedRunGrants } from "../run-grant-materialization"; @@ -63,6 +64,7 @@ import { paginatedResponse, pageParameters, } from "../pagination"; +import { jsonResponse } from "../openapi"; // Stop and mail history are not yet wired onto a workflow (anchor) run. They // were built for the retired folded-launch surface and need genuinely new @@ -75,14 +77,10 @@ function workflowRunOperationUnsupported( c: Context, operation: string, ) { - return c.json( - { - error: { - code: "not_implemented", - message: `${operation} is not yet supported for workflow runs`, - }, - }, - 501, + return errorResponse( + c, + "not_implemented", + `${operation} is not yet supported for workflow runs`, ); } @@ -219,21 +217,14 @@ export function createRunRoutes({ const record = await findRoutableById(db, runId, tenantCtx.id); if (record === undefined) { - return c.json( - { error: { code: "not_found", message: "Run not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Run not found"); } if (runReader === null) { - return c.json( - { - error: { - code: "unavailable", - message: "The run event log is not available", - }, - }, - 503, + return errorResponse( + c, + "unavailable", + "The run event log is not available", ); } @@ -272,14 +263,7 @@ export function createRunRoutes({ ...pageParameters, ], responses: { - 200: { - description: "List of runs", - content: { - "application/json": { - schema: resolver(paginatedSchema(WorkflowRunResponse)), - }, - }, - }, + 200: jsonResponse("List of runs", paginatedSchema(WorkflowRunResponse)), }, }), async (c) => { @@ -359,24 +343,9 @@ export function createRunRoutes({ description: "Blob bytes", content: { "application/octet-stream": {} }, }, - 400: { - description: "Invalid blob ID", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 403: { - description: "Forbidden", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 404: { - description: "Blob not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 400: jsonResponse("Invalid blob ID", ErrorResponse), + 403: jsonResponse("Forbidden", ErrorResponse), + 404: jsonResponse("Blob not found", ErrorResponse), }, }), async (c) => { @@ -387,20 +356,14 @@ export function createRunRoutes({ // mailId may itself contain underscores, so we match the suffix. const blobMatch = /^blob_(.+?)_(\d[\d.]*)$/.exec(blobId); if (!blobMatch) { - return c.json( - { error: { code: "bad_request", message: "Invalid blob ID format" } }, - 400, - ); + return errorResponse(c, "bad_request", "Invalid blob ID format"); } const mailId = blobMatch[1]; const partPath = blobMatch[2]; if (!mailId || !partPath) { - return c.json( - { error: { code: "bad_request", message: "Invalid blob ID format" } }, - 400, - ); + return errorResponse(c, "bad_request", "Invalid blob ID format"); } const tenant = c.get("tenant"); @@ -413,10 +376,7 @@ export function createRunRoutes({ }); if (!mailRow) { - return c.json( - { error: { code: "not_found", message: "Blob not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Blob not found"); } // The authorization subject is the mail's owning routable. A folded run's @@ -430,10 +390,7 @@ export function createRunRoutes({ mailRow.runId ?? (await resolveRunIdForSession(db, mailRow.sessionId, tenant.id)); if (!resolvedRunId) { - return c.json( - { error: { code: "not_found", message: "Blob not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Blob not found"); } const principal = c.get("principal"); @@ -448,14 +405,10 @@ export function createRunRoutes({ ); if (authResult.effect !== "allow") { - return c.json( - { - error: { - code: "forbidden", - message: "You do not have permission to perform this action", - }, - }, - 403, + return errorResponse( + c, + "forbidden", + "You do not have permission to perform this action", ); } @@ -463,10 +416,7 @@ export function createRunRoutes({ try { partBytes = extractPartByPath(mailRow.raw, partPath); } catch { - return c.json( - { error: { code: "not_found", message: "Blob not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Blob not found"); } return c.body( @@ -492,18 +442,8 @@ export function createRunRoutes({ description: "Returns workflow run state including status, public key, and sidecar assignment.", responses: { - 200: { - description: "Run detail", - content: { - "application/json": { schema: resolver(WorkflowRunResponse) }, - }, - }, - 404: { - description: "Run not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Run detail", WorkflowRunResponse), + 404: jsonResponse("Run not found", ErrorResponse), }, }), async (c) => { @@ -514,10 +454,7 @@ export function createRunRoutes({ // display name. const record = await findRoutableById(db, runId, tenantCtx.id); if (record === undefined) { - return c.json( - { error: { code: "not_found", message: "Run not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Run not found"); } // The display name lives on the definition the record belongs to, @@ -529,10 +466,7 @@ export function createRunRoutes({ ), }); if (definitionRow === undefined) { - return c.json( - { error: { code: "not_found", message: "Run not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Run not found"); } // Enrich with runtime status from the event collector if available. @@ -553,20 +487,8 @@ export function createRunRoutes({ description: "Returns the run's effective authorization floor: its committed grants and their resolved effects. A standing 'always' approval mutates the tool's committed grant in place at resolve time (approve-always sets allow, reject-always sets deny), so a standing-resolved tool reads that effect directly here. This is the floor the runtime enforces, so the view mirrors what the run can do. Complete for the source-ref deploy lineage (the shipping pipeline); a pinned-tool deploy's sidecar-injected ask floor is not reflected here.", responses: { - 200: { - description: "Run authorization", - content: { - "application/json": { - schema: resolver(RunAuthorizationResponse), - }, - }, - }, - 404: { - description: "Run not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Run authorization", RunAuthorizationResponse), + 404: jsonResponse("Run not found", ErrorResponse), }, }), async (c) => { @@ -575,10 +497,7 @@ export function createRunRoutes({ const record = await findRoutableById(db, runId, tenantCtx.id); if (record === undefined) { - return c.json( - { error: { code: "not_found", message: "Run not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Run not found"); } // The run's committed per-run grants ARE its effective floor: a standing @@ -611,18 +530,8 @@ export function createRunRoutes({ description: "Returns the run's approval decisions, newest first, across every status. The tools an operator turned into standing approvals are the entries with scope 'always' and status 'approved'.", responses: { - 200: { - description: "Run approvals", - content: { - "application/json": { schema: resolver(RunApprovalsResponse) }, - }, - }, - 404: { - description: "Run not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Run approvals", RunApprovalsResponse), + 404: jsonResponse("Run not found", ErrorResponse), }, }), async (c) => { @@ -631,10 +540,7 @@ export function createRunRoutes({ const record = await findRoutableById(db, runId, tenantCtx.id); if (record === undefined) { - return c.json( - { error: { code: "not_found", message: "Run not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Run not found"); } const approvals = await approvalStore.listByRunId(tenantCtx.id, runId); @@ -651,24 +557,9 @@ export function createRunRoutes({ description: "Returns liveness and readiness for a live run. Liveness reflects whether the run's sidecar connection is active. Readiness reflects whether the run has an active event collector and can process work.", responses: { - 200: { - description: "Health status", - content: { - "application/json": { schema: resolver(WorkflowRunHealth) }, - }, - }, - 404: { - description: "Run not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 410: { - description: "Run stopped", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Health status", WorkflowRunHealth), + 404: jsonResponse("Run not found", ErrorResponse), + 410: jsonResponse("Run stopped", ErrorResponse), }, }), async (c) => { @@ -677,17 +568,11 @@ export function createRunRoutes({ const record = await findRoutableById(db, runId, tenantCtx.id); if (record === undefined) { - return c.json( - { error: { code: "not_found", message: "Run not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Run not found"); } if (viewStatusOf(record) === "stopped") { - return c.json( - { error: { code: "gone", message: "Run has stopped" } }, - 410, - ); + return errorResponse(c, "gone", "Run has stopped"); } const routableAddresses = sidecarRouter.getRoutableAddresses(); @@ -711,20 +596,8 @@ export function createRunRoutes({ description: "Returns the offerings associated with the run's workflow definition. These represent the capabilities the run can provide.", responses: { - 200: { - description: "List of offerings", - content: { - "application/json": { - schema: resolver(OfferingDetail.array()), - }, - }, - }, - 404: { - description: "Run not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("List of offerings", OfferingDetail.array()), + 404: jsonResponse("Run not found", ErrorResponse), }, }), async (c) => { @@ -733,10 +606,7 @@ export function createRunRoutes({ const record = await findRoutableById(db, runId, tenantCtx.id); if (record === undefined) { - return c.json( - { error: { code: "not_found", message: "Run not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Run not found"); } // Offerings are keyed on the definition (their `agentId` column holds a @@ -746,10 +616,7 @@ export function createRunRoutes({ where: eq(workflowDefinition.id, record.definitionId), }); if (definitionRow === undefined) { - return c.json( - { error: { code: "not_found", message: "Run not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Run not found"); } const offerings = await db.query.offering.findMany({ @@ -777,24 +644,9 @@ export function createRunRoutes({ 204: { description: "Run stopped", }, - 404: { - description: "Run not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 409: { - description: "Run already stopped", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 502: { - description: "Sidecar unavailable", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 404: jsonResponse("Run not found", ErrorResponse), + 409: jsonResponse("Run already stopped", ErrorResponse), + 502: jsonResponse("Sidecar unavailable", ErrorResponse), }, }), async (c) => { @@ -817,24 +669,9 @@ export function createRunRoutes({ description: "Returns the run's committed, seq-ordered event log (RunStarted, StepStarted, StepCompleted, SignalAwaited, RunCompleted, etc.). The full log is returned on every call; a client polling for live updates deduplicates on seq. A run that has not been triggered yet returns an empty list.", responses: { - 200: { - description: "Seq-ordered run events", - content: { - "application/json": { schema: resolver(WorkflowRunEventsResponse) }, - }, - }, - 404: { - description: "Run not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 503: { - description: "Run event log unavailable", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Seq-ordered run events", WorkflowRunEventsResponse), + 404: jsonResponse("Run not found", ErrorResponse), + 503: jsonResponse("Run event log unavailable", ErrorResponse), }, }), async (c) => serveRunEvents(c, c.req.param("runId")), @@ -849,59 +686,33 @@ export function createRunRoutes({ description: "Delivers a fresh signed conversation message to the run, firing it through the run's workflow-native Trigger path. The first accepted message fires the run; while it remains live, later messages may resume its onTrigger input. A terminal run cannot be fired again. The returned messageId identifies this trigger occurrence.", responses: { - 202: { - description: "Trigger accepted for delivery", - content: { - "application/json": { - schema: resolver(WorkflowRunTriggerResponse), - }, - }, - }, - 400: { - description: - "Attachment validation error. Each variant carries a structured code (oversize_attachment, disallowed_mime_type, malformed_base64, oversize_total) with the offending index and limits. A malformed request body that fails SendMessage validation returns the generic error shape instead.", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 404: { - description: "Run not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 409: { - description: - "Run address is not routable, its allocation is no longer active, or the run is terminal", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 413: { - description: "Request body exceeds the maximum allowed size", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 503: { - description: "Run trigger substrate unavailable", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 202: jsonResponse( + "Trigger accepted for delivery", + WorkflowRunTriggerResponse, + ), + 400: jsonResponse( + "Attachment validation error. Each variant carries a structured code (oversize_attachment, disallowed_mime_type, malformed_base64, oversize_total) with the offending index and limits. A malformed request body that fails SendMessage validation returns the generic error shape instead.", + ErrorResponse, + ), + 404: jsonResponse("Run not found", ErrorResponse), + 409: jsonResponse( + "Run address is not routable, its allocation is no longer active, or the run is terminal", + ErrorResponse, + ), + 413: jsonResponse( + "Request body exceeds the maximum allowed size", + ErrorResponse, + ), + 503: jsonResponse("Run trigger substrate unavailable", ErrorResponse), }, }), bodyLimit({ maxSize: MAX_MAIL_BODY_BYTES, onError: (c) => - c.json( - { - error: { - code: "payload_too_large", - message: "Request body exceeds the maximum allowed size", - }, - }, - 413, + errorResponse( + c, + "payload_too_large", + "Request body exceeds the maximum allowed size", ), }), validator("json", SendMessage), @@ -913,10 +724,7 @@ export function createRunRoutes({ // exactly as the turns/events routes do, before the trigger runs. const record = await findRoutableById(db, runId, tenantCtx.id); if (record === undefined) { - return c.json( - { error: { code: "not_found", message: "Run not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Run not found"); } // Mail send fires the run through its workflow-native Trigger path. That @@ -924,14 +732,10 @@ export function createRunRoutes({ // runs without the deploy surface the trigger is null, so answer 503 // rather than silently dropping the send. if (triggerWorkflowRun === null) { - return c.json( - { - error: { - code: "unavailable", - message: "The run trigger substrate is not available", - }, - }, - 503, + return errorResponse( + c, + "unavailable", + "The run trigger substrate is not available", ); } @@ -956,20 +760,8 @@ export function createRunRoutes({ "Returns parsed JMAP Email objects in reverse chronological order. Cursor-paginated.", parameters: [...pageParameters], responses: { - 200: { - description: "List of mail", - content: { - "application/json": { - schema: resolver(paginatedSchema(MailResponse)), - }, - }, - }, - 404: { - description: "Run not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("List of mail", paginatedSchema(MailResponse)), + 404: jsonResponse("Run not found", ErrorResponse), }, }), async (c) => { @@ -990,24 +782,9 @@ export function createRunRoutes({ description: "Returns the run's step and lifecycle events in seq order -- the same committed event log the events route serves, presented as the run's turns. A run that has not been triggered yet returns an empty list.", responses: { - 200: { - description: "Seq-ordered run events", - content: { - "application/json": { schema: resolver(WorkflowRunEventsResponse) }, - }, - }, - 404: { - description: "Run not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 503: { - description: "Run event log unavailable", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Seq-ordered run events", WorkflowRunEventsResponse), + 404: jsonResponse("Run not found", ErrorResponse), + 503: jsonResponse("Run event log unavailable", ErrorResponse), }, }), async (c) => serveRunEvents(c, c.req.param("runId")), diff --git a/vendor/intx/hub-api/src/routes/tenant-federation.ts b/vendor/intx/hub-api/src/routes/tenant-federation.ts index e89c4d842a..8f9b3e86a7 100644 --- a/vendor/intx/hub-api/src/routes/tenant-federation.ts +++ b/vendor/intx/hub-api/src/routes/tenant-federation.ts @@ -1,17 +1,18 @@ import { eq, and } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { federationTrust, tenant } from "@intx/db/schema"; import type { DB } from "@intx/db"; import { FederationTrust, - CreateFederationTrust, ErrorResponse, + CreateFederationTrust, paginatedSchema, } from "@intx/types"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { ts } from "../format"; import { generateId } from "@intx/hub-common"; import { @@ -21,6 +22,7 @@ import { paginatedResponse, pageParameters, } from "../pagination"; +import { jsonResponse } from "../openapi"; export type CreateTenantFederationRoutesDeps = { db: DB["db"]; @@ -38,14 +40,10 @@ export function createTenantFederationRoutes({ summary: "List federation trust relationships", parameters: [...pageParameters], responses: { - 200: { - description: "Federation trusts", - content: { - "application/json": { - schema: resolver(paginatedSchema(FederationTrust)), - }, - }, - }, + 200: jsonResponse( + "Federation trusts", + paginatedSchema(FederationTrust), + ), }, }), async (c) => { @@ -104,18 +102,8 @@ export function createTenantFederationRoutes({ description: "Creates a trust relationship with another tenant for cross-tenant agent discovery and interaction.", responses: { - 201: { - description: "Trust established", - content: { - "application/json": { schema: resolver(FederationTrust) }, - }, - }, - 400: { - description: "Validation error", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("Trust established", FederationTrust), + 400: jsonResponse("Validation error", ErrorResponse), }, }), validator("json", CreateFederationTrust), @@ -127,15 +115,7 @@ export function createTenantFederationRoutes({ where: eq(tenant.id, body.targetTenantId), }); if (!target) { - return c.json( - { - error: { - code: "not_found", - message: "Target tenant not found", - }, - }, - 404, - ); + return errorResponse(c, "not_found", "Target tenant not found"); } const existing = await db.query.federationTrust.findFirst({ @@ -145,14 +125,10 @@ export function createTenantFederationRoutes({ ), }); if (existing) { - return c.json( - { - error: { - code: "conflict", - message: "Trust relationship already exists", - }, - }, - 409, + return errorResponse( + c, + "conflict", + "Trust relationship already exists", ); } @@ -186,12 +162,7 @@ export function createTenantFederationRoutes({ 204: { description: "Trust revoked", }, - 404: { - description: "Trust not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 404: jsonResponse("Trust not found", ErrorResponse), }, }), async (c) => { @@ -209,12 +180,7 @@ export function createTenantFederationRoutes({ .returning(); if (deleted.length === 0) { - return c.json( - { - error: { code: "not_found", message: "Trust not found" }, - }, - 404, - ); + return errorResponse(c, "not_found", "Trust not found"); } return c.body(null, 204); diff --git a/vendor/intx/hub-api/src/routes/tenants.ts b/vendor/intx/hub-api/src/routes/tenants.ts index 2a57ef7a19..e46ed43304 100644 --- a/vendor/intx/hub-api/src/routes/tenants.ts +++ b/vendor/intx/hub-api/src/routes/tenants.ts @@ -1,19 +1,21 @@ -import { eq, and } from "drizzle-orm"; +import { eq, and, sql } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { tenant, principal, role, principalRole, grant } from "@intx/db/schema"; import { createPrincipalStore, parseTenantRow } from "@intx/db"; import type { DB, PrincipalKeyStore } from "@intx/db"; import { CreateTenant, + ErrorResponse, UpdateTenant, TenantResponse, - ErrorResponse, } from "@intx/types"; -import type { AppEnv } from "../context"; +import { unauthorizedResponse, type AppEnv } from "../context"; +import { errorResponse } from "../error-response"; import { first, ts } from "../format"; +import { jsonResponse } from "../openapi"; import { generateId } from "@intx/hub-common"; const SYSTEM_ROLES = ["owner", "admin", "member"] as const; @@ -52,47 +54,33 @@ export function createTenantRoutes({ description: "Creates a new tenant. The authenticated user becomes the owner with a principal and default owner role.", responses: { - 201: { - description: "Tenant created", - content: { - "application/json": { schema: resolver(TenantResponse) }, - }, - }, - 400: { - description: "Validation error", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("Tenant created", TenantResponse), + 400: jsonResponse("Validation error", ErrorResponse), }, }), validator("json", CreateTenant), async (c) => { const user = c.get("user"); if (!user) { - return c.json( - { - error: { code: "unauthorized", message: "Authentication required" }, - }, - 401, - ); + return unauthorizedResponse(c); } const body = c.req.valid("json"); const tenantId = generateId("tenant"); - const domain = `${body.slug}.localhost`; + // Derive the domain from a lowercased slug and check the slug conflict + // case-insensitively. An inbound mail `From` is lowercased when parsed, so + // a sender address must map to exactly one tenant; `tenant_domain_lower_idx` + // enforces a unique `lower(domain)`. Normalizing here keeps a case-variant + // slug a clean 409 instead of a unique-violation 500, and stops a + // case-variant registration from shadowing another tenant's senders. + const domain = `${body.slug.toLowerCase()}.localhost`; const existing = await db.query.tenant.findFirst({ - where: eq(tenant.slug, body.slug), + where: eq(sql`lower(${tenant.slug})`, body.slug.toLowerCase()), }); if (existing) { - return c.json( - { - error: { code: "conflict", message: "Slug already taken" }, - }, - 409, - ); + return errorResponse(c, "conflict", "Slug already taken"); } const now = new Date(); @@ -232,35 +220,15 @@ export function createTenantRoutes({ tags: ["Tenants"], summary: "Get tenant details", responses: { - 200: { - description: "Tenant details", - content: { - "application/json": { schema: resolver(TenantResponse) }, - }, - }, - 403: { - description: "Not a member of this tenant", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 404: { - description: "Tenant not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Tenant details", TenantResponse), + 403: jsonResponse("Not a member of this tenant", ErrorResponse), + 404: jsonResponse("Tenant not found", ErrorResponse), }, }), async (c) => { const user = c.get("user"); if (!user) { - return c.json( - { - error: { code: "unauthorized", message: "Authentication required" }, - }, - 401, - ); + return unauthorizedResponse(c); } const tenantId = c.req.param("tenantId"); @@ -269,10 +237,7 @@ export function createTenantRoutes({ where: eq(tenant.id, tenantId), }); if (!tenantRow) { - return c.json( - { error: { code: "not_found", message: "Tenant not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Tenant not found"); } const membership = await db.query.principal.findFirst({ @@ -283,15 +248,7 @@ export function createTenantRoutes({ ), }); if (!membership) { - return c.json( - { - error: { - code: "forbidden", - message: "Not a member of this tenant", - }, - }, - 403, - ); + return errorResponse(c, "forbidden", "Not a member of this tenant"); } return c.json(formatTenant(tenantRow)); @@ -305,30 +262,15 @@ export function createTenantRoutes({ summary: "Update tenant config", description: "Requires admin or higher grant within the tenant.", responses: { - 200: { - description: "Tenant updated", - content: { - "application/json": { schema: resolver(TenantResponse) }, - }, - }, - 403: { - description: "Insufficient grants", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Tenant updated", TenantResponse), + 403: jsonResponse("Insufficient grants", ErrorResponse), }, }), validator("json", UpdateTenant), async (c) => { const user = c.get("user"); if (!user) { - return c.json( - { - error: { code: "unauthorized", message: "Authentication required" }, - }, - 401, - ); + return unauthorizedResponse(c); } const tenantId = c.req.param("tenantId"); @@ -342,15 +284,7 @@ export function createTenantRoutes({ ), }); if (!membership) { - return c.json( - { - error: { - code: "forbidden", - message: "Not a member of this tenant", - }, - }, - 403, - ); + return errorResponse(c, "forbidden", "Not a member of this tenant"); } const updates: Record = { updatedAt: new Date() }; @@ -364,10 +298,7 @@ export function createTenantRoutes({ .returning(); if (!updated) { - return c.json( - { error: { code: "not_found", message: "Tenant not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Tenant not found"); } return c.json(formatTenant(updated)); diff --git a/vendor/intx/hub-api/src/routes/wallets.ts b/vendor/intx/hub-api/src/routes/wallets.ts index 6d63958cb3..d7a64bf913 100644 --- a/vendor/intx/hub-api/src/routes/wallets.ts +++ b/vendor/intx/hub-api/src/routes/wallets.ts @@ -1,20 +1,21 @@ import { eq, and } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { wallet, transaction } from "@intx/db/schema"; import { parseWalletRow, parseTransactionRow } from "@intx/db"; import type { DB } from "@intx/db"; import { CreateWallet, + ErrorResponse, UpdateWallet, WalletResponse, TransactionResponse, - ErrorResponse, paginatedSchema, } from "@intx/types"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { first, ts } from "../format"; import { generateId } from "@intx/hub-common"; import { isReferencedRowViolation } from "../pg-errors"; @@ -27,6 +28,7 @@ import { paginatedResponse, pageParameters, } from "../pagination"; +import { jsonResponse } from "../openapi"; function formatWallet(row: typeof wallet.$inferSelect) { const parsed = parseWalletRow(row); @@ -79,14 +81,7 @@ export function createWalletRoutes({ summary: "List wallets in the tenant", parameters: [...pageParameters], responses: { - 200: { - description: "List of wallets", - content: { - "application/json": { - schema: resolver(paginatedSchema(WalletResponse)), - }, - }, - }, + 200: jsonResponse("List of wallets", paginatedSchema(WalletResponse)), }, }), async (c) => { @@ -120,18 +115,8 @@ export function createWalletRoutes({ description: "Creates a wallet with the specified payment backend and currency. Access for agents is managed through grants.", responses: { - 201: { - description: "Wallet created", - content: { - "application/json": { schema: resolver(WalletResponse) }, - }, - }, - 400: { - description: "Validation error", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 201: jsonResponse("Wallet created", WalletResponse), + 400: jsonResponse("Validation error", ErrorResponse), }, }), validator("json", CreateWallet), @@ -169,18 +154,8 @@ export function createWalletRoutes({ summary: "Get wallet details", description: "Returns wallet details including current balance.", responses: { - 200: { - description: "Wallet details", - content: { - "application/json": { schema: resolver(WalletResponse) }, - }, - }, - 404: { - description: "Wallet not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Wallet details", WalletResponse), + 404: jsonResponse("Wallet not found", ErrorResponse), }, }), async (c) => { @@ -192,10 +167,7 @@ export function createWalletRoutes({ }); if (!row) { - return c.json( - { error: { code: "not_found", message: "Wallet not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Wallet not found"); } return c.json(formatWallet(row)); @@ -209,18 +181,8 @@ export function createWalletRoutes({ tags: ["Wallets"], summary: "Update wallet config", responses: { - 200: { - description: "Wallet updated", - content: { - "application/json": { schema: resolver(WalletResponse) }, - }, - }, - 404: { - description: "Wallet not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Wallet updated", WalletResponse), + 404: jsonResponse("Wallet not found", ErrorResponse), }, }), validator("json", UpdateWallet), @@ -240,10 +202,7 @@ export function createWalletRoutes({ .returning(); if (!updated) { - return c.json( - { error: { code: "not_found", message: "Wallet not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Wallet not found"); } return c.json(formatWallet(updated)); @@ -260,18 +219,11 @@ export function createWalletRoutes({ 204: { description: "Wallet deactivated", }, - 404: { - description: "Wallet not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 409: { - description: "Wallet is in use by a model provider", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 404: jsonResponse("Wallet not found", ErrorResponse), + 409: jsonResponse( + "Wallet is in use by a model provider", + ErrorResponse, + ), }, }), async (c) => { @@ -304,22 +256,15 @@ export function createWalletRoutes({ if (!isReferencedRowViolation(err)) { throw err; } - return c.json( - { - error: { - code: "conflict", - message: "Wallet is in use by a model provider", - }, - }, - 409, + return errorResponse( + c, + "conflict", + "Wallet is in use by a model provider", ); } if (outcome === "not_found") { - return c.json( - { error: { code: "not_found", message: "Wallet not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Wallet not found"); } return c.body(null, 204); @@ -346,14 +291,10 @@ export function createWalletRoutes({ ...pageParameters, ], responses: { - 200: { - description: "List of transactions", - content: { - "application/json": { - schema: resolver(paginatedSchema(TransactionResponse)), - }, - }, - }, + 200: jsonResponse( + "List of transactions", + paginatedSchema(TransactionResponse), + ), }, }), async (c) => { @@ -365,10 +306,7 @@ export function createWalletRoutes({ }); if (!walletRow) { - return c.json( - { error: { code: "not_found", message: "Wallet not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Wallet not found"); } const runId = c.req.query("runId"); diff --git a/vendor/intx/hub-api/src/routes/workflow-definitions.ts b/vendor/intx/hub-api/src/routes/workflow-definitions.ts index 286759315c..0bc64b8823 100644 --- a/vendor/intx/hub-api/src/routes/workflow-definitions.ts +++ b/vendor/intx/hub-api/src/routes/workflow-definitions.ts @@ -1,6 +1,6 @@ import { eq, and } from "drizzle-orm"; import { Hono } from "hono"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { workflowDefinition, workflowDefinitionVersion } from "@intx/db/schema"; import { @@ -11,13 +11,14 @@ import { import type { DB } from "@intx/db"; import { WorkflowDefinitionVersion, + ErrorResponse, WorkflowDefinitionResponse, WorkflowRollbackRequest, - ErrorResponse, paginatedSchema, } from "@intx/types"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { ts } from "../format"; import { idResource } from "../middleware/grant"; import type { RequireGrant } from "../middleware/grant"; @@ -28,6 +29,7 @@ import { paginatedResponse, pageParameters, } from "../pagination"; +import { jsonResponse } from "../openapi"; export type CreateWorkflowDefinitionRoutesDeps = { db: DB["db"]; @@ -51,14 +53,10 @@ export function createWorkflowDefinitionRoutes({ "Lists the workflow definitions for the tenant, most recent first.", parameters: [...pageParameters], responses: { - 200: { - description: "List of workflow definitions", - content: { - "application/json": { - schema: resolver(paginatedSchema(WorkflowDefinitionResponse)), - }, - }, - }, + 200: jsonResponse( + "List of workflow definitions", + paginatedSchema(WorkflowDefinitionResponse), + ), }, }), async (c) => { @@ -112,20 +110,11 @@ export function createWorkflowDefinitionRoutes({ description: "Lists all versions of a workflow definition with status.", parameters: [...pageParameters], responses: { - 200: { - description: "List of versions", - content: { - "application/json": { - schema: resolver(paginatedSchema(WorkflowDefinitionVersion)), - }, - }, - }, - 404: { - description: "Definition not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse( + "List of versions", + paginatedSchema(WorkflowDefinitionVersion), + ), + 404: jsonResponse("Definition not found", ErrorResponse), }, }), async (c) => { @@ -144,10 +133,7 @@ export function createWorkflowDefinitionRoutes({ ), }); if (definition === undefined) { - return c.json( - { error: { code: "not_found", message: "Definition not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Definition not found"); } const { limit, cursor } = parsePageParams({ @@ -199,26 +185,9 @@ export function createWorkflowDefinitionRoutes({ description: "Activates the specified version and stops the current one; repoints currentVersion.", responses: { - 200: { - description: "Rollback applied", - content: { - "application/json": { - schema: resolver(WorkflowDefinitionResponse), - }, - }, - }, - 400: { - description: "Invalid version", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, - 404: { - description: "Definition not found", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, + 200: jsonResponse("Rollback applied", WorkflowDefinitionResponse), + 400: jsonResponse("Invalid version", ErrorResponse), + 404: jsonResponse("Definition not found", ErrorResponse), }, }), validator("json", WorkflowRollbackRequest), @@ -235,17 +204,9 @@ export function createWorkflowDefinitionRoutes({ if (!result.ok) { if (result.reason === "definition_not_found") { - return c.json( - { error: { code: "not_found", message: "Definition not found" } }, - 404, - ); + return errorResponse(c, "not_found", "Definition not found"); } - return c.json( - { - error: { code: "bad_request", message: "Target version not found" }, - }, - 400, - ); + return errorResponse(c, "bad_request", "Target version not found"); } const def = result.definition; diff --git a/vendor/intx/hub-api/src/routes/workflows.ts b/vendor/intx/hub-api/src/routes/workflows.ts index ef41b8ccbf..7a9878e903 100644 --- a/vendor/intx/hub-api/src/routes/workflows.ts +++ b/vendor/intx/hub-api/src/routes/workflows.ts @@ -2,7 +2,7 @@ import { and, desc, eq, isNotNull } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import { Hono } from "hono"; import { bodyLimit } from "hono/body-limit"; -import { describeRoute, resolver, validator } from "hono-openapi"; +import { describeRoute, validator } from "hono-openapi"; import { type } from "arktype"; import { @@ -20,11 +20,13 @@ import { import type { GrantStore } from "@intx/types/authz"; import { correlationIdFromSignalName, - deriveWorkflowRunId, ErrorResponse, + deriveWorkflowRunId, isSidecarAllocationDispatchable, SendMessage, + WorkflowDeploymentResponse, type SidecarAllocationStatus, + type WorkflowDeploymentStatus, } from "@intx/types"; import { WorkflowDefinitionSource } from "@intx/types/workflow-sources"; import { @@ -42,6 +44,7 @@ import { } from "@intx/workflow-deploy"; import type { TenantEnv } from "../context"; +import { errorResponse } from "../error-response"; import { idResource, type RequireGrant } from "../middleware/grant"; import { lockDispatchableAllocation, @@ -59,6 +62,7 @@ import { MAX_MAIL_BODY_BYTES, WorkflowRunTriggerResponse, } from "../workflow-run-trigger"; +import { jsonResponse } from "../openapi"; // Request body for the general workflow deploy. The definition is CODE-SOURCED: // `source` names where its bytes come from and `entry` the `interchange.workflow` @@ -96,14 +100,6 @@ const DeliverSignal = type({ "payload?": "unknown", }); -const WorkflowDeploymentResponse = type({ - id: "string", - tenantId: "string", - definitionAssetId: "string", - status: "string", - createdAt: "string", -}); - const WorkflowRunListResponse = type({ runIds: "string[]", }); @@ -125,8 +121,8 @@ function formatDeployment( allocationStatus?: SidecarAllocationStatus | null; allocationNextAttemptAt?: Date | null; }, - statusOverride?: string, -) { + statusOverride?: WorkflowDeploymentStatus, +): WorkflowDeploymentResponse { if (row.definitionAssetId === null) { throw new Error( `deployment ${row.id}: anchor run's definition has no asset`, @@ -144,7 +140,7 @@ function formatDeployment( function formatAllocationStatus(row: { allocationStatus?: SidecarAllocationStatus | null; allocationNextAttemptAt?: Date | null; -}): string { +}): WorkflowDeploymentStatus { switch (row.allocationStatus) { case undefined: case null: @@ -157,6 +153,7 @@ function formatAllocationStatus(row: { case "replacing": return "recovering"; case "releasing": + case "destroy_failed": case "released": case "failed": return row.allocationStatus; @@ -245,31 +242,20 @@ export function createWorkflowRoutes({ description: "Installs, probes, gates, and freezes a code-sourced workflow definition from its `source`/`entry`, then creates a pending provisioned deployment. Provisioning continues asynchronously; the response returns the deployment record.", responses: { - 201: { - description: "Workflow deployment accepted for provisioning", - content: { - "application/json": { - schema: resolver(WorkflowDeploymentResponse), - }, - }, - }, - 404: { - description: "Workflow asset not found", - content: { "application/json": { schema: resolver(ErrorResponse) } }, - }, - 409: { - description: - "Workflow definition or source offering chain invalid, workflow provisioning unavailable, or provisioner selection failed", - content: { "application/json": { schema: resolver(ErrorResponse) } }, - }, - 500: { - description: "Deployment projection row missing after preparation", - content: { "application/json": { schema: resolver(ErrorResponse) } }, - }, - 502: { - description: "Sidecar unavailable", - content: { "application/json": { schema: resolver(ErrorResponse) } }, - }, + 201: jsonResponse( + "Workflow deployment accepted for provisioning", + WorkflowDeploymentResponse, + ), + 404: jsonResponse("Workflow asset not found", ErrorResponse), + 409: jsonResponse( + "Workflow definition or source offering chain invalid, workflow provisioning unavailable, or provisioner selection failed", + ErrorResponse, + ), + 500: jsonResponse( + "Deployment projection row missing after preparation", + ErrorResponse, + ), + 502: jsonResponse("Sidecar unavailable", ErrorResponse), }, }), validator("json", DeployWorkflow), @@ -283,15 +269,10 @@ export function createWorkflowRoutes({ // backing asset for the definition, so this route (which anchors every // deployment to a workflow asset) does not support it yet. if (body.source.kind !== "asset") { - return c.json( - { - error: { - code: "unsupported_source", - message: - "Registry-sourced workflow deploys are not yet supported on this route", - }, - }, - 400, + return errorResponse( + c, + "unsupported_source", + "Registry-sourced workflow deploys are not yet supported on this route", ); } const definitionAssetId = body.source.assetId; @@ -304,23 +285,14 @@ export function createWorkflowRoutes({ ), }); if (!assetRow) { - return c.json( - { - error: { code: "not_found", message: "Workflow asset not found" }, - }, - 404, - ); + return errorResponse(c, "not_found", "Workflow asset not found"); } if (workflowAllocationService === undefined) { - return c.json( - { - error: { - code: "workflow_provisioning_unavailable", - message: "Workflow provisioning is not configured on this Hub", - }, - }, - 409, + return errorResponse( + c, + "workflow_provisioning_unavailable", + "Workflow provisioning is not configured on this Hub", ); } @@ -328,7 +300,7 @@ export function createWorkflowRoutes({ const sessionId = generateId("session"); let deployedId: string; - let deploymentStatus: string; + let deploymentStatus: WorkflowDeploymentStatus; try { const prepared = await workflowAllocationService.prepareProvisionedDeployment({ @@ -351,10 +323,7 @@ export function createWorkflowRoutes({ // An install/gate rejection or an unapproved/mis-ordered source chain // is a client/definition error, not a sidecar-reachability failure. if (err instanceof WorkflowDefinitionInvalidError) { - return c.json( - { error: { code: "invalid_workflow", message: err.message } }, - 409, - ); + return errorResponse(c, "invalid_workflow", err.message); } if (err instanceof WorkflowProvisioningError) { return c.json( @@ -362,17 +331,10 @@ export function createWorkflowRoutes({ 409, ); } - return c.json( - { - error: { - code: "sidecar_unavailable", - message: - err instanceof Error - ? err.message - : "Failed to deploy workflow", - }, - }, - 502, + return errorResponse( + c, + "sidecar_unavailable", + err instanceof Error ? err.message : "Failed to deploy workflow", ); } @@ -397,14 +359,10 @@ export function createWorkflowRoutes({ .where(eq(workflowRun.id, deployedId)) .limit(1); if (!row) { - return c.json( - { - error: { - code: "anchor_run_missing", - message: `anchor workflow_run ${deployedId} missing after deployment preparation`, - }, - }, - 500, + return errorResponse( + c, + "anchor_run_missing", + `anchor workflow_run ${deployedId} missing after deployment preparation`, ); } return c.json(formatDeployment(row, deploymentStatus), 201); @@ -420,14 +378,10 @@ export function createWorkflowRoutes({ description: "Lists the workflow deployments for the tenant, most recent first.", responses: { - 200: { - description: "List of workflow deployments", - content: { - "application/json": { - schema: resolver(WorkflowDeploymentResponse.array()), - }, - }, - }, + 200: jsonResponse( + "List of workflow deployments", + WorkflowDeploymentResponse.array(), + ), }, }), async (c) => { @@ -483,28 +437,20 @@ export function createWorkflowRoutes({ 202: { description: "Signal accepted for delivery", }, - 400: { - description: - "Reserved signal name or a runId that is not the deployment's addressable run", - content: { "application/json": { schema: resolver(ErrorResponse) } }, - }, - 404: { - description: "Workflow deployment not found", - content: { "application/json": { schema: resolver(ErrorResponse) } }, - }, - 409: { - description: - "Workflow run has not started, is terminal, its deployment allocation is no longer active, or the signalId conflicts with a previously accepted payload", - content: { "application/json": { schema: resolver(ErrorResponse) } }, - }, - 502: { - description: "Sidecar unavailable", - content: { "application/json": { schema: resolver(ErrorResponse) } }, - }, - 503: { - description: "Durable workflow dispatch unavailable", - content: { "application/json": { schema: resolver(ErrorResponse) } }, - }, + 400: jsonResponse( + "Reserved signal name or a runId that is not the deployment's addressable run", + ErrorResponse, + ), + 404: jsonResponse("Workflow deployment not found", ErrorResponse), + 409: jsonResponse( + "Workflow run has not started, is terminal, its deployment allocation is no longer active, or the signalId conflicts with a previously accepted payload", + ErrorResponse, + ), + 502: jsonResponse("Sidecar unavailable", ErrorResponse), + 503: jsonResponse( + "Durable workflow dispatch unavailable", + ErrorResponse, + ), }, }), validator("json", DeliverSignal), @@ -547,15 +493,7 @@ export function createWorkflowRoutes({ ) .limit(1); if (deployment === undefined) { - return c.json( - { - error: { - code: "not_found", - message: "Workflow deployment not found", - }, - }, - 404, - ); + return errorResponse(c, "not_found", "Workflow deployment not found"); } // A reserved control-plane channel name (`signalName(correlationId)`) is @@ -564,15 +502,10 @@ export function createWorkflowRoutes({ // approval co-write and its authorization -- so reject it. Author signals // to a workflow use free-form names. if (correlationIdFromSignalName(body.signalName) !== undefined) { - return c.json( - { - error: { - code: "reserved_signal_name", - message: - "signalName names a reserved control-plane channel; deliver author-named signals only", - }, - }, - 400, + return errorResponse( + c, + "reserved_signal_name", + "signalName names a reserved control-plane channel; deliver author-named signals only", ); } @@ -581,14 +514,10 @@ export function createWorkflowRoutes({ // signal for a section body is delivered to the parent deployment run and // relayed down by the runtime, never addressed to the child directly. if (body.runId !== runId) { - return c.json( - { - error: { - code: "unaddressable_run", - message: "runId is not the addressable run of this deployment", - }, - }, - 400, + return errorResponse( + c, + "unaddressable_run", + "runId is not the addressable run of this deployment", ); } @@ -604,23 +533,18 @@ export function createWorkflowRoutes({ !isLiveWorkflowRunStatus(deployment.runStatus) || durableLifecycle !== "live" ) { - return c.json( - { - error: { - code: "workflow_run_not_running", - message: - durableLifecycle !== "live" - ? durableLifecycle === "terminal" - ? "Workflow run is terminal" - : "Workflow run has not started" - : !isLiveWorkflowRunStatus(deployment.anchorStatus) - ? `Workflow deployment is ${deployment.anchorStatus}` - : deployment.runStatus === null - ? "Workflow run has not started" - : `Workflow run is ${deployment.runStatus}`, - }, - }, - 409, + return errorResponse( + c, + "workflow_run_not_running", + durableLifecycle !== "live" + ? durableLifecycle === "terminal" + ? "Workflow run is terminal" + : "Workflow run has not started" + : !isLiveWorkflowRunStatus(deployment.anchorStatus) + ? `Workflow deployment is ${deployment.anchorStatus}` + : deployment.runStatus === null + ? "Workflow run has not started" + : `Workflow run is ${deployment.runStatus}`, ); } @@ -628,29 +552,20 @@ export function createWorkflowRoutes({ deployment.allocationStatus !== null && !isSidecarAllocationDispatchable(deployment.allocationStatus) ) { - return c.json( - { - error: { - code: "deployment_unreachable", - message: `Workflow deployment allocation is ${deployment.allocationStatus}`, - }, - }, - 409, + return errorResponse( + c, + "deployment_unreachable", + `Workflow deployment allocation is ${deployment.allocationStatus}`, ); } const allocationId = deployment.allocationId; if (allocationId !== null) { if (workflowDispatchService === undefined) { - return c.json( - { - error: { - code: "workflow_dispatch_unavailable", - message: - "Durable workflow dispatch is unavailable for this provisioned deployment", - }, - }, - 503, + return errorResponse( + c, + "workflow_dispatch_unavailable", + "Durable workflow dispatch is unavailable for this provisioned deployment", ); } try { @@ -713,15 +628,10 @@ export function createWorkflowRoutes({ return c.body(null, 202); } catch (error) { if (error instanceof WorkflowRunDispatchPayloadConflictError) { - return c.json( - { - error: { - code: "signal_id_conflict", - message: - "signalId has already been used with a different signal payload", - }, - }, - 409, + return errorResponse( + c, + "signal_id_conflict", + "signalId has already been used with a different signal payload", ); } throw error; @@ -737,17 +647,12 @@ export function createWorkflowRoutes({ payload: body.payload, }); } catch (err) { - return c.json( - { - error: { - code: "sidecar_unavailable", - message: - err instanceof Error - ? err.message - : "Failed to deliver signal to sidecar", - }, - }, - 502, + return errorResponse( + c, + "sidecar_unavailable", + err instanceof Error + ? err.message + : "Failed to deliver signal to sidecar", ); } @@ -764,49 +669,36 @@ export function createWorkflowRoutes({ description: "Delivers a fresh signed conversation message to the deployment's stable top-level run. The first accepted message fires that run; while it remains live, later messages may resume its onTrigger input. A terminal deployment run cannot be fired again. The returned messageId identifies this trigger occurrence.", responses: { - 202: { - description: "Trigger accepted for delivery", - content: { - "application/json": { - schema: resolver(WorkflowRunTriggerResponse), - }, - }, - }, - 400: { - description: - "Attachment validation error. Each variant carries a structured code (oversize_attachment, disallowed_mime_type, malformed_base64, oversize_total) with the offending index and limits. A malformed request body that fails SendMessage validation returns the generic error shape instead.", - content: { "application/json": { schema: resolver(ErrorResponse) } }, - }, - 404: { - description: "Workflow deployment not found", - content: { "application/json": { schema: resolver(ErrorResponse) } }, - }, - 409: { - description: - "Deployment address is not routable, its allocation is no longer active, or its top-level run is terminal", - content: { "application/json": { schema: resolver(ErrorResponse) } }, - }, - 413: { - description: "Request body exceeds the maximum allowed size", - content: { "application/json": { schema: resolver(ErrorResponse) } }, - }, - 503: { - description: "Durable workflow dispatch unavailable", - content: { "application/json": { schema: resolver(ErrorResponse) } }, - }, + 202: jsonResponse( + "Trigger accepted for delivery", + WorkflowRunTriggerResponse, + ), + 400: jsonResponse( + "Attachment validation error. Each variant carries a structured code (oversize_attachment, disallowed_mime_type, malformed_base64, oversize_total) with the offending index and limits. A malformed request body that fails SendMessage validation returns the generic error shape instead.", + ErrorResponse, + ), + 404: jsonResponse("Workflow deployment not found", ErrorResponse), + 409: jsonResponse( + "Deployment address is not routable, its allocation is no longer active, or its top-level run is terminal", + ErrorResponse, + ), + 413: jsonResponse( + "Request body exceeds the maximum allowed size", + ErrorResponse, + ), + 503: jsonResponse( + "Durable workflow dispatch unavailable", + ErrorResponse, + ), }, }), bodyLimit({ maxSize: MAX_MAIL_BODY_BYTES, onError: (c) => - c.json( - { - error: { - code: "payload_too_large", - message: "Request body exceeds the maximum allowed size", - }, - }, - 413, + errorResponse( + c, + "payload_too_large", + "Request body exceeds the maximum allowed size", ), }), validator("json", SendMessage), @@ -831,18 +723,8 @@ export function createWorkflowRoutes({ description: "Lists the run ids present in the deployment's workflow-run event log. Returns an empty list when no run has committed events yet.", responses: { - 200: { - description: "List of run ids", - content: { - "application/json": { - schema: resolver(WorkflowRunListResponse), - }, - }, - }, - 404: { - description: "Workflow deployment not found", - content: { "application/json": { schema: resolver(ErrorResponse) } }, - }, + 200: jsonResponse("List of run ids", WorkflowRunListResponse), + 404: jsonResponse("Workflow deployment not found", ErrorResponse), }, }), async (c) => { @@ -850,15 +732,7 @@ export function createWorkflowRoutes({ const anchorRunId = c.req.param("runId"); if (!(await deploymentAnchorRunExists(db, anchorRunId, tenant.id))) { - return c.json( - { - error: { - code: "not_found", - message: "Workflow deployment not found", - }, - }, - 404, - ); + return errorResponse(c, "not_found", "Workflow deployment not found"); } const runIds = await runReader.listRunIds( @@ -878,18 +752,8 @@ export function createWorkflowRoutes({ description: "Returns the seq-ordered event projection (RunStarted, StepStarted, StepCompleted, SignalAwaited, RunCompleted, etc.) for a single run. The full event log is returned in ascending seq order; an unknown run returns an empty list.", responses: { - 200: { - description: "Seq-ordered run events", - content: { - "application/json": { - schema: resolver(WorkflowRunEventsResponse), - }, - }, - }, - 404: { - description: "Workflow deployment not found", - content: { "application/json": { schema: resolver(ErrorResponse) } }, - }, + 200: jsonResponse("Seq-ordered run events", WorkflowRunEventsResponse), + 404: jsonResponse("Workflow deployment not found", ErrorResponse), }, }), async (c) => { @@ -898,15 +762,7 @@ export function createWorkflowRoutes({ const runId = c.req.param("eventRunId"); if (!(await deploymentAnchorRunExists(db, anchorRunId, tenant.id))) { - return c.json( - { - error: { - code: "not_found", - message: "Workflow deployment not found", - }, - }, - 404, - ); + return errorResponse(c, "not_found", "Workflow deployment not found"); } // The inner :eventRunId is not independently tenant-checked, and does diff --git a/vendor/intx/hub-api/src/workflow-run-trigger.ts b/vendor/intx/hub-api/src/workflow-run-trigger.ts index cb43aad681..6d747fac08 100644 --- a/vendor/intx/hub-api/src/workflow-run-trigger.ts +++ b/vendor/intx/hub-api/src/workflow-run-trigger.ts @@ -23,15 +23,14 @@ import { workflowRun, } from "@intx/db/schema"; import type { DB, PrincipalKeyStore } from "@intx/db"; -import { loadFrozenGrantSnapshot } from "@intx/db"; +import { loadFrozenGrantSnapshot, resolveFrameSenderKey } from "@intx/db"; import type { GrantStore } from "@intx/types/authz"; import { assembleSignedContent, assembleMessage, - createDetachedSignatureFromProvider, type MessageHeaders, } from "@intx/mime"; -import { generateKeyPair, createEd25519Crypto } from "@intx/crypto"; +import { createDetachedSignatureWithSigner } from "@intx/crypto"; import { base64Encode, deriveWorkflowRunId, @@ -143,6 +142,26 @@ export function createWorkflowRunTrigger(deps: TriggerWorkflowRunDeps) { args: TriggerWorkflowRunArgs, ): Promise { const { tenant, principal, anchorRunId, message: body } = args; + + // The outbound mail is signed with the caller's durable hub principal key, + // and its From address (`${principal.refId}@${tenant.domain}`) must resolve + // back to that same key when the signature is verified. Only a user + // principal's address resolves to its hub principal key; a run principal's + // address resolves to the sidecar-minted `workflow_run.public_key`, and an + // agent principal's address has no resolution surface, so signing here as a + // non-user principal would guarantee a verification mismatch. The route + // layer only ever admits user principals to this path, so this is an + // invariant assertion, not a client-facing rejection: fail loud if a wiring + // change ever lets another kind through, rather than silently emitting mail + // that verification cannot resolve. Revisit this fence if the resolver + // learns to resolve agent addresses. + if (principal.kind !== "user") { + throw new Error( + `triggerWorkflowRun: signing principal ${principal.id} has kind ` + + `${principal.kind}; only a user principal may originate hub-signed mail`, + ); + } + const address = deriveRunAddress({ runId: anchorRunId, domain: tenant.domain, @@ -361,16 +380,11 @@ export function createWorkflowRunTrigger(deps: TriggerWorkflowRunDeps) { } // A trigger occurrence is threading-less at the mail boundary, so no - // inReplyTo or references are stamped. The supervisor decides whether - // this first-fires the absent top-level log or resumes a live onTrigger - // input. This is the same fresh-signed-message - // shape the deploy-flow fixture's mail trigger and the production - // session-service mail path assemble. The route does not route - // through sessionService.sendUserMessage because that path stamps - // interchangeSessionId/agentId headers that scope the message to an - // agent session; a workflow run trigger has no such session. - const keyPair = await generateKeyPair(); - const crypto = createEd25519Crypto(keyPair); + // inReplyTo or references are stamped, and it carries no agent session, so + // the interchangeSessionId/agentId headers are left unset. The supervisor + // decides whether this first-fires the absent top-level log or resumes a + // live onTrigger input. This is the same fresh-signed-message shape the + // deploy-flow fixture's mail trigger assembles. const headers: MessageHeaders = { from, to: [address], @@ -398,9 +412,9 @@ export function createWorkflowRunTrigger(deps: TriggerWorkflowRunDeps) { ? { attachments: messageAttachments } : {}), }); - const signature = await createDetachedSignatureFromProvider( + const signature = await createDetachedSignatureWithSigner( signedContent, - crypto, + (input) => principalKeyStore.sign(principal.id, input), ); const rawMessage = assembleMessage(headers, signedContent, signature); const base64 = base64Encode(rawMessage); @@ -459,6 +473,7 @@ export function createWorkflowRunTrigger(deps: TriggerWorkflowRunDeps) { id: `dispatch:${anchorRunId}:${messageId}`, anchorRunId: anchorRunId, messageId, + senderAddress: fromAddr, rawMessage, stepGrants: canonicalStepGrants, now, @@ -529,6 +544,25 @@ export function createWorkflowRunTrigger(deps: TriggerWorkflowRunDeps) { } stepGrants = reserved; + // Stamp the hub-verified principal address (fromAddr, the address the + // message is signed and addressed under) as the authenticated sender -- + // never the message's own MIME From. Resolve its hub-held key so the + // recipient can verify the signature locally against the key the hub + // vouches for, and co-deliver it on the run's grants barrier below so the + // sender's key rides the same push as the grant. Best-effort: a resolution + // fault degrades to a null key (logged) rather than blocking the trigger. + // A null key is omitted from the co-delivery, so the recipient resolves the + // sender to `unknown`, which its admission policy rejects by default. + const authenticatedSenderPublicKey = await resolveFrameSenderKey( + db, + principalKeyStore, + fromAddr, + ); + const senderIdentities = + authenticatedSenderPublicKey !== null + ? [{ address: fromAddr, publicKey: authenticatedSenderPublicKey }] + : undefined; + // Send the run's grants BEFORE the trigger mail. Both frames route // through the same per-address channel, so same-websocket FIFO // ordering guarantees the grants land at the sidecar before the mail @@ -540,6 +574,7 @@ export function createWorkflowRunTrigger(deps: TriggerWorkflowRunDeps) { address, runId, stepGrants, + senderIdentities, ); if (!grantsDelivered) { return { @@ -554,7 +589,12 @@ export function createWorkflowRunTrigger(deps: TriggerWorkflowRunDeps) { }; } - const delivered = sidecarRouter.routeMail(address, base64, messageId); + const delivered = sidecarRouter.routeMail( + address, + base64, + fromAddr, + messageId, + ); if (!delivered) { return { ok: false, diff --git a/vendor/intx/hub-sessions/src/agent-state-kind.ts b/vendor/intx/hub-sessions/src/agent-state-kind.ts index 83d9b17958..d0a89c5134 100644 --- a/vendor/intx/hub-sessions/src/agent-state-kind.ts +++ b/vendor/intx/hub-sessions/src/agent-state-kind.ts @@ -1,7 +1,6 @@ import { type } from "arktype"; -import { glob, repoActionToGrantVerb } from "@intx/hub-common"; import { - UserPrincipal, + authorizeUserPrincipal, type AuthorizeFn, type KindHandler, type Principal, @@ -144,69 +143,22 @@ export const agentStateAuthorize: AuthorizeFn = ( } if (principal.kind === "user") { - // The route layer has already pre-resolved the grant verdict and - // attached it as `authz`. The substrate does NOT re-query the - // grant store here; it (a) checks the bearer-token's claims - // bound the requested (ref, action) and have not expired, and - // (b) sanity-checks that the pre-resolved verdict targets this - // exact resource and grant verb. Both gates must pass before the - // verdict's `effect` is honoured. - const parsed = UserPrincipal(principal); - if (parsed instanceof type.errors) { - return { - allowed: false, - reason: `user principal is malformed: ${parsed.summary}`, - }; - } + // `agentStateAuthorize` has no top-level repoId.kind gate (the + // other kind handlers reject a mismatched kind up front), so the + // kind check lives here inside the user branch. if (repoId.kind !== "agent-state") { return { allowed: false, reason: `user authorize received non-agent-state repo ${repoId.kind}/${repoId.id}`, }; } - if (!parsed.tokenClaims.actions.includes(action)) { - return { - allowed: false, - reason: `token does not grant action ${action}`, - }; - } - // `ref === "*"` is the substrate's sentinel for the bulk read - // performed by `listRefs`. Per-ref filtering is the advertise-refs - // layer's responsibility, so the bulk read is gated on action and - // expiry alone. - if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) { - return { - allowed: false, - reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`, - }; - } - if (Date.now() >= parsed.tokenClaims.expiresAt) { - return { - allowed: false, - reason: `token expired at ${parsed.tokenClaims.expiresAt}`, - }; - } - const expectedResource = `agent-state:${repoId.id}`; - if (parsed.authz.resource !== expectedResource) { - return { - allowed: false, - reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`, - }; - } - const expectedGrantVerb = repoActionToGrantVerb(action); - if (parsed.authz.grantVerb !== expectedGrantVerb) { - return { - allowed: false, - reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`, - }; - } - if (parsed.authz.effect === "allow") { - return { allowed: true }; - } - return { - allowed: false, - reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`, - }; + return authorizeUserPrincipal({ + principal, + repoId, + ref, + action, + resourcePrefix: "agent-state", + }); } // Fail closed on any kind not handled above. The tenant-level diff --git a/vendor/intx/hub-sessions/src/asset-service.ts b/vendor/intx/hub-sessions/src/asset-service.ts index 0b7e11c774..83ee588c0e 100644 --- a/vendor/intx/hub-sessions/src/asset-service.ts +++ b/vendor/intx/hub-sessions/src/asset-service.ts @@ -123,16 +123,20 @@ export type AssetServiceErrorReason = | "not_found" | "path_violation"; -// Asset names become the default workspace mountpath segment at -// session start (`skills//`). The mountpath segment -// validator in applyAssetPack rejects anything outside a safe -// character set; validate at the createAsset boundary so a bad name -// fails at creation time rather than at materialization time. Names -// must be lowercase-kebab: lowercase letters, digits, hyphens, with -// no leading or trailing hyphen. Exported so a caller deriving a name -// (the agent-fold materializer) asserts the shape at its own boundary -// rather than discovering a violation three layers in as a generic -// `invalid_name`. +// An asset name becomes a workspace mountpath segment for the asset +// kinds a session actually mounts: a tool-package registry mounts at +// `package-registries//`. A skill is an asset kind too, +// and its `SKILL.md` frontmatter is validated on push and indexed +// under the pushed ref, but nothing consumes that index and no +// session path mounts a skill asset into an agent workspace. A skill +// asset is therefore recorded and validated, not materialized and not +// executed. The mountpath segment validator in applyAssetPack +// rejects anything outside a safe character set; validate at the +// createAsset boundary so a bad name fails at creation time rather +// than at materialization time. Names must be lowercase-kebab: +// lowercase letters, digits, hyphens, with no leading or trailing +// hyphen. The sole enforcement site is the createAsset check below, +// which rejects a bad name as `invalid_name`. export const ASSET_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; export class AssetServiceError extends Error { diff --git a/vendor/intx/hub-sessions/src/event-collector.test.ts b/vendor/intx/hub-sessions/src/event-collector.test.ts deleted file mode 100644 index f08d7f3835..0000000000 --- a/vendor/intx/hub-sessions/src/event-collector.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { encodeToolName } from "@intx/inference"; -import type { InferenceEvent } from "@intx/types/runtime"; -import type { DB } from "@intx/db"; - -import { createEventCollector } from "./event-collector"; -import { MALFORMED_TOOL_NAME } from "./sanitize-tool-name"; - -// Stand-in for the drizzle call chains event-collector.ts drives, recording -// every turnPart insert so a test can inspect what actually landed in -// "history". -function createRecordingDb(): { - db: DB["db"]; - insertedParts: { type: string; metadata?: Record }[]; -} { - const insertedParts: { type: string; metadata?: Record }[] = - []; - const chain = { - set: () => chain, - where: () => Promise.resolve(), - }; - const db = { - insert: (table: unknown) => ({ - values: (values: { - type: string; - metadata?: Record; - }) => { - // inferenceTurn inserts don't carry a turnPart-shaped `type` - // discriminant matching the ones this suite cares about, but the - // fake table objects are opaque here, so filter on shape instead. - if (typeof values.type === "string") { - insertedParts.push(values); - } - return Promise.resolve(); - }, - ...chain, - }), - update: () => chain, - } as unknown as DB["db"]; - return { db, insertedParts }; -} - -describe("CL-6478: malformed tool-call names never wedge the room", () => { - const OPENAI_LIMIT = { provider: "openai", maxLength: 64 } as const; - - test("a leaked provider fragment in a tool-call name persists as a re-encodable placeholder", async () => { - const { db, insertedParts } = createRecordingDb(); - const collector = createEventCollector({ - db, - sessionId: "session-1", - runId: "run-1", - tenantId: "tenant-acme", - }); - - await collector.onEvent({ - type: "inference.start", - seq: 1, - data: { model: "qwen3.8:27b" }, - } as InferenceEvent); - - await collector.onEvent({ - type: "inference.done", - seq: 2, - data: { - turn: { - content: [ - { - type: "tool_call", - id: "call-1", - // The CL-6478 report: qwen3.8:27b leaked this fragment into - // the function name decodeToolName then passed through - // verbatim. - name: "@intx/tools-posix/sidecar-bundle:run_shell\n part.metadata?.kind === "call", - ); - if (toolCallPart === undefined) - throw new Error("expected a tool call part"); - const persistedName = toolCallPart.metadata?.name; - - // The malformed name was never written to history as-is. - expect(persistedName).toBe(MALFORMED_TOOL_NAME); - - // The regression that matters: whatever got persisted, the next - // turn's outbound request can put back on the wire. Before the fix, - // persisting the raw leaked name here meant this call threw, and the - // room could never accept another message. - expect(() => - encodeToolName(persistedName as string, OPENAI_LIMIT), - ).not.toThrow(); - - // And the collector itself is unaffected: a second turn in the same - // room proceeds normally. - await collector.onEvent({ - type: "inference.start", - seq: 3, - data: { model: "qwen3.8:27b" }, - } as InferenceEvent); - const secondTurnId = collector.getCurrentTurnId(); - expect(secondTurnId).not.toBeNull(); - }); - - test("a well-formed tool-call name still persists unchanged", async () => { - const { db, insertedParts } = createRecordingDb(); - const collector = createEventCollector({ - db, - sessionId: "session-1", - runId: "run-1", - tenantId: "tenant-acme", - }); - - await collector.onEvent({ - type: "inference.start", - seq: 1, - data: { model: "claude-sonnet" }, - } as InferenceEvent); - - await collector.onEvent({ - type: "inference.done", - seq: 2, - data: { - turn: { - content: [ - { - type: "tool_call", - id: "call-1", - name: "@intx/tools-posix/sidecar-bundle:run_shell", - arguments: {}, - }, - ], - }, - }, - } as InferenceEvent); - - const toolCallPart = insertedParts.find( - (part) => part.metadata?.kind === "call", - ); - expect(toolCallPart?.metadata?.name).toBe( - "@intx/tools-posix/sidecar-bundle:run_shell", - ); - }); -}); diff --git a/vendor/intx/hub-sessions/src/event-collector.ts b/vendor/intx/hub-sessions/src/event-collector.ts index e99aaceb06..a99ec80bec 100644 --- a/vendor/intx/hub-sessions/src/event-collector.ts +++ b/vendor/intx/hub-sessions/src/event-collector.ts @@ -18,8 +18,6 @@ import { type DB, parseTurnPartType } from "@intx/db"; import { generateId } from "@intx/hub-common"; -import { sanitizeToolNameForPersistence } from "./sanitize-tool-name"; - const log = getLogger(["hub", "event-collector"]); export type TurnToolCall = { @@ -299,24 +297,16 @@ export function createEventCollector( // string (observed: PROHIBITED_CONTENT). await insertPart("safety_rating", block.blockReason, null); break; - case "tool_call": { - // block.name came through decodeToolName, which is deliberately - // total and returns a hallucinated or provider-mangled name - // verbatim. Persist only a name the next turn's request-builder - // can put back on the wire (see sanitize-tool-name.ts) so a bad - // tool-call name fails this turn cleanly instead of wedging the - // room forever (CL-6478). - const name = sanitizeToolNameForPersistence(block.name); - callNames.set(block.id, name); + case "tool_call": + callNames.set(block.id, block.name); callArgs.set(block.id, block.arguments); await insertPart("tool", null, { kind: "call", callId: block.id, - name, + name: block.name, arguments: block.arguments, }); break; - } case "tool_result": // Tool results in the content block are echoes of earlier // tool.done events. Skip to avoid duplication. diff --git a/vendor/intx/hub-sessions/src/hub-session-lookups.test.ts b/vendor/intx/hub-sessions/src/hub-session-lookups.test.ts deleted file mode 100644 index 93e60a2f24..0000000000 --- a/vendor/intx/hub-sessions/src/hub-session-lookups.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - anchorAddressForPackSource, - decideTerminalRunFlip, - ownsWorkflowRunRepo, -} from "./hub-session-lookups"; - -describe("ownsWorkflowRunRepo", () => { - const anchor = { - id: "run_1", - address: "run_1@alice.localhost", - anchorRunId: "run_1", - }; - - test("accepts a self-anchored row with a routable address", () => { - expect(ownsWorkflowRunRepo(anchor)).toBe(true); - }); - - test("accepts a terminal anchor so its own teardown bookkeeping can land", () => { - // The status column is not part of the gate: a terminal run must still be - // able to enqueue and reject mail that arrived in its teardown window. - expect(ownsWorkflowRunRepo({ ...anchor })).toBe(true); - }); - - test("rejects an address with no anchor row", () => { - expect(ownsWorkflowRunRepo(undefined)).toBe(false); - }); - - test("rejects a child run that does not anchor its own deployment", () => { - expect(ownsWorkflowRunRepo({ ...anchor, anchorRunId: "run_0" })).toBe(false); - }); - - test("rejects an anchor with no address", () => { - expect(ownsWorkflowRunRepo({ ...anchor, address: null })).toBe(false); - }); -}); - -describe("anchorAddressForPackSource", () => { - const runId = "run_5554796211fb7e08f1748bd9db41f71f"; - const domain = "workbench.localhost"; - - test("resolves a base deployment address to itself", () => { - expect(anchorAddressForPackSource(`${runId}@${domain}`)).toBe( - `${runId}@${domain}`, - ); - }); - - test("peels a per-step address back to its base run's anchor address", () => { - // Mirrors deriveStepAddress in - // vendor/intx/workflow-deploy/src/orchestrator.ts:837. - expect(anchorAddressForPackSource(`${runId}-write@${domain}`)).toBe( - `${runId}@${domain}`, - ); - }); - - test("peels a step id that itself contains dashes", () => { - expect( - anchorAddressForPackSource(`${runId}-fetch-and-write@${domain}`), - ).toBe(`${runId}@${domain}`); - }); - - test("returns null for an address with no run_ prefix", () => { - expect(anchorAddressForPackSource(`not-a-run@${domain}`)).toBe(null); - }); - - test("returns null for a malformed address parseRunAddress rejects", () => { - expect(anchorAddressForPackSource("no-at-sign")).toBe(null); - }); - - test("returns null for a run id shorter than the minted hex length", () => { - expect(anchorAddressForPackSource(`run_deadbeef@${domain}`)).toBe(null); - }); -}); - -describe("decideTerminalRunFlip (CL-6379)", () => { - const anchorId = "run_cd77dfa3f597bb7d9c9ad83b9419857b"; - const mintedRunId = "run_5554796211fb7e08f1748bd9db41f71f"; - - test("flips a row anchored on the source deployment", () => { - expect(decideTerminalRunFlip(mintedRunId, anchorId, anchorId)).toEqual({ - kind: "flip", - }); - }); - - test("skips a section occurrence's repo-local child run (turn__): no row is expected and none is a defect", () => { - expect(decideTerminalRunFlip("turn__1", undefined, anchorId)).toEqual({ - kind: "skip_repo_local", - }); - }); - - test("reports a minted run id whose row is missing: the run terminated before its anchor committed", () => { - expect(decideTerminalRunFlip(mintedRunId, undefined, anchorId)).toEqual({ - kind: "missing_row", - }); - }); - - test("rejects a row anchored on a different deployment", () => { - expect( - decideTerminalRunFlip( - mintedRunId, - "run_38f239787346a29593f3bcc73efa8062", - anchorId, - ), - ).toEqual({ kind: "foreign_anchor" }); - }); - - test("a lazily-anchored internal run row with a null anchor is not the source deployment's to flip", () => { - expect(decideTerminalRunFlip(mintedRunId, null, anchorId)).toEqual({ - kind: "foreign_anchor", - }); - }); -}); diff --git a/vendor/intx/hub-sessions/src/hub-session-lookups.ts b/vendor/intx/hub-sessions/src/hub-session-lookups.ts index c5bbcb1b75..c4d0a46859 100644 --- a/vendor/intx/hub-sessions/src/hub-session-lookups.ts +++ b/vendor/intx/hub-sessions/src/hub-session-lookups.ts @@ -23,7 +23,7 @@ import { workflowRun, } from "@intx/db/schema"; import { getLogger } from "@intx/log"; -import { formatRunAddress, parseRunAddress, signalName } from "@intx/types"; +import { parseRunAddress, signalName } from "@intx/types"; import { SignalDeliverFrame } from "@intx/types/sidecar"; import { deriveWorkflowRunRepoId } from "@intx/workflow-deploy"; @@ -34,112 +34,10 @@ import { listAcceptedWorkflowDispatches, listConsumedWorkflowDispatches, } from "./workflow-dispatch-settlement"; -import { - readCommittedWorkflowRunLifecycle, - readCommittedWorkflowRunTerminalStatus, -} from "./workflow-run-kind"; +import { readCommittedWorkflowRunLifecycle } from "./workflow-run-kind"; const logger = getLogger(["hub", "lookups"]); -/** - * Ownership gate for a workflow-run pack: the pushing address must resolve to - * a self-anchored `workflow_run` row that owns the repository it is writing. - * - * Deliberately NOT gated on a live run status (workbench-local; see VENDORED.md). - * A terminal anchor still has bookkeeping to durably write about its own - * death -- the inbox enqueue for mail that arrived in the teardown window and - * the `markConsumed` rejection record that retires it. Gating those writes on - * a live status wedged the pair into an unbreakable loop: the hub rejected the - * pack with `path_violation`, the sidecar withheld the ack, the hub redelivered, - * forever. Accepting the pack is safe because the substrate walk yields no new - * terminal events for an already-settled run, and the allocation fences below - * still decide who may write. - */ -export function ownsWorkflowRunRepo( - anchor: - | { id: string; address: string | null; anchorRunId: string | null } - | undefined, -): anchor is { id: string; address: string; anchorRunId: string } { - return ( - anchor !== undefined && - anchor.address !== null && - anchor.anchorRunId === anchor.id - ); -} - -const RUN_ID_HEX_LENGTH = 32; -const RUN_ID_PATTERN = new RegExp(`^run_[0-9a-f]{${RUN_ID_HEX_LENGTH}}`); - -/** - * Recover the deployment anchor's routing address from a workflow-run pack's - * source address. A deployment's one addressable anchor run stores its own - * `@` address (`isTopLevelRun`); a multi-step deployment's - * per-step agents push under `deriveStepAddress`'s `-@` - * instead (`vendor/intx/workflow-deploy/src/orchestrator.ts:837`), which never - * matches any `workflow_run.address` row -- only the anchor row carries one. - * Peel the step suffix back to the run id so a step's pack resolves to the - * SAME anchor its base run owns. - * - * Run ids are minted `run_` + 32 lowercase hex chars - * (`generateId("workflowRun")`, `vendor/intx/hub-common/src/ids.ts`), a fixed - * length disjoint from the free-form `stepId` (`[a-zA-Z0-9_-]+`), so the - * boundary parses unambiguously: the characters immediately after - * `run_<32 hex>` are either the end of the local part (a base address) or a - * `-` (a step address). Matching from the left avoids splitting on `-` - * generally, since a `stepId` may itself contain dashes. - * - * Returns null for an address `parseRunAddress` itself rejects or whose local - * part is not `run_`-prefixed hex, so a malformed address fails the later - * ownership lookup closed rather than silently resolving to a fabricated - * anchor. - */ -export function anchorAddressForPackSource( - agentAddress: string, -): string | null { - const parsed = parseRunAddress(agentAddress); - if (parsed === null) return null; - const match = RUN_ID_PATTERN.exec(parsed.runId); - if (match === null) return null; - return formatRunAddress(match[0], parsed.domain); -} - -/** - * What an accepted workflow-run pack's newly-terminal run means for the - * `workflow_run` table. `ownedAnchorRunId` is the run's row as the flip - * transaction read it: `undefined` when no row exists, otherwise the row's - * own `anchorRunId` (possibly null). - * - * Only a minted run id (`run_` + 32 hex, the shape `generateId("workflowRun") - * produces) ever owns a row. A section-mode occurrence runs as a repo-local - * child run (`turn__` — see `@corbits/agent-runtime`'s - * `agentRuntimeTurnRunId`) whose whole identity lives in the deployment's - * own event repo: it never crosses a route that mints a `workflow_run` row, - * so its terminal event has no DB flip to perform and its absence is not a - * defect. A minted id with no row IS one — the run terminated before its - * anchor committed — and a row anchored elsewhere (or lazily anchored with - * a null anchor) is not the source deployment's to flip. - */ -export type TerminalRunFlipDecision = - | { kind: "flip" } - | { kind: "skip_repo_local" } - | { kind: "missing_row" } - | { kind: "foreign_anchor" }; - -export function decideTerminalRunFlip( - runId: string, - ownedAnchorRunId: string | null | undefined, - sourceAnchorId: string, -): TerminalRunFlipDecision { - if (ownedAnchorRunId === undefined) { - return RUN_ID_PATTERN.test(runId) - ? { kind: "missing_row" } - : { kind: "skip_repo_local" }; - } - return ownedAnchorRunId === sourceAnchorId - ? { kind: "flip" } - : { kind: "foreign_anchor" }; -} - export type HubSessionLookupsDeps = { db: DB["db"]; agentRepoStore: AgentRepoStore; @@ -150,7 +48,10 @@ export function createHubSessionLookups( ): Required< Omit< SidecarLookups, - "materializeMailTriggeredRunGrants" | "resyncCredentials" + | "materializeMailTriggeredRunGrants" + | "resyncCredentials" + | "resolveSenderKey" + | "resolveSenderKeyStrict" > > { const { db, agentRepoStore } = deps; @@ -270,16 +171,6 @@ export function createHubSessionLookups( kind, approvalSnapshot, }) { - // This co-write is approval-only: it always writes an `approval` row, - // built from `approvalSnapshot`, alongside the correlation. A signal - // kind this RPC has no persistence for fails loud here rather than - // writing a spurious approval row for it. - if (kind !== "approval") { - throw new Error( - `registerSignalCorrelation only supports "approval" signals; got "${kind}" for ${correlationId}`, - ); - } - // Resolve tenancy and co-write both rows in one transaction so a resolver // never sees a correlation without its approval or vice versa. Both // inserts are idempotent on their dedup key (the signal_correlation @@ -447,25 +338,29 @@ export function createHubSessionLookups( logger.warn`Workflow-run pack rejected for ${workflowRunRepoId}: source address does not own the repository`; return { accepted: false, reason: "path_violation" as const }; } - const anchorLookupAddress = - anchorAddressForPackSource(source.agentAddress) ?? - source.agentAddress; const [anchor] = await db .select({ id: workflowRun.id, address: workflowRun.address, anchorRunId: workflowRun.anchorRunId, + tenantId: workflowRun.tenantId, + definitionId: workflowRun.definitionId, }) .from(workflowRun) .where( and( - eq(workflowRun.address, anchorLookupAddress), + eq(workflowRun.address, source.agentAddress), + inArray(workflowRun.status, [...liveWorkflowRunStatuses]), isNotNull(workflowRun.definitionId), ), ) .limit(1); - if (!ownsWorkflowRunRepo(anchor)) { - logger.warn`Workflow-run pack rejected for ${workflowRunRepoId}: source address has no deployment anchor it owns`; + if ( + anchor === undefined || + anchor.anchorRunId !== anchor.id || + anchor.address === null + ) { + logger.warn`Workflow-run pack rejected for ${workflowRunRepoId}: source address has no live deployment anchor`; return { accepted: false, reason: "path_violation" as const }; } const anchorAddress = anchor.address; @@ -536,26 +431,48 @@ export function createHubSessionLookups( for (const { runId, status } of newlyTerminalRuns) { try { await db.transaction(async (tx) => { + // Lazily anchor the run before settling it. An internal run that + // parks only on a plain signal gate never reaches + // `registerSignalCorrelation`, the sole other path that mints an + // internal run row, so its terminal event can be the first the hub + // sees of the run. A never-minted row is ordinary bookkeeping, not + // a deployment-boundary violation, so mint it here against this + // deployment's anchor rather than letting the ownership guard below + // mistake absence for foreignness. The insert no-ops when any row + // already exists, which keeps that guard authoritative for a row + // that exists and anchors elsewhere. The principal is null: an + // internal run inherits its deployment's grants and has none of its + // own. + // + // The mint necessarily precedes the ownership guard, so an id the + // hub has never seen is claimed under THIS anchor before anything + // establishes it belongs here. That ordering is required -- the + // guard reads the row the mint may have to create -- and it is + // bounded rather than unbounded: internal run ids are supplied by + // the sidecar and accepted verbatim, so the value is + // caller-influenced, but it is a different population from the + // anchor ids the hub mints itself, and nothing resolves an + // internal id without also constraining the anchor or the tenant. + // The insert cannot take a row away from another deployment; the + // worst it does is create one for an id that deployment would + // otherwise have created later. + await workflowRunStore.createIfAbsent( + { + id: runId, + anchorRunId: anchor.id, + definitionId: anchor.definitionId, + tenantId: anchor.tenantId, + principalId: null, + status: "running", + }, + tx, + ); const [ownedRun] = await tx .select({ anchorRunId: workflowRun.anchorRunId }) .from(workflowRun) .where(eq(workflowRun.id, runId)) .limit(1); - const decision = decideTerminalRunFlip( - runId, - ownedRun === undefined ? undefined : ownedRun.anchorRunId, - anchor.id, - ); - if (decision.kind === "skip_repo_local") { - // A section occurrence's child run (`turn__`) settles in - // the deployment's own event repo; there is no row to flip. - return; - } - if (decision.kind === "missing_row") { - logger.error`Terminal event for run ${runId} (deployment ${anchor.id}, target status ${status}) has no workflow_run row; the run terminated before its anchor committed`; - return; - } - if (decision.kind === "foreign_anchor") { + if (ownedRun?.anchorRunId !== anchor.id) { logger.error`Ignoring terminal event for run ${runId}: it does not belong to source deployment ${anchor.id}`; return; } @@ -566,12 +483,11 @@ export function createHubSessionLookups( tx, ); if (won === null) { - // The row exists and is anchored here, but `markTerminal`'s - // guard is a LIVE status (`deployed` or `running`), not just - // "running" -- so this also covers a deployment torn down - // before its first trigger. No live row matched: the run is - // already terminal — a benign replay against an - // already-settled row. + // The row exists (the mint above guarantees it) and belongs to + // this deployment (the guard above), so no running row matched + // only because the run is already terminal -- a benign replay + // against an already-settled row. Leave its settled status and + // `endedAt` alone. return; } // Deactivate the run's own principal, if it has one. Externally- @@ -711,41 +627,6 @@ export function createHubSessionLookups( } catch (error) { logger.error`Failed to close unsettled workflow dispatches for terminal run ${anchorAddress}: ${error instanceof Error ? error.message : String(error)}`; } - - // Defense in depth for CL-6595: the committed Git log just proved - // this run terminal, independent of whether the newly-terminal - // detection above caught it on this pack (or any earlier one). If - // `workflow_run.status` is still live, self-heal it here rather than - // leaving every future reader to hit the same stale column. - try { - const reads = await agentRepoStore.repoStore.openCommittedReads( - { kind: "hub" }, - repoId, - ref, - ); - const status = await readCommittedWorkflowRunTerminalStatus( - reads, - anchor.id, - ); - if (status !== null) { - const won = await db.transaction((tx) => - workflowRunStore.markTerminal(anchor.id, status, now, tx), - ); - if (won !== null && won.principalId !== null) { - await db - .update(principal) - .set({ status: "deactivated", updatedAt: now }) - .where( - and( - eq(principal.id, won.principalId), - eq(principal.refId, anchor.id), - ), - ); - } - } - } catch (error) { - logger.error`Terminal-status backfill failed for run ${anchor.id}; workflow_run.status may still read live: ${error instanceof Error ? error.message : String(error)}`; - } } return { accepted: true }; diff --git a/vendor/intx/hub-sessions/src/hub-session-orchestrator.ts b/vendor/intx/hub-sessions/src/hub-session-orchestrator.ts index c8331ee9e3..c4191a7378 100644 --- a/vendor/intx/hub-sessions/src/hub-session-orchestrator.ts +++ b/vendor/intx/hub-sessions/src/hub-session-orchestrator.ts @@ -20,6 +20,7 @@ import { getLogger } from "@intx/log"; import type { EventCollectorRegistry } from "./event-collector-registry"; import type { SidecarEventEmitter } from "./ws/sidecar-events"; +import type { SenderDeploySettledOutcome } from "./ws/sidecar-handler"; const log = getLogger(["hub", "orchestrator"]); @@ -28,6 +29,10 @@ const log = getLogger(["hub", "orchestrator"]); * from the rest of the router API. */ export type HubSessionRouterFacade = { dispatchAgentEvent(agentAddress: string, event: unknown): void; + noteSenderDeploySettled( + address: string, + outcome: SenderDeploySettledOutcome, + ): void; }; export type HubSessionOrchestratorDeps = { @@ -101,6 +106,14 @@ export function createHubSessionOrchestrator( .update(workflowRun) .set({ publicKey }) .where(eq(workflowRun.address, agentAddress)); + + // The key is now durable. Wake any mail the run parked while pre-ack so it + // is delivered with the sender key co-delivered, closing the window where + // a run sends before its key is recorded. The write above happens-before + // this settle, so a re-drive resolves the recorded key. The address is the + // run's own deploy address, byte-identical to the sender address its mail + // was sent under. + router.noteSenderDeploySettled(agentAddress, { recorded: publicKey }); }), ); diff --git a/vendor/intx/hub-sessions/src/index.ts b/vendor/intx/hub-sessions/src/index.ts index f1037242d2..b3caf6f288 100644 --- a/vendor/intx/hub-sessions/src/index.ts +++ b/vendor/intx/hub-sessions/src/index.ts @@ -35,11 +35,13 @@ export { createSidecarRouter, type SidecarRouter, type SidecarRouterConfig, - type SidecarAuthIdentity, - type SidecarAuthenticator, - type OAuthLoginRequestOutcome, + type SenderDeploySettledOutcome, type OAuthLoginFinalOutcome, type OAuthLoginGateOutcome, + type OAuthLoginRequestOutcome, + DEFAULT_OAUTH_LOGIN_TIMEOUT_MS, + type SidecarAuthIdentity, + type SidecarAuthenticator, type AllocatedSidecarTarget, type SidecarAllocationRouter, createSidecarCredentialResolver, @@ -98,9 +100,15 @@ export { type SidecarProvisionerChooser, type SidecarAllocationReconciler, type SidecarAllocationReconcilerDeps, + type SidecarReconciliationContext, } from "./sidecar-allocation"; export { ensureWorkflowDefinitionForAsset } from "./workflow-definition-ensure"; export { workflowSourceAssetMountPath } from "./workflow-closure-resolution"; +export { + createReconciliationScheduler, + DEFAULT_SIDECAR_ALLOCATION_CONCURRENCY, + type ReconciliationSchedulerOptions, +} from "./reconciliation-scheduler"; export { createWorkflowAllocationService, WorkflowProvisioningError, @@ -197,7 +205,6 @@ export { type WorkflowRunSidecarPrincipal, type WorkflowRunWorkflowProcessPrincipal, type WorkflowRunSupervisorPrincipal, - readCommittedWorkflowRunTerminalStatus, } from "./workflow-run-kind"; export { restoreWorkflowRunToAllocation, diff --git a/vendor/intx/hub-sessions/src/reconciliation-scheduler.ts b/vendor/intx/hub-sessions/src/reconciliation-scheduler.ts new file mode 100644 index 0000000000..606b504f6d --- /dev/null +++ b/vendor/intx/hub-sessions/src/reconciliation-scheduler.ts @@ -0,0 +1,71 @@ +import { getLogger } from "@intx/log"; + +const logger = getLogger(["hub", "reconciliation"]); + +export const DEFAULT_SIDECAR_ALLOCATION_CONCURRENCY = 8; + +export type ReconciliationSchedulerOptions = { + readonly name: string; + /** Claim and process at most one item; false means no due work was found. */ + readonly reconcileNext: () => Promise; + readonly concurrency?: number; + readonly intervalMs?: number; +}; + +/** Polls for work without waiting for other occupied slots to finish. */ +export function createReconciliationScheduler({ + name, + reconcileNext, + concurrency = DEFAULT_SIDECAR_ALLOCATION_CONCURRENCY, + intervalMs = 1_000, +}: ReconciliationSchedulerOptions) { + if (!Number.isSafeInteger(concurrency) || concurrency <= 0) { + throw new Error("Reconciliation concurrency must be a positive integer"); + } + if (!Number.isSafeInteger(intervalMs) || intervalMs <= 0) { + throw new Error("Reconciliation interval must be a positive integer"); + } + let stopped = true; + let active = 0; + let timer: ReturnType | undefined; + + function finish(worked: boolean): void { + active -= 1; + if (worked) wake(); + } + + function wake(): void { + if (stopped) return; + while (active < concurrency) { + active += 1; + void Promise.resolve() + .then(() => (stopped ? false : reconcileNext())) + .then(finish, (error: unknown) => { + logger.error`${name} reconciliation failed: ${error instanceof Error ? error.message : String(error)}`; + finish(false); + }); + } + } + + function schedule(delayMs: number): void { + timer = setTimeout(() => { + if (stopped) return; + wake(); + schedule(intervalMs); + }, delayMs); + timer.unref?.(); + } + + return { + start(): void { + if (!stopped) return; + stopped = false; + schedule(0); + }, + stop(): void { + stopped = true; + clearTimeout(timer); + }, + wake, + }; +} diff --git a/vendor/intx/hub-sessions/src/repo-store/index.ts b/vendor/intx/hub-sessions/src/repo-store/index.ts index 8b6b656b48..de047b20e6 100644 --- a/vendor/intx/hub-sessions/src/repo-store/index.ts +++ b/vendor/intx/hub-sessions/src/repo-store/index.ts @@ -19,6 +19,10 @@ export type { WriteTreePreservingPrefixArgs, } from "./types"; export { UserPrincipal } from "./types"; +export { + authorizeUserPrincipal, + type AuthorizeUserPrincipalArgs, +} from "./user-principal-gate"; export { createRepoStore, type CreateRepoStoreConfig } from "./store"; export { subscribeKind, diff --git a/vendor/intx/hub-sessions/src/repo-store/user-principal-gate.ts b/vendor/intx/hub-sessions/src/repo-store/user-principal-gate.ts new file mode 100644 index 0000000000..329f0650ec --- /dev/null +++ b/vendor/intx/hub-sessions/src/repo-store/user-principal-gate.ts @@ -0,0 +1,102 @@ +// Shared authorization gate for the `user` principal variant, used by +// every kind handler that accepts user-token-authenticated requests +// (workflow, skill, agent-state, workflow-run). +// +// The route layer has already pre-resolved the grant verdict and +// attached it as `authz`; the kind handlers do NOT re-query the grant +// store here. This gate (a) checks the bearer-token's claims bound the +// requested (ref, action) and have not expired, and (b) sanity-checks +// that the pre-resolved verdict targets this exact resource and grant +// verb. Both gates must pass before the verdict's `effect` is honoured. +// +// Funnelling every kind through this one gate keeps the security- +// critical claim/verdict cross-check from drifting between kinds. The +// only per-kind input is the `resourcePrefix` the verdict's `resource` +// must carry (`asset:` for the codebase kinds, `agent-state:`, +// `workflow-run:`). + +import { type } from "arktype"; +import { glob, repoActionToGrantVerb } from "@intx/hub-common"; +import type { RepoAction, RepoId, Principal } from "./types"; +import { UserPrincipal } from "./types"; + +export type AuthorizeUserPrincipalArgs = { + principal: Principal; + repoId: RepoId; + ref: string; + action: RepoAction; + /** + * The resource-kind prefix the pre-resolved authz verdict must carry + * for this kind: the verdict's `resource` is compared against + * `:`. + */ + resourcePrefix: string; +}; + +/** + * Verdict for a `user` principal performing `action` on `ref` of + * `repoId`, in the shape the substrate's `AuthorizeFn` contract + * expects. The caller dispatches on `principal.kind === "user"` first; + * this function narrows with `UserPrincipal` and applies the full + * claim/verdict cross-check. + */ +export function authorizeUserPrincipal({ + principal, + repoId, + ref, + action, + resourcePrefix, +}: AuthorizeUserPrincipalArgs): + | { allowed: true } + | { allowed: false; reason: string } { + const parsed = UserPrincipal(principal); + if (parsed instanceof type.errors) { + return { + allowed: false, + reason: `user principal is malformed: ${parsed.summary}`, + }; + } + if (!parsed.tokenClaims.actions.includes(action)) { + return { + allowed: false, + reason: `token does not grant action ${action}`, + }; + } + // `ref === "*"` is the substrate's sentinel for the bulk read + // performed by `listRefs`. Per-ref filtering is the advertise-refs + // layer's responsibility, so the bulk read is gated on action and + // expiry alone. + if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) { + return { + allowed: false, + reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`, + }; + } + if (Date.now() >= parsed.tokenClaims.expiresAt) { + return { + allowed: false, + reason: `token expired at ${parsed.tokenClaims.expiresAt}`, + }; + } + const expectedResource = `${resourcePrefix}:${repoId.id}`; + if (parsed.authz.resource !== expectedResource) { + return { + allowed: false, + reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`, + }; + } + const expectedGrantVerb = repoActionToGrantVerb(action); + if (parsed.authz.grantVerb !== expectedGrantVerb) { + return { + allowed: false, + reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`, + }; + } + if (parsed.authz.effect === "allow") { + return { allowed: true }; + } + return { + allowed: false, + reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`, + }; +} diff --git a/vendor/intx/hub-sessions/src/sanitize-tool-name.test.ts b/vendor/intx/hub-sessions/src/sanitize-tool-name.test.ts deleted file mode 100644 index e05707ee2a..0000000000 Binary files a/vendor/intx/hub-sessions/src/sanitize-tool-name.test.ts and /dev/null differ diff --git a/vendor/intx/hub-sessions/src/sanitize-tool-name.ts b/vendor/intx/hub-sessions/src/sanitize-tool-name.ts deleted file mode 100644 index 848077393b..0000000000 --- a/vendor/intx/hub-sessions/src/sanitize-tool-name.ts +++ /dev/null @@ -1,33 +0,0 @@ -// decodeToolName (from @intx/inference) is deliberately total: a -// hallucinated or provider-mangled function name is returned verbatim -// rather than throwing, so a bad tool-call name never tears down the -// stream mid-turn. But encodeToolName -- used to put a persisted tool -// name back on the wire when the *next* turn's request is built -- throws -// on a name it cannot re-encode (too long once escaped, or carrying a -// multi-byte character). Persisting a decoded name unchecked means that -// fault surfaces one turn late, after the bad data is already durable: -// every following turn in the room dies rebuilding its request, forever -// (CL-6478). -// -// This is the one place a decoded tool name is accepted into turn -// history. Only a name encodeToolName can invert is persisted as-is; -// anything else collapses to a stable placeholder, so a single bad -// tool-call name can never permanently wedge a room. -import { encodeToolName } from "@intx/inference"; - -// The tightest tool-name wire limit among the OpenAI-compatible providers -// workbench talks to (OpenAI, xAI, Groq, DeepSeek, Mistral, OpenRouter, -// Ollama all cap at 64). Used only as this module's round-trip sanity -// check, not as the wire encoding an outbound request actually sends. -const ROUND_TRIP_LIMIT = { provider: "round-trip-check", maxLength: 64 } as const; - -export const MALFORMED_TOOL_NAME = "malformed_tool_call"; - -export function sanitizeToolNameForPersistence(name: string): string { - try { - encodeToolName(name, ROUND_TRIP_LIMIT); - return name; - } catch { - return MALFORMED_TOOL_NAME; - } -} diff --git a/vendor/intx/hub-sessions/src/session-service-pins.test.ts b/vendor/intx/hub-sessions/src/session-service-pins.test.ts deleted file mode 100644 index ac2c0a9f37..0000000000 --- a/vendor/intx/hub-sessions/src/session-service-pins.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import type { HarnessConfig } from "@intx/types/runtime"; -import type { ToolPackagePin } from "@intx/types/tool-packages"; -import { - deriveStepAgentId, - resolveStepAddress, -} from "@intx/workflow-deploy"; - -import type { DeployContent } from "./agent-repo"; -import { preparedStepStageArgs } from "./session-service"; -import type { AllocatedSidecarTarget } from "./ws/sidecar-handler"; - -const pins: readonly ToolPackagePin[] = [ - { name: "acme-tool", version: "^1.2.3" }, -]; - -function baseArgs() { - return { - anchorRunId: "run_1", - deploymentDomain: "example.test", - stepId: "step-a", - stepCount: 2, - config: { marker: "config" } as unknown as HarnessConfig, - deployContent: { systemPrompt: "prompt" } satisfies DeployContent, - allocationTarget: { marker: "target" } as unknown as AllocatedSidecarTarget, - }; -} - -describe("preparedStepStageArgs — corbitsdev/workbench#709 prepared-path pins", () => { - test("forwards toolPackagePins verbatim when present", () => { - const base = baseArgs(); - const args = preparedStepStageArgs({ ...base, toolPackagePins: pins }); - - expect(args.toolPackagePins).toBe(pins); - }); - - test("leaves address, agent id, and passthrough fields untouched", () => { - const base = baseArgs(); - const args = preparedStepStageArgs({ ...base, toolPackagePins: pins }); - - expect(args.agentAddress).toBe( - resolveStepAddress({ - runId: base.anchorRunId, - stepId: base.stepId, - domain: base.deploymentDomain, - stepCount: base.stepCount, - }), - ); - expect(args.agentId).toBe( - deriveStepAgentId({ runId: base.anchorRunId, stepId: base.stepId }), - ); - expect(args.runId).toBe(base.anchorRunId); - expect(args.config).toBe(base.config); - expect(args.deployContent).toBe(base.deployContent); - expect(args.allocationTarget).toBe(base.allocationTarget); - }); - - test("omits the key when pins are undefined so the empty/undefined skip holds", () => { - const args = preparedStepStageArgs(baseArgs()); - - expect("toolPackagePins" in args).toBe(false); - }); - - test("forwards an empty array as-is (stageWorkflowStep treats empty as skip)", () => { - const empty: readonly ToolPackagePin[] = []; - const args = preparedStepStageArgs({ - ...baseArgs(), - toolPackagePins: empty, - }); - - expect(args.toolPackagePins).toBe(empty); - }); -}); diff --git a/vendor/intx/hub-sessions/src/session-service.ts b/vendor/intx/hub-sessions/src/session-service.ts index 139e6ef7d8..dca48e3af8 100644 --- a/vendor/intx/hub-sessions/src/session-service.ts +++ b/vendor/intx/hub-sessions/src/session-service.ts @@ -1,13 +1,7 @@ import { type } from "arktype"; -import { and, eq, isNull } from "drizzle-orm"; +import { and, eq, gt, isNull, sql } from "drizzle-orm"; import { getLogger } from "@intx/log"; -import { - assembleMessage, - assembleSignedContent, - createDetachedSignatureFromProvider, - type MessageHeaders, -} from "@intx/mime"; import { buildCredentialDelivery, listAssetsForTenant, @@ -20,19 +14,14 @@ import { workflowRun as workflowRunTable, type WorkflowRunCredentialRefs, } from "@intx/db/schema"; -import { base64Encode, hexEncode } from "@intx/types"; +import { hexEncode } from "@intx/types"; import type { CredentialDelivery, CredentialMaterialEntry, } from "@intx/types/sidecar"; import type { CredentialCipher } from "@intx/types"; import { sessionAsset as sessionAssetTable } from "@intx/db/schema"; -import type { - CryptoProvider, - HarnessConfig, - InferenceSource, - MessageAttachment, -} from "@intx/types/runtime"; +import type { HarnessConfig, InferenceSource } from "@intx/types/runtime"; import { type RegistryConfig, type RegistrySource, @@ -58,6 +47,7 @@ import type { } from "@intx/types/workflow-sources"; import { buildInertProjectionStepSources, + collectAgentBearingStepIds, deriveRunAddress, deriveStepAgentId, resolveStepAddress, @@ -73,6 +63,7 @@ import { } from "./asset-service"; import type { AllocatedSidecarTarget, + SenderDeploySettledOutcome, SendProbeArgs, SidecarAllocationRouter, SidecarRouter, @@ -92,6 +83,7 @@ import { type InstallAndApproveResult, } from "./workflow-probe-gate"; import { buildReferencedWorkflowSourcePins } from "./workflow-source-pins"; +import type { SidecarReconciliationContext } from "./sidecar-allocation/operation"; const logger = getLogger(["interchange", "hub", "session-service"]); @@ -131,13 +123,6 @@ export type SessionService = { allocationTarget: AllocatedSidecarTarget; }): Promise; - /** - * Compose a signed RFC 2822 message from the user and deliver it to the - * agent via the mail transport. Throws if the agent is unreachable. - * Returns the raw MIME bytes of the assembled message. - */ - sendUserMessage(params: UserMessageParams): Promise; - /** * Undeploy an agent and wait for the sidecar to acknowledge. */ @@ -216,6 +201,8 @@ export type DeployPreparedCodeSourcedWorkflowParams = { toolPackagePins?: readonly ToolPackagePin[]; /** The exact allocation generation to deploy onto. */ allocationTarget: AllocatedSidecarTarget; + /** Current owner and cancellation of this initialization attempt. */ + reconciliation: SidecarReconciliationContext; /** Cipher for the definition's tenant-owned credential bindings, if any. */ credentialCipher?: CredentialCipher; }; @@ -241,20 +228,6 @@ export type PreparedWorkflowDeployer = { ): Promise; }; -export type UserMessageParams = { - agentAddress: string; - from: string; - messageId: string; - date: Date; - content: string; - attachments?: MessageAttachment[]; - inReplyTo?: string; - references?: string[]; - sessionId: string; - tenantId: string; - cryptoProvider: CryptoProvider; -}; - export type SessionServiceDeps = { sidecarRouter: SidecarRouter; /** Present when this Hub can route deploy phases to provisioned allocations. */ @@ -466,7 +439,9 @@ export type SendMultiStepDeployFrameArgs = SourceRefDeployFrameArgs; */ export async function sendMultiStepDeployFrame( args: SendMultiStepDeployFrameArgs, + signal?: AbortSignal, ): Promise<{ publicKey: string }> { + signal?.throwIfAborted(); const workflow = { // The deploy frame carries no inline definition: the sidecar evaluates the // pinned code closure from `sourceRef` and re-verifies it against @@ -490,6 +465,7 @@ export async function sendMultiStepDeployFrame( args.agentAddress, args.config, workflow, + signal, ); } @@ -675,7 +651,7 @@ async function prepareSourceRefDeploy( // The hub holds only the frozen inert projection, so it enumerates the inline // bodies from the wire form and resolves each body step's source through the // same resolver + operator-approval gate the top-level steps use - // (`pickStepInferenceSource` against `approval.approvedGrants`). Each body's + // (`pickStepInferenceSource` against `approval.approvedSurface`). Each body's // wire hash is recomputed from the inert body verbatim, so a body child's // re-verify over the re-evaluated closure clears the same barrier a top-level // re-verify does. The pinned sources ride OUTSIDE the hash; their trust comes @@ -694,15 +670,22 @@ async function prepareSourceRefDeploy( const referencedDefinitions = await buildReferencedWorkflowSourcePins({ projection, config: args.config, - operatorApprovals: approval.approvedGrants, + operatorApprovals: approval.approvedSurface, }); // Assemble the ONE credential delivery. Its `materials` cover three rails, each // authorized upstream on its own terms, deduped by credentialId into one cell: // - tool bindings, grant-scoped through `buildCredentialDelivery` above; - // - EVERY top-level inference source (`args.sources`), tenant-owned; - // - EVERY inline body step's inference source (onTrigger/childWorkflow bodies + // - the inference source pinned to each top-level step that can actually + // invoke inference, tenant-owned; + // - the same for each inline body step (onTrigger/childWorkflow bodies // pinned above), tenant-owned. + // Which steps those are is read from the hash-covered projection, never from + // the pinned map: every step carries a pin because the wire shape demands one, + // but a step that cannot issue a request has no use for a secret. Delivering + // one anyway decrypts a tenant credential, seals it to the sidecar, and + // re-delivers it on every reconnect, on behalf of a step that never makes a + // call. // The inference rails are resolved HERE from the DB under the tenant-ownership // authority, so this deploy is self-contained: a direct deploy (a test) that // seeds the credentials in the DB -- rather than pre-supplying material -- still @@ -716,17 +699,34 @@ async function prepareSourceRefDeploy( materials.set(material.credentialId, material); } const inferenceCredentialIds = new Set(); - for (const stepSources of Object.values(args.sources)) { - for (const source of stepSources) { - inferenceCredentialIds.add(source.credentialId); - } - } - for (const body of referencedDefinitions) { - for (const stepSources of Object.values(body.sources)) { + const addAgentBearingCredentials = ( + definition: WorkflowProjectionWithSources["definition"], + pinned: Readonly>, + context: string, + ): void => { + for (const stepId of collectAgentBearingStepIds({ definition, context })) { + const stepSources = pinned[stepId]; + if (stepSources === undefined) { + throw new Error( + `${context}step ${stepId} can invoke inference but carries no pinned source`, + ); + } for (const source of stepSources) { inferenceCredentialIds.add(source.credentialId); } } + }; + addAgentBearingCredentials( + projection, + args.sources, + "deployCodeSourcedWorkflow: ", + ); + for (const body of referencedDefinitions) { + addAgentBearingCredentials( + body.definition, + body.sources, + `deployCodeSourcedWorkflow body ${body.definition.id}: `, + ); } if (inferenceCredentialIds.size > 0) { if (args.credentialCipher === undefined) { @@ -814,14 +814,17 @@ async function emitSourceRefDeployFrame( allocationTarget?: AllocatedSidecarTarget; sidecarAllocationRouter?: SidecarAllocationRouter; }, + signal?: AbortSignal, ): Promise<{ publicKey: string; definitionId: string; credentialRefs?: WorkflowRunCredentialRefs; }> { + signal?.throwIfAborted(); const { definitionId, sendArgs } = await prepareSourceRefDeploy(args); + signal?.throwIfAborted(); try { - const result = await sendMultiStepDeployFrame(sendArgs); + const result = await sendMultiStepDeployFrame(sendArgs, signal); return { publicKey: result.publicKey, definitionId, @@ -1388,7 +1391,7 @@ export function createSessionService( const common = { entry: params.entry, assetId: params.definitionAssetId, - approvals: { mode: "approve-probed" } as const, + approvals: { kind: "approve-probed" } as const, router: { sendProbe: (args: SendProbeArgs) => requireAllocationRouter().sendProbeToAllocation( @@ -1530,8 +1533,8 @@ export function createSessionService( * Update a prepared anchor run's `publicKey` under the allocation-ownership * lock. The anchor row was inserted at prepare time; this stamps the * supervisor key returned by the deploy ack, but only while the allocation - * still names this exact accepted generation for this anchor. A lost lock (the - * allocation moved on, another worker took the generation) fails closed as a + * still names this exact accepted generation and unexpired reconciliation + * lease for this anchor. Lost ownership or cancellation fails closed as a * leaked-agent `SessionLaunchError` -- the deploy already reached the sidecar, * so the caller must treat the sidecar agent as possibly live. Used by the * `deployPreparedCodeSourcedWorkflow` prepared path. @@ -1540,6 +1543,7 @@ export function createSessionService( tenantId: string; anchorRunId: string; allocationTarget: AllocatedSidecarTarget; + reconciliation: SidecarReconciliationContext; publicKey: string; credentialRefs?: WorkflowRunCredentialRefs; }): Promise { @@ -1550,6 +1554,7 @@ export function createSessionService( } const dbHandle = db; try { + args.reconciliation.signal.throwIfAborted(); const updated = await dbHandle.transaction(async (tx) => { const [allocation] = await tx .select({ @@ -1562,10 +1567,21 @@ export function createSessionService( }) .from(sidecarAllocationTable) .where( - eq(sidecarAllocationTable.id, args.allocationTarget.allocationId), + and( + eq(sidecarAllocationTable.id, args.allocationTarget.allocationId), + eq( + sidecarAllocationTable.reconciliationLeaseId, + args.reconciliation.leaseId, + ), + gt( + sidecarAllocationTable.reconciliationLeaseExpiresAt, + sql`clock_timestamp()`, + ), + ), ) .limit(1) .for("update"); + args.reconciliation.signal.throwIfAborted(); if ( allocation === undefined || allocation.anchorRunId !== args.anchorRunId || @@ -1592,6 +1608,7 @@ export function createSessionService( ), ) .returning({ id: workflowRunTable.id }); + args.reconciliation.signal.throwIfAborted(); return anchor ?? null; }); if (updated === null) { @@ -1617,6 +1634,8 @@ export function createSessionService( async function deployPreparedCodeSourcedWorkflow( params: DeployPreparedCodeSourcedWorkflowParams, ): Promise { + const { signal } = params.reconciliation; + signal.throwIfAborted(); if (db === undefined) { throw new Error( "deployPreparedCodeSourcedWorkflow requires a db handle to update the prepared anchor run", @@ -1638,7 +1657,7 @@ export function createSessionService( const sources = buildInertProjectionStepSources({ projection: params.approved.projection, config: params.config, - operatorApprovals: approval.approvedGrants, + operatorApprovals: approval.approvedSurface, }); // Restore the Hub-authoritative run ref onto the exact allocation generation @@ -1648,7 +1667,9 @@ export function createSessionService( allocationRouter, allocationTarget: params.allocationTarget, agentAddress: params.agentAddress, + signal, }); + signal.throwIfAborted(); // Stage every top-level step's deploy tree BEFORE the deployment frame: // the frame spawns the child, whose step tools read each step's staged @@ -1703,36 +1724,86 @@ export function createSessionService( definitionId: string; credentialRefs?: WorkflowRunCredentialRefs; }; - if (source.kind === "asset") { - if (resolveAttachment === null) { - throw new Error( - "deployPreparedCodeSourcedWorkflow: asset source deploy is missing its attachment resolver", + let senderDeploySettled = false; + function settleSenderDeploy(outcome: SenderDeploySettledOutcome): void { + if (senderDeploySettled) return; + senderDeploySettled = true; + sidecarRouter.noteSenderDeploySettled(params.agentAddress, outcome); + } + const cancelSenderDeploy = () => { + settleSenderDeploy({ + failed: + signal.reason instanceof Error + ? signal.reason.message + : "Initialization cancelled", + }); + }; + try { + // Bracket the allocated pre-ack window: mark the sender's key-record as + // mid-flight before the deploy emit so a run that sends mail before its + // anchor key is committed parks rather than delivering keyless. The settle + // in both the success and catch paths below clears the marker. + sidecarRouter.noteSenderDeployStarted(params.agentAddress); + // Settle before replacement starts, so this attempt's late completion + // cannot clear the next attempt's sender-key marker at the same address. + signal.addEventListener("abort", cancelSenderDeploy, { once: true }); + signal.throwIfAborted(); + if (source.kind === "asset") { + if (resolveAttachment === null) { + throw new Error( + "deployPreparedCodeSourcedWorkflow: asset source deploy is missing its attachment resolver", + ); + } + result = await emitSourceRefDeployFrame( + { ...commonEmit, source, resolveAttachment }, + signal, + ); + } else { + result = await emitSourceRefDeployFrame( + { ...commonEmit, source }, + signal, ); } - result = await emitSourceRefDeployFrame({ - ...commonEmit, - source, - resolveAttachment, + + await updateAnchorPublicKeyUnderAllocationLock({ + tenantId: params.tenantId, + anchorRunId: params.anchorRunId, + allocationTarget: params.allocationTarget, + reconciliation: params.reconciliation, + publicKey: result.publicKey, + ...(result.credentialRefs !== undefined + ? { credentialRefs: result.credentialRefs } + : {}), }); - } else { - result = await emitSourceRefDeployFrame({ ...commonEmit, source }); - } - await updateAnchorPublicKeyUnderAllocationLock({ - tenantId: params.tenantId, - anchorRunId: params.anchorRunId, - allocationTarget: params.allocationTarget, - publicKey: result.publicKey, - ...(result.credentialRefs !== undefined - ? { credentialRefs: result.credentialRefs } - : {}), - }); + // The anchor's public key is now durable. Wake any mail the run parked + // while pre-ack so it delivers with the sender key co-delivered, closing + // the window where a run sends before its key is recorded. The write above + // happens-before this settle, so a re-drive resolves the recorded key. + // `params.agentAddress` is the run's deploy address, byte-identical to the + // sender address its mail was sent under (asserted against the anchor at + // deploy time), so a settle matches the parked entries. + settleSenderDeploy({ + recorded: result.publicKey, + }); - return { - anchorRunId: params.anchorRunId, - deploymentAddress: params.agentAddress, - publicKey: result.publicKey, - }; + return { + anchorRunId: params.anchorRunId, + deploymentAddress: params.agentAddress, + publicKey: result.publicKey, + }; + } catch (error) { + // The deploy frame or the durable key write failed. Drain any mail the run + // parked while pre-ack so it surfaces as undelivered rather than waiting + // out the TTL. An allocated deploy's failure is owned here, not in the + // router's reject boundary. + settleSenderDeploy({ + failed: error instanceof Error ? error.message : String(error), + }); + throw error; + } finally { + signal.removeEventListener("abort", cancelSenderDeploy); + } } async function sendAttachmentPack( @@ -1938,66 +2009,6 @@ export function createSessionService( }; } - async function sendUserMessage( - params: UserMessageParams, - ): Promise { - const { - agentAddress, - from, - messageId, - date, - content, - attachments, - inReplyTo, - references, - sessionId, - tenantId, - cryptoProvider, - } = params; - - const headers: MessageHeaders = { - from, - to: [agentAddress], - cc: undefined, - date, - messageId, - subject: undefined, - inReplyTo, - references, - mimeVersion: "1.0", - interchangeType: "conversation.message", - interchangeCorrelationId: undefined, - interchangeTenantId: tenantId, - interchangeAgentId: undefined, - interchangeSessionId: sessionId, - interchangeOfferingId: undefined, - interchangeSchemaVersion: undefined, - traceparent: undefined, - tracestate: undefined, - }; - - const signedContent = assembleSignedContent({ - kind: "conversation", - text: content, - ...(attachments !== undefined ? { attachments } : {}), - }); - const signature = await createDetachedSignatureFromProvider( - signedContent, - cryptoProvider, - ); - const rawMessage = assembleMessage(headers, signedContent, signature); - const base64 = base64Encode(rawMessage); - - const delivered = sidecarRouter.routeMail(agentAddress, base64, messageId); - if (!delivered) { - throw new Error( - `Failed to deliver message to ${agentAddress}: agent is unreachable`, - ); - } - - return rawMessage; - } - async function endSession( agentAddress: string, reason: string, @@ -2009,7 +2020,6 @@ export function createSessionService( stageWorkflowStep, installAndApproveWorkflowSource, deployPreparedCodeSourcedWorkflow, - sendUserMessage, endSession, }; } diff --git a/vendor/intx/hub-sessions/src/sidecar-allocation/contracts.ts b/vendor/intx/hub-sessions/src/sidecar-allocation/contracts.ts index 87d359de9e..b052c596dc 100644 --- a/vendor/intx/hub-sessions/src/sidecar-allocation/contracts.ts +++ b/vendor/intx/hub-sessions/src/sidecar-allocation/contracts.ts @@ -3,6 +3,8 @@ import { type } from "arktype"; import type { SidecarCapabilityDeclaration } from "@intx/types"; export type EnsureSidecarRequest = { + /** Cancellation is best effort; a cancelled ensure has an uncertain outcome. */ + readonly signal?: AbortSignal; readonly allocationId: string; readonly generation: number; readonly tenantId: string; @@ -13,6 +15,8 @@ export type EnsureSidecarRequest = { }; export type DestroySidecarRequest = { + /** Cancellation never confirms destruction; the Hub may retry cleanup. */ + readonly signal?: AbortSignal; readonly allocationId: string; readonly generation: number; readonly sidecarId: string; @@ -37,8 +41,9 @@ export type SidecarOperationFailure = typeof SidecarOperationFailure.infer; /** * Acceptance means the requested infrastructure exists, not that it is ready. - * Rejection means no infrastructure exists for this generation; a provisioner - * must throw when it cannot determine whether the request took effect. + * Rejection means no infrastructure exists for this generation (ensure-only; + * destroy rejections below carry no such guarantee); a provisioner must throw + * when it cannot determine whether the request took effect. */ export const EnsureSidecarResult = type({ kind: "'accepted'", @@ -46,6 +51,10 @@ export const EnsureSidecarResult = type({ }).or(SidecarOperationFailure); export type EnsureSidecarResult = typeof EnsureSidecarResult.infer; +/** + * Destruction confirms the capacity is gone and older ensure calls are fenced. + * A non-retryable rejection stops automatic cleanup; capacity may still exist. + */ export const DestroySidecarResult = type({ kind: "'destroyed'", }).or(SidecarOperationFailure); @@ -60,6 +69,8 @@ export interface SidecarProvisioner { /** * Converges infrastructure for this generation. Implementations must be * idempotent and reject generations older than one they have observed. + * Honour the request signal where possible. A late completion must still + * respect a concurrent destroy's fence, even if cancellation was ignored. */ ensure(request: EnsureSidecarRequest): Promise; /** diff --git a/vendor/intx/hub-sessions/src/sidecar-allocation/index.ts b/vendor/intx/hub-sessions/src/sidecar-allocation/index.ts index 044612082b..de74e78e76 100644 --- a/vendor/intx/hub-sessions/src/sidecar-allocation/index.ts +++ b/vendor/intx/hub-sessions/src/sidecar-allocation/index.ts @@ -27,3 +27,5 @@ export { type SidecarAllocationReconciler, type SidecarAllocationReconcilerDeps, } from "./reconciler"; + +export type { SidecarReconciliationContext } from "./operation"; diff --git a/vendor/intx/hub-sessions/src/sidecar-allocation/operation.ts b/vendor/intx/hub-sessions/src/sidecar-allocation/operation.ts new file mode 100644 index 0000000000..a6d1c65c5d --- /dev/null +++ b/vendor/intx/hub-sessions/src/sidecar-allocation/operation.ts @@ -0,0 +1,68 @@ +export const DEFAULT_SIDECAR_OPERATION_TIMEOUT_MS = 120_000; + +export type SidecarReconciliationContext = { + readonly signal: AbortSignal; + readonly leaseId: string; +}; + +export class SidecarOperationTimeoutError extends Error { + constructor(operation: string, timeoutMs: number) { + super(`${operation} timed out after ${String(timeoutMs)}ms`); + this.name = "SidecarOperationTimeoutError"; + } +} + +/** Stops waiting on cancellation or an optional deadline, even if work ignores the signal. */ +export async function runSidecarOperation( + operation: string, + timeoutMs: number | undefined, + run: (signal: AbortSignal) => Promise, + signal?: AbortSignal, +): Promise { + if ( + timeoutMs !== undefined && + (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) + ) { + throw new Error("Sidecar operation timeout must be a positive integer"); + } + signal?.throwIfAborted(); + const controller = new AbortController(); + const cancel = () => { + controller.abort( + signal?.reason instanceof Error + ? signal.reason + : new Error(`${operation} cancelled`), + ); + }; + signal?.addEventListener("abort", cancel, { once: true }); + let rejectAborted: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + rejectAborted = () => { + reject(controller.signal.reason); + }; + controller.signal.addEventListener("abort", rejectAborted, { once: true }); + }); + const timer = + timeoutMs === undefined + ? undefined + : setTimeout(() => { + controller.abort( + new SidecarOperationTimeoutError(operation, timeoutMs), + ); + }, timeoutMs); + try { + return await Promise.race([ + Promise.resolve().then(() => { + controller.signal.throwIfAborted(); + return run(controller.signal); + }), + aborted, + ]); + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", cancel); + if (rejectAborted !== undefined) { + controller.signal.removeEventListener("abort", rejectAborted); + } + } +} diff --git a/vendor/intx/hub-sessions/src/sidecar-allocation/reconciler.ts b/vendor/intx/hub-sessions/src/sidecar-allocation/reconciler.ts index db54e02007..5708cc725d 100644 --- a/vendor/intx/hub-sessions/src/sidecar-allocation/reconciler.ts +++ b/vendor/intx/hub-sessions/src/sidecar-allocation/reconciler.ts @@ -16,6 +16,11 @@ import { type SidecarProvisioner, } from "./contracts"; import type { SidecarPluginRegistry } from "./plugin-registry"; +import { + DEFAULT_SIDECAR_OPERATION_TIMEOUT_MS, + runSidecarOperation, + type SidecarReconciliationContext, +} from "./operation"; const logger = getLogger(["hub", "sidecar-allocation"]); @@ -29,9 +34,11 @@ type AllocationStore = Pick< | "extendReconciliationLease" | "failWithoutInfrastructure" | "listActive" + | "isReconciliationLeaseCurrent" | "markAllocated" | "markConnectionLost" | "markConnectionReady" + | "markDestroyFailed" | "markReleased" | "parkReconciliation" | "scheduleReconnectIfUnscheduled" @@ -51,7 +58,10 @@ export type SidecarAllocationReconcilerDeps = { >; readonly hubWebSocketUrl: string; /** Idempotently restores and deploys one connected allocation generation. */ - readonly onReady?: (allocation: SidecarAllocation) => Promise; + readonly onReady?: ( + allocation: SidecarAllocation, + reconciliation: SidecarReconciliationContext, + ) => Promise; /** * Replace an allocated worker after its reconnect grace expires. Disabled by * default because Hub recovery does not restore arbitrary sidecar or @@ -61,6 +71,7 @@ export type SidecarAllocationReconcilerDeps = { readonly enableAutomaticReplacementRecovery?: boolean; readonly leaseDurationMs?: number; readonly connectTimeoutMs?: number; + readonly operationTimeoutMs?: number; readonly retryDelayMs?: (attempt: number) => number; readonly now?: () => Date; readonly createSidecarId?: () => string; @@ -83,6 +94,15 @@ export type SidecarAllocationReconciler = { reconcileUntilIdle(maxIterations?: number): Promise; }; +class ReconciliationLeaseLostError extends Error { + constructor(allocationId: string, cause?: unknown) { + super(`Reconciliation lease lost for allocation ${allocationId}`, { + cause, + }); + this.name = "ReconciliationLeaseLostError"; + } +} + const DEFAULT_LEASE_DURATION_MS = 60_000; const DEFAULT_CONNECT_TIMEOUT_MS = 120_000; const MAX_RETRY_BACKOFF_ATTEMPT = 5; @@ -127,6 +147,7 @@ export function createSidecarAllocationReconciler({ enableAutomaticReplacementRecovery = false, leaseDurationMs = DEFAULT_LEASE_DURATION_MS, connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, + operationTimeoutMs = DEFAULT_SIDECAR_OPERATION_TIMEOUT_MS, retryDelayMs = defaultRetryDelay, now = () => new Date(), createSidecarId = () => `sc_${randomHex(16)}`, @@ -137,6 +158,44 @@ export function createSidecarAllocationReconciler({ if (connectTimeoutMs <= 0) { throw new Error("connectTimeoutMs must be positive"); } + if (!Number.isSafeInteger(operationTimeoutMs) || operationTimeoutMs <= 0) { + throw new Error("operationTimeoutMs must be a positive integer"); + } + + const activeAllocations = new Map< + string, + { + allocation: SidecarAllocation; + controller: AbortController; + leaseDeadline: number; + pendingConnect: AllocatedSidecarTarget | null; + } + >(); + + function trackAllocation(allocation: SidecarAllocation): void { + const active = activeAllocations.get(allocation.id); + if (active === undefined) return; + if (active.allocation.generation !== allocation.generation) + active.pendingConnect = null; + active.allocation = allocation; + } + + async function finishReconciliation( + allocationId: string, + apply: (pendingConnect: boolean) => Promise, + ): Promise { + const active = activeAllocations.get(allocationId); + if (active === undefined) + throw new ReconciliationLeaseLostError(allocationId); + const target = { allocationId, generation: active.allocation.generation }; + await queueConnectionEvent(target, async () => { + active.controller.signal.throwIfAborted(); + await apply( + active.pendingConnect?.generation === target.generation && + active.allocation.status === "allocated", + ); + }); + } function provisionerFor( allocation: SidecarAllocation, @@ -160,22 +219,97 @@ export function createSidecarAllocationReconciler({ async function withLeaseHeartbeat( allocation: SidecarAllocation, leaseId: string, - operation: () => Promise, + operationName: string, + operation: (context: SidecarReconciliationContext) => Promise, + timeoutMs?: number, ): Promise { + trackAllocation(allocation); + const active = activeAllocations.get(allocation.id); + if (active === undefined) + throw new ReconciliationLeaseLostError(allocation.id); + const controller = active.controller; + let finished = false; + let expiryTimer: ReturnType | undefined; + const checkLeaseExpiry = (): void => { + if (finished || controller.signal.aborted) return; + const remaining = active.leaseDeadline - performance.now(); + if (remaining <= 0) { + controller.abort(new ReconciliationLeaseLostError(allocation.id)); + } else { + expiryTimer = setTimeout(checkLeaseExpiry, Math.ceil(remaining)); + } + }; + let renewing = false; + const renew = async (): Promise => { + if (renewing || controller.signal.aborted) return; + renewing = true; + const startedAt = performance.now(); + try { + const renewed = await allocationStore.extendReconciliationLease( + allocation.id, + leaseId, + leaseDurationMs, + ); + if (finished || controller.signal.aborted) return; + if (!renewed || performance.now() >= active.leaseDeadline) { + controller.abort(new ReconciliationLeaseLostError(allocation.id)); + } else { + // The database grants the lease during the request. Counting from its + // start avoids extending ownership by the response's transit time. + active.leaseDeadline = startedAt + leaseDurationMs; + } + } catch (error) { + if (!finished) { + controller.abort( + new ReconciliationLeaseLostError(allocation.id, error), + ); + } + } finally { + renewing = false; + } + }; const interval = setInterval( () => { - void allocationStore - .extendReconciliationLease(allocation.id, leaseId, leaseDurationMs) - .catch((error: unknown) => { - logger.warn`Failed to extend allocation ${allocation.id} lease: ${error instanceof Error ? error.message : String(error)}`; - }); + void renew(); }, Math.max(1, Math.floor(leaseDurationMs / 3)), ); + checkLeaseExpiry(); try { - return await operation(); + return await runSidecarOperation( + operationName, + timeoutMs, + async (signal) => { + if ( + !(await runSidecarOperation( + "Reconciliation lease validation", + operationTimeoutMs, + () => + allocationStore.isReconciliationLeaseCurrent( + allocation.id, + allocation.generation, + leaseId, + ), + signal, + )) + ) { + throw new ReconciliationLeaseLostError(allocation.id); + } + if (performance.now() >= active.leaseDeadline) { + controller.abort(new ReconciliationLeaseLostError(allocation.id)); + } + signal.throwIfAborted(); + return operation({ signal, leaseId }); + }, + controller.signal, + ); + } catch (error) { + controller.signal.throwIfAborted(); + throw error; } finally { + finished = true; clearInterval(interval); + clearTimeout(expiryTimer); } } @@ -237,11 +371,16 @@ export function createSidecarAllocationReconciler({ !connectionAlreadyReady && !(await router.isAllocatedSidecarReady(target)) ) { - await withLeaseHeartbeat(allocation, leaseId, () => - router.waitForAllocatedSidecar(target, Math.max(0, remaining)), + await withLeaseHeartbeat( + allocation, + leaseId, + "Sidecar connection", + () => router.waitForAllocatedSidecar(target, Math.max(0, remaining)), + operationTimeoutMs, ); } } catch (error) { + if (error instanceof ReconciliationLeaseLostError) throw error; await replaceAfterFailure( allocation, leaseId, @@ -251,12 +390,24 @@ export function createSidecarAllocationReconciler({ return; } + await queueConnectionEvent(target, async () => { + const active = activeAllocations.get(allocation.id); + if (active?.allocation.generation !== allocation.generation) + throw new ReconciliationLeaseLostError(allocation.id); + active.controller.signal.throwIfAborted(); + active.pendingConnect = null; + }); + if (onReady !== undefined) { try { - await withLeaseHeartbeat(allocation, leaseId, () => - onReady(allocation), + await withLeaseHeartbeat( + allocation, + leaseId, + "Workflow initialization", + (context) => onReady(allocation, context), ); } catch (error) { + if (error instanceof ReconciliationLeaseLostError) throw error; if (error instanceof SessionLaunchError && error.leakedAgent) { await replaceAfterFailure( allocation, @@ -266,30 +417,43 @@ export function createSidecarAllocationReconciler({ ); return; } - await allocationStore.scheduleRetry({ - allocationId: allocation.id, - expectedStatus: "allocated", - expectedGeneration: allocation.generation, - // Initialization has no separate durable attempt counter. Use the - // capped delay so a persistent launch error cannot create a hot loop. - nextAttemptAt: retryAt(MAX_RETRY_BACKOFF_ATTEMPT), - expectedLeaseId: leaseId, - failure: { - code: "sidecar_initialization_failed", - message: error instanceof Error ? error.message : String(error), - }, - now: now(), - }); + await finishReconciliation(allocation.id, () => + allocationStore.scheduleRetry({ + allocationId: allocation.id, + expectedStatus: "allocated", + expectedGeneration: allocation.generation, + // Initialization has no separate durable attempt counter. Use the + // capped delay so a persistent launch error cannot create a hot loop. + nextAttemptAt: retryAt(MAX_RETRY_BACKOFF_ATTEMPT), + expectedLeaseId: leaseId, + failure: { + code: "sidecar_initialization_failed", + message: error instanceof Error ? error.message : String(error), + }, + now: now(), + }), + ); return; } } - await allocationStore.markConnectionReady({ - allocationId: allocation.id, - generation: allocation.generation, - expectedLeaseId: leaseId, - now: now(), - }); + await finishReconciliation(allocation.id, (pendingConnect) => + pendingConnect + ? allocationStore.scheduleRetry({ + allocationId: allocation.id, + expectedStatus: "allocated", + expectedGeneration: allocation.generation, + expectedLeaseId: leaseId, + nextAttemptAt: now(), + now: now(), + }) + : allocationStore.markConnectionReady({ + allocationId: allocation.id, + generation: allocation.generation, + expectedLeaseId: leaseId, + now: now(), + }), + ); } async function acceptEnsure( @@ -305,19 +469,26 @@ export function createSidecarAllocationReconciler({ let result: EnsureSidecarResult; try { result = parseEnsureResult( - await withLeaseHeartbeat(allocation, leaseId, () => - provisioner.ensure({ - allocationId: allocation.id, - generation: allocation.generation, - tenantId: allocation.tenantId, - anchorRunId: allocation.anchorRunId, - sidecarId, - token, - hubWebSocketUrl, - }), + await withLeaseHeartbeat( + allocation, + leaseId, + "Sidecar ensure", + ({ signal }) => + provisioner.ensure({ + signal, + allocationId: allocation.id, + generation: allocation.generation, + tenantId: allocation.tenantId, + anchorRunId: allocation.anchorRunId, + sidecarId, + token, + hubWebSocketUrl, + }), + operationTimeoutMs, ), ); } catch (error) { + if (error instanceof ReconciliationLeaseLostError) throw error; await replaceAfterFailure( allocation, leaseId, @@ -364,6 +535,7 @@ export function createSidecarAllocationReconciler({ now: now(), }); if (allocated !== null) { + trackAllocation(allocated); const target = { allocationId: allocated.id, generation: allocated.generation, @@ -376,10 +548,21 @@ export function createSidecarAllocationReconciler({ // transitions. Do not hold the single reconciliation loop for the full // connection timeout: park this lease at its persisted deadline and let // sidecar.allocated.connected wake it immediately when the worker arrives. - await allocationStore.parkReconciliation(allocated.id, leaseId, { - kind: "await-connection", - fallbackNextAttemptAt: retryAt(MAX_RETRY_BACKOFF_ATTEMPT), - }); + await finishReconciliation(allocated.id, (pendingConnect) => + pendingConnect + ? allocationStore.scheduleRetry({ + allocationId: allocated.id, + expectedStatus: "allocated", + expectedGeneration: allocated.generation, + expectedLeaseId: leaseId, + nextAttemptAt: now(), + now: now(), + }) + : allocationStore.parkReconciliation(allocated.id, leaseId, { + kind: "await-connection", + fallbackNextAttemptAt: retryAt(MAX_RETRY_BACKOFF_ATTEMPT), + }), + ); } } @@ -413,6 +596,7 @@ export function createSidecarAllocationReconciler({ }); if (bound === null) return; + trackAllocation(bound); router.fenceAllocation(bound.id, bound.generation); await acceptEnsure(bound, leaseId, provisioner, token); } @@ -429,15 +613,18 @@ export function createSidecarAllocationReconciler({ `Cannot retry destroy while allocation ${allocation.id} is ${allocation.status}`, ); } - await allocationStore.scheduleRetry({ - allocationId: allocation.id, - expectedStatus: allocation.status, - expectedGeneration: allocation.generation, - nextAttemptAt: retryAt(allocation.destroyAttempts), - expectedLeaseId: leaseId, - attempt: "destroy", - now: now(), - }); + const status = allocation.status; + await finishReconciliation(allocation.id, () => + allocationStore.scheduleRetry({ + allocationId: allocation.id, + expectedStatus: status, + expectedGeneration: allocation.generation, + nextAttemptAt: retryAt(allocation.destroyAttempts), + expectedLeaseId: leaseId, + attempt: "destroy", + now: now(), + }), + ); } async function destroyCurrent( @@ -447,22 +634,49 @@ export function createSidecarAllocationReconciler({ ): Promise { if (allocation.sidecarId === undefined) return true; const sidecarId = allocation.sidecarId; + let result: DestroySidecarResult; try { - const result = parseDestroyResult( - await withLeaseHeartbeat(allocation, leaseId, () => - provisioner.destroy({ - allocationId: allocation.id, - generation: allocation.generation, - sidecarId, - ...(allocation.externalRef !== undefined - ? { externalRef: allocation.externalRef } - : {}), - }), + result = parseDestroyResult( + await withLeaseHeartbeat( + allocation, + leaseId, + "Sidecar destroy", + ({ signal }) => + provisioner.destroy({ + signal, + allocationId: allocation.id, + generation: allocation.generation, + sidecarId, + ...(allocation.externalRef !== undefined + ? { externalRef: allocation.externalRef } + : {}), + }), + operationTimeoutMs, ), ); - if (result.kind === "destroyed") return true; } catch (error) { + if (error instanceof ReconciliationLeaseLostError) throw error; logger.warn`Destroy failed for allocation ${allocation.id}: ${error instanceof Error ? error.message : String(error)}`; + await retryDestroy(allocation, leaseId); + return false; + } + if (result.kind === "destroyed") return true; + if (!result.retryable) { + const failed = await allocationStore.markDestroyFailed({ + allocationId: allocation.id, + expectedGeneration: allocation.generation, + expectedLeaseId: leaseId, + code: result.code, + message: result.message, + now: now(), + }); + if (failed !== null) { + router.retireAllocation({ + allocationId: failed.id, + generation: failed.generation, + }); + } + return false; } await retryDestroy(allocation, leaseId); return false; @@ -473,7 +687,11 @@ export function createSidecarAllocationReconciler({ leaseId: string, ): Promise { router.fenceAllocation(allocation.id, allocation.generation); - if (allocation.status === "released" || allocation.status === "failed") { + if ( + allocation.status === "released" || + allocation.status === "failed" || + allocation.status === "destroy_failed" + ) { return; } const provisioner = provisionerFor(allocation); @@ -495,16 +713,19 @@ export function createSidecarAllocationReconciler({ }); } } else { - await allocationStore.scheduleRetry({ - allocationId: allocation.id, - expectedStatus: allocation.status, - expectedGeneration: allocation.generation, - nextAttemptAt: retryAt( - allocation.ensureAttempts + allocation.destroyAttempts, - ), - expectedLeaseId: leaseId, - now: now(), - }); + const status = allocation.status; + await finishReconciliation(allocation.id, () => + allocationStore.scheduleRetry({ + allocationId: allocation.id, + expectedStatus: status, + expectedGeneration: allocation.generation, + nextAttemptAt: retryAt( + allocation.ensureAttempts + allocation.destroyAttempts, + ), + expectedLeaseId: leaseId, + now: now(), + }), + ); } return; } @@ -579,24 +800,66 @@ export function createSidecarAllocationReconciler({ } } - async function handleDisconnect( + const connectionEvents = new Map>(); + + function queueConnectionEvent( target: AllocatedSidecarTarget, + apply: () => Promise, ): Promise { - await allocationStore.markConnectionLost({ - allocationId: target.allocationId, - generation: target.generation, - connectDeadline: new Date(now().getTime() + connectTimeoutMs), - now: now(), - }); + const previous = + connectionEvents.get(target.allocationId) ?? Promise.resolve(); + const pending = previous.catch(() => undefined).then(apply); + connectionEvents.set(target.allocationId, pending); + const settled = () => { + if (connectionEvents.get(target.allocationId) === pending) + connectionEvents.delete(target.allocationId); + }; + void pending.then(settled, settled); + return pending; } - async function handleConnected( + function noteDisconnect( target: AllocatedSidecarTarget, - ): Promise { - await allocationStore.wakeReconciliation( - target.allocationId, - target.generation, - ); + leaseInvalidated = false, + ): void { + const active = activeAllocations.get(target.allocationId); + if (active?.allocation.generation !== target.generation) return; + active.pendingConnect = null; + if (leaseInvalidated || active.allocation.status === "allocated") { + active.controller.abort( + new ReconciliationLeaseLostError(target.allocationId), + ); + } + } + + function noteConnect(target: AllocatedSidecarTarget): void { + const active = activeAllocations.get(target.allocationId); + if (active?.allocation.generation === target.generation) + active.pendingConnect = target; + } + + function handleDisconnect(target: AllocatedSidecarTarget): Promise { + noteDisconnect(target); + return queueConnectionEvent(target, async () => { + const disconnected = await allocationStore.markConnectionLost({ + allocationId: target.allocationId, + generation: target.generation, + connectDeadline: new Date(now().getTime() + connectTimeoutMs), + now: now(), + }); + noteDisconnect(target, disconnected !== null); + }); + } + + function handleConnected(target: AllocatedSidecarTarget): Promise { + noteConnect(target); + return queueConnectionEvent(target, async () => { + await allocationStore.wakeReconciliation( + target.allocationId, + target.generation, + ); + noteConnect(target); + }); } async function repairUnscheduledConnections(): Promise { @@ -630,19 +893,61 @@ export function createSidecarAllocationReconciler({ async function reconcileNext(): Promise { const leaseId = createLeaseId(); - const allocation = await allocationStore.claimNextReconcilable({ - leaseId, - leaseDurationMs, - }); + const claimStartedAt = performance.now(); + const allocation = await runSidecarOperation( + "Sidecar allocation claim", + operationTimeoutMs, + () => + allocationStore.claimNextReconcilable({ + leaseId, + leaseDurationMs, + excludedAllocationIds: [...activeAllocations.keys()], + }), + ); if (allocation === null) return false; - try { - await reconcile(allocation, leaseId); - } catch (error) { - logger.error`Allocation ${allocation.id} reconciliation failed: ${error instanceof Error ? error.message : String(error)}`; + // A claim started before another local claim returned can outlive its lease. + if (activeAllocations.has(allocation.id)) { await allocationStore.parkReconciliation(allocation.id, leaseId, { kind: "retry-after-error", notBefore: retryAt(MAX_RETRY_BACKOFF_ATTEMPT), }); + return true; + } + activeAllocations.set(allocation.id, { + allocation, + controller: new AbortController(), + leaseDeadline: claimStartedAt + leaseDurationMs, + pendingConnect: null, + }); + try { + await reconcile(allocation, leaseId); + } catch (error) { + if (error instanceof ReconciliationLeaseLostError) { + const cause = error.cause; + if (cause === undefined) { + logger.info`Allocation ${allocation.id} reconciliation stopped: lease ${leaseId} is no longer current`; + } else { + logger.warn`Allocation ${allocation.id} reconciliation stopped because renewal of lease ${leaseId} failed: ${cause instanceof Error ? cause.message : String(cause)}`; + try { + await allocationStore.parkReconciliation(allocation.id, leaseId, { + kind: "retry-after-error", + notBefore: retryAt(MAX_RETRY_BACKOFF_ATTEMPT), + }); + } catch (parkError) { + logger.warn`Failed to park allocation ${allocation.id} after renewal failure: ${parkError instanceof Error ? parkError.message : String(parkError)}`; + } + } + return true; + } + logger.error`Allocation ${allocation.id} reconciliation failed: ${error instanceof Error ? error.message : String(error)}`; + await finishReconciliation(allocation.id, () => + allocationStore.parkReconciliation(allocation.id, leaseId, { + kind: "retry-after-error", + notBefore: retryAt(MAX_RETRY_BACKOFF_ATTEMPT), + }), + ); + } finally { + activeAllocations.delete(allocation.id); } return true; } diff --git a/vendor/intx/hub-sessions/src/skill-kind.ts b/vendor/intx/hub-sessions/src/skill-kind.ts index 2823926d5d..673358e2f9 100644 --- a/vendor/intx/hub-sessions/src/skill-kind.ts +++ b/vendor/intx/hub-sessions/src/skill-kind.ts @@ -1,8 +1,7 @@ import { type } from "arktype"; import { getLogger } from "@intx/log"; -import { glob, repoActionToGrantVerb } from "@intx/hub-common"; import { - UserPrincipal, + authorizeUserPrincipal, type AuthorizeFn, type KindHandler, type Principal, @@ -304,66 +303,13 @@ export const skillAuthorize: AuthorizeFn = ( } if (principal.kind === "user") { - // The route layer has already pre-resolved the grant verdict and - // attached it as `authz`. The substrate does NOT re-query the - // grant store here; it (a) checks the bearer-token's claims - // bound the requested (ref, action) and have not expired, and - // (b) sanity-checks that the pre-resolved verdict targets this - // exact resource and grant verb. Both gates must pass before the - // verdict's `effect` is honoured. - const parsed = UserPrincipal(principal); - if (parsed instanceof type.errors) { - return { - allowed: false, - reason: `user principal is malformed: ${parsed.summary}`, - }; - } - if (!parsed.tokenClaims.actions.includes(action)) { - return { - allowed: false, - reason: `token does not grant action ${action}`, - }; - } - // `ref === "*"` is the substrate's sentinel for a bulk read used - // by `listRefs`. The advertise-refs layer applies the per-ref - // refPattern filter on the listing it returns, so gating the bulk - // read on refPattern here would prevent any token with a - // narrow-pattern (e.g. `refs/heads/main`) from listing refs at - // all. Only the action and expiry are enforced for the bulk read; - // every other call site passes a concrete ref and is gated below. - if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) { - return { - allowed: false, - reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`, - }; - } - if (Date.now() >= parsed.tokenClaims.expiresAt) { - return { - allowed: false, - reason: `token expired at ${parsed.tokenClaims.expiresAt}`, - }; - } - const expectedResource = `asset:${repoId.id}`; - if (parsed.authz.resource !== expectedResource) { - return { - allowed: false, - reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`, - }; - } - const expectedGrantVerb = repoActionToGrantVerb(action); - if (parsed.authz.grantVerb !== expectedGrantVerb) { - return { - allowed: false, - reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`, - }; - } - if (parsed.authz.effect === "allow") { - return { allowed: true }; - } - return { - allowed: false, - reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`, - }; + return authorizeUserPrincipal({ + principal, + repoId, + ref, + action, + resourcePrefix: "asset", + }); } // Fail closed on any kind not handled above. The tenant-level diff --git a/vendor/intx/hub-sessions/src/workflow-allocation-service.ts b/vendor/intx/hub-sessions/src/workflow-allocation-service.ts index 9141937e23..b16c9be78c 100644 --- a/vendor/intx/hub-sessions/src/workflow-allocation-service.ts +++ b/vendor/intx/hub-sessions/src/workflow-allocation-service.ts @@ -14,7 +14,6 @@ import { import { grant, workflowRun } from "@intx/db/schema"; import { eq } from "drizzle-orm"; import { generateId } from "@intx/hub-common"; -import { getLogger } from "@intx/log"; import { hexEncode, SidecarCapabilityRule, @@ -25,6 +24,8 @@ import type { HarnessConfig } from "@intx/types/runtime"; import type { ToolPackagePin } from "@intx/types/tool-packages"; import type { WorkflowDefinitionSource } from "@intx/types/workflow-sources"; import { + approvalItemsFromSet, + approvalSetFromItems, buildInertProjectionStepSources, deriveRunAddress, deriveRunAgentId, @@ -50,8 +51,11 @@ import type { } from "./ws/sidecar-handler"; import type { InstallAndApproveResult } from "./workflow-probe-gate"; import { buildReferencedWorkflowSourcePins } from "./workflow-source-pins"; - -const logger = getLogger(["hub", "workflow-allocation"]); +import { + DEFAULT_SIDECAR_OPERATION_TIMEOUT_MS, + runSidecarOperation, + type SidecarReconciliationContext, +} from "./sidecar-allocation/operation"; export class WorkflowProvisioningError extends Error { readonly code: string; @@ -100,6 +104,7 @@ export type WorkflowAllocationService = { ): Promise; deployReadyAllocation( allocation: SidecarAllocation, + reconciliation: SidecarReconciliationContext, ): Promise; }; @@ -125,6 +130,7 @@ export type WorkflowAllocationServiceDeps = { readonly createSidecarId?: () => string; readonly createToken?: () => string; readonly connectTimeoutMs?: number; + readonly operationTimeoutMs?: number; readonly now?: () => Date; }; @@ -228,6 +234,7 @@ export function createWorkflowAllocationService({ createSidecarId = randomSidecarId, createToken = randomToken, connectTimeoutMs = 120_000, + operationTimeoutMs = DEFAULT_SIDECAR_OPERATION_TIMEOUT_MS, now = () => new Date(), }: WorkflowAllocationServiceDeps): WorkflowAllocationService { const allocationStore = createSidecarAllocationStore(db); @@ -248,6 +255,9 @@ export function createWorkflowAllocationService({ if (connectTimeoutMs <= 0) { throw new Error("connectTimeoutMs must be positive"); } + if (!Number.isSafeInteger(operationTimeoutMs) || operationTimeoutMs <= 0) { + throw new Error("operationTimeoutMs must be a positive integer"); + } function matchingProvisioner( probe: WorkflowProbe, @@ -281,11 +291,36 @@ export function createWorkflowAllocationService({ ); } - async function finishProbeRelease( - releasing: WorkflowProbe, + const probeCleanupTasks = new Map>(); + + function finishProbeRelease( + probe: WorkflowProbe, + finalStatus: "succeeded" | "failed", + ): Promise { + const existing = probeCleanupTasks.get(probe.id); + if (existing !== undefined) return existing; + const task = cleanUpProbe(probe, finalStatus); + probeCleanupTasks.set(probe.id, task); + const settled = () => { + if (probeCleanupTasks.get(probe.id) === task) + probeCleanupTasks.delete(probe.id); + }; + void task.then(settled, settled); + return task; + } + + async function cleanUpProbe( + probe: WorkflowProbe, finalStatus: "succeeded" | "failed", ): Promise { - if (releasing.sidecarId !== null) { + const releasing = await probeStore.get(probe.id); + if (releasing?.status === "succeeded" || releasing?.status === "failed") + return; + if (releasing?.status !== "releasing") { + throw new Error(`Workflow probe ${probe.id} is not releasing`); + } + const sidecarId = releasing.sidecarId; + if (sidecarId !== null) { const provisioner = matchingProvisioner(releasing); if (provisioner === null) { throw new Error( @@ -293,14 +328,20 @@ export function createWorkflowAllocationService({ ); } const destroyed = parseDestroyResult( - await provisioner.destroy({ - allocationId: releasing.id, - generation: releasing.generation, - sidecarId: releasing.sidecarId, - ...(releasing.externalRef !== null - ? { externalRef: releasing.externalRef } - : {}), - }), + await runSidecarOperation( + "Probe destroy", + operationTimeoutMs, + (signal) => + provisioner.destroy({ + signal, + allocationId: releasing.id, + generation: releasing.generation, + sidecarId, + ...(releasing.externalRef !== null + ? { externalRef: releasing.externalRef } + : {}), + }), + ), ); if (destroyed.kind === "rejected") { throw new Error( @@ -365,7 +406,9 @@ export function createWorkflowAllocationService({ projection: approved.projection, closure: approved.closure, approvedWireHash: approved.approval.approvedWireHash, - approvedGrants: [...approved.approval.approvedGrants], + approvedGrants: [ + ...approvalItemsFromSet(approved.approval.approvedSurface), + ], }; await db.transaction(async (tx) => { @@ -550,17 +593,23 @@ export function createWorkflowAllocationService({ }; allocationRouter.fenceAllocation(probe.id, probe.generation); const ensured = parseEnsureResult( - await probeProvisioner.ensure({ - allocationId: probe.id, - generation: probe.generation, - tenantId: probe.tenantId, - // The provisioner contract treats this as an opaque owner id. A - // probe has no workflow run, so its own id is the honest owner. - anchorRunId: probe.id, - sidecarId, - token, - hubWebSocketUrl, - }), + await runSidecarOperation( + "Probe ensure", + operationTimeoutMs, + (signal) => + probeProvisioner.ensure({ + signal, + allocationId: probe.id, + generation: probe.generation, + tenantId: probe.tenantId, + // The provisioner contract treats this as an opaque owner id. A + // probe has no workflow run, so its own id is the honest owner. + anchorRunId: probe.id, + sidecarId, + token, + hubWebSocketUrl, + }), + ), ); if (ensured.kind === "rejected") { throw new WorkflowProvisioningError(ensured.code, ensured.message); @@ -625,12 +674,12 @@ export function createWorkflowAllocationService({ buildInertProjectionStepSources({ projection: approved.projection, config, - operatorApprovals: approved.approval.approvedGrants, + operatorApprovals: approved.approval.approvedSurface, }); await buildReferencedWorkflowSourcePins({ projection: approved.projection, config, - operatorApprovals: approved.approval.approvedGrants, + operatorApprovals: approved.approval.approvedSurface, }); const deploymentProvisioner = selectProvisioner( await deploymentPlugins.selectProvisioner({ @@ -670,7 +719,9 @@ export function createWorkflowAllocationService({ async function deployReadyAllocation( allocation: SidecarAllocation, + reconciliation: SidecarReconciliationContext, ): Promise { + reconciliation.signal.throwIfAborted(); if ( allocation.status !== "allocated" || allocation.ensureAcceptedGeneration !== allocation.generation @@ -712,15 +763,16 @@ export function createWorkflowAllocationService({ ); } // The frozen bundle deploys verbatim -- no re-probe. Rehydrate the approval - // hand-off from it: the approved grant set becomes a `Set`, and the frozen - // definition id is the anchor's own (set at prepare time from this freeze). + // hand-off from it: the persisted flat item list is partitioned back into + // the gate's `ApprovalSet`, and the frozen definition id is the anchor's own + // (set at prepare time from this freeze). const bundle = spec.frozenApprovalBundle; const approved: InstallAndApproveResult = { approval: { ok: true, definitionId: anchor.definitionId, approvedWireHash: bundle.approvedWireHash, - approvedGrants: new Set(bundle.approvedGrants), + approvedSurface: approvalSetFromItems(bundle.approvedGrants), projection: bundle.projection, }, projection: bundle.projection, @@ -758,7 +810,9 @@ export function createWorkflowAllocationService({ defaultSource: defaultSource.id, }); + reconciliation.signal.throwIfAborted(); return preparedDeployer.deployPreparedCodeSourcedWorkflow({ + reconciliation, tenantId: allocation.tenantId, anchorRunId: allocation.anchorRunId, deploymentDomain: spec.deploymentDomain, @@ -802,9 +856,8 @@ export function createWorkflowAllocationService({ async function initialize(): Promise { const failures: unknown[] = []; for (const probe of await probeStore.listActive()) { - let releasing: WorkflowProbe | null; try { - releasing = await beginProbeRelease( + await beginProbeRelease( probe, probe.status === "releasing" ? undefined @@ -815,13 +868,6 @@ export function createWorkflowAllocationService({ ); } catch (error) { failures.push(error); - continue; - } - if (releasing === null) continue; - try { - await finishProbeRelease(releasing, cleanupFinalStatus(releasing)); - } catch (error) { - logger.warn`Workflow probe ${releasing.id} cleanup remains pending after startup: ${error instanceof Error ? error.message : String(error)}`; } } if (failures.length > 0) { diff --git a/vendor/intx/hub-sessions/src/workflow-dispatch-service.ts b/vendor/intx/hub-sessions/src/workflow-dispatch-service.ts index 4cf4d4ead7..d1339069e2 100644 --- a/vendor/intx/hub-sessions/src/workflow-dispatch-service.ts +++ b/vendor/intx/hub-sessions/src/workflow-dispatch-service.ts @@ -200,12 +200,23 @@ export function createWorkflowDispatchService({ payload: signal.payload, }); } else { + // A deliverable mail dispatch always carries the sender persisted at + // enqueue (the workflow_run_dispatch mail-sender check enforces it). + // A null here means the row bypassed that invariant, so fail loudly + // rather than deliver with no authenticated sender or fall back to the + // MIME From. + if (dispatch.senderAddress === null) { + throw new Error( + `mail dispatch ${dispatch.id} has no persisted authenticated sender`, + ); + } await router.sendWorkflowRunDispatchToAllocation( target, agentAddress, deriveWorkflowRunId(agentAddress), dispatch.stepGrants, base64Encode(dispatch.rawMessage), + dispatch.senderAddress, dispatch.messageId, ); } diff --git a/vendor/intx/hub-sessions/src/workflow-kind.ts b/vendor/intx/hub-sessions/src/workflow-kind.ts index b254e3b374..816d7f48b6 100644 --- a/vendor/intx/hub-sessions/src/workflow-kind.ts +++ b/vendor/intx/hub-sessions/src/workflow-kind.ts @@ -29,10 +29,10 @@ import { GrantRequirement, SidecarCapabilityPolicy, } from "@intx/types"; +import { InboundMailPolicy } from "@intx/types/runtime"; import { PackageJSON, isContainedEntryPath } from "@intx/types/package-json"; -import { glob, repoActionToGrantVerb } from "@intx/hub-common"; import { - UserPrincipal, + authorizeUserPrincipal, type AuthorizeFn, type KindHandler, type Principal, @@ -105,6 +105,12 @@ export const workflowDefinitionEnvelopeSchema = type({ // passed through to launch-time resolution unchecked. "credentialBindings?": CredentialBinding.array(), "sidecarPlacement?": SidecarCapabilityPolicy, + // `inboundMailPolicy` is validated here too -- same defense-in-depth + // rationale as credentialBindings above: a malformed policy (an unknown + // outcome key or a value that is not reject/admit) is rejected at the deploy + // boundary rather than passed through to later admission resolution + // unchecked. + "inboundMailPolicy?": InboundMailPolicy, }).onUndeclaredKey("ignore"); const SidecarPrincipal = type({ @@ -356,63 +362,13 @@ export const workflowAuthorize: AuthorizeFn = ( } if (principal.kind === "user") { - // The route layer has already pre-resolved the grant verdict and - // attached it as `authz`. The substrate does NOT re-query the - // grant store here; it (a) checks the bearer-token's claims - // bound the requested (ref, action) and have not expired, and - // (b) sanity-checks that the pre-resolved verdict targets this - // exact resource and grant verb. Both gates must pass before the - // verdict's `effect` is honoured. - const parsed = UserPrincipal(principal); - if (parsed instanceof type.errors) { - return { - allowed: false, - reason: `user principal is malformed: ${parsed.summary}`, - }; - } - if (!parsed.tokenClaims.actions.includes(action)) { - return { - allowed: false, - reason: `token does not grant action ${action}`, - }; - } - // `ref === "*"` is the substrate's sentinel for the bulk read - // performed by `listRefs`. Per-ref filtering is the advertise-refs - // layer's responsibility, so the bulk read is gated on action and - // expiry alone. - if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) { - return { - allowed: false, - reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`, - }; - } - if (Date.now() >= parsed.tokenClaims.expiresAt) { - return { - allowed: false, - reason: `token expired at ${parsed.tokenClaims.expiresAt}`, - }; - } - const expectedResource = `asset:${repoId.id}`; - if (parsed.authz.resource !== expectedResource) { - return { - allowed: false, - reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`, - }; - } - const expectedGrantVerb = repoActionToGrantVerb(action); - if (parsed.authz.grantVerb !== expectedGrantVerb) { - return { - allowed: false, - reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`, - }; - } - if (parsed.authz.effect === "allow") { - return { allowed: true }; - } - return { - allowed: false, - reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`, - }; + return authorizeUserPrincipal({ + principal, + repoId, + ref, + action, + resourcePrefix: "asset", + }); } // Fail closed on any kind not handled above. The tenant-level diff --git a/vendor/intx/hub-sessions/src/workflow-probe-gate.ts b/vendor/intx/hub-sessions/src/workflow-probe-gate.ts index 61e7907443..52cb9c4cc2 100644 --- a/vendor/intx/hub-sessions/src/workflow-probe-gate.ts +++ b/vendor/intx/hub-sessions/src/workflow-probe-gate.ts @@ -12,11 +12,17 @@ // 3. RECOMPUTE the wire hash over the RECEIVED projection as tamper-evidence: // a shipped hash that differs from the hub recompute is rejected, fail // closed, no coercion. -// 4. Gate the advisory grant set against the approval policy: an operator -// `ApprovalSet` requires every grant the probe surfaced to be approved or -// the gate fails, while `approve-probed` approves exactly what the probe -// surfaced. -// 5. Freeze the approved wire hash onto the definition version row, keyed by +// 4. Reject a projection declaring a trigger type the runtime does not +// implement, so a deployment that could only ever sit inert never gets +// approved. +// 5. Reject a projection carrying a step the grant walk left no record for, +// so no deployment can schedule a step whose grants nobody approved. +// 6. Gate the advisory grant set AND the definition's declared grant +// requirements against the approval policy: an operator `ApprovalSet` +// requires every grant the probe surfaced and every requirement it +// declared to be approved or the gate fails, while `approve-probed` +// approves exactly what the probe surfaced. +// 7. Freeze the approved wire hash onto the definition version row, keyed by // the definition's selector, and return the frozen approved grant set. // // The frozen approved set is the single source of truth for the definition's @@ -26,11 +32,16 @@ // grant set is a deterministic projection of the exact content the hash // addresses, so pinning the hash pins the set. +import { type } from "arktype"; import { and, eq } from "drizzle-orm"; import type { DBExecutor } from "@intx/db"; import { workflowDefinitionVersion } from "@intx/db/schema"; -import type { GrantWalkSnapshot } from "@intx/types"; +import type { + ApprovalItem, + GrantRequirement, + GrantWalkSnapshot, +} from "@intx/types"; import type { PackumentFetcher, RegistryConfig } from "@intx/tool-packaging"; import type { WorkflowSourceAssetMount, @@ -42,7 +53,16 @@ import type { WorkflowDefinitionAssetSource, WorkflowDefinitionRegistrySource, } from "@intx/types/workflow-sources"; -import type { ApprovalSet } from "@intx/workflow-deploy"; +import { + EXECUTABLE_STEP_DESCENT, + walkStepTree, +} from "@intx/workflow/definition"; +import { + approvalSetFromItems, + inertNestedBodies, + isApprovedGrantRequirement, + type ApprovalSet, +} from "@intx/workflow-deploy"; import { buildSourceAssetMounts, @@ -65,25 +85,26 @@ const FROZEN_VERSION = "1"; /** * The frozen record an approval writes: the definition's asset selector, the - * approved wire hash (the freeze anchor), the approved grant set, and the - * grant-walk snapshot the run path materializes grants from. The grant set is a - * deterministic projection of the content the hash addresses and rides the - * deploy hand-off in memory; the snapshot is persisted onto the version row so a - * run derives its grants from the frozen walk without re-reading and re-walking - * the workflow's `workflow.json`. + * approved wire hash (the freeze anchor), the approved surface, and the + * grant-walk snapshot the run path materializes grants from. The approved + * surface is a deterministic projection of the content the hash addresses and + * rides the deploy hand-off in memory; the snapshot is persisted onto the + * version row so a run derives its grants from the frozen walk without + * re-reading and re-walking the workflow's `workflow.json`. + * + * `approvedGrants` carries both kinds of approved item: the walk's grant-shape + * strings and the definition's declared grant requirements. The requirements + * belong in the same record because the run path mints real grant rows from + * them, so an account of the approval that listed only the walk strings would + * under-report the authority the definition will actually carry. It is the flat + * `ApprovalItem` list rather than the gate's partitioned `ApprovalSet` because + * this record is the input to persistence, and the persisted form is flat. */ export type FrozenApproval = { readonly assetId: string; readonly approvedWireHash: string; - readonly approvedGrants: readonly string[]; + readonly approvedGrants: readonly ApprovalItem[]; readonly grantSnapshot: GrantWalkSnapshot; - /** - * WORKBENCH DELTA (see VENDORED.md): the inert projection the hash above was - * recomputed over, persisted with it so a hub-side reader can recover the - * definition's body without re-probing. Rides the same frozen record as the - * hash rather than a second write, so the two can never disagree. - */ - readonly projection: WorkflowProjectionDefinition; }; /** @@ -97,16 +118,18 @@ export type PersistFrozenApprovalFn = ( /** * The outcome of gating and freezing a probe result. `ok: true` is the frozen - * approval the deploy hand-off consumes. The `ok: false` arms name the two + * approval the deploy hand-off consumes. The `ok: false` arms name the five * fail-closed paths: a shipped hash that does not match the hub recompute - * (tamper-evidence), and advisory grants the operator did not approve. + * (tamper-evidence), advisory grants the operator did not approve, declared + * grant requirements the operator did not approve, a trigger type the runtime + * does not implement, and an executable step the grant walk left no record for. */ export type ProbeGateResult = | { readonly ok: true; readonly definitionId: string; readonly approvedWireHash: string; - readonly approvedGrants: ReadonlySet; + readonly approvedSurface: ApprovalSet; /** * The inert wire projection the freeze hashed. Rides the ok-arm so the * deploy hand-off carries the exact content the frozen hash addresses, @@ -124,8 +147,60 @@ export type ProbeGateResult = readonly ok: false; readonly reason: "grants_not_approved"; readonly unapprovedGrants: readonly string[]; + } + | { + readonly ok: false; + readonly reason: "grant_requirements_not_approved"; + /** + * The declared requirements the operator's approval does not cover, in + * the order the probe declared them. Named in full so the operator can + * see exactly which authority the definition asked to delegate. + */ + readonly unapprovedGrantRequirements: readonly GrantRequirement[]; + } + | { + readonly ok: false; + readonly reason: "unimplemented_trigger"; + /** The distinct reserved-but-unimplemented trigger types the projection declared. */ + readonly unimplementedTriggerTypes: readonly string[]; + } + | { + readonly ok: false; + readonly reason: "steps_without_grant_record"; + /** Every executable step the grant-walk snapshot accounts for nothing at. */ + readonly stepsWithoutGrantRecord: readonly StepWithoutGrantRecord[]; + /** + * One sentence naming every miss. A deploy that trips this is a defect in + * the deploy path rather than in the author's workflow, so the sentence + * carries what whoever maintains that path needs: the step, the position, + * and the record that was absent. + */ + readonly message: string; }; +/** + * One step the deployment can execute that the probe's grant-walk snapshot + * carries no record for. + */ +export type StepWithoutGrantRecord = { + /** The executable step id with no approved grants behind it. */ + readonly stepId: string; + /** + * The chain of step ids the executable walk reached `stepId` through, + * outermost first and `stepId` itself last. Two nested bodies may + * legitimately carry the same step id, so the chain -- not the id alone -- + * is what names the position in the closure. + */ + readonly reachedThrough: readonly string[]; + /** + * The top-level step whose snapshot record was supposed to account for + * `stepId`. The capability walk folds every nested body's grants into the + * record of the top-level step that carries the body, so this names the + * `perStep` key the absent record would have had. + */ + readonly recordStepId: string; +}; + /** * Build the production persistence step of the freeze. Records identity through * the selector-keyed ensure helper (a definition keyed by `(assetId, @@ -138,7 +213,7 @@ export type ProbeGateResult = export function createDbFrozenApprovalWriter( db: DBExecutor, ): PersistFrozenApprovalFn { - return async ({ assetId, approvedWireHash, grantSnapshot, projection }) => { + return async ({ assetId, approvedWireHash, grantSnapshot }) => { // Ensure-then-stamp is one freeze: a crash between the two would persist a // version row with a NULL `approvedWireHash`, which the schema treats as // the legitimate "not yet approved" state -- indistinguishable from an @@ -155,7 +230,7 @@ export function createDbFrozenApprovalWriter( // fails loud instead of open. const stamped = await tx .update(workflowDefinitionVersion) - .set({ approvedWireHash, grantSnapshot, wireProjection: projection }) + .set({ approvedWireHash, grantSnapshot }) .where( and( eq(workflowDefinitionVersion.definitionId, definitionId), @@ -176,27 +251,138 @@ export function createDbFrozenApprovalWriter( /** * Approve exactly the grant surface the probe reports, without a pre-walked * operator `ApprovalSet` to gate against. Under this mode the gate skips the - * per-grant membership check and freezes exactly what the probe advertised. + * per-grant and per-requirement membership checks and freezes exactly what the + * probe advertised. * * This is the code-sourced analogue of the live-authored self-approve: the hub * has no live definition to pre-walk, so the probe's advertised grants ARE the * declared surface. It does NOT relax tamper-evidence -- the wire-hash check * still runs and can still fail closed. */ -export type ApproveProbedGrants = { readonly mode: "approve-probed" }; +export type ApproveProbedGrants = { readonly kind: "approve-probed" }; /** - * How the gate turns the probe's advisory grant set into an approved set. - * Either an explicit operator `ApprovalSet` -- every advertised grant must - * appear in it or the gate fails closed -- or `approve-probed`, which approves - * exactly the surface the probe reported. + * How the gate turns the probe's advertised surface into an approved one. + * Either an explicit operator `ApprovalSet` -- every advertised grant and every + * declared grant requirement must appear in it or the gate fails closed -- or + * `approve-probed`, which approves exactly the surface the probe reported. */ export type ProbeApprovalPolicy = ApprovalSet | ApproveProbedGrants; function isApproveProbed( policy: ProbeApprovalPolicy, ): policy is ApproveProbedGrants { - return "mode" in policy; + return policy.kind === "approve-probed"; +} + +/** + * Trigger types the definition vocabulary declares but the runtime does not + * implement. `schedule` is reserved: no cron parser and no scheduler exist, so + * a schedule-triggered deployment would hash, deploy, and then never fire -- + * no error, no log, no failed run. Admitting one at the gate is the only way a + * deployment reaches that state, so the gate refuses it. + */ +const UNIMPLEMENTED_TRIGGER_TYPES: ReadonlySet = new Set(["schedule"]); + +// A projected trigger, typed only to its discriminant. `triggers` rides the +// wire projection as `unknown[]` on purpose (the wire envelope does not own +// the trigger vocabulary), so the discriminant is read through a validator +// rather than an assertion. An entry that carries no string `type` is not a +// trigger this gate has an opinion about and is left to the deploy path. +const ProjectedTriggerType = type({ type: "string" }); + +/** + * The distinct unimplemented trigger types a projection declares, in first-seen + * order. Empty when every declared trigger has an implementation behind it. + */ +function collectUnimplementedTriggerTypes( + triggers: readonly unknown[], +): readonly string[] { + const found: string[] = []; + for (const trigger of triggers) { + const parsed = ProjectedTriggerType(trigger); + if (parsed instanceof type.errors) continue; + if (!UNIMPLEMENTED_TRIGGER_TYPES.has(parsed.type)) continue; + if (found.includes(parsed.type)) continue; + found.push(parsed.type); + } + return found; +} + +const EXECUTABLE_CLOSURE_CONTEXT = "probe gate executable closure: "; + +/** One step the executable walk reached, with where it was reached from. */ +type ExecutableReach = StepWithoutGrantRecord; + +/** + * Every step the deployment can execute, walked over the frozen inert + * projection under `EXECUTABLE_STEP_DESCENT`, each carrying the chain it was + * reached through and the top-level step whose grant record accounts for it. + * + * The descent is the canonical one rather than a local re-derivation on + * purpose. Sharing `EXECUTABLE_STEP_DESCENT` with the capability walk makes the + * two sides agree on ONE thing: which primitive kinds are descended into, so a + * newly-added container kind cannot become reachable here while staying + * invisible to the walk. It does NOT make the two agree about grants. This + * function sees only step positions; whether the walk folded the right grants + * into a record is not observable from the inert projection and is not checked + * anywhere on this path. + * + * The position each step was reached at is the walk's own `path`, for the same + * reason. The head of that path is the top-level step the entry descends from, + * which is exactly the `perStep` key the capability walk folds its grants into. + */ +function collectExecutableReaches( + projection: WorkflowProjectionDefinition, +): readonly ExecutableReach[] { + const reaches: ExecutableReach[] = []; + walkStepTree({ + tree: projection, + context: EXECUTABLE_CLOSURE_CONTEXT, + nestedTrees: (step) => inertNestedBodies(step, EXECUTABLE_STEP_DESCENT), + visit: ({ stepId, path }) => { + reaches.push({ stepId, recordStepId: path[0], reachedThrough: path }); + }, + }); + return reaches; +} + +/** + * Every executable step the grant-walk snapshot carries no record for. + * + * A step's approved-grant record is the snapshot entry keyed by the top-level + * step it descends from: the capability walk collects one record per top-level + * step and folds into it the grants of every step that step can run. + * + * This is a PRESENCE check and nothing more. An absent record is decisive -- + * it leaves every step of that subtree with no approved grants at all, which + * is the hole this catches. A present record is not evidence the other way: + * nothing here opens the record to confirm it actually carries the grants the + * steps beneath it need. A record present but under-filled passes this check + * and still refuses those tool calls at run time. + */ +function collectStepsWithoutGrantRecord( + projection: WorkflowProjectionDefinition, + snapshot: GrantWalkSnapshot, +): readonly StepWithoutGrantRecord[] { + const recordedStepIds = new Set( + snapshot.perStep.map((record) => record.stepId), + ); + return collectExecutableReaches(projection).filter( + (reach) => !recordedStepIds.has(reach.recordStepId), + ); +} + +function describeStepsWithoutGrantRecord( + missing: readonly StepWithoutGrantRecord[], +): string { + const positions = missing + .map( + (missed) => + `${missed.stepId} (reached through ${missed.reachedThrough.join(" > ")}; expected a grant-walk record keyed by top-level step ${missed.recordStepId})`, + ) + .join("; "); + return `the probe's grant-walk snapshot carries no approved-grant record covering ${String(missing.length)} executable step(s): ${positions}. A step outside every record deploys with no approved grants, and its tool calls are refused at run time with nothing on the deploy path reporting it.`; } export type GateAndFreezeArgs = { @@ -205,9 +391,9 @@ export type GateAndFreezeArgs = { /** The sidecar's inert probe answer: projection, advisory grants, shipped hash. */ readonly probeResult: WorkflowProbeResult; /** - * The approval policy. An `ApprovalSet` gates the advisory set against the - * operator-approved grant-shape strings; `approve-probed` approves exactly - * the surface the probe reported. + * The approval policy. An `ApprovalSet` gates the advisory set and the + * declared grant requirements against the operator-approved items; + * `approve-probed` approves exactly the surface the probe reported. */ readonly approvals: ProbeApprovalPolicy; /** Persistence step for the freeze; `createDbFrozenApprovalWriter` in production. */ @@ -219,11 +405,19 @@ export type GateAndFreezeArgs = { * inert projection and grant set -- no author code runs here and the capability * walk is never re-run. * - * Fails closed on the two security-load-bearing checks before it writes + * Fails closed on the three security-load-bearing checks before it writes * anything: the recomputed wire hash must match the hash the sidecar shipped - * (tamper-evidence), and every advisory grant must be operator-approved. Only - * then does it freeze the recomputed hash onto the version row and return the - * approved grant set. + * (tamper-evidence), every advisory grant must be operator-approved, and every + * declared grant requirement must be operator-approved. It also refuses a + * projection whose triggers include a reserved-but-unimplemented type -- not a + * security check, but the layer a pinned closure cannot carry a stale copy of, + * so it is where a workflow that could only sit inert is caught -- and one + * whose executable closure reaches a step the grant walk left no record for -- + * the layer holding both halves of the probe answer at once, and a presence + * check on those records rather than a check that any record's contents are + * sufficient. + * Only then does it freeze the recomputed hash onto the version row and return + * the approved grant set. */ export async function gateAndFreezeProbeResult( args: GateAndFreezeArgs, @@ -246,34 +440,117 @@ export async function gateAndFreezeProbeResult( }; } + // Reject a trigger type nothing implements. This runs on the projection + // rather than on the author's definition because `defineWorkflow` is bundled + // INTO the pinned workflow closure: a closure published before the authoring + // check carries its own frozen copy and never sees it. The projection's + // `triggers` are produced by the hub's live->inert projector, so this is the + // one trigger surface a stale closure cannot carry past. Placed after the + // wire-hash check so tamper-evidence still decides first -- the projection + // must be the one the sidecar hashed before its content is reasoned about. + const unimplementedTriggerTypes = collectUnimplementedTriggerTypes( + probeResult.projection.triggers, + ); + if (unimplementedTriggerTypes.length > 0) { + return { + ok: false, + reason: "unimplemented_trigger", + unimplementedTriggerTypes, + }; + } + + // Totality: every step the deployment can execute must have an approved-grant + // record behind it. The two halves of a probe answer are produced + // independently -- the projection by the hub's live->inert projector, the + // grant-walk snapshot by the sidecar's capability walk over the live + // definition -- and nothing until now compared them. A step the walk skipped + // still projects, still deploys, and is still scheduled; its tool calls are + // then refused for lack of any grant, and the tool runner turns that refusal + // into an error tool result rather than a failure, so the run completes + // having done none of the work. This is the check that makes that + // unreachable: it is total over the closure and it runs on every deploy, + // rather than depending on some test happening to invoke a tool from the + // affected step. + // + // Placed after the trigger check and before the operator-policy checks + // below. A deploy that trips this is a defect in the deploy path, not a + // decision the operator can make differently, so it must not be reported + // behind an unapproved-grant message an operator would act on instead. + // + // DO NOT move this assertion earlier in this package's history. The record it + // requires is a claim that the approval covers everything the step can run, + // and that claim was not kept for a step inside a loop body or a section body + // until the deploy and runtime producers were made total over the executable + // closure. Asserted before those producers, the gate would have been + // enforcing a guarantee the rest of the system did not honour. + const stepsWithoutGrantRecord = collectStepsWithoutGrantRecord( + probeResult.projection, + probeResult.grantWalkSnapshot, + ); + if (stepsWithoutGrantRecord.length > 0) { + return { + ok: false, + reason: "steps_without_grant_record", + stepsWithoutGrantRecord, + message: describeStepsWithoutGrantRecord(stepsWithoutGrantRecord), + }; + } + // Gate the advisory grant set. Under an `ApprovalSet` every grant the probe // surfaced must appear in the operator's approved set; any miss fails the // gate closed. Under `approve-probed` there is no set to gate against -- the // probe's surface IS the approved set -- so nothing is ever unapproved. const unapprovedGrants = isApproveProbed(approvals) ? [] - : probeResult.grants.filter((grant) => !approvals.has(grant)); + : probeResult.grants.filter((grant) => !approvals.grants.has(grant)); if (unapprovedGrants.length > 0) { return { ok: false, reason: "grants_not_approved", unapprovedGrants }; } - // Freeze: the approved set is exactly what the workflow advertised (all of it - // now operator-approved), pinned to the recomputed hash. Persisting the hash - // is the freeze; the grant set is returned for the deploy hand-off. - const approvedGrants: readonly string[] = [...probeResult.grants]; + // Gate the DECLARED grant requirements. These ride the walk snapshot rather + // than the flattened `grants`, and the walk never surfaces them, so the + // filter above cannot see them -- yet the run path materializes each one into + // a real grant row on the run principal, and a wildcard row reaches gates no + // walk-derived row can address. They therefore need the operator's decision + // on exactly the same terms the advertised grants do. Under `approve-probed` + // the probe's surface IS the approved surface, so there is nothing to gate + // against and the requirements ride into the approval below. + const declaredRequirements = probeResult.grantWalkSnapshot.grantRequirements; + const unapprovedGrantRequirements = isApproveProbed(approvals) + ? [] + : declaredRequirements.filter( + (requirement) => !isApprovedGrantRequirement(approvals, requirement), + ); + if (unapprovedGrantRequirements.length > 0) { + return { + ok: false, + reason: "grant_requirements_not_approved", + unapprovedGrantRequirements, + }; + } + + // Freeze: the approved surface is exactly what the workflow advertised (all + // of it now operator-approved), pinned to the recomputed hash. Persisting the + // hash is the freeze; the approved surface is returned for the deploy + // hand-off. The declared requirements join the walk's grant strings in that + // surface under both policies, so the record the hand-off and any audit read + // describes every kind of authority the freeze will mint. + const approvedGrants: readonly ApprovalItem[] = [ + ...probeResult.grants, + ...declaredRequirements, + ]; const { definitionId } = await persist({ assetId, approvedWireHash: recomputedWireHash, approvedGrants, grantSnapshot: probeResult.grantWalkSnapshot, - projection: probeResult.projection, }); return { ok: true, definitionId, approvedWireHash: recomputedWireHash, - approvedGrants: new Set(approvedGrants), + approvedSurface: approvalSetFromItems(approvedGrants), projection: probeResult.projection, }; } diff --git a/vendor/intx/hub-sessions/src/workflow-run-kind.test.ts b/vendor/intx/hub-sessions/src/workflow-run-kind.test.ts deleted file mode 100644 index ae8c3c9e60..0000000000 --- a/vendor/intx/hub-sessions/src/workflow-run-kind.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - WORKFLOW_RUN_RUNS_PREFIX, - readCommittedWorkflowRunTerminalStatus, - workflowRunKindHandler, -} from "./workflow-run-kind"; -import { WORKFLOW_RUN_EVENTS_FILE } from "./workflow-run-event-log"; -import type { CommittedReads, CommittedTreeEntry } from "./repo-store"; - -const encoder = new TextEncoder(); - -/** A `CommittedReads` fake backed by an in-memory path -> content map. */ -function makeCommittedReads(files: Record): CommittedReads { - const oidFor = (path: string) => `oid:${path}`; - return { - listDir: async (relPath: string): Promise => { - const prefix = relPath === "" ? "" : `${relPath}/`; - const children = new Map(); - for (const p of Object.keys(files)) { - if (!p.startsWith(prefix)) continue; - const rest = p.slice(prefix.length); - const [name, ...more] = rest.split("/"); - if (name === undefined) continue; - children.set(name, { - name, - oid: more.length === 0 ? oidFor(p) : oidFor(`${prefix}${name}`), - type: more.length === 0 ? "blob" : "tree", - }); - } - return [...children.values()]; - }, - readBlobByOid: async (oid: string): Promise => { - for (const [p, content] of Object.entries(files)) { - if (oidFor(p) === oid) return encoder.encode(content); - } - throw new Error(`no such blob oid: ${oid}`); - }, - treeOid: async () => null, - }; -} - -/** - * A minimal in-memory tree: paths are POSIX, root-relative, no leading - * slash. `listDir` returns the direct child names of a directory path - * (files and subdirectories alike, matching the substrate's contract); - * `readBlob`/`priorReadBlob` resolve a file's own bytes. - */ -function makeTree(files: Record) { - const paths = Object.keys(files); - const listDir = async (dir: string): Promise => { - const prefix = dir === "" ? "" : `${dir}/`; - const children = new Set(); - for (const p of paths) { - if (!p.startsWith(prefix)) continue; - const rest = p.slice(prefix.length); - const name = rest.split("/")[0]; - if (name !== undefined) children.add(name); - } - return [...children]; - }; - const readBlob = async (path: string): Promise => { - const content = files[path]; - if (content === undefined) throw new Error(`no such blob: ${path}`); - return encoder.encode(content); - }; - return { listDir, readBlob }; -} - -function emptyPrior() { - return { - priorListDir: async () => [], - priorReadBlob: async () => null, - }; -} - -const hubPrincipal = { kind: "hub" as const }; - -describe("workflowRunKindHandler.validatePush — CL-6595 combined-run terminal detection", () => { - test("a run sealed from birth (events.jsonl only, never per-event files) is reported newly terminal", async () => { - // This is the exact shape of the runs behind CL-6595: the run's own - // trace page already says "This run finished before we started - // recording steps" because its entire event log arrived pre-combined - // in a single push, with no per-event `.json` blobs ever landing. - const combined = [ - JSON.stringify({ type: "RunStarted", seq: 0 }), - JSON.stringify({ type: "RunCompleted", seq: 1 }), - ].join("\n"); - const { listDir, readBlob } = makeTree({ - [`${WORKFLOW_RUN_RUNS_PREFIX}/run_1/${WORKFLOW_RUN_EVENTS_FILE}`]: combined, - }); - - const result = await workflowRunKindHandler.validatePush({ - repoId: { kind: "workflow-run", id: "run_1" }, - ref: "refs/heads/main", - principal: hubPrincipal, - topLevelTreePaths: [WORKFLOW_RUN_RUNS_PREFIX], - readBlob, - listDir, - ...emptyPrior(), - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.newlyTerminalRuns).toEqual([ - { runId: "run_1", status: "completed", terminalEventJson: expect.any(String) }, - ]); - }); - - test("a run already sealed in the prior tree is not reported newly terminal again", async () => { - const combined = [ - JSON.stringify({ type: "RunStarted", seq: 0 }), - JSON.stringify({ type: "RunFailed", seq: 1 }), - ].join("\n"); - const files = { - [`${WORKFLOW_RUN_RUNS_PREFIX}/run_1/${WORKFLOW_RUN_EVENTS_FILE}`]: combined, - }; - const { listDir, readBlob } = makeTree(files); - const prior = makeTree(files); - - const result = await workflowRunKindHandler.validatePush({ - repoId: { kind: "workflow-run", id: "run_1" }, - ref: "refs/heads/main", - principal: hubPrincipal, - topLevelTreePaths: [WORKFLOW_RUN_RUNS_PREFIX], - readBlob, - listDir, - priorListDir: prior.listDir, - priorReadBlob: async (path) => { - try { - return await prior.readBlob(path); - } catch { - return null; - } - }, - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.newlyTerminalRuns ?? []).toEqual([]); - }); - - test("a live per-event run with no terminal event yet is not reported terminal", async () => { - const { listDir, readBlob } = makeTree({ - [`${WORKFLOW_RUN_RUNS_PREFIX}/run_1/events/0.json`]: JSON.stringify({ - type: "RunStarted", - seq: 0, - }), - }); - - const result = await workflowRunKindHandler.validatePush({ - repoId: { kind: "workflow-run", id: "run_1" }, - ref: "refs/heads/main", - principal: hubPrincipal, - topLevelTreePaths: [WORKFLOW_RUN_RUNS_PREFIX], - readBlob, - listDir, - ...emptyPrior(), - }); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.newlyTerminalRuns ?? []).toEqual([]); - }); -}); - -describe("readCommittedWorkflowRunTerminalStatus", () => { - test("maps a sealed run's terminal event to its workflow_run.status", async () => { - const reads = makeCommittedReads({ - [`${WORKFLOW_RUN_RUNS_PREFIX}/run_1/${WORKFLOW_RUN_EVENTS_FILE}`]: [ - JSON.stringify({ type: "RunStarted", seq: 0 }), - JSON.stringify({ type: "RunCancelled", seq: 1 }), - ].join("\n"), - }); - expect( - await readCommittedWorkflowRunTerminalStatus(reads, "run_1"), - ).toBe("cancelled"); - }); - - test("returns null for a live per-event run", async () => { - const reads = makeCommittedReads({ - [`${WORKFLOW_RUN_RUNS_PREFIX}/run_1/events/0.json`]: JSON.stringify({ - type: "RunStarted", - seq: 0, - }), - }); - expect( - await readCommittedWorkflowRunTerminalStatus(reads, "run_1"), - ).toBeNull(); - }); - - test("returns null for an absent run", async () => { - const reads = makeCommittedReads({}); - expect( - await readCommittedWorkflowRunTerminalStatus(reads, "run_1"), - ).toBeNull(); - }); - - test("returns null when reads is null (no ref/repo yet)", async () => { - expect( - await readCommittedWorkflowRunTerminalStatus(null, "run_1"), - ).toBeNull(); - }); -}); diff --git a/vendor/intx/hub-sessions/src/workflow-run-kind.ts b/vendor/intx/hub-sessions/src/workflow-run-kind.ts index 054c6baf0d..a2ffc1e1f8 100644 --- a/vendor/intx/hub-sessions/src/workflow-run-kind.ts +++ b/vendor/intx/hub-sessions/src/workflow-run-kind.ts @@ -166,9 +166,8 @@ import fs from "node:fs"; import git from "isomorphic-git"; import { type } from "arktype"; import { getLogger } from "@intx/log"; -import { glob, repoActionToGrantVerb } from "@intx/hub-common"; import { - UserPrincipal, + authorizeUserPrincipal, type AuthorizeFn, type CommittedReads, type KindHandler, @@ -1186,9 +1185,11 @@ async function validateRunPartsSubtree(args: { readBlob: (path: string) => Promise; priorReadBlob: (path: string) => Promise; listDirOids: - ((path: string) => Promise<{ name: string; oid: string }[]>) | undefined; + | ((path: string) => Promise<{ name: string; oid: string }[]>) + | undefined; priorListDirOids: - ((path: string) => Promise<{ name: string; oid: string }[]>) | undefined; + | ((path: string) => Promise<{ name: string; oid: string }[]>) + | undefined; scopeRunIds: ReadonlySet | undefined; }): Promise { const prospective = await enumerateRunParts(args.listDir, args.scopeRunIds); @@ -1797,7 +1798,8 @@ async function hashConsumedBlobOid(bytes: Uint8Array): Promise { function makeListingOidResolver( sideLabel: string, listDirOids: - ((path: string) => Promise<{ name: string; oid: string }[]>) | undefined, + | ((path: string) => Promise<{ name: string; oid: string }[]>) + | undefined, hashFallback: (blobPath: string) => Promise, ): (blobPath: string) => Promise { const dirOidCache = new Map>(); @@ -1829,7 +1831,8 @@ function makeListingOidResolver( function makePriorConsumedOidResolver( priorReadBlob: (path: string) => Promise, priorListDirOids: - ((path: string) => Promise<{ name: string; oid: string }[]>) | undefined, + | ((path: string) => Promise<{ name: string; oid: string }[]>) + | undefined, ): (blobPath: string) => Promise { return makeListingOidResolver("prior", priorListDirOids, async (blobPath) => { const bytes = await priorReadBlob(blobPath); @@ -2746,50 +2749,6 @@ export const workflowRunKindHandler: KindHandler = { return { ok: false, reason: combinedRuns.reason }; } - // A sealed run (`hasCombined`, above) is skipped by the per-event - // terminal scan entirely, since its events never appear as individual - // `.json` blobs -- `checkCombinedStructure` already proved every - // combined `events.jsonl` ends on a terminal event, so every combined - // run IS terminal. Surface it here as newly terminal unless the prior - // tree already carried the same sealed file (already reported on - // whichever push first sealed it, or a run compacted before its first - // push ever landed -- CL-6595's "finished before we started recording - // steps" runs are exactly this case: sealed from birth, so the - // per-event loop above never had a blob to see them by). - for (const runId of combinedRuns.combinedRunIds) { - const runDirPath = `${WORKFLOW_RUN_RUNS_PREFIX}/${runId}`; - const priorChildren = await priorListDir(runDirPath); - if (priorChildren.includes(WORKFLOW_RUN_EVENTS_FILE)) continue; - const combinedPath = `${runDirPath}/${WORKFLOW_RUN_EVENTS_FILE}`; - const content = new TextDecoder().decode(await readBlob(combinedPath)); - const lines = splitCombinedEventLog(content); - const lastLine = lines[lines.length - 1]; - if (lastLine === undefined) { - throw new Error( - `combined event log ${combinedPath} sealed with no lines after passing structural validation`, - ); - } - const body: unknown = JSON.parse(lastLine); - const type = - typeof body === "object" && body !== null && "type" in body - ? body.type - : undefined; - const classified = - typeof type === "string" - ? classifyTerminalEvent(type) - : ({ terminal: false } as const); - if (!classified.terminal) { - throw new Error( - `combined event log ${combinedPath} for run ${runId} sealed without a recognized terminal event type`, - ); - } - newlyTerminalRuns.push({ - runId, - status: classified.status, - terminalEventJson: lastLine, - }); - } - const blobsEnumerated = await enumerateRunBlobs(listDir, scopeRunIds); if (!blobsEnumerated.ok) { logger.debug`workflow-run validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${blobsEnumerated.reason}`; @@ -2993,63 +2952,13 @@ export const workflowRunAuthorize: AuthorizeFn = ( } if (principal.kind === "user") { - // The route layer has already pre-resolved the grant verdict and - // attached it as `authz`. The substrate does NOT re-query the - // grant store here; it (a) checks the bearer-token's claims - // bound the requested (ref, action) and have not expired, and - // (b) sanity-checks that the pre-resolved verdict targets this - // exact resource and grant verb. Both gates must pass before the - // verdict's `effect` is honoured. - const parsed = UserPrincipal(principal); - if (parsed instanceof type.errors) { - return { - allowed: false, - reason: `user principal is malformed: ${parsed.summary}`, - }; - } - if (!parsed.tokenClaims.actions.includes(action)) { - return { - allowed: false, - reason: `token does not grant action ${action}`, - }; - } - // `ref === "*"` is the substrate's sentinel for the bulk read - // performed by `listRefs`. Per-ref filtering is the advertise-refs - // layer's responsibility, so the bulk read is gated on action and - // expiry alone. - if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) { - return { - allowed: false, - reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`, - }; - } - if (Date.now() >= parsed.tokenClaims.expiresAt) { - return { - allowed: false, - reason: `token expired at ${parsed.tokenClaims.expiresAt}`, - }; - } - const expectedResource = `workflow-run:${repoId.id}`; - if (parsed.authz.resource !== expectedResource) { - return { - allowed: false, - reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`, - }; - } - const expectedGrantVerb = repoActionToGrantVerb(action); - if (parsed.authz.grantVerb !== expectedGrantVerb) { - return { - allowed: false, - reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`, - }; - } - if (parsed.authz.effect === "allow") { - return { allowed: true }; - } - return { - allowed: false, - reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`, - }; + return authorizeUserPrincipal({ + principal, + repoId, + ref, + action, + resourcePrefix: "workflow-run", + }); } // Fail closed on any kind not handled above. The tenant-level @@ -3279,7 +3188,10 @@ export type EnqueueInboxResult = { * receipt on the enqueue may safely acknowledge on any of them. */ export type EnqueueAlreadyPresentReason = - "duplicate" | "already_inbox" | "processing" | "consumed"; + | "duplicate" + | "already_inbox" + | "processing" + | "consumed"; /** * Outcome of an `enqueueInbox` call. Modeled as a value (not an exception) @@ -3988,87 +3900,6 @@ export async function readCommittedWorkflowRunLifecycle( }); } -/** - * Read one run's terminal `workflow_run.status` value from a committed - * workflow-run tree, or `null` if the run has not reached a terminal event. - * Companion to `readCommittedWorkflowRunLifecycle` for a caller that needs - * the actual status to write (e.g. a `markTerminal` backfill), not just the - * live/terminal/absent classification. - */ -export async function readCommittedWorkflowRunTerminalStatus( - reads: CommittedReads | null, - runId: string, -): Promise<"completed" | "failed" | "cancelled" | null> { - if (reads === null) return null; - const runPath = `${WORKFLOW_RUN_RUNS_PREFIX}/${runId}`; - const runChildren = await reads.listDir(runPath); - const sealed = runChildren.find( - (entry) => entry.type === "blob" && entry.name === WORKFLOW_RUN_EVENTS_FILE, - ); - if (sealed !== undefined) { - const content = new TextDecoder().decode( - await reads.readBlobByOid(sealed.oid), - ); - const lines = splitCombinedEventLog(content); - const lastLine = lines[lines.length - 1]; - if (lastLine === undefined) { - throw new Error( - `combined event log ${runPath}/${WORKFLOW_RUN_EVENTS_FILE} is sealed but has no lines`, - ); - } - const body: unknown = JSON.parse(lastLine); - const type = - typeof body === "object" && body !== null && "type" in body - ? body.type - : undefined; - const classified = - typeof type === "string" - ? classifyTerminalEvent(type) - : ({ terminal: false } as const); - if (!classified.terminal) { - throw new Error( - `sealed run ${runId} has no recognized terminal event type`, - ); - } - return classified.status; - } - - const eventsPath = `${runPath}/${WORKFLOW_RUN_EVENTS_DIR}`; - const eventEntries = (await reads.listDir(eventsPath)).filter( - (entry) => entry.type === "blob" && parseEventSeq(entry.name) !== null, - ); - const latest = eventEntries.reduce<(typeof eventEntries)[number] | undefined>( - (candidate, entry) => { - if (candidate === undefined) return entry; - const candidateSeq = parseEventSeq(candidate.name); - const entrySeq = parseEventSeq(entry.name); - return entrySeq !== null && - candidateSeq !== null && - entrySeq > candidateSeq - ? entry - : candidate; - }, - undefined, - ); - if (latest === undefined) return null; - const eventPath = `${eventsPath}/${latest.name}`; - let parsed: unknown; - try { - parsed = JSON.parse( - new TextDecoder().decode(await reads.readBlobByOid(latest.oid)), - ); - } catch (cause) { - throw new Error(`workflow_run_event_unreadable: ${eventPath}`, { cause }); - } - const type = - typeof parsed === "object" && parsed !== null && "type" in parsed - ? parsed.type - : undefined; - if (typeof type !== "string") return null; - const classified = classifyTerminalEvent(type); - return classified.terminal ? classified.status : null; -} - /** * Read the durable lifecycle of one run from the workflow-run working tree. * `grants.json` alone is still an absent run: grants are staged before the diff --git a/vendor/intx/hub-sessions/src/workflow-run-restore.ts b/vendor/intx/hub-sessions/src/workflow-run-restore.ts index f787fad054..316b6575cb 100644 --- a/vendor/intx/hub-sessions/src/workflow-run-restore.ts +++ b/vendor/intx/hub-sessions/src/workflow-run-restore.ts @@ -25,6 +25,7 @@ export async function restoreWorkflowRunToAllocation(args: { >; allocationTarget: AllocatedSidecarTarget; agentAddress: string; + signal?: AbortSignal; }): Promise { const { agentRepoStore, allocationRouter, allocationTarget, agentAddress } = args; @@ -35,11 +36,13 @@ export async function restoreWorkflowRunToAllocation(args: { }; for (const ref of WORKFLOW_RUN_RESTORE_REFS) { + args.signal?.throwIfAborted(); const tip = await agentRepoStore.repoStore.resolveRef( principal, repoId, ref, ); + args.signal?.throwIfAborted(); if (tip === null) continue; const pack = await agentRepoStore.repoStore.createPack( @@ -47,12 +50,15 @@ export async function restoreWorkflowRunToAllocation(args: { repoId, ref, ); + args.signal?.throwIfAborted(); await allocationRouter.sendWorkflowRunPackToAllocation( allocationTarget, agentAddress, pack.pack, pack.ref, pack.commitSha, + args.signal, ); } + args.signal?.throwIfAborted(); } diff --git a/vendor/intx/hub-sessions/src/workflow-source-pins.ts b/vendor/intx/hub-sessions/src/workflow-source-pins.ts index f5b3eb3c90..cf5e707322 100644 --- a/vendor/intx/hub-sessions/src/workflow-source-pins.ts +++ b/vendor/intx/hub-sessions/src/workflow-source-pins.ts @@ -2,10 +2,8 @@ import type { WorkflowProjectionWithSources } from "@intx/types/sidecar"; import type { HarnessConfig } from "@intx/types/runtime"; import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; import { + buildInertBodyStepSources, enumerateInertBodies, - pickStepInferenceSource, - pinInertStepSources, - WorkflowDefinitionInvalidError, type ApprovalSet, } from "@intx/workflow-deploy"; @@ -15,38 +13,15 @@ export async function buildReferencedWorkflowSourcePins(args: { operatorApprovals: ApprovalSet; }): Promise { return Promise.all( - enumerateInertBodies(args.projection).map(async (body) => { - const sources = pinInertStepSources({ + enumerateInertBodies(args.projection).map(async (body) => ({ + definition: body.definition, + sources: buildInertBodyStepSources({ definition: body.definition, workflowId: body.ref, - context: `deployCodeSourcedWorkflow body ${body.ref}: `, - resolveLeafSource: ({ stepId, isAgent, preference }) => { - if (!isAgent) { - const placeholder = args.config.sources.find( - (source) => source.id === args.config.defaultSource, - ); - if (placeholder === undefined) { - throw new WorkflowDefinitionInvalidError( - body.ref, - `non-agent body step ${stepId} needs an inert placeholder source, but the deploy config carries no defaultSource entry to pin`, - ); - } - return placeholder; - } - return pickStepInferenceSource({ - preferred: preference, - stepId, - workflowId: body.ref, - config: args.config, - operatorApprovals: args.operatorApprovals, - }); - }, - }); - return { - definition: body.definition, - sources, - approvedWireHash: await computeWireDefinitionHash(body.definition), - }; - }), + config: args.config, + operatorApprovals: args.operatorApprovals, + }), + approvedWireHash: await computeWireDefinitionHash(body.definition), + })), ); } diff --git a/vendor/intx/hub-sessions/src/ws/index.ts b/vendor/intx/hub-sessions/src/ws/index.ts index 30467c34a7..eb91862f45 100644 --- a/vendor/intx/hub-sessions/src/ws/index.ts +++ b/vendor/intx/hub-sessions/src/ws/index.ts @@ -2,12 +2,14 @@ export { createSidecarRouter, type SidecarRouter, type SidecarRouterConfig, + type SenderDeploySettledOutcome, + type OAuthLoginFinalOutcome, + type OAuthLoginGateOutcome, + type OAuthLoginRequestOutcome, + DEFAULT_OAUTH_LOGIN_TIMEOUT_MS, type SidecarConnection, type SidecarAuthIdentity, type SidecarAuthenticator, - type OAuthLoginRequestOutcome, - type OAuthLoginFinalOutcome, - type OAuthLoginGateOutcome, type AllocatedSidecarTarget, type SidecarAllocationRouter, type SendPackOptions, diff --git a/vendor/intx/hub-sessions/src/ws/pending-tracker.ts b/vendor/intx/hub-sessions/src/ws/pending-tracker.ts new file mode 100644 index 0000000000..07d90e5a1f --- /dev/null +++ b/vendor/intx/hub-sessions/src/ws/pending-tracker.ts @@ -0,0 +1,135 @@ +// Generic pending-round-trip tracker for hub→sidecar requests. +// +// `sidecar-handler.ts` runs five request/response round-trips over the same +// websocket (session requests, agent deploys, pack transfers, undeploys, +// workflow probes). Each used to hand-roll the same lifecycle: register an +// entry in a Map keyed by request/transfer/address, arm a timeout that +// settles the entry, settle it early when the matching reply frame arrives, +// and sweep every entry owned by a connection when that connection drops. +// The sites differ only in the key, the resolved value, and the per-site +// cleanup their resolve/reject closures capture — the map + timer lifecycle +// is identical. `PendingTracker` owns that lifecycle so timeouts and +// disconnect sweeps behave uniformly and a fix to either lands in one place. + +// Minimal handle so the router doesn't depend on a specific WebSocket impl. +export type WsHandle = { + send(data: string): void; + close(): void; +}; + +export type PendingEntry = { + key: Key; + ws: WsHandle; + /** + * Opaque per-entry payload for settle-time ownership checks. The pack + * tracker stores the send-site `{ agentAddress, repoId }` so an ack/reject + * is honored only when it comes from the connection that owns the transfer + * for the same repo; the other trackers pass `undefined`. + */ + meta: Meta; + resolve(value: Value): void; + reject(error: string): void; + timer: ReturnType; +}; + +export class PendingTracker { + private readonly entries = new Map>(); + + /** + * Register a pending round-trip and arm its timeout. The entry is stored + * before the caller sends its frame, so a synchronous reply (loopback + * transports, tests) settles it. When `timeoutMs` elapses the entry is + * dropped and `reject` is invoked with `timeoutMessage` — the same + * rejection path an error reply frame uses, so per-site cleanup (routing + * rollback, address bookkeeping) runs exactly once either way. + * + * `meta` is the opaque per-entry payload settle-time ownership checks read + * off `get`; pass `undefined` when the round-trip carries none. + */ + register( + key: Key, + ws: WsHandle, + options: { + timeoutMs: number; + timeoutMessage: string; + resolve(value: Value): void; + reject(error: string): void; + }, + meta: Meta, + ): void { + const timer = setTimeout(() => { + this.entries.delete(key); + options.reject(options.timeoutMessage); + }, options.timeoutMs); + this.entries.set(key, { + key, + ws, + meta, + resolve: options.resolve, + reject: options.reject, + timer, + }); + } + + has(key: Key): boolean { + return this.entries.has(key); + } + + get(key: Key): PendingEntry | undefined { + return this.entries.get(key); + } + + /** + * Settle a pending entry as resolved. No-op when the entry is already + * gone (timed out or swept on disconnect). + */ + resolve(key: Key, value: Value): boolean { + const entry = this.entries.get(key); + if (entry === undefined) return false; + clearTimeout(entry.timer); + this.entries.delete(key); + entry.resolve(value); + return true; + } + + /** + * Settle a pending entry as rejected. No-op when the entry is already + * gone (timed out or swept on disconnect). + */ + reject(key: Key, error: string): boolean { + const entry = this.entries.get(key); + if (entry === undefined) return false; + clearTimeout(entry.timer); + this.entries.delete(key); + entry.reject(error); + return true; + } + + /** + * Drop a pending entry without settling it. The caller rejects its own + * promise directly — used when a frame provably never reached the wire, so + * the failure must not take the normal rejection path (the deploy + * "failed to send" path, which must report `frameSent: false` and must not + * let the armed timer fire later and double-reject). + */ + delete(key: Key): void { + const entry = this.entries.get(key); + if (entry === undefined) return; + clearTimeout(entry.timer); + this.entries.delete(key); + } + + /** + * Reject every pending entry owned by `ws` — the disconnect sweep. Each + * entry's reject closure runs its own per-site cleanup (address + * bookkeeping), identical to a frame-error rejection. + */ + rejectAllForWs(ws: WsHandle, error: string): void { + for (const [key, entry] of this.entries) { + if (entry.ws !== ws) continue; + clearTimeout(entry.timer); + this.entries.delete(key); + entry.reject(error); + } + } +} diff --git a/vendor/intx/hub-sessions/src/ws/sidecar-events.ts b/vendor/intx/hub-sessions/src/ws/sidecar-events.ts index 9a7ccd7a66..10d1d79b89 100644 --- a/vendor/intx/hub-sessions/src/ws/sidecar-events.ts +++ b/vendor/intx/hub-sessions/src/ws/sidecar-events.ts @@ -77,7 +77,6 @@ export type SidecarEventMap = { agentAddress: string; sessionId: string; event: unknown; - /** Occurrence child run id (`turn__`) when the sidecar supplied it. */ childRunId?: string; }; @@ -265,6 +264,39 @@ export type SidecarLookups = { raw: Uint8Array; }) => Promise; + /** Resolves the hub-held public key a sender address signs with, hex-encoded, + * or `null` when the sender has no resolvable key (a run whose deploy is not + * yet acked, an address matching no known principal). The `mail.inbound` frame + * carries the result so a recipient verifies the signature locally against the + * hub-resolved key rather than the message's own spoofable From. Returns only + * the key string, not its source, so the recipient cannot branch on sender + * kind. + * + * Best-effort: this NEVER throws. A resolution fault degrades to `null` + * (logged at ERROR by the resolver), so a key-resolution problem can never + * block mail delivery. The strict, throwing resolver is `resolveSenderKey` + * in `@intx/db`; the reconnect reconciliation consumes it through + * `resolveSenderKeyStrict` below, which needs to tell a fault from a genuine + * absence. */ + resolveSenderKey?: (address: string) => Promise; + + /** Strict sibling of `resolveSenderKey` for the reconnect reconciliation. The + * register/reconnect handler re-resolves each reported cached sender and acts + * on the three-way outcome the best-effort resolver collapses: + * - returns the hex key when the sender resolves -> push `sender.key.refresh`; + * - returns `null` for a CONFIRMED absence (no matching principal = a deleted + * sender) -> push `sender.key.evict`; + * - THROWS on a fault (ambiguous address, a keyless-principal invariant + * break, a DB error) -> keep the stale cached key, evict nothing. + * Never evicting on a throw is load-bearing: dropping a live key on a + * transient DB fault would be worse than doing nothing. Wraps the strict + * `resolveSenderKey` in `@intx/db` (unwrapped to the key string). Note the + * naming inversion: in `@intx/db` the STRICT resolver is `resolveSenderKey` + * and the best-effort one is `resolveFrameSenderKey`, whereas on this lookup + * type the best-effort field is `resolveSenderKey` and this is the strict + * one. */ + resolveSenderKeyStrict?: (address: string) => Promise; + /** Co-writes the `signal_correlation` routing row and the `approval` row * for a suspending workflow agent step, in one transaction. Called from * the `signal.correlation.register` frame handler after the wire layer has diff --git a/vendor/intx/hub-sessions/src/ws/sidecar-handler-oauth-login.test.ts b/vendor/intx/hub-sessions/src/ws/sidecar-handler-oauth-login.test.ts deleted file mode 100644 index 88d7e37597..0000000000 --- a/vendor/intx/hub-sessions/src/ws/sidecar-handler-oauth-login.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -// CL-7508 review S1: a login the hub gives up on (whole-login timeout) must -// cancel the sidecar's staged listener — an abandoned pinned-port bind would -// make every retry of the connector fail to bind — and a retry must get a -// fresh, working start. -import { describe, expect, test } from "bun:test"; -import { - connectAllocated, - createAllocatedRouter, - parsedFrames, - tick, -} from "./sidecar-handler.test-helpers"; - -function sentOAuthFrames( - ws: { sent: string[] }, - type: "oauth.login.start" | "oauth.login.cancel", -): { requestId: string }[] { - return parsedFrames(ws).flatMap((frame) => { - if ( - typeof frame === "object" && - frame !== null && - "type" in frame && - frame.type === type && - "requestId" in frame && - typeof frame.requestId === "string" - ) { - return [{ requestId: frame.requestId }]; - } - return []; - }); -} - -describe("requestOAuthLogin cancellation", () => { - test("a timed-out login sends oauth.login.cancel and a retry starts fresh", async () => { - const router = createAllocatedRouter({ - oauthLogin: { isLocalSidecar: () => true, timeoutMs: 50 }, - }); - const ws = await connectAllocated(router); - - const firstPending = router.requestOAuthLogin({ connectorId: "codex" }); - await tick(); - const [firstStart] = sentOAuthFrames(ws, "oauth.login.start"); - const firstRequestId = firstStart?.requestId; - if (firstRequestId === undefined) throw new Error("no start frame sent"); - // Simulate the sidecar staging the login: the started arm resolves the - // request promise and hands back the terminal promise. - router.handleMessage( - ws, - JSON.stringify({ - type: "oauth.login.result", - requestId: firstStart?.requestId, - outcome: { status: "started", authorizeUrl: "https://auth.example/a" }, - }), - ); - const first = await firstPending; - if (first.status !== "started") throw new Error("expected a started login"); - - // The sidecar never sends a terminal frame, so the whole-login timeout - // fires: the request fails and the sidecar is told to tear its - // listener down. - const final = await first.completed; - if (final.status !== "error") throw new Error("expected a timeout error"); - expect(final.message).toContain("timed out"); - expect(sentOAuthFrames(ws, "oauth.login.cancel")).toEqual([ - { requestId: firstRequestId }, - ]); - - // The retry for the same connector gets a fresh start with a new - // requestId — no stale pending entry, no wedged gate. - const secondPending = router.requestOAuthLogin({ connectorId: "codex" }); - await tick(); - const [secondStart] = sentOAuthFrames(ws, "oauth.login.start").slice(-1); - expect(secondStart?.requestId).not.toBe(firstRequestId); - router.handleMessage( - ws, - JSON.stringify({ - type: "oauth.login.result", - requestId: secondStart?.requestId, - outcome: { status: "started", authorizeUrl: "https://auth.example/b" }, - }), - ); - const second = await secondPending; - if (second.status !== "started") - throw new Error("expected a started retry"); - expect(sentOAuthFrames(ws, "oauth.login.start")).toHaveLength(2); - }); - - test("a completed outcome resolves the pending login with its tokens", async () => { - const router = createAllocatedRouter({ - oauthLogin: { isLocalSidecar: () => true, timeoutMs: 5000 }, - }); - const ws = await connectAllocated(router); - - const pending = router.requestOAuthLogin({ connectorId: "codex" }); - await tick(); - const [start] = sentOAuthFrames(ws, "oauth.login.start"); - if (start === undefined) throw new Error("no start frame sent"); - router.handleMessage( - ws, - JSON.stringify({ - type: "oauth.login.result", - requestId: start.requestId, - outcome: { status: "started", authorizeUrl: "https://auth.example/a" }, - }), - ); - const started = await pending; - if (started.status !== "started") throw new Error("expected a started login"); - - const tokens = { access: "at_1", refresh: "rt_1", expiresAt: 1750000000000 }; - router.handleMessage( - ws, - JSON.stringify({ - type: "oauth.login.result", - requestId: start.requestId, - outcome: { status: "completed", tokens }, - }), - ); - const final = await started.completed; - expect(final).toEqual({ status: "completed", tokens }); - }); - - test("an error outcome before started settles the request as an error", async () => { - const router = createAllocatedRouter({ - oauthLogin: { isLocalSidecar: () => true, timeoutMs: 5000 }, - }); - const ws = await connectAllocated(router); - - const pending = router.requestOAuthLogin({ connectorId: "codex" }); - await tick(); - const [start] = sentOAuthFrames(ws, "oauth.login.start"); - if (start === undefined) throw new Error("no start frame sent"); - router.handleMessage( - ws, - JSON.stringify({ - type: "oauth.login.result", - requestId: start.requestId, - outcome: { status: "error", message: "port 1455 busy" }, - }), - ); - const outcome = await pending; - expect(outcome).toEqual({ status: "error", message: "port 1455 busy" }); - }); - - test("a completed outcome before started settles the request — no hang", async () => { - const router = createAllocatedRouter({ - oauthLogin: { isLocalSidecar: () => true, timeoutMs: 5000 }, - }); - const ws = await connectAllocated(router); - - const pending = router.requestOAuthLogin({ connectorId: "codex" }); - await tick(); - const [start] = sentOAuthFrames(ws, "oauth.login.start"); - if (start === undefined) throw new Error("no start frame sent"); - // A misbehaving peer skips the started acknowledgement and answers - // with the terminal arm directly: the request promise must settle - // (as an error — the caller never saw an authorize URL) instead of - // hanging until the whole-login timeout. - router.handleMessage( - ws, - JSON.stringify({ - type: "oauth.login.result", - requestId: start.requestId, - outcome: { - status: "completed", - tokens: { access: "at_1", refresh: "rt_1", expiresAt: 1750000000000 }, - }, - }), - ); - const outcome = await pending; - if (outcome.status !== "error") - throw new Error("expected a request-level error"); - expect(outcome.message).toContain("before it started"); - }); -}); diff --git a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts index 5c5f424791..b0be486cef 100644 --- a/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts +++ b/vendor/intx/hub-sessions/src/ws/sidecar-handler.ts @@ -16,6 +16,7 @@ import type { GrantWalkSnapshot } from "@intx/types"; import { deriveWorkflowRunRepoId } from "@intx/workflow-deploy"; import { type } from "arktype"; import { + MAX_MAIL_OUTBOUND_BODY_BYTES, SidecarFrame, type AgentDeployAckFrame, type AgentDeployFrame, @@ -47,6 +48,11 @@ import { type SidecarLookups, type SidecarMailPersistedRow, } from "./sidecar-events"; +import { + PendingTracker, + type PendingEntry, + type WsHandle, +} from "./pending-tracker"; const logger = getLogger(["hub", "ws", "sidecar"]); @@ -129,14 +135,6 @@ function ownedAddresses(conn: SidecarConnection): Set { return new Set([...conn.agentAddresses, ...conn.workflowAddresses]); } -type PendingRequest = { - requestId: string; - ws: WsHandle; - resolve(): void; - reject(error: string): void; - timer: ReturnType; -}; - export type SendPackOptions = { /** * Repo-relative mount path under the sidecar's per-agent workspace. @@ -185,6 +183,18 @@ export type WorkflowProbeResult = { wireHash: string; }; +/** + * The result of a run-address sender's deploy, reported to the router once the + * deploy's key write is durable. `recorded` carries the sender's now-persisted + * public key and wakes the sender's parked pre-ack mail for redelivery; + * `failed` carries a failure reason and drains that mail to + * `mail.outbound.undelivered`. Modeled as a discriminated result so a settle is + * unambiguous about which side of the deploy it reports. + */ +export type SenderDeploySettledOutcome = + | { recorded: string } + | { failed: string }; + /** The terminal arms of a sidecar-hosted loopback login. */ export type OAuthLoginFinalOutcome = | { status: "completed"; tokens: OAuthLoginTokens } @@ -216,7 +226,13 @@ export type SidecarRouter = { routeMail( agentAddress: string, rawMessage: string, + authenticatedSender: string, messageId?: string, + runGrants?: { + runId: string; + stepGrants: RunGrantsFrame["stepGrants"]; + senderIdentities?: RunGrantsFrame["senderIdentities"]; + }, ): boolean; /** * Deliver a run's authorization grants to the sidecar hosting the named @@ -232,12 +248,43 @@ export type SidecarRouter = { * `run.grants` then has no queue to ride and this returns `false`. Returns * `false` whenever the address is unroutable; the caller keeps any stable-run * grant reservation so a later first-delivery attempt reuses it. + * + * `senderIdentities` co-delivers the run's authorized senders' resolved keys + * on the same barrier as the grant, so a recipient that caches from this + * frame binds each sender address to the hub-vouched key. The caller passes + * `undefined` when there is no sender to co-deliver (a standing-grant refresh) + * or the sender has no resolvable key; a null key is never carried. */ sendRunGrants( agentAddress: string, runId: string, stepGrants: RunGrantsFrame["stepGrants"], + senderIdentities: RunGrantsFrame["senderIdentities"], ): boolean; + /** + * Report that a run-address sender's deploy has settled, driving any mail the + * sender parked while its public key was not yet recorded. A `recorded` + * outcome wakes the parked mail and re-drives its delivery now that the key + * co-delivers; a `failed` outcome drains it to `mail.outbound.undelivered`. + * Called from BOTH durable key-record sites (the non-allocated deploy-ack + * projection and the allocated anchor-key update) immediately after the write. + * The `address` MUST be byte-identical to the sender address the mail was sent + * under, or no parked entry matches. Idempotent by sender: a second settle, or + * a settle after the TTL already drained the mail, is a no-op. + */ + noteSenderDeploySettled( + address: string, + outcome: SenderDeploySettledOutcome, + ): void; + /** + * Mark a run-address sender's ALLOCATED deploy as mid-flight, before the deploy + * emit and its anchor-key update. An allocated run records its key later than + * the deploy ack clears `pendingDeploys`, so this marker covers the allocated + * pre-ack window that `pendingDeploys` alone under-covers. `noteSenderDeploySettled` + * clears it on both the recorded and failed outcomes, keeping the start/settle + * bracket balanced. + */ + noteSenderDeployStarted(address: string): void; /** * Returns the current connector-thread state for the named agent, or * `null` if the agent has no active connector thread (or if the @@ -371,6 +418,7 @@ export type SidecarAllocationRouter = { agentAddress: string, config: HarnessConfig, workflow?: AgentDeployFrame["workflow"], + signal?: AbortSignal, ): Promise<{ publicKey: string }>; sendPackToAllocation( target: AllocatedSidecarTarget, @@ -390,6 +438,7 @@ export type SidecarAllocationRouter = { pack: Uint8Array, ref: string, commitSha: string, + signal?: AbortSignal, ): Promise; bindAllocatedStepRoute( target: AllocatedSidecarTarget, @@ -417,6 +466,7 @@ export type SidecarAllocationRouter = { runId: string, stepGrants: RunGrantsFrame["stepGrants"], rawMessage: string, + authenticatedSender: string, messageId: string, ): Promise; /** Deliver an idempotent signal to the exact provisioned generation. */ @@ -495,11 +545,10 @@ export type SidecarRouterConfig = { lookups?: SidecarLookups; }; -// Minimal handle so the router doesn't depend on a specific WebSocket impl. -export type WsHandle = { - send(data: string): void; - close(): void; -}; +// Re-exported so existing consumers keep importing the handle type from the +// router module; the definition now lives in `pending-tracker.ts`, which also +// operates on it. +export type { WsHandle }; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; // A probe fetches a workflow's dependency closure from a registry and @@ -512,6 +561,19 @@ const DEFAULT_PING_TIMEOUT_MS = 60_000; const DEFAULT_MAIL_ACK_RETRY_INTERVAL_MS = 10_000; const DEFAULT_MAIL_ACK_MAX_RETRIES = 5; +// The hub re-resolves and re-pushes a key for each rotatable sender a sidecar +// reports on (re)connect. A legitimate sidecar caches keys for tens, maybe low +// hundreds of distinct user senders, so this cap sits well above ten times that +// ceiling: it NEVER truncates a real report -- dropping a genuine sender would +// leave its key stale, the exact failure this refresh exists to prevent. It +// bounds only a hostile or buggy sidecar, since a compromised authenticated +// sidecar could otherwise report an unbounded set and drive that many sequential +// DB resolves on every reconnect. The cap lives in the handler, not on the +// arktype frame schema, on purpose: rejecting an over-cap frame at parse would +// fail the whole reconnect (a hard outage) rather than degrade gracefully to a +// bounded refresh. +export const MAX_RESYNC_SENDER_ADDRESSES = 2048; + export function createSidecarRouter( config: SidecarRouterConfig, ): SidecarRouter & SidecarAllocationRouter { @@ -552,17 +614,20 @@ export function createSidecarRouter( const allocationWaiters = new Map>(); // agentAddress → ws handle (routing table) const addressIndex = new Map(); - // requestId → pending promise - const pending = new Map(); + // requestId → pending promise (resolved by session.ack, rejected by + // session.error). `PendingTracker` owns the register/timeout/settle/sweep + // lifecycle shared by all five pending round-trips below; each entry's + // resolve/reject closures carry the per-round-trip cleanup. + const pendingRequests = new PendingTracker(); // agentAddress → pending deploy promise (matched by agent.deploy.ack/agent.error) - type PendingDeploy = { - agentAddress: string; - ws: WsHandle; - resolve(publicKey: string): void; - reject(error: string): void; - timer: ReturnType; - }; - const pendingDeploys = new Map(); + const pendingDeploys = new PendingTracker(); + // Run addresses whose ALLOCATED deploy is mid-flight -- key-record has been + // started but not yet committed. pendingDeploys clears at the deploy ack, but an + // allocated run's key is recorded LATER by session-service's anchor-key update, + // so pendingDeploys alone under-covers the allocated pre-ack window. session- + // service brackets this marker across its deploy try/catch: set before the + // deploy emit, cleared by noteSenderDeploySettled on record or failure. + const allocatedKeyRecordInFlight = new Set(); // agentAddress → queued frames for disconnected agents awaiting reconnect type DisconnectedAgent = { queue: HubFrame[]; @@ -589,8 +654,14 @@ export function createSidecarRouter( // `run.grants` frame AHEAD of the mail so the redelivered trigger lands on // a sidecar that has the run's grants, rather than failing its onRunStart // barrier closed. Re-materializing at redelivery time is unsafe (it carries - // commit/authority semantics); replaying the same bytes is not. - runGrants?: { runId: string; stepGrants: RunGrantsFrame["stepGrants"] }; + // commit/authority semantics); replaying the same bytes is not. The + // co-delivered `senderIdentities` ride the same snapshot, so a sidecar that + // first learns the grant on the reconnect replay also learns the key. + runGrants?: { + runId: string; + stepGrants: RunGrantsFrame["stepGrants"]; + senderIdentities?: RunGrantsFrame["senderIdentities"]; + }; /** Present for a durable trigger pinned to a provisioned allocation. */ allocatedTarget?: AllocatedSidecarTarget; }; @@ -602,6 +673,26 @@ export function createSidecarRouter( // does not leak entries. Cleared when the address reconnects (redelivery) or // its last pending entry is acked. const pendingMailRetention = new Map>(); + // sender address → pre-ack mail parked while the sender's public key is not + // yet recorded. A run mints its signing keypair locally at the sidecar and can + // send mail before the hub has recorded its `public_key`; such mail resolves a + // null sender key, so no key co-delivers and a strict recipient drops it as an + // unknown sender. Each entry holds what a re-drive of `handleMailOutbound` + // needs -- the raw message and its recipients -- plus a per-entry TTL timer. + // This is a SIBLING of `pendingMail`, keyed by SENDER: it means "the SENDER's + // key is missing," the opposite axis from the recipient-keyed disconnect queue + // (`disconnectedAgents`), which means "the RECIPIENT socket is gone." Here the + // recipient is live; only the sender's key is absent. `noteSenderDeploySettled` + // drives an entry to delivery once the key lands or to + // `mail.outbound.undelivered` when the deploy fails; the TTL backstops the case + // where neither happens. + type DeferredSenderMailEntry = { + authenticatedSender: string; + rawMessage: string; + recipients: string[]; + timer: ReturnType; + }; + const deferredSenderMail = new Map>(); // agentAddress → set of subscriber callbacks for agent events const agentSubscribers = new Map void>>(); // agentAddress → cached connector-thread state, populated by @@ -623,43 +714,24 @@ export function createSidecarRouter( // close. const messageChains = new Map>(); - // transferId → pending pack transfer (resolved by repo.pack.ack, rejected by repo.pack.reject) - type PendingPack = { - transferId: string; - ws: WsHandle; - agentAddress: string; - repoId: RepoId; - resolve(): void; - reject(error: string): void; - timer: ReturnType; - }; - const pendingPacks = new Map(); + // transferId → pending pack transfer (resolved by repo.pack.ack, rejected + // by repo.pack.reject). The entry carries the send-site agentAddress and + // repoId so an ack/reject is honored only when it comes from the + // connection that owns the transfer for the same repo. + type PackTransferMeta = { agentAddress: string; repoId: RepoId }; + const pendingPacks = new PendingTracker(); let packCounter = 0; // agentAddress → pending undeploy (resolved by agent.undeploy.ack) - type PendingUndeploy = { - agentAddress: string; - ws: WsHandle; - resolve(): void; - reject(error: string): void; - timer: ReturnType; - }; - const pendingUndeploys = new Map(); + const pendingUndeploys = new PendingTracker(); // requestId → pending workflow probe (resolved by workflow.probe.result, - // rejected by workflow.probe.error). Result-carrying, unlike `pending` - // (which resolves void): a probe returns the sidecar's inert projection + - // grant set + wire hash. Keyed on requestId alone -- the probe runs in the - // sidecar's pre-deploy state and enters no address map, so `handleClose`'s - // ws-keyed sweep is its ONLY disconnect cleanup. - type PendingProbe = { - requestId: string; - ws: WsHandle; - resolve(result: WorkflowProbeResult): void; - reject(error: string): void; - timer: ReturnType; - }; - const pendingProbes = new Map(); + // rejected by workflow.probe.error). Result-carrying, unlike the other + // trackers (which resolve void): a probe returns the sidecar's inert + // projection + grant set + wire hash. Keyed on requestId alone -- the + // probe runs in the sidecar's pre-deploy state and enters no address map, + // so `handleClose`'s ws-keyed sweep is its ONLY disconnect cleanup. + const pendingProbes = new PendingTracker(); // Sidecar-hosted loopback logins (CL-7508). Keyed on requestId alone — the // login is tenant-level, not address-scoped — so, like probes, the @@ -733,6 +805,21 @@ export function createSidecarRouter( return true; } + // Arm a redelivery-retry timer for a tracked pending mail. Wraps the async + // `retryPendingMail` so a rejection -- a socket write that throws once the + // sidecar is gone -- is logged rather than floating out of the timer as an + // unhandled rejection. + function scheduleMailRetry( + agentAddress: string, + messageId: string, + ): ReturnType { + return setTimeout(() => { + void retryPendingMail(agentAddress, messageId).catch((err: unknown) => { + logger.warn`Redelivery retry for mail ${messageId} to ${agentAddress} failed: ${err instanceof Error ? err.message : String(err)}`; + }); + }, mailAckRetryIntervalMs); + } + // Track a connected-window `mail.inbound` for redelivery until the sidecar // acks its durable inbox write. Replaces any prior entry for the same // (agentAddress, messageId) -- clearing its timer first so no timer leaks -- @@ -741,7 +828,11 @@ export function createSidecarRouter( agentAddress: string, messageId: string, frame: HubFrame, - runGrants?: { runId: string; stepGrants: RunGrantsFrame["stepGrants"] }, + runGrants?: { + runId: string; + stepGrants: RunGrantsFrame["stepGrants"]; + senderIdentities?: RunGrantsFrame["senderIdentities"]; + }, allocatedTarget?: AllocatedSidecarTarget, ): void { let byId = pendingMail.get(agentAddress); @@ -756,30 +847,134 @@ export function createSidecarRouter( messageId, frame, attempts: 0, - timer: setTimeout( - () => retryPendingMail(agentAddress, messageId), - mailAckRetryIntervalMs, - ), + timer: scheduleMailRetry(agentAddress, messageId), ...(runGrants !== undefined ? { runGrants } : {}), ...(allocatedTarget !== undefined ? { allocatedTarget } : {}), }); } - // Replay a mail-triggered run's grants ahead of a redelivery of its trigger - // mail. Same-connection FIFO lands the `run.grants` frame before the mail, so - // the redelivered run resolves its onRunStart barrier instead of failing - // closed on missing grants. - function replayRunGrantsAhead( - conn: SidecarConnection, + // Resolve the frame that must precede a redelivery of a trigger mail on the + // FIFO socket, re-resolving a keyless run sender's key so it still + // co-delivers. Returns: + // - a `run.grants` frame when the entry carries run grants (the redelivered + // run resolves its onRunStart barrier instead of failing closed on + // missing grants); + // - a bare `sender.key.refresh` frame when the entry carries NO run grants + // but its run sender's key was never co-delivered, so the recipient still + // caches the key ahead of the mail; + // - `undefined` when nothing must precede the mail. + // + // The re-resolve is KIND-GATED to run-address senders only. A run's + // deployment key is immutable once acked, so the re-resolved key equals the + // signing-time key -- safe. A user (non-run) sender's key may have rotated + // since it signed, so re-resolving would check the fixed signed bytes against + // a newer key and turn a valid message into a false `invalid`; such a sender + // lacking a captured key stays keyless (an honest `unknown`). An entry that + // captured `senderIdentities` at track time replays that snapshot as-is: it + // holds the signing-time key and is never re-resolved. + // + // Awaits any key resolve so the caller sends the returned frame and the mail + // back-to-back with no await between them, keeping the co-delivered key ahead + // of the mail on the FIFO socket. + async function resolveReplayLeadFrame( entry: PendingMailEntry, - ): void { - if (entry.runGrants === undefined) return; - conn.send({ + ): Promise { + const authenticatedSender = + entry.frame.type === "mail.inbound" + ? entry.frame.authenticatedSender + : undefined; + const senderIsRun = + authenticatedSender !== undefined && isRunAddress(authenticatedSender); + + if (entry.runGrants === undefined) { + if (authenticatedSender === undefined || !senderIsRun) return undefined; + const key = await reresolveRunSenderKey(authenticatedSender); + if (key === null) return undefined; + return { + type: "sender.key.refresh", + address: authenticatedSender, + publicKey: key, + }; + } + + let senderIdentities = entry.runGrants.senderIdentities; + if ( + senderIdentities === undefined && + authenticatedSender !== undefined && + senderIsRun + ) { + const key = await reresolveRunSenderKey(authenticatedSender); + senderIdentities = senderIdentitiesFromKey(authenticatedSender, key); + } + return { type: "run.grants", agentAddress: entry.agentAddress, runId: entry.runGrants.runId, stepGrants: entry.runGrants.stepGrants, - }); + ...(senderIdentities !== undefined ? { senderIdentities } : {}), + }; + } + + // Best-effort re-resolve of a run sender's hub-held key at replay time. Only + // called for a run-address sender, whose deployment key is immutable once + // acked, so the current key equals the signing-time key. Returns null when no + // resolver is wired or the sender has no durable key. + // + // This relies on `lookups.resolveSenderKey` being the BEST-EFFORT, + // NEVER-THROWS resolver (the contract at sidecar-events.ts:274-279, wired to + // resolveFrameSenderKey, which swallows faults to null). That contract is + // load-bearing here: `redeliverPendingMail` clears the retention TTL up-front + // and re-arms each entry's per-entry timer only on a successful send, so a + // resolver that THREW would abort the redeliver loop and strand the + // not-yet-processed entries with no timer and no TTL until a process restart. + // A strict/throwing resolver must NOT be wired here. Do not add a try/catch: + // the boundary owns the never-throws contract; duplicating it here would + // violate that ownership. The dispatch-time resolveSenderKey call + // (sendWorkflowRunDispatchToAllocation path) carries the same dependency + // note. + async function reresolveRunSenderKey( + authenticatedSender: string, + ): Promise { + const resolveSenderKey = lookups.resolveSenderKey; + if (resolveSenderKey === undefined) return null; + return resolveSenderKey(authenticatedSender); + } + + // Replay a pending mail's lead frame (its run grants or a re-resolved sender + // key) and then the mail itself over `conn`. Awaits the resolve FIRST, then + // sends the lead frame and the mail back-to-back with NO await between them, + // so the co-delivered key always precedes the mail on the FIFO socket. + // Returns whether the mail was (re)sent, so the caller re-arms the retry timer + // only for an entry it actually redelivered. + async function replaySendPendingMail( + conn: SidecarConnection, + entry: PendingMailEntry, + ): Promise { + const lead = await resolveReplayLeadFrame(entry); + // The resolve above may have awaited real I/O; during that gap a queued + // `mail.inbound.ack` can advance and run `resolvePendingMail` (delete + + // clearTimeout) on this entry. The window is opened by the timer-macrotask + // retry path, NOT by any bypass: `mail.inbound.ack` is a QUEUED frame + // (frameBypassesQueue returns false for it). It can interleave because the + // retry runs as an independent setTimeout macrotask (retryPendingMail), so + // the owning ws's message chain is free to advance the ack during the + // resolve await. On the reconnect/redeliver path the ack cannot interleave + // at all -- it queues behind the still-running reconnect handler on the + // same ws -- so here this guard is pure defense-in-depth. Re-confirm it is + // still the tracked entry before sending, or a post-ack redelivery would + // arm a retry timer on a detached entry. + if (pendingMail.get(entry.agentAddress)?.get(entry.messageId) !== entry) { + return false; + } + // The same gap can span a disconnect or a takeover that moves the address + // off `conn`. Sending on the stale conn would write to a dead socket and + // re-arm a retry that later drops a still-retained entry. Skip so the entry + // survives for the reconnect redelivery. + const ws = addressIndex.get(entry.agentAddress); + if (ws === undefined || connections.get(ws) !== conn) return false; + if (lead !== undefined) conn.send(lead); + conn.send(entry.frame); + return true; } function deletePendingMail( @@ -800,7 +995,10 @@ export function createSidecarRouter( } } - function retryPendingMail(agentAddress: string, messageId: string): void { + async function retryPendingMail( + agentAddress: string, + messageId: string, + ): Promise { const byId = pendingMail.get(agentAddress); if (byId === undefined) return; const entry = byId.get(messageId); @@ -845,13 +1043,9 @@ export function createSidecarRouter( return; } + if (!(await replaySendPendingMail(conn, entry))) return; entry.attempts += 1; - replayRunGrantsAhead(conn, entry); - conn.send(entry.frame); - entry.timer = setTimeout( - () => retryPendingMail(agentAddress, messageId), - mailAckRetryIntervalMs, - ); + entry.timer = scheduleMailRetry(agentAddress, messageId); } function resolvePendingMail(agentAddress: string, messageId: string): void { @@ -900,10 +1094,10 @@ export function createSidecarRouter( // (effectively-once) and processes one it had dropped (no loss). Re-arms the // connected-window retry over the new connection with a fresh per-generation // budget, so a redelivery that is itself dropped before its ack is retried. - function redeliverPendingMail( + async function redeliverPendingMail( agentAddress: string, conn: SidecarConnection, - ): void { + ): Promise { const retention = pendingMailRetention.get(agentAddress); if (retention !== undefined) { clearTimeout(retention); @@ -925,13 +1119,9 @@ export function createSidecarRouter( deletePendingMail(byId, agentAddress, entry.messageId); continue; } - replayRunGrantsAhead(conn, entry); - conn.send(entry.frame); + if (!(await replaySendPendingMail(conn, entry))) continue; entry.attempts = 0; - entry.timer = setTimeout( - () => retryPendingMail(agentAddress, entry.messageId), - mailAckRetryIntervalMs, - ); + entry.timer = scheduleMailRetry(agentAddress, entry.messageId); } if (byId.size > 0) { logger.info`Redelivered ${String(byId.size)} un-acked message(s) to ${agentAddress} on reconnect`; @@ -1072,14 +1262,16 @@ export function createSidecarRouter( switch (frame.type) { case "register": { const agentAddresses = frame.agentAddresses; + const cachedSenderAddresses = frame.cachedSenderAddresses ?? []; return authenticateHandshake(ws, frame, (identity) => - handleRegister(ws, identity, agentAddresses), + handleRegister(ws, identity, agentAddresses, cachedSenderAddresses), ); } case "reconnect": { const agentAddresses = frame.agentAddresses; + const cachedSenderAddresses = frame.cachedSenderAddresses ?? []; return authenticateHandshake(ws, frame, (identity) => - handleReconnect(ws, identity, agentAddresses), + handleReconnect(ws, identity, agentAddresses, cachedSenderAddresses), ); } case "agent.deploy.ack": @@ -1101,8 +1293,26 @@ export function createSidecarRouter( logger.warn`Dropping mail.outbound from ${frame.senderAddress}: not registered to this sidecar`; return; } + // The DoS backstop and the trust boundary for an untrusted sidecar's + // mail body: measure the true byte cost (a hostile sidecar can send + // multi-byte UTF-8, so `.length` would undercount) and drop an over-cap + // frame here before either delivery path allocates on it. The socket's + // maxPayloadLength has already closed the connection for a truly huge + // frame; this catches one between the mail cap and that ceiling. + const bodyBytes = Buffer.byteLength(frame.rawMessage, "utf8"); + if (bodyBytes > MAX_MAIL_OUTBOUND_BODY_BYTES) { + logger.warn`Dropping mail.outbound from ${frame.senderAddress}: rawMessage of ${String(bodyBytes)} bytes exceeds the ${String(MAX_MAIL_OUTBOUND_BODY_BYTES)}-byte cap`; + return; + } if (frame.delivered !== true) { - return handleMailOutbound(frame.rawMessage, frame.recipients); + // frame.senderAddress is the sender this connection was just gated + // on by connOwnsAddress above -- a hub-verified value. Thread it so + // the relayed inbound frame is stamped with it, not the MIME From. + return handleMailOutbound( + frame.rawMessage, + frame.senderAddress, + frame.recipients, + ); } if (lookups.persistMail) { return handleMailPersist( @@ -1179,10 +1389,10 @@ export function createSidecarRouter( case "signal.correlation.register": return handleSignalCorrelationRegister(ws, frame); case "session.ack": - resolvePending(frame.requestId); + pendingRequests.resolve(frame.requestId); return; case "session.error": - rejectPending(frame.requestId, frame.error); + pendingRequests.reject(frame.requestId, frame.error); return; case "repo.pack.ack": resolvePackPending(ws, frame); @@ -1272,6 +1482,7 @@ export function createSidecarRouter( ws: WsHandle, identity: SidecarAuthIdentity, agentAddresses: string[], + cachedSenderAddresses: string[], ): Promise { if (allocationFences.get(identity.allocationId) !== identity.generation) { logger.warn`Rejected allocated sidecar ${identity.sidecarId}: allocation ${identity.allocationId} generation ${String(identity.generation)} is not fenced as current`; @@ -1351,7 +1562,7 @@ export function createSidecarRouter( } allocatedConnections.set(identity.allocationId, { ws, identity }); for (const address of newlyRoutedAddresses) { - redeliverPendingMail(address, conn); + await redeliverPendingMail(address, conn); } // Reconcile a reconnecting deployment's credentials, closing the offline // window: a credential revoked, deleted, or rotated while the sidecar was @@ -1365,6 +1576,70 @@ export function createSidecarRouter( resyncCredentials(address); } } + // Reconcile the sidecar's cached sender keys, closing the offline window: a + // user-principal key that rotated while the sidecar was disconnected is + // re-resolved and re-pushed, and a sender whose principal was DELETED while + // the sidecar was disconnected is evicted, so the recipient stops verifying + // either against a key the hub no longer vouches for. Only allocated + // sidecars host a sender cache worth reconciling. Resolve and push + // SEQUENTIALLY in one detached task: registration is never blocked, and a + // large cache cannot fan out into one concurrent DB query per reported + // sender on every reconnect. + const resolveSenderKeyStrict = lookups.resolveSenderKeyStrict; + if (identity.kind === "allocated" && resolveSenderKeyStrict !== undefined) { + const rotatableSenders = new Set(cachedSenderAddresses); + // Resolve-don't-trust applied to input SIZE: bound the reported set before + // acting on it. Run addresses count toward the cap by design -- the + // isRunAddress skip below is inside the loop, so the iteration, and thus + // the DB resolves, can never exceed the cap regardless of the run/non-run + // mix. Over the cap, reconcile the first MAX_RESYNC_SENDER_ADDRESSES and + // log the overflow so a misbehaving sidecar is detectable. + let sendersToResync = [...rotatableSenders]; + if (sendersToResync.length > MAX_RESYNC_SENDER_ADDRESSES) { + logger.warn`Sidecar ${identity.sidecarId} reported ${String(sendersToResync.length)} cached sender addresses on allocation ${identity.allocationId} generation ${String(identity.generation)}, over the ${String(MAX_RESYNC_SENDER_ADDRESSES)} resync cap; reconciling the first ${String(MAX_RESYNC_SENDER_ADDRESSES)} and ignoring the rest`; + sendersToResync = sendersToResync.slice(0, MAX_RESYNC_SENDER_ADDRESSES); + } + void (async () => { + for (const address of sendersToResync) { + // The sidecar already reports only non-run senders, but do not trust + // the report: a run sender's key is the immutable + // workflow_run.public_key and is never refreshed or evicted, so skip + // it here too rather than couple correctness to the sidecar's filter. + if (isRunAddress(address)) continue; + // Tri-state, deleted-vs-fault distinguished by the STRICT resolver: + // - resolves to a key -> refresh the sidecar's cached key; + // - CONFIRMED null (no matching principal = a deleted sender) -> + // evict it; + // - THROWS (fault: ambiguous address, keyless-principal invariant + // break, DB error) -> keep the stale key, evict nothing. + // Never evicting on a fault is the load-bearing property: dropping a + // live key on a transient DB fault would be worse than doing nothing. + // Only the resolve is guarded here; conn.send stays outside so a + // socket-gone throw propagates to the outer catch and stops the loop. + let publicKey: string | null; + try { + publicKey = await resolveSenderKeyStrict(address); + } catch (cause) { + const message = + cause instanceof Error ? cause.message : String(cause); + logger.error`Keeping the stale cached key for ${address}: resolving it faulted (a fault, not a deleted sender): ${message}`; + continue; + } + if (publicKey !== null) { + conn.send({ type: "sender.key.refresh", address, publicKey }); + } else { + conn.send({ type: "sender.key.evict", address }); + } + } + })().catch((cause) => { + // The per-address resolve is guarded above, so the only throw reaching + // here is conn.send (JSON.stringify + the socket write) once the sidecar + // is gone. That means the connection left, so stop -- the remaining + // sends would fail the same way. + const message = cause instanceof Error ? cause.message : String(cause); + logger.warn`Sender-key resync for sidecar ${identity.sidecarId} stopped: ${message}`; + }); + } logger.info`Provisioned sidecar ${identity.sidecarId} registered for allocation ${identity.allocationId} generation ${String(identity.generation)}`; await notifyAllocationWaiters(identity.allocationId); if (identity.kind === "allocated") { @@ -1379,20 +1654,228 @@ export function createSidecarRouter( ws: WsHandle, identity: SidecarAuthIdentity, agentAddresses: string[], + cachedSenderAddresses: string[], ): Promise { - await handleAllocatedRegister(ws, identity, agentAddresses); + await handleAllocatedRegister( + ws, + identity, + agentAddresses, + cachedSenderAddresses, + ); } async function handleReconnect( ws: WsHandle, identity: SidecarAuthIdentity, agentAddresses: string[], + cachedSenderAddresses: string[], ): Promise { - await handleAllocatedRegister(ws, identity, agentAddresses); + await handleAllocatedRegister( + ws, + identity, + agentAddresses, + cachedSenderAddresses, + ); + } + + // Park a pre-ack sender's mail synchronously and return its entry. Registering + // the entry BEFORE the caller awaits `resolveSenderKey` is the interlock that + // guarantees a settle landing during the resolve has an entry to find: the + // event loop is single-threaded, so no settle can interleave between this + // synchronous registration and the caller's first await. + function parkDeferredSenderMail( + authenticatedSender: string, + rawMessage: string, + recipients: string[], + ): DeferredSenderMailEntry { + let parked = deferredSenderMail.get(authenticatedSender); + if (parked === undefined) { + parked = new Set(); + deferredSenderMail.set(authenticatedSender, parked); + } + const entry: DeferredSenderMailEntry = { + authenticatedSender, + rawMessage, + recipients, + timer: setTimeout(() => { + // TTL backstop for the case where a settle never arrives (the sender's + // deploy never acked and never failed loudly). Claim the entry and + // surface it as undelivered so the mail is not held forever. + if (!claimDeferredSenderEntry(entry)) return; + events.emit("mail.outbound.undelivered", { + rawMessage: entry.rawMessage, + recipients: entry.recipients, + }); + logger.warn`Dropping mail from ${entry.authenticatedSender}: its sender key was not recorded before the deferred-mail TTL expired`; + }, disconnectQueueTTLMs), + }; + parked.add(entry); + return entry; + } + + // Remove one parked entry by identity, clearing its TTL timer. Returns whether + // THIS call removed it. The inline-deliver path, a settle, and the TTL all + // race to claim the same entry; only the claimer acts on it, so a claim that + // finds nothing (already claimed) is a no-op. This is the idempotent + // remove-by-key that keeps a settle and the inline non-null branch from both + // delivering the same message. + function claimDeferredSenderEntry(entry: DeferredSenderMailEntry): boolean { + const parked = deferredSenderMail.get(entry.authenticatedSender); + if (parked === undefined) return false; + const claimed = parked.delete(entry); + if (!claimed) return false; + clearTimeout(entry.timer); + if (parked.size === 0) deferredSenderMail.delete(entry.authenticatedSender); + return true; + } + + // Claim every entry parked for a sender, clearing their TTL timers. A later + // settle or TTL for the same sender then finds nothing. + function claimAllDeferredSenderMail( + authenticatedSender: string, + ): DeferredSenderMailEntry[] { + const parked = deferredSenderMail.get(authenticatedSender); + if (parked === undefined) return []; + deferredSenderMail.delete(authenticatedSender); + const entries = [...parked]; + for (const entry of entries) clearTimeout(entry.timer); + return entries; + } + + function drainDeferredSenderMail( + authenticatedSender: string, + reason: string, + ): void { + const entries = claimAllDeferredSenderMail(authenticatedSender); + if (entries.length === 0) return; + for (const entry of entries) { + events.emit("mail.outbound.undelivered", { + rawMessage: entry.rawMessage, + recipients: entry.recipients, + }); + } + logger.warn`Dropping ${String(entries.length)} deferred message(s) from ${authenticatedSender}: ${reason}`; + } + + function noteSenderDeployStarted(address: string): void { + allocatedKeyRecordInFlight.add(address); + } + + function noteSenderDeploySettled( + address: string, + outcome: SenderDeploySettledOutcome, + ): void { + allocatedKeyRecordInFlight.delete(address); + if ("failed" in outcome) { + drainDeferredSenderMail( + address, + `sender deploy failed: ${outcome.failed}`, + ); + return; + } + for (const entry of claimAllDeferredSenderMail(address)) { + // Re-drive delivery as its OWN task, off the settle's stack, so delivery + // work never runs on the deploy-ack handler's stack or reorders against + // it. The re-drive re-enters handleMailOutbound; the key is recorded (the + // durable write happens-before this settle), so it resolves the sender key + // and delivers inline. + void Promise.resolve() + .then(() => + handleMailOutbound( + entry.rawMessage, + entry.authenticatedSender, + entry.recipients, + ), + ) + .catch((err: unknown) => { + logger.error`Re-driving deferred mail from ${entry.authenticatedSender} failed: ${err instanceof Error ? err.message : String(err)}`; + }); + } + } + + function senderIdentitiesFromKey( + address: string, + publicKey: string | null, + ): RunGrantsFrame["senderIdentities"] { + return publicKey !== null ? [{ address, publicKey }] : undefined; + } + + // Resolve the co-delivered sender identities for a message, applying the + // register-before-read interlock for a pre-ack run sender. Returns either + // `deliver: true` with the resolved identities (undefined when there is no + // resolvable key), or `deliver: false` when the message is parked and will be + // driven later by a settle (`noteSenderDeploySettled`) or the TTL. + async function resolveSenderIdentitiesOrPark( + rawMessage: string, + authenticatedSender: string, + recipients: string[], + ): Promise< + | { deliver: true; senderIdentities: RunGrantsFrame["senderIdentities"] } + | { deliver: false } + > { + const resolveSenderKey = lookups.resolveSenderKey; + if (resolveSenderKey === undefined) + return { deliver: true, senderIdentities: undefined }; + + // The co-delivered key is consumed only by a run recipient caching it from the + // run.grants frame. Purely external/federated mail never uses it and is never + // locally verified, so resolve nothing and never park it. + if (!recipients.some(isRunAddress)) + return { deliver: true, senderIdentities: undefined }; + + // A stable-key (non-run) sender has no pre-ack window; resolve inline. + if (!isRunAddress(authenticatedSender)) { + const key = await resolveSenderKey(authenticatedSender); + return { + deliver: true, + senderIdentities: senderIdentitiesFromKey(authenticatedSender, key), + }; + } + + // Park a run sender ONLY while a key-record settle is guaranteed to arrive -- a + // deploy is in flight. Without one, a null resolve is a transient fault or a + // genuine absence on an already-settled run: no settle is coming, so parking + // would strand the mail to the TTL. Deliver on the normal path instead. + const settleGuaranteed = + pendingDeploys.has(authenticatedSender) || + allocatedKeyRecordInFlight.has(authenticatedSender); + if (!settleGuaranteed) { + const key = await resolveSenderKey(authenticatedSender); + return { + deliver: true, + senderIdentities: senderIdentitiesFromKey(authenticatedSender, key), + }; + } + + // Register-before-read: park a waiter entry synchronously (NO await) so a + // settle that lands while we resolve below has an entry to find, THEN + // resolve. The single-threaded event loop cannot interleave a settle between + // this registration and the await. + const entry = parkDeferredSenderMail( + authenticatedSender, + rawMessage, + recipients, + ); + const key = await resolveSenderKey(authenticatedSender); + if (key === null) { + // Not recorded yet. Leave the entry parked; a settle or the TTL drives it. + return { deliver: false }; + } + // The key was already recorded before we parked. Claim our entry and deliver + // inline -- unless a concurrent settle already claimed it and is re-driving + // this message, in which case claiming fails and we must NOT deliver again. + if (!claimDeferredSenderEntry(entry)) { + return { deliver: false }; + } + return { + deliver: true, + senderIdentities: senderIdentitiesFromKey(authenticatedSender, key), + }; } async function handleMailOutbound( rawMessage: string, + authenticatedSender: string, recipients: string[], ): Promise { // A mail addressed to more than one workflow deployment would birth a @@ -1420,6 +1903,22 @@ export function createSidecarRouter( } } + // Resolve the sender's hub-held key ONCE for the whole message, ahead of the + // recipient fan-out and any grant materialization, so every recipient in + // this fan-out binds the same key snapshot. A run-address sender may be + // pre-ack -- it minted its keypair locally and can send before the hub + // records its public key. The register-before-read interlock holds such mail + // until the key lands rather than delivering it keyless, which a strict + // recipient drops as an unknown sender. A parked message returns here and is + // re-driven later by a settle or the TTL. + const resolution = await resolveSenderIdentitiesOrPark( + rawMessage, + authenticatedSender, + recipients, + ); + if (!resolution.deliver) return; + const senderIdentities = resolution.senderIdentities; + // Route to locally connected sidecars first, then try disconnect queues. const unrouted: string[] = []; for (const recipient of recipients) { @@ -1428,7 +1927,12 @@ export function createSidecarRouter( // co-recipients. The catch fails THIS recipient closed (its run never // starts under-authorized) and continues to the rest. try { - const outcome = await deliverMailToRecipient(recipient, rawMessage); + const outcome = await deliverMailToRecipient( + recipient, + rawMessage, + authenticatedSender, + senderIdentities, + ); if (outcome === "unrouted") unrouted.push(recipient); } catch (err) { logger.error`Failed to deliver mail to ${recipient}: ${err instanceof Error ? err.message : String(err)}`; @@ -1466,6 +1970,8 @@ export function createSidecarRouter( async function deliverMailToRecipient( recipient: string, rawMessage: string, + authenticatedSender: string, + senderIdentities: RunGrantsFrame["senderIdentities"], ): Promise<"routed" | "unrouted" | "failed-closed"> { if ( lookups.materializeMailTriggeredRunGrants !== undefined && @@ -1493,11 +1999,22 @@ export function createSidecarRouter( return "failed-closed"; } if (result.outcome === "materialized") { + // The sender's hub-held key was resolved ONCE in handleMailOutbound, + // ahead of this fan-out, and threaded in as `senderIdentities`. Co- + // deliver it on the run's grants barrier so a recipient that caches from + // the `run.grants` frame binds the sender address to the key and can + // verify the sender's mail locally. A null key is never carried (the + // list is undefined then), so the "authorized-with-a-key implies key + // cached" invariant holds; a recipient with no cached key resolves such + // mail as `unknown`, which its admission policy rejects by default (a + // workflow may relax `unknown` to admit). // Send the run's grants ahead of the mail. A `false` here means the // deployment is unroutable. Do not route the mail that would dispatch // it; the grants-only reservation remains the canonical snapshot for a // later first-delivery attempt. - if (!sendRunGrants(recipient, runId, result.stepGrants)) { + if ( + !sendRunGrants(recipient, runId, result.stepGrants, senderIdentities) + ) { logger.error`Deployment ${recipient} is not routable for run ${runId}; retaining the unfired run's grant reservation for retry`; return "unrouted"; } @@ -1515,8 +2032,13 @@ export function createSidecarRouter( const outcome: "routed" | "unrouted" = routeMail( recipient, rawMessage, + authenticatedSender, messageId, - { runId, stepGrants: result.stepGrants }, + { + runId, + stepGrants: result.stepGrants, + ...(senderIdentities !== undefined ? { senderIdentities } : {}), + }, ) ? "routed" : "unrouted"; @@ -1527,7 +2049,9 @@ export function createSidecarRouter( // authorize. No run is committed here, so no ack handshake is needed. } - return routeMail(recipient, rawMessage) ? "routed" : "unrouted"; + return routeMail(recipient, rawMessage, authenticatedSender) + ? "routed" + : "unrouted"; } async function handleMailPersist( @@ -1691,50 +2215,30 @@ export function createSidecarRouter( // Drop the per-ws serialization chain; no more frames will queue on it. messageChains.delete(ws); - // Reject any in-flight requests that were sent to this sidecar. - for (const [requestId, req] of pending) { - if (req.ws !== ws) continue; - clearTimeout(req.timer); - pending.delete(requestId); - req.reject(`Sidecar ${conn.sidecarId} disconnected`); - } - + // Reject any in-flight requests that were sent to this sidecar. Each + // entry's reject closure runs its own per-site cleanup (the deploy and + // undeploy closures roll routing back), exactly as a frame-error + // rejection would. + pendingRequests.rejectAllForWs( + ws, + `Sidecar ${conn.sidecarId} disconnected`, + ); // Reject every deploy issued on this socket, including allocated // workflow deployments stored in `workflowAddresses` rather than // `agentAddresses`. - for (const [agentAddress, req] of pendingDeploys) { - if (req.ws !== ws) continue; - clearTimeout(req.timer); - pendingDeploys.delete(agentAddress); - req.reject(`Sidecar ${conn.sidecarId} disconnected`); - } - + pendingDeploys.rejectAllForWs(ws, `Sidecar ${conn.sidecarId} disconnected`); // Reject any in-flight pack transfers for this sidecar. - for (const [transferId, pack] of pendingPacks) { - if (pack.ws !== ws) continue; - clearTimeout(pack.timer); - pendingPacks.delete(transferId); - pack.reject(`Sidecar ${conn.sidecarId} disconnected`); - } - + pendingPacks.rejectAllForWs(ws, `Sidecar ${conn.sidecarId} disconnected`); // Reject any in-flight undeploys for this sidecar. - for (const [addr, req] of pendingUndeploys) { - if (req.ws !== ws) continue; - clearTimeout(req.timer); - pendingUndeploys.delete(addr); - req.reject(`Sidecar ${conn.sidecarId} disconnected`); - } - + pendingUndeploys.rejectAllForWs( + ws, + `Sidecar ${conn.sidecarId} disconnected`, + ); // Reject any in-flight probes sent to this sidecar. A probe never enters // the address maps, so this ws-keyed sweep is its ONLY disconnect cleanup: // without it a probe whose sidecar drops mid-flight would hang until its // own timeout instead of failing fast on the disconnect. - for (const [requestId, probe] of pendingProbes) { - if (probe.ws !== ws) continue; - clearTimeout(probe.timer); - pendingProbes.delete(requestId); - probe.reject(`Sidecar ${conn.sidecarId} disconnected`); - } + pendingProbes.rejectAllForWs(ws, `Sidecar ${conn.sidecarId} disconnected`); // Fail any in-flight loopback logins on this sidecar the same way: the // login dies with its callback server, so a disconnect is terminal even @@ -1796,55 +2300,34 @@ export function createSidecarRouter( const frame = buildFrame(requestId); return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pending.delete(requestId); - reject( - new Error( - `Request ${requestId} timed out after ${requestTimeoutMs}ms`, - ), - ); - }, requestTimeoutMs); - - pending.set(requestId, { + pendingRequests.register( requestId, ws, - resolve, - reject(error: string) { - reject(new Error(error)); + { + timeoutMs: requestTimeoutMs, + timeoutMessage: `Request ${requestId} timed out after ${requestTimeoutMs}ms`, + resolve, + reject(error: string) { + reject(new Error(error)); + }, }, - timer, - }); + undefined, + ); conn.send(frame); }); } - function resolvePending(requestId: string): void { - const req = pending.get(requestId); - if (req === undefined) return; - clearTimeout(req.timer); - pending.delete(requestId); - req.resolve(); - } - - function rejectPending(requestId: string, error: string): void { - const req = pending.get(requestId); - if (req === undefined) return; - clearTimeout(req.timer); - pending.delete(requestId); - req.reject(error); - } - function packResponseMatches( - entry: PendingPack, + entry: PendingEntry, ws: WsHandle, frame: PackAckFrame | PackRejectFrame, ): boolean { return ( entry.ws === ws && - entry.agentAddress === frame.agentAddress && - entry.repoId.kind === frame.repoId.kind && - entry.repoId.id === frame.repoId.id + entry.meta.agentAddress === frame.agentAddress && + entry.meta.repoId.kind === frame.repoId.kind && + entry.meta.repoId.id === frame.repoId.id ); } @@ -1855,9 +2338,7 @@ export function createSidecarRouter( logger.warn`Ignoring repo.pack.ack for transfer ${frame.transferId} from a connection that does not own the pending transfer`; return; } - clearTimeout(entry.timer); - pendingPacks.delete(frame.transferId); - entry.resolve(); + pendingPacks.resolve(frame.transferId); } function rejectPackPending(ws: WsHandle, frame: PackRejectFrame): void { @@ -1867,14 +2348,17 @@ export function createSidecarRouter( logger.warn`Ignoring repo.pack.reject for transfer ${frame.transferId} from a connection that does not own the pending transfer`; return; } - clearTimeout(entry.timer); - pendingPacks.delete(frame.transferId); // Surface the receiver's specific cause when it carried one, so the awaiting - // push sees "corrupt: " rather than only the coarse reason. - entry.reject( - frame.detail !== undefined - ? `${frame.reason}: ${frame.detail}` - : frame.reason, + // push sees "corrupt: " rather than only the coarse reason. The + // "Pack rejected:" prefix is applied here rather than in the entry's + // reject closure because a TIMEOUT rejection must not carry it. + pendingPacks.reject( + frame.transferId, + `Pack rejected: ${ + frame.detail !== undefined + ? `${frame.reason}: ${frame.detail}` + : frame.reason + }`, ); } @@ -1885,9 +2369,7 @@ export function createSidecarRouter( return; } if (req.ws !== ws) return; - clearTimeout(req.timer); - pendingUndeploys.delete(agentAddress); - req.resolve(); + pendingUndeploys.resolve(agentAddress); } function rejectUndeployPending( @@ -1898,9 +2380,7 @@ export function createSidecarRouter( const req = pendingUndeploys.get(agentAddress); if (req === undefined) return; if (req.ws !== ws) return; - clearTimeout(req.timer); - pendingUndeploys.delete(agentAddress); - req.reject(error); + pendingUndeploys.reject(agentAddress, error); } function resolveProbe( @@ -1910,17 +2390,13 @@ export function createSidecarRouter( ): void { const req = pendingProbes.get(requestId); if (req === undefined || req.ws !== ws) return; - clearTimeout(req.timer); - pendingProbes.delete(requestId); - req.resolve(result); + pendingProbes.resolve(requestId, result); } function rejectProbe(ws: WsHandle, requestId: string, error: string): void { const req = pendingProbes.get(requestId); if (req === undefined || req.ws !== ws) return; - clearTimeout(req.timer); - pendingProbes.delete(requestId); - req.reject(error); + pendingProbes.reject(requestId, error); } // Routing rule: pick the receiver dedicated to the repoId.kind the @@ -2352,26 +2828,19 @@ export function createSidecarRouter( // Register pending entry before sending frames so that a synchronous // repo.pack.ack (e.g. in tests or loopback transports) resolves correctly. return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pendingPacks.delete(transferId); - reject( - new Error( - `Pack transfer ${transferId} timed out after ${PACK_TIMEOUT_MS}ms`, - ), - ); - }, PACK_TIMEOUT_MS); - - pendingPacks.set(transferId, { + pendingPacks.register( transferId, ws, - agentAddress, - repoId, - resolve, - reject(error: string) { - reject(new Error(`Pack rejected: ${error}`)); + { + timeoutMs: PACK_TIMEOUT_MS, + timeoutMessage: `Pack transfer ${transferId} timed out after ${PACK_TIMEOUT_MS}ms`, + resolve, + reject(error: string) { + reject(new Error(error)); + }, }, - timer, - }); + { agentAddress, repoId }, + ); // Send chunks for (const chunk of chunkPack(pack)) { @@ -2429,8 +2898,11 @@ export function createSidecarRouter( pack: Uint8Array, ref: string, commitSha: string, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const { ws, conn } = await getAllocatedConnection(target, "routing"); + signal?.throwIfAborted(); if (agentAddress !== conn.identity.workflowRunAddress) { throw new Error( `Allocation ${target.allocationId} cannot restore unrelated address ${agentAddress}`, @@ -2452,9 +2924,23 @@ export function createSidecarRouter( function routeMail( agentAddress: string, rawMessage: string, + authenticatedSender: string, messageId?: string, - runGrants?: { runId: string; stepGrants: RunGrantsFrame["stepGrants"] }, + runGrants?: { + runId: string; + stepGrants: RunGrantsFrame["stepGrants"]; + senderIdentities?: RunGrantsFrame["senderIdentities"]; + }, ): boolean { + // `authenticatedSender` is hub-assigned by the caller from a hub-verified + // value (the ownership-gated sender of a relayed mail, or the triggering + // principal's address) -- never the message's own MIME `From`. It rides + // the frame as the hub-verified sender of record, so a recipient can take + // the sender from it rather than the forgeable `From`. The recipient's + // signature check reads it as the sender of record -- resolving the + // sender's key from its local cache to verify the signature -- and its + // admission policy gates delivery on the verdict. + // // Carry the hub-minted messageId on the frame so the sidecar's durable- // receipt ack (`mail.inbound.ack`) keys on the same id the hub tracks, and // a redelivery replays identical bytes for the downstream RunStarted dedup. @@ -2465,6 +2951,7 @@ export function createSidecarRouter( type: "mail.inbound", agentAddress, rawMessage, + authenticatedSender, ...(messageId !== undefined ? { messageId } : {}), }; const ws = addressIndex.get(agentAddress); @@ -2493,12 +2980,14 @@ export function createSidecarRouter( agentAddress: string, runId: string, stepGrants: RunGrantsFrame["stepGrants"], + senderIdentities: RunGrantsFrame["senderIdentities"], ): boolean { const frame: HubFrame = { type: "run.grants", agentAddress, runId, stepGrants, + ...(senderIdentities !== undefined ? { senderIdentities } : {}), }; const ws = addressIndex.get(agentAddress); @@ -2528,6 +3017,7 @@ export function createSidecarRouter( runId: string, stepGrants: RunGrantsFrame["stepGrants"], rawMessage: string, + authenticatedSender: string, messageId: string, ): Promise { const { ws, conn } = await getAllocatedConnection(target, "routing"); @@ -2536,17 +3026,52 @@ export function createSidecarRouter( `Address ${agentAddress} is not routed on allocation ${target.allocationId}`, ); } - const runGrants = { runId, stepGrants }; + // authenticatedSender is the sender persisted at enqueue on the dispatch + // row (the triggering principal's hub-verified address); the caller reads + // it from that row. It is never the message's MIME From. + // + // Resolve its key here, at dispatch (redelivery) time, from that persisted + // address, to co-deliver on the run's grants barrier so the recipient + // caches the sender's current hub-held key. A run sender's deployment key + // is immutable once acked; a user sender's key rotating mid-flight would + // leave the fixed signed bytes checked against the new key, which the + // recipient logs as unverifiable. Null when unresolvable (no resolver + // wired, or the sender has no durable key). The lookup contract (see + // SidecarLookups.resolveSenderKey) is best-effort and never throws, so + // resolving ahead of the run.grants send cannot block it. + const authenticatedSenderPublicKey = + lookups.resolveSenderKey !== undefined + ? await lookups.resolveSenderKey(authenticatedSender) + : null; + // Co-deliver the resolved key on the run's grants barrier, omitting a null + // key so it is never cached (see deliverMailToRecipient). The same list + // rides the pending-mail entry so the reconnect replay carries it too. + const senderIdentities = + authenticatedSenderPublicKey !== null + ? [ + { + address: authenticatedSender, + publicKey: authenticatedSenderPublicKey, + }, + ] + : undefined; + const runGrants = { + runId, + stepGrants, + ...(senderIdentities !== undefined ? { senderIdentities } : {}), + }; conn.send({ type: "run.grants", agentAddress, runId, stepGrants, + ...(senderIdentities !== undefined ? { senderIdentities } : {}), }); const frame: HubFrame = { type: "mail.inbound", agentAddress, rawMessage, + authenticatedSender, messageId, }; conn.send(frame); @@ -2581,28 +3106,14 @@ export function createSidecarRouter( : {}), }); } catch (err) { - rejectDeployPending( - req, + pendingDeploys.reject( + frame.agentAddress, `Failed to store public key: ${err instanceof Error ? err.message : String(err)}`, ); return; } } - resolveDeployPending(req, frame.publicKey); - } - - function resolveDeployPending(req: PendingDeploy, publicKey: string): void { - if (pendingDeploys.get(req.agentAddress) !== req) return; - clearTimeout(req.timer); - pendingDeploys.delete(req.agentAddress); - req.resolve(publicKey); - } - - function rejectDeployPending(req: PendingDeploy, error: string): void { - if (pendingDeploys.get(req.agentAddress) !== req) return; - clearTimeout(req.timer); - pendingDeploys.delete(req.agentAddress); - req.reject(error); + pendingDeploys.resolve(frame.agentAddress, frame.publicKey); } function rejectDeployPendingFromFrame( @@ -2612,7 +3123,9 @@ export function createSidecarRouter( ): void { const req = pendingDeploys.get(agentAddress); if (req === undefined || req.ws !== ws) return; - rejectDeployPending(req, error); + // Settle by key, not by the `req` object: a key lookup observes the + // CURRENT entry, so a stale handle cannot settle a replaced round-trip. + pendingDeploys.reject(agentAddress, error); } function sendAgentDeployOnConnection( @@ -2644,35 +3157,37 @@ export function createSidecarRouter( addressIndex.set(agentAddress, ws); return new Promise<{ publicKey: string }>((resolve, reject) => { - const timer = setTimeout(() => { - pendingDeploys.delete(agentAddress); - if (addressIndex.get(agentAddress) === ws) { - addressSet.delete(agentAddress); - addressIndex.delete(agentAddress); - } - reject( - deployFrameFailure( - `Deploy of "${agentAddress}" timed out after ${requestTimeoutMs}ms`, - true, - ), - ); - }, requestTimeoutMs); - - pendingDeploys.set(agentAddress, { + // Timeout and frame-error rejections share this closure, so the routing + // rollback and the `frameSent: true` tag live in one place. + pendingDeploys.register( agentAddress, ws, - resolve(publicKey) { - resolve({ publicKey }); - }, - reject(error: string) { - if (addressIndex.get(agentAddress) === ws) { - addressSet.delete(agentAddress); - addressIndex.delete(agentAddress); - } - reject(deployFrameFailure(error, true)); + { + timeoutMs: requestTimeoutMs, + timeoutMessage: `Deploy of "${agentAddress}" timed out after ${requestTimeoutMs}ms`, + resolve(publicKey) { + resolve({ publicKey }); + }, + reject(error: string) { + if (addressIndex.get(agentAddress) === ws) { + addressSet.delete(agentAddress); + addressIndex.delete(agentAddress); + } + // A non-allocated deployment's key is recorded by the deploy-ack + // projection, whose failure (reject/timeout/agent.error/disconnect) + // is observed only here. Drain any pre-ack sender mail parked on + // this address so it surfaces as undelivered rather than waiting out + // the TTL. An allocated deployment's failure is drained by its + // session-service owner instead, so skip it here to keep one owner + // per case. + if (conn.identity.kind !== "allocated") { + drainDeferredSenderMail(agentAddress, `deploy failed: ${error}`); + } + reject(deployFrameFailure(error, true)); + }, }, - timer, - }); + undefined, + ); try { conn.send({ @@ -2685,9 +3200,11 @@ export function createSidecarRouter( }); } catch (err) { // A synchronous send failure means the frame never reached the wire. - // Tear down the pending entry and timer we just registered, and reject - // as not-sent so a caller may safely roll back what it staged. - clearTimeout(timer); + // Drop the pending entry (and its armed timer) and reject as not-sent + // so a caller may safely roll back what it staged. The drop bypasses + // the entry's reject closure: this failure must report + // `frameSent: false`, and the timer must not fire later and + // double-reject. pendingDeploys.delete(agentAddress); if (addressIndex.get(agentAddress) === ws) { addressSet.delete(agentAddress); @@ -2708,8 +3225,11 @@ export function createSidecarRouter( agentAddress: string, harnessConfig: HarnessConfig, workflow?: AgentDeployFrame["workflow"], + signal?: AbortSignal, ): Promise<{ publicKey: string }> { + signal?.throwIfAborted(); const { ws, conn } = await getAllocatedConnection(target, "routing"); + signal?.throwIfAborted(); if (agentAddress !== conn.identity.workflowRunAddress) { throw new Error( `Allocation ${target.allocationId} cannot deploy unrelated address ${agentAddress}`, @@ -2759,29 +3279,25 @@ export function createSidecarRouter( const hubKey = hubPublicKeyHex; return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pendingDeploys.delete(agentAddress); - reject( - new Error( - `Step provision of "${agentAddress}" timed out after ${requestTimeoutMs}ms`, - ), - ); - }, requestTimeoutMs); // The sidecar's `agent.deploy.ack` resolves this through - // `resolveDeployPending` -> `req.resolve()`. The per-step address is - // workflow-derived and records no hub-side key, so the ack's public - // key is not needed and this resolves void. - pendingDeploys.set(agentAddress, { + // `pendingDeploys.resolve`. The per-step address is workflow-derived + // and records no hub-side key, so the ack's public key is not needed + // and this resolves void. + pendingDeploys.register( agentAddress, ws, - resolve(_publicKey) { - resolve(); - }, - reject(error: string) { - reject(new Error(error)); + { + timeoutMs: requestTimeoutMs, + timeoutMessage: `Step provision of "${agentAddress}" timed out after ${requestTimeoutMs}ms`, + resolve(_publicKey) { + resolve(); + }, + reject(error: string) { + reject(new Error(error)); + }, }, - timer, - }); + undefined, + ); conn.send({ type: "agent.deploy", @@ -2816,22 +3332,19 @@ export function createSidecarRouter( const requestId = nextRequestId(); return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pendingProbes.delete(requestId); - reject( - new Error(`Probe ${requestId} timed out after ${probeTimeoutMs}ms`), - ); - }, probeTimeoutMs); - - pendingProbes.set(requestId, { + pendingProbes.register( requestId, ws, - resolve, - reject(error: string) { - reject(new Error(error)); + { + timeoutMs: probeTimeoutMs, + timeoutMessage: `Probe ${requestId} timed out after ${probeTimeoutMs}ms`, + resolve, + reject(error: string) { + reject(new Error(error)); + }, }, - timer, - }); + undefined, + ); conn.send({ type: "workflow.probe.request", @@ -2993,29 +3506,25 @@ export function createSidecarRouter( } return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pendingUndeploys.delete(agentAddress); - removeAgentAddress(ws, agentAddress); - reject( - new Error( - `Undeploy of "${agentAddress}" timed out after ${requestTimeoutMs}ms`, - ), - ); - }, requestTimeoutMs); - - pendingUndeploys.set(agentAddress, { + // Timeout, ack, and error rejection share one closure so the routing + // teardown runs exactly once no matter how the round-trip settles. + pendingUndeploys.register( agentAddress, ws, - resolve() { - removeAgentAddress(ws, agentAddress); - resolve(); + { + timeoutMs: requestTimeoutMs, + timeoutMessage: `Undeploy of "${agentAddress}" timed out after ${requestTimeoutMs}ms`, + resolve() { + removeAgentAddress(ws, agentAddress); + resolve(); + }, + reject(error: string) { + removeAgentAddress(ws, agentAddress); + reject(new Error(error)); + }, }, - reject(error: string) { - removeAgentAddress(ws, agentAddress); - reject(new Error(error)); - }, - timer, - }); + undefined, + ); conn.send({ type: "agent.undeploy", @@ -3199,8 +3708,9 @@ export function createSidecarRouter( handleClose, routeMail, sendRunGrants, + noteSenderDeployStarted, + noteSenderDeploySettled, sendProbeToAllocation, - requestOAuthLogin, disconnectAllocation, sendAgentUndeploy, sendSourcesUpdate, @@ -3226,6 +3736,7 @@ export function createSidecarRouter( getConnectedSidecars, getRoutableAddresses, getConnectorState, + requestOAuthLogin, events, }; } diff --git a/vendor/intx/inference/src/providers/anthropic.ts b/vendor/intx/inference/src/providers/anthropic.ts index 4db92e31a2..fe9fa2ab7d 100644 --- a/vendor/intx/inference/src/providers/anthropic.ts +++ b/vendor/intx/inference/src/providers/anthropic.ts @@ -570,12 +570,15 @@ const MessageStart = type({ const MessageStop = type({ type: "'message_stop'" }); const Ping = type({ type: "'ping'" }); -const AnthropicSSEEvent = ContentBlockDelta.or(ContentBlockStart) - .or(ContentBlockStop) - .or(MessageDelta) - .or(MessageStart) - .or(MessageStop) - .or(Ping); +const AnthropicSSEEvent = type.or( + ContentBlockDelta, + ContentBlockStart, + ContentBlockStop, + MessageDelta, + MessageStart, + MessageStop, + Ping, +); // Maps Anthropic's wire usage object onto the internal TokenUsage. Anthropic // never reports a distinct thinking-token count, so `thinking` is always 0. diff --git a/vendor/intx/mailbox/src/fetch.ts b/vendor/intx/mailbox/src/fetch.ts index 792c7d6e9e..6e9b08fae1 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 efc103b749..15d11b1a7b 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.ts b/vendor/intx/mailbox/src/verify-signature.ts new file mode 100644 index 0000000000..565387075e --- /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/mime.ts b/vendor/intx/mime/src/mime.ts index adb9021f09..f842b2594d 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/types/src/agent-address.ts b/vendor/intx/types/src/agent-address.ts index 2af45e5601..56610d931e 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/base64.ts b/vendor/intx/types/src/base64.ts index 5158372ce6..7e7221f30a 100644 --- a/vendor/intx/types/src/base64.ts +++ b/vendor/intx/types/src/base64.ts @@ -4,12 +4,29 @@ // Centralizing here keeps the encoding stable across the sidecar/hub // boundary and any other consumer that needs the same wire shape. +// Bytes per encoding chunk. Must be a multiple of 3 so each chunk except the +// last encodes to whole base64 groups with no padding, letting the per-chunk +// outputs be joined without re-encoding. The bound keeps intermediates small +// even for a body at the 44 MB `mail.outbound` cap, which the unchunked +// per-byte string build inflated to multi-GB RSS. +const BASE64_CHUNK_BYTES = 24576; + +function encodeChunk(bytes: Uint8Array, start: number, end: number): string { + const chars = new Array(end - start); + let i = 0; + for (const byte of bytes.subarray(start, end)) { + chars[i++] = String.fromCharCode(byte); + } + return btoa(chars.join("")); +} + export function base64Encode(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) { - binary += String.fromCharCode(byte); + const parts: string[] = []; + for (let start = 0; start < bytes.length; start += BASE64_CHUNK_BYTES) { + const end = Math.min(start + BASE64_CHUNK_BYTES, bytes.length); + parts.push(encodeChunk(bytes, start, end)); } - return btoa(binary); + return parts.join(""); } export function base64Decode(base64: string): Uint8Array { diff --git a/vendor/intx/types/src/grants.ts b/vendor/intx/types/src/grants.ts index 99f64a6843..723ec57891 100644 --- a/vendor/intx/types/src/grants.ts +++ b/vendor/intx/types/src/grants.ts @@ -112,3 +112,25 @@ export const GrantRequirement = type({ "conditions?": "Record | null", }); export type GrantRequirement = typeof GrantRequirement.infer; + +export const ApprovalItem = type("string").or(GrantRequirement); + +/** + * One thing an operator approved for a workflow deployment. The approved + * surface has two kinds of entry and they live on different axes, so the + * vocabulary carries both rather than flattening one into the other: + * + * - A grant-shape string (`tool:`, `effect:`, + * `inference.source::`, ...) -- what the deploy-time + * capability walk surfaces per step. + * - A `GrantRequirement` -- what the definition declares, and what the run + * path materializes into real grant rows on the run principal. It is not + * expressible as a grant string: `source` names WHOSE authority is + * delegated, which is not a resource/action fact, and `conditions` is an + * open record. + * + * Keeping both in one vocabulary keeps "what the operator approved" a single + * record with a single gate, and lets the gate compare a declared requirement + * to an approved one structurally rather than through a lossy string form. + */ +export type ApprovalItem = typeof ApprovalItem.infer; diff --git a/vendor/intx/types/src/runtime.ts b/vendor/intx/types/src/runtime.ts index deb3239612..c71574348b 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,13 +1441,13 @@ export const InferenceEvent = type({ partial: PartialMessage, "index?": "number", }, - }) - .or({ + }, + { type: "'inference.usage'", seq: "number", data: { usage: TokenUsage, source: LastCycleSource }, - }) - .or({ + }, + { type: "'inference.done'", seq: "number", data: { @@ -1390,13 +1456,13 @@ export const InferenceEvent = type({ source: LastCycleSource, "pacingDelayMs?": "number", }, - }) - .or({ + }, + { type: "'inference.error'", seq: "number", data: { error: InferenceError, partial: PartialMessage }, - }) - .or({ + }, + { type: "'inference.retry'", seq: "number", data: { @@ -1404,8 +1470,8 @@ export const InferenceEvent = type({ delayMs: "number", previousError: InferenceError, }, - }) - .or({ + }, + { type: "'inference.citation'", seq: "number", // `index`, when present, names the source content block (typically @@ -1417,8 +1483,8 @@ export const InferenceEvent = type({ // `content[]` and consumers attribute them to the nearest // preceding TextBlock per the CitationBlock docstring. data: { citation: CitationBlock, "index?": "number" }, - }) - .or({ + }, + { type: "'inference.safety_rating'", seq: "number", // Prompt-level structured safety signal (observed Gemini @@ -1426,13 +1492,13 @@ export const InferenceEvent = type({ // capture has zero candidates. Harness appends the block to the // finalized turn's `content[]`. data: { safetyRating: SafetyRatingBlock }, - }) - .or({ + }, + { type: "'inference.code_execution.start'", seq: "number", data: { request: CodeExecutionRequestBlock, "index?": "number" }, - }) - .or({ + }, + { type: "'inference.code_execution.delta'", seq: "number", // requestId correlates fragments back to the originating @@ -1447,13 +1513,13 @@ export const InferenceEvent = type({ codeFragment: "string", "index?": "number", }, - }) - .or({ + }, + { type: "'inference.code_execution.result'", seq: "number", data: { result: CodeExecutionResultBlock, "index?": "number" }, - }) - .or({ + }, + { type: "'inference.image_output'", seq: "number", // Fires mid-stream when an adapter finalizes an image-output @@ -1464,28 +1530,28 @@ export const InferenceEvent = type({ // ~1MB inline blobs); consumers that subscribe to this event // should treat it as a non-trivial transport size. data: { image: ImageBlock, "index?": "number" }, - }) - .or({ + }, + { type: "'tool.start'", seq: "number", data: { call: ToolCall }, - }) - .or({ + }, + { type: "'tool.update'", seq: "number", data: { callId: "string", partial: "string" }, - }) - .or({ + }, + { type: "'tool.done'", seq: "number", data: { result: ToolResult }, - }) - .or({ + }, + { type: "'message.queued'", seq: "number", data: { message: WireInboundMessage }, - }) - .or({ + }, + { type: "'message.run.started'", seq: "number", data: { @@ -1493,8 +1559,8 @@ export const InferenceEvent = type({ messageRunId: "string", receivedAt: "number", }, - }) - .or({ + }, + { type: "'message.run.ended'", seq: "number", data: { @@ -1506,23 +1572,23 @@ export const InferenceEvent = type({ "kind?": "string", }, }, - }) - .or({ + }, + { type: "'message.correlated'", seq: "number", data: { message: WireInboundMessage, correlationId: "string" }, - }) - .or({ + }, + { type: "'connector.reply'", seq: "number", data: { content: "string", "checkpointHash?": "string" }, - }) - .or({ + }, + { type: "'reactor.start'", seq: "number", data: "object", - }) - .or({ + }, + { type: "'reactor.gate.blocked'", seq: "number", data: { @@ -1531,50 +1597,51 @@ export const InferenceEvent = type({ "correlationId?": "string", "approvalSnapshot?": ApprovalSnapshot, }, - }) - .or({ + }, + { type: "'reactor.gate.cleared'", seq: "number", data: { gateId: "string", reason: type.enumerated("resolved", "timeout", "shutdown"), }, - }) - .or({ + }, + { type: "'reactor.done'", seq: "number", data: "object", - }) - .or({ + }, + { type: "'reactor.error'", seq: "number", data: { error: "string", fatal: "boolean" }, - }) - .or({ + }, + { type: "'fork.created'", seq: "number", data: { forkId: "string", parentId: "string", mode: ForkMode }, - }) - .or({ + }, + { type: "'fork.done'", seq: "number", data: { forkId: "string", "result?": "unknown" }, - }) - .or({ + }, + { type: "'fork.error'", seq: "number", data: { forkId: "string", error: "string" }, - }) - .or({ + }, + { type: "'fork.aborted'", seq: "number", data: { forkId: "string" }, - }) - .or({ + }, + { type: /^custom\./, seq: "number", data: "Record", - }); + }, +); // The TypeScript type is defined manually rather than inferred from the // validator because the `custom.*` variant uses a regex pattern which // arktype infers as `string`. A bare `string` in the discriminant position diff --git a/vendor/intx/types/src/sessions.ts b/vendor/intx/types/src/sessions.ts index 1917f67895..8296b76eaf 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 ccbf59cf4b..6a5a4cf7e8 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-oauth-login.test.ts b/vendor/intx/types/src/sidecar-oauth-login.test.ts deleted file mode 100644 index 1027363fcd..0000000000 --- a/vendor/intx/types/src/sidecar-oauth-login.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -// CL-7508 local delta: parse/reject tests for the oauth.login.start / -// oauth.login.result frame pair the sidecar ws channel threads (see the -// `vendor/intx/types` ledger row). -import { describe, expect, test } from "bun:test"; -import { type } from "arktype"; -import { - HubFrame, - OAuthLoginCancelFrame, - OAuthLoginResultFrame, - OAuthLoginStartFrame, - SidecarFrame, -} from "./sidecar"; - -describe("oauth.login.start frame", () => { - test("parses a well-formed hub request", () => { - const frame = { - type: "oauth.login.start", - requestId: "req_1", - connectorId: "codex", - }; - expect(OAuthLoginStartFrame(frame)).not.toBeInstanceOf(type.errors); - expect(HubFrame(frame)).not.toBeInstanceOf(type.errors); - }); - - test("accepts every loopback connector id and nothing else", () => { - expect( - OAuthLoginStartFrame({ - type: "oauth.login.start", - requestId: "r", - connectorId: "xai-oauth", - }), - ).not.toBeInstanceOf(type.errors); - expect( - OAuthLoginStartFrame({ - type: "oauth.login.start", - requestId: "r", - connectorId: "github", - }), - ).toBeInstanceOf(type.errors); - }); - - test("rejects a missing requestId", () => { - expect( - OAuthLoginStartFrame({ type: "oauth.login.start", connectorId: "codex" }), - ).toBeInstanceOf(type.errors); - }); - - test("parses the cancel frame the hub sends on timeout", () => { - const frame = { type: "oauth.login.cancel", requestId: "req_1" }; - expect(OAuthLoginCancelFrame(frame)).not.toBeInstanceOf(type.errors); - expect(HubFrame(frame)).not.toBeInstanceOf(type.errors); - expect( - OAuthLoginCancelFrame({ type: "oauth.login.cancel" }), - ).toBeInstanceOf(type.errors); - }); -}); - -describe("oauth.login.result frame", () => { - test("parses the started arm with its authorize URL", () => { - const frame = { - type: "oauth.login.result", - requestId: "req_1", - outcome: { status: "started", authorizeUrl: "https://auth.example/authorize?x=1" }, - }; - expect(OAuthLoginResultFrame(frame)).not.toBeInstanceOf(type.errors); - expect(SidecarFrame(frame)).not.toBeInstanceOf(type.errors); - }); - - test("parses the completed arm with tokens", () => { - const frame = { - type: "oauth.login.result", - requestId: "req_1", - outcome: { - status: "completed", - tokens: { - access: "at", - refresh: "rt", - expiresAt: 123, - idToken: "idt", - accountId: "acc", - }, - }, - }; - expect(OAuthLoginResultFrame(frame)).not.toBeInstanceOf(type.errors); - }); - - test("parses the error arm", () => { - expect( - OAuthLoginResultFrame({ - type: "oauth.login.result", - requestId: "req_1", - outcome: { status: "error", message: "port in use" }, - }), - ).not.toBeInstanceOf(type.errors); - }); - - test("rejects a completed arm without tokens", () => { - expect( - OAuthLoginResultFrame({ - type: "oauth.login.result", - requestId: "req_1", - outcome: { status: "completed" }, - }), - ).toBeInstanceOf(type.errors); - }); - - test("rejects an unknown outcome status", () => { - expect( - OAuthLoginResultFrame({ - type: "oauth.login.result", - requestId: "req_1", - outcome: { status: "pending" }, - }), - ).toBeInstanceOf(type.errors); - }); -}); diff --git a/vendor/intx/types/src/sidecar.ts b/vendor/intx/types/src/sidecar.ts index d961a5dae2..d4ca93ceca 100644 --- a/vendor/intx/types/src/sidecar.ts +++ b/vendor/intx/types/src/sidecar.ts @@ -9,6 +9,7 @@ import { type } from "arktype"; import { GrantWalkSnapshot } from "./grant-snapshot"; +import { ApprovalItem } from "./grants"; import { WireGrantRule } from "./grant-wire"; import { BoundedApprovalSnapshot, @@ -21,6 +22,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,7 +118,18 @@ 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; @@ -47,7 +142,17 @@ export const ReconnectFrame = type({ type: "'reconnect'", 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; 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; @@ -85,12 +190,12 @@ export type AgentErrorFrame = typeof AgentErrorFrame.infer; export const MailOutboundFrame = type({ type: "'mail.outbound'", rawMessage: "string", - recipients: "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; @@ -229,11 +334,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; @@ -282,6 +399,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 @@ -295,15 +423,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 @@ -407,8 +591,13 @@ export type SourceRefPin = typeof SourceRefPin.infer; * definition's bytes come from and the entry module the probe evaluated; * `projection` is the inert wire projection the freeze hashed; `closure` is the * frozen dependency closure the pin resolved to; `approvedWireHash` is the freeze - * anchor; `approvedGrants` is the approved grant set (rehydrated to a `Set` on - * the deploy hand-off). Per-step inference sources are deliberately NOT frozen + * anchor; `approvedGrants` is the approved surface -- the walk's grant-shape + * strings plus the definition's declared grant requirements, each an + * `ApprovalItem` (partitioned by kind into an `ApprovalSet` on the deploy + * hand-off, via `approvalSetFromItems`). The persisted form stays this flat + * list: rows written before the requirement kind existed hold plain strings, + * and a string is an `ApprovalItem`, so they still parse. Per-step + * inference sources are deliberately NOT frozen * here -- they carry credential secrets and are re-resolved from the launch * spec's offering ids at deploy time. */ @@ -418,7 +607,7 @@ export const FrozenApprovalBundle = type({ projection: WorkflowProjectionDefinition, closure: ToolPackageManifest, approvedWireHash: "string > 0", - approvedGrants: "string[]", + approvedGrants: ApprovalItem.array(), }); export type FrozenApprovalBundle = typeof FrozenApprovalBundle.infer; @@ -576,7 +765,9 @@ export const CredentialsUpdateFrame = type({ requestId: "string", agentAddress: "string", delivery: CredentialDelivery, - "revoke?": "string[]", + "revoke?": type("string") + .array() + .atMostLength(MAX_CREDENTIAL_REVOCATIONS_FRAME), }); export type CredentialsUpdateFrame = typeof CredentialsUpdateFrame.infer; @@ -923,7 +1114,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", }); @@ -1010,45 +1201,53 @@ export type OAuthLoginCancelFrame = typeof OAuthLoginCancelFrame.infer; // --------------------------------------------------------------------------- /** All frame types the sidecar sends to the hub. */ -export const SidecarFrame = RegisterFrame.or(ReconnectFrame) - .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) - .or(OAuthLoginResultFrame); +export const SidecarFrame = type.or( + RegisterFrame, + ReconnectFrame, + AgentDeployAckFrame, + AgentErrorFrame, + MailOutboundFrame, + AgentEventFrame, + ConnectorStateChangedFrame, + PingFrame, + SessionAckFrame, + SessionErrorFrame, + AgentUndeployAckFrame, + SignalCorrelationRegisterFrame, + PackPushFrame, + PackDoneFrame, + PackAckFrame, + PackRejectFrame, + MailInboundAckFrame, + WorkflowProbeResultFrame, + WorkflowProbeErrorFrame, + OAuthLoginResultFrame, +); export type SidecarFrame = typeof SidecarFrame.infer; /** All frame types the hub sends to the sidecar. */ -export const HubFrame = MailInboundFrame.or(AgentDeployFrame) - .or(AgentUndeployFrame) - .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) - .or(OAuthLoginStartFrame) - .or(OAuthLoginCancelFrame); +export const HubFrame = type.or( + MailInboundFrame, + AgentDeployFrame, + AgentUndeployFrame, + PongFrame, + SourcesUpdateFrame, + CredentialsUpdateFrame, + PackPushFrame, + PackDoneFrame, + PackAckFrame, + PackRejectFrame, + SyncRequestFrame, + SignalDeliverFrame, + RunGrantsFrame, + SenderKeyRefreshFrame, + SenderKeyEvictFrame, + SignalCorrelationRegisterAckFrame, + DrainDeliverFrame, + WorkflowProbeRequestFrame, + OAuthLoginStartFrame, + OAuthLoginCancelFrame, +); export type HubFrame = typeof HubFrame.infer; /** Any frame on the wire, regardless of direction. */ diff --git a/vendor/intx/types/src/wire-workflow.ts b/vendor/intx/types/src/wire-workflow.ts index 358d7d369b..015cca33fe 100644 --- a/vendor/intx/types/src/wire-workflow.ts +++ b/vendor/intx/types/src/wire-workflow.ts @@ -7,7 +7,7 @@ import { type } from "arktype"; import { CredentialBinding } from "./credentials"; -import { InferenceSource } from "./runtime"; +import { InboundMailPolicy, InferenceSource } from "./runtime"; import { SidecarCapabilityPolicy } from "./sidecar-capabilities"; /** @@ -114,6 +114,14 @@ export const WorkflowProjectionDefinition = type({ // 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 7677f9ccfe..014ed7d668 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-deploy/src/capability-approval.ts b/vendor/intx/workflow-deploy/src/capability-approval.ts index 1a543d46dc..374a8d9c6e 100644 --- a/vendor/intx/workflow-deploy/src/capability-approval.ts +++ b/vendor/intx/workflow-deploy/src/capability-approval.ts @@ -5,24 +5,148 @@ // supplied `ApprovalSet` and decides whether the deploy may proceed. // // Approval semantics (v1): -// - The operator supplies a flat set of approved grant-shape strings -// (`ApprovalSet`). Every grant the walk surfaced on every step must -// appear in that set; any miss is a per-step `pending` entry and -// fails the gate. +// - The operator supplies one approved surface (`ApprovalSet`) holding the +// grant-shape strings and the grant requirements they approved. Every +// grant the walk surfaced on every step must appear in that surface's +// `grants`; any miss is a per-step `pending` entry and fails the gate. // - A non-empty `unresolvedDirectors` field on the walk result is // itself a deploy-time failure; the gate surfaces it through // `ApprovalDecision` and the caller aborts the deploy. +import { isDeepStrictEqual } from "node:util"; + +import { type ApprovalItem, GrantRequirement } from "@intx/types"; + import type { CapabilityWalkResult } from "./capability-walk"; /** - * A flat set of grant-shape strings the operator has approved for this - * deployment. The deploy flow's wiring synthesizes the set from the - * deployment context (admin UI cache, legacy grant-store mirror, - * scripted policy). Order does not matter; membership is the only thing - * the gate consults. + * What the operator has approved for this deployment, in the form the gate + * consults. The deploy flow's wiring synthesizes it from the deployment + * context (admin UI cache, legacy grant-store mirror, scripted policy). + * + * The two fields hold the two kinds of approved item, each next to the + * membership test that can actually answer for it: + * + * - `grants` -- the grant-shape strings the capability walk surfaces. + * Answered by `Set.has`. + * - `requirements` -- the grant requirements a definition declares. A + * record has no useful identity, so membership is a structural + * comparison over the list (`isApprovedGrantRequirement`). + * + * This is ONE approved surface behind ONE operator decision and ONE gate. + * The split is in the payload, not in the number of approvals: a caller + * supplies both fields together and the gate consults both. + * + * Order does not matter in either field; membership is the only thing the + * gate consults. + */ +export type ApprovalSet = { + /** + * Discriminant. The gate's `ProbeApprovalPolicy` is a union of this type and + * `ApproveProbedGrants`, and the two arms mean opposite things: this one + * gates every advertised grant against what the operator approved, the other + * approves whatever the probe reported. Carrying an explicit literal on each + * arm keeps that choice a compiler-checked discriminated union rather than a + * structural guess about which optional keys happen to be present -- a guess + * that would silently resolve to the approve-everything arm if this type ever + * grew the key it tested for. + */ + readonly kind: "approval-set"; + readonly grants: ReadonlySet; + readonly requirements: readonly GrantRequirement[]; +}; + +/** + * Build an `ApprovalSet` from what the operator approved. This is the edge of + * the approval vocabulary: every requirement is validated here, so the gate + * and `isApprovedGrantRequirement` can trust the records they compare. + * + * Validating here is what makes the structural comparison correct. + * `isDeepStrictEqual` treats a present-but-undefined optional key as different + * from an absent one, so an approval carrying `conditions: undefined` would + * silently fail to match a declared requirement that omits the key. Parsing + * rejects that record outright -- `GrantRequirement` admits an object or + * `null` for `conditions`, never `undefined` -- so the malformed approval + * fails loudly at construction instead of becoming an unexplained + * `grant_requirements_not_approved` at deploy. + * + * `requirements` is typed as `unknown` entries on purpose: a caller handing + * over an already-typed record loses nothing, and a caller handing over + * rehydrated or hand-assembled data cannot skip the parse. */ -export type ApprovalSet = ReadonlySet; +export function createApprovalSet( + grants: Iterable, + requirements: Iterable = [], +): ApprovalSet { + return { + kind: "approval-set", + grants: new Set(grants), + requirements: [...requirements].map((requirement) => + GrantRequirement.assert(requirement), + ), + }; +} + +/** + * Rehydrate the flat `ApprovalItem` list a freeze persisted into the + * `ApprovalSet` the gate and the source-pinning pass consult, partitioning the + * items by kind. + * + * The persisted form stays flat and the in-memory form does not, because the + * two owe different things. A flat array is what a stored row already holds, + * and it is append-friendly and order-independent, so it is the shape a wire + * format should keep. The struct is what the code reading it needs, because + * the two kinds are tested by two different operations. This function is the + * one place the two forms meet, and it routes the requirement half through + * `createApprovalSet` so the parse below applies to rehydrated records too. + */ +export function approvalSetFromItems(items: Iterable): ApprovalSet { + const grants: string[] = []; + const requirements: unknown[] = []; + for (const item of items) { + if (typeof item === "string") { + grants.push(item); + } else { + requirements.push(item); + } + } + return createApprovalSet(grants, requirements); +} + +/** + * Flatten an `ApprovalSet` back into the `ApprovalItem` list a freeze + * persists. The inverse of `approvalSetFromItems` up to order: the round trip + * preserves every item, and the partition is by kind, so re-splitting a + * flattened set yields the same two groups. + */ +export function approvalItemsFromSet( + approvals: ApprovalSet, +): readonly ApprovalItem[] { + return [...approvals.grants, ...approvals.requirements]; +} + +/** + * Whether the operator approved this declared grant requirement. + * + * A requirement is compared as a WHOLE RECORD, not by resource alone: + * `source` decides whose authority the run path delegates, `effect` decides + * what the materialized row permits, and `conditions` narrows when it + * applies, so a change to any of them changes the authority the definition + * mints. Anything the operator did not approve exactly is unapproved, which + * is the fail-closed direction. + * + * The comparison is structural over the validated record rather than over a + * canonical string form: `conditions` is an open `Record`, + * so no single-axis string can carry a requirement without losing part of it. + */ +export function isApprovedGrantRequirement( + approvals: ApprovalSet, + requirement: GrantRequirement, +): boolean { + return approvals.requirements.some((approved) => + isDeepStrictEqual(approved, requirement), + ); +} /** * Source the approval gate consults. Kept as an indirection so a future @@ -30,7 +154,7 @@ export type ApprovalSet = ReadonlySet; * gate actually runs (e.g. a remote operator UI fetch). */ export interface ApprovalSource { - approvedGrants(): Promise; + approvedSurface(): Promise; } /** @@ -61,15 +185,15 @@ export interface CapabilityApprovalGate { /** * Build a gate that decides against a fixed `ApprovalSet`. Suitable for - * the operator-supplied flat-set case and for tests. + * the operator-supplied case and for tests. * * The gate computes the per-step delta deterministically: * * - Walk every step in the walk's `perStep` map (input order * preserved). * - For each step, list grants the walk surfaced that are not in the - * approval set, preserving the walk's order to keep the operator- - * facing pending list stable. + * approved grant strings, preserving the walk's order to keep the + * operator-facing pending list stable. * - Empty per-step deltas are omitted from the result map so the * operator-facing pending output names only steps with something to * approve. @@ -87,7 +211,7 @@ export function createApprovalSetGate( for (const [stepId, declarations] of walk.perStep) { const missing: string[] = []; for (const grant of declarations.grants) { - if (!approvals.has(grant)) { + if (!approvals.grants.has(grant)) { missing.push(grant); } } @@ -110,7 +234,7 @@ export function createApprovalSetGate( /** * Build a gate that consults an `ApprovalSource` on every call. Useful - * when the approved-set materialization is async (e.g. a remote operator + * when the approved-surface materialization is async (e.g. a remote operator * UI fetch) or per-call dynamic. */ export function createApprovalSourceGate( @@ -118,7 +242,7 @@ export function createApprovalSourceGate( ): CapabilityApprovalGate { return { async evaluate(walk: CapabilityWalkResult): Promise { - const approvals = await source.approvedGrants(); + const approvals = await source.approvedSurface(); return createApprovalSetGate(approvals).evaluate(walk); }, }; diff --git a/vendor/intx/workflow-deploy/src/capability-walk.ts b/vendor/intx/workflow-deploy/src/capability-walk.ts index 19755a2afa..ecbf768852 100644 --- a/vendor/intx/workflow-deploy/src/capability-walk.ts +++ b/vendor/intx/workflow-deploy/src/capability-walk.ts @@ -62,6 +62,10 @@ import { UnknownDirectorIdError, } from "@intx/agent"; import type { GrantEffect } from "@intx/types"; +import { + EXECUTABLE_STEP_DESCENT, + walkNestedWorkflowSteps, +} from "@intx/workflow/definition"; import type { WorkflowDefinition } from "@intx/workflow/definition"; /** @@ -233,15 +237,16 @@ function collectActionGrants( * body-bearing primitive -- the grants of every step of its nested body. A * loop, an inline onTrigger section, and an inline childWorkflow each run their * body per the deployment, so the operator must approve everything the body can - * run. The walk descends into the authored `{ inline }` form; a `{ ref }` body - * is a separately-declared asset whose grants were folded in from its own - * inline form, so it is skipped here. + * run. That is exactly `EXECUTABLE_STEP_DESCENT`: every step the deployment can + * execute, including the ones a lifted body owns. A `{ ref }` body is a + * separately-declared asset whose grants were folded in from its own inline + * form, so the descent skips it. * - * The nesting switch is EXHAUSTIVE: a newly-added primitive kind fails the - * `never` assignment below at compile time, forcing the walk to decide how to - * treat it rather than silently dropping a nested closure's grants. A miss here - * is a silent, fail-open authorization gap because `director:` is not re-gated - * at runtime -- so the compiler, not a remembered call site, owns coverage. + * Duplicate-name handling is scoped per body step: `collectAgentGrants` throws + * on a duplicate within a single agent, but two DIFFERENT body steps that each + * mint the same `tool:` are distinct runtime agents (the runtime builds + * one agent per step), so the union across body steps is not a duplicate-name + * error. */ function collectPrimitiveGrants( primitive: WorkflowDefinition["steps"][string], @@ -250,100 +255,35 @@ function collectPrimitiveGrants( unresolved: Set, collected: GrantSet, ): void { - const agent = extractAgent(primitive); - if (agent !== null) { - collectAgentGrants(agent, registry, pluginDefs, unresolved, collected); - } - for (const grant of collectActionGrants(primitive)) { - collected.grants.add(grant); - } - switch (primitive.kind) { - case "loop": - collectBodyGrants( - primitive.body, - registry, - pluginDefs, - unresolved, - collected, - ); - return; - case "onTrigger": - if ("inline" in primitive.body) { - collectBodyGrants( - primitive.body.inline, - registry, - pluginDefs, - unresolved, - collected, - ); - } - return; - case "childWorkflow": - if ("inline" in primitive.definition) { - collectBodyGrants( - primitive.definition.inline, - registry, - pluginDefs, - unresolved, - collected, - ); - } - return; - case "step": - case "map": - case "action": - case "gate": - case "escalation": - case "awaitSignal": - case "sleep": - // Leaf primitives: no nested body to descend into. - return; - default: { - const exhaustive: never = primitive; - throw new Error( - `capability walk: unhandled primitive kind ${JSON.stringify( - (exhaustive as { kind: string }).kind, - )}`, - ); - } - } + collectOwnGrants(primitive, registry, pluginDefs, unresolved, collected); + walkNestedWorkflowSteps({ + primitive, + descent: EXECUTABLE_STEP_DESCENT, + context: "capability walk: body ", + visit: ({ step }) => { + collectOwnGrants(step, registry, pluginDefs, unresolved, collected); + }, + }); } /** - * Walk every step of a nested body (a loop body, an inline onTrigger section - * body, or an inline childWorkflow definition) and union its grants into - * `collected`. Each step routes through `collectPrimitiveGrants`, so a body - * that itself nests another body -- including a loop body that contains a - * nested loop -- is covered by the same single dispatch, which recurses into - * the inner body's own grants. - * - * Duplicate-name handling is scoped per body step: `collectAgentGrants` throws - * on a duplicate within a single agent, but two DIFFERENT body steps that each - * mint the same `tool:` are distinct runtime agents (the runtime builds - * one agent per step), so the union across body steps is not a duplicate-name - * error. + * Union the grants a single primitive declares in its own right -- its agent's + * grants and its action's `effect:` grants -- into `collected`, without + * descending into anything it nests. */ -function collectBodyGrants( - body: WorkflowDefinition, +function collectOwnGrants( + primitive: WorkflowDefinition["steps"][string], registry: DirectorRegistry, pluginDefs: PluginToolDefinitions, unresolved: Set, collected: GrantSet, ): void { - for (const bodyStepId of body.stepOrder) { - const bodyPrimitive = body.steps[bodyStepId]; - if (bodyPrimitive === undefined) { - throw new Error( - `capability walk: body step ${bodyStepId} listed in stepOrder is missing from steps`, - ); - } - collectPrimitiveGrants( - bodyPrimitive, - registry, - pluginDefs, - unresolved, - collected, - ); + const agent = extractAgent(primitive); + if (agent !== null) { + collectAgentGrants(agent, registry, pluginDefs, unresolved, collected); + } + for (const grant of collectActionGrants(primitive)) { + collected.grants.add(grant); } } diff --git a/vendor/intx/workflow-deploy/src/index.ts b/vendor/intx/workflow-deploy/src/index.ts index fab17440c0..17cc5280b4 100644 --- a/vendor/intx/workflow-deploy/src/index.ts +++ b/vendor/intx/workflow-deploy/src/index.ts @@ -11,7 +11,9 @@ // (`deriveRunAddress`, `deriveStepAddress`, `resolveStepAddress`, ...) // and per-step inference-source resolution against the operator- // approved grant set (`pickStepInferenceSource`, `pinInertStepSources`, -// `buildInertProjectionStepSources`). +// `buildInertProjectionStepSources`, `buildInertBodyStepSources`), plus +// `collectAgentBearingStepIds` for consumers that must tell a step which +// can invoke inference from one merely carrying a placeholder pin. export { walkCapabilities, @@ -20,8 +22,12 @@ export { type PluginToolDefinitions, } from "./capability-walk"; export { + approvalItemsFromSet, + approvalSetFromItems, + createApprovalSet, createApprovalSetGate, createApprovalSourceGate, + isApprovedGrantRequirement, type ApprovalDecision, type ApprovalSet, type ApprovalSource, @@ -30,14 +36,18 @@ export { export { extractFoldedBody, type FoldedBody } from "./fold-synthesis"; export { enumerateInertBodies, + inertFlatNamespaceStepIds, inertLoopBody, + inertNestedBodies, type EnumeratedInertBody, type InertBodyStepPreference, } from "./inert-ontrigger-bodies"; export { pickStepInferenceSource, pinInertStepSources, + collectAgentBearingStepIds, buildInertProjectionStepSources, + buildInertBodyStepSources, buildSingleStepAgentDefinition, deriveRunAddress, deriveRunAgentId, diff --git a/vendor/intx/workflow-deploy/src/inert-ontrigger-bodies.ts b/vendor/intx/workflow-deploy/src/inert-ontrigger-bodies.ts index f4e52f0f8e..3023254f4f 100644 --- a/vendor/intx/workflow-deploy/src/inert-ontrigger-bodies.ts +++ b/vendor/intx/workflow-deploy/src/inert-ontrigger-bodies.ts @@ -30,10 +30,43 @@ // validated through an arktype. A step that claims to be an agent-bearing // primitive (`step`/`map`) but carries no well-formed `agent.modelSources` is a // malformed projection and throws, rather than silently pinning a fallback. +// +// Reading a nested body off an inert step is also the one part of the canonical +// step walk the inert representation has to supply itself, so it lives here as +// `inertNestedBodies` -- the counterpart of the live `nestedWorkflowBodies`. +// It owns the descent for the consumers that walk a whole frozen projection, +// so the malformed-container throws below apply on those paths. +// `enumerateInertBodiesAtDepth` in this same module is NOT one of them: it +// hand-rolls a depth-limited descent with its own probes, so a malformed +// container reads as a leaf there rather than throwing. Nothing reaches it +// with an unvalidated step today -- the wire narrow validates every top-level +// step, descent validates each nested body, and the probe gate's totality walk +// runs first -- so the divergence is latent, not exploitable. Whoever adds the +// next container kind has to add it in both places. +// +// Both readers are EXHAUSTIVE OVER THE SAME KIND SET, and that is what the +// reader's shape here buys. The live reader switches over the typed `Primitive` +// union and ends in a `never` assignment; an inert step is `unknown` on the +// wire, so no switch over it can be typed that way. Exhaustiveness over a kind +// set is a property of a TABLE'S KEYS though, independent of how its values are +// typed -- so this reader is a `Record` whose arms still +// take `unknown` and still probe with arktype. Keying it on the same union the +// live `never` guards makes the two readers fail together: a body-bearing +// primitive added to the live reader and forgotten here is a compile error +// rather than a container the deploy-time totality check reads as a leaf. import { type } from "arktype"; -import { WorkflowProjectionDefinition } from "@intx/types/sidecar"; +import { + WorkflowProjectionDefinition, + WorkflowStep, +} from "@intx/types/sidecar"; import { inlineBodyRef } from "@intx/workflow"; +import { + LOOP_BODY_DESCENT, + walkStepTree, + type Primitive, + type StepWalkDescent, +} from "@intx/workflow/definition"; /** * A body step's declared preferred inference-source identity -- the `(provider, @@ -92,6 +125,16 @@ const InlineOnTriggerStep = type({ body: { inline: "unknown" }, }); +// An onTrigger step whose body is a `{ ref }` to a separately-deployed asset. +// Paired with `InlineOnTriggerStep` so a reader can tell that form -- nothing to +// descend into -- apart from an onTrigger step carrying no recognizable body +// slot at all, which is a malformed projection. `OnTriggerBody`'s ref arm types +// the ref as a plain `string`, so this matches it exactly rather than narrowing. +const RefOnTriggerStep = type({ + kind: "'onTrigger'", + body: { ref: "string" }, +}); + // A childWorkflow step carrying an inline child definition. Mirrors // `InlineOnTriggerStep`: an already-ref child (`definition: { ref }`) is skipped, // but a frozen source-ref projection keeps its children inline. @@ -100,6 +143,12 @@ const InlineChildWorkflowStep = type({ definition: { inline: "unknown" }, }); +// A childWorkflow step whose child is a `{ ref }`. Mirrors `RefOnTriggerStep`. +const RefChildWorkflowStep = type({ + kind: "'childWorkflow'", + definition: { ref: "string" }, +}); + // The agent surface the resolver reads. Undeclared keys pass through, so this // matches a full inert agent while typing only `modelSources`. const AgentWithModelSources = type({ modelSources: InertModelSource.array() }); @@ -132,6 +181,24 @@ function firstPreference( const InertLoopStep = type({ kind: "'loop'", body: "unknown" }); +/** + * Validate one inline body value as a workflow projection. `where` names the + * reader and the body it was reached through, so a malformed frozen projection + * is traceable to the exact position that carried it. + */ +function validateInertBodyProjection( + inlineBody: unknown, + where: string, +): WorkflowProjectionDefinition { + const validated = WorkflowProjectionDefinition(inlineBody); + if (validated instanceof type.errors) { + throw new Error( + `${where} is not a valid workflow projection: ${validated.summary}`, + ); + } + return validated; +} + /** * If an inert projection step is a `loop`, return its body projection (a nested * inert workflow definition); otherwise null. A loop body runs in-process as a @@ -142,16 +209,189 @@ const InertLoopStep = type({ kind: "'loop'", body: "unknown" }); */ export function inertLoopBody( stepValue: unknown, -): typeof WorkflowProjectionDefinition.infer | null { +): WorkflowProjectionDefinition | null { const asLoop = InertLoopStep(stepValue); if (asLoop instanceof type.errors) return null; - const body = WorkflowProjectionDefinition(asLoop.body); - if (body instanceof type.errors) { + return validateInertBodyProjection( + asLoop.body, + "inertLoopBody: loop step body", + ); +} + +/** + * Read the nested inert body projections one step of a single primitive kind + * carries, filtered by `descent`. The step arrives as `unknown` -- the wire + * projection types its steps that way -- so each arm validates what it reads. + */ +type InertNestedBodyReader = ( + stepValue: unknown, + descent: StepWalkDescent, +) => readonly WorkflowProjectionDefinition[]; + +/** A leaf primitive: no nested body to descend into under any descent. */ +const NO_NESTED_BODY: InertNestedBodyReader = () => []; + +/** + * The per-kind inert body readers, keyed by the LIVE `Primitive["kind"]` union + * -- the same union the live `nestedWorkflowBodies` switch guards with its + * `never` assignment. The keys are what make this reader exhaustive: a primitive + * kind added to that union leaves a missing property here and fails to compile, + * so the live reader and this one fail together rather than the inert half + * silently reading a new container as a leaf. + * + * Each arm separates the three non-defect outcomes a container has -- an inline + * body the descent wants, an inline body the descent excludes, and a `{ ref }` + * body whose asset is deployed and walked on its own -- from the one outcome + * that is a defect: a step that announces a container kind but carries no + * recognizable body slot. That is a malformed frozen projection and throws, + * because reading it as a leaf would silently shrink whatever surface the caller + * is walking. + * + * The `{ ref }` check lives inside its own kind's arm, mirroring the live + * switch's `"inline" in primitive.body`, so the two readers are structurally + * parallel rather than accidentally in agreement. + */ +const inertNestedBodyReaders: Record = + { + loop: (stepValue, descent) => { + // A loop body is always inline -- `LoopPrimitive.body` has no `{ ref }` + // form -- so a loop step with no body projection is malformed, not a leaf. + const body = inertLoopBody(stepValue); + if (body === null) { + throw new Error( + "inertNestedBodies: loop step carries no body; a loop body is always inline", + ); + } + return descent.loopBodies ? [body] : []; + }, + onTrigger: (stepValue, descent) => { + const asInline = InlineOnTriggerStep(stepValue); + if (!(asInline instanceof type.errors)) { + return descent.inlineOnTriggerBodies + ? [ + validateInertBodyProjection( + asInline.body.inline, + "inertNestedBodies: inline onTrigger body", + ), + ] + : []; + } + if (RefOnTriggerStep(stepValue) instanceof type.errors) { + throw new Error( + "inertNestedBodies: onTrigger step carries neither an inline body nor a { ref } body", + ); + } + return []; + }, + childWorkflow: (stepValue, descent) => { + const asInline = InlineChildWorkflowStep(stepValue); + if (!(asInline instanceof type.errors)) { + return descent.inlineChildWorkflowBodies + ? [ + validateInertBodyProjection( + asInline.definition.inline, + "inertNestedBodies: inline childWorkflow body", + ), + ] + : []; + } + if (RefChildWorkflowStep(stepValue) instanceof type.errors) { + throw new Error( + "inertNestedBodies: childWorkflow step carries neither an inline definition nor a { ref } definition", + ); + } + return []; + }, + step: NO_NESTED_BODY, + map: NO_NESTED_BODY, + action: NO_NESTED_BODY, + gate: NO_NESTED_BODY, + escalation: NO_NESTED_BODY, + awaitSignal: NO_NESTED_BODY, + sleep: NO_NESTED_BODY, + }; + +/** + * The nested inert body projections one frozen-projection step carries, + * filtered by `descent`. This is the inert analogue of the live + * `nestedWorkflowBodies`, and it exists so the inert representation states the + * descent rule in ONE place the way the live one does. + * + * The step's kind is validated against the closed wire enum before dispatch, so + * an unrecognized kind throws instead of reading as a leaf. Every production + * caller hands over a step that already passed that same enum through + * `WorkflowSteps`' narrow, so the throw marks a projection assembled outside the + * validated path rather than an input the wire admits. + * + * Indexing the `Primitive["kind"]`-keyed table with a wire-validated kind is + * also what ties the two hand-written enumerations of the primitive set + * together at compile time: a kind the wire admits but the live union lacks + * fails this dispatch. + */ +export function inertNestedBodies( + stepValue: unknown, + descent: StepWalkDescent, +): readonly WorkflowProjectionDefinition[] { + const parsed = WorkflowStep(stepValue); + if (parsed instanceof type.errors) { throw new Error( - `inertLoopBody: loop step body is not a valid workflow projection: ${body.summary}`, + `inertNestedBodies: step is not a known workflow primitive: ${parsed.summary}`, ); } - return body; + return inertNestedBodyReaders[parsed.kind](stepValue, descent); +} + +/** + * Visit every step of a frozen inert projection in `stepOrder`, recursing into + * `loop` bodies. This is the walk that stays inside ONE FLAT STEP-ID NAMESPACE: + * a loop body runs in-process as a child run sharing the parent's env, so its + * steps resolve against the enclosing definition's per-step tables, while an + * onTrigger section or childWorkflow body is lifted to its own definition with + * tables of its own and the walk stops at that boundary. + * + * The traversal itself is the canonical `walkStepTree`. Only the per-rung body + * read is supplied here, because the inert projection types its steps as + * `unknown` and `inertLoopBody` is what validates one. + * + * `context` is the caller label the walk's throw is prefixed with, so a + * malformed projection is traceable to whoever walked it. + */ +export function forEachInertLoopBodyStep( + args: { definition: WorkflowProjectionDefinition; context: string }, + visit: (entry: { stepId: string; step: unknown }) => void, +): void { + walkStepTree({ + tree: args.definition, + context: args.context, + nestedTrees: (stepValue) => inertNestedBodies(stepValue, LOOP_BODY_DESCENT), + visit: ({ stepId, step }) => { + visit({ stepId, step }); + }, + }); +} + +/** + * Every step id in a frozen inert projection's FLAT STEP-ID NAMESPACE: its own + * `stepOrder` plus the step ids of every `loop` body it carries, transitively, + * deduplicated in first-reach order. + * + * This is the domain of every per-deployment table keyed by plain step id -- + * the credentials snapshot above all. A loop iteration inherits the parent + * run's env, so a body step authorizes against the SAME snapshot the enclosing + * definition's steps do; a snapshot built from `stepOrder` alone therefore has + * no entry for it and the child's authorize throws on the body's first tool + * call. Deduplication is exact here: the ids share one namespace, so a repeated + * id IS the same id. + */ +export function inertFlatNamespaceStepIds(args: { + definition: WorkflowProjectionDefinition; + context: string; +}): readonly string[] { + const ids = new Set(); + forEachInertLoopBodyStep(args, ({ stepId }) => { + ids.add(stepId); + }); + return [...ids]; } /** @@ -214,12 +454,10 @@ function liftInertBody( kind: "onTrigger" | "childWorkflow", ): EnumeratedInertBody { const ref = inlineBodyRef(enclosingId, stepId); - const validatedBody = WorkflowProjectionDefinition(inlineBody); - if (validatedBody instanceof type.errors) { - throw new Error( - `enumerateInertBodies: inline ${kind} body at step ${stepId} is not a valid workflow projection: ${validatedBody.summary}`, - ); - } + const validatedBody = validateInertBodyProjection( + inlineBody, + `enumerateInertBodies: inline ${kind} body at step ${stepId}`, + ); const definition = { ...validatedBody, id: ref }; return { ref, definition }; } diff --git a/vendor/intx/workflow-deploy/src/orchestrator.ts b/vendor/intx/workflow-deploy/src/orchestrator.ts index c748531895..f247c7dc24 100644 --- a/vendor/intx/workflow-deploy/src/orchestrator.ts +++ b/vendor/intx/workflow-deploy/src/orchestrator.ts @@ -11,10 +11,13 @@ // from the host-sourced step count alone. // // The source-pinning utilities (`pickStepInferenceSource`, -// `buildInertProjectionStepSources`, `isSourceApproved`) resolve each step's -// inference source against the operator-approved grant set, so an unapproved +// `buildInertProjectionStepSources`, `buildInertBodyStepSources`, +// `isSourceApproved`) resolve each step's inference source. An agent-bearing +// step resolves against the operator-approved grant set, so an unapproved // source fails the deploy closed rather than slipping past the capability-walk -// gate. +// gate. A step that cannot invoke inference takes the deploy's default source +// as an inert placeholder: the wire shape requires a source for every step, +// but that step never issues a request through it. import type { AgentDefinition, @@ -29,7 +32,7 @@ import { formatRunAddress } from "@intx/types"; import { type ApprovalSet } from "./capability-approval"; import { - inertLoopBody, + forEachInertLoopBodyStep, readInertStepInference, type InertBodyStepPreference, } from "./inert-ontrigger-bodies"; @@ -74,7 +77,7 @@ export function isSourceApproved( source: InferenceSource, operatorApprovals: ApprovalSet, ): boolean { - return operatorApprovals.has( + return operatorApprovals.grants.has( `inference.source:${source.provider}:${source.model}`, ); } @@ -142,10 +145,10 @@ export function pickStepInferenceSource(args: { /** * Walk a frozen inert definition's steps and pin each to a single inference - * source, recursing into `loop` bodies. This owns the traversal -- the step - * walk, the flat-map collision rule, and the loop-body recursion. The per-step - * LEAF policy (which source a given step resolves to, and how a step that - * declares no source is pinned) is supplied by the caller through + * source, recursing into `loop` bodies. This owns the flat-map collision rule; + * the traversal is the canonical step walk (see `forEachInertStep`). The + * per-step LEAF policy (which source a given step resolves to, and how a step + * that declares no source is pinned) is supplied by the caller through * `resolveLeafSource`, so the walk can be reused by callers that pin steps * under different rules. * @@ -173,45 +176,95 @@ export function pinInertStepSources(args: { }) => InferenceSource; }): Record { const sources: Record = {}; - const pin = (def: WorkflowProjectionDefinition): void => { - for (const stepId of def.stepOrder) { - const stepValue = def.steps[stepId]; - const { isAgent, preference } = readInertStepInference( - stepValue, - args.context, - stepId, - ); - const resolved = args.resolveLeafSource({ stepId, isAgent, preference }); - const existing = sources[stepId]?.[0]; - if (existing !== undefined) { - if (!sameInferenceSource(existing, resolved)) { - throw new WorkflowDefinitionInvalidError( - args.workflowId, - `step id ${stepId} resolves to two different inference sources across nested loop bodies; a loop-body step id that collides with another step must resolve to the same source`, - ); - } - } else { - sources[stepId] = [resolved]; + forEachInertStep(args, ({ stepId, isAgent, preference }) => { + const resolved = args.resolveLeafSource({ stepId, isAgent, preference }); + const existing = sources[stepId]?.[0]; + if (existing !== undefined) { + if (!sameInferenceSource(existing, resolved)) { + throw new WorkflowDefinitionInvalidError( + args.workflowId, + `step id ${stepId} resolves to two different inference sources across nested loop bodies; a loop-body step id that collides with another step must resolve to the same source`, + ); } - const loopBody = inertLoopBody(stepValue); - if (loopBody !== null) pin(loopBody); + return; } - }; - pin(args.definition); + sources[stepId] = [resolved]; + }); return sources; } /** - * Pin every step of a frozen inert projection to a single approved inference - * source, producing the `sources` map the source-ref deploy frame carries. The - * hub holds no live definition, so each step's declared `(provider, model)` - * preference is read off the inert projection's `modelSources` and resolved - * through the `pickStepInferenceSource` resolver + operator-approval gate. A - * step whose preferred source the operator never approved (or that resolves to - * no approved source at all) throws, failing the whole deploy closed before any - * frame is sent. Every step -- agent or not -- is approval-gated here: a - * non-agent step falls back to the approved default, so the sidecar child finds - * a pinned source for each staged step. + * Walk a frozen inert definition's steps in `stepOrder`, recursing into `loop` + * bodies, and hand each leaf its `(isAgent, preference)` classification. Owns + * the classification so every consumer reads a step the same way. + * + * The traversal itself is `forEachInertLoopBodyStep` -- the canonical + * `walkStepTree` under the descent that stays inside ONE flat step-id + * namespace, which is what the pinned `sources` map is keyed by. An onTrigger + * section or childWorkflow body is lifted to its own definition with its own + * sources map, so that walk stops at the boundary. This wrapper adds only the + * per-step inference classification. + */ +function forEachInertStep( + args: { definition: WorkflowProjectionDefinition; context: string }, + visit: (leaf: { + stepId: string; + isAgent: boolean; + preference: InertBodyStepPreference | null; + }) => void, +): void { + forEachInertLoopBodyStep(args, ({ stepId, step }) => { + const { isAgent, preference } = readInertStepInference( + step, + args.context, + stepId, + ); + visit({ stepId, isAgent, preference }); + }); +} + +/** + * Collect the ids of the steps in a frozen inert definition that can invoke + * inference: an `agent` step or a `map` over one. Every other primitive -- + * `action`, `gate`, `awaitSignal`, `sleep`, `escalation`, and the `loop`, + * `onTrigger` and `childWorkflow` containers -- is pinned a source to satisfy + * the wire requirement that every step carry one, and never issues a request + * through it. + * + * A container's own id is excluded while its contents are not: a `loop` body's + * steps are classified on their own visit into the same flat namespace, and an + * `onTrigger` or `childWorkflow` body is lifted to its own definition and + * classified there. + * + * Derived from the definition rather than from a pinned sources map, so a + * consumer deciding what a step is entitled to reads the hash-covered + * projection instead of trusting a map it was handed. + */ +export function collectAgentBearingStepIds(args: { + definition: WorkflowProjectionDefinition; + context: string; +}): Set { + const agentStepIds = new Set(); + forEachInertStep(args, ({ stepId, isAgent }) => { + if (isAgent) agentStepIds.add(stepId); + }); + return agentStepIds; +} + +/** + * Pin every step of a frozen inert projection to a single inference source, + * producing the `sources` map the source-ref deploy frame carries. The hub + * holds no live definition, so an agent-bearing step's declared + * `(provider, model)` preference is read off the inert projection's + * `modelSources` and resolved through the `pickStepInferenceSource` resolver + * and its operator-approval gate. A preference the operator never approved -- + * or that resolves to no approved source at all -- throws, failing the whole + * deploy closed before any frame is sent. + * + * A step that cannot invoke inference takes the inert placeholder instead and + * is not approval-gated. Its pin exists because the wire shape requires a + * source for every step, not because it names a route the step will take, and + * the hub delivers no credential against it. * * The walk recurses into `loop` bodies via `pinInertStepSources`. onTrigger * bodies are NOT walked here -- they are lifted to `referencedDefinitions` with @@ -227,14 +280,87 @@ export function buildInertProjectionStepSources(args: { definition: args.projection, workflowId: args.projection.id, context: "buildInertProjectionStepSources: ", - resolveLeafSource: ({ stepId, preference }) => - pickStepInferenceSource({ - preferred: preference, - stepId, - workflowId: args.projection.id, - config: args.config, - operatorApprovals: args.operatorApprovals, - }), + resolveLeafSource: ({ stepId, isAgent, preference }) => + isAgent + ? pickStepInferenceSource({ + preferred: preference, + stepId, + workflowId: args.projection.id, + config: args.config, + operatorApprovals: args.operatorApprovals, + }) + : inertPlaceholderSource({ + stepId, + workflowId: args.projection.id, + config: args.config, + }), + }); +} + +/** + * Resolve the inert placeholder source that a step which cannot invoke + * inference is pinned to: the deploy's default source. Such a step never + * issues a request, so the pin exists only to fill the per-step coverage slot + * the deploy frame requires, and carries no approval decision. + * + * The lookup runs per leaf rather than once per walk on purpose. A definition + * whose steps all resolve a source of their own must not fail merely because + * `defaultSource` dangles; only a step that actually needs the placeholder + * does. + */ +function inertPlaceholderSource(args: { + stepId: string; + workflowId: string; + config: HarnessConfig; +}): InferenceSource { + const placeholder = args.config.sources.find( + (source) => source.id === args.config.defaultSource, + ); + if (placeholder === undefined) { + throw new WorkflowDefinitionInvalidError( + args.workflowId, + `step ${args.stepId} needs an inert placeholder source, but defaultSource ${JSON.stringify(args.config.defaultSource)} names no entry in HarnessConfig.sources`, + ); + } + return placeholder; +} + +/** + * Pin every step of a lifted onTrigger body to a single inference source, + * producing the `sources` map the body's `referencedDefinitions` entry carries. + * + * An agent-bearing body step resolves through `pickStepInferenceSource` and its + * operator-approval gate. A body step that is not agent-bearing cannot invoke + * inference, so it takes the inert placeholder and no approval applies to it. + * + * The branch keys on whether the step is agent-bearing, never on whether it + * declared a preference: an agent that declares no `modelSources` also arrives + * without one, and must keep its gate. + */ +export function buildInertBodyStepSources(args: { + definition: WorkflowProjectionDefinition; + workflowId: string; + config: HarnessConfig; + operatorApprovals: ApprovalSet; +}): Record { + return pinInertStepSources({ + definition: args.definition, + workflowId: args.workflowId, + context: `buildInertBodyStepSources ${args.workflowId}: `, + resolveLeafSource: ({ stepId, isAgent, preference }) => + isAgent + ? pickStepInferenceSource({ + preferred: preference, + stepId, + workflowId: args.workflowId, + config: args.config, + operatorApprovals: args.operatorApprovals, + }) + : inertPlaceholderSource({ + stepId, + workflowId: args.workflowId, + config: args.config, + }), }); } diff --git a/vendor/intx/workflow-host/src/adapters/repo-store.ts b/vendor/intx/workflow-host/src/adapters/repo-store.ts index 046d0ea915..8396b24e30 100644 --- a/vendor/intx/workflow-host/src/adapters/repo-store.ts +++ b/vendor/intx/workflow-host/src/adapters/repo-store.ts @@ -35,6 +35,7 @@ import type { RepoStore as SubstrateRepoStore, } from "@intx/hub-sessions/substrate"; import { + parseEventSeq, subscribeKind, WORKFLOW_RUN_EVENTS_FILE, splitCombinedEventLog, @@ -53,8 +54,6 @@ type SubscribeOpts = Parameters[1]; const RUNS_PREFIX = "runs"; const EVENTS_DIR = "events"; -const EVENT_FILENAME_RE = /^(0|[1-9][0-9]*)\.json$/; - /** * On-disk envelope shape committed under * `runs//events/.json`. Carries the seq cross-check the @@ -232,11 +231,8 @@ async function readAllEventsForRun( const eventBlobs = await reads.listDir(`${runDir}/${EVENTS_DIR}`); for (const child of eventBlobs) { if (child.type !== "blob") continue; - const match = EVENT_FILENAME_RE.exec(child.name); - if (match === null) continue; - const seqStr = match[1]; - if (seqStr === undefined) continue; - const seqFromName = Number.parseInt(seqStr, 10); + const seqFromName = parseEventSeq(child.name); + if (seqFromName === null) continue; const raw = decoder.decode(await reads.readBlobByOid(child.oid)); const source = `${opts.repoId.id}/${runId}/${EVENTS_DIR}/${child.name}`; const entry = parseEventEnvelope(raw, source); @@ -372,11 +368,8 @@ async function appendBatchEvents( let priorLastSeq = 0; for (const filepath of existing.keys()) { const name = filepath.slice(prefix.length); - const match = EVENT_FILENAME_RE.exec(name); - if (match === null) continue; - const seqStr = match[1]; - if (seqStr === undefined) continue; - const seq = Number.parseInt(seqStr, 10); + const seq = parseEventSeq(name); + if (seq === null) continue; if (seq > priorLastSeq) priorLastSeq = seq; } const files: Record = {}; diff --git a/vendor/intx/workflow-host/src/child/mailbox-mutation-bridge.ts b/vendor/intx/workflow-host/src/child/mailbox-mutation-bridge.ts index 9a9cb2136f..0f83e62209 100644 --- a/vendor/intx/workflow-host/src/child/mailbox-mutation-bridge.ts +++ b/vendor/intx/workflow-host/src/child/mailbox-mutation-bridge.ts @@ -38,6 +38,8 @@ import type { ControlPayload, } from "../ipc/control-channel"; +import { createPendingRequestCore } from "./pending-request"; + const logger = getLogger(["workflow-host", "child", "mailbox-mutation-bridge"]); /** @@ -99,33 +101,27 @@ export interface CreateChildMailboxMutationBridgeOpts { allocateRequestId?: () => string; } -type PendingEntry = { - resolve: (value: MailboxMutationResult) => void; - reject: (err: Error) => void; -}; - /** * Construct the child-side mailbox-mutation bridge. Pending mutations - * live in a map keyed by `requestId`; the bridge resolves the awaiter - * when the supervisor's matching `mailbox.mutate.response` lands. + * live in the shared pending-request core keyed by `requestId`; the + * bridge resolves the awaiter when the supervisor's matching + * `mailbox.mutate.response` lands. */ export function createChildMailboxMutationBridge( opts: CreateChildMailboxMutationBridgeOpts, ): ChildMailboxMutationBridge { - const pending = new Map(); - const allocate = opts.allocateRequestId ?? defaultRequestIdAllocator(); + const pending = createPendingRequestCore({ + label: "workflow-child mailbox mutation", + allocatorPrefix: "mm", + allocateRequestId: opts.allocateRequestId, + }); return { get pendingCount() { - return pending.size; + return pending.pendingCount; }, async submit(mutation: MailboxMutation): Promise { - const requestId = allocate(); - const resultPromise = new Promise( - (resolve, reject) => { - pending.set(requestId, { resolve, reject }); - }, - ); + const { requestId, promise } = pending.register(undefined); const data = mutation.op === "expunge" ? { @@ -148,22 +144,17 @@ export function createChildMailboxMutationBridge( data, }); } catch (cause) { - pending.delete(requestId); - const reason = cause instanceof Error ? cause.message : String(cause); - throw new Error( - `workflow-child mailbox mutation: upstream send failed for requestId ${requestId}: ${reason}`, - { cause }, - ); + pending.discard(requestId); + throw pending.sendFailedError(requestId, cause); } - return resultPromise; + return promise; }, handleResult(data) { - const entry = pending.get(data.requestId); + const entry = pending.settle(data.requestId); if (entry === undefined) { logger.warn`mailbox.mutate.response landed with no pending entry; requestId=${data.requestId} dropped`; return; } - pending.delete(data.requestId); if (data.result.ok) { const result: MailboxMutationResult = {}; if (data.result.expungedUids !== undefined) { @@ -172,30 +163,10 @@ export function createChildMailboxMutationBridge( entry.resolve(result); return; } - entry.reject( - new Error( - `workflow-child mailbox mutation (requestId=${data.requestId}) rejected by supervisor: ${data.result.reason}`, - ), - ); + entry.reject(pending.rejectedError(data.requestId, data.result.reason)); }, cancelAll(reason: string) { - for (const [requestId, entry] of pending) { - entry.reject( - new Error( - `workflow-child mailbox mutation (requestId=${requestId}) cancelled: ${reason}`, - ), - ); - } - pending.clear(); + pending.cancelAll(reason); }, }; } - -function defaultRequestIdAllocator(): () => string { - let counter = 0; - return () => { - counter += 1; - const rand = Math.random().toString(36).slice(2, 10); - return `mm-${String(counter)}-${rand}`; - }; -} diff --git a/vendor/intx/workflow-host/src/child/outbound-mail-bridge.ts b/vendor/intx/workflow-host/src/child/outbound-mail-bridge.ts index ac78f421a9..8f6e916134 100644 --- a/vendor/intx/workflow-host/src/child/outbound-mail-bridge.ts +++ b/vendor/intx/workflow-host/src/child/outbound-mail-bridge.ts @@ -42,6 +42,8 @@ import type { OutboundMessagePayload, } from "../ipc/control-channel"; +import { createPendingRequestCore } from "./pending-request"; + const logger = getLogger(["workflow-host", "child", "outbound-mail-bridge"]); /** @@ -74,34 +76,30 @@ export interface CreateChildOutboundMailBridgeOpts { allocateRequestId?: () => string; } -type PendingEntry = { - resolve: (value: SendReceipt) => void; - reject: (err: Error) => void; -}; - /** * Construct the child-side outbound-mail bridge. Pending sends live in - * a map keyed by `requestId`; the bridge resolves the awaiter when the - * supervisor's matching `outbound.result` lands. + * the shared pending-request core keyed by `requestId`; the bridge + * resolves the awaiter when the supervisor's matching `outbound.result` + * lands. */ export function createChildOutboundMailBridge( opts: CreateChildOutboundMailBridgeOpts, ): ChildOutboundMailBridge { - const pending = new Map(); - const allocate = opts.allocateRequestId ?? defaultRequestIdAllocator(); + const pending = createPendingRequestCore({ + label: "workflow-child outbound mail", + allocatorPrefix: "om", + allocateRequestId: opts.allocateRequestId, + }); return { get pendingCount() { - return pending.size; + return pending.pendingCount; }, async submit( senderAddress: string, message: OutboundMessage, ): Promise { - const requestId = allocate(); - const resultPromise = new Promise((resolve, reject) => { - pending.set(requestId, { resolve, reject }); - }); + const { requestId, promise } = pending.register(undefined); try { await opts.upstreamSender.send({ type: "outbound.message", @@ -112,22 +110,17 @@ export function createChildOutboundMailBridge( }, }); } catch (cause) { - pending.delete(requestId); - const reason = cause instanceof Error ? cause.message : String(cause); - throw new Error( - `workflow-child outbound mail: upstream send failed for requestId ${requestId}: ${reason}`, - { cause }, - ); + pending.discard(requestId); + throw pending.sendFailedError(requestId, cause); } - return resultPromise; + return promise; }, handleResult(data) { - const entry = pending.get(data.requestId); + const entry = pending.settle(data.requestId); if (entry === undefined) { logger.warn`outbound.result landed with no pending entry; requestId=${data.requestId} dropped`; return; } - pending.delete(data.requestId); if (data.result.ok) { entry.resolve({ messageId: data.result.messageId, @@ -135,21 +128,10 @@ export function createChildOutboundMailBridge( }); return; } - entry.reject( - new Error( - `workflow-child outbound mail (requestId=${data.requestId}) rejected by supervisor: ${data.result.reason}`, - ), - ); + entry.reject(pending.rejectedError(data.requestId, data.result.reason)); }, cancelAll(reason: string) { - for (const [requestId, entry] of pending) { - entry.reject( - new Error( - `workflow-child outbound mail (requestId=${requestId}) cancelled: ${reason}`, - ), - ); - } - pending.clear(); + pending.cancelAll(reason); }, }; } @@ -183,21 +165,8 @@ function projectOutboundMessage( payload.attachments = message.attachments.map((a) => ({ name: a.name, contentType: a.contentType, - dataBase64: bytesToBase64(a.data), + dataBase64: base64Encode(a.data), })); } return payload; } - -function bytesToBase64(bytes: Uint8Array): string { - return base64Encode(bytes); -} - -function defaultRequestIdAllocator(): () => string { - let counter = 0; - return () => { - counter += 1; - const rand = Math.random().toString(36).slice(2, 10); - return `om-${String(counter)}-${rand}`; - }; -} diff --git a/vendor/intx/workflow-host/src/child/pending-request.ts b/vendor/intx/workflow-host/src/child/pending-request.ts new file mode 100644 index 0000000000..371390093a --- /dev/null +++ b/vendor/intx/workflow-host/src/child/pending-request.ts @@ -0,0 +1,178 @@ +// Generic pending-request core shared by the three child-side control-IPC +// bridges (substrate-write, outbound-mail, mailbox-mutation). +// +// Every bridge runs the same request/response round-trip over the control +// channel: `submit` mints a `requestId`, registers a pending awaiter keyed +// by that id, emits a request frame upstream, and resolves/rejects the +// awaiter when the supervisor's matching response frame lands. The three +// bridges differ only in the request payload they send, the value they +// resolve, the label their error messages carry, and (for the +// substrate-write bridge) an intermediate merge round-trip that must peek +// at the pending entry without settling it. +// +// This core owns that lifecycle in one place so the per-bridge files +// contain only their wire-specific parts. It never touches the control +// channel itself: each bridge performs its own `upstreamSender.send` so +// the frame payloads stay byte-identical to what the supervisor's +// `ControlPayload` validator expects. + +type PendingEntry = { + meta: Meta; + resolve: (value: Value) => void; + reject: (err: Error) => void; +}; + +/** + * A live pending entry as returned by {@link PendingRequestCore.get} and + * {@link PendingRequestCore.settle}. `meta` is the per-bridge payload the + * entry was registered with (e.g. the substrate-write request whose merge + * closure the merge round-trip invokes). + */ +export type PendingEntryHandle = { + meta: Meta; + resolve: (value: Value) => void; + reject: (err: Error) => void; +}; + +/** + * Options for {@link createPendingRequestCore}. `label` prefixes every + * error the core builds (`