Skip to content

Commit 5b56c39

Browse files
committed
feat(core): Workflow P4a — extractAndStripMeta + meta on RunOutcome (#4721)
First half of P4 (per the refined #4721 plan). Extracts the script's `export const meta = {...}` declaration into a typed object so the workflow tool's display payload, and the future /workflows command + phase-tree UI, can read it without re-parsing the script source. The other half of P4 (slash command + KIND_NAMES extension + phase-tree UI + WorkflowTaskRegistry) is queued as a follow-up PR. Architecture: reuse the P1 brace-walker (zero-dep, no parser deps) to locate the meta object literal's source range, then evaluate the literal inside a fresh `vm.createContext(Object.create(null))` — null-prototyped globalThis, no host bridge (no `args` / `process` / `require` / workflow- sandbox globals). The vm realm still exposes its OWN intrinsics (`Object` / `Math` / `Date` / `JSON`), which is fine: meta extraction is one-shot at tool invocation, not replayed on resume. validateMeta walks the eval result field-by-field and copies into a fresh host-realm plain object — no JSON round-trip needed because every contract field is a primitive. User-visible additions: - `extractAndStripMeta(source)` exported from workflow-sandbox.ts - `WorkflowMeta` interface (`{ name, description, whenToUse?, phases?: Array<{title, detail?, model?}> }`) — verbatim shape from upstream Claude Code 2.1.168 - `WorkflowSandbox.getMeta()` accessor alongside `getPhases()` / `getLogs()` - `WorkflowRunOutcome.meta: WorkflowMeta | null` (non-breaking add) - `WorkflowExecutionError.meta: WorkflowMeta | null` so the failure display shows the workflow's name / description / phases even when the script body throws - `WorkflowTool.execute` adds `meta` to the returnDisplay payload when present (omitted when the script had no meta) Error messages verbatim from upstream where applicable: - `meta.name must be a non-empty string` - `meta.description must be a non-empty string` Refactor: P1's `stripExportMeta` is preserved as a thin wrapper around a new `findMetaBlockBounds` helper that both old and new functions share. All 86 existing sandbox tests pass unchanged (no behavior regression in the strip path). Tests: - 11 new `extractAndStripMeta` unit tests covering happy path, optional fields, missing-required validation, malformed shape, vm-eval failure, null-prototype globalThis (no `args` / `process` / `require`), and unbalanced braces - 3 new `createWorkflowSandbox.getMeta()` integration tests - 3 new `WorkflowOrchestrator` outcome.meta tests (null path, parsed path, meta-survives-body-throw on the error path) - 3 new `WorkflowTool` display payload tests (meta in payload, omitted when absent, present on failure path) Suite: 207/207 workflow + adjacent regression green; typecheck + lint clean on packages/core. (Pre-existing acp test type errors in packages/cli are unrelated; CI will confirm.) Related #4721 (parent design — multi-phase, not closed by this PR) Related #4732 (P1) #4947 (P2) #5034 (P3) — all merged P4b follow-up: /workflows command + TaskKind workflow union + BackgroundTasksPill KIND_NAMES + phase-tree UI + WorkflowTaskRegistry
1 parent e834271 commit 5b56c39

6 files changed

Lines changed: 502 additions & 6 deletions

File tree

packages/core/src/agents/runtime/workflow-orchestrator.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,54 @@ describe('WorkflowOrchestrator', () => {
177177
expect(outcome.result).toBe('world');
178178
});
179179

180+
// P4: outcome.meta surfaces the extracted `export const meta = {...}`
181+
// declaration. Null when the script omits it.
182+
it('outcome.meta is null when the script has no meta declaration', async () => {
183+
const orchestrator = new WorkflowOrchestrator(async () => 'unused');
184+
const outcome = await orchestrator.run({
185+
script: `return 1`,
186+
args: undefined,
187+
});
188+
expect(outcome.meta).toBeNull();
189+
});
190+
191+
it('outcome.meta is the parsed meta when the script declares one', async () => {
192+
const orchestrator = new WorkflowOrchestrator(async () => 'unused');
193+
const outcome = await orchestrator.run({
194+
script: `export const meta = { name: 'demo', description: 'demo workflow', phases: [{ title: 'plan' }] }
195+
return 1`,
196+
args: undefined,
197+
});
198+
expect(outcome.meta).toEqual({
199+
name: 'demo',
200+
description: 'demo workflow',
201+
phases: [{ title: 'plan' }],
202+
});
203+
expect(outcome.result).toBe(1);
204+
});
205+
206+
// P4: a script body that throws still surfaces the meta on the wrapped
207+
// WorkflowExecutionError so the user-facing display can identify which
208+
// workflow ran before the body failed.
209+
it('WorkflowExecutionError carries meta when the body throws AFTER meta parsed', async () => {
210+
const orchestrator = new WorkflowOrchestrator(async () => 'unused');
211+
let caught: unknown;
212+
try {
213+
await orchestrator.run({
214+
script: `export const meta = { name: 'fails', description: 'will throw' }
215+
throw new Error("body boom")`,
216+
args: undefined,
217+
});
218+
} catch (e) {
219+
caught = e;
220+
}
221+
expect(caught).toBeInstanceOf(WorkflowExecutionError);
222+
expect((caught as WorkflowExecutionError).meta).toEqual({
223+
name: 'fails',
224+
description: 'will throw',
225+
});
226+
});
227+
180228
it('surfaces a thrown error from the script', async () => {
181229
const orchestrator = new WorkflowOrchestrator(async () => 'unused');
182230
await expect(

packages/core/src/agents/runtime/workflow-orchestrator.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { createWorkflowSandbox, debugLogger } from './workflow-sandbox.js';
1111
import type {
1212
WorkflowAgentOpts,
1313
WorkflowAgentResult,
14+
WorkflowMeta,
1415
} from './workflow-sandbox.js';
1516
import {
1617
WORKFLOW_SUBAGENT_SYSTEM_PROMPT,
@@ -164,6 +165,13 @@ export class WorkflowExecutionError extends Error {
164165
message: string,
165166
readonly phases: string[],
166167
readonly logs: string[],
168+
/**
169+
* The extracted meta if it was parsed before the script body threw —
170+
* null otherwise (no declaration in the source, or malformed meta
171+
* which itself was the failure). Surfaced so the tool's failure
172+
* display can still show the workflow's name / description / phases.
173+
*/
174+
readonly meta: WorkflowMeta | null = null,
167175
) {
168176
super(message);
169177
}
@@ -172,7 +180,7 @@ export class WorkflowExecutionError extends Error {
172180
// FIX-E (Round 4 ARCH-I1): single source of truth for the dispatch return
173181
// type is `workflow-sandbox.ts`. Re-exported here so external consumers
174182
// (WorkflowTool) can import the alias from the orchestrator module.
175-
export type { WorkflowAgentResult };
183+
export type { WorkflowAgentResult, WorkflowMeta };
176184

177185
export interface WorkflowRunRequest {
178186
script: string;
@@ -199,6 +207,14 @@ export interface WorkflowRunOutcome {
199207
result: unknown;
200208
phases: string[];
201209
logs: string[];
210+
/**
211+
* The script's `export const meta = {...}` declaration (P4). `null` when
212+
* the script omits the declaration. Surfaced verbatim from the sandbox's
213+
* `getMeta()` so callers (`/workflows` listing, phase-tree UI) can read
214+
* the workflow's name / description / phases / whenToUse without
215+
* re-parsing the script source.
216+
*/
217+
meta: WorkflowMeta | null;
202218
}
203219

204220
export type WorkflowAgentDispatch = (
@@ -1091,6 +1107,7 @@ export class WorkflowOrchestrator {
10911107
result,
10921108
phases: sandbox.getPhases(),
10931109
logs: sandbox.getLogs(),
1110+
meta: sandbox.getMeta(),
10941111
};
10951112
} catch (err) {
10961113
// T19 (PR #4732 R1): preserve phases and logs accumulated before the
@@ -1105,6 +1122,7 @@ export class WorkflowOrchestrator {
11051122
extractErrorMessage(err),
11061123
sandbox.getPhases(),
11071124
sandbox.getLogs(),
1125+
sandbox.getMeta(),
11081126
);
11091127
}
11101128
}

packages/core/src/agents/runtime/workflow-sandbox.test.ts

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
*/
66

77
import { describe, it, expect } from 'vitest';
8-
import { stripExportMeta, createWorkflowSandbox } from './workflow-sandbox.js';
8+
import {
9+
stripExportMeta,
10+
extractAndStripMeta,
11+
createWorkflowSandbox,
12+
} from './workflow-sandbox.js';
913

1014
describe('stripExportMeta', () => {
1115
it('returns input unchanged when no export meta present', () => {
@@ -136,6 +140,125 @@ return x;`;
136140
});
137141
});
138142

143+
describe('extractAndStripMeta', () => {
144+
// P4: extracts the `export const meta = {...}` declaration into a typed
145+
// object AND strips it from the script source (delegates to the same
146+
// brace-walker stripExportMeta uses). `meta: null` when the script has no
147+
// declaration; throws when the declaration is present but malformed.
148+
it('returns meta: null and unchanged source when no meta declaration', () => {
149+
const src = `phase("plan")\nreturn 1`;
150+
const { stripped, meta } = extractAndStripMeta(src);
151+
expect(stripped).toBe(src);
152+
expect(meta).toBeNull();
153+
});
154+
155+
it('extracts the required name + description fields', () => {
156+
const src = `export const meta = { name: 'demo', description: 'a demo workflow' }\nreturn 1`;
157+
const { stripped, meta } = extractAndStripMeta(src);
158+
expect(stripped.trim()).toBe('return 1');
159+
expect(meta).toEqual({ name: 'demo', description: 'a demo workflow' });
160+
});
161+
162+
it('extracts optional whenToUse + phases array', () => {
163+
const src = `export const meta = {
164+
name: 'multi',
165+
description: 'multi-phase',
166+
whenToUse: 'when the user needs a multi-phase report',
167+
phases: [
168+
{ title: 'collect' },
169+
{ title: 'analyse', detail: 'aggregate findings', model: 'qwen3-coder-plus' },
170+
],
171+
}
172+
return 1;`;
173+
const { meta } = extractAndStripMeta(src);
174+
expect(meta).toEqual({
175+
name: 'multi',
176+
description: 'multi-phase',
177+
whenToUse: 'when the user needs a multi-phase report',
178+
phases: [
179+
{ title: 'collect' },
180+
{ title: 'analyse', detail: 'aggregate findings', model: 'qwen3-coder-plus' },
181+
],
182+
});
183+
});
184+
185+
it('throws upstream-verbatim error when name is missing', () => {
186+
const src = `export const meta = { description: 'no name' }\nreturn 1`;
187+
expect(() => extractAndStripMeta(src)).toThrow(
188+
/^meta\.name must be a non-empty string$/,
189+
);
190+
});
191+
192+
it('throws upstream-verbatim error when description is missing', () => {
193+
const src = `export const meta = { name: 'x' }\nreturn 1`;
194+
expect(() => extractAndStripMeta(src)).toThrow(
195+
/^meta\.description must be a non-empty string$/,
196+
);
197+
});
198+
199+
it('throws when name is empty string', () => {
200+
const src = `export const meta = { name: '', description: 'd' }\nreturn 1`;
201+
expect(() => extractAndStripMeta(src)).toThrow(
202+
/^meta\.name must be a non-empty string$/,
203+
);
204+
});
205+
206+
it('throws when phases is not an array', () => {
207+
const src = `export const meta = { name: 'n', description: 'd', phases: 'oops' }\nreturn 1`;
208+
expect(() => extractAndStripMeta(src)).toThrow(/phases must be an array/);
209+
});
210+
211+
it('throws when a phase is missing its title', () => {
212+
const src = `export const meta = { name: 'n', description: 'd', phases: [{ detail: 'no title here' }] }\nreturn 1`;
213+
expect(() => extractAndStripMeta(src)).toThrow(
214+
/phases\[\]\.title must be a non-empty string/,
215+
);
216+
});
217+
218+
// Security regression: the meta-eval vm context has no globals at all
219+
// (Object.create(null) prototype), so the model cannot reach host
220+
// primitives during meta evaluation — even ones that the script-side
221+
// sandbox normally provides (args, agent, phase, log, parallel,
222+
// pipeline). Referencing any of them throws ReferenceError.
223+
it('rejects meta that references a name that does not exist in the eval context', () => {
224+
const src = `export const meta = { name: args.x, description: 'd' }\nreturn 1`;
225+
expect(() => extractAndStripMeta(src)).toThrow(
226+
/failed to evaluate meta object literal/,
227+
);
228+
});
229+
230+
// Security regression: the meta-eval context's globalThis is null-
231+
// prototyped, so the model has no bridge to host primitives like
232+
// `process`, `require`, or the workflow-sandbox bridge globals
233+
// (`args` / `agent` / `phase` / `log` / etc.). The vm realm still
234+
// exposes its OWN intrinsics (`Object`, `Math`, `Date`, …) which is
235+
// fine — meta extraction is one-shot at tool-invocation time, not
236+
// replayed on resume, so it can be non-deterministic without breaking
237+
// the resume contract that the script body honors.
238+
it('meta source cannot reference a workflow-sandbox bridge global (args)', () => {
239+
const src = `export const meta = { name: args.x, description: 'd' }\nreturn 1`;
240+
expect(() => extractAndStripMeta(src)).toThrow(
241+
/failed to evaluate meta object literal/,
242+
);
243+
});
244+
245+
it('meta source cannot reach the host process / require / fs', () => {
246+
const src1 = `export const meta = { name: process.version, description: 'd' }\nreturn 1`;
247+
expect(() => extractAndStripMeta(src1)).toThrow(
248+
/failed to evaluate meta object literal/,
249+
);
250+
const src2 = `export const meta = { name: 'x', description: require('fs').readFileSync('/etc/passwd', 'utf8') }\nreturn 1`;
251+
expect(() => extractAndStripMeta(src2)).toThrow(
252+
/failed to evaluate meta object literal/,
253+
);
254+
});
255+
256+
it('unbalanced braces still throw the stripExportMeta error', () => {
257+
const src = `export const meta = { name: 'x'`;
258+
expect(() => extractAndStripMeta(src)).toThrow(/unbalanced/i);
259+
});
260+
});
261+
139262
describe('createWorkflowSandbox', () => {
140263
it('exposes args verbatim', async () => {
141264
const sandbox = createWorkflowSandbox({
@@ -175,6 +298,45 @@ describe('createWorkflowSandbox', () => {
175298
const result = await sandbox.run(`return 1 + 2`);
176299
expect(result).toBe(3);
177300
});
301+
302+
// P4: meta declaration in the script is extracted before the body runs
303+
// and exposed via getMeta(). The script body sees the stripped source.
304+
it('getMeta() returns null when no export const meta declaration', async () => {
305+
const sandbox = createWorkflowSandbox({
306+
args: undefined,
307+
dispatch: async () => 'ignored',
308+
});
309+
await sandbox.run(`return 42`);
310+
expect(sandbox.getMeta()).toBeNull();
311+
});
312+
313+
it('getMeta() returns the parsed meta when present', async () => {
314+
const sandbox = createWorkflowSandbox({
315+
args: undefined,
316+
dispatch: async () => 'ignored',
317+
});
318+
const result = await sandbox.run(
319+
`export const meta = { name: 'unit', description: 'unit-test workflow', phases: [{ title: 'one' }] }\nreturn 'done'`,
320+
);
321+
expect(result).toBe('done');
322+
expect(sandbox.getMeta()).toEqual({
323+
name: 'unit',
324+
description: 'unit-test workflow',
325+
phases: [{ title: 'one' }],
326+
});
327+
});
328+
329+
it('getMeta() failure on malformed meta propagates as the run rejection', async () => {
330+
const sandbox = createWorkflowSandbox({
331+
args: undefined,
332+
dispatch: async () => 'ignored',
333+
});
334+
await expect(
335+
sandbox.run(
336+
`export const meta = { name: 'x' }\nreturn 1`,
337+
),
338+
).rejects.toThrow(/^meta\.description must be a non-empty string$/);
339+
});
178340
});
179341

180342
// Security PoC tests — verify that every known realm-escape vector returns

0 commit comments

Comments
 (0)