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
8 changes: 5 additions & 3 deletions docs/orchestration-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,11 @@ Updates from late child cleanup are ignored once the tool call has settled.
This projection describes assigned tasks and lifecycle states; child tool and
model output remain in the child trajectories.

Workflows require the native `isolated-vm` runtime. Registration is disabled when
it cannot load. Unit tests can inject a VM executor to check the host contract;
those tests do not validate native isolation.
Workflows run in QuickJS compiled to WebAssembly, a pure-JavaScript dependency
that needs no native build, so registration no longer depends on the runtime and
is disabled only by `enabled: false` or `STEP_DISABLE_WORKFLOW=1`. Unit tests can
inject a VM executor to check the host contract; those tests do not validate
isolation itself.

## Child fan-out and turn settlement

Expand Down
14 changes: 7 additions & 7 deletions packages/coding-agent/docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,16 @@ The script can be run from any directory. Step keeps the caller's current workin

### Workflow runtime

Workflow execution requires the optional `isolated-vm` native addon to load under the selected Node runtime. A successful install with `--ignore-scripts` alone does not establish that the native addon is built. From the repository root, check that the addon can create an isolate:
Workflow scripts run in QuickJS compiled to WebAssembly (`src/features/workflow/vm.ts`). The engine ships with the package as a pure-JavaScript dependency, so it needs no native build step and behaves identically under Node and under the standalone executable — the `workflow` tool, `/workflows`, and `/ultraloop` register on every supported runtime.

```bash
cd packages/coding-agent
node --no-node-snapshot -e 'const vm = require("isolated-vm"); const isolate = new vm.Isolate({ memoryLimit: 16 }); console.log(isolate.createContextSync().evalSync("1 + 1")); isolate.dispose();'
```
This replaced the `isolated-vm` native addon, which linked V8's C++ API directly and therefore could only load on a V8 host. The standalone executable is built with Bun and runs on JavaScriptCore, so that addon could never load there: workflows, and with them the ultraloop opt-in that shares the workflow registration gate, were silently missing from every released build while working in a source run on Node.

Two constraints matter when editing the sandbox:

The expected result is `2`. If loading fails under Node, inspect the underlying error and install or rebuild the addon for that runtime before using workflows.
- Keep the `singlefile` QuickJS variant. The default `wasmfile` variant loads its `.wasm` from disk beside its own module, and that path does not exist inside a compiled executable's virtual filesystem.
- Release every handle before disposing the context, and dispose the context before the runtime. Otherwise QuickJS aborts the whole WebAssembly instance on `JS_FreeRuntime`, and because the module is cached process-wide that poisons every later run in the session.

The Bun standalone executable cannot host this V8 addon. It therefore does not register the `workflow` tool, `/workflows`, or `/ultraloop`. Suppressing the unsupported-runtime warning does not enable those features; use the Node source entry with a working addon when workflows are required.
Guest scripts have no access to `process`, `require`, `fetch`, the wall clock, or randomness, and run under a memory cap and a timeout. A single uninterrupted CPU burst longer than the timeout is terminated; time spent waiting on a host call is not counted against it.

## Forking / Rebranding

Expand Down
3 changes: 1 addition & 2 deletions packages/coding-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,7 @@
}
},
"optionalDependencies": {
"@mariozechner/clipboard": "0.3.9",
"isolated-vm": "6.0.1"
"@mariozechner/clipboard": "0.3.9"
},
"devDependencies": {
"@types/cross-spawn": "6.0.6",
Expand Down
10 changes: 2 additions & 8 deletions packages/coding-agent/src/features/workflow/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,7 @@ export {
workflowHash,
} from "./journal.ts";
export { formatWorkflowStatus, listSavedWorkflows, listWorkflowRuns, WorkflowProgressStore } from "./progress.ts";
export {
defaultWorkflowVmExecutor,
WorkflowRuntime,
WorkflowSchemaError,
workflowToolResult,
} from "./runtime.ts";
export { WorkflowRuntime, WorkflowSchemaError, workflowToolResult } from "./runtime.ts";
export { validateWorkflowSchema } from "./schema.ts";
export {
createStepWorkflowExtension,
Expand All @@ -53,5 +48,4 @@ export {
WORKFLOW_TOOL_PROFILES,
} from "./tool-profile.ts";
export type * from "./types.ts";
export { isIsolatedVmAvailable, loadIsolatedVm, runInIsolatedVm, WORKFLOW_MAX_SCRIPT_BYTES } from "./vm.ts";
export { isQuickJsVmAvailable, runInQuickJs } from "./vm-quickjs.ts";
export { runInQuickJs, WORKFLOW_MAX_SCRIPT_BYTES } from "./vm.ts";
52 changes: 15 additions & 37 deletions packages/coding-agent/src/features/workflow/registration-gate.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,36 @@
import { isIsolatedVmAvailable, isIsolatedVmHostable } from "./vm.ts";

function envFlag(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "on";
}

export type WorkflowRegistrationDecision =
| { enabled: true }
| { enabled: false; reason: "not-enabled" | "disabled-by-env" | "vm-unavailable" };

/**
* Startup warning for the one refusal that contradicts the default-on
* registration: every other reason honors an explicit "off" or a runtime that
* has a working executor anyway.
*/
export const WORKFLOW_VM_UNAVAILABLE_WARNING =
"Workflow tools are unavailable this session: the isolated-vm native module failed to load. Rebuild or reinstall isolated-vm to restore the workflow tool, /workflows, and /ultraloop, or set STEP_DISABLE_WORKFLOW=1 to silence this warning.";
| { enabled: false; reason: "not-enabled" | "disabled-by-env" };

/**
* Shared registration gate for the workflow tool and any extension layered on
* top of it (e.g. ultraloop-opt-in). Consumers should call this instead of
* duplicating the predicate so the two gates cannot drift. Kept in its own
* leaf module so callers do not pull the full workflow runtime chain just to
* check the gate. Returns the refusal reason so the workflow extension can
* warn when the default-on registration degrades, instead of silently
* registering nothing.
* duplicating the predicate so the two gates cannot drift. Kept in its own leaf
* module so callers do not pull the full workflow runtime chain just to check
* the gate.
*
* Registration is on by default, matching Claude Code: a registered tool is an
* environment capability, and USAGE consent stays gated per turn/session by
* the ultraloop opt-in. An embedder's `enabled: false` or
* STEP_DISABLE_WORKFLOW=1 turns registration off; STEP_ENABLE_WORKFLOW is no
* longer read.
* Registration is on by default and now depends only on explicit opt-outs. The
* sandbox is QuickJS compiled to WebAssembly (see `vm.ts`); it ships with the
* package and runs on every supported runtime, so no environment can take
* workflows away any more. The V8-native `isolated-vm` used to — silently, on
* every released executable, whose JavaScriptCore engine can never load it.
*
* A runtime that cannot host isolated-vm is not a refusal. The V8-native addon
* never loads on the shipped executable's JavaScriptCore engine, so that host
* runs workflows through the bundled QuickJS WebAssembly executor instead (see
* `vm-quickjs.ts`) and registers normally. Only a V8 host whose addon failed to
* load is a real, fixable gap — that one warns.
* A registered tool is only an environment capability; USAGE consent stays gated
* per turn/session by the ultraloop opt-in. An embedder's `enabled: false` or
* STEP_DISABLE_WORKFLOW=1 turns registration off; STEP_ENABLE_WORKFLOW is not
* read.
*/
export function resolveWorkflowRegistration(
options: { enabled?: boolean; vmExecutor?: unknown } = {},
vmAvailable: boolean = isIsolatedVmAvailable(),
vmHostable: boolean = isIsolatedVmHostable(),
): WorkflowRegistrationDecision {
export function resolveWorkflowRegistration(options: { enabled?: boolean } = {}): WorkflowRegistrationDecision {
if (envFlag(process.env.STEP_DISABLE_WORKFLOW)) return { enabled: false, reason: "disabled-by-env" };
if (options.enabled === false) return { enabled: false, reason: "not-enabled" };
if (!vmAvailable && !options.vmExecutor) {
// Non-V8 host: QuickJS stands in for isolated-vm, so workflows are available.
if (!vmHostable) return { enabled: true };
return { enabled: false, reason: "vm-unavailable" };
}
return { enabled: true };
}

export function isWorkflowRegistrationEnabled(options: { enabled?: boolean; vmExecutor?: unknown } = {}): boolean {
export function isWorkflowRegistrationEnabled(options: { enabled?: boolean } = {}): boolean {
return resolveWorkflowRegistration(options).enabled;
}
22 changes: 2 additions & 20 deletions packages/coding-agent/src/features/workflow/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,32 +49,14 @@ import type {
WorkflowUsage,
} from "./types.ts";
import { emptyWorkflowUsage, mergeWorkflowUsage, workflowUsageTokens } from "./types.ts";
import {
isIsolatedVmAvailable,
runInIsolatedVm,
type WorkflowVmHost,
type WorkflowVmOptions,
type WorkflowVmResult,
} from "./vm.ts";
import { runInQuickJs } from "./vm-quickjs.ts";
import { runInQuickJs, type WorkflowVmHost, type WorkflowVmOptions, type WorkflowVmResult } from "./vm.ts";

const DEFAULT_MAX_ITERATIONS = 20;
const MAX_MAX_ITERATIONS = 100;
const DEFAULT_STAGNATION_LIMIT = 3;
const DEFAULT_AGENT_TIMEOUT_MS = 30 * 60 * 1_000;
const MAX_AGENT_TIMEOUT_MS = 60 * 60 * 1_000;

/**
* Pick the sandbox for this host. isolated-vm is a V8-native addon, so it is
* absent on the shipped executable's JavaScriptCore engine; QuickJS compiled to
* WebAssembly runs anywhere and stands in there. A V8 host with the addon
* installed keeps using it, so nothing changes for a source/Node run. An
* explicit `vmExecutor` still wins over both.
*/
export function defaultWorkflowVmExecutor(): NonNullable<WorkflowRuntimeOptions["vmExecutor"]> {
return isIsolatedVmAvailable() ? runInIsolatedVm : runInQuickJs;
}

export interface WorkflowRuntimeOptions {
cwd: string;
runId: string;
Expand Down Expand Up @@ -159,7 +141,7 @@ export class WorkflowRuntime {
this.signal = options.signal;
this.nestedWorkflow = options.nestedWorkflow;
this.now = options.now ?? Date.now;
this.vmExecutor = options.vmExecutor ?? defaultWorkflowVmExecutor();
this.vmExecutor = options.vmExecutor ?? runInQuickJs;
const startedAt = this.readNow();
this.startedAt = startedAt;
const initial: WorkflowProgress = {
Expand Down
19 changes: 4 additions & 15 deletions packages/coding-agent/src/features/workflow/step-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type { StepTelemetryReporter } from "../../step/telemetry.ts";
import { createDefaultWorkflowAgentRunner } from "./agent-runner.ts";
import { createWorkflowRunPaths, newWorkflowRunId, resolveWorkflowRoot, WorkflowJournal } from "./journal.ts";
import { formatWorkflowStatus, listSavedWorkflows, listWorkflowRuns } from "./progress.ts";
import { resolveWorkflowRegistration, WORKFLOW_VM_UNAVAILABLE_WARNING } from "./registration-gate.ts";
import { resolveWorkflowRegistration } from "./registration-gate.ts";
import {
renderWorkflowCall,
renderWorkflowResult,
Expand All @@ -23,7 +23,7 @@ import { WorkflowRuntime, workflowToolResult } from "./runtime.ts";
import { isWorkflowPathInside } from "./tool-profile.ts";
import type { WorkflowAgentRunner, WorkflowProgress, WorkflowRunResult } from "./types.ts";
import type { UltraloopTurnState } from "./ultraloop-opt-in.ts";
import { type runInIsolatedVm, WORKFLOW_MAX_SCRIPT_BYTES } from "./vm.ts";
import { type runInQuickJs, WORKFLOW_MAX_SCRIPT_BYTES } from "./vm.ts";

export const WorkflowParams = Type.Object({
script: Type.Optional(Type.String({ description: "Inline JavaScript workflow script" })),
Expand Down Expand Up @@ -51,7 +51,7 @@ export interface StepWorkflowExtensionOptions {
telemetry?: StepTelemetryReporter;
enabled?: boolean;
runner?: WorkflowAgentRunner;
vmExecutor?: typeof runInIsolatedVm;
vmExecutor?: typeof runInQuickJs;
maxConcurrency?: number;
maxAgents?: number;
agentTimeoutMs?: number;
Expand Down Expand Up @@ -131,18 +131,7 @@ async function exists(filePath: string): Promise<boolean> {
/** Register the feature-gated Workflow tool and /workflows status command. */
export function createStepWorkflowExtension(options: StepWorkflowExtensionOptions = {}): ExtensionFactory {
return (pi: ExtensionAPI): void => {
const registration = resolveWorkflowRegistration(options);
if (!registration.enabled) {
if (registration.reason === "vm-unavailable") {
let warned = false;
pi.on("session_start", (_event, ctx) => {
if (warned) return;
warned = true;
ctx.ui.notify(WORKFLOW_VM_UNAVAILABLE_WARNING, "warning");
});
}
return;
}
if (!resolveWorkflowRegistration(options).enabled) return;
const activeRuns = new Set<AbortController>();
const runner = options.runner ?? createDefaultWorkflowAgentRunner();
const homeRoot = options.homeRoot ?? resolveStepStorageRoot();
Expand Down
Loading
Loading