From 54ad6abe8472ff4f8fd9e1006a946d4ac9e48b4f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 08:18:25 -0700 Subject: [PATCH] Re-vendor @intx/hub-api at a8bc06ae; keep two deltas Upstream ba4359b6 exports resolveApproval for programmatic callers under ResolveApprovalRequest/ResolveApprovalOutcome, so the bare export delta is gone; the null-principal request (CL-6345) - a decision a standing-grant allowance already authorized skips the per-principal resolve gate the HTTP routes still enforce - rides on upstream's types, and the hub's grant-allowance auto-approver keeps calling it. The bearer-authenticated workflow-deploy mirror re-applies unchanged: upstream still has no bearer path onto the deployments route. @types/ssri comes from the shared catalog now, as upstream carries it (d86c341b), so that delta is gone too. --- VENDORED.md | 21 +-- scripts/checks/kill-dates.txt | 2 +- vendor/intx/hub-api/VENDORED-FROM | 4 +- vendor/intx/hub-api/package.json | 8 +- vendor/intx/hub-api/src/app.ts | 1 + vendor/intx/hub-api/src/index.ts | 29 ++-- vendor/intx/hub-api/src/middleware/grant.ts | 6 +- vendor/intx/hub-api/src/routes/approvals.ts | 128 +++++++++++++----- vendor/intx/hub-api/src/routes/principals.ts | 5 +- vendor/intx/hub-api/src/routes/runs.ts | 107 ++++++++++++++- .../hub-api/src/run-grant-materialization.ts | 62 +++++++++ vendor/intx/hub-api/tsconfig.json | 8 +- 12 files changed, 308 insertions(+), 73 deletions(-) diff --git a/VENDORED.md b/VENDORED.md index ae0e9390e..5dcad7aac 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -27,7 +27,7 @@ never a convenience. | `apps/sidecar` | Derived from upstream's own `apps/sidecar`: of 38 tracked `src/` modules, 5 are byte-identical to upstream (`default-harness.ts`, `source-asset-delivery.ts`, `workflow-closure-apply.ts`, `workflow-probe-handler.ts`, `workflow-run-pack-restore.ts`), 10 are substantially rewritten under the same name (`atomic-write.ts`, `config.ts`, `conversation-state.ts`, `index.ts`, `run-grants.ts`, `signing-keypair.ts`, `step-agent-tools.ts`, `tool-materialization.ts`, `workflow-closure-materialization.ts`, `workflow-run-pack-client.ts`), and the remaining 23 are workbench-only, including the `workflow-host-wiring/` and `workflow-substrate-factory/` module splits of upstream's single-file `workflow-host-wiring.ts` and `workflow-substrate-factory.ts`. A living fork, not a frozen copy, so this row carries no tree hash. | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | 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-09-19 | `check:killdates` | | `vendor/intx/agent` | `@intx/agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates the operator-configurable doom-loop threshold (`afd0c82b`, `c421c092`) the re-vendored `workflow-host` configures; no local delta; retired by the next `@intx/agent` publish | sawyer | 2026-10-26 | `check:killdates` | | `vendor/intx/db` | `@intx/db` source (`src/`, `migrations/`, drizzle config, manifest, tsconfigs) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | 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 `0086`/`0087` behind upstream's `0085_add_approval_run_idx`, plus `0088` rewriting the retired `onBodyFailure: "continue"` literal to upstream's `"tolerate"` in stored wire projections; retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | -| `vendor/intx/hub-api` | `@intx/hub-api` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `b5580a02` (v0.3.0) | npm 0.3.0 covers the base package but not the exported null-principal `resolveApproval` (CL-6345) or the bearer-authenticated workflow-deploy mirror (`middleware/workflow-run-deploy-auth.ts`, CL-workflow-deploy-bearer); retired when upstream absorbs the deltas | sawyer | 2026-09-19 | `check:killdates` | +| `vendor/intx/hub-api` | `@intx/hub-api` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the null-principal `resolveApproval` for policy-resolved decisions (CL-6345) or the bearer-authenticated workflow-deploy mirror (`middleware/workflow-run-deploy-auth.ts`, CL-workflow-deploy-bearer); retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | | `vendor/intx/hub-sessions` | `@intx/hub-sessions` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 covers the base package but not the pack-acceptance fixes (`ownsWorkflowRunRepo`, `anchorAddressForPackSource`, `decideTerminalRunFlip`), the adopted deploy front + `sourceRef` (CL-6324), the wire-projection writer (CL-6324), malformed tool-call-name sanitization (CL-6478) or the sealed-run terminal-status backfill (CL-6595); retired when upstream absorbs the deltas | sawyer | 2026-10-26 | `check:killdates` | | `vendor/intx/inference` | `@intx/inference` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | npm 0.3.0 predates doom-loop detection (`8da4c827`, `afd0c82b`, `c421c092`); one 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); retired by the next publish | sawyer | 2026-10-26 | `check:killdates` | | `vendor/intx/mail-memory` | `@intx/mail-memory` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | 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-10-26 | `check:killdates` | @@ -60,7 +60,7 @@ crash-loop guard latches, credential/wallet deletion guards with per-credential grant cleanup (and the removal of the dead `bindingGrants` construction from `buildCredentialDelivery`), and an orphaned-grant cleanup migration (upstream `0084`, which took the number our `wire_projection` -migration held — ours is renumbered `0085`). +migration held — ours is now `0086`, behind upstream's later `0085`). v0.3.0 is also the first release whose `@intx/*` npm publishes cover the folded model, so the fifteen previously vendored trees that carried no local @@ -69,20 +69,21 @@ delta — `agent`, `authz`, `crypto`, `harness`, `hub-agent`, `hub-common`, `pack-transport`, `storage-isogit`, `tool-packaging`, `types` — are retired: deleted and consumed as published `@intx/*@0.3.0` packages. The rows above survive because each carries a local delta the publish lacks, or is -imported at a newer API by a tree that does. The root `package.json` `overrides` pin every `@intx/*` -name to `0.3.0` so external dependencies' older exact pins collapse onto the -same resolution workbench uses: the npm publish for retired names, the -vendored workspace copy for surviving ones. +imported at a newer API by a tree that does. The root `package.json` `overrides` pin every npm-consumed `@intx/*` +name to `0.3.0` and every vendored name to `workspace:*`, so external +dependencies' own `@intx/*` pins collapse onto the same resolution workbench +uses: the npm publish for retired names, the vendored workspace copy for +surviving ones. 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 `dist/` references and the `customConditions` entry in the shared tsconfig removed — workbench forbids custom resolve conditions — and each tsconfig -carries `types: ["bun"]`; `vendor/intx/hub-api` -adds a `@types/ssri` devDependency that bun's isolated linker does not hoist -from tool-packaging the way upstream's install does. -`vendor/intx/hub-api` (CL-workflow-deploy-bearer) also adds +carries `types: ["bun"]`. `vendor/intx/hub-api` (CL-6345) accepts +`principalId: null` on `resolveApproval` for a decision a standing-grant +allowance already authorized, skipping the per-principal resolve gate the +HTTP routes still enforce. `vendor/intx/hub-api` (CL-workflow-deploy-bearer) adds `middleware/workflow-run-deploy-auth.ts`, an optional bearer-authenticated mirror onto the SAME session-cookie `POST/GET .../workflows/deployments` route `routes/workflows.ts` already mounts (that route file is otherwise diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index c13a14849..e0a9baf55 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -16,7 +16,7 @@ apps/sidecar | sawyer | 2026-09-19 vendor/intx/agent | sawyer | 2026-10-26 | d0d56d9f452b78f4b541ad8f4e89f975e8069446bb98f2c8097b90de4b020243 vendor/intx/db | sawyer | 2026-09-19 | 0a4cdb9a8a6ff19d5d4713cbc4f5cc9257aad839b1fa393b2026e6d5afd828b9 -vendor/intx/hub-api | sawyer | 2026-09-19 | f93a383cb5d6acdf50a461b43e4c8991dbdf7e13d34a4e5598e556eaee66308b +vendor/intx/hub-api | sawyer | 2026-09-19 | 42ee33e027559b236065382cb94f393bbcfee69625615894f82be778f34f7aa1 vendor/intx/hub-sessions | sawyer | 2026-09-19 | 53addc3090ad9f54bc4bac8fb50ad8d567ccf46f30bb5403d447351cb16b4fb6 vendor/intx/inference | sawyer | 2026-10-26 | 77fec29b078e8d03e686747c70e6b62ac1fd1434db0fb2c1e12e84b6dc71465f vendor/intx/mail-memory | sawyer | 2026-10-26 | 9f3601a7fb22e2d1c63daa976f3afccbd79af2187c155a0080c0d60c82450b92 diff --git a/vendor/intx/hub-api/VENDORED-FROM b/vendor/intx/hub-api/VENDORED-FROM index e073e89a5..65e7dc098 100644 --- a/vendor/intx/hub-api/VENDORED-FROM +++ b/vendor/intx/hub-api/VENDORED-FROM @@ -1,4 +1,4 @@ Source: https://github.com/faremeter/interchange (packages/hub-api) -Commit: b5580a02fb918eebccc33ded7727ffee781ffbd1 (tag v0.3.0) +Commit: a8bc06ae38661c5e0ed91ded8559bf09f502213d (origin/main, 2026-08-27) License: LGPL-2.1-only (see vendor/intx/LICENSE) -Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. adds a @types/ssri devDependency that bun's isolated linker does not hoist from tool-packaging the way upstream's install does. resolveApproval is exported (with its args/result types and readDurableWorkflowRunLifecycles) and accepts principalId: null for policy-resolved (grant-allowance) decisions, which skip the per-principal approval:/resolve gate the HTTP routes still enforce (CL-6345). workflow-run-deploy-auth.test.ts's header comment no longer cites the deleted packages/approvals/test/needs-you.test.ts. +Local modifications: exports map repointed from the upstream intx-src condition to direct TypeScript source resolution (types/default -> ./src/...); dist references removed. CL-6345: resolveApproval's ResolveApprovalRequest accepts principalId: null for policy-resolved (grant-allowance) decisions, which skip the per-principal approval:/resolve gate the HTTP routes still enforce; readDurableWorkflowRunLifecycles is re-exported from the barrel for the hub's grant-allowance gate. CL-workflow-deploy-bearer: middleware/workflow-run-deploy-auth.ts (+ test) is a bearer-authenticated mirror onto the session-cookie POST/GET .../workflows/deployments route; MountHubRoutesDeps and CreateAppOpts gain an optional workflowRunAuthenticator that mounts it ahead of resolveTenant, and src/index.ts exports the middleware and its types. Retired at this pin: the @types/ssri devDependency (upstream d86c341b carries it via the catalog), the bare resolveApproval export (upstream ba4359b6) and the needs-you route reservation (CL-7113). diff --git a/vendor/intx/hub-api/package.json b/vendor/intx/hub-api/package.json index 28753ca51..1a4d712a0 100644 --- a/vendor/intx/hub-api/package.json +++ b/vendor/intx/hub-api/package.json @@ -15,16 +15,16 @@ }, "dependencies": { "@hono/standard-validator": "^0.2.2", - "@intx/agent": "0.3.0", + "@intx/agent": "workspace:*", "@intx/authz": "0.3.0", "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-common": "0.3.0", "@intx/hub-sessions": "workspace:*", "@intx/log": "0.3.0", - "@intx/mime": "0.3.0", + "@intx/mime": "workspace:*", "@intx/storage-isogit": "0.3.0", - "@intx/types": "0.3.0", + "@intx/types": "workspace:*", "@intx/workflow-deploy": "workspace:*", "arktype": "catalog:", "better-auth": "catalog:", @@ -37,7 +37,7 @@ "devDependencies": { "@intx/workflow": "workspace:*", "@types/bun": "catalog:", - "@types/ssri": "^7.1.5", + "@types/ssri": "catalog:", "openapi-types": "^12.1.3", "tar": "catalog:", "typescript": "catalog:" diff --git a/vendor/intx/hub-api/src/app.ts b/vendor/intx/hub-api/src/app.ts index 6e5668e2b..fb23d7211 100644 --- a/vendor/intx/hub-api/src/app.ts +++ b/vendor/intx/hub-api/src/app.ts @@ -323,6 +323,7 @@ export function mountHubRoutes( grantStore, conditionRegistry, requireGrant, + approvalStore, }), ); diff --git a/vendor/intx/hub-api/src/index.ts b/vendor/intx/hub-api/src/index.ts index fd2940dbc..b99982dd4 100644 --- a/vendor/intx/hub-api/src/index.ts +++ b/vendor/intx/hub-api/src/index.ts @@ -14,12 +14,6 @@ export { type CreateRequireGrantDeps, type RequireGrant, } from "./middleware/grant"; -export { - createWorkflowRunDeployAuth, - type CreateWorkflowRunDeployAuthDeps, - type WorkflowRunAuthenticator, - type WorkflowRunDeployScope, -} from "./middleware/workflow-run-deploy-auth"; export { createResolveTenant, requireAuth, @@ -31,19 +25,26 @@ export { resolveWorkflowPrincipalNames, resolveWorkflowPrincipalLabels, } from "./routes/workflow-principal-name"; -export { - resolveApproval, - type CreateApprovalRoutesDeps, - type ReadRunLifecycles, - type ResolveApprovalArgs, - type ResolveApprovalResult, -} from "./routes/approvals"; -export { readDurableWorkflowRunLifecycles } from "./workflow-run-lifecycle"; export { createMailTriggeredRunGrantsMaterializer, + setRunToolGrantEffect, type MailTriggeredRunGrantsDeps, } from "./run-grant-materialization"; export { resolveDefinitionSources, type DefinitionSourceResolution, } from "./run-source-resolution"; +export { + resolveApproval, + type ResolveApprovalRequest, + type ResolveApprovalOutcome, + type CreateApprovalRoutesDeps, + type ReadRunLifecycles, +} from "./routes/approvals"; +export { readDurableWorkflowRunLifecycles } from "./workflow-run-lifecycle"; +export { + createWorkflowRunDeployAuth, + type CreateWorkflowRunDeployAuthDeps, + type WorkflowRunAuthenticator, + type WorkflowRunDeployScope, +} from "./middleware/workflow-run-deploy-auth"; diff --git a/vendor/intx/hub-api/src/middleware/grant.ts b/vendor/intx/hub-api/src/middleware/grant.ts index a907c2cac..0eca745d7 100644 --- a/vendor/intx/hub-api/src/middleware/grant.ts +++ b/vendor/intx/hub-api/src/middleware/grant.ts @@ -34,7 +34,7 @@ export type CreateRequireGrantDeps = { * grant store and condition registry. Usage: * * const requireGrant = createRequireGrant({ grantStore, conditionRegistry }); - * app.get("/", requireGrant("agent:*", "read"), handler); + * app.get("/", requireGrant("workflow-run:*", "read"), handler); */ export function createRequireGrant({ grantStore, @@ -92,8 +92,8 @@ export function createRequireGrant({ * Helper that builds a resource string from a URL parameter. * * Usage: - * requireGrant(idResource("agent", "agentId"), "manage") - * // resolves to "agent:agt_abc123" from the URL + * requireGrant(idResource("workflow-run", "runId"), "manage") + * // resolves to "workflow-run:run_abc123" from the URL */ export function idResource( resourceType: string, diff --git a/vendor/intx/hub-api/src/routes/approvals.ts b/vendor/intx/hub-api/src/routes/approvals.ts index 63ef3ec86..a191ff8ce 100644 --- a/vendor/intx/hub-api/src/routes/approvals.ts +++ b/vendor/intx/hub-api/src/routes/approvals.ts @@ -25,9 +25,16 @@ import { signalName, } from "@intx/types"; +import { getLogger } from "@intx/log"; + import type { TenantEnv } from "../context"; import { ts } from "../format"; -import { lockWorkflowRunState } from "../run-grant-materialization"; +import { + approvalToolName, + loadCommittedRunGrants, + lockWorkflowRunState, + setRunToolGrantEffect, +} from "../run-grant-materialization"; import { cursorCondition, pageOrder, @@ -38,7 +45,50 @@ import { type ParsedApproval = ReturnType; -function formatApproval(row: ParsedApproval) { +const log = getLogger(["hub", "approvals"]); + +/** + * Push a run's grants to the sidecar after a standing (`scope: "always"`) + * resolution has mutated them in the resolve transaction. Re-reads the now + * durably-mutated committed grants and rewrites the run's `grants.json` (the + * `run.grants` handler also refreshes a live child), so the running child sees + * the change in-flight and any respawn re-reads it. + * + * Best-effort relative to the resume: this runs after the resolve committed and + * the parked run must still be resumed, so a failure is logged loudly but never + * propagated -- a throw would skip the caller's signal delivery and hang the + * run. It self-heals regardless: the mutation is already durable in the + * committed grants, and the next dispatch's per-run re-establish reads the same + * committed grants, so a missed push only delays the effect, never loses it. + */ +async function propagateRunGrantsToSidecar( + deps: CreateApprovalRoutesDeps, + approval: ParsedApproval, + tenantId: string, +): Promise { + try { + const committed = await loadCommittedRunGrants( + deps.db, + tenantId, + approval.runId, + ); + // A run with no committed per-run grants has nothing to push. + if (committed === null) return; + const delivered = deps.sidecarRouter.sendRunGrants( + approval.agentAddress, + approval.runId, + committed.stepGrants, + ); + if (!delivered) { + log.warn`standing grant for run ${approval.runId} not pushed: deployment ${approval.agentAddress} is not routable; the next dispatch re-establishes it`; + } + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + log.error`failed to push standing grant for run ${approval.runId}; the parked run still resumes and the next dispatch re-establishes it: ${message}`; + } +} + +export function formatApproval(row: ParsedApproval) { return { id: row.id, tenantId: row.tenantId, @@ -80,7 +130,7 @@ export type ReadRunLifecycles = ( target: WorkflowRunLifecycle; }>; -export type ResolveApprovalArgs = { +export type ResolveApprovalRequest = { approvalId: string; tenantId: string; /** @@ -93,11 +143,11 @@ export type ResolveApprovalArgs = { */ principalId: string | null; status: "approved" | "rejected"; - scope?: "once"; + scope?: "once" | "always"; decisionPayload: ApprovalDecision; }; -export type ResolveApprovalResult = +export type ResolveApprovalOutcome = | { kind: "resolved"; approval: ParsedApproval } | { kind: "not_found" } | { kind: "forbidden" } @@ -107,7 +157,7 @@ export type ResolveApprovalResult = | { kind: "dispatch_unavailable" }; type PendingFailureKind = Extract< - ResolveApprovalResult["kind"], + ResolveApprovalOutcome["kind"], "deployment_unavailable" | "run_not_running" | "dispatch_unavailable" >; @@ -129,8 +179,8 @@ type PendingFailureKind = Extract< */ export async function resolveApproval( deps: CreateApprovalRoutesDeps, - args: ResolveApprovalArgs, -): Promise { + args: ResolveApprovalRequest, +): Promise { const { db, sidecarRouter, @@ -161,6 +211,14 @@ export async function resolveApproval( } } + // A standing resolution mutates the run's committed grant for the approved + // tool. Resolve the tool name up front (before the claim) so a malformed + // snapshot fails the resolve cleanly rather than mid-transaction. `allow` for + // approve-always, `deny` for reject-always. + const standingToolName = + args.scope === "always" ? approvalToolName(approval.toolDefinition) : null; + const standingEffect = args.status === "approved" ? "allow" : "deny"; + const resolvedAt = new Date(); const signalId = generateId("signal"); const claimed = await db.transaction(async (tx) => { @@ -268,6 +326,21 @@ export async function resolveApproval( ); } + // A standing resolution durably mutates the run's committed grant for this + // tool IN this transaction -- `allow` on approve-always, `deny` on + // reject-always -- so a rolled-back resolve reverts the grant change too. + // The run keeps it: enforcement, the authorization view, and the + // per-dispatch re-establish all read the committed grant. + if (standingToolName !== null) { + await setRunToolGrantEffect( + tx, + args.tenantId, + approval.runId, + standingToolName, + standingEffect, + ); + } + if (exclusiveDispatchService !== undefined) { await exclusiveDispatchService.enqueueSignal( { @@ -297,6 +370,17 @@ export async function resolveApproval( return claimed; } + // Standing resolution: the committed grant was mutated in the transaction + // above. Push the now-durable grants to the sidecar so the running child sees + // the change in-flight. Post-commit, so a rolled-back resolve pushes nothing. + // It runs before the wake/signal delivery so the floor lands promptly, but + // correctness does not depend on that ordering: `deliverSignal` refreshes + // grants on the child's control FIFO immediately ahead of the resume signal, + // and the next dispatch re-establishes them regardless. + if (args.scope === "always") { + await propagateRunGrantsToSidecar(deps, approval, args.tenantId); + } + if (claimed.exclusiveDispatchService !== undefined) { // enqueueSignal may wake before its surrounding transaction commits. Wake // once more after commit so the row cannot wait for the periodic sweep. @@ -480,7 +564,7 @@ export function createApprovalRoutes( tags: ["Approvals"], summary: "Approve an action", description: - "Approves the pending action. With scope 'once', the approval is one-time. Scope 'always' is not yet supported: a standing grant requires the tool identity, which the suspend path does not yet capture.", + "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", @@ -488,12 +572,6 @@ export function createApprovalRoutes( "application/json": { schema: resolver(ApprovalResponse) }, }, }, - 400: { - description: "Unsupported scope", - content: { - "application/json": { schema: resolver(ErrorResponse) }, - }, - }, 404: { description: "Approval not found", content: { @@ -528,25 +606,12 @@ export function createApprovalRoutes( const approvalId = c.req.param("approvalId"); const body = c.req.valid("json"); - if (body.scope === "always") { - return c.json( - { - error: { - code: "unsupported_scope", - message: - "scope 'always' is not yet supported: a standing grant requires the tool identity, which the suspend path does not yet capture", - }, - }, - 400, - ); - } - const result = await resolveApproval(deps, { approvalId, tenantId: tenant.id, principalId: principal.id, status: "approved", - scope: "once", + scope: body.scope, decisionPayload: { outcome: "approved" }, }); @@ -560,7 +625,7 @@ export function createApprovalRoutes( tags: ["Approvals"], summary: "Reject an action", description: - "Rejects the pending action. An optional message provides feedback to the agent.", + "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", @@ -607,6 +672,7 @@ export function createApprovalRoutes( tenantId: tenant.id, principalId: principal.id, status: "rejected", + ...(body.scope !== undefined ? { scope: body.scope } : {}), decisionPayload: { outcome: "rejected", ...(body.message !== undefined ? { message: body.message } : {}), @@ -620,7 +686,7 @@ export function createApprovalRoutes( return app; } -function respond(c: Context, result: ResolveApprovalResult) { +function respond(c: Context, result: ResolveApprovalOutcome) { switch (result.kind) { case "resolved": return c.json(formatApproval(result.approval), 200); diff --git a/vendor/intx/hub-api/src/routes/principals.ts b/vendor/intx/hub-api/src/routes/principals.ts index ed7e7cf84..0fa5e710c 100644 --- a/vendor/intx/hub-api/src/routes/principals.ts +++ b/vendor/intx/hub-api/src/routes/principals.ts @@ -112,7 +112,7 @@ export function createPrincipalRoutes({ tags: ["Principals"], summary: "List principals in the tenant", description: - "Lists all principals (users, agents, and workflow deployments) in the tenant. Filterable by kind and status.", + "Lists all principals (users, agents, and workflow runs) in the tenant. Filterable by kind and status.", parameters: [ { name: "kind", @@ -329,8 +329,7 @@ export function createPrincipalRoutes({ describeRoute({ tags: ["Principals"], summary: "Remove principal from tenant", - description: - "Removes a user or agent principal from the tenant. For agents, use agent deletion instead.", + description: "Removes a principal from the tenant.", responses: { 204: { description: "Principal removed", diff --git a/vendor/intx/hub-api/src/routes/runs.ts b/vendor/intx/hub-api/src/routes/runs.ts index 97f830105..6915c1ed3 100644 --- a/vendor/intx/hub-api/src/routes/runs.ts +++ b/vendor/intx/hub-api/src/routes/runs.ts @@ -9,7 +9,7 @@ import { workflowDefinition, workflowRun, } from "@intx/db/schema"; -import type { DB } from "@intx/db"; +import type { DB, ApprovalStore } from "@intx/db"; import { authorize } from "@intx/authz"; import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; import { extractPartByPath } from "@intx/mime"; @@ -17,6 +17,8 @@ import { extractPartByPath } from "@intx/mime"; import { WorkflowRunResponse, WorkflowRunHealth, + RunAuthorizationResponse, + RunApprovalsResponse, OfferingDetail, SendMessage, MailResponse, @@ -36,6 +38,7 @@ import { type WorkflowDispatchService, } from "@intx/hub-sessions"; import { formatOffering } from "./offerings"; +import { formatApproval } from "./approvals"; import { formatRunView, viewStatusOf, @@ -46,6 +49,7 @@ import { WorkflowRunEventsResponse, formatRunEvent } from "./run-events-view"; import type { TenantEnv } from "../context"; import { idResource } from "../middleware/grant"; import type { RequireGrant } from "../middleware/grant"; +import { loadCommittedRunGrants } from "../run-grant-materialization"; import { workflowRunRepoId, WORKFLOW_RUN_REF } from "../workflow-run-lifecycle"; import { createWorkflowRunTrigger, @@ -162,6 +166,8 @@ export type CreateRunRoutesDeps = { grantStore: GrantStore; conditionRegistry: ConditionRegistry; requireGrant: RequireGrant; + // The run approvals-list route reads the run's approval decisions here. + approvalStore: ApprovalStore; }; export function createRunRoutes({ @@ -173,6 +179,7 @@ export function createRunRoutes({ grantStore, conditionRegistry, requireGrant, + approvalStore, }: CreateRunRoutesDeps): Hono { const app = new Hono(); @@ -534,6 +541,104 @@ export function createRunRoutes({ }, ); + app.get( + "/:runId/authorization", + requireGrant(idResource("workflow-run", "runId"), "read"), + describeRoute({ + tags: ["Runs"], + summary: "Get run authorization", + 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) }, + }, + }, + }, + }), + async (c) => { + const tenantCtx = c.get("tenant"); + const runId = c.req.param("runId"); + + const record = await findRoutableById(db, runId, tenantCtx.id); + if (record === undefined) { + return c.json( + { error: { code: "not_found", message: "Run not found" } }, + 404, + ); + } + + // The run's committed per-run grants ARE its effective floor: a standing + // ("always") resolution mutates them in place (ask -> allow on + // approve-always, ask -> deny on reject-always), so what the child + // enforces and what this returns are the same rows -- the view cannot + // drift from enforcement. A run with no committed grants (deployed but + // never triggered) has an empty floor. + const committed = await loadCommittedRunGrants(db, tenantCtx.id, runId); + if (committed === null) { + return c.json({ runId, grants: [] }); + } + return c.json({ + runId, + grants: committed.stepGrants.map((g) => ({ + resource: g.resource, + action: g.action, + effect: g.effect, + })), + }); + }, + ); + + app.get( + "/:runId/approvals", + requireGrant(idResource("workflow-run", "runId"), "read"), + describeRoute({ + tags: ["Runs"], + summary: "List run approvals", + 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) }, + }, + }, + }, + }), + async (c) => { + const tenantCtx = c.get("tenant"); + const runId = c.req.param("runId"); + + const record = await findRoutableById(db, runId, tenantCtx.id); + if (record === undefined) { + return c.json( + { error: { code: "not_found", message: "Run not found" } }, + 404, + ); + } + + const approvals = await approvalStore.listByRunId(tenantCtx.id, runId); + return c.json({ runId, approvals: approvals.map(formatApproval) }); + }, + ); + app.get( "/:runId/health", requireGrant(idResource("workflow-run", "runId"), "read"), diff --git a/vendor/intx/hub-api/src/run-grant-materialization.ts b/vendor/intx/hub-api/src/run-grant-materialization.ts index 419dd3341..28c521076 100644 --- a/vendor/intx/hub-api/src/run-grant-materialization.ts +++ b/vendor/intx/hub-api/src/run-grant-materialization.ts @@ -36,6 +36,7 @@ import { type GrantWalkSnapshot, } from "@intx/types"; import { RunGrantsFrame } from "@intx/types/sidecar"; +import { ToolDefinition } from "@intx/types/runtime"; import { type MailTriggeredRunGrantsResult } from "@intx/hub-sessions"; import { deriveRunPrincipalId, generateId } from "@intx/hub-common"; @@ -400,6 +401,67 @@ export async function loadCommittedRunGrants( return loadCommittedRunGrantsFromExecutor(db, tenantId, runId); } +/** + * The tool name an approval names, read from its `toolDefinition` snapshot. The + * name lives in untyped jsonb; validate it through the `ToolDefinition` arktype + * rather than reaching in, so a malformed snapshot fails loudly instead of + * yielding an unusable name. + */ +export function approvalToolName( + toolDefinition: Record, +): string { + return ToolDefinition.assert(toolDefinition).name; +} + +/** + * Resolve a run's `ask` checkpoint on one tool into a standing effect -- the + * durable mutation a `scope: "always"` resolution makes. An operator who + * approves-always sets `allow` (stop asking, let it through); one who + * rejects-always sets `deny` (stop asking, block it). The grant stays with the + * run: every later read (the child's enforcement floor, the authorization view, + * and the per-dispatch re-establish) sees the standing effect, so the tool is + * not asked again for the life of the run. + * + * Guarded to only change a grant currently gated `ask`: the `effect = "ask"` + * predicate means it only ever resolves the checkpoint, never overrides an + * existing `allow`/`deny` and never touches a tool the run does not already + * hold. So a standing resolution can only remove the checkpoint on a capability + * the deploy already granted-with-a-checkpoint -- in the direction the operator + * chose. Runs against the passed executor, so the caller mutates inside the + * resolve transaction and a rolled-back resolve rolls back the grant change with + * it. A run with no principal (nothing to mutate) is a no-op. + */ +export async function setRunToolGrantEffect( + executor: DBExecutor, + tenantId: string, + runId: string, + toolName: string, + effect: "allow" | "deny", +): Promise { + const [runPrincipal] = await executor + .select({ id: principalTable.id }) + .from(principalTable) + .where( + and( + eq(principalTable.tenantId, tenantId), + eq(principalTable.kind, "workflow"), + eq(principalTable.refId, runId), + ), + ) + .limit(1); + if (runPrincipal === undefined) return; + await executor + .update(grantTable) + .set({ effect, updatedAt: new Date() }) + .where( + and( + eq(grantTable.principalId, runPrincipal.id), + eq(grantTable.resource, `${TOOL_GRANT_PREFIX}${toolName}`), + eq(grantTable.effect, "ask"), + ), + ); +} + /** * Idempotently reserve a run's principal, run row, and immutable grant rows * in one transaction, keyed on the deployment's stable top-level run id. diff --git a/vendor/intx/hub-api/tsconfig.json b/vendor/intx/hub-api/tsconfig.json index d984862c9..dbbb0384b 100644 --- a/vendor/intx/hub-api/tsconfig.json +++ b/vendor/intx/hub-api/tsconfig.json @@ -1,11 +1,11 @@ { "extends": "../tsconfig.base.json", + "include": [ + "src/**/*.ts" + ], "compilerOptions": { "types": [ "bun" ] - }, - "include": [ - "src/**/*.ts" - ] + } }