From a620353d6bd55260c59b70369a39ebc50bbc31c0 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 1 Sep 2026 23:21:16 -0700 Subject: [PATCH 1/8] Enforce authorization on routine targets (CL-7354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A create or PATCH-carried retarget now must clear the same two gates before it is persisted: resolveLaunchableDefinition must resolve the asset, and the acting principal must be authorized for workflow-definition:/read — the same verb listRoutineTargets already checks per row. A denial is a typed 403 alongside the existing 400/404/409 target envelopes, on both the tenant-session and Myra's workflow-run-authenticated routine surfaces. Also routes a pre-existing catch in postRoutineEnabledNotice through reportError, since this change's diff now touches that line and check:report-error requires it. --- packages/routines/src/routes.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/routines/src/routes.ts b/packages/routines/src/routes.ts index 8567d38a4..04bf85276 100644 --- a/packages/routines/src/routes.ts +++ b/packages/routines/src/routes.ts @@ -889,6 +889,27 @@ export function createRoutineRoutes( const effectiveDefinitionAssetId = body.definitionAssetId ?? existing.definitionAssetId; + if ( + body.definitionAssetId !== undefined && + body.definitionAssetId !== existing.definitionAssetId + ) { + const rejection = await rejectUnlaunchableTarget( + deps, + tenant.id, + principal.id, + body.definitionAssetId, + ); + if (rejection !== undefined) { + return c.json( + makeErrorEnvelope({ + code: rejection.code, + userMessage: rejection.userMessage, + }), + rejection.status, + ); + } + } + if ( body.trigger !== undefined && !(await webhookTriggerValid( From 2c34d3a3cae1933c76c0ce66a42496924929c630 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 1 Sep 2026 23:29:32 -0700 Subject: [PATCH 2/8] Add tests for surfacing deploy approval to the initiating human (CL-7362) Covers the preview route/registry method with a fake deployer (no freeze, grants passed through), the wire-hash-mismatch 409 after a deploy that already froze a different definition, and the workflow_deploy approval headline rendering asset/sha/grants. --- .../src/registry.test.ts | 155 ++++++++++++++++++ .../src/workflow-routes.test.ts | 68 ++++++++ packages/approvals/src/headline.test.ts | 30 ++++ .../workflow-authoring-tools/src/tool.test.ts | 57 ++++++- 4 files changed, 306 insertions(+), 4 deletions(-) diff --git a/packages/agent-workflow-authoring/src/registry.test.ts b/packages/agent-workflow-authoring/src/registry.test.ts index 2b6dfa6f3..d86e584d4 100644 --- a/packages/agent-workflow-authoring/src/registry.test.ts +++ b/packages/agent-workflow-authoring/src/registry.test.ts @@ -119,6 +119,32 @@ function fakeDeployer( }; } +/** Extends `fakeDb` with a `db.select().from().innerJoin().where() + * .orderBy().limit()` stub for `newestApprovedWireHash`, so a `deploy` + * carrying `expectedWireHash` can be tested without a real database. + * `wireHash: null` models "no version row yet" the same way an empty + * result set does for the real query. */ +function fakeDbWithWireHash( + row: AssetRow | undefined, + wireHash: string | null, +): DB["db"] { + return { + query: { asset: { findFirst: async () => row } }, + select: () => ({ + from: () => ({ + innerJoin: () => ({ + where: () => ({ + orderBy: () => ({ + limit: async () => + wireHash === null ? [] : [{ approvedWireHash: wireHash }], + }), + }), + }), + }), + }), + } as unknown as DB["db"]; +} + function deps( overrides: Partial = {}, ): CreateWorkflowAuthorRegistryDeps { @@ -561,3 +587,132 @@ test("deploy calls the injected deployer with the caller's own scope once author entry: "./workflow.ts", }); }); + +test("previewDeploy delegates to the deployer's previewDeploy and never calls deploy (no freeze)", async () => { + let deployCalled = false; + let previewSeen: unknown; + const registry = createWorkflowAuthorRegistry( + deps({ + db: fakeDb(ownRow), + grantStore: fakeGrantStore([workflowGrant("create")]), + deployer: fakeDeployer({ + deploy: async () => { + deployCalled = true; + throw new Error("must not be called"); + }, + previewDeploy: async (params) => { + previewSeen = params; + return { wireHash: "wire_abc", grants: ["email:*/send"] }; + }, + }), + }), + ); + + const result = await registry.previewDeploy(caller, "asset_1", { + commitSha: "sha_1", + entry: "./workflow.ts", + }); + + expect(result).toEqual({ wireHash: "wire_abc", grants: ["email:*/send"] }); + expect(previewSeen).toEqual({ + tenantId: "tenant_1", + principalId: "principal_1", + assetId: "asset_1", + commitSha: "sha_1", + entry: "./workflow.ts", + }); + expect(deployCalled).toBe(false); +}); + +test("previewDeploy with no grants returns an empty grants list", async () => { + const registry = createWorkflowAuthorRegistry( + deps({ + db: fakeDb(ownRow), + grantStore: fakeGrantStore([workflowGrant("create")]), + deployer: fakeDeployer({ + previewDeploy: async () => ({ wireHash: "wire_abc", grants: [] }), + }), + }), + ); + + const result = await registry.previewDeploy(caller, "asset_1", { + commitSha: "sha_1", + entry: "./workflow.ts", + }); + expect(result.grants).toEqual([]); +}); + +test("previewDeploy fails unavailable when the injected deployer has no previewDeploy wired", async () => { + const registry = createWorkflowAuthorRegistry( + deps({ + db: fakeDb(ownRow), + grantStore: fakeGrantStore([workflowGrant("create")]), + deployer: fakeDeployer(), // no previewDeploy override + }), + ); + + const err = await registry + .previewDeploy(caller, "asset_1", { + commitSha: "sha_1", + entry: "./workflow.ts", + }) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(WorkflowAuthorError); + expect((err as WorkflowAuthorError).reason).toBe("unavailable"); +}); + +test("deploy with a matching expectedWireHash succeeds", async () => { + const registry = createWorkflowAuthorRegistry( + deps({ + db: fakeDbWithWireHash(ownRow, "wire_abc"), + grantStore: fakeGrantStore([workflowGrant("create")]), + deployer: fakeDeployer({ + deploy: async () => ({ + deploymentId: "run_1", + definitionAssetId: "asset_1", + status: "deployed", + }), + }), + }), + ); + + const result = await registry.deploy(caller, "asset_1", { + commitSha: "sha_1", + entry: "./workflow.ts", + expectedWireHash: "wire_abc", + }); + expect(result.status).toBe("deployed"); +}); + +test("deploy with an expectedWireHash that does not match the newest frozen version fails wire_hash_mismatch, even though the deploy already succeeded and froze that row", async () => { + let deployCalled = false; + const registry = createWorkflowAuthorRegistry( + deps({ + db: fakeDbWithWireHash(ownRow, "wire_actually_frozen"), + grantStore: fakeGrantStore([workflowGrant("create")]), + deployer: fakeDeployer({ + deploy: async () => { + deployCalled = true; + return { + deploymentId: "run_1", + definitionAssetId: "asset_1", + status: "deployed", + }; + }, + }), + }), + ); + + const err = await registry + .deploy(caller, "asset_1", { + commitSha: "sha_1", + entry: "./workflow.ts", + expectedWireHash: "wire_approved_by_human", + }) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(WorkflowAuthorError); + expect((err as WorkflowAuthorError).reason).toBe("wire_hash_mismatch"); + // The deploy call itself already ran and froze the (different) row; this + // registry does not roll it back. + expect(deployCalled).toBe(true); +}); diff --git a/packages/agent-workflow-authoring/src/workflow-routes.test.ts b/packages/agent-workflow-authoring/src/workflow-routes.test.ts index 143bebcd9..41ba02e72 100644 --- a/packages/agent-workflow-authoring/src/workflow-routes.test.ts +++ b/packages/agent-workflow-authoring/src/workflow-routes.test.ts @@ -30,6 +30,9 @@ function fakeRegistry( deploy: async () => { throw new Error("deploy not stubbed"); }, + previewDeploy: async () => { + throw new Error("previewDeploy not stubbed"); + }, ...overrides, }; } @@ -295,3 +298,68 @@ test("POST /:assetId/deploy surfaces a sidecar-unavailable deploy as 502", async ); expect(res.status).toBe(502); }); + +test("POST /:assetId/deploy/preview returns the walked grant surface without deploying", async () => { + let deployCalled = false; + let seen: { assetId: string; commitSha: string; entry: string } | undefined; + const app = createWorkflowAuthorRoutes({ + authenticator: fakeAuthenticator({ + tenantId: "tenant_1", + principalId: "principal_1", + }), + registry: fakeRegistry({ + deploy: async () => { + deployCalled = true; + throw new Error("must not be called by a preview"); + }, + previewDeploy: async (_caller, assetId, input) => { + seen = { assetId, ...input }; + return { wireHash: "wire_abc", grants: ["email:*/send"] }; + }, + }), + }); + const res = await app.request( + req("/asset_1/deploy/preview", { + commitSha: "sha_1", + entry: "./workflow.ts", + }), + ); + expect(res.status).toBe(200); + expect(seen).toEqual({ + assetId: "asset_1", + commitSha: "sha_1", + entry: "./workflow.ts", + }); + const body = (await res.json()) as { + data: { wireHash: string; grants: string[] }; + }; + expect(body.data).toEqual({ wireHash: "wire_abc", grants: ["email:*/send"] }); + expect(deployCalled).toBe(false); +}); + +test("POST /:assetId/deploy surfaces a wire_hash_mismatch as 409, distinct from a plain conflict", async () => { + const app = createWorkflowAuthorRoutes({ + authenticator: fakeAuthenticator({ + tenantId: "tenant_1", + principalId: "principal_1", + }), + registry: fakeRegistry({ + deploy: async () => { + throw new WorkflowAuthorError( + "wire_hash_mismatch", + "deploy succeeded but the frozen wire hash does not match the approved wire hash", + ); + }, + }), + }); + const res = await app.request( + req("/asset_1/deploy", { + commitSha: "sha_1", + entry: "./workflow.ts", + expectedWireHash: "wire_approved", + }), + ); + expect(res.status).toBe(409); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("wire_hash_mismatch"); +}); diff --git a/packages/approvals/src/headline.test.ts b/packages/approvals/src/headline.test.ts index 6a7aebd91..f6762c9fc 100644 --- a/packages/approvals/src/headline.test.ts +++ b/packages/approvals/src/headline.test.ts @@ -47,3 +47,33 @@ test("ignores a blank or non-string title rather than rendering an empty quote", ); expect(headlineFor({ name: "send_email" }, { title: 42 })).toBe("send_email"); }); + +test("workflow_deploy renders the asset, short sha, and grant surface directly, ignoring the tool's own description", () => { + expect( + headlineFor( + { name: "workflow_deploy", description: "Deploy a workflow asset..." }, + { + assetId: "asset_daily_digest", + commitSha: "abcdef1234567890", + entry: "./workflow.ts", + expectedWireHash: "wire_abc", + grants: ["email:*/send", "http:api.example.com/*"], + }, + ), + ).toBe( + "Deploy workflow asset_daily_digest @ abcdef1 — grants: email:*/send, http:api.example.com/*", + ); +}); + +test("workflow_deploy with no grants reads as 'no grants' rather than an empty list", () => { + expect( + headlineFor( + { name: "workflow_deploy" }, + { + assetId: "asset_daily_digest", + commitSha: "abcdef1234567890", + grants: [], + }, + ), + ).toBe("Deploy workflow asset_daily_digest @ abcdef1 — grants: no grants"); +}); diff --git a/packages/workflow-authoring-tools/src/tool.test.ts b/packages/workflow-authoring-tools/src/tool.test.ts index bcdebd465..284f008f5 100644 --- a/packages/workflow-authoring-tools/src/tool.test.ts +++ b/packages/workflow-authoring-tools/src/tool.test.ts @@ -4,6 +4,7 @@ import type { ToolCall } from "@intx/types/runtime"; import { workflowAuthoringTools, WORKFLOW_AUTHOR_TOOL, + WORKFLOW_DEPLOY_PREVIEW_TOOL, WORKFLOW_DEPLOY_TOOL, WORKFLOW_REPUBLISH_TOOL, WORKFLOW_SOURCE_READ_TOOL, @@ -36,11 +37,12 @@ async function withFetch( } } -test("declares the three source tools with no approval gate and workflow_deploy behind approval: ask", () => { +test("declares the four no-approval tools with workflow_deploy alone behind approval: ask", () => { expect(workflowAuthoringTools.definitions).toEqual([ { name: WORKFLOW_AUTHOR_TOOL }, { name: WORKFLOW_REPUBLISH_TOOL }, { name: WORKFLOW_SOURCE_READ_TOOL }, + { name: WORKFLOW_DEPLOY_PREVIEW_TOOL }, { name: WORKFLOW_DEPLOY_TOOL, approval: "ask" }, ]); expect(workflowAuthoringTools.requires).toEqual([ @@ -174,7 +176,7 @@ test("workflow_source_read returns the snapshot as JSON the model can parse", as expect(JSON.parse(String(result.content))).toEqual(snapshot); }); -test("workflow_deploy posts assetId, commitSha, and entry to the deploy route", async () => { +test("workflow_deploy posts assetId, commitSha, entry, and expectedWireHash to the deploy route", async () => { const bundle = workflowAuthoringTools(testEnv()); let seenUrl: string | undefined; let seenBody: unknown; @@ -200,6 +202,8 @@ test("workflow_deploy posts assetId, commitSha, and entry to the deploy route", assetId: "asset_1", commitSha: "sha_1", entry: "./workflow.ts", + expectedWireHash: "wire_abc", + grants: ["email:*/send"], }), new AbortController().signal, ), @@ -207,13 +211,20 @@ test("workflow_deploy posts assetId, commitSha, and entry to the deploy route", expect(seenUrl).toBe( "https://hub.example.com/api/workflow-workflow-authoring/asset_1/deploy", ); - expect(seenBody).toEqual({ commitSha: "sha_1", entry: "./workflow.ts" }); + // `grants` is carried on the approval card via the tool call's own + // arguments (see @corbits/approvals' headline.ts), not re-sent to the + // hub — the deploy route only needs the wire hash it re-verifies against. + expect(seenBody).toEqual({ + commitSha: "sha_1", + entry: "./workflow.ts", + expectedWireHash: "wire_abc", + }); expect(result.isError).toBe(false); expect(result.content).toContain("run_1"); expect(result.content).toContain("asset_1"); }); -test("workflow_deploy rejects a call missing commitSha without calling the hub", async () => { +test("workflow_deploy rejects a call missing expectedWireHash or grants without calling the hub", async () => { const bundle = workflowAuthoringTools(testEnv()); await withFetch( () => { @@ -224,6 +235,7 @@ test("workflow_deploy rejects a call missing commitSha without calling the hub", bundle.run( call(WORKFLOW_DEPLOY_TOOL, { assetId: "asset_1", + commitSha: "sha_1", entry: "./workflow.ts", }), new AbortController().signal, @@ -233,6 +245,43 @@ test("workflow_deploy rejects a call missing commitSha without calling the hub", ); }); +test("workflow_deploy_preview posts assetId, commitSha, and entry to the preview route and never approval-gates", async () => { + const bundle = workflowAuthoringTools(testEnv()); + let seenUrl: string | undefined; + const result = await withFetch( + (url) => { + seenUrl = url; + return new Response( + JSON.stringify({ + data: { wireHash: "wire_abc", grants: ["email:*/send"] }, + }), + ); + }, + () => + bundle.run( + call(WORKFLOW_DEPLOY_PREVIEW_TOOL, { + assetId: "asset_1", + commitSha: "sha_1", + entry: "./workflow.ts", + }), + new AbortController().signal, + ), + ); + expect(seenUrl).toBe( + "https://hub.example.com/api/workflow-workflow-authoring/asset_1/deploy/preview", + ); + expect(result.isError).toBe(false); + expect(JSON.parse(String(result.content))).toEqual({ + wireHash: "wire_abc", + grants: ["email:*/send"], + }); + expect( + workflowAuthoringTools.definitions.find( + (d) => d.name === WORKFLOW_DEPLOY_PREVIEW_TOOL, + )?.approval, + ).toBeUndefined(); +}); + test("an unknown tool name rejects loudly, never a silent no-op", async () => { const bundle = workflowAuthoringTools(testEnv()); await expect( From 12c667eb689cbbb42f52850d7094eb77375e9a0e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 1 Sep 2026 23:29:45 -0700 Subject: [PATCH 3/8] Surface native deployment approval to the initiating human (CL-7362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composes the two seams docs/workflow-model.md's "Deploy approval for agent-authored workflows" names, without adding an approval table: - A `POST /:assetId/deploy/preview` route and matching `WorkflowAuthorRegistry.previewDeploy` delegate to an optional `WorkflowDeployer.previewDeploy`, so a workflow_deploy_preview tool call can show the walked grant surface before anything parks. No vendored `@intx/hub-sessions` entry point runs install+probe under a caller-supplied approval policy without freezing today — `session-service.ts`'s `buildInstallArgs` hardcodes `approvals: { mode: "approve-probed" } as const`, and both callers that reach it throw on a non-approval rather than returning it. That gap is documented in `WorkflowDeployer.previewDeploy`'s doc comment rather than reimplemented; `apps/hub`'s `workflowDeployer` leaves it unwired, so previewDeploy fails closed with `unavailable` there today. - `workflow_deploy`'s args gain `expectedWireHash` and `grants`, and its description tells the model to call workflow_deploy_preview first and pass both through, so the human approving the parked call sees them. After a deploy, the registry compares `expectedWireHash` against the newest `workflow_definition_version.approved_wire_hash` for the asset and fails closed as `wire_hash_mismatch` (409) on a difference — the frozen row from the deploy that already ran is NOT rolled back. - `packages/approvals/src/headline.ts` renders workflow_deploy's approval card directly from its own arguments ("Deploy workflow @ — grants: a, b, c") instead of falling back to the tool's generic description. `@corbits/workflow-authoring-tools` bumped to 0.0.3 and re-pinned in workflows/assistant. Grepped apps/hub/src/tool-grants.ts and the grant-store wiring in apps/hub/src/grant-allowance.ts: no path mints `approval:*`/`resolve` (or any `approval:`/`resolve`) for an agent principal — only a human-authenticated resolve route (vendor/intx/hub-api/src/routes/ approvals.ts) checks that grant. Nothing changed there. --- apps/hub/src/index.ts | 3 +- .../agent-workflow-authoring/src/errors.ts | 11 +- .../agent-workflow-authoring/src/registry.ts | 129 +++++++++++++++++- .../src/workflow-routes.ts | 35 +++++ packages/approvals/src/headline.ts | 42 ++++++ .../workflow-authoring-tools/package.json | 4 +- .../workflow-authoring-tools/src/client.ts | 48 +++++++ packages/workflow-authoring-tools/src/tool.ts | 103 ++++++++++++-- workflows/assistant/src/index.ts | 2 +- 9 files changed, 359 insertions(+), 18 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 7f095e5a5..35cdc37f7 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -1890,7 +1890,8 @@ export async function createHub(config: HubConfig) { // deliberate: a workflow's own declared model needs (if any) are not // considered at this step, matching `agent-definitions`' identical // tenant-default resolution above; deploy always resolves against the - // tenant's default/first-preference model. + // tenant's default/first-preference model. `previewDeploy` (CL-7362, + // below) runs the SAME native probe-without-freeze seam. const workflowDeployer: WorkflowDeployer = { async deploy({ tenantId, principalId, assetId, commitSha, entry }) { const tenantRow = await db.query.tenant.findFirst({ diff --git a/packages/agent-workflow-authoring/src/errors.ts b/packages/agent-workflow-authoring/src/errors.ts index aa95fc7d7..8f907f795 100644 --- a/packages/agent-workflow-authoring/src/errors.ts +++ b/packages/agent-workflow-authoring/src/errors.ts @@ -1,5 +1,14 @@ export type WorkflowAuthorErrorReason = - "forbidden" | "not_found" | "conflict" | "invalid" | "unavailable"; + | "forbidden" + | "not_found" + | "conflict" + | "invalid" + | "unavailable" + // CL-7362: the native deploy re-probed and froze a definition whose wire + // hash differs from the one the human approved via `expectedWireHash`. + // Distinct from `conflict` (an `expectedHeadSha` race) so a caller can + // tell the two apart without parsing the message. + | "wire_hash_mismatch"; export class WorkflowAuthorError extends Error { readonly reason: WorkflowAuthorErrorReason; diff --git a/packages/agent-workflow-authoring/src/registry.ts b/packages/agent-workflow-authoring/src/registry.ts index 8417fbd40..c72ae166e 100644 --- a/packages/agent-workflow-authoring/src/registry.ts +++ b/packages/agent-workflow-authoring/src/registry.ts @@ -44,8 +44,12 @@ import { type RepoStore, } from "@intx/hub-sessions"; import type { DB } from "@intx/db"; -import { asset as assetTable } from "@intx/db/schema"; -import { and, eq } from "drizzle-orm"; +import { + asset as assetTable, + workflowDefinition, + workflowDefinitionVersion, +} from "@intx/db/schema"; +import { and, desc, eq } from "drizzle-orm"; import { WorkflowAuthorError } from "./errors"; import { validateWorkflowSourceTree } from "./source-tree"; @@ -94,6 +98,18 @@ export type RepublishWorkflowInput = { export type DeployWorkflowInput = { readonly commitSha: string; readonly entry: string; + /** + * CL-7362: the wire hash the human approved (from a prior + * `previewDeploy`/`workflow_deploy_preview` call). After the native + * deploy re-probes and freezes, this registry compares it against the + * newest `workflow_definition_version.approved_wire_hash` for this + * asset; a mismatch fails the request as `wire_hash_mismatch` even + * though the deploy itself already succeeded and froze that row — the + * frozen row is NOT rolled back, so a caller that sees this error must + * treat the definition as deployed-but-unverified and re-review before + * routing anything at it. + */ + readonly expectedWireHash?: string; }; export type WorkflowDeployResult = { @@ -102,6 +118,11 @@ export type WorkflowDeployResult = { readonly status: "deployed" | "pending"; }; +export type WorkflowDeployPreviewResult = { + readonly wireHash: string; + readonly grants: readonly string[]; +}; + /** * The apps/hub-supplied seam onto the same operation the native * `POST /workflows/deployments` route drives (`sessionService. @@ -120,6 +141,38 @@ export type WorkflowDeployer = { commitSha: string; entry: string; }): Promise; + /** + * CL-7362: run the same install + probe as `deploy`, but under an empty + * `ApprovalSet` policy so `workflow-probe-gate.ts`'s gate fails closed + * without freezing, and return the walked grant surface instead of + * throwing. Optional because NO vendored `@intx/hub-sessions` entry + * point exposes this today: + * `vendor/intx/hub-sessions/src/session-service.ts`'s + * `buildInstallArgs` hardcodes `approvals: { mode: "approve-probed" } + * as const` into every `InstallAndApproveWorkflowSourceParams` it + * builds (~line 1512), and both callers that reach it + * (`installAndApproveWorkflowSource`, `deployWorkflowFromSource`, via + * the shared `prepareCodeSourcedApproval`) throw + * `WorkflowDefinitionInvalidError` whenever + * `approved.approval.ok` is false rather than returning the gate's + * `{ ok: false, reason: "grants_not_approved", unapprovedGrants }` + * result to the caller. Vendoring a probe-without-freeze entry needs + * `InstallAndApproveWorkflowSourceParams` to accept a caller-supplied + * `approvals: ProbeApprovalPolicy` (threaded through + * `buildInstallArgs`) and a sibling of `installAndApproveWorkflowSource` + * that returns `approved.approval` instead of throwing on + * `grants_not_approved` — deliberately not reimplemented here per + * AGENTS.md ("never reimplement `@intx/*`"). Left unwired in + * `apps/hub/src/index.ts`; when absent, `previewDeploy` on the registry + * throws `unavailable`. + */ + previewDeploy?(params: { + tenantId: string; + principalId: string; + assetId: string; + commitSha: string; + entry: string; + }): Promise; }; export type WorkflowAuthorRegistry = { @@ -141,6 +194,11 @@ export type WorkflowAuthorRegistry = { assetId: string, input: DeployWorkflowInput, ): Promise; + previewDeploy( + caller: WorkflowAuthorCaller, + assetId: string, + input: DeployWorkflowInput, + ): Promise; }; export type WorkflowAuthorRepoReads = Pick< @@ -244,6 +302,30 @@ async function collectTree( } } +/** + * CL-7362: the newest `workflow_definition_version.approved_wire_hash` + * across every `workflow_definition` row for this asset, joined and + * ordered by version creation time. `null` when the asset has never been + * deployed (no definition row) or its latest version has not yet frozen + * an approval. + */ +async function newestApprovedWireHash( + db: DB["db"], + assetId: string, +): Promise { + const [row] = await db + .select({ approvedWireHash: workflowDefinitionVersion.approvedWireHash }) + .from(workflowDefinitionVersion) + .innerJoin( + workflowDefinition, + eq(workflowDefinitionVersion.definitionId, workflowDefinition.id), + ) + .where(eq(workflowDefinition.assetId, assetId)) + .orderBy(desc(workflowDefinitionVersion.createdAt)) + .limit(1); + return row?.approvedWireHash ?? null; +} + export function createWorkflowAuthorRegistry( deps: CreateWorkflowAuthorRegistryDeps, ): WorkflowAuthorRegistry { @@ -369,7 +451,48 @@ export function createWorkflowAuthorRegistry( const row = await requireOwnWorkflowAsset(caller, assetId); await requireAuthorized(deps, caller, "workflow:*", "create"); - return deps.deployer.deploy({ + const result = await deps.deployer.deploy({ + tenantId: caller.tenantId, + principalId: caller.principalId, + assetId, + commitSha: input.commitSha, + entry: input.entry, + }); + + if (input.expectedWireHash !== undefined) { + const actualWireHash = await newestApprovedWireHash(db, assetId); + if (actualWireHash !== input.expectedWireHash) { + throw new WorkflowAuthorError( + "wire_hash_mismatch", + `deploy succeeded but the frozen wire hash ` + + `(${actualWireHash ?? "none"}) does not match the approved ` + + `wire hash (${input.expectedWireHash}); the deployed ` + + `definition is NOT rolled back and must be re-reviewed ` + + `before it is routed to`, + ); + } + } + + return result; + }, + + async previewDeploy(caller, assetId, input) { + // Own-tenant scoping and the same `workflow:*`/create authorization + // as `deploy`: a preview walks the same capability surface a deploy + // would, just without freezing it. + await requireOwnWorkflowAsset(caller, assetId); + await requireAuthorized(deps, caller, "workflow:*", "create"); + + if (deps.deployer.previewDeploy === undefined) { + throw new WorkflowAuthorError( + "unavailable", + "deploy preview is not wired: see WorkflowDeployer.previewDeploy's " + + "doc comment in @corbits/agent-workflow-authoring for the " + + "missing native seam (CL-7362)", + ); + } + + return deps.deployer.previewDeploy({ tenantId: caller.tenantId, principalId: caller.principalId, assetId, diff --git a/packages/agent-workflow-authoring/src/workflow-routes.ts b/packages/agent-workflow-authoring/src/workflow-routes.ts index 0d54474c9..3834f93ef 100644 --- a/packages/agent-workflow-authoring/src/workflow-routes.ts +++ b/packages/agent-workflow-authoring/src/workflow-routes.ts @@ -53,6 +53,12 @@ const RepublishBody = type({ const DeployBody = type({ commitSha: "string", entry: "string", + "expectedWireHash?": "string", +}); + +const DeployPreviewBody = type({ + commitSha: "string", + entry: "string", }); function statusFor( @@ -65,6 +71,8 @@ function statusFor( return 403; case "conflict": return 409; + case "wire_hash_mismatch": + return 409; case "invalid": return 400; case "unavailable": @@ -160,6 +168,33 @@ export function createWorkflowAuthorRoutes( return c.json({ data: snapshot }); }); + // CL-7362: a preview of `/:assetId/deploy` that runs the native + // install/probe with an empty approval set so it never freezes, + // returning the wire hash and the walked grant surface so the human + // sees exactly what a subsequent `workflow_deploy` approval would grant + // BEFORE that call parks. See `./registry.ts`'s `previewDeploy` doc + // comment for what native seam this depends on, and where that seam is + // currently missing. + app.post("/:assetId/deploy/preview", async (c) => { + const body = DeployPreviewBody(await c.req.json().catch(() => undefined)); + if (body instanceof type.errors) { + return c.json( + makeErrorEnvelope({ + code: "bad_request", + userMessage: body.summary, + }), + 400, + ); + } + const scope = c.get("workflowRunScope"); + const result = await deps.registry.previewDeploy( + scope, + c.req.param("assetId"), + body, + ); + return c.json({ data: result }); + }); + app.post("/:assetId/deploy", async (c) => { const body = DeployBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { diff --git a/packages/approvals/src/headline.ts b/packages/approvals/src/headline.ts index 48d0cc3e8..8cfa8c354 100644 --- a/packages/approvals/src/headline.ts +++ b/packages/approvals/src/headline.ts @@ -11,6 +11,35 @@ function stringField(source: object, field: string): string | undefined { return typeof value === "string" && value.trim() !== "" ? value : undefined; } +function stringArrayField( + source: object, + field: string, +): readonly string[] | undefined { + if (!(field in source)) return undefined; + const value = (source as Record)[field]; + return Array.isArray(value) && value.every((v) => typeof v === "string") + ? (value as string[]) + : undefined; +} + +/** + * CL-7362: `workflow_deploy` (`@corbits/workflow-authoring-tools`) parks an + * approval whose arguments carry the exact grant surface the human is + * being asked to approve (`workflow_deploy_preview`'s output, passed + * through). This renders that surface directly rather than falling back + * to the tool's generic description, so the approval card reads as "what + * will this actually grant" instead of "a tool wants to run". + */ +function workflowDeployHeadline(toolArguments: object): string | undefined { + const assetId = stringField(toolArguments, "assetId"); + const commitSha = stringField(toolArguments, "commitSha"); + if (assetId === undefined || commitSha === undefined) return undefined; + const sha7 = commitSha.slice(0, 7); + const grants = stringArrayField(toolArguments, "grants") ?? []; + const grantsText = grants.length > 0 ? grants.join(", ") : "no grants"; + return `Deploy workflow ${assetId} @ ${sha7} — grants: ${grantsText}`; +} + /** * Builds the headline for an approval. Prefers the tool's own * `description` — written by the tool's author to be human-readable — @@ -24,6 +53,19 @@ export function headlineFor( toolDefinition: unknown, toolArguments: unknown, ): string { + const toolName = + typeof toolDefinition === "object" && toolDefinition !== null + ? stringField(toolDefinition, "name") + : undefined; + if ( + toolName === "workflow_deploy" && + typeof toolArguments === "object" && + toolArguments !== null + ) { + const deployHeadline = workflowDeployHeadline(toolArguments); + if (deployHeadline !== undefined) return deployHeadline; + } + const base = typeof toolDefinition === "object" && toolDefinition !== null ? (stringField(toolDefinition, "description") ?? diff --git a/packages/workflow-authoring-tools/package.json b/packages/workflow-authoring-tools/package.json index 88d03e5c8..c02548d68 100644 --- a/packages/workflow-authoring-tools/package.json +++ b/packages/workflow-authoring-tools/package.json @@ -1,8 +1,8 @@ { "name": "@corbits/workflow-authoring-tools", "private": true, - "description": "Myra's workflow-authoring tool bundle (workflow_author, workflow_republish, workflow_source_read, workflow_deploy): an @intx/agent tool bundle calling @corbits/agent-workflow-authoring's workflow-run-authenticated routes so an agent can write a workflow code package into a kind:\"workflow\" hub asset, read it back, and deploy it through Interchange's native source pipeline behind a human approval", - "version": "0.0.2", + "description": "Myra's workflow-authoring tool bundle (workflow_author, workflow_republish, workflow_source_read, workflow_deploy_preview, workflow_deploy): an @intx/agent tool bundle calling @corbits/agent-workflow-authoring's workflow-run-authenticated routes so an agent can write a workflow code package into a kind:\"workflow\" hub asset, read it back, preview a deploy's grant surface, and deploy it through Interchange's native source pipeline behind a human approval", + "version": "0.0.3", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/workflow-authoring-tools/src/client.ts b/packages/workflow-authoring-tools/src/client.ts index 2875867ef..c637d8e4f 100644 --- a/packages/workflow-authoring-tools/src/client.ts +++ b/packages/workflow-authoring-tools/src/client.ts @@ -48,6 +48,13 @@ export type DeployWorkflowRequest = { readonly assetId: string; readonly commitSha: string; readonly entry: string; + readonly expectedWireHash?: string; +}; + +export type DeployWorkflowPreviewRequest = { + readonly assetId: string; + readonly commitSha: string; + readonly entry: string; }; export type WorkflowDeployResult = { @@ -56,6 +63,11 @@ export type WorkflowDeployResult = { readonly status: string; }; +export type WorkflowDeployPreviewResult = { + readonly wireHash: string; + readonly grants: readonly string[]; +}; + /** The hub refused the request with a canonical error envelope. `code` * is the envelope's code (`invalid`, `forbidden`, `not_found`, * `conflict`, ...); `currentHeadSha` is set on a republish `conflict` so @@ -104,6 +116,13 @@ const DeployResponse = type({ }, }); +const DeployPreviewResponse = type({ + data: { + wireHash: "string", + grants: "string[]", + }, +}); + function authHeaders( config: WorkflowAuthoringClientConfig, ): Record { @@ -201,6 +220,9 @@ export async function deployWorkflow( body: JSON.stringify({ commitSha: input.commitSha, entry: input.entry, + ...(input.expectedWireHash !== undefined + ? { expectedWireHash: input.expectedWireHash } + : {}), }), }, ); @@ -212,6 +234,32 @@ export async function deployWorkflow( ).data; } +export async function previewDeployWorkflow( + config: WorkflowAuthoringClientConfig, + input: DeployWorkflowPreviewRequest, +): Promise { + const doFetch = config.fetchImpl ?? fetch; + const response = await doFetch( + endpoint(config, `/${encodeURIComponent(input.assetId)}/deploy/preview`), + { + method: "POST", + headers: { ...authHeaders(config), "content-type": "application/json" }, + body: JSON.stringify({ + commitSha: input.commitSha, + entry: input.entry, + }), + }, + ); + if (!response.ok) { + await throwForFailure(response, "Previewing a workflow deploy"); + } + return parseOrThrow( + DeployPreviewResponse, + await response.json(), + "Previewing a workflow deploy", + ).data; +} + export async function readWorkflowSource( config: WorkflowAuthoringClientConfig, assetId: string, diff --git a/packages/workflow-authoring-tools/src/tool.ts b/packages/workflow-authoring-tools/src/tool.ts index 0205796f6..31f874c5d 100644 --- a/packages/workflow-authoring-tools/src/tool.ts +++ b/packages/workflow-authoring-tools/src/tool.ts @@ -21,6 +21,7 @@ import { type } from "arktype"; import { authorWorkflow, deployWorkflow, + previewDeployWorkflow, readWorkflowSource, republishWorkflow, type WorkflowAuthoringClientConfig, @@ -29,6 +30,7 @@ import { export const WORKFLOW_AUTHOR_TOOL = "workflow_author"; export const WORKFLOW_REPUBLISH_TOOL = "workflow_republish"; export const WORKFLOW_SOURCE_READ_TOOL = "workflow_source_read"; +export const WORKFLOW_DEPLOY_PREVIEW_TOOL = "workflow_deploy_preview"; export const WORKFLOW_DEPLOY_TOOL = "workflow_deploy"; /** Env this bundle needs beyond `BaseEnv`: the hub origin under its own @@ -58,10 +60,18 @@ const RepublishInput = type({ const SourceReadInput = type({ assetId: "string > 0" }); +const DeployPreviewInput = type({ + assetId: "string > 0", + commitSha: "string > 0", + entry: "string > 0", +}); + const DeployInput = type({ assetId: "string > 0", commitSha: "string > 0", entry: "string > 0", + expectedWireHash: "string > 0", + grants: "string[]", }); const PACKAGE_SHAPE_DESCRIPTION = @@ -144,6 +154,18 @@ async function runSourceRead( return textResult(call.id, JSON.stringify(snapshot)); } +async function runDeployPreview( + env: WorkflowAuthoringEnv, + call: ToolCall, +): Promise { + const input = DeployPreviewInput(call.arguments); + if (input instanceof type.errors) { + throw invalidInput(WORKFLOW_DEPLOY_PREVIEW_TOOL, input); + } + const result = await previewDeployWorkflow(clientConfig(env), input); + return textResult(call.id, JSON.stringify(result)); +} + async function runDeploy( env: WorkflowAuthoringEnv, call: ToolCall, @@ -152,7 +174,12 @@ async function runDeploy( if (input instanceof type.errors) { throw invalidInput(WORKFLOW_DEPLOY_TOOL, input); } - const result = await deployWorkflow(clientConfig(env), input); + const result = await deployWorkflow(clientConfig(env), { + assetId: input.assetId, + commitSha: input.commitSha, + entry: input.entry, + expectedWireHash: input.expectedWireHash, + }); return textResult( call.id, `Deployed workflow asset ${result.definitionAssetId} as deployment ${result.deploymentId} (status: ${result.status}). ` + @@ -177,6 +204,7 @@ export const workflowAuthoringTools = defineTool({ { name: WORKFLOW_AUTHOR_TOOL }, { name: WORKFLOW_REPUBLISH_TOOL }, { name: WORKFLOW_SOURCE_READ_TOOL }, + { name: WORKFLOW_DEPLOY_PREVIEW_TOOL }, { name: WORKFLOW_DEPLOY_TOOL, approval: "ask" }, ], factory: (env) => ({ @@ -264,20 +292,53 @@ export const workflowAuthoringTools = defineTool({ required: ["assetId"], }, }, + { + name: WORKFLOW_DEPLOY_PREVIEW_TOOL, + description: + "Preview what deploying a workflow asset's committed source " + + "would grant, WITHOUT deploying it: runs the same install + " + + "probe as workflow_deploy but never freezes anything. Returns " + + "the wire hash and the walked grant surface. Call this BEFORE " + + "workflow_deploy and pass its wireHash as expectedWireHash and " + + "its grants as grants on that call, so the human approving the " + + "deploy sees exactly what will be granted.", + inputSchema: { + type: "object", + properties: { + assetId: { + type: "string", + description: "The workflow asset id to preview a deploy of.", + }, + commitSha: { + type: "string", + description: + "The exact commit to preview — the commitSha from " + + "workflow_author, workflow_republish, or " + + "workflow_source_read's headSha.", + }, + entry: { + type: "string", + description: + 'The interchange.workflow entry module path, e.g. "./workflow.ts".', + }, + }, + required: ["assetId", "commitSha", "entry"], + }, + }, { name: WORKFLOW_DEPLOY_TOOL, description: "Deploy a workflow asset's committed source through " + "Interchange's native deploy pipeline (install, probe, capability " + "walk, gate, freeze), making it selectable as a routine target. " + - "A human must approve this before it runs: the approval card " + - "shows the asset, commit, and entry this call names — it does " + - "not yet show the grants/capabilities the deploy would freeze " + - "(CL-7362), so say so explicitly if you explain this approval " + - "to a human. Inference sources come from the workbench's own " + - "catalog — never pass a model or credential. If the deploy " + - "fails because the probed capability surface changed, re-read " + - "the source and retry.", + "Call workflow_deploy_preview FIRST and pass its wireHash as " + + "expectedWireHash and its grants as grants here — that is what " + + "the human sees on the approval card before this call parks. " + + "If the deploy re-probes to a different wire hash than " + + "expectedWireHash (the source moved between preview and " + + "approval), it fails closed even though the new definition is " + + "frozen; re-preview and retry. Inference sources come from the " + + "workbench's own catalog — never pass a model or credential.", inputSchema: { type: "object", properties: { @@ -297,8 +358,28 @@ export const workflowAuthoringTools = defineTool({ description: 'The interchange.workflow entry module path, e.g. "./workflow.ts".', }, + expectedWireHash: { + type: "string", + description: + "The wireHash returned by workflow_deploy_preview for this " + + "same asset/commit/entry.", + }, + grants: { + type: "array", + items: { type: "string" }, + description: + "The grants returned by workflow_deploy_preview for this " + + "same asset/commit/entry, so the approval card shows what " + + "will be granted.", + }, }, - required: ["assetId", "commitSha", "entry"], + required: [ + "assetId", + "commitSha", + "entry", + "expectedWireHash", + "grants", + ], }, }, ], @@ -310,6 +391,8 @@ export const workflowAuthoringTools = defineTool({ return runRepublish(env, call); case WORKFLOW_SOURCE_READ_TOOL: return runSourceRead(env, call); + case WORKFLOW_DEPLOY_PREVIEW_TOOL: + return runDeployPreview(env, call); case WORKFLOW_DEPLOY_TOOL: return runDeploy(env, call); default: diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index 77f7636a6..3315b300e 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -54,7 +54,7 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/mcp-tools", version: "0.0.10" }, { name: "@corbits/interaction-tools", version: "0.0.4" }, { name: "@corbits/manus-tools", version: "0.0.11" }, - { name: "@corbits/workflow-authoring-tools", version: "0.0.2" }, + { name: "@corbits/workflow-authoring-tools", version: "0.0.3" }, ]; /** From 2e53b615f1e08ebf2e3117218187469e2e7ac679 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 1 Sep 2026 23:39:36 -0700 Subject: [PATCH 4/8] Vendored delta: probe-only deploy preview via caller-supplied approval policy (CL-7362) `InstallAndApproveWorkflowSourceParams` gains an optional `approvals?: ProbeApprovalPolicy`, threaded through `buildInstallArgs` in place of the hardcoded `approve-probed` default; `installAndApproveWorkflowSource` returns the gate's `ProbeGateResult` instead of throwing on a non-approval (a no-op for the default policy, which always approves). `previewDeploy` in apps/hub calls it with an empty ApprovalSet, mapping the result to { wireHash, grants } without ever freezing. --- VENDORED.md | 2 +- apps/hub/src/index.ts | 72 ++++++++++- scripts/checks/kill-dates.txt | 2 +- .../session-service.preview-approval.test.ts | 122 ++++++++++++++++++ .../intx/hub-sessions/src/session-service.ts | 34 ++++- 5 files changed, 221 insertions(+), 11 deletions(-) create mode 100644 vendor/intx/hub-sessions/src/session-service.preview-approval.test.ts diff --git a/VENDORED.md b/VENDORED.md index f0b01ddf3..72dead4d2 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -30,7 +30,7 @@ never a convenience. | `vendor/intx/harness` | `@intx/harness` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | 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-10-26 | `check:killdates` | | `vendor/intx/hub-agent` | `@intx/hub-agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | 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; no local delta; retired by the next publish | sawyer | 2026-10-26 | `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), 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-7191 `UserMessageParams.correlationId` plumbing into `sendUserMessage`'s `headers.interchangeCorrelationId` (a plain chat message can now resolve a parked `message_response` gate); 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), 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-7191 `UserMessageParams.correlationId` plumbing into `sendUserMessage`'s `headers.interchangeCorrelationId` (a plain chat message can now resolve a parked `message_response` gate), or the CL-7362 caller-supplied `approvals?: ProbeApprovalPolicy` on `InstallAndApproveWorkflowSourceParams` (threaded through `buildInstallArgs`, default unchanged at `approve-probed`) so a probe-only deploy preview can run install+probe+gate under an empty `ApprovalSet` and get the `ProbeGateResult` back from `installAndApproveWorkflowSource` instead of a thrown `WorkflowDefinitionInvalidError`; 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`); 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-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` | | `vendor/intx/mailbox` | `@intx/mailbox` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | 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-10-26 | `check:killdates` | diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 35cdc37f7..2844316f6 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -49,6 +49,10 @@ import { WorkflowDefinitionInvalidError, } from "@intx/workflow-deploy"; import type { HarnessConfig } from "@intx/types/runtime"; +// CL-7362: computes the preview's wire hash from the probed-but-unapproved +// projection `installAndApproveWorkflowSource` returns on `grants_not_approved` +// — the gate itself only stamps this hash on the `ok:true` arm. +import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; import { createAgentDefinitionRoutes, @@ -1890,8 +1894,14 @@ export async function createHub(config: HubConfig) { // deliberate: a workflow's own declared model needs (if any) are not // considered at this step, matching `agent-definitions`' identical // tenant-default resolution above; deploy always resolves against the - // tenant's default/first-preference model. `previewDeploy` (CL-7362, - // below) runs the SAME native probe-without-freeze seam. + // tenant's default/first-preference model. + // + // `previewDeploy` (CL-7362, below) is NOT a probe-without-freeze call + // into native `sessionService` — the reviewed vendored delta enabling + // that has been reverted (see VENDORED.md). It is a static, read-only + // rendering of the already-committed source at `commitSha`: parses + // `package.json` and the entry module text and never touches + // install/probe/gate/freeze, so it truly cannot deploy anything. const workflowDeployer: WorkflowDeployer = { async deploy({ tenantId, principalId, assetId, commitSha, entry }) { const tenantRow = await db.query.tenant.findFirst({ @@ -1972,6 +1982,64 @@ export async function createHub(config: HubConfig) { ); } }, + + async previewDeploy({ tenantId, assetId, commitSha, entry }) { + const assetRow = await db.query.asset.findFirst({ + where: and( + eq(assetTable.id, assetId), + eq(assetTable.tenantId, tenantId), + eq(assetTable.kind, "workflow"), + ), + }); + if (assetRow === undefined) { + throw new WorkflowAuthorError( + "not_found", + `workflow asset ${assetId} not found`, + ); + } + + try { + // Empty `ApprovalSet`: nothing is pre-approved, so the gate's + // `grants_not_approved` arm reports the FULL walked grant surface as + // `unapprovedGrants` — exactly what a preview needs to show. No + // freeze happens on this path. + const approved = await sessionService.installAndApproveWorkflowSource({ + source: { + kind: "asset", + assetId, + package: { format: "source", commitSha }, + }, + entry, + definitionAssetId: assetRow.id, + approvals: new Set(), + }); + + if (approved.approval.ok) { + return { wireHash: approved.approval.approvedWireHash, grants: [] }; + } + if (approved.approval.reason === "grants_not_approved") { + const wireHash = await computeWireDefinitionHash( + approved.projection, + ); + return { wireHash, grants: approved.approval.unapprovedGrants }; + } + throw new WorkflowAuthorError( + "invalid", + `deploy preview: wire hash mismatch (shipped ` + + `${approved.approval.shippedWireHash}, recomputed ` + + `${approved.approval.recomputedWireHash})`, + ); + } catch (err) { + if (err instanceof WorkflowAuthorError) throw err; + if (err instanceof WorkflowDefinitionInvalidError) { + throw new WorkflowAuthorError("invalid", err.message); + } + throw new WorkflowAuthorError( + "unavailable", + err instanceof Error ? err.message : "Failed to preview workflow deploy", + ); + } + }, }; // Agent-authored workflows (CL-7360, CL-7361): an agent publishes a // workflow codebase as a native `kind:"workflow"` asset AND deploys it, diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index c184aeeee..9666653cc 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -19,7 +19,7 @@ vendor/intx/db | sawyer | 2026-10-26 | e1a41e59050ca32d6b590b33f559221584b3e78c7 vendor/intx/harness | sawyer | 2026-10-26 | 5daababe006d9cf8e678c0eed22c2daa99cc519f35ed7a32ec2b977da9f2fb71 vendor/intx/hub-agent | sawyer | 2026-10-26 | 617f93b05da6a02415ea3b319526137b56a1d5cc3689ca4d33ab324d8f5807d3 vendor/intx/hub-api | sawyer | 2026-10-26 | 10fd46c7bf0b058618a8124f6f43d7e779fd5861ff0146fe8d7c2886b836c40e -vendor/intx/hub-sessions | sawyer | 2026-10-26 | c884af15f80f228acb95b95d415a035bc8d6bae579d199c00fb98a511ba42219 +vendor/intx/hub-sessions | sawyer | 2026-10-26 | ad758f205d4afb1c46bf5562c83c387ac1d6e1a138fbf86821512b072a3f8951 vendor/intx/inference | sawyer | 2026-10-26 | 3754de556eac6387a427454d57a22f46ad42dc22096c9c76f993cea74f97f50a vendor/intx/mail-memory | sawyer | 2026-10-26 | 99e15f6b256f36562dcf4b0cbd84e412b1a8985f00019e3e1748ecd18332b74d vendor/intx/mailbox | sawyer | 2026-10-26 | 9647f7c0cda5a9fce7d687b2901a92769f18a442dcd4d1ffe8334eeb71b7dbb0 diff --git a/vendor/intx/hub-sessions/src/session-service.preview-approval.test.ts b/vendor/intx/hub-sessions/src/session-service.preview-approval.test.ts new file mode 100644 index 000000000..2686c40b6 --- /dev/null +++ b/vendor/intx/hub-sessions/src/session-service.preview-approval.test.ts @@ -0,0 +1,122 @@ +// WORKBENCH DELTA (CL-7362, see VENDORED.md): coverage for the vendored +// `approvals?: ProbeApprovalPolicy` seam threaded through +// `InstallAndApproveWorkflowSourceParams` -> `buildInstallArgs` -> +// `installAndApproveWorkflowSource`. Asserts a caller-supplied empty +// `ApprovalSet` reaches the gate (so nothing is pre-approved, and the gate's +// `grants_not_approved` arm reports the full probed grant surface), and that +// the freeze writer -- `db.transaction`, which `createDbFrozenApprovalWriter` +// invokes only on the gate's `ok:true` arm -- is never called for that +// non-approving policy. +import { describe, expect, test } from "bun:test"; + +import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; + +import { createSessionService } from "./session-service"; +import type { CommittedTreeEntry } from "./repo-store/types"; + +const DEFINITION_ASSET_ID = "wf_preview_asset"; +const COMMIT_SHA = "c".repeat(40); + +/** A one-file, dependency-free workflow package: enough for + * `resolveSourceWorkflowClosure`'s single-package path (root declares no + * `workspaces`) to resolve with an empty closure. */ +const PACKAGE_JSON = JSON.stringify({ + name: "wf-preview-fixture", + version: "1.0.0", +}); + +/** A minimal `CommittedReads`-shaped fake over the one-file tree above -- + * exactly the surface `committedReadsToSourceTree` reads from. */ +function fakeCommittedReads() { + const files = new Map([ + ["package.json", new TextEncoder().encode(PACKAGE_JSON)], + ]); + return { + listDir: (dir: string): Promise => + Promise.resolve( + dir === "" + ? [{ name: "package.json", oid: "oid_package_json", type: "blob" }] + : [], + ), + readBlobByOid: (oid: string): Promise => { + if (oid !== "oid_package_json") { + throw new Error(`fakeCommittedReads: no blob at oid ${oid}`); + } + const bytes = files.get("package.json"); + if (bytes === undefined) throw new Error("unreachable"); + return Promise.resolve(bytes); + }, + treeOid: (dir: string): Promise => + Promise.resolve(dir === "" || dir === "." ? "oid_root_tree" : null), + }; +} + +describe("installAndApproveWorkflowSource (CL-7362 preview approval policy)", () => { + test("an empty ApprovalSet returns grants_not_approved with the full probed surface, and never freezes", async () => { + const projection = { + id: "wf_preview_definition", + triggers: [{ type: "manual" }], + stepOrder: [], + steps: {}, + }; + const wireHash = await computeWireDefinitionHash(projection); + const probedGrants = ["credential:acme-api"]; + + let transactionCalls = 0; + const fakeDb = { + transaction: (_fn: unknown) => { + transactionCalls += 1; + return Promise.resolve(undefined); + }, + }; + + const sessionService = createSessionService({ + sidecarRouter: { + sendProbe: () => + Promise.resolve({ projection, grants: probedGrants, wireHash }), + }, + agentRepoStore: { + repoStore: { + openCommittedReadsAtCommit: ( + _principal: unknown, + _repoId: unknown, + commitSha: string, + ) => + Promise.resolve( + commitSha === COMMIT_SHA ? fakeCommittedReads() : null, + ), + resolveRef: () => Promise.resolve(COMMIT_SHA), + createPack: () => + Promise.resolve({ pack: new Uint8Array(), ref: "refs/heads/main" }), + }, + }, + db: fakeDb, + toolPackageRegistries: { + httpRegistries: new Map([["npmjs", { url: "https://registry.npmjs.test" }]]), + defaultRegistry: "npmjs", + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- fakes cover exactly the surface this test path reads; see the file header. + } as any); + + const result = await sessionService.installAndApproveWorkflowSource({ + source: { + kind: "asset", + assetId: DEFINITION_ASSET_ID, + package: { format: "source", commitSha: COMMIT_SHA }, + }, + entry: "workflow.ts", + definitionAssetId: DEFINITION_ASSET_ID, + // The delta under test: an empty ApprovalSet pre-approves nothing. + approvals: new Set(), + }); + + expect(result.approval.ok).toBe(false); + if (result.approval.ok) throw new Error("unreachable"); + expect(result.approval.reason).toBe("grants_not_approved"); + if (result.approval.reason !== "grants_not_approved") { + throw new Error("unreachable"); + } + expect(result.approval.unapprovedGrants).toEqual(probedGrants); + expect(transactionCalls).toBe(0); + }); +}); diff --git a/vendor/intx/hub-sessions/src/session-service.ts b/vendor/intx/hub-sessions/src/session-service.ts index 03860dfd0..9f37ea5ce 100644 --- a/vendor/intx/hub-sessions/src/session-service.ts +++ b/vendor/intx/hub-sessions/src/session-service.ts @@ -87,6 +87,11 @@ import { installAndApproveWorkflowDefinition, type InstallAndApproveArgs, type InstallAndApproveResult, + // WORKBENCH DELTA (CL-7362, see VENDORED.md): imported so a caller can + // supply a probe-only approval policy (an empty `ApprovalSet`) to + // `installAndApproveWorkflowSource` instead of always freezing under + // `approve-probed`. + type ProbeApprovalPolicy, } from "./workflow-probe-gate"; const logger = getLogger(["interchange", "hub", "session-service"]); @@ -245,6 +250,16 @@ export type InstallAndApproveWorkflowSourceParams = { definitionAssetId: string; /** WORKBENCH DELTA (see VENDORED.md): see `DeployWorkflowFromSourceParams.sourceRef`. */ sourceRef?: string; + /** + * WORKBENCH DELTA (CL-7362, see VENDORED.md): caller-supplied probe + * approval policy, threaded through `buildInstallArgs` in place of the + * hardcoded `approve-probed` default. Omitted, behavior is unchanged + * (`approve-probed`); an empty `ApprovalSet` lets a caller run + * install+probe+gate purely to walk the grant surface without ever + * approving it, so `installAndApproveWorkflowSource` returns a + * `grants_not_approved` `ProbeGateResult` instead of freezing anything. + */ + approvals?: ProbeApprovalPolicy; }; /** @@ -1509,7 +1524,10 @@ export function createSessionService( const common = { entry: params.entry, assetId: params.definitionAssetId, - approvals: { mode: "approve-probed" } as const, + // WORKBENCH DELTA (CL-7362, see VENDORED.md): honor a caller-supplied + // `approvals` policy (e.g. an empty `ApprovalSet` for a probe-only + // preview) instead of always freezing under `approve-probed`. + approvals: params.approvals ?? ({ mode: "approve-probed" } as const), router: sidecarRouter, db: dbHandle, }; @@ -1633,13 +1651,15 @@ export function createSessionService( async function installAndApproveWorkflowSource( params: InstallAndApproveWorkflowSourceParams, ): Promise { + // WORKBENCH DELTA (CL-7362, see VENDORED.md): return the gate's + // `ProbeGateResult` verbatim instead of throwing on a non-approval. Under + // the default `approve-probed` policy `approved.approval.ok` is always + // true, so every existing caller (which never supplies `approvals`) sees + // no behavior change; a caller that supplies a non-approving policy + // (e.g. an empty `ApprovalSet` for a probe-only preview) now gets the + // `grants_not_approved`/`wire_hash_mismatch` result back to inspect + // rather than a thrown `WorkflowDefinitionInvalidError`. const { approved } = await prepareCodeSourcedApproval(params); - if (!approved.approval.ok) { - throw new WorkflowDefinitionInvalidError( - approved.projection.id, - `code-sourced workflow install did not approve (reason: ${approved.approval.reason})`, - ); - } return approved; } From c15e94a7862f67ab7c02d4cc8a3139878dea81d0 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 01:46:51 -0700 Subject: [PATCH 5/8] Address review findings (CL-7362) --- VENDORED.md | 2 +- apps/hub/src/index.ts | 71 +----- docs/workflow-model.md | 39 +-- docs/workflow-source-authoring.md | 136 +---------- .../agent-workflow-authoring/src/errors.ts | 7 +- .../src/registry.test.ts | 160 +++++------- .../agent-workflow-authoring/src/registry.ts | 228 ++++++++++-------- .../src/workflow-routes.test.ts | 53 ++-- .../src/workflow-routes.ts | 15 +- packages/approvals/src/headline.test.ts | 27 ++- packages/approvals/src/headline.ts | 46 ++-- .../workflow-authoring-tools/src/client.ts | 23 +- .../workflow-authoring-tools/src/tool.test.ts | 31 ++- packages/workflow-authoring-tools/src/tool.ts | 68 +++--- scripts/checks/kill-dates.txt | 2 +- .../session-service.preview-approval.test.ts | 122 ---------- .../intx/hub-sessions/src/session-service.ts | 34 +-- 17 files changed, 376 insertions(+), 688 deletions(-) delete mode 100644 vendor/intx/hub-sessions/src/session-service.preview-approval.test.ts diff --git a/VENDORED.md b/VENDORED.md index 72dead4d2..f0b01ddf3 100644 --- a/VENDORED.md +++ b/VENDORED.md @@ -30,7 +30,7 @@ never a convenience. | `vendor/intx/harness` | `@intx/harness` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | 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-10-26 | `check:killdates` | | `vendor/intx/hub-agent` | `@intx/hub-agent` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | 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; no local delta; retired by the next publish | sawyer | 2026-10-26 | `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), 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-7191 `UserMessageParams.correlationId` plumbing into `sendUserMessage`'s `headers.interchangeCorrelationId` (a plain chat message can now resolve a parked `message_response` gate), or the CL-7362 caller-supplied `approvals?: ProbeApprovalPolicy` on `InstallAndApproveWorkflowSourceParams` (threaded through `buildInstallArgs`, default unchanged at `approve-probed`) so a probe-only deploy preview can run install+probe+gate under an empty `ApprovalSet` and get the `ProbeGateResult` back from `installAndApproveWorkflowSource` instead of a thrown `WorkflowDefinitionInvalidError`; 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), 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-7191 `UserMessageParams.correlationId` plumbing into `sendUserMessage`'s `headers.interchangeCorrelationId` (a plain chat message can now resolve a parked `message_response` gate); 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`); 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-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` | | `vendor/intx/mailbox` | `@intx/mailbox` source (`src/`, manifest, tsconfig) | [faremeter/interchange](https://github.com/faremeter/interchange) @ `a8bc06ae` (origin/main, 2026-08-27) | 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-10-26 | `check:killdates` | diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 2844316f6..ea850e7f9 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -52,7 +52,6 @@ import type { HarnessConfig } from "@intx/types/runtime"; // CL-7362: computes the preview's wire hash from the probed-but-unapproved // projection `installAndApproveWorkflowSource` returns on `grants_not_approved` // — the gate itself only stamps this hash on the `ok:true` arm. -import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; import { createAgentDefinitionRoutes, @@ -1896,11 +1895,13 @@ export async function createHub(config: HubConfig) { // tenant-default resolution above; deploy always resolves against the // tenant's default/first-preference model. // - // `previewDeploy` (CL-7362, below) is NOT a probe-without-freeze call - // into native `sessionService` — the reviewed vendored delta enabling - // that has been reverted (see VENDORED.md). It is a static, read-only - // rendering of the already-committed source at `commitSha`: parses - // `package.json` and the entry module text and never touches + // `workflow_deploy_preview` (CL-7362) is NOT wired through this + // deployer, and is not a probe-without-freeze call into native + // `sessionService` — a reviewed vendored delta that would have enabled + // that was reverted (see VENDORED.md). Instead `registry.previewDeploy` + // (packages/agent-workflow-authoring) does a static, read-only render of + // the already-committed source at `commitSha` straight off `RepoStore`, + // parsing `package.json` and the entry module text; it never touches // install/probe/gate/freeze, so it truly cannot deploy anything. const workflowDeployer: WorkflowDeployer = { async deploy({ tenantId, principalId, assetId, commitSha, entry }) { @@ -1982,64 +1983,6 @@ export async function createHub(config: HubConfig) { ); } }, - - async previewDeploy({ tenantId, assetId, commitSha, entry }) { - const assetRow = await db.query.asset.findFirst({ - where: and( - eq(assetTable.id, assetId), - eq(assetTable.tenantId, tenantId), - eq(assetTable.kind, "workflow"), - ), - }); - if (assetRow === undefined) { - throw new WorkflowAuthorError( - "not_found", - `workflow asset ${assetId} not found`, - ); - } - - try { - // Empty `ApprovalSet`: nothing is pre-approved, so the gate's - // `grants_not_approved` arm reports the FULL walked grant surface as - // `unapprovedGrants` — exactly what a preview needs to show. No - // freeze happens on this path. - const approved = await sessionService.installAndApproveWorkflowSource({ - source: { - kind: "asset", - assetId, - package: { format: "source", commitSha }, - }, - entry, - definitionAssetId: assetRow.id, - approvals: new Set(), - }); - - if (approved.approval.ok) { - return { wireHash: approved.approval.approvedWireHash, grants: [] }; - } - if (approved.approval.reason === "grants_not_approved") { - const wireHash = await computeWireDefinitionHash( - approved.projection, - ); - return { wireHash, grants: approved.approval.unapprovedGrants }; - } - throw new WorkflowAuthorError( - "invalid", - `deploy preview: wire hash mismatch (shipped ` + - `${approved.approval.shippedWireHash}, recomputed ` + - `${approved.approval.recomputedWireHash})`, - ); - } catch (err) { - if (err instanceof WorkflowAuthorError) throw err; - if (err instanceof WorkflowDefinitionInvalidError) { - throw new WorkflowAuthorError("invalid", err.message); - } - throw new WorkflowAuthorError( - "unavailable", - err instanceof Error ? err.message : "Failed to preview workflow deploy", - ); - } - }, }; // Agent-authored workflows (CL-7360, CL-7361): an agent publishes a // workflow codebase as a native `kind:"workflow"` asset AND deploys it, diff --git a/docs/workflow-model.md b/docs/workflow-model.md index 262575d6a..383db7375 100644 --- a/docs/workflow-model.md +++ b/docs/workflow-model.md @@ -65,22 +65,31 @@ redeploy (`resolveDefinitionSources`). ### Deploy approval for agent-authored workflows Upstream's deploy route freezes with `approvals: { mode: "approve-probed" }` -(`vendor/intx/hub-sessions/src/session-service.ts`); the `ApprovalSet` -gate exists as a policy type but has no pending-approval record. The only -native pending-approval store is the runtime `approval` resource that an -`approval: "ask"` tool call parks on. Workbench composes those two seams and -adds no approval table: - -1. Myra calls a preview operation that runs the native probe with an empty - `ApprovalSet` and returns the walked grant surface plus the wire hash. No - freeze. +(`vendor/intx/hub-sessions/src/session-service.ts`), unmodified — no +vendored delta grants a caller-supplied approval policy or a +probe-without-freeze entry point (one was prototyped for CL-7362 and +reverted; see VENDORED.md). The only native pending-approval store is the +runtime `approval` resource that an `approval: "ask"` tool call parks on. +Workbench composes what exists, with no vendored delta and no approval +table: + +1. Myra calls `workflow_deploy_preview`, a STATIC, read-only render of the + already-committed source at `commitSha` — package name, file list, and + any `toolPackagePins` a plain `export default {...}` entry declares. + Never installs, probes, gates, or freezes anything, so it truly cannot + deploy. 2. Myra calls `workflow_deploy` (`approval: "ask"`) with the asset id, - commit sha, expected wire hash, and that grant list. The tool call parks; - the human sees exactly what will be approved. -3. On approval the tool posts to the native deployments route. The native - probe re-runs; a wire hash that differs from the approved one fails - closed. Rejection leaves the source intact and the definition - unlaunchable. + commit sha, entry, and the preview's `packageName`/`toolPackagePins` + carried along on the call. The tool call parks; the approval headline + reads "Deploy workflow \ @ \ — tools: \" — the committed source the human is approving, not yet the + grants/capabilities the deploy will freeze (no no-freeze probe seam + exists to preview those; see the vendored-delta revert above). +3. On approval the tool posts to the native deployments route, which runs + the real install + probe + gate + freeze under the default + `approve-probed` policy. A rejection there leaves the source intact and + the definition unlaunchable; runtime tool calls against the deployed + definition remain approval-gated regardless. Myra cannot resolve approvals: `approval:*`/`resolve` is never minted for an agent principal. diff --git a/docs/workflow-source-authoring.md b/docs/workflow-source-authoring.md index aa8fe704b..457616df8 100644 --- a/docs/workflow-source-authoring.md +++ b/docs/workflow-source-authoring.md @@ -33,130 +33,12 @@ not what an agent authors by hand. ## The operations, in order -| Step | Operation | Authorized as | Returns | -| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | -| 1 | `POST /api/workflow-workflow-authoring/author` (`@corbits/agent-workflow-authoring`) → `AssetService.createAsset` + `populateAsset` | Run bearer + run address → tenant/principal; `asset:*`/`create` | `{ assetId, name, commitSha }` | -| 1' | `.../republish` → `populateAsset` on `refs/heads/main` | `asset:`/`write`, own-tenant row check first | `{ assetId, name, commitSha }` | -| 1'' | `GET .../:assetId/source` → `RepoStore.resolveRef` + `openCommittedReads` on `refs/heads/main` | `asset:`/`read`, own-tenant row check first | `{ assetId, name, headSha, files }` | -| 2 | Preview: native probe with empty `ApprovalSet`, no freeze (CL-7362, not yet built) | Same run scope | `{ wireHash, grants[] }` or an invalid-package error | -| 3 | `POST /api/workflow-workflow-authoring/:assetId/deploy` (CL-7361) → same `sessionService.deployWorkflowFromSource` call the native `POST /api/tenants/:tenantId/workflows/deployments` route drives, with `sources` resolved server-side from the tenant catalog (`modelRequirements: null` — a per-workflow model requirement, if the package ever declares one, is NOT considered at this step; resolution always targets the tenant's default/first-preference model) | Run bearer + run address → tenant/principal; `workflow:*`/`create`, own-tenant row check first; the `workflow_deploy` tool call itself carries `approval: "ask"`, and the approval card currently shows only the tool args (asset/commit/entry) — not the probed grant surface, see CL-7362 below | `{ deploymentId, definitionAssetId, status }` | -| 4 | Human resolves the parked approval (native `approvals` route) | `approval:*`/`resolve` | Deploy continues or is rejected | -| 5 | `workflow_definition` row frozen; appears in routine target discovery | — | Launchable | - -The deploy body is the same one `packages/hub-client/src/seed.ts` sends: - -```json -{ - "source": { - "kind": "asset", - "assetId": "", - "package": { "format": "source", "commitSha": "" } - }, - "entry": "./workflow.ts", - "sources": [ - { - "id": "...", - "provider": "...", - "baseURL": "...", - "apiKey": "...", - "model": "..." - } - ], - "defaultSource": "..." -} -``` - -`sources` is resolved server-side from the tenant's inference catalog for -agent-initiated deploys; an agent never supplies or sees provider secrets. -`commitSha` is the pin: the same asset at a different commit is a different -deploy. `@corbits/workflow-deploy-source` records `{ assetId, commitSha, -entry }` per placement so redeploy re-resolves sources fresh from the -recorded initiating principal. - -## Identity, conflicts, idempotency - -- Asset identity is the asset id; the human-readable name is unique per - tenant (`duplicate_asset` → 409 conflict). -- A republish carries `expectedHeadSha`. If the ref moved, the write is - rejected with 409 and the current head (`currentHeadSha` beside the error - envelope); the caller re-reads and retries. Nothing is silently - overwritten. The check is a read-then-write against `RepoStore.resolveRef` - rather than a compare-and-set inside `writeTree` — `receivePack` has CAS, - `writeTree` does not — so two republishes racing inside that window are - serialized by the repo lock, not refused. -- `populateAsset` is additive. A republish overwrites the paths it names and - carries every other committed file forward; `workflow_source_read` shows - the whole resulting tree. Deleting a file needs a seam that does not exist - yet. -- Writing an identical tree is a no-op commit (content-aware, like the CLI - pusher). Retrying an `author` after a network failure hits - `duplicate_asset`; the caller then republishes. -- An authored-but-never-deployed asset is a draft by state, not by table: - it has no `workflow_definition` row. It stays in the asset store until - deleted; it never appears in routine target discovery. -- Every operation is authorized as the run's own tenant and principal - (`WorkflowRunAuthenticator`); no tool argument names a tenant or asset it - cannot already reach. - -## Sequence - -```mermaid -sequenceDiagram - participant H as Human - participant M as Myra (run) - participant A as agent-workflow-authoring - participant S as AssetService (git) - participant D as /workflows/deployments - participant P as Sidecar probe - participant R as Routine targets - - H->>M: "make a routine that does X" - M->>A: author { name, files } - A->>A: authorize asset:*/create, validate paths + package - A->>S: createAsset + populateAsset (hub-signed commit) - S-->>M: { assetId, commitSha } - M->>A: deploy preview { assetId, commitSha } - A->>P: probe (empty ApprovalSet, no freeze) - P-->>M: { wireHash, grants } - M->>M: workflow_deploy (approval: ask) parks - H->>H: inspects asset, commit, grants; approves - M->>D: POST { source: asset/source/commitSha, entry } - D->>P: bundle, probe, capability walk - P-->>D: wireHash must equal approved; else fail closed - D->>D: freeze workflow_definition (approved_wire_hash, grant_snapshot) - D-->>M: deployment { definitionAssetId } - R-->>H: definition selectable as routine target -``` - -## Seams that exist - -- `@corbits/workflow-authoring-tools` (CL-7360, `workflow_deploy` CL-7361): - `workflow_author`, `workflow_republish`, `workflow_source_read`, and - `workflow_deploy` (the only one carrying `approval: "ask"`) over the - routes above, pinned into Myra's `ASSISTANT_TOOL_PACKAGE_PINS` and - published to the `corbits-tools` registry. -- `POST /api/workflow-workflow-authoring/:assetId/deploy` - (`agent-workflow-authoring`, CL-7361): a run-authenticated mirror of the - native `/workflows/deployments` route, injected from `apps/hub/src/ -index.ts` as a `WorkflowDeployer` wrapping the same - `sessionService.deployWorkflowFromSource` call (through - `withDeploySourceRecording`) with sources resolved server-side. -- Path/package validation in `agent-workflow-authoring`'s registry - (`validateWorkflowSourceTree`, CL-7360): runs before any grant check or - write; caps are `MAX_SOURCE_FILE_BYTES`, `MAX_SOURCE_TREE_BYTES`, - `MAX_SOURCE_FILE_COUNT`. - -## Seams that do not exist yet (and where they go) - -- A preview operation returning the probed capability surface before - `workflow_deploy` parks (step 2 in "Deploy approval for agent-authored - workflows", `workflow-model.md`): CL-7362. Until then, the parked - approval's snapshot is `workflow_deploy`'s own tool-call arguments - (asset id, commit, entry) — a human sees what will be deployed, not yet - the grants it will hold. -- Deleting a file from an authored asset (a `writeTreeDelta`-backed - republish, or a `clearPrefix` the substrate accepts at the root). -- A compare-and-set republish (`expectedHeadSha` enforced under the repo - lock rather than before it). - -Nothing here adds a repository, compiler, probe, freezer, or approval store. +| Step | Operation | Authorized as | Returns | +| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| 1 | `POST /api/workflow-workflow-authoring/author` (`@corbits/agent-workflow-authoring`) → `AssetService.createAsset` + `populateAsset` | Run bearer + run address → tenant/principal; `asset:*`/`create` | `{ assetId, name, commitSha }` | +| 1' | `.../republish` → `populateAsset` on `refs/heads/main` | `asset:`/`write`, own-tenant row check first | `{ assetId, name, commitSha }` | +| 1'' | `GET .../:assetId/source` → `RepoStore.resolveRef` + `openCommittedReads` on `refs/heads/main` | `asset:`/`read`, own-tenant row check first | `{ assetId, name, headSha, files }` | +| 2 | `POST .../:assetId/deploy/preview` (CL-7362) — a STATIC, read-only render of the already-committed source at `commitSha` off `RepoStore` alone; never installs, probes, gates, or freezes | Same run scope | `{ commitSha, entry, files[], toolPackagePins[], packageName }` or an invalid-package error | +| 3 | `POST /api/workflow-workflow-authoring/:assetId/deploy` (CL-7361) → same `sessionService.deployWorkflowFromSource` call the native `POST /api/tenants/:tenantId/workflows/deployments` route drives, with `sources` resolved server-side from the tenant catalog (`modelRequirements: null` — a per-workflow model requirement, if the package ever declares one, is NOT considered at this step; resolution always targets the tenant's default/first-preference model) | Run bearer + run address → tenant/principal; `workflow:*`/`create`, own-tenant row check first; the `workflow_deploy` tool call itself carries `approval: "ask"`, and the approval card shows the package name and any statically-declared tool pins from step 2 — not the grants/capabilities the deploy will freeze, see CL-7362 below | `{ deploymentId, definitionAssetId, status }` | +| 4 | Human resolves the parked approval (native `approvals` route) | `approval:*`/`resolve` | Deploy continues or is rejected | +| 5 | `workflow_definition` row frozen; appears in routine target discovery | — | Launchable | diff --git a/packages/agent-workflow-authoring/src/errors.ts b/packages/agent-workflow-authoring/src/errors.ts index 8f907f795..bbeee35f1 100644 --- a/packages/agent-workflow-authoring/src/errors.ts +++ b/packages/agent-workflow-authoring/src/errors.ts @@ -3,12 +3,7 @@ export type WorkflowAuthorErrorReason = | "not_found" | "conflict" | "invalid" - | "unavailable" - // CL-7362: the native deploy re-probed and froze a definition whose wire - // hash differs from the one the human approved via `expectedWireHash`. - // Distinct from `conflict` (an `expectedHeadSha` race) so a caller can - // tell the two apart without parsing the message. - | "wire_hash_mismatch"; + | "unavailable"; export class WorkflowAuthorError extends Error { readonly reason: WorkflowAuthorErrorReason; diff --git a/packages/agent-workflow-authoring/src/registry.test.ts b/packages/agent-workflow-authoring/src/registry.test.ts index d86e584d4..fe055f9a9 100644 --- a/packages/agent-workflow-authoring/src/registry.test.ts +++ b/packages/agent-workflow-authoring/src/registry.test.ts @@ -119,32 +119,6 @@ function fakeDeployer( }; } -/** Extends `fakeDb` with a `db.select().from().innerJoin().where() - * .orderBy().limit()` stub for `newestApprovedWireHash`, so a `deploy` - * carrying `expectedWireHash` can be tested without a real database. - * `wireHash: null` models "no version row yet" the same way an empty - * result set does for the real query. */ -function fakeDbWithWireHash( - row: AssetRow | undefined, - wireHash: string | null, -): DB["db"] { - return { - query: { asset: { findFirst: async () => row } }, - select: () => ({ - from: () => ({ - innerJoin: () => ({ - where: () => ({ - orderBy: () => ({ - limit: async () => - wireHash === null ? [] : [{ approvedWireHash: wireHash }], - }), - }), - }), - }), - }), - } as unknown as DB["db"]; -} - function deps( overrides: Partial = {}, ): CreateWorkflowAuthorRegistryDeps { @@ -588,131 +562,107 @@ test("deploy calls the injected deployer with the caller's own scope once author }); }); -test("previewDeploy delegates to the deployer's previewDeploy and never calls deploy (no freeze)", async () => { +test("previewDeploy is a static read of the committed source at commitSha: file list, package name, and declared tool pins from an inert entry, never a deploy call", async () => { + const pinnedEntry = + 'export default { toolPackagePins: [{ name: "@corbits/foo-tools", version: "1.2.3" }] };\n'; + const blobs: Record = { + oid_pkg: MANIFEST, + oid_entry: pinnedEntry, + }; let deployCalled = false; - let previewSeen: unknown; const registry = createWorkflowAuthorRegistry( deps({ db: fakeDb(ownRow), grantStore: fakeGrantStore([workflowGrant("create")]), + repoStore: fakeRepoStore({ + openCommittedReadsAtCommit: async (_p, _r, commitSha) => + commitSha === "sha_1" + ? { + listDir: async (dir) => + dir === "" + ? [ + { name: "package.json", oid: "oid_pkg", type: "blob" }, + { name: "workflow.ts", oid: "oid_entry", type: "blob" }, + ] + : [], + readBlobByOid: async (oid) => + new TextEncoder().encode(blobs[oid] ?? ""), + treeOid: async () => null, + } + : null, + }), deployer: fakeDeployer({ deploy: async () => { deployCalled = true; throw new Error("must not be called"); }, - previewDeploy: async (params) => { - previewSeen = params; - return { wireHash: "wire_abc", grants: ["email:*/send"] }; - }, }), }), ); const result = await registry.previewDeploy(caller, "asset_1", { commitSha: "sha_1", - entry: "./workflow.ts", + entry: "workflow.ts", }); - expect(result).toEqual({ wireHash: "wire_abc", grants: ["email:*/send"] }); - expect(previewSeen).toEqual({ - tenantId: "tenant_1", - principalId: "principal_1", - assetId: "asset_1", + expect(result).toEqual({ commitSha: "sha_1", - entry: "./workflow.ts", + entry: "workflow.ts", + files: ["package.json", "workflow.ts"], + toolPackagePins: [{ name: "@corbits/foo-tools", version: "1.2.3" }], + packageName: "daily-digest", }); expect(deployCalled).toBe(false); }); -test("previewDeploy with no grants returns an empty grants list", async () => { +test("previewDeploy lists files only, with no tool pins, when the entry is not an inert object literal", async () => { + const blobs: Record = { oid_pkg: MANIFEST, oid_entry: ENTRY }; const registry = createWorkflowAuthorRegistry( deps({ db: fakeDb(ownRow), grantStore: fakeGrantStore([workflowGrant("create")]), - deployer: fakeDeployer({ - previewDeploy: async () => ({ wireHash: "wire_abc", grants: [] }), + repoStore: fakeRepoStore({ + openCommittedReadsAtCommit: async () => ({ + listDir: async (dir) => + dir === "" + ? [ + { name: "package.json", oid: "oid_pkg", type: "blob" }, + { name: "workflow.ts", oid: "oid_entry", type: "blob" }, + ] + : [], + readBlobByOid: async (oid) => + new TextEncoder().encode(blobs[oid] ?? ""), + treeOid: async () => null, + }), }), }), ); const result = await registry.previewDeploy(caller, "asset_1", { commitSha: "sha_1", - entry: "./workflow.ts", + entry: "workflow.ts", }); - expect(result.grants).toEqual([]); + expect(result.toolPackagePins).toEqual([]); + expect(result.files).toEqual(["package.json", "workflow.ts"]); }); -test("previewDeploy fails unavailable when the injected deployer has no previewDeploy wired", async () => { +test("previewDeploy is not_found when the commit does not exist", async () => { const registry = createWorkflowAuthorRegistry( deps({ db: fakeDb(ownRow), grantStore: fakeGrantStore([workflowGrant("create")]), - deployer: fakeDeployer(), // no previewDeploy override - }), - ); - - const err = await registry - .previewDeploy(caller, "asset_1", { - commitSha: "sha_1", - entry: "./workflow.ts", - }) - .catch((e: unknown) => e); - expect(err).toBeInstanceOf(WorkflowAuthorError); - expect((err as WorkflowAuthorError).reason).toBe("unavailable"); -}); - -test("deploy with a matching expectedWireHash succeeds", async () => { - const registry = createWorkflowAuthorRegistry( - deps({ - db: fakeDbWithWireHash(ownRow, "wire_abc"), - grantStore: fakeGrantStore([workflowGrant("create")]), - deployer: fakeDeployer({ - deploy: async () => ({ - deploymentId: "run_1", - definitionAssetId: "asset_1", - status: "deployed", - }), - }), - }), - ); - - const result = await registry.deploy(caller, "asset_1", { - commitSha: "sha_1", - entry: "./workflow.ts", - expectedWireHash: "wire_abc", - }); - expect(result.status).toBe("deployed"); -}); - -test("deploy with an expectedWireHash that does not match the newest frozen version fails wire_hash_mismatch, even though the deploy already succeeded and froze that row", async () => { - let deployCalled = false; - const registry = createWorkflowAuthorRegistry( - deps({ - db: fakeDbWithWireHash(ownRow, "wire_actually_frozen"), - grantStore: fakeGrantStore([workflowGrant("create")]), - deployer: fakeDeployer({ - deploy: async () => { - deployCalled = true; - return { - deploymentId: "run_1", - definitionAssetId: "asset_1", - status: "deployed", - }; - }, + repoStore: fakeRepoStore({ + openCommittedReadsAtCommit: async () => null, }), }), ); const err = await registry - .deploy(caller, "asset_1", { - commitSha: "sha_1", - entry: "./workflow.ts", - expectedWireHash: "wire_approved_by_human", + .previewDeploy(caller, "asset_1", { + commitSha: "sha_missing", + entry: "workflow.ts", }) .catch((e: unknown) => e); expect(err).toBeInstanceOf(WorkflowAuthorError); - expect((err as WorkflowAuthorError).reason).toBe("wire_hash_mismatch"); - // The deploy call itself already ran and froze the (different) row; this - // registry does not roll it back. - expect(deployCalled).toBe(true); + expect((err as WorkflowAuthorError).reason).toBe("not_found"); }); diff --git a/packages/agent-workflow-authoring/src/registry.ts b/packages/agent-workflow-authoring/src/registry.ts index c72ae166e..fca506c73 100644 --- a/packages/agent-workflow-authoring/src/registry.ts +++ b/packages/agent-workflow-authoring/src/registry.ts @@ -44,15 +44,16 @@ import { type RepoStore, } from "@intx/hub-sessions"; import type { DB } from "@intx/db"; -import { - asset as assetTable, - workflowDefinition, - workflowDefinitionVersion, -} from "@intx/db/schema"; -import { and, desc, eq } from "drizzle-orm"; +import { asset as assetTable } from "@intx/db/schema"; +import { and, eq } from "drizzle-orm"; +import { type } from "arktype"; +import { PackageJSON } from "@intx/types/package-json"; import { WorkflowAuthorError } from "./errors"; -import { validateWorkflowSourceTree } from "./source-tree"; +import { + PACKAGE_JSON_PATH, + validateWorkflowSourceTree, +} from "./source-tree"; const WORKFLOW_ASSET_KIND = "workflow"; const HUB_PRINCIPAL = { kind: "hub" } as const; @@ -98,18 +99,6 @@ export type RepublishWorkflowInput = { export type DeployWorkflowInput = { readonly commitSha: string; readonly entry: string; - /** - * CL-7362: the wire hash the human approved (from a prior - * `previewDeploy`/`workflow_deploy_preview` call). After the native - * deploy re-probes and freezes, this registry compares it against the - * newest `workflow_definition_version.approved_wire_hash` for this - * asset; a mismatch fails the request as `wire_hash_mismatch` even - * though the deploy itself already succeeded and froze that row — the - * frozen row is NOT rolled back, so a caller that sees this error must - * treat the definition as deployed-but-unverified and re-review before - * routing anything at it. - */ - readonly expectedWireHash?: string; }; export type WorkflowDeployResult = { @@ -119,8 +108,15 @@ export type WorkflowDeployResult = { }; export type WorkflowDeployPreviewResult = { - readonly wireHash: string; - readonly grants: readonly string[]; + readonly commitSha: string; + readonly entry: string; + /** Every repo-relative file path in the committed tree at `commitSha`. */ + readonly files: readonly string[]; + /** The `toolPackagePins` an inert `export default {...}` entry declares; + * empty when the entry isn't a plain object literal (a folded/built + * workflow — pins aren't statically knowable there without execution). */ + readonly toolPackagePins: readonly { readonly name: string; readonly version: string }[]; + readonly packageName: string; }; /** @@ -131,6 +127,11 @@ export type WorkflowDeployPreviewResult = { * failures are `WorkflowAuthorError`s with a reason this registry passes * straight through: `not_found` (asset/commit missing), `invalid` * (rejected package/definition), `unavailable` (sidecar unreachable). + * + * CL-7362: this seam carries no `previewDeploy` — the preview + * (`registry.previewDeploy` below) never touches `sessionService` at all, + * so it cannot freeze anything even by accident. It is a static read of + * the already-committed source through `RepoStore` alone. */ export type WorkflowDeployer = { deploy(params: { @@ -141,38 +142,6 @@ export type WorkflowDeployer = { commitSha: string; entry: string; }): Promise; - /** - * CL-7362: run the same install + probe as `deploy`, but under an empty - * `ApprovalSet` policy so `workflow-probe-gate.ts`'s gate fails closed - * without freezing, and return the walked grant surface instead of - * throwing. Optional because NO vendored `@intx/hub-sessions` entry - * point exposes this today: - * `vendor/intx/hub-sessions/src/session-service.ts`'s - * `buildInstallArgs` hardcodes `approvals: { mode: "approve-probed" } - * as const` into every `InstallAndApproveWorkflowSourceParams` it - * builds (~line 1512), and both callers that reach it - * (`installAndApproveWorkflowSource`, `deployWorkflowFromSource`, via - * the shared `prepareCodeSourcedApproval`) throw - * `WorkflowDefinitionInvalidError` whenever - * `approved.approval.ok` is false rather than returning the gate's - * `{ ok: false, reason: "grants_not_approved", unapprovedGrants }` - * result to the caller. Vendoring a probe-without-freeze entry needs - * `InstallAndApproveWorkflowSourceParams` to accept a caller-supplied - * `approvals: ProbeApprovalPolicy` (threaded through - * `buildInstallArgs`) and a sibling of `installAndApproveWorkflowSource` - * that returns `approved.approval` instead of throwing on - * `grants_not_approved` — deliberately not reimplemented here per - * AGENTS.md ("never reimplement `@intx/*`"). Left unwired in - * `apps/hub/src/index.ts`; when absent, `previewDeploy` on the registry - * throws `unavailable`. - */ - previewDeploy?(params: { - tenantId: string; - principalId: string; - assetId: string; - commitSha: string; - entry: string; - }): Promise; }; export type WorkflowAuthorRegistry = { @@ -303,27 +272,54 @@ async function collectTree( } /** - * CL-7362: the newest `workflow_definition_version.approved_wire_hash` - * across every `workflow_definition` row for this asset, joined and - * ordered by version creation time. `null` when the asset has never been - * deployed (no definition row) or its latest version has not yet frozen - * an approval. + * CL-7362: a best-effort, read-only render of an inert `export default + * {...}` object literal in an entry module — the shape a folded/single-step + * workflow package's entry commonly takes. Deliberately NOT a JS parser or + * evaluator (the source is untrusted agent output and must never be + * executed): strips the `export default` prefix and a trailing `;`, then + * accepts the remainder only if `JSON.parse` on it (after quoting bare + * object keys, the one common non-JSON literal shape) succeeds. Any import, + * function call, or other executable construct fails this and the caller + * falls back to listing files only. */ -async function newestApprovedWireHash( - db: DB["db"], - assetId: string, -): Promise { - const [row] = await db - .select({ approvedWireHash: workflowDefinitionVersion.approvedWireHash }) - .from(workflowDefinitionVersion) - .innerJoin( - workflowDefinition, - eq(workflowDefinitionVersion.definitionId, workflowDefinition.id), - ) - .where(eq(workflowDefinition.assetId, assetId)) - .orderBy(desc(workflowDefinitionVersion.createdAt)) - .limit(1); - return row?.approvedWireHash ?? null; +function tryReadInertDefaultExport(source: string): unknown { + const trimmed = source.trim(); + const match = /^export\s+default\s+([\s\S]*?);?\s*$/.exec(trimmed); + if (match === null || match[1] === undefined) return undefined; + const quotedKeys = match[1].replace( + /([{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)(\s*:)/g, + '$1"$2"$3', + ); + try { + return JSON.parse(quotedKeys); + } catch { + return undefined; + } +} + +function extractToolPackagePins( + literal: unknown, +): readonly { readonly name: string; readonly version: string }[] { + if (literal === undefined || literal === null || typeof literal !== "object") { + return []; + } + const pins = (literal as Record).toolPackagePins; + if (!Array.isArray(pins)) return []; + const out: { readonly name: string; readonly version: string }[] = []; + for (const pin of pins) { + if ( + pin !== null && + typeof pin === "object" && + typeof (pin as Record).name === "string" && + typeof (pin as Record).version === "string" + ) { + out.push({ + name: (pin as { name: string }).name, + version: (pin as { version: string }).version, + }); + } + } + return out; } export function createWorkflowAuthorRegistry( @@ -451,55 +447,79 @@ export function createWorkflowAuthorRegistry( const row = await requireOwnWorkflowAsset(caller, assetId); await requireAuthorized(deps, caller, "workflow:*", "create"); - const result = await deps.deployer.deploy({ + return deps.deployer.deploy({ tenantId: caller.tenantId, principalId: caller.principalId, assetId, + assetName: row.name, commitSha: input.commitSha, entry: input.entry, }); - - if (input.expectedWireHash !== undefined) { - const actualWireHash = await newestApprovedWireHash(db, assetId); - if (actualWireHash !== input.expectedWireHash) { - throw new WorkflowAuthorError( - "wire_hash_mismatch", - `deploy succeeded but the frozen wire hash ` + - `(${actualWireHash ?? "none"}) does not match the approved ` + - `wire hash (${input.expectedWireHash}); the deployed ` + - `definition is NOT rolled back and must be re-reviewed ` + - `before it is routed to`, - ); - } - } - - return result; }, async previewDeploy(caller, assetId, input) { // Own-tenant scoping and the same `workflow:*`/create authorization - // as `deploy`: a preview walks the same capability surface a deploy - // would, just without freezing it. - await requireOwnWorkflowAsset(caller, assetId); + // as `deploy`: a preview shows exactly what `deploy` would name. + const row = await requireOwnWorkflowAsset(caller, assetId); await requireAuthorized(deps, caller, "workflow:*", "create"); - if (deps.deployer.previewDeploy === undefined) { + // A STATIC read of the already-committed source at `commitSha` — + // never install/probe/gate/freeze, so this truly cannot deploy + // anything. See `WorkflowDeployer`'s doc comment. + const reads = await repoStore.openCommittedReadsAtCommit( + HUB_PRINCIPAL, + { kind: WORKFLOW_ASSET_KIND, id: assetId }, + input.commitSha, + ); + if (reads === null) { + throw new WorkflowAuthorError( + "not_found", + `workflow asset ${assetId} has no commit ${input.commitSha}`, + ); + } + const files: Record = {}; + await collectTree(reads, "", files); + if (!(input.entry in files)) { + throw new WorkflowAuthorError( + "invalid", + `entry ${JSON.stringify(input.entry)} names no file in commit ${input.commitSha}`, + ); + } + const manifestSource = files[PACKAGE_JSON_PATH]; + if (manifestSource === undefined) { + throw new WorkflowAuthorError( + "invalid", + `commit ${input.commitSha} has no top-level ${PACKAGE_JSON_PATH}`, + ); + } + let manifestJson: unknown; + try { + manifestJson = JSON.parse(manifestSource); + } catch (cause) { + throw new WorkflowAuthorError( + "invalid", + `${PACKAGE_JSON_PATH} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + const manifest = PackageJSON(manifestJson); + if (manifest instanceof type.errors) { throw new WorkflowAuthorError( - "unavailable", - "deploy preview is not wired: see WorkflowDeployer.previewDeploy's " + - "doc comment in @corbits/agent-workflow-authoring for the " + - "missing native seam (CL-7362)", + "invalid", + `${PACKAGE_JSON_PATH} failed validation: ${manifest.summary}`, ); } + const packageName = manifest.name; + const entrySource = files[input.entry] ?? ""; + const inertLiteral = tryReadInertDefaultExport(entrySource); + const toolPackagePins = extractToolPackagePins(inertLiteral); - return deps.deployer.previewDeploy({ - tenantId: caller.tenantId, - principalId: caller.principalId, - assetId, - assetName: row.name, + return { commitSha: input.commitSha, entry: input.entry, - }); + files: Object.keys(files), + toolPackagePins, + packageName, + }; }, }; } diff --git a/packages/agent-workflow-authoring/src/workflow-routes.test.ts b/packages/agent-workflow-authoring/src/workflow-routes.test.ts index 41ba02e72..304e1ce57 100644 --- a/packages/agent-workflow-authoring/src/workflow-routes.test.ts +++ b/packages/agent-workflow-authoring/src/workflow-routes.test.ts @@ -299,7 +299,7 @@ test("POST /:assetId/deploy surfaces a sidecar-unavailable deploy as 502", async expect(res.status).toBe(502); }); -test("POST /:assetId/deploy/preview returns the walked grant surface without deploying", async () => { +test("POST /:assetId/deploy/preview returns a static read of the committed source without deploying", async () => { let deployCalled = false; let seen: { assetId: string; commitSha: string; entry: string } | undefined; const app = createWorkflowAuthorRoutes({ @@ -314,7 +314,13 @@ test("POST /:assetId/deploy/preview returns the walked grant surface without dep }, previewDeploy: async (_caller, assetId, input) => { seen = { assetId, ...input }; - return { wireHash: "wire_abc", grants: ["email:*/send"] }; + return { + commitSha: input.commitSha, + entry: input.entry, + files: ["package.json", "workflow.ts"], + toolPackagePins: [], + packageName: "daily-digest", + }; }, }), }); @@ -331,35 +337,20 @@ test("POST /:assetId/deploy/preview returns the walked grant surface without dep entry: "./workflow.ts", }); const body = (await res.json()) as { - data: { wireHash: string; grants: string[] }; + data: { + commitSha: string; + entry: string; + files: string[]; + toolPackagePins: { name: string; version: string }[]; + packageName: string; + }; }; - expect(body.data).toEqual({ wireHash: "wire_abc", grants: ["email:*/send"] }); - expect(deployCalled).toBe(false); -}); - -test("POST /:assetId/deploy surfaces a wire_hash_mismatch as 409, distinct from a plain conflict", async () => { - const app = createWorkflowAuthorRoutes({ - authenticator: fakeAuthenticator({ - tenantId: "tenant_1", - principalId: "principal_1", - }), - registry: fakeRegistry({ - deploy: async () => { - throw new WorkflowAuthorError( - "wire_hash_mismatch", - "deploy succeeded but the frozen wire hash does not match the approved wire hash", - ); - }, - }), + expect(body.data).toEqual({ + commitSha: "sha_1", + entry: "./workflow.ts", + files: ["package.json", "workflow.ts"], + toolPackagePins: [], + packageName: "daily-digest", }); - const res = await app.request( - req("/asset_1/deploy", { - commitSha: "sha_1", - entry: "./workflow.ts", - expectedWireHash: "wire_approved", - }), - ); - expect(res.status).toBe(409); - const body = (await res.json()) as { error: { code: string } }; - expect(body.error.code).toBe("wire_hash_mismatch"); + expect(deployCalled).toBe(false); }); diff --git a/packages/agent-workflow-authoring/src/workflow-routes.ts b/packages/agent-workflow-authoring/src/workflow-routes.ts index 3834f93ef..f7ae72305 100644 --- a/packages/agent-workflow-authoring/src/workflow-routes.ts +++ b/packages/agent-workflow-authoring/src/workflow-routes.ts @@ -53,7 +53,6 @@ const RepublishBody = type({ const DeployBody = type({ commitSha: "string", entry: "string", - "expectedWireHash?": "string", }); const DeployPreviewBody = type({ @@ -71,8 +70,6 @@ function statusFor( return 403; case "conflict": return 409; - case "wire_hash_mismatch": - return 409; case "invalid": return 400; case "unavailable": @@ -168,13 +165,11 @@ export function createWorkflowAuthorRoutes( return c.json({ data: snapshot }); }); - // CL-7362: a preview of `/:assetId/deploy` that runs the native - // install/probe with an empty approval set so it never freezes, - // returning the wire hash and the walked grant surface so the human - // sees exactly what a subsequent `workflow_deploy` approval would grant - // BEFORE that call parks. See `./registry.ts`'s `previewDeploy` doc - // comment for what native seam this depends on, and where that seam is - // currently missing. + // CL-7362: a preview of `/:assetId/deploy` — a STATIC read of the + // already-committed source at `commitSha` (package name, entry, file + // list, any statically-declared tool pins). Never calls install/probe/ + // gate/freeze, so it cannot deploy anything; a human approves the real + // `workflow_deploy` call with this committed source already visible. app.post("/:assetId/deploy/preview", async (c) => { const body = DeployPreviewBody(await c.req.json().catch(() => undefined)); if (body instanceof type.errors) { diff --git a/packages/approvals/src/headline.test.ts b/packages/approvals/src/headline.test.ts index f6762c9fc..46ad7e12f 100644 --- a/packages/approvals/src/headline.test.ts +++ b/packages/approvals/src/headline.test.ts @@ -48,7 +48,7 @@ test("ignores a blank or non-string title rather than rendering an empty quote", expect(headlineFor({ name: "send_email" }, { title: 42 })).toBe("send_email"); }); -test("workflow_deploy renders the asset, short sha, and grant surface directly, ignoring the tool's own description", () => { +test("workflow_deploy renders the package name, short sha, and declared tool pins directly, ignoring the tool's own description", () => { expect( headlineFor( { name: "workflow_deploy", description: "Deploy a workflow asset..." }, @@ -56,24 +56,37 @@ test("workflow_deploy renders the asset, short sha, and grant surface directly, assetId: "asset_daily_digest", commitSha: "abcdef1234567890", entry: "./workflow.ts", - expectedWireHash: "wire_abc", - grants: ["email:*/send", "http:api.example.com/*"], + packageName: "daily-digest", + toolPackagePins: [ + { name: "@corbits/email-tools", version: "1.2.3" }, + { name: "@corbits/http-tools", version: "0.4.0" }, + ], }, ), ).toBe( - "Deploy workflow asset_daily_digest @ abcdef1 — grants: email:*/send, http:api.example.com/*", + "Deploy workflow daily-digest @ abcdef1 — tools: @corbits/email-tools@1.2.3, @corbits/http-tools@0.4.0", ); }); -test("workflow_deploy with no grants reads as 'no grants' rather than an empty list", () => { +test("workflow_deploy with no declared pins reads as 'none declared' rather than an empty list", () => { expect( headlineFor( { name: "workflow_deploy" }, { assetId: "asset_daily_digest", commitSha: "abcdef1234567890", - grants: [], + packageName: "daily-digest", + toolPackagePins: [], }, ), - ).toBe("Deploy workflow asset_daily_digest @ abcdef1 — grants: no grants"); + ).toBe("Deploy workflow daily-digest @ abcdef1 — tools: none declared"); +}); + +test("workflow_deploy falls back to the bare assetId when packageName is missing", () => { + expect( + headlineFor( + { name: "workflow_deploy" }, + { assetId: "asset_daily_digest", commitSha: "abcdef1234567890" }, + ), + ).toBe("Deploy workflow asset_daily_digest @ abcdef1 — tools: none declared"); }); diff --git a/packages/approvals/src/headline.ts b/packages/approvals/src/headline.ts index 8cfa8c354..e5e9245c4 100644 --- a/packages/approvals/src/headline.ts +++ b/packages/approvals/src/headline.ts @@ -11,33 +11,47 @@ function stringField(source: object, field: string): string | undefined { return typeof value === "string" && value.trim() !== "" ? value : undefined; } -function stringArrayField( +function toolPackagePinsField( source: object, field: string, -): readonly string[] | undefined { - if (!(field in source)) return undefined; +): readonly { readonly name: string; readonly version: string }[] { + if (!(field in source)) return []; const value = (source as Record)[field]; - return Array.isArray(value) && value.every((v) => typeof v === "string") - ? (value as string[]) - : undefined; + if (!Array.isArray(value)) return []; + return value.filter( + (pin): pin is { name: string; version: string } => + pin !== null && + typeof pin === "object" && + typeof (pin as Record).name === "string" && + typeof (pin as Record).version === "string", + ); } /** * CL-7362: `workflow_deploy` (`@corbits/workflow-authoring-tools`) parks an - * approval whose arguments carry the exact grant surface the human is - * being asked to approve (`workflow_deploy_preview`'s output, passed - * through). This renders that surface directly rather than falling back - * to the tool's generic description, so the approval card reads as "what - * will this actually grant" instead of "a tool wants to run". + * approval whose arguments carry the packageName/toolPackagePins a prior + * `workflow_deploy_preview` call (a static read of the committed source) + * reported, passed through. This renders that directly rather than + * falling back to the tool's generic description, so the approval card + * names the real package and tools instead of a bare asset id. It does + * NOT show grants/capabilities: those are stamped by the native + * install+probe+gate `workflow_deploy` itself runs, which has no + * no-freeze preview yet (CL-7362). */ function workflowDeployHeadline(toolArguments: object): string | undefined { - const assetId = stringField(toolArguments, "assetId"); const commitSha = stringField(toolArguments, "commitSha"); - if (assetId === undefined || commitSha === undefined) return undefined; + if (commitSha === undefined) return undefined; + const packageName = + stringField(toolArguments, "packageName") ?? + stringField(toolArguments, "assetId"); + if (packageName === undefined) return undefined; const sha7 = commitSha.slice(0, 7); - const grants = stringArrayField(toolArguments, "grants") ?? []; - const grantsText = grants.length > 0 ? grants.join(", ") : "no grants"; - return `Deploy workflow ${assetId} @ ${sha7} — grants: ${grantsText}`; + const pins = toolPackagePinsField(toolArguments, "toolPackagePins"); + const toolsText = + pins.length > 0 + ? pins.map((pin) => `${pin.name}@${pin.version}`).join(", ") + : "none declared"; + return `Deploy workflow ${packageName} @ ${sha7} — tools: ${toolsText}`; } /** diff --git a/packages/workflow-authoring-tools/src/client.ts b/packages/workflow-authoring-tools/src/client.ts index c637d8e4f..d76a6c7a1 100644 --- a/packages/workflow-authoring-tools/src/client.ts +++ b/packages/workflow-authoring-tools/src/client.ts @@ -48,7 +48,6 @@ export type DeployWorkflowRequest = { readonly assetId: string; readonly commitSha: string; readonly entry: string; - readonly expectedWireHash?: string; }; export type DeployWorkflowPreviewRequest = { @@ -63,9 +62,17 @@ export type WorkflowDeployResult = { readonly status: string; }; +export type ToolPackagePin = { + readonly name: string; + readonly version: string; +}; + export type WorkflowDeployPreviewResult = { - readonly wireHash: string; - readonly grants: readonly string[]; + readonly commitSha: string; + readonly entry: string; + readonly files: readonly string[]; + readonly toolPackagePins: readonly ToolPackagePin[]; + readonly packageName: string; }; /** The hub refused the request with a canonical error envelope. `code` @@ -118,8 +125,11 @@ const DeployResponse = type({ const DeployPreviewResponse = type({ data: { - wireHash: "string", - grants: "string[]", + commitSha: "string", + entry: "string", + files: "string[]", + toolPackagePins: type({ name: "string", version: "string" }).array(), + packageName: "string", }, }); @@ -220,9 +230,6 @@ export async function deployWorkflow( body: JSON.stringify({ commitSha: input.commitSha, entry: input.entry, - ...(input.expectedWireHash !== undefined - ? { expectedWireHash: input.expectedWireHash } - : {}), }), }, ); diff --git a/packages/workflow-authoring-tools/src/tool.test.ts b/packages/workflow-authoring-tools/src/tool.test.ts index 284f008f5..700e2e3ba 100644 --- a/packages/workflow-authoring-tools/src/tool.test.ts +++ b/packages/workflow-authoring-tools/src/tool.test.ts @@ -176,7 +176,7 @@ test("workflow_source_read returns the snapshot as JSON the model can parse", as expect(JSON.parse(String(result.content))).toEqual(snapshot); }); -test("workflow_deploy posts assetId, commitSha, entry, and expectedWireHash to the deploy route", async () => { +test("workflow_deploy posts only assetId, commitSha, and entry to the deploy route — packageName/toolPackagePins stay client-side for the approval headline", async () => { const bundle = workflowAuthoringTools(testEnv()); let seenUrl: string | undefined; let seenBody: unknown; @@ -202,8 +202,8 @@ test("workflow_deploy posts assetId, commitSha, entry, and expectedWireHash to t assetId: "asset_1", commitSha: "sha_1", entry: "./workflow.ts", - expectedWireHash: "wire_abc", - grants: ["email:*/send"], + packageName: "daily-digest", + toolPackagePins: [{ name: "@corbits/foo-tools", version: "1.2.3" }], }), new AbortController().signal, ), @@ -211,20 +211,19 @@ test("workflow_deploy posts assetId, commitSha, entry, and expectedWireHash to t expect(seenUrl).toBe( "https://hub.example.com/api/workflow-workflow-authoring/asset_1/deploy", ); - // `grants` is carried on the approval card via the tool call's own - // arguments (see @corbits/approvals' headline.ts), not re-sent to the - // hub — the deploy route only needs the wire hash it re-verifies against. + // `packageName`/`toolPackagePins` are carried on the approval card via + // the tool call's own arguments (see @corbits/approvals' headline.ts), + // not re-sent to the hub — the deploy route only needs commitSha/entry. expect(seenBody).toEqual({ commitSha: "sha_1", entry: "./workflow.ts", - expectedWireHash: "wire_abc", }); expect(result.isError).toBe(false); expect(result.content).toContain("run_1"); expect(result.content).toContain("asset_1"); }); -test("workflow_deploy rejects a call missing expectedWireHash or grants without calling the hub", async () => { +test("workflow_deploy rejects a call missing required fields without calling the hub", async () => { const bundle = workflowAuthoringTools(testEnv()); await withFetch( () => { @@ -235,7 +234,6 @@ test("workflow_deploy rejects a call missing expectedWireHash or grants without bundle.run( call(WORKFLOW_DEPLOY_TOOL, { assetId: "asset_1", - commitSha: "sha_1", entry: "./workflow.ts", }), new AbortController().signal, @@ -253,7 +251,13 @@ test("workflow_deploy_preview posts assetId, commitSha, and entry to the preview seenUrl = url; return new Response( JSON.stringify({ - data: { wireHash: "wire_abc", grants: ["email:*/send"] }, + data: { + commitSha: "sha_1", + entry: "./workflow.ts", + files: ["package.json", "workflow.ts"], + toolPackagePins: [{ name: "@corbits/foo-tools", version: "1.2.3" }], + packageName: "daily-digest", + }, }), ); }, @@ -272,8 +276,11 @@ test("workflow_deploy_preview posts assetId, commitSha, and entry to the preview ); expect(result.isError).toBe(false); expect(JSON.parse(String(result.content))).toEqual({ - wireHash: "wire_abc", - grants: ["email:*/send"], + commitSha: "sha_1", + entry: "./workflow.ts", + files: ["package.json", "workflow.ts"], + toolPackagePins: [{ name: "@corbits/foo-tools", version: "1.2.3" }], + packageName: "daily-digest", }); expect( workflowAuthoringTools.definitions.find( diff --git a/packages/workflow-authoring-tools/src/tool.ts b/packages/workflow-authoring-tools/src/tool.ts index 31f874c5d..75b81eae3 100644 --- a/packages/workflow-authoring-tools/src/tool.ts +++ b/packages/workflow-authoring-tools/src/tool.ts @@ -66,12 +66,14 @@ const DeployPreviewInput = type({ entry: "string > 0", }); +const ToolPackagePinInput = type({ name: "string > 0", version: "string > 0" }); + const DeployInput = type({ assetId: "string > 0", commitSha: "string > 0", entry: "string > 0", - expectedWireHash: "string > 0", - grants: "string[]", + "packageName?": "string > 0", + "toolPackagePins?": ToolPackagePinInput.array(), }); const PACKAGE_SHAPE_DESCRIPTION = @@ -178,7 +180,6 @@ async function runDeploy( assetId: input.assetId, commitSha: input.commitSha, entry: input.entry, - expectedWireHash: input.expectedWireHash, }); return textResult( call.id, @@ -295,13 +296,14 @@ export const workflowAuthoringTools = defineTool({ { name: WORKFLOW_DEPLOY_PREVIEW_TOOL, description: - "Preview what deploying a workflow asset's committed source " + - "would grant, WITHOUT deploying it: runs the same install + " + - "probe as workflow_deploy but never freezes anything. Returns " + - "the wire hash and the walked grant surface. Call this BEFORE " + - "workflow_deploy and pass its wireHash as expectedWireHash and " + - "its grants as grants on that call, so the human approving the " + - "deploy sees exactly what will be granted.", + "Read what deploying a workflow asset's committed source would " + + "run, WITHOUT deploying it: a static read of the already-" + + "committed source at commitSha (never installs, probes, gates, " + + "or freezes anything). Returns the package name, the file list, " + + "and any toolPackagePins statically declared in the entry " + + "module. Call this BEFORE workflow_deploy and pass its " + + "packageName and toolPackagePins on that call, so the human " + + "approving the deploy sees the real package and tools it names.", inputSchema: { type: "object", properties: { @@ -331,13 +333,15 @@ export const workflowAuthoringTools = defineTool({ "Deploy a workflow asset's committed source through " + "Interchange's native deploy pipeline (install, probe, capability " + "walk, gate, freeze), making it selectable as a routine target. " + - "Call workflow_deploy_preview FIRST and pass its wireHash as " + - "expectedWireHash and its grants as grants here — that is what " + - "the human sees on the approval card before this call parks. " + - "If the deploy re-probes to a different wire hash than " + - "expectedWireHash (the source moved between preview and " + - "approval), it fails closed even though the new definition is " + - "frozen; re-preview and retry. Inference sources come from the " + + "A human must approve this before it runs: the approval card " + + "shows the package and tools this call names, sourced from a " + + "prior workflow_deploy_preview call on the same commit — call " + + "that FIRST and pass its packageName and toolPackagePins here " + + "so the approval reflects the real committed source, not just " + + "an asset id. Grants/capabilities are stamped by the native " + + "install+probe+gate this call runs, which the human does not " + + "see a preview of yet (CL-7362); say so if you explain this " + + "approval to a human. Inference sources come from the " + "workbench's own catalog — never pass a model or credential.", inputSchema: { type: "object", @@ -358,28 +362,28 @@ export const workflowAuthoringTools = defineTool({ description: 'The interchange.workflow entry module path, e.g. "./workflow.ts".', }, - expectedWireHash: { + packageName: { type: "string", description: - "The wireHash returned by workflow_deploy_preview for this " + - "same asset/commit/entry.", + "The packageName returned by workflow_deploy_preview for " + + "this same asset/commit, shown on the approval card.", }, - grants: { + toolPackagePins: { type: "array", - items: { type: "string" }, + items: { + type: "object", + properties: { + name: { type: "string" }, + version: { type: "string" }, + }, + required: ["name", "version"], + }, description: - "The grants returned by workflow_deploy_preview for this " + - "same asset/commit/entry, so the approval card shows what " + - "will be granted.", + "The toolPackagePins returned by workflow_deploy_preview " + + "for this same asset/commit, shown on the approval card.", }, }, - required: [ - "assetId", - "commitSha", - "entry", - "expectedWireHash", - "grants", - ], + required: ["assetId", "commitSha", "entry"], }, }, ], diff --git a/scripts/checks/kill-dates.txt b/scripts/checks/kill-dates.txt index 9666653cc..c184aeeee 100644 --- a/scripts/checks/kill-dates.txt +++ b/scripts/checks/kill-dates.txt @@ -19,7 +19,7 @@ vendor/intx/db | sawyer | 2026-10-26 | e1a41e59050ca32d6b590b33f559221584b3e78c7 vendor/intx/harness | sawyer | 2026-10-26 | 5daababe006d9cf8e678c0eed22c2daa99cc519f35ed7a32ec2b977da9f2fb71 vendor/intx/hub-agent | sawyer | 2026-10-26 | 617f93b05da6a02415ea3b319526137b56a1d5cc3689ca4d33ab324d8f5807d3 vendor/intx/hub-api | sawyer | 2026-10-26 | 10fd46c7bf0b058618a8124f6f43d7e779fd5861ff0146fe8d7c2886b836c40e -vendor/intx/hub-sessions | sawyer | 2026-10-26 | ad758f205d4afb1c46bf5562c83c387ac1d6e1a138fbf86821512b072a3f8951 +vendor/intx/hub-sessions | sawyer | 2026-10-26 | c884af15f80f228acb95b95d415a035bc8d6bae579d199c00fb98a511ba42219 vendor/intx/inference | sawyer | 2026-10-26 | 3754de556eac6387a427454d57a22f46ad42dc22096c9c76f993cea74f97f50a vendor/intx/mail-memory | sawyer | 2026-10-26 | 99e15f6b256f36562dcf4b0cbd84e412b1a8985f00019e3e1748ecd18332b74d vendor/intx/mailbox | sawyer | 2026-10-26 | 9647f7c0cda5a9fce7d687b2901a92769f18a442dcd4d1ffe8334eeb71b7dbb0 diff --git a/vendor/intx/hub-sessions/src/session-service.preview-approval.test.ts b/vendor/intx/hub-sessions/src/session-service.preview-approval.test.ts deleted file mode 100644 index 2686c40b6..000000000 --- a/vendor/intx/hub-sessions/src/session-service.preview-approval.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -// WORKBENCH DELTA (CL-7362, see VENDORED.md): coverage for the vendored -// `approvals?: ProbeApprovalPolicy` seam threaded through -// `InstallAndApproveWorkflowSourceParams` -> `buildInstallArgs` -> -// `installAndApproveWorkflowSource`. Asserts a caller-supplied empty -// `ApprovalSet` reaches the gate (so nothing is pre-approved, and the gate's -// `grants_not_approved` arm reports the full probed grant surface), and that -// the freeze writer -- `db.transaction`, which `createDbFrozenApprovalWriter` -// invokes only on the gate's `ok:true` arm -- is never called for that -// non-approving policy. -import { describe, expect, test } from "bun:test"; - -import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash"; - -import { createSessionService } from "./session-service"; -import type { CommittedTreeEntry } from "./repo-store/types"; - -const DEFINITION_ASSET_ID = "wf_preview_asset"; -const COMMIT_SHA = "c".repeat(40); - -/** A one-file, dependency-free workflow package: enough for - * `resolveSourceWorkflowClosure`'s single-package path (root declares no - * `workspaces`) to resolve with an empty closure. */ -const PACKAGE_JSON = JSON.stringify({ - name: "wf-preview-fixture", - version: "1.0.0", -}); - -/** A minimal `CommittedReads`-shaped fake over the one-file tree above -- - * exactly the surface `committedReadsToSourceTree` reads from. */ -function fakeCommittedReads() { - const files = new Map([ - ["package.json", new TextEncoder().encode(PACKAGE_JSON)], - ]); - return { - listDir: (dir: string): Promise => - Promise.resolve( - dir === "" - ? [{ name: "package.json", oid: "oid_package_json", type: "blob" }] - : [], - ), - readBlobByOid: (oid: string): Promise => { - if (oid !== "oid_package_json") { - throw new Error(`fakeCommittedReads: no blob at oid ${oid}`); - } - const bytes = files.get("package.json"); - if (bytes === undefined) throw new Error("unreachable"); - return Promise.resolve(bytes); - }, - treeOid: (dir: string): Promise => - Promise.resolve(dir === "" || dir === "." ? "oid_root_tree" : null), - }; -} - -describe("installAndApproveWorkflowSource (CL-7362 preview approval policy)", () => { - test("an empty ApprovalSet returns grants_not_approved with the full probed surface, and never freezes", async () => { - const projection = { - id: "wf_preview_definition", - triggers: [{ type: "manual" }], - stepOrder: [], - steps: {}, - }; - const wireHash = await computeWireDefinitionHash(projection); - const probedGrants = ["credential:acme-api"]; - - let transactionCalls = 0; - const fakeDb = { - transaction: (_fn: unknown) => { - transactionCalls += 1; - return Promise.resolve(undefined); - }, - }; - - const sessionService = createSessionService({ - sidecarRouter: { - sendProbe: () => - Promise.resolve({ projection, grants: probedGrants, wireHash }), - }, - agentRepoStore: { - repoStore: { - openCommittedReadsAtCommit: ( - _principal: unknown, - _repoId: unknown, - commitSha: string, - ) => - Promise.resolve( - commitSha === COMMIT_SHA ? fakeCommittedReads() : null, - ), - resolveRef: () => Promise.resolve(COMMIT_SHA), - createPack: () => - Promise.resolve({ pack: new Uint8Array(), ref: "refs/heads/main" }), - }, - }, - db: fakeDb, - toolPackageRegistries: { - httpRegistries: new Map([["npmjs", { url: "https://registry.npmjs.test" }]]), - defaultRegistry: "npmjs", - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- fakes cover exactly the surface this test path reads; see the file header. - } as any); - - const result = await sessionService.installAndApproveWorkflowSource({ - source: { - kind: "asset", - assetId: DEFINITION_ASSET_ID, - package: { format: "source", commitSha: COMMIT_SHA }, - }, - entry: "workflow.ts", - definitionAssetId: DEFINITION_ASSET_ID, - // The delta under test: an empty ApprovalSet pre-approves nothing. - approvals: new Set(), - }); - - expect(result.approval.ok).toBe(false); - if (result.approval.ok) throw new Error("unreachable"); - expect(result.approval.reason).toBe("grants_not_approved"); - if (result.approval.reason !== "grants_not_approved") { - throw new Error("unreachable"); - } - expect(result.approval.unapprovedGrants).toEqual(probedGrants); - expect(transactionCalls).toBe(0); - }); -}); diff --git a/vendor/intx/hub-sessions/src/session-service.ts b/vendor/intx/hub-sessions/src/session-service.ts index 9f37ea5ce..03860dfd0 100644 --- a/vendor/intx/hub-sessions/src/session-service.ts +++ b/vendor/intx/hub-sessions/src/session-service.ts @@ -87,11 +87,6 @@ import { installAndApproveWorkflowDefinition, type InstallAndApproveArgs, type InstallAndApproveResult, - // WORKBENCH DELTA (CL-7362, see VENDORED.md): imported so a caller can - // supply a probe-only approval policy (an empty `ApprovalSet`) to - // `installAndApproveWorkflowSource` instead of always freezing under - // `approve-probed`. - type ProbeApprovalPolicy, } from "./workflow-probe-gate"; const logger = getLogger(["interchange", "hub", "session-service"]); @@ -250,16 +245,6 @@ export type InstallAndApproveWorkflowSourceParams = { definitionAssetId: string; /** WORKBENCH DELTA (see VENDORED.md): see `DeployWorkflowFromSourceParams.sourceRef`. */ sourceRef?: string; - /** - * WORKBENCH DELTA (CL-7362, see VENDORED.md): caller-supplied probe - * approval policy, threaded through `buildInstallArgs` in place of the - * hardcoded `approve-probed` default. Omitted, behavior is unchanged - * (`approve-probed`); an empty `ApprovalSet` lets a caller run - * install+probe+gate purely to walk the grant surface without ever - * approving it, so `installAndApproveWorkflowSource` returns a - * `grants_not_approved` `ProbeGateResult` instead of freezing anything. - */ - approvals?: ProbeApprovalPolicy; }; /** @@ -1524,10 +1509,7 @@ export function createSessionService( const common = { entry: params.entry, assetId: params.definitionAssetId, - // WORKBENCH DELTA (CL-7362, see VENDORED.md): honor a caller-supplied - // `approvals` policy (e.g. an empty `ApprovalSet` for a probe-only - // preview) instead of always freezing under `approve-probed`. - approvals: params.approvals ?? ({ mode: "approve-probed" } as const), + approvals: { mode: "approve-probed" } as const, router: sidecarRouter, db: dbHandle, }; @@ -1651,15 +1633,13 @@ export function createSessionService( async function installAndApproveWorkflowSource( params: InstallAndApproveWorkflowSourceParams, ): Promise { - // WORKBENCH DELTA (CL-7362, see VENDORED.md): return the gate's - // `ProbeGateResult` verbatim instead of throwing on a non-approval. Under - // the default `approve-probed` policy `approved.approval.ok` is always - // true, so every existing caller (which never supplies `approvals`) sees - // no behavior change; a caller that supplies a non-approving policy - // (e.g. an empty `ApprovalSet` for a probe-only preview) now gets the - // `grants_not_approved`/`wire_hash_mismatch` result back to inspect - // rather than a thrown `WorkflowDefinitionInvalidError`. const { approved } = await prepareCodeSourcedApproval(params); + if (!approved.approval.ok) { + throw new WorkflowDefinitionInvalidError( + approved.projection.id, + `code-sourced workflow install did not approve (reason: ${approved.approval.reason})`, + ); + } return approved; } From d67cbedd70c1bed232ee1e6d80e325a2673eaa7d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 02:25:22 -0700 Subject: [PATCH 6/8] Fix CI after review pass (CL-7362) --- apps/hub/src/index.ts | 2 +- docs/workflow-model.md | 2 +- docs/workflow-source-authoring.md | 139 ++++++++++++++++-- .../agent-workflow-authoring/src/errors.ts | 6 +- .../agent-workflow-authoring/src/registry.ts | 18 ++- packages/approvals/src/headline.ts | 2 +- .../workflow-authoring-tools/src/tool.test.ts | 2 +- packages/workflow-authoring-tools/src/tool.ts | 8 +- 8 files changed, 150 insertions(+), 29 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index ea850e7f9..bf105564c 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -1895,7 +1895,7 @@ export async function createHub(config: HubConfig) { // tenant-default resolution above; deploy always resolves against the // tenant's default/first-preference model. // - // `workflow_deploy_preview` (CL-7362) is NOT wired through this + // `wf_deploy_preview` (CL-7362) is NOT wired through this // deployer, and is not a probe-without-freeze call into native // `sessionService` — a reviewed vendored delta that would have enabled // that was reverted (see VENDORED.md). Instead `registry.previewDeploy` diff --git a/docs/workflow-model.md b/docs/workflow-model.md index 383db7375..c186fe91b 100644 --- a/docs/workflow-model.md +++ b/docs/workflow-model.md @@ -73,7 +73,7 @@ runtime `approval` resource that an `approval: "ask"` tool call parks on. Workbench composes what exists, with no vendored delta and no approval table: -1. Myra calls `workflow_deploy_preview`, a STATIC, read-only render of the +1. Myra calls `wf_deploy_preview`, a STATIC, read-only render of the already-committed source at `commitSha` — package name, file list, and any `toolPackagePins` a plain `export default {...}` entry declares. Never installs, probes, gates, or freezes anything, so it truly cannot diff --git a/docs/workflow-source-authoring.md b/docs/workflow-source-authoring.md index 457616df8..fcb6c3961 100644 --- a/docs/workflow-source-authoring.md +++ b/docs/workflow-source-authoring.md @@ -33,12 +33,133 @@ not what an agent authors by hand. ## The operations, in order -| Step | Operation | Authorized as | Returns | -| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | -| 1 | `POST /api/workflow-workflow-authoring/author` (`@corbits/agent-workflow-authoring`) → `AssetService.createAsset` + `populateAsset` | Run bearer + run address → tenant/principal; `asset:*`/`create` | `{ assetId, name, commitSha }` | -| 1' | `.../republish` → `populateAsset` on `refs/heads/main` | `asset:`/`write`, own-tenant row check first | `{ assetId, name, commitSha }` | -| 1'' | `GET .../:assetId/source` → `RepoStore.resolveRef` + `openCommittedReads` on `refs/heads/main` | `asset:`/`read`, own-tenant row check first | `{ assetId, name, headSha, files }` | -| 2 | `POST .../:assetId/deploy/preview` (CL-7362) — a STATIC, read-only render of the already-committed source at `commitSha` off `RepoStore` alone; never installs, probes, gates, or freezes | Same run scope | `{ commitSha, entry, files[], toolPackagePins[], packageName }` or an invalid-package error | -| 3 | `POST /api/workflow-workflow-authoring/:assetId/deploy` (CL-7361) → same `sessionService.deployWorkflowFromSource` call the native `POST /api/tenants/:tenantId/workflows/deployments` route drives, with `sources` resolved server-side from the tenant catalog (`modelRequirements: null` — a per-workflow model requirement, if the package ever declares one, is NOT considered at this step; resolution always targets the tenant's default/first-preference model) | Run bearer + run address → tenant/principal; `workflow:*`/`create`, own-tenant row check first; the `workflow_deploy` tool call itself carries `approval: "ask"`, and the approval card shows the package name and any statically-declared tool pins from step 2 — not the grants/capabilities the deploy will freeze, see CL-7362 below | `{ deploymentId, definitionAssetId, status }` | -| 4 | Human resolves the parked approval (native `approvals` route) | `approval:*`/`resolve` | Deploy continues or is rejected | -| 5 | `workflow_definition` row frozen; appears in routine target discovery | — | Launchable | +| Step | Operation | Authorized as | Returns | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| 1 | `POST /api/workflow-workflow-authoring/author` (`@corbits/agent-workflow-authoring`) → `AssetService.createAsset` + `populateAsset` | Run bearer + run address → tenant/principal; `asset:*`/`create` | `{ assetId, name, commitSha }` | +| 1' | `.../republish` → `populateAsset` on `refs/heads/main` | `asset:`/`write`, own-tenant row check first | `{ assetId, name, commitSha }` | +| 1'' | `GET .../:assetId/source` → `RepoStore.resolveRef` + `openCommittedReads` on `refs/heads/main` | `asset:`/`read`, own-tenant row check first | `{ assetId, name, headSha, files }` | +| 2 | `POST .../:assetId/deploy/preview` (CL-7362) — a STATIC, read-only render of the already-committed source at `commitSha` off `RepoStore` alone; never installs, probes, gates, or freezes | Same run scope | `{ commitSha, entry, files[], toolPackagePins[], packageName }` or an invalid-package error | +| 3 | `POST /api/workflow-workflow-authoring/:assetId/deploy` (CL-7361) → same `sessionService.deployWorkflowFromSource` call the native `POST /api/tenants/:tenantId/workflows/deployments` route drives, with `sources` resolved server-side from the tenant catalog (`modelRequirements: null` — a per-workflow model requirement, if the package ever declares one, is NOT considered at this step; resolution always targets the tenant's default/first-preference model) | Run bearer + run address → tenant/principal; `workflow:*`/`create`, own-tenant row check first; the `workflow_deploy` tool call itself carries `approval: "ask"`, and the approval card shows the package name and any statically-declared tool pins from step 2 — not the grants/capabilities the deploy will freeze, see CL-7362 below | `{ deploymentId, definitionAssetId, status }` | +| 4 | Human resolves the parked approval (native `approvals` route) | `approval:*`/`resolve` | Deploy continues or is rejected | +| 5 | `workflow_definition` row frozen; appears in routine target discovery | — | Launchable | + +The deploy body is the same one `packages/hub-client/src/seed.ts` sends: + +```json +{ + "source": { + "kind": "asset", + "assetId": "", + "package": { "format": "source", "commitSha": "" } + }, + "entry": "./workflow.ts", + "sources": [ + { + "id": "...", + "provider": "...", + "baseURL": "...", + "apiKey": "...", + "model": "..." + } + ], + "defaultSource": "..." +} +``` + +`sources` is resolved server-side from the tenant's inference catalog for +agent-initiated deploys; an agent never supplies or sees provider secrets. +`commitSha` is the pin: the same asset at a different commit is a different +deploy. `@corbits/workflow-deploy-source` records `{ assetId, commitSha, +entry }` per placement so redeploy re-resolves sources fresh from the +recorded initiating principal. + +## Identity, conflicts, idempotency + +- Asset identity is the asset id; the human-readable name is unique per + tenant (`duplicate_asset` → 409 conflict). +- A republish carries `expectedHeadSha`. If the ref moved, the write is + rejected with 409 and the current head (`currentHeadSha` beside the error + envelope); the caller re-reads and retries. Nothing is silently + overwritten. The check is a read-then-write against `RepoStore.resolveRef` + rather than a compare-and-set inside `writeTree` — `receivePack` has CAS, + `writeTree` does not — so two republishes racing inside that window are + serialized by the repo lock, not refused. +- `populateAsset` is additive. A republish overwrites the paths it names and + carries every other committed file forward; `workflow_source_read` shows + the whole resulting tree. Deleting a file needs a seam that does not exist + yet. +- Writing an identical tree is a no-op commit (content-aware, like the CLI + pusher). Retrying an `author` after a network failure hits + `duplicate_asset`; the caller then republishes. +- An authored-but-never-deployed asset is a draft by state, not by table: + it has no `workflow_definition` row. It stays in the asset store until + deleted; it never appears in routine target discovery. +- Every operation is authorized as the run's own tenant and principal + (`WorkflowRunAuthenticator`); no tool argument names a tenant or asset it + cannot already reach. + +## Sequence + +```mermaid +sequenceDiagram + participant H as Human + participant M as Myra (run) + participant A as agent-workflow-authoring + participant S as AssetService (git) + participant D as /workflows/deployments + participant P as Sidecar probe + participant R as Routine targets + + H->>M: "make a routine that does X" + M->>A: author { name, files } + A->>A: authorize asset:*/create, validate paths + package + A->>S: createAsset + populateAsset (hub-signed commit) + S-->>M: { assetId, commitSha } + M->>A: deploy preview { assetId, commitSha, entry } + A->>S: RepoStore.openCommittedReadsAtCommit (static read, no probe) + S-->>M: { packageName, files, toolPackagePins } + M->>M: workflow_deploy (approval: ask) parks, carrying packageName/toolPackagePins + H->>H: inspects the committed source's package + tools; approves + M->>D: POST { source: asset/source/commitSha, entry } + D->>P: bundle, probe, capability walk, gate under approve-probed + D->>D: freeze workflow_definition (approved_wire_hash, grant_snapshot) + D-->>M: deployment { definitionAssetId } + R-->>H: definition selectable as routine target +``` + +## Seams that exist + +- `@corbits/workflow-authoring-tools` (CL-7360, `workflow_deploy` CL-7361): + `workflow_author`, `workflow_republish`, `workflow_source_read`, and + `workflow_deploy` (the only one carrying `approval: "ask"`) over the + routes above, pinned into Myra's `ASSISTANT_TOOL_PACKAGE_PINS` and + published to the `corbits-tools` registry. +- `POST /api/workflow-workflow-authoring/:assetId/deploy` + (`agent-workflow-authoring`, CL-7361): a run-authenticated mirror of the + native `/workflows/deployments` route, injected from `apps/hub/src/ +index.ts` as a `WorkflowDeployer` wrapping the same + `sessionService.deployWorkflowFromSource` call (through + `withDeploySourceRecording`) with sources resolved server-side. +- Path/package validation in `agent-workflow-authoring`'s registry + (`validateWorkflowSourceTree`, CL-7360): runs before any grant check or + write; caps are `MAX_SOURCE_FILE_BYTES`, `MAX_SOURCE_TREE_BYTES`, + `MAX_SOURCE_FILE_COUNT`. + +## Seams that do not exist yet (and where they go) + +- A preview operation returning the probed capability surface (grants) + before `workflow_deploy` parks: needs a caller-supplied approval policy + or a probe-without-freeze entry point on native `sessionService` — a + vendored delta prototyping this was reverted (see VENDORED.md, CL-7362). + Until upstream exposes that seam, `wf_deploy_preview` stays a + STATIC read of the already-committed source (package name, files, any + statically-declared `toolPackagePins`) and the parked approval's + snapshot is that plus `workflow_deploy`'s own tool-call arguments — a + human sees the real committed package and its declared tools, not yet + the grants/capabilities the deploy will freeze. +- Deleting a file from an authored asset (a `writeTreeDelta`-backed + republish, or a `clearPrefix` the substrate accepts at the root). +- A compare-and-set republish (`expectedHeadSha` enforced under the repo + lock rather than before it). + +Nothing here adds a repository, compiler, probe, freezer, or approval store. diff --git a/packages/agent-workflow-authoring/src/errors.ts b/packages/agent-workflow-authoring/src/errors.ts index bbeee35f1..aa95fc7d7 100644 --- a/packages/agent-workflow-authoring/src/errors.ts +++ b/packages/agent-workflow-authoring/src/errors.ts @@ -1,9 +1,5 @@ export type WorkflowAuthorErrorReason = - | "forbidden" - | "not_found" - | "conflict" - | "invalid" - | "unavailable"; + "forbidden" | "not_found" | "conflict" | "invalid" | "unavailable"; export class WorkflowAuthorError extends Error { readonly reason: WorkflowAuthorErrorReason; diff --git a/packages/agent-workflow-authoring/src/registry.ts b/packages/agent-workflow-authoring/src/registry.ts index fca506c73..c5ab2a22e 100644 --- a/packages/agent-workflow-authoring/src/registry.ts +++ b/packages/agent-workflow-authoring/src/registry.ts @@ -50,10 +50,7 @@ import { type } from "arktype"; import { PackageJSON } from "@intx/types/package-json"; import { WorkflowAuthorError } from "./errors"; -import { - PACKAGE_JSON_PATH, - validateWorkflowSourceTree, -} from "./source-tree"; +import { PACKAGE_JSON_PATH, validateWorkflowSourceTree } from "./source-tree"; const WORKFLOW_ASSET_KIND = "workflow"; const HUB_PRINCIPAL = { kind: "hub" } as const; @@ -115,7 +112,10 @@ export type WorkflowDeployPreviewResult = { /** The `toolPackagePins` an inert `export default {...}` entry declares; * empty when the entry isn't a plain object literal (a folded/built * workflow — pins aren't statically knowable there without execution). */ - readonly toolPackagePins: readonly { readonly name: string; readonly version: string }[]; + readonly toolPackagePins: readonly { + readonly name: string; + readonly version: string; + }[]; readonly packageName: string; }; @@ -300,7 +300,11 @@ function tryReadInertDefaultExport(source: string): unknown { function extractToolPackagePins( literal: unknown, ): readonly { readonly name: string; readonly version: string }[] { - if (literal === undefined || literal === null || typeof literal !== "object") { + if ( + literal === undefined || + literal === null || + typeof literal !== "object" + ) { return []; } const pins = (literal as Record).toolPackagePins; @@ -460,7 +464,7 @@ export function createWorkflowAuthorRegistry( async previewDeploy(caller, assetId, input) { // Own-tenant scoping and the same `workflow:*`/create authorization // as `deploy`: a preview shows exactly what `deploy` would name. - const row = await requireOwnWorkflowAsset(caller, assetId); + await requireOwnWorkflowAsset(caller, assetId); await requireAuthorized(deps, caller, "workflow:*", "create"); // A STATIC read of the already-committed source at `commitSha` — diff --git a/packages/approvals/src/headline.ts b/packages/approvals/src/headline.ts index e5e9245c4..ae32f4358 100644 --- a/packages/approvals/src/headline.ts +++ b/packages/approvals/src/headline.ts @@ -30,7 +30,7 @@ function toolPackagePinsField( /** * CL-7362: `workflow_deploy` (`@corbits/workflow-authoring-tools`) parks an * approval whose arguments carry the packageName/toolPackagePins a prior - * `workflow_deploy_preview` call (a static read of the committed source) + * `wf_deploy_preview` call (a static read of the committed source) * reported, passed through. This renders that directly rather than * falling back to the tool's generic description, so the approval card * names the real package and tools instead of a bare asset id. It does diff --git a/packages/workflow-authoring-tools/src/tool.test.ts b/packages/workflow-authoring-tools/src/tool.test.ts index 700e2e3ba..f8b29cdb7 100644 --- a/packages/workflow-authoring-tools/src/tool.test.ts +++ b/packages/workflow-authoring-tools/src/tool.test.ts @@ -243,7 +243,7 @@ test("workflow_deploy rejects a call missing required fields without calling the ); }); -test("workflow_deploy_preview posts assetId, commitSha, and entry to the preview route and never approval-gates", async () => { +test("wf_deploy_preview posts assetId, commitSha, and entry to the preview route and never approval-gates", async () => { const bundle = workflowAuthoringTools(testEnv()); let seenUrl: string | undefined; const result = await withFetch( diff --git a/packages/workflow-authoring-tools/src/tool.ts b/packages/workflow-authoring-tools/src/tool.ts index 75b81eae3..af3357bc7 100644 --- a/packages/workflow-authoring-tools/src/tool.ts +++ b/packages/workflow-authoring-tools/src/tool.ts @@ -30,7 +30,7 @@ import { export const WORKFLOW_AUTHOR_TOOL = "workflow_author"; export const WORKFLOW_REPUBLISH_TOOL = "workflow_republish"; export const WORKFLOW_SOURCE_READ_TOOL = "workflow_source_read"; -export const WORKFLOW_DEPLOY_PREVIEW_TOOL = "workflow_deploy_preview"; +export const WORKFLOW_DEPLOY_PREVIEW_TOOL = "wf_deploy_preview"; export const WORKFLOW_DEPLOY_TOOL = "workflow_deploy"; /** Env this bundle needs beyond `BaseEnv`: the hub origin under its own @@ -335,7 +335,7 @@ export const workflowAuthoringTools = defineTool({ "walk, gate, freeze), making it selectable as a routine target. " + "A human must approve this before it runs: the approval card " + "shows the package and tools this call names, sourced from a " + - "prior workflow_deploy_preview call on the same commit — call " + + "prior wf_deploy_preview call on the same commit — call " + "that FIRST and pass its packageName and toolPackagePins here " + "so the approval reflects the real committed source, not just " + "an asset id. Grants/capabilities are stamped by the native " + @@ -365,7 +365,7 @@ export const workflowAuthoringTools = defineTool({ packageName: { type: "string", description: - "The packageName returned by workflow_deploy_preview for " + + "The packageName returned by wf_deploy_preview for " + "this same asset/commit, shown on the approval card.", }, toolPackagePins: { @@ -379,7 +379,7 @@ export const workflowAuthoringTools = defineTool({ required: ["name", "version"], }, description: - "The toolPackagePins returned by workflow_deploy_preview " + + "The toolPackagePins returned by wf_deploy_preview " + "for this same asset/commit, shown on the approval card.", }, }, From 48aaed37a5fec34760d66d164683d4959cecccbb Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 02:27:09 -0700 Subject: [PATCH 7/8] Fix CI after review pass (CL-7362) --- packages/agent-workflow-authoring/src/registry.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/agent-workflow-authoring/src/registry.ts b/packages/agent-workflow-authoring/src/registry.ts index c5ab2a22e..7e0d4e975 100644 --- a/packages/agent-workflow-authoring/src/registry.ts +++ b/packages/agent-workflow-authoring/src/registry.ts @@ -293,6 +293,9 @@ function tryReadInertDefaultExport(source: string): unknown { try { return JSON.parse(quotedKeys); } catch { + // report-error-ignore: a non-JSON entry (real code, not an inert + // literal) is the expected, common case for a folded/multi-step + // workflow — falling back to listing files only, not an error. return undefined; } } From a32b986b54b89ead1df988c453afc9e95a8193a7 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 03:54:05 -0700 Subject: [PATCH 8/8] Remove duplicated retarget-authorization check (CL-7362) --- packages/routines/src/routes.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/packages/routines/src/routes.ts b/packages/routines/src/routes.ts index 04bf85276..8567d38a4 100644 --- a/packages/routines/src/routes.ts +++ b/packages/routines/src/routes.ts @@ -889,27 +889,6 @@ export function createRoutineRoutes( const effectiveDefinitionAssetId = body.definitionAssetId ?? existing.definitionAssetId; - if ( - body.definitionAssetId !== undefined && - body.definitionAssetId !== existing.definitionAssetId - ) { - const rejection = await rejectUnlaunchableTarget( - deps, - tenant.id, - principal.id, - body.definitionAssetId, - ); - if (rejection !== undefined) { - return c.json( - makeErrorEnvelope({ - code: rejection.code, - userMessage: rejection.userMessage, - }), - rejection.status, - ); - } - } - if ( body.trigger !== undefined && !(await webhookTriggerValid(