Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ 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 {
createAgentDefinitionRoutes,
Expand Down Expand Up @@ -1891,6 +1894,15 @@ export async function createHub(config: HubConfig) {
// considered at this step, matching `agent-definitions`' identical
// tenant-default resolution above; deploy always resolves against the
// tenant's default/first-preference model.
//
// `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`
// (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 }) {
const tenantRow = await db.query.tenant.findFirst({
Expand Down
39 changes: 24 additions & 15 deletions docs/workflow-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `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
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 \<packageName\> @ \<sha7\> — tools: \<pins or "none
declared"\>" — 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.
Expand Down
47 changes: 25 additions & 22 deletions docs/workflow-source-authoring.md

Large diffs are not rendered by default.

105 changes: 105 additions & 0 deletions packages/agent-workflow-authoring/src/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,3 +561,108 @@ test("deploy calls the injected deployer with the caller's own scope once author
entry: "./workflow.ts",
});
});

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<string, string> = {
oid_pkg: MANIFEST,
oid_entry: pinnedEntry,
};
let deployCalled = false;
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");
},
}),
}),
);

const result = await registry.previewDeploy(caller, "asset_1", {
commitSha: "sha_1",
entry: "workflow.ts",
});

expect(result).toEqual({
commitSha: "sha_1",
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 lists files only, with no tool pins, when the entry is not an inert object literal", async () => {
const blobs: Record<string, string> = { oid_pkg: MANIFEST, oid_entry: ENTRY };
const registry = createWorkflowAuthorRegistry(
deps({
db: fakeDb(ownRow),
grantStore: fakeGrantStore([workflowGrant("create")]),
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",
});
expect(result.toolPackagePins).toEqual([]);
expect(result.files).toEqual(["package.json", "workflow.ts"]);
});

test("previewDeploy is not_found when the commit does not exist", async () => {
const registry = createWorkflowAuthorRegistry(
deps({
db: fakeDb(ownRow),
grantStore: fakeGrantStore([workflowGrant("create")]),
repoStore: fakeRepoStore({
openCommittedReadsAtCommit: async () => null,
}),
}),
);

const err = await registry
.previewDeploy(caller, "asset_1", {
commitSha: "sha_missing",
entry: "workflow.ts",
})
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(WorkflowAuthorError);
expect((err as WorkflowAuthorError).reason).toBe("not_found");
});
152 changes: 151 additions & 1 deletion packages/agent-workflow-authoring/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ import {
import type { DB } from "@intx/db";
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;
Expand Down Expand Up @@ -102,6 +104,21 @@ export type WorkflowDeployResult = {
readonly status: "deployed" | "pending";
};

export type WorkflowDeployPreviewResult = {
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;
};

/**
* The apps/hub-supplied seam onto the same operation the native
* `POST /workflows/deployments` route drives (`sessionService.
Expand All @@ -110,6 +127,11 @@ export type WorkflowDeployResult = {
* 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: {
Expand Down Expand Up @@ -141,6 +163,11 @@ export type WorkflowAuthorRegistry = {
assetId: string,
input: DeployWorkflowInput,
): Promise<WorkflowDeployResult>;
previewDeploy(
caller: WorkflowAuthorCaller,
assetId: string,
input: DeployWorkflowInput,
): Promise<WorkflowDeployPreviewResult>;
};

export type WorkflowAuthorRepoReads = Pick<
Expand Down Expand Up @@ -244,6 +271,64 @@ async function collectTree(
}
}

/**
* 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.
*/
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 {
// 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;
}
}

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<string, unknown>).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<string, unknown>).name === "string" &&
typeof (pin as Record<string, unknown>).version === "string"
) {
out.push({
name: (pin as { name: string }).name,
version: (pin as { version: string }).version,
});
}
}
return out;
}

export function createWorkflowAuthorRegistry(
deps: CreateWorkflowAuthorRegistryDeps,
): WorkflowAuthorRegistry {
Expand Down Expand Up @@ -378,5 +463,70 @@ export function createWorkflowAuthorRegistry(
entry: input.entry,
});
},

async previewDeploy(caller, assetId, input) {
// Own-tenant scoping and the same `workflow:*`/create authorization
// as `deploy`: a preview shows exactly what `deploy` would name.
await requireOwnWorkflowAsset(caller, assetId);
await requireAuthorized(deps, caller, "workflow:*", "create");

// 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<string, string> = {};
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(
"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 {
commitSha: input.commitSha,
entry: input.entry,
files: Object.keys(files),
toolPackagePins,
packageName,
};
},
};
}
Loading
Loading