From 6298e355668b954c4dd41545caa32ea05da0e61c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 1 Sep 2026 23:21:16 -0700 Subject: [PATCH 1/7] 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 7e549759524b147be2060094c6aa20f257024a6e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 1 Sep 2026 22:59:40 -0700 Subject: [PATCH 2/7] Deploy Myra-authored assets through Interchange's native source pipeline (CL-7361) Add a run-authenticated POST /:assetId/deploy route to agent-workflow-authoring that resolves the tenant's inference sources server-side and calls the same sessionService.deployWorkflowFromSource the native /workflows/deployments route drives, via a WorkflowDeployer apps/hub injects. Add the workflow_deploy tool (approval: "ask") to workflow-authoring-tools so a human approves before an agent-authored workflow becomes a routine target. --- apps/hub/package.json | 1 + apps/hub/src/index.ts | 132 ++++++++++++++++-- bun.lock | 15 ++ .../agent-workflow-authoring/package.json | 2 +- .../agent-workflow-authoring/src/errors.ts | 2 +- .../agent-workflow-authoring/src/index.ts | 3 + .../src/registry.test.ts | 110 +++++++++++++++ .../agent-workflow-authoring/src/registry.ts | 66 ++++++++- .../src/workflow-routes.test.ts | 78 +++++++++++ .../src/workflow-routes.ts | 35 ++++- .../workflow-authoring-tools/package.json | 4 +- .../workflow-authoring-tools/src/client.ts | 44 ++++++ .../workflow-authoring-tools/src/index.ts | 4 + .../workflow-authoring-tools/src/tool.test.ts | 62 +++++++- packages/workflow-authoring-tools/src/tool.ts | 75 +++++++++- workflows/assistant/src/index.ts | 2 +- 16 files changed, 605 insertions(+), 30 deletions(-) diff --git a/apps/hub/package.json b/apps/hub/package.json index 498318498..5974b3f0a 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -63,6 +63,7 @@ "@intx/mime": "workspace:*", "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", + "@intx/workflow-deploy": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "@workbench/access-policy": "workspace:*", "@workbench/connections": "workspace:*", diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 4cccdd652..ac99f6774 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -39,9 +39,16 @@ import { createMailTriggeredRunGrantsMaterializer, createRequireGrant, readDurableWorkflowRunLifecycles, + resolveDefinitionSources, type AppEnv, type TenantEnv, } from "@intx/hub-api"; +import { + deriveRunAddress, + deriveRunAgentId, + WorkflowDefinitionInvalidError, +} from "@intx/workflow-deploy"; +import type { HarnessConfig } from "@intx/types/runtime"; import { createAgentDefinitionRoutes, @@ -330,6 +337,8 @@ import { createSkillRoutes, createWorkflowSkillRoutes } from "@corbits/skills"; import { createWorkflowAuthorRegistry, createWorkflowAuthorRoutes, + WorkflowAuthorError, + type WorkflowDeployer, } from "@corbits/agent-workflow-authoring"; import { mountArtifacts } from "./artifacts-mount"; import { mountWorkbenchSlackTag } from "./slack-tag-mount"; @@ -1858,16 +1867,118 @@ export async function createHub(config: HubConfig) { registry: skills.registry, }), ); - // Agent-authored workflows (CL-agent-authored-workflows): an agent - // publishes a workflow codebase as a native `kind:"workflow"` asset - // through this workflow-run-authenticated surface, then deploys the - // resulting asset through the tenant-session `/workflows/deployments` - // route this hub already mounts (unchanged, below) — this package never - // reimplements that deploy gating. Unlike `/api/workflow-skills` above, - // every write here also runs a real `chatGrantStore` authorization - // check (`asset:*`/create, `asset:`/write) before reaching - // `RepoStore`, because authoring is publishing executable code, not a - // markdown skill. + // CL-7361: the `deploy` half of the run-authenticated deployer this + // route's registry calls — the SAME `sessionService. + // deployWorkflowFromSource` call (already `withDeploySourceRecording`- + // wrapped above) the native `POST /workflows/deployments` route's own + // non-exclusive branch makes, not a reimplementation of install/probe/ + // gate/freeze. Inference sources are resolved server-side from the + // tenant's catalog (`resolveDefinitionSources`) exactly as + // `agent-definitions`' `tenantDefaultModel` does above — an agent never + // supplies or sees a provider secret. Exclusive sidecar placement is out + // of scope: an agent-authored deploy always lands on shared capacity. + const workflowDeployer: WorkflowDeployer = { + async deploy({ tenantId, principalId, 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`, + ); + } + const tenantRow = await db.query.tenant.findFirst({ + where: eq(tenantTable.id, tenantId), + }); + if (tenantRow === undefined) { + throw new WorkflowAuthorError("not_found", `tenant ${tenantId} not found`); + } + + const fallbackModel = + (await workbenchHostInferencePreferencesResolver(tenantId))[0] + ?.model ?? null; + const resolution = await resolveDefinitionSources({ + db, + tenantId, + modelRequirements: null, + fallbackModel, + invokerPreferences: {}, + credentialCipher, + }); + if (!resolution.ok) { + throw new WorkflowAuthorError("invalid", resolution.message); + } + + const anchorRunId = generateId("workflowRun"); + const agentAddress = deriveRunAddress({ + runId: anchorRunId, + domain: tenantRow.domain, + }); + const config: HarnessConfig = { + sessionId: generateId("session"), + agentId: deriveRunAgentId({ runId: anchorRunId }), + tenantId, + principalId, + agentAddress, + systemPrompt: "", + tools: [], + grants: [], + sources: resolution.sources, + defaultSource: resolution.defaultSource, + }; + + try { + const result = await sessionService.deployWorkflowFromSource({ + tenantId, + anchorRunId, + deploymentDomain: tenantRow.domain, + agentAddress, + source: { + kind: "asset", + assetId, + package: { format: "source", commitSha }, + }, + entry, + definitionAssetId: assetRow.id, + config, + }); + return { + deploymentId: result.anchorRunId, + definitionAssetId: assetRow.id, + status: "deployed", + }; + } catch (err) { + // Mirrors `@intx/hub-api`'s own `/workflows/deployments` route: an + // install/gate rejection or an unapproved source chain is a + // client/definition error; anything else (a missing commit, an + // unreachable sidecar) is reported as `unavailable` rather than + // guessed apart, exactly as the native route's own catch-all does. + if (err instanceof WorkflowDefinitionInvalidError) { + throw new WorkflowAuthorError("invalid", err.message); + } + throw new WorkflowAuthorError( + "unavailable", + err instanceof Error ? err.message : "Failed to deploy workflow", + ); + } + }, + }; + // Agent-authored workflows (CL-7360, CL-7361): an agent publishes a + // workflow codebase as a native `kind:"workflow"` asset AND deploys it, + // both through this workflow-run-authenticated surface — `deploy` + // reaches the exact same `sessionService.deployWorkflowFromSource` the + // tenant-session `/workflows/deployments` route drives (`workflowDeployer` + // above), never a second gating path. Unlike `/api/workflow-skills` + // above, every write here also runs a real `chatGrantStore` + // authorization check (`asset:*`/create, `asset:`/write, + // `workflow:*`/create) before reaching `RepoStore` or the deploy call, + // because authoring and deploying are side effects, not a markdown + // skill edit. app.route( "/api/workflow-workflow-authoring", createWorkflowAuthorRoutes({ @@ -1878,6 +1989,7 @@ export async function createHub(config: HubConfig) { repoStore: agentRepoStore.repoStore, grantStore: chatGrantStore, conditionRegistry: chatConditionRegistry, + deployer: workflowDeployer, }), }), ); diff --git a/bun.lock b/bun.lock index e533133e2..075630757 100644 --- a/bun.lock +++ b/bun.lock @@ -83,6 +83,7 @@ "@intx/mime": "workspace:*", "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", + "@intx/workflow-deploy": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "@workbench/access-policy": "workspace:*", "@workbench/connections": "workspace:*", @@ -3650,6 +3651,16 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#81049ed", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-81049ed", "sha512-oTE0iFDyQdz0ifG1epo39pwaCaYaw19YcKXwfaZqAEQ56a1g9YIozXwH9CG4NaUTwcJKUeYGuNls6oJsMPisCw=="], + + "@corbits/chat-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/context-menu/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3672,6 +3683,10 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], + "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#81049ed", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-81049ed", "sha512-oTE0iFDyQdz0ifG1epo39pwaCaYaw19YcKXwfaZqAEQ56a1g9YIozXwH9CG4NaUTwcJKUeYGuNls6oJsMPisCw=="], + + "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], diff --git a/packages/agent-workflow-authoring/package.json b/packages/agent-workflow-authoring/package.json index c51e7119b..e1d78db12 100644 --- a/packages/agent-workflow-authoring/package.json +++ b/packages/agent-workflow-authoring/package.json @@ -1,7 +1,7 @@ { "name": "@corbits/agent-workflow-authoring", "private": true, - "description": "Lets an agent author a workflow codebase as a native kind:\"workflow\" hub asset and republish it, gated by an explicit asset:*/create+write grant on the agent's own principal and tenant — the hub half of the agent-authored-workflows capability; deploy stays on @intx/hub-api's existing source-based deploy route", + "description": "Lets an agent author a workflow codebase as a native kind:\"workflow\" hub asset, republish it, and deploy it through Interchange's native source pipeline, each gated by an explicit grant on the agent's own principal and tenant — the hub half of the agent-authored-workflows capability", "version": "0.1.0", "license": "LGPL-2.1-or-later", "type": "module", diff --git a/packages/agent-workflow-authoring/src/errors.ts b/packages/agent-workflow-authoring/src/errors.ts index 216a48385..aa95fc7d7 100644 --- a/packages/agent-workflow-authoring/src/errors.ts +++ b/packages/agent-workflow-authoring/src/errors.ts @@ -1,5 +1,5 @@ export type WorkflowAuthorErrorReason = - "forbidden" | "not_found" | "conflict" | "invalid"; + "forbidden" | "not_found" | "conflict" | "invalid" | "unavailable"; export class WorkflowAuthorError extends Error { readonly reason: WorkflowAuthorErrorReason; diff --git a/packages/agent-workflow-authoring/src/index.ts b/packages/agent-workflow-authoring/src/index.ts index b9fb735ff..92718bb14 100644 --- a/packages/agent-workflow-authoring/src/index.ts +++ b/packages/agent-workflow-authoring/src/index.ts @@ -4,11 +4,14 @@ export { WORKFLOW_ASSET_NAME_PATTERN, type AuthorWorkflowInput, type CreateWorkflowAuthorRegistryDeps, + type DeployWorkflowInput, type RepublishWorkflowInput, type WorkflowAssetSummary, type WorkflowAuthorCaller, type WorkflowAuthorRegistry, type WorkflowAuthorRepoReads, + type WorkflowDeployer, + type WorkflowDeployResult, type WorkflowSourceSnapshot, } from "./registry"; export { diff --git a/packages/agent-workflow-authoring/src/registry.test.ts b/packages/agent-workflow-authoring/src/registry.test.ts index cbd4f18e8..869b01312 100644 --- a/packages/agent-workflow-authoring/src/registry.test.ts +++ b/packages/agent-workflow-authoring/src/registry.test.ts @@ -12,6 +12,7 @@ import { createWorkflowAuthorRegistry, type CreateWorkflowAuthorRegistryDeps, type WorkflowAuthorRepoReads, + type WorkflowDeployer, } from "./registry"; const MANIFEST = JSON.stringify({ @@ -107,6 +108,17 @@ function fakeDb(row: AssetRow | undefined): DB["db"] { } as unknown as DB["db"]; } +function fakeDeployer( + overrides: Partial = {}, +): WorkflowDeployer { + return { + deploy: async () => { + throw new Error("deploy not stubbed"); + }, + ...overrides, + }; +} + function deps( overrides: Partial = {}, ): CreateWorkflowAuthorRegistryDeps { @@ -120,10 +132,25 @@ function deps( allowGrant("read"), ]), conditionRegistry, + deployer: fakeDeployer(), ...overrides, }; } +function workflowGrant(action: string): GrantRule { + return { + id: `g_workflow_${action}`, + resource: "workflow:*", + action, + effect: "allow", + origin: "system", + conditions: null, + expiresAt: null, + roleId: null, + principalId: null, + }; +} + const caller = { tenantId: "tenant_1", principalId: "principal_1" }; test("author publishes a workflow codebase as a workflow-kind asset", async () => { @@ -450,3 +477,86 @@ test("readSource refuses without an asset read grant", async () => { .catch((e: unknown) => e); expect((err as WorkflowAuthorError).reason).toBe("forbidden"); }); + +test("deploy refuses an asset id that does not resolve in the caller's own tenant", async () => { + let deployCalled = false; + const registry = createWorkflowAuthorRegistry( + deps({ + db: fakeDb(undefined), + grantStore: fakeGrantStore([workflowGrant("create")]), + deployer: fakeDeployer({ + deploy: async () => { + deployCalled = true; + throw new Error("must not be called"); + }, + }), + }), + ); + const err = await registry + .deploy(caller, "asset_from_another_tenant", { + commitSha: "sha_1", + entry: "./workflow.ts", + }) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(WorkflowAuthorError); + expect((err as WorkflowAuthorError).reason).toBe("not_found"); + expect(deployCalled).toBe(false); +}); + +test("deploy refuses when the grant store has no matching workflow:*/create grant", async () => { + let deployCalled = false; + const registry = createWorkflowAuthorRegistry( + deps({ + db: fakeDb(ownRow), + grantStore: fakeGrantStore([allowGrant("create")]), // asset:*/create, not workflow:*/create + deployer: fakeDeployer({ + deploy: async () => { + deployCalled = true; + throw new Error("must not be called"); + }, + }), + }), + ); + const err = await registry + .deploy(caller, "asset_1", { commitSha: "sha_1", entry: "./workflow.ts" }) + .catch((e: unknown) => e); + expect(err).toBeInstanceOf(WorkflowAuthorError); + expect((err as WorkflowAuthorError).reason).toBe("forbidden"); + expect(deployCalled).toBe(false); +}); + +test("deploy calls the injected deployer with the caller's own scope once authorized", async () => { + let seen: unknown; + const registry = createWorkflowAuthorRegistry( + deps({ + db: fakeDb(ownRow), + grantStore: fakeGrantStore([workflowGrant("create")]), + deployer: fakeDeployer({ + deploy: async (params) => { + seen = params; + return { + deploymentId: "run_1", + definitionAssetId: "asset_1", + status: "deployed", + }; + }, + }), + }), + ); + const result = await registry.deploy(caller, "asset_1", { + commitSha: "sha_1", + entry: "./workflow.ts", + }); + expect(result).toEqual({ + deploymentId: "run_1", + definitionAssetId: "asset_1", + status: "deployed", + }); + expect(seen).toEqual({ + tenantId: "tenant_1", + principalId: "principal_1", + assetId: "asset_1", + commitSha: "sha_1", + entry: "./workflow.ts", + }); +}); diff --git a/packages/agent-workflow-authoring/src/registry.ts b/packages/agent-workflow-authoring/src/registry.ts index 8c8c64158..4c05680db 100644 --- a/packages/agent-workflow-authoring/src/registry.ts +++ b/packages/agent-workflow-authoring/src/registry.ts @@ -6,12 +6,14 @@ // grant-store authorization call (`asset:*`/create for a new asset, // `asset:`/write for a republish, `asset:`/read for a source read) // — and by `validateWorkflowSourceTree` before anything reaches -// `RepoStore`. Deploying an authored asset is deliberately NOT this -// package's job: deploy stays on `@intx/hub-api`'s existing source-based -// `POST .../workflows/deployments` route (`WorkflowDefinitionSource` + -// `entry`), which already installs, probes, gates, and freezes the -// definition. Building a second deploy path here would duplicate that -// gating, not strengthen it. +// `RepoStore`. `deploy` (CL-7361) is a run-authenticated mirror of +// `@intx/hub-api`'s existing source-based `POST .../workflows/deployments` +// route: it checks `workflow:*`/`create` itself, then calls a +// `WorkflowDeployer` apps/hub injects that wraps the SAME +// `sessionService.deployWorkflowFromSource` call the native route makes +// (`withDeploySourceRecording` included) with inference sources resolved +// server-side from the tenant's catalog, never supplied by the caller. +// No install/probe/gate/freeze logic is reimplemented here. // // `populateAsset` is called with `principal: { kind: "hub" }`, the same // principal `@corbits/skills`' `writeSkillMd` uses. This is deliberate, @@ -89,6 +91,36 @@ export type RepublishWorkflowInput = { readonly expectedHeadSha?: string; }; +export type DeployWorkflowInput = { + readonly commitSha: string; + readonly entry: string; +}; + +export type WorkflowDeployResult = { + readonly deploymentId: string; + readonly definitionAssetId: string; + readonly status: string; +}; + +/** + * The apps/hub-supplied seam onto the same operation the native + * `POST /workflows/deployments` route drives (`sessionService. + * deployWorkflowFromSource`, wrapped by `withDeploySourceRecording`), with + * inference sources resolved server-side from the tenant's catalog. Thrown + * failures are `WorkflowAuthorError`s with a reason this registry passes + * straight through: `not_found` (asset/commit missing), `invalid` + * (rejected package/definition), `unavailable` (sidecar unreachable). + */ +export type WorkflowDeployer = { + deploy(params: { + tenantId: string; + principalId: string; + assetId: string; + commitSha: string; + entry: string; + }): Promise; +}; + export type WorkflowAuthorRegistry = { author( caller: WorkflowAuthorCaller, @@ -103,6 +135,11 @@ export type WorkflowAuthorRegistry = { caller: WorkflowAuthorCaller, assetId: string, ): Promise; + deploy( + caller: WorkflowAuthorCaller, + assetId: string, + input: DeployWorkflowInput, + ): Promise; }; export type WorkflowAuthorRepoReads = Pick< @@ -116,6 +153,7 @@ export type CreateWorkflowAuthorRegistryDeps = { repoStore: WorkflowAuthorRepoReads; grantStore: GrantStore; conditionRegistry: ConditionRegistry; + deployer: WorkflowDeployer; }; async function requireAuthorized( @@ -322,5 +360,21 @@ export function createWorkflowAuthorRegistry( await collectTree(reads, "", files); return { assetId, name: row.name, headSha, files }; }, + + async deploy(caller, assetId, input) { + // Own-tenant scoping resolved BEFORE the grant check, same as every + // other write here: an asset id from another tenant reads as + // not_found, never a 403 confirming the id exists. + await requireOwnWorkflowAsset(caller, assetId); + await requireAuthorized(deps, caller, "workflow:*", "create"); + + return deps.deployer.deploy({ + tenantId: caller.tenantId, + principalId: caller.principalId, + assetId, + commitSha: input.commitSha, + entry: input.entry, + }); + }, }; } diff --git a/packages/agent-workflow-authoring/src/workflow-routes.test.ts b/packages/agent-workflow-authoring/src/workflow-routes.test.ts index 030125608..143bebcd9 100644 --- a/packages/agent-workflow-authoring/src/workflow-routes.test.ts +++ b/packages/agent-workflow-authoring/src/workflow-routes.test.ts @@ -27,6 +27,9 @@ function fakeRegistry( readSource: async () => { throw new Error("readSource not stubbed"); }, + deploy: async () => { + throw new Error("deploy not stubbed"); + }, ...overrides, }; } @@ -217,3 +220,78 @@ test("GET /:assetId/source returns the registry's snapshot for the authenticated const body = (await res.json()) as { data: { headSha: string } }; expect(body.data.headSha).toBe("sha_head"); }); + +test("POST /:assetId/deploy returns the deployment on the happy path", async () => { + let seen: { assetId: string; commitSha: string; entry: string } | undefined; + const app = createWorkflowAuthorRoutes({ + authenticator: fakeAuthenticator({ + tenantId: "tenant_1", + principalId: "principal_1", + }), + registry: fakeRegistry({ + deploy: async (_caller, assetId, input) => { + seen = { assetId, ...input }; + return { + deploymentId: "run_1", + definitionAssetId: assetId, + status: "deployed", + }; + }, + }), + }); + const res = await app.request( + req("/asset_1/deploy", { commitSha: "sha_1", entry: "./workflow.ts" }), + ); + expect(res.status).toBe(201); + expect(seen).toEqual({ + assetId: "asset_1", + commitSha: "sha_1", + entry: "./workflow.ts", + }); + const body = (await res.json()) as { + data: { deploymentId: string; status: string }; + }; + expect(body.data.deploymentId).toBe("run_1"); + expect(body.data.status).toBe("deployed"); +}); + +test("POST /:assetId/deploy surfaces a forbidden deploy as 403, not a 500", async () => { + const app = createWorkflowAuthorRoutes({ + authenticator: fakeAuthenticator({ + tenantId: "tenant_1", + principalId: "principal_1", + }), + registry: fakeRegistry({ + deploy: async () => { + throw new WorkflowAuthorError( + "forbidden", + 'principal principal_1 is not granted "create" on "workflow:*"', + ); + }, + }), + }); + const res = await app.request( + req("/asset_1/deploy", { commitSha: "sha_1", entry: "./workflow.ts" }), + ); + expect(res.status).toBe(403); + const body = (await res.json()) as { error: { code: string } }; + expect(body.error.code).toBe("forbidden"); +}); + +test("POST /:assetId/deploy surfaces a sidecar-unavailable deploy as 502", async () => { + const app = createWorkflowAuthorRoutes({ + authenticator: fakeAuthenticator({ + tenantId: "tenant_1", + principalId: "principal_1", + }), + registry: fakeRegistry({ + deploy: async () => { + throw new WorkflowAuthorError("unavailable", "sidecar unreachable"); + }, + }), + }); + const res = await app.request( + req("/asset_1/deploy", { commitSha: "sha_1", entry: "./workflow.ts" }), + ); + expect(res.status).toBe(502); +}); diff --git a/packages/agent-workflow-authoring/src/workflow-routes.ts b/packages/agent-workflow-authoring/src/workflow-routes.ts index 8f034a339..0d54474c9 100644 --- a/packages/agent-workflow-authoring/src/workflow-routes.ts +++ b/packages/agent-workflow-authoring/src/workflow-routes.ts @@ -11,8 +11,10 @@ // never name a different tenant or write into another principal's // workflow asset. // -// This surface stops at "author a workflow asset". Deploying it is -// deliberately out of scope here — see `./registry.ts`'s doc comment. +// `POST /:assetId/deploy` (CL-7361) extends this surface to deployment: a +// run-authenticated mirror of the native `/workflows/deployments` route, +// authorized and gated exactly the same way — see `./registry.ts`'s doc +// comment on `deploy`. import { type } from "arktype"; import { Hono } from "hono"; import { makeErrorEnvelope } from "@workbench/hub-client"; @@ -48,9 +50,14 @@ const RepublishBody = type({ "expectedHeadSha?": "string", }); +const DeployBody = type({ + commitSha: "string", + entry: "string", +}); + function statusFor( reason: WorkflowAuthorError["reason"], -): 400 | 403 | 404 | 409 { +): 400 | 403 | 404 | 409 | 502 { switch (reason) { case "not_found": return 404; @@ -60,6 +67,8 @@ function statusFor( return 409; case "invalid": return 400; + case "unavailable": + return 502; } } @@ -151,5 +160,25 @@ export function createWorkflowAuthorRoutes( return c.json({ data: snapshot }); }); + app.post("/:assetId/deploy", async (c) => { + const body = DeployBody(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.deploy( + scope, + c.req.param("assetId"), + body, + ); + return c.json({ data: result }, 201); + }); + return app; } diff --git a/packages/workflow-authoring-tools/package.json b/packages/workflow-authoring-tools/package.json index 93e6ac518..88d03e5c8 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): 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 and read it back; deploying that asset is a separate, approval-gated step", - "version": "0.0.1", + "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", "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 bbfeb5900..2875867ef 100644 --- a/packages/workflow-authoring-tools/src/client.ts +++ b/packages/workflow-authoring-tools/src/client.ts @@ -44,6 +44,18 @@ export type WorkflowSourceSnapshot = { readonly files: WorkflowSourceFiles; }; +export type DeployWorkflowRequest = { + readonly assetId: string; + readonly commitSha: string; + readonly entry: string; +}; + +export type WorkflowDeployResult = { + readonly deploymentId: string; + readonly definitionAssetId: string; + readonly status: 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 @@ -84,6 +96,14 @@ const SnapshotResponse = type({ }, }); +const DeployResponse = type({ + data: { + deploymentId: "string", + definitionAssetId: "string", + status: "string", + }, +}); + function authHeaders( config: WorkflowAuthoringClientConfig, ): Record { @@ -168,6 +188,30 @@ export async function republishWorkflow( ).data; } +export async function deployWorkflow( + config: WorkflowAuthoringClientConfig, + input: DeployWorkflowRequest, +): Promise { + const doFetch = config.fetchImpl ?? fetch; + const response = await doFetch( + endpoint(config, `/${encodeURIComponent(input.assetId)}/deploy`), + { + method: "POST", + headers: { ...authHeaders(config), "content-type": "application/json" }, + body: JSON.stringify({ + commitSha: input.commitSha, + entry: input.entry, + }), + }, + ); + if (!response.ok) await throwForFailure(response, "Deploying a workflow"); + return parseOrThrow( + DeployResponse, + await response.json(), + "Deploying a workflow", + ).data; +} + export async function readWorkflowSource( config: WorkflowAuthoringClientConfig, assetId: string, diff --git a/packages/workflow-authoring-tools/src/index.ts b/packages/workflow-authoring-tools/src/index.ts index 5f33bdbf1..7b2d62de8 100644 --- a/packages/workflow-authoring-tools/src/index.ts +++ b/packages/workflow-authoring-tools/src/index.ts @@ -1,18 +1,22 @@ export { authorWorkflow, + deployWorkflow, readWorkflowSource, republishWorkflow, WorkflowAuthoringRequestError, type AuthorWorkflowRequest, + type DeployWorkflowRequest, type RepublishWorkflowRequest, type WorkflowAssetSummary, type WorkflowAuthoringClientConfig, + type WorkflowDeployResult, type WorkflowSourceFiles, type WorkflowSourceSnapshot, } from "./client"; export { workflowAuthoringTools, WORKFLOW_AUTHOR_TOOL, + WORKFLOW_DEPLOY_TOOL, WORKFLOW_REPUBLISH_TOOL, WORKFLOW_SOURCE_READ_TOOL, type WorkflowAuthoringEnv, diff --git a/packages/workflow-authoring-tools/src/tool.test.ts b/packages/workflow-authoring-tools/src/tool.test.ts index 268dae8c4..1ebd700a9 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_TOOL, WORKFLOW_REPUBLISH_TOOL, WORKFLOW_SOURCE_READ_TOOL, type WorkflowAuthoringEnv, @@ -35,11 +36,12 @@ async function withFetch( } } -test("declares the three authoring tools with no approval gate — writing source is not a side effect", () => { +test("declares the three source tools with no approval gate and workflow_deploy behind approval: ask", () => { expect(workflowAuthoringTools.definitions).toEqual([ { name: WORKFLOW_AUTHOR_TOOL }, { name: WORKFLOW_REPUBLISH_TOOL }, { name: WORKFLOW_SOURCE_READ_TOOL }, + { name: WORKFLOW_DEPLOY_TOOL, approval: "ask" }, ]); expect(workflowAuthoringTools.requires).toEqual([ "hubWorkflowAuthoringUrl", @@ -172,6 +174,64 @@ 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 () => { + const bundle = workflowAuthoringTools(testEnv()); + let seenUrl: string | undefined; + let seenBody: unknown; + const result = await withFetch( + (url, init) => { + seenUrl = url; + seenBody = init?.body !== undefined ? JSON.parse(String(init.body)) : undefined; + return new Response( + JSON.stringify({ + data: { + deploymentId: "run_1", + definitionAssetId: "asset_1", + status: "deployed", + }, + }), + { status: 201 }, + ); + }, + () => + bundle.run( + call(WORKFLOW_DEPLOY_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", + ); + expect(seenBody).toEqual({ commitSha: "sha_1", entry: "./workflow.ts" }); + 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 () => { + const bundle = workflowAuthoringTools(testEnv()); + await withFetch( + () => { + throw new Error("must not be called"); + }, + async () => { + await expect( + bundle.run( + call(WORKFLOW_DEPLOY_TOOL, { + assetId: "asset_1", + entry: "./workflow.ts", + }), + new AbortController().signal, + ), + ).rejects.toThrow(/invalid input/); + }, + ); +}); + test("an unknown tool name rejects loudly, never a silent no-op", async () => { const bundle = workflowAuthoringTools(testEnv()); await expect( diff --git a/packages/workflow-authoring-tools/src/tool.ts b/packages/workflow-authoring-tools/src/tool.ts index a77d9f3a1..bffa8be45 100644 --- a/packages/workflow-authoring-tools/src/tool.ts +++ b/packages/workflow-authoring-tools/src/tool.ts @@ -1,10 +1,14 @@ // The `@corbits/workflow-authoring-tools` bundle: `workflow_author`, -// `workflow_republish`, and `workflow_source_read` — an agent's way to -// write a workflow code package into a `kind: "workflow"` hub asset and -// read it back. None of the three carries `approval: "ask"`: writing +// `workflow_republish`, `workflow_source_read`, and `workflow_deploy` — an +// agent's way to write a workflow code package into a `kind: "workflow"` +// hub asset, read it back, and deploy it through Interchange's native +// source pipeline. The first three carry no `approval: "ask"`: writing // source is not a side effect (docs/workflow-model.md, "Authority -// boundaries"). Deploying the asset is a separate, approval-gated step -// owned by the `workflow_deploy` tool, never by this bundle. +// boundaries"). `workflow_deploy` does — deploying is what makes a +// workflow selectable as a routine target, so a human sees the deploy +// intent and approves it before the tool call ever reaches the hub (CL-7362 +// still owns showing the probed capability surface on that approval card; +// today's snapshot is the tool call's own arguments). // // A thrown error here is the honest result: `@intx/agent`'s tool runner // converts a rejected `run` into `ToolResult { isError: true }` carrying @@ -16,6 +20,7 @@ import { type } from "arktype"; import { authorWorkflow, + deployWorkflow, readWorkflowSource, republishWorkflow, type WorkflowAuthoringClientConfig, @@ -24,6 +29,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_TOOL = "workflow_deploy"; /** Env this bundle needs beyond `BaseEnv`: the hub origin under its own * key plus the run's bearer token and address, threaded by @@ -52,6 +58,12 @@ const RepublishInput = type({ const SourceReadInput = type({ assetId: "string > 0" }); +const DeployInput = type({ + assetId: "string > 0", + commitSha: "string > 0", + entry: "string > 0", +}); + const PACKAGE_SHAPE_DESCRIPTION = "The package is an ordinary code package: a top-level package.json " + 'with name, version, "type": "module", and ' + @@ -132,6 +144,22 @@ async function runSourceRead( return textResult(call.id, JSON.stringify(snapshot)); } +async function runDeploy( + env: WorkflowAuthoringEnv, + call: ToolCall, +): Promise { + const input = DeployInput(call.arguments); + if (input instanceof type.errors) { + throw invalidInput(WORKFLOW_DEPLOY_TOOL, input); + } + const result = await deployWorkflow(clientConfig(env), input); + return textResult( + call.id, + `Deployed workflow asset ${result.definitionAssetId} as deployment ${result.deploymentId} (status: ${result.status}). ` + + "It is now selectable as a routine target.", + ); +} + /** * The bundle id's middle segment is not the package name, unlike every * other `@corbits/*-tools` bundle: `:` is what goes on the @@ -149,6 +177,7 @@ export const workflowAuthoringTools = defineTool({ { name: WORKFLOW_AUTHOR_TOOL }, { name: WORKFLOW_REPUBLISH_TOOL }, { name: WORKFLOW_SOURCE_READ_TOOL }, + { name: WORKFLOW_DEPLOY_TOOL, approval: "ask" }, ], factory: (env) => ({ definitions: [ @@ -235,6 +264,40 @@ export const workflowAuthoringTools = defineTool({ required: ["assetId"], }, }, + { + 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. 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.", + inputSchema: { + type: "object", + properties: { + assetId: { + type: "string", + description: "The workflow asset id to deploy.", + }, + commitSha: { + type: "string", + description: + "The exact commit to deploy — 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"], + }, + }, ], run: (call: ToolCall, _signal: AbortSignal) => { switch (call.name) { @@ -244,6 +307,8 @@ export const workflowAuthoringTools = defineTool({ return runRepublish(env, call); case WORKFLOW_SOURCE_READ_TOOL: return runSourceRead(env, call); + case WORKFLOW_DEPLOY_TOOL: + return runDeploy(env, call); default: return Promise.reject( new Error( diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index 24df9bf01..77f7636a6 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.1" }, + { name: "@corbits/workflow-authoring-tools", version: "0.0.2" }, ]; /** From 2fd0cbd48cfc6d138ec486ec652b6c90ff6c6974 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 1 Sep 2026 22:59:45 -0700 Subject: [PATCH 3/7] Update docs: agent-authored workflow deploy route (CL-7361) Replace the "not yet built" deploy seam in workflow-source-authoring.md with the run-authenticated /:assetId/deploy route and workflow_deploy tool now in place; the probe-preview step stays CL-7362's. --- docs/workflow-source-authoring.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/workflow-source-authoring.md b/docs/workflow-source-authoring.md index 26a79bd52..98f3ba527 100644 --- a/docs/workflow-source-authoring.md +++ b/docs/workflow-source-authoring.md @@ -38,8 +38,8 @@ not what an agent authors by hand. | 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 | Same run scope | `{ wireHash, grants[] }` or an invalid-package error | -| 3 | `POST /api/tenants/:tenantId/workflows/deployments` | `workflow:*`/`create`; parked on `approval: "ask"` when agent-initiated | `WorkflowDeploymentResponse { id, definitionAssetId, status }` | +| 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 | Run bearer + run address → tenant/principal; `workflow:*`/`create`, own-tenant row check first; the `workflow_deploy` tool call itself carries `approval: "ask"` | `{ 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 | @@ -130,10 +130,17 @@ sequenceDiagram ## Seams that exist -- `@corbits/workflow-authoring-tools` (CL-7360): `workflow_author`, - `workflow_republish`, `workflow_source_read` over the routes above, - pinned into Myra's `ASSISTANT_TOOL_PACKAGE_PINS` and published to the - `corbits-tools` registry. +- `@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`, @@ -141,8 +148,12 @@ sequenceDiagram ## Seams that do not exist yet (and where they go) -- A run-authenticated preview route and the `workflow_deploy` tool: CL-7361, - CL-7362. +- 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 From dac00bfead89b243b9fa958b53ac5d35016263a3 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 1 Sep 2026 23:25:33 -0700 Subject: [PATCH 4/7] Fix lint (CL-7361) --- apps/hub/src/index.ts | 9 ++++++--- docs/workflow-source-authoring.md | 18 +++++++++--------- .../workflow-authoring-tools/src/tool.test.ts | 3 ++- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index ac99f6774..7780a151c 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -1896,12 +1896,15 @@ export async function createHub(config: HubConfig) { where: eq(tenantTable.id, tenantId), }); if (tenantRow === undefined) { - throw new WorkflowAuthorError("not_found", `tenant ${tenantId} not found`); + throw new WorkflowAuthorError( + "not_found", + `tenant ${tenantId} not found`, + ); } const fallbackModel = - (await workbenchHostInferencePreferencesResolver(tenantId))[0] - ?.model ?? null; + (await workbenchHostInferencePreferencesResolver(tenantId))[0]?.model ?? + null; const resolution = await resolveDefinitionSources({ db, tenantId, diff --git a/docs/workflow-source-authoring.md b/docs/workflow-source-authoring.md index 98f3ba527..cc9d26032 100644 --- a/docs/workflow-source-authoring.md +++ b/docs/workflow-source-authoring.md @@ -33,15 +33,15 @@ 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 | Run bearer + run address → tenant/principal; `workflow:*`/`create`, own-tenant row check first; the `workflow_deploy` tool call itself carries `approval: "ask"` | `{ 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 | 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 | Run bearer + run address → tenant/principal; `workflow:*`/`create`, own-tenant row check first; the `workflow_deploy` tool call itself carries `approval: "ask"` | `{ 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: diff --git a/packages/workflow-authoring-tools/src/tool.test.ts b/packages/workflow-authoring-tools/src/tool.test.ts index 1ebd700a9..bcdebd465 100644 --- a/packages/workflow-authoring-tools/src/tool.test.ts +++ b/packages/workflow-authoring-tools/src/tool.test.ts @@ -181,7 +181,8 @@ test("workflow_deploy posts assetId, commitSha, and entry to the deploy route", const result = await withFetch( (url, init) => { seenUrl = url; - seenBody = init?.body !== undefined ? JSON.parse(String(init.body)) : undefined; + seenBody = + init?.body !== undefined ? JSON.parse(String(init.body)) : undefined; return new Response( JSON.stringify({ data: { From c8ef5b6ed74542c53c2dbf4df996f7a3b4dad577 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 01:32:33 -0700 Subject: [PATCH 5/7] Address review findings (CL-7361) --- apps/hub/src/index.ts | 33 ++++++++++--------- docs/workflow-source-authoring.md | 2 +- .../src/registry.test.ts | 1 + .../agent-workflow-authoring/src/registry.ts | 6 ++-- packages/workflow-authoring-tools/src/tool.ts | 11 ++++--- 5 files changed, 30 insertions(+), 23 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 7780a151c..807edc9ae 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -1877,21 +1877,22 @@ export async function createHub(config: HubConfig) { // `agent-definitions`' `tenantDefaultModel` does above — an agent never // supplies or sees a provider secret. Exclusive sidecar placement is out // of scope: an agent-authored deploy always lands on shared capacity. + // Thin adapter over Interchange's native deploy: `registry.deploy()` + // (packages/agent-workflow-authoring) already resolves and authorizes the + // asset (own-tenant row check, `workflow:*`/create) before calling this, + // so this seam receives the already-resolved `assetId`/`assetName` + // rather than re-querying `assetTable` — the only work this adapter adds + // on top of native `sessionService.deployWorkflowFromSource` is + // server-side inference-source resolution (`resolveDefinitionSources`), + // because the native `/workflows/deployments` route requires the caller + // to supply `sources` directly and an agent caller must never see a + // provider secret to do that itself. `modelRequirements: null` is + // 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. const workflowDeployer: WorkflowDeployer = { - async deploy({ tenantId, principalId, 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`, - ); - } + async deploy({ tenantId, principalId, assetId, assetName, commitSha, entry }) { const tenantRow = await db.query.tenant.findFirst({ where: eq(tenantTable.id, tenantId), }); @@ -1947,12 +1948,12 @@ export async function createHub(config: HubConfig) { package: { format: "source", commitSha }, }, entry, - definitionAssetId: assetRow.id, + definitionAssetId: assetId, config, }); return { deploymentId: result.anchorRunId, - definitionAssetId: assetRow.id, + definitionAssetId: assetId, status: "deployed", }; } catch (err) { diff --git a/docs/workflow-source-authoring.md b/docs/workflow-source-authoring.md index cc9d26032..cd7680bb1 100644 --- a/docs/workflow-source-authoring.md +++ b/docs/workflow-source-authoring.md @@ -39,7 +39,7 @@ not what an agent authors by hand. | 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 | Run bearer + run address → tenant/principal; `workflow:*`/`create`, own-tenant row check first; the `workflow_deploy` tool call itself carries `approval: "ask"` | `{ deploymentId, definitionAssetId, status }` | +| 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 | diff --git a/packages/agent-workflow-authoring/src/registry.test.ts b/packages/agent-workflow-authoring/src/registry.test.ts index 869b01312..2b6dfa6f3 100644 --- a/packages/agent-workflow-authoring/src/registry.test.ts +++ b/packages/agent-workflow-authoring/src/registry.test.ts @@ -556,6 +556,7 @@ test("deploy calls the injected deployer with the caller's own scope once author tenantId: "tenant_1", principalId: "principal_1", assetId: "asset_1", + assetName: "daily-digest", commitSha: "sha_1", entry: "./workflow.ts", }); diff --git a/packages/agent-workflow-authoring/src/registry.ts b/packages/agent-workflow-authoring/src/registry.ts index 4c05680db..8417fbd40 100644 --- a/packages/agent-workflow-authoring/src/registry.ts +++ b/packages/agent-workflow-authoring/src/registry.ts @@ -99,7 +99,7 @@ export type DeployWorkflowInput = { export type WorkflowDeployResult = { readonly deploymentId: string; readonly definitionAssetId: string; - readonly status: string; + readonly status: "deployed" | "pending"; }; /** @@ -116,6 +116,7 @@ export type WorkflowDeployer = { tenantId: string; principalId: string; assetId: string; + assetName: string; commitSha: string; entry: string; }): Promise; @@ -365,13 +366,14 @@ export function createWorkflowAuthorRegistry( // Own-tenant scoping resolved BEFORE the grant check, same as every // other write here: an asset id from another tenant reads as // not_found, never a 403 confirming the id exists. - await requireOwnWorkflowAsset(caller, assetId); + const row = await requireOwnWorkflowAsset(caller, assetId); await requireAuthorized(deps, caller, "workflow:*", "create"); return deps.deployer.deploy({ tenantId: caller.tenantId, principalId: caller.principalId, assetId, + assetName: row.name, commitSha: input.commitSha, entry: input.entry, }); diff --git a/packages/workflow-authoring-tools/src/tool.ts b/packages/workflow-authoring-tools/src/tool.ts index bffa8be45..0205796f6 100644 --- a/packages/workflow-authoring-tools/src/tool.ts +++ b/packages/workflow-authoring-tools/src/tool.ts @@ -271,10 +271,13 @@ export const workflowAuthoringTools = defineTool({ "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. 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.", + "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.", inputSchema: { type: "object", properties: { From 341d0787775459bb14a839afbb051684d74b9605 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 02:23:35 -0700 Subject: [PATCH 6/7] Fix CI after review pass (CL-7361) --- apps/hub/src/index.ts | 2 +- docs/workflow-source-authoring.md | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 807edc9ae..7f095e5a5 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -1892,7 +1892,7 @@ export async function createHub(config: HubConfig) { // tenant-default resolution above; deploy always resolves against the // tenant's default/first-preference model. const workflowDeployer: WorkflowDeployer = { - async deploy({ tenantId, principalId, assetId, assetName, commitSha, entry }) { + async deploy({ tenantId, principalId, assetId, commitSha, entry }) { const tenantRow = await db.query.tenant.findFirst({ where: eq(tenantTable.id, tenantId), }); diff --git a/docs/workflow-source-authoring.md b/docs/workflow-source-authoring.md index cd7680bb1..aa8fe704b 100644 --- a/docs/workflow-source-authoring.md +++ b/docs/workflow-source-authoring.md @@ -33,15 +33,15 @@ 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 | +| 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 | +| 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: From d4a366da9d2c3039db762495e6438a808b2c9ccc Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 2 Sep 2026 03:51:47 -0700 Subject: [PATCH 7/7] Remove duplicated retarget-authorization check (CL-7361) --- 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(