Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
48 changes: 48 additions & 0 deletions packages/core/src/agents/runtime/workflow-orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,54 @@ describe('WorkflowOrchestrator', () => {
expect(outcome.result).toBe('world');
});

// P4: outcome.meta surfaces the extracted `export const meta = {...}`
// declaration. Null when the script omits it.
it('outcome.meta is null when the script has no meta declaration', async () => {
const orchestrator = new WorkflowOrchestrator(async () => 'unused');
const outcome = await orchestrator.run({
script: `return 1`,
args: undefined,
});
expect(outcome.meta).toBeNull();
});

it('outcome.meta is the parsed meta when the script declares one', async () => {
const orchestrator = new WorkflowOrchestrator(async () => 'unused');
const outcome = await orchestrator.run({
script: `export const meta = { name: 'demo', description: 'demo workflow', phases: [{ title: 'plan' }] }
return 1`,
args: undefined,
});
expect(outcome.meta).toEqual({
name: 'demo',
description: 'demo workflow',
phases: [{ title: 'plan' }],
});
expect(outcome.result).toBe(1);
});

// P4: a script body that throws still surfaces the meta on the wrapped
// WorkflowExecutionError so the user-facing display can identify which
// workflow ran before the body failed.
it('WorkflowExecutionError carries meta when the body throws AFTER meta parsed', async () => {
const orchestrator = new WorkflowOrchestrator(async () => 'unused');
let caught: unknown;
try {
await orchestrator.run({
script: `export const meta = { name: 'fails', description: 'will throw' }
throw new Error("body boom")`,
args: undefined,
});
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(WorkflowExecutionError);
expect((caught as WorkflowExecutionError).meta).toEqual({
name: 'fails',
description: 'will throw',
});
});

it('surfaces a thrown error from the script', async () => {
const orchestrator = new WorkflowOrchestrator(async () => 'unused');
await expect(
Expand Down
20 changes: 19 additions & 1 deletion packages/core/src/agents/runtime/workflow-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createWorkflowSandbox, debugLogger } from './workflow-sandbox.js';
import type {
WorkflowAgentOpts,
WorkflowAgentResult,
WorkflowMeta,
} from './workflow-sandbox.js';
import {
WORKFLOW_SUBAGENT_SYSTEM_PROMPT,
Expand Down Expand Up @@ -164,6 +165,13 @@ export class WorkflowExecutionError extends Error {
message: string,
readonly phases: string[],
readonly logs: string[],
/**
* The extracted meta if it was parsed before the script body threw —
* null otherwise (no declaration in the source, or malformed meta
Comment thread
LaZzyMan marked this conversation as resolved.
Resolved
* which itself was the failure). Surfaced so the tool's failure
* display can still show the workflow's name / description / phases.
*/
readonly meta: WorkflowMeta | null = null,
) {
super(message);
}
Expand All @@ -172,7 +180,7 @@ export class WorkflowExecutionError extends Error {
// FIX-E (Round 4 ARCH-I1): single source of truth for the dispatch return
// type is `workflow-sandbox.ts`. Re-exported here so external consumers
// (WorkflowTool) can import the alias from the orchestrator module.
export type { WorkflowAgentResult };
export type { WorkflowAgentResult, WorkflowMeta };

export interface WorkflowRunRequest {
script: string;
Expand All @@ -199,6 +207,14 @@ export interface WorkflowRunOutcome {
result: unknown;
phases: string[];
logs: string[];
/**
* The script's `export const meta = {...}` declaration (P4). `null` when
* the script omits the declaration. Surfaced verbatim from the sandbox's
* `getMeta()` so callers (`/workflows` listing, phase-tree UI) can read
* the workflow's name / description / phases / whenToUse without
* re-parsing the script source.
*/
meta: WorkflowMeta | null;
}

export type WorkflowAgentDispatch = (
Expand Down Expand Up @@ -1091,6 +1107,7 @@ export class WorkflowOrchestrator {
result,
phases: sandbox.getPhases(),
logs: sandbox.getLogs(),
meta: sandbox.getMeta(),
};
} catch (err) {
// T19 (PR #4732 R1): preserve phases and logs accumulated before the
Expand All @@ -1105,6 +1122,7 @@ export class WorkflowOrchestrator {
extractErrorMessage(err),
sandbox.getPhases(),
sandbox.getLogs(),
sandbox.getMeta(),
);
}
}
Expand Down
164 changes: 163 additions & 1 deletion packages/core/src/agents/runtime/workflow-sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
*/

import { describe, it, expect } from 'vitest';
import { stripExportMeta, createWorkflowSandbox } from './workflow-sandbox.js';
import {
stripExportMeta,
extractAndStripMeta,
createWorkflowSandbox,
} from './workflow-sandbox.js';

describe('stripExportMeta', () => {
it('returns input unchanged when no export meta present', () => {
Expand Down Expand Up @@ -136,6 +140,125 @@ return x;`;
});
});

describe('extractAndStripMeta', () => {
// P4: extracts the `export const meta = {...}` declaration into a typed
// object AND strips it from the script source (delegates to the same
// brace-walker stripExportMeta uses). `meta: null` when the script has no
// declaration; throws when the declaration is present but malformed.
it('returns meta: null and unchanged source when no meta declaration', () => {
const src = `phase("plan")\nreturn 1`;
const { stripped, meta } = extractAndStripMeta(src);
expect(stripped).toBe(src);
expect(meta).toBeNull();
});

it('extracts the required name + description fields', () => {
const src = `export const meta = { name: 'demo', description: 'a demo workflow' }\nreturn 1`;
const { stripped, meta } = extractAndStripMeta(src);
expect(stripped.trim()).toBe('return 1');
expect(meta).toEqual({ name: 'demo', description: 'a demo workflow' });
});

it('extracts optional whenToUse + phases array', () => {
const src = `export const meta = {
name: 'multi',
description: 'multi-phase',
whenToUse: 'when the user needs a multi-phase report',
phases: [
{ title: 'collect' },
{ title: 'analyse', detail: 'aggregate findings', model: 'qwen3-coder-plus' },
],
}
return 1;`;
const { meta } = extractAndStripMeta(src);
expect(meta).toEqual({
name: 'multi',
description: 'multi-phase',
whenToUse: 'when the user needs a multi-phase report',
phases: [
{ title: 'collect' },
{ title: 'analyse', detail: 'aggregate findings', model: 'qwen3-coder-plus' },
],
});
});

it('throws upstream-verbatim error when name is missing', () => {
const src = `export const meta = { description: 'no name' }\nreturn 1`;
expect(() => extractAndStripMeta(src)).toThrow(
/^meta\.name must be a non-empty string$/,
);
});

it('throws upstream-verbatim error when description is missing', () => {
const src = `export const meta = { name: 'x' }\nreturn 1`;
expect(() => extractAndStripMeta(src)).toThrow(
/^meta\.description must be a non-empty string$/,
);
});

it('throws when name is empty string', () => {
const src = `export const meta = { name: '', description: 'd' }\nreturn 1`;
expect(() => extractAndStripMeta(src)).toThrow(
/^meta\.name must be a non-empty string$/,
);
});

it('throws when phases is not an array', () => {
const src = `export const meta = { name: 'n', description: 'd', phases: 'oops' }\nreturn 1`;
expect(() => extractAndStripMeta(src)).toThrow(/phases must be an array/);
});

it('throws when a phase is missing its title', () => {
Comment thread
LaZzyMan marked this conversation as resolved.
Resolved
const src = `export const meta = { name: 'n', description: 'd', phases: [{ detail: 'no title here' }] }\nreturn 1`;
expect(() => extractAndStripMeta(src)).toThrow(
/phases\[\]\.title must be a non-empty string/,
);
});

// Security regression: the meta-eval vm context has no globals at all
// (Object.create(null) prototype), so the model cannot reach host
// primitives during meta evaluation — even ones that the script-side
// sandbox normally provides (args, agent, phase, log, parallel,
// pipeline). Referencing any of them throws ReferenceError.
it('rejects meta that references a name that does not exist in the eval context', () => {
const src = `export const meta = { name: args.x, description: 'd' }\nreturn 1`;
expect(() => extractAndStripMeta(src)).toThrow(
/failed to evaluate meta object literal/,
);
});
Comment thread
LaZzyMan marked this conversation as resolved.
Resolved

// Security regression: the meta-eval context's globalThis is null-
// prototyped, so the model has no bridge to host primitives like
// `process`, `require`, or the workflow-sandbox bridge globals
// (`args` / `agent` / `phase` / `log` / etc.). The vm realm still
// exposes its OWN intrinsics (`Object`, `Math`, `Date`, …) which is
// fine — meta extraction is one-shot at tool-invocation time, not
// replayed on resume, so it can be non-deterministic without breaking
// the resume contract that the script body honors.
it('meta source cannot reference a workflow-sandbox bridge global (args)', () => {
const src = `export const meta = { name: args.x, description: 'd' }\nreturn 1`;
expect(() => extractAndStripMeta(src)).toThrow(
/failed to evaluate meta object literal/,
);
});

it('meta source cannot reach the host process / require / fs', () => {
const src1 = `export const meta = { name: process.version, description: 'd' }\nreturn 1`;
expect(() => extractAndStripMeta(src1)).toThrow(
/failed to evaluate meta object literal/,
);
const src2 = `export const meta = { name: 'x', description: require('fs').readFileSync('/etc/passwd', 'utf8') }\nreturn 1`;
expect(() => extractAndStripMeta(src2)).toThrow(
/failed to evaluate meta object literal/,
);
});

it('unbalanced braces still throw the stripExportMeta error', () => {
const src = `export const meta = { name: 'x'`;
expect(() => extractAndStripMeta(src)).toThrow(/unbalanced/i);
});
});

describe('createWorkflowSandbox', () => {
it('exposes args verbatim', async () => {
const sandbox = createWorkflowSandbox({
Expand Down Expand Up @@ -175,6 +298,45 @@ describe('createWorkflowSandbox', () => {
const result = await sandbox.run(`return 1 + 2`);
expect(result).toBe(3);
});

// P4: meta declaration in the script is extracted before the body runs
// and exposed via getMeta(). The script body sees the stripped source.
it('getMeta() returns null when no export const meta declaration', async () => {
const sandbox = createWorkflowSandbox({
args: undefined,
dispatch: async () => 'ignored',
});
await sandbox.run(`return 42`);
expect(sandbox.getMeta()).toBeNull();
});

it('getMeta() returns the parsed meta when present', async () => {
const sandbox = createWorkflowSandbox({
args: undefined,
dispatch: async () => 'ignored',
});
const result = await sandbox.run(
`export const meta = { name: 'unit', description: 'unit-test workflow', phases: [{ title: 'one' }] }\nreturn 'done'`,
);
expect(result).toBe('done');
expect(sandbox.getMeta()).toEqual({
name: 'unit',
description: 'unit-test workflow',
phases: [{ title: 'one' }],
});
});

it('getMeta() failure on malformed meta propagates as the run rejection', async () => {
const sandbox = createWorkflowSandbox({
args: undefined,
dispatch: async () => 'ignored',
});
await expect(
sandbox.run(
`export const meta = { name: 'x' }\nreturn 1`,
),
).rejects.toThrow(/^meta\.description must be a non-empty string$/);
});
});

// Security PoC tests — verify that every known realm-escape vector returns
Expand Down
Loading
Loading