diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c68915ed..8a6646c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '22.14.0' + node-version: '26.5.0' cache: 'pnpm' - name: Install deps diff --git a/.github/workflows/deploy-e2e.yml b/.github/workflows/deploy-e2e.yml index 414fbf74..40ef1ad0 100644 --- a/.github/workflows/deploy-e2e.yml +++ b/.github/workflows/deploy-e2e.yml @@ -36,7 +36,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '22.14.0' + node-version: '26.5.0' cache: 'pnpm' - name: Install deps diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5c790e78..9a1010d2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -66,7 +66,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v6 with: - node-version: '22.14.0' + node-version: '26.5.0' registry-url: 'https://registry.npmjs.org' cache: 'pnpm' diff --git a/.github/workflows/verify-publish.yml b/.github/workflows/verify-publish.yml index f01e282e..f77ee676 100644 --- a/.github/workflows/verify-publish.yml +++ b/.github/workflows/verify-publish.yml @@ -40,7 +40,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '22.14.0' + node-version: '26.5.0' registry-url: 'https://registry.npmjs.org' - name: Resolve version diff --git a/.workflow-artifacts/composable-runtime-closure/reviews/workforce-installed-layout-repair.md b/.workflow-artifacts/composable-runtime-closure/reviews/workforce-installed-layout-repair.md new file mode 100644 index 00000000..ed4c40d9 --- /dev/null +++ b/.workflow-artifacts/composable-runtime-closure/reviews/workforce-installed-layout-repair.md @@ -0,0 +1,35 @@ +# Workforce installed-layout local-preview repair + +- Date: 2026-07-16 +- Commit: `a27067422bdc6b4fa8cca9e27bb74fa9ce72d5d6` + +## Scope + +Repaired the local-preview worker root/permission strategy in `@agentworkforce/runtime` so installed-package execution stages under the invocation workspace instead of deriving a pseudo-workspace from `@agentworkforce/runtime/dist`. Added premature-worker stderr diagnostics with redaction, and added regression coverage that imports a copied runtime package from a temp consumer `node_modules` layout. + +## Files changed + +- `packages/runtime/src/local-preview.ts` +- `packages/runtime/src/local-preview.test.ts` + +## Verification + +Passed: + +1. `mise exec node@26.3.1 -- pnpm --filter @agentworkforce/runtime test` +2. `mise exec node@26.3.1 -- pnpm --dir packages/cli exec tsc -p tsconfig.json` +3. `mise exec node@26.3.1 -- node --test dist/invoke-command.test.js` (run from `packages/cli`) +4. `mise exec node@26.3.1 -- pnpm build` +5. Installed-package smoke: + - packed `@agentworkforce/runtime` + - installed the tarball into a temp consumer project + - ran `executeLocalRun()` from the installed package against a simple local-preview bundle + - observed `{"exitCode":0,"status":"succeeded"}` + +## Notes + +- The permission allowlist now stages under `process.cwd()/.workforce` and grants only: + - the staged bundle/stage roots, + - the runtime package root/runtime dist root, + - dependency roots implied by the runtime package layout (consumer/global `node_modules`, monorepo `packages` + root `node_modules` when applicable). +- Premature worker exits now include redacted child stderr so `ERR_ACCESS_DENIED` failures are actionable. diff --git a/.workflow-artifacts/composable-runtime-closure/workforce-implementation.md b/.workflow-artifacts/composable-runtime-closure/workforce-implementation.md new file mode 100644 index 00000000..1fc3090c --- /dev/null +++ b/.workflow-artifacts/composable-runtime-closure/workforce-implementation.md @@ -0,0 +1,39 @@ +Workforce composable runtime closure notes + +- Extended `agentworkforce invoke` to support exactly one of `--fixture`, `--schedule`, or `--case`, plus `--reads`, `--model`, and rerun-only `--watch`. +- Routed local invoke through `RunRequestV1` / `RunRecordV2` using `packages/runtime/src/local-preview.ts`. +- Enforced preview isolation before bundle import by: + - binding Relayfile preview transport before import and restoring it in `finally` + - sanitizing process env to strip ambient credentials + - installing a policy-controlled fetch bridge + - denying authored raw imports of `node:http`, `node:https`, `node:net`, `node:tls`, `node:dgram`, and `node:child_process` +- Added deterministic preview coverage proving: + - preview transport intercepts ambient Slack helper writes + - allowed live GET reads can pass while denied POST writes do not reach the sentinel server + - authored raw `node:https` import fails closed before handler execution +- Added versioned YAML case parsing for checked-in case shape, multi-turn state carry-forward, HN-oriented assertions, and HTTP fixtures. +- Added replay bundle export support to `runs export --bundle` and local replay ingestion that preserves replay mode, state-source fidelity labels, and source run provenance when present. +- Extended integrations JSON/detail output with `registrationHealth`. +- Added a parent-process watch rerun loop using local dependency tracking plus watched fixture/case/seed files. + +Verification + +- `pnpm --filter @agentworkforce/runtime test` +- `pnpm --filter @agentworkforce/deploy test` +- `pnpm --filter @agentworkforce/cli test` + +Follow-up after `ee57866` + +- Local preview now fails closed unless the parent runtime is a supported patched Node `>=26.3.1` with `--permission`, `--allow-fs-read`, and `--allow-net` support. The refusal message includes the detected Node version. +- Worker argv still omits `--allow-net`, so direct network access inside the worker remains denied even on the supported parent runtime. +- Added credential-shape redaction independent of input key names, applied before values enter worker env / invoke payloads and again on local preview logs and run records. +- Added bounded preview worker readiness / overall / fetch IPC timeouts, deterministic parent-fetch failure responses, AbortController-backed parent fetch cancellation, SIGTERM→SIGKILL teardown, and a timeout test that asserts no orphan `local-preview-worker-*` staging dirs remain. +- Replay ingestion now accepts Cloud's wrapped replay bundle shape (`{schemaVersion, manifest, files}`), unwraps `files["event.json"].content`, preserves run/event provenance from the manifest bundle, and keeps unavailable-vs-historical state fidelity instead of assuming a synthetic top-level event payload. +- Relevant CI / publish / verify / deploy-e2e workflows now pin Node `26.5.0` so invoke safety is exercised on the supported runtime rather than relying on a test-only local setup. + +Verification (Node 26.5) + +- `mise exec node@26.5.0 -- pnpm --filter @agentworkforce/runtime test` +- `mise exec node@26.5.0 -- pnpm --filter @agentworkforce/deploy test` +- `mise exec node@26.5.0 -- pnpm --filter @agentworkforce/cli test` +- `mise exec node@26.5.0 -- pnpm -r build` diff --git a/packages/cli/package.json b/packages/cli/package.json index a02c4fd2..4c892cfd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -12,13 +12,15 @@ "dependencies": { "@agent-relay/cloud": "^10.1.0", "@agentworkforce/deploy": "workspace:*", + "@agentworkforce/events": "workspace:*", "@agentworkforce/local-surface": "workspace:*", "@agentworkforce/persona-kit": "workspace:*", "@agentworkforce/runtime": "workspace:*", "@agentworkforce/workload-router": "workspace:*", "@relayburn/sdk": "^2.5.2", "@relayfile/local-mount": "^0.10.23", - "ora": "^9.4.0" + "ora": "^9.4.0", + "yaml": "^2.9.0" }, "repository": { "type": "git", diff --git a/packages/cli/src/cli-impl.ts b/packages/cli/src/cli-impl.ts index 9ecd7ae8..450cef82 100644 --- a/packages/cli/src/cli-impl.ts +++ b/packages/cli/src/cli-impl.ts @@ -265,7 +265,8 @@ Commands: 1-based configurable position. integrations [provider] [--all] [--json] Discover workspace integrations, connection status, and - known trigger events. + known trigger events. JSON output includes registration + health when the cloud status API provides it. trigger [flags] Manually fire an active deployed persona for testing. The selector accepts agent id, compact agent id, @@ -315,29 +316,32 @@ Commands: mirroring is not supported, so this targets cron/timer- only or webhook-shape-only personas. See \`agentworkforce local-surface --help\` for flags. - invoke --fixture [flags] - Simulate an invocation: run the persona's handler - against fixture event envelope(s) with every external - side effect recorded, NOT executed. Emits a - Cloud-compatible run record (origin "local_dry_run") - to stdout. Distinct from \`deploy --dry-run\`, which - validates without invoking the handler. + invoke (--fixture | --schedule | --case ) [flags] + Run a local preview invocation through the composable + runtime path. Effects are previewed locally and raw + output is a RunRecord JSON document. Flags: - --fixture JSON envelope, JSON array, or - NDJSON of raw gateway envelopes + --fixture JSON event/replay fixture, JSON + array, or NDJSON + --schedule invoke a declared schedule + --case run a YAML case with assertions + --reads fixtures | live + --model stub | fixture | live + --watch rerun the same selected source --output write the run record to a file --input KEY=value override a declared persona input - --seed PATH=file seed the simulated filesystem - --workspace workspace id for the simulated ctx + --seed PATH=file seed preview file state + --workspace workspace id for synthesized events --scaffold emit a fixture skeleton for an event type instead of running (no persona needed) runs export [flags] - Export the gateway envelope cloud delivered to a run - as a replayable invoke fixture. Flags: + Export the cloud replay fixture or replay bundle for a + run. Flags: --agent agentId/deployedName owning the run (otherwise all are checked) - --fixture write the fixture to a file + --fixture write the legacy fixture to a file + --bundle write the replay bundle to a file deployments list List deployed cloud agents in the active workspace. deployments logs Show structured logs for a deployed cloud agent. destroy [flags] diff --git a/packages/cli/src/integrations-command.test.ts b/packages/cli/src/integrations-command.test.ts index fa655be7..1b6bb6cd 100644 --- a/packages/cli/src/integrations-command.test.ts +++ b/packages/cli/src/integrations-command.test.ts @@ -16,12 +16,14 @@ const fixture: IntegrationsDocument = { adapterSlug: 'github', inCloudCatalog: true, connected: true, + registrationHealth: { workspace: { registered: true, healthy: true } }, connections: [ { connectionId: 'conn-github', scope: 'workspace', serviceAccountName: null, - status: 'ready' + status: 'ready', + registrationHealth: { registered: true, healthy: true } } ], triggers: ['issues.opened', 'pull_request.opened', 'pull_request.closed'], @@ -169,6 +171,7 @@ test('runIntegrationsCommand renders single-provider details and snippet', async assert.equal(process.exitCode, 0); assert.match(io.stdout.text, /Triggers:\n issues\.opened/); assert.match(io.stdout.text, /connectionId|conn-github/); + assert.match(io.stdout.text, /registrationHealth/); assert.match(io.stdout.text, /"integrations": \{ "github": \{\} \}/); assert.match(io.stdout.text, /triggers: \{ "github": \[\{ "on": "issues\.opened" \}\] \}/); } finally { diff --git a/packages/cli/src/integrations-command.ts b/packages/cli/src/integrations-command.ts index 05c8b840..8861f7e5 100644 --- a/packages/cli/src/integrations-command.ts +++ b/packages/cli/src/integrations-command.ts @@ -167,8 +167,14 @@ export function formatSingleProvider(row: IntegrationRow | undefined): string { lines.push(` serviceAccountName: ${connection.serviceAccountName}`); } lines.push(` status: ${connection.status}`); + if (connection.registrationHealth) { + lines.push(` registrationHealth: ${JSON.stringify(connection.registrationHealth)}`); + } } } + if (row.registrationHealth) { + lines.push('', 'Registration health:', ` ${JSON.stringify(row.registrationHealth)}`); + } const firstTrigger = row.triggers[0] ?? 'event.name'; lines.push( '', diff --git a/packages/cli/src/invoke-command.test.ts b/packages/cli/src/invoke-command.test.ts index 328850fb..13f70fa9 100644 --- a/packages/cli/src/invoke-command.test.ts +++ b/packages/cli/src/invoke-command.test.ts @@ -1,9 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { - extractBundleHandler, + deriveEventSource, + matchesExpectedProviderAction, parseFixtureEnvelopes, parseInvokeArgs, renderHumanSummary, @@ -11,397 +13,1688 @@ import { scaffoldFixture, type RunInvokeIO } from './invoke-command.js'; -import type { SimulationResult } from '@agentworkforce/runtime'; +import { parseInvokeCase } from './invoke/case-file.js'; +import { prepareInvokeTarget } from './invoke/prepare-target.js'; +import type { RunRecordV2 } from '@agentworkforce/runtime'; -// --------------------------------------------------------------------------- -// parseInvokeArgs +function collectingIO(): RunInvokeIO & { out: string[]; err: string[] } { + const out: string[] = []; + const err: string[] = []; + return { + out, + err, + stdout: (text: string) => out.push(text), + stderr: (text: string) => err.push(text) + }; +} -test('parseInvokeArgs: persona + fixture + repeatable flags', () => { +function extractLoggedMessagePayloads(record: RunRecordV2): string[] { + const logs = ((record.extensions as Record).logs ?? []) as string[]; + return logs.map((line) => { + try { + const parsed = JSON.parse(line) as { message?: unknown }; + return typeof parsed.message === 'string' ? parsed.message : ''; + } catch { + return line; + } + }); +} + +test('parseInvokeArgs: schedule source with policy flags', () => { const parsed = parseInvokeArgs([ - './persona.json', - '--fixture', - './event.json', + './agent.ts', + '--schedule', + 'scan', + '--reads', + 'live', + '--model=stub', '--input', - 'SLACK_CHANNEL=#general', - '--input=REGION=eu', + 'SLACK_CHANNEL=C123', '--seed', - '/slack/channels/_index.json=./channels.json', - '--workspace', - 'ws-123', - '--output', - './run.json' + '/tmp/state.json=./state.json', + '--watch' ]); assert.ok(!('help' in parsed) && !('scaffold' in parsed)); if ('help' in parsed || 'scaffold' in parsed) return; - assert.equal(path.basename(parsed.personaPath), 'persona.json'); - assert.equal(path.basename(parsed.fixturePath), 'event.json'); - assert.equal(path.basename(parsed.outputPath ?? ''), 'run.json'); - assert.deepEqual(parsed.inputs, { SLACK_CHANNEL: '#general', REGION: 'eu' }); - assert.deepEqual(parsed.seeds, { '/slack/channels/_index.json': './channels.json' }); - assert.equal(parsed.workspaceId, 'ws-123'); + assert.equal(parsed.source.kind, 'schedule'); + assert.equal(parsed.source.name, 'scan'); + assert.equal(parsed.reads, 'live'); + assert.equal(parsed.model, 'stub'); + assert.equal(parsed.watch, true); + assert.deepEqual(parsed.inputs, { SLACK_CHANNEL: 'C123' }); }); -test('parseInvokeArgs: -h returns help sentinel', () => { - assert.deepEqual(parseInvokeArgs(['-h']), { help: true }); +test('parseInvokeArgs: case and fixture are mutually exclusive', () => { + assert.throws( + () => parseInvokeArgs(['./agent.ts', '--case', './case.yaml', '--fixture', './event.json']), + /mutually exclusive/ + ); }); -test('parseInvokeArgs: missing persona path throws', () => { - assert.throws(() => parseInvokeArgs(['--fixture', 'x.json']), /missing persona path/); +test('parseFixtureEnvelopes: object, array, and NDJSON stay supported', () => { + const object = parseFixtureEnvelopes('{"id":"evt_1"}', 'event.json'); + const array = parseFixtureEnvelopes('[{"id":"evt_1"},{"id":"evt_2"}]', 'events.json'); + const ndjson = parseFixtureEnvelopes('{"id":"evt_1"}\n{"id":"evt_2"}\n', 'events.ndjson'); + assert.equal(object.length, 1); + assert.equal(array.length, 2); + assert.equal(ndjson.length, 2); }); -test('parseInvokeArgs: missing --fixture throws', () => { - assert.throws(() => parseInvokeArgs(['./persona.json']), /missing --fixture/); +test('parseInvokeCase: normalizeCaseEvent rejects unknown cron and slack fields path-specifically', async () => { + await withAgent({ + 'cron.case.yaml': ` +schemaVersion: 1 +id: bad.cron +kind: scheduled +event: + schedule: scan + extra: nope +`, + 'slack.case.yaml': ` +schemaVersion: 1 +id: bad.slack +kind: chat +event: + type: slack.message.created + resource: + channel: C123 + ts: "1.2" + text: hi + user: U123 + extra: nope +` + }, async (dir) => { + await assert.rejects( + () => parseInvokeCase(path.join(dir, 'cron.case.yaml')), + /\$\.event\.extra: unknown field/ + ); + await assert.rejects( + () => parseInvokeCase(path.join(dir, 'slack.case.yaml')), + /\$\.event\.resource\.extra: unknown field/ + ); + }); }); -test('parseInvokeArgs: unknown flag throws', () => { - assert.throws( - () => parseInvokeArgs(['./persona.json', '--fixture', 'x.json', '--bogus']), - /unknown flag "--bogus"/ - ); +test('deriveEventSource: maps core and provider-prefixed contracts without guessing unknowns', () => { + assert.equal(deriveEventSource('cron.tick@1'), 'cron'); + assert.equal(deriveEventSource('slack.message.created@1'), 'slack'); + assert.equal(deriveEventSource('github.pull_request.opened@2026-07-15'), 'github'); + assert.equal(deriveEventSource('acme.custom.event@1'), 'acme'); + assert.equal(deriveEventSource('startup@1'), 'startup'); + assert.equal(deriveEventSource('unknown'), undefined); }); -// --------------------------------------------------------------------------- -// parseFixtureEnvelopes +async function withAgent( + files: Record, + fn: (dir: string, agentPath: string) => Promise +): Promise { + const dir = await mkdtemp(path.join(process.cwd(), '.invoke-')); + try { + for (const [relativePath, contents] of Object.entries(files)) { + const absPath = path.join(dir, relativePath); + await writeFile(absPath, contents, 'utf8'); + } + return await fn(dir, path.join(dir, 'agent.ts')); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test('runInvoke: bare Agent source runs by schedule and emits a RunRecordV2', async () => { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async (ctx) => { + await ctx.memory.save(JSON.stringify({ ok: true }), { tags: ['probe'], scope: 'workspace' }); + ctx.log('info', 'agent.ran'); + } + }); + ` + }, async (_dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--schedule', 'scan'], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; -const ENVELOPE = { - id: 'e1', - workspace: 'ws-test', - type: 'cron.tick', - occurredAt: '2026-05-12T09:00:00Z', - name: 'weekly', - cron: '0 9 * * 6' -}; + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 0); + assert.equal(result.status, 'succeeded'); + assert.equal(result.eventContract, 'cron.tick@1'); + assert.ok(result.actions.some((action) => action.kind === 'memory.save')); + assert.match(io.err.join(''), /preview: 1 run\(s\) — 1 ok, 0 failed/); + }); +}); -test('parseFixtureEnvelopes: single JSON object (incl. pretty-printed)', () => { - const parsed = parseFixtureEnvelopes(JSON.stringify(ENVELOPE, null, 2), 'event.json'); - assert.equal(parsed.length, 1); - assert.equal(parsed[0].id, 'e1'); +test('prepareInvokeTarget: matching sibling persona source is used for bare Agent handlers', async () => { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async () => {} + }); + `, + 'persona.ts': ` + import { definePersona } from '@agentworkforce/persona-kit'; + export default definePersona({ + id: 'hn-monitor', + intent: 'review', + description: 'Real sibling persona.', + model: 'claude-haiku-4-5-20251001', + inputs: { SLACK_CHANNEL: 'C123' }, + capabilities: { + liveReads: { mode: 'hn' } + }, + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + onEvent: './agent.ts' + }); + ` + }, async (dir, agentPath) => { + const prepared = await prepareInvokeTarget(agentPath); + assert.equal(prepared.personaPath, path.join(dir, 'persona.ts')); + assert.equal(prepared.compiled.sourceKind, 'split'); + assert.equal(prepared.compiled.handlerEntry, agentPath); + assert.equal(prepared.compiled.persona.id, 'hn-monitor'); + assert.equal(prepared.compiled.persona.model, 'claude-haiku-4-5-20251001'); + assert.deepEqual(prepared.compiled.persona.inputs, { + SLACK_CHANNEL: { default: 'C123' } + }); + assert.deepEqual(prepared.compiled.persona.capabilities, { + liveReads: { mode: 'hn' } + }); + assert.deepEqual(prepared.warnings, []); + }); }); -test('parseFixtureEnvelopes: JSON array', () => { - const parsed = parseFixtureEnvelopes(JSON.stringify([ENVELOPE, { ...ENVELOPE, id: 'e2' }]), 'events.json'); - assert.deepEqual(parsed.map((e) => e.id), ['e1', 'e2']); +test('prepareInvokeTarget: unrelated sibling persona source is ignored and bare Agent fallback stays synthetic', async () => { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async () => {} + }); + `, + 'other-agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'other', cron: '0 10 * * *' }], + handler: async () => {} + }); + `, + 'persona.ts': ` + import { definePersona } from '@agentworkforce/persona-kit'; + export default { + id: 'other-persona', + intent: 'review', + description: 'Points at a different agent.', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + onEvent: './other-agent.ts' + }; + ` + }, async (_dir, agentPath) => { + const prepared = await prepareInvokeTarget(agentPath); + assert.equal(prepared.personaPath, agentPath); + assert.equal(prepared.compiled.sourceKind, 'single-file'); + assert.equal(prepared.compiled.handlerEntry, agentPath); + assert.equal(prepared.compiled.persona.id, 'agent'); + assert.match(prepared.compiled.persona.description ?? '', /Synthetic local preview persona/); + assert.deepEqual(prepared.warnings, [ + `invoke synthesized a minimal preview persona for bare Agent source ${agentPath}` + ]); + }); }); -test('parseFixtureEnvelopes: NDJSON lines', () => { - const ndjson = `${JSON.stringify(ENVELOPE)}\n${JSON.stringify({ ...ENVELOPE, id: 'e2' })}\n`; - const parsed = parseFixtureEnvelopes(ndjson, 'events.ndjson'); - assert.deepEqual(parsed.map((e) => e.id), ['e1', 'e2']); +test('prepareInvokeTarget: bare Agent source without sibling persona keeps existing synthetic fallback', async () => { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async () => {} + }); + ` + }, async (_dir, agentPath) => { + const prepared = await prepareInvokeTarget(agentPath); + assert.equal(prepared.personaPath, agentPath); + assert.equal(prepared.compiled.sourceKind, 'single-file'); + assert.equal(prepared.compiled.handlerEntry, agentPath); + assert.equal(prepared.compiled.persona.id, 'agent'); + assert.deepEqual(prepared.warnings, [ + `invoke synthesized a minimal preview persona for bare Agent source ${agentPath}` + ]); + }); }); -test('parseFixtureEnvelopes: empty / malformed inputs throw with the label', () => { - assert.throws(() => parseFixtureEnvelopes(' ', 'empty.json'), /empty\.json is empty/); - assert.throws(() => parseFixtureEnvelopes('not json', 'bad.json'), /must be a JSON envelope object/); - assert.throws(() => parseFixtureEnvelopes('[{"id":"x"}, 42]', 'mixed.json'), /mixed\.json\[1\] must be a JSON object/); - assert.throws( - () => parseFixtureEnvelopes(`${JSON.stringify(ENVELOPE)}\nnot-json\n`, 'lines.ndjson'), - /lines\.ndjson:2 is not valid JSON/ - ); +test('runInvoke: watch reruns the same source when authored files change', async () => { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + import { marker } from './marker.ts'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async (ctx) => { + ctx.log('info', marker); + } + }); + `, + 'marker.ts': `export const marker = 'first-pass';\n` + }, async (dir, agentPath) => { + const io = collectingIO(); + let waits = 0; + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--schedule', 'scan', '--watch'], io, { + waitForWatchChange: async (files) => { + waits += 1; + if (waits === 1) { + assert.ok(files.some((file) => file.endsWith(path.sep + 'marker.ts'))); + await writeFile(path.join(dir, 'marker.ts'), `export const marker = 'second-pass';\n`, 'utf8'); + return 'change'; + } + return 'stop'; + } + }); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 0); + assert.match(io.err.join(''), /watch: change detected, rerunning preview/); + const logs = ((result.extensions as Record).logs ?? []) as string[]; + assert.ok(logs.some((line) => line.includes('second-pass'))); + }); }); -// --------------------------------------------------------------------------- -// extractBundleHandler — mirrors the generated runner.mjs extraction +test('runInvoke: watch reruns preserve simulated provider receipts for later threaded replies', async () => { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + import { slackClient } from '@relayfile/relay-helpers'; + import { marker } from './marker.ts'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async (ctx) => { + const slack = slackClient(); + const existing = await ctx.files.read('/tmp/thread-ref.txt').catch(() => ''); + if (!existing) { + const header = await slack.post('C123', marker); + await ctx.files.write('/tmp/thread-ref.txt', header.ref); + return; + } + await slack.post('C123', marker, { replyTo: existing }); + } + }); + `, + 'marker.ts': `export const marker = 'header';\n` + }, async (dir, agentPath) => { + const relayfileDir = path.join(dir, 'node_modules', '@relayfile'); + await mkdir(relayfileDir, { recursive: true }); + await symlink( + path.resolve(process.cwd(), '..', 'delivery', 'node_modules', '@relayfile', 'relay-helpers'), + path.join(relayfileDir, 'relay-helpers') + ); -test('extractBundleHandler: defineAgent default export', () => { - const handler = async () => {}; - const extracted = extractBundleHandler( - { default: { __workforceAgent: true, handler, schedules: [] } }, - 'p1' - ); - assert.equal(extracted, handler); -}); - -test('extractBundleHandler: plain { handler } object', () => { - const handler = async () => {}; - assert.equal(extractBundleHandler({ default: { handler } }, 'p2'), handler); -}); - -test('extractBundleHandler: bare function fallback', () => { - const handler = async () => {}; - assert.equal(extractBundleHandler({ default: handler }, 'p3'), handler); -}); - -test('extractBundleHandler: non-function throws the defineAgent hint', () => { - assert.throws(() => extractBundleHandler({ default: { nope: true } }, 'p4'), /defineAgent/); -}); - -// --------------------------------------------------------------------------- -// End-to-end: real preflight → real esbuild bundle → simulateInvocation. -// -// The temp persona lives INSIDE packages/cli (not os.tmpdir) on purpose: the -// staged bundle leaves `@agentworkforce/runtime` external, so Node must be -// able to walk up from the bundle to a node_modules that provides it — same -// constraint deploy's `.workforce/build/` placement satisfies. - -const E2E_PERSONA = { - id: 'invoke-e2e', - intent: 'documentation', - tags: ['documentation'], - description: 'invoke e2e persona', - harness: 'claude', - model: 'anthropic/claude-3-5-sonnet', - systemPrompt: 'be helpful', - harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, - cloud: true, - onEvent: './agent.ts' -}; - -const E2E_AGENT_SRC = `import { defineAgent, isCronTickEvent } from '@agentworkforce/runtime'; -export default defineAgent({ - schedules: [{ name: 'weekly', cron: '0 9 * * 6' }], - handler: async (ctx, event) => { - ctx.log('info', 'e2e handler ran', { id: event.id }); - await ctx.memory.save('e2e note'); - if (isCronTickEvent(event) && event.schedule === 'explode') { - throw new Error('fixture asked for failure'); + const io = collectingIO(); + let waits = 0; + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--schedule', 'scan', '--watch'], io, { + waitForWatchChange: async () => { + waits += 1; + if (waits === 1) { + await writeFile(path.join(dir, 'marker.ts'), `export const marker = 'reply';\n`, 'utf8'); + return 'change'; + } + return 'stop'; + } + }); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 0); + const write = result.actions.find((entry) => entry.kind === 'provider.write' && entry.provider === 'slack'); + const data = write?.data as Record | undefined; + const body = data?.body as Record | undefined; + assert.equal(body?.parentRef, '/slack/channels/C123/messages/preview-slack-messages-0001.json'); + assert.equal(body?.thread_ts, 'preview-slack-messages-0001'); + }); +}); + +test('runInvoke: case file executes with assertions and fixture-backed HTTP/model preview', async () => { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async (ctx) => { + const response = await fetch('https://example.test/front-page'); + const story = await response.json(); + await ctx.memory.save(JSON.stringify(story), { tags: ['stories'], scope: 'workspace' }); + await ctx.llm.complete('Return ONLY compact JSON with this shape:\\n[{"id":1,"title":"Agent"}]'); + ctx.log('info', 'agent.case-ran'); + } + }); + `, + 'case.yaml': ` +schemaVersion: 1 +id: test.case +kind: scheduled +event: + schedule: scan +policy: + reads: fixtures + model: stub +http: + - method: GET + match: front-page + file: ./fixture.json +expect: + status: succeeded + eventSource: cron + logsContain: + - agent.case-ran + effectsContain: + - memory.save + - model.complete +`, + 'fixture.json': '{"id":1,"title":"Agent"}\n' + }, async (dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--case', path.join(dir, 'case.yaml')], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 0); + assert.equal(result.status, 'succeeded'); + assert.ok(result.actions.some((action) => action.kind === 'http.read')); + assert.ok(result.actions.some((action) => action.kind === 'model.complete')); + }); +}); + +test('runInvoke: case model fixtures fail clearly when exhausted', async () => { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async (ctx) => { + ctx.log('info', await ctx.llm.complete('first')); + ctx.log('info', await ctx.llm.complete('second')); + } + }); + `, + 'case.yaml': ` +schemaVersion: 1 +id: test.model-fixture +kind: scheduled +event: + schedule: scan +policy: + model: fixture +model: + - output: first-output +` + }, async (dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--case', path.join(dir, 'case.yaml')], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 1); + assert.equal(result.status, 'failed'); + assert.match(String(result.error ?? ''), /model fixture 2 requested but only 1 fixture\(s\) are available/); + }); +}); + +test('runInvoke: case live model uses current fidelity even when model fixtures are present', async () => { + let modelRequests = 0; + let seenPrompt = ''; + const server = createServer(async (req, res) => { + if (req.method !== 'POST' || req.url !== '/backend-api/codex/responses') { + res.statusCode = 404; + res.end('not found'); + return; + } + modelRequests += 1; + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { + input?: Array<{ content?: Array<{ text?: string }> }>; + }; + seenPrompt = String(body.input?.[0]?.content?.[0]?.text ?? ''); + res.statusCode = 200; + res.setHeader('content-type', 'text/event-stream'); + res.end([ + 'event: response.output_text.delta', + 'data: {"type":"response.output_text.delta","delta":"live-case-output"}', + '', + 'event: response.completed', + 'data: {"type":"response.completed","response":{"id":"resp-live-case"}}', + '', + '' + ].join('\n')); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const baseUrl = `http://127.0.0.1:${address.port}`; + const previousEnv = { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN, + CODEX_ACCOUNT_ID: process.env.CODEX_ACCOUNT_ID, + CODEX_BACKEND_BASE_URL: process.env.CODEX_BACKEND_BASE_URL, + CODEX_OAUTH_CREDENTIAL: process.env.CODEX_OAUTH_CREDENTIAL, + CODEX_OAUTH_TOKEN: process.env.CODEX_OAUTH_TOKEN, + OPENAI_API_KEY: process.env.OPENAI_API_KEY + }; + + try { + delete process.env.ANTHROPIC_API_KEY; + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + delete process.env.CODEX_ACCOUNT_ID; + delete process.env.CODEX_BACKEND_BASE_URL; + delete process.env.CODEX_OAUTH_TOKEN; + delete process.env.OPENAI_API_KEY; + process.env.CODEX_OAUTH_CREDENTIAL = JSON.stringify({ + tokens: { + access_token: 'fresh-access', + refresh_token: 'refresh', + account_id: 'acct-live-case' + }, + last_refresh: '2026-07-15T09:00:00.000Z', + base_url: `${baseUrl}/backend-api/codex` + }); + + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + id: 'live-case-model-fidelity', + intent: 'documentation', + description: 'case live model fidelity test', + skills: [], + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async (ctx) => { + ctx.log('info', await ctx.llm.complete('live case prompt')); + } + }); + `, + 'case.yaml': ` +schemaVersion: 1 +id: live.case.model +kind: scheduled +event: + schedule: scan +policy: + model: live +model: + - output: fixture-should-not-be-used +expect: + status: succeeded + eventSource: cron +` + }, async (dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--case', path.join(dir, 'case.yaml')], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 0); + assert.equal(modelRequests, 1); + assert.equal(seenPrompt, 'live case prompt'); + const sourceFidelity = ((result.extensions as Record).sourceFidelity ?? {}) as Record; + assert.equal(sourceFidelity.model, 'current'); + const action = result.actions.find((entry) => entry.kind === 'model.complete'); + assert.equal(action?.data?.source, 'current'); + const messages = extractLoggedMessagePayloads(result); + assert.ok(messages.some((line) => line.includes('live-case-output'))); + assert.ok(messages.every((line) => !line.includes('fixture-should-not-be-used'))); + }); + } finally { + server.close(); + for (const [key, value] of Object.entries(previousEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; } - return 'weekly digest sent'; } }); -`; -function collectingIO(): RunInvokeIO & { out: string[]; err: string[] } { - const out: string[] = []; - const err: string[] = []; - return { - out, - err, - stdout: (text: string) => { - out.push(text); - }, - stderr: (text: string) => { - err.push(text); +test('runInvoke: schedule invocation carries persona httpRead capability rules', async () => { + const hits: string[] = []; + const server = createServer((req, res) => { + hits.push(req.url ?? '/'); + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const url = `http://127.0.0.1:${address.port}/schedule-allowed`; + + try { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + id: 'schedule-http-read', + intent: 'documentation', + description: 'schedule http read test', + skills: [], + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + capabilities: { + httpRead: { + allow: [{ method: 'GET', urlGlob: '${url}' }] + } + }, + handler: async (ctx) => { + const response = await fetch('${url}'); + ctx.log('info', await response.text()); + } + }); + ` + }, async (_dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--schedule', 'scan', '--reads', 'live'], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 0); + assert.equal(result.status, 'succeeded'); + assert.deepEqual(hits, ['/schedule-allowed']); + }); + } finally { + server.close(); + } +}); + +test('runInvoke: fixture invocation carries persona httpRead capability rules', async () => { + const hits: string[] = []; + const server = createServer((req, res) => { + hits.push(req.url ?? '/'); + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const url = `http://127.0.0.1:${address.port}/fixture-allowed`; + + try { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + id: 'fixture-http-read', + intent: 'documentation', + description: 'fixture http read test', + skills: [], + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + capabilities: { + httpRead: { + allow: [{ method: 'GET', urlGlob: '${url}' }] + } + }, + handler: async (ctx) => { + const response = await fetch('${url}'); + ctx.log('info', await response.text()); + } + }); + `, + 'event.json': JSON.stringify({ + id: 'evt_fixture_http_read', + workspace: 'ws-local', + type: 'cron.tick', + occurredAt: '2026-07-15T09:00:00.000Z', + name: 'scan', + cron: '0 9 * * *' + }) + }, async (dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke( + [agentPath, '--fixture', path.join(dir, 'event.json'), '--reads', 'live'], + io + ); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 0); + assert.equal(result.status, 'succeeded'); + assert.deepEqual(hits, ['/fixture-allowed']); + }); + } finally { + server.close(); + } +}); + +test('runInvoke: case invocation unions persona httpRead capability rules with case-derived rules', async () => { + const hits: string[] = []; + const server = createServer((req, res) => { + hits.push(req.url ?? '/'); + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const liveUrl = `http://127.0.0.1:${address.port}/case-allowed`; + + try { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + id: 'case-http-read', + intent: 'documentation', + description: 'case http read test', + skills: [], + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + capabilities: { + httpRead: { + allow: [{ method: 'GET', urlGlob: '${liveUrl}' }] + } + }, + handler: async (ctx) => { + const response = await fetch('${liveUrl}'); + ctx.log('info', await response.text()); + } + }); + `, + 'case.yaml': ` +schemaVersion: 1 +id: test.case.http-read-union +kind: scheduled +event: + schedule: scan +policy: + reads: live +http: + - method: GET + match: ignored-fixture + file: ./fixture.json +expect: + status: succeeded + eventSource: cron +`, + 'fixture.json': '{"unused":true}\n' + }, async (dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--case', path.join(dir, 'case.yaml')], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 0); + assert.equal(result.status, 'succeeded'); + assert.deepEqual(hits, ['/case-allowed']); + }); + } finally { + server.close(); + } +}); + +test('matchesExpectedProviderAction: threaded Slack replies satisfy messages+threaded case expectations', () => { + const action: RunRecordV2['actions'][number] = { + kind: 'provider.write', + status: 'previewed', + provider: 'slack', + resource: 'replies', + id: 'preview-slack-replies-0001', + data: { + parameters: { + channelId: 'C123', + messageTs: '300_1' + }, + path: '/slack/channels/C123/messages/300_1/replies/preview-slack-replies-0001.json', + body: { + text: 'Threaded reply', + thread_ts: '300.1' + } } }; -} -async function withE2EPersona( - fn: (dir: string, personaPath: string) => Promise -): Promise { - const dir = await mkdtemp(path.join(process.cwd(), '.invoke-e2e-')); - const personaPath = path.join(dir, 'persona.json'); + assert.equal(matchesExpectedProviderAction(action, { + provider: 'slack', + resource: 'messages', + channel: 'C123', + threaded: true, + textContains: ['Threaded reply'] + }), true); +}); + +test('runInvoke: preview transport binds before import and blocks ambient Slack writes', async () => { + const previousRelayfileToken = process.env.RELAYFILE_TOKEN; + const previousRelayfileUrl = process.env.RELAYFILE_URL; + const previousRelayfileWorkspace = process.env.RELAYFILE_WORKSPACE_ID; + process.env.RELAYFILE_TOKEN = 'xoxb-prod-shaped-token'; + process.env.RELAYFILE_URL = 'https://prod.example.com'; + process.env.RELAYFILE_WORKSPACE_ID = 'ws-prod'; + try { - await writeFile(personaPath, JSON.stringify(E2E_PERSONA, null, 2), 'utf8'); - await writeFile(path.join(dir, 'agent.ts'), E2E_AGENT_SRC, 'utf8'); - return await fn(dir, personaPath); + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + import { slackClient } from '@relayfile/relay-helpers'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async () => { + await slackClient().post('C123', 'Preview only'); + } + }); + ` + }, async (dir, agentPath) => { + const relayfileDir = path.join(dir, 'node_modules', '@relayfile'); + await mkdir(relayfileDir, { recursive: true }); + await symlink( + path.resolve(process.cwd(), '..', 'delivery', 'node_modules', '@relayfile', 'relay-helpers'), + path.join(relayfileDir, 'relay-helpers') + ); + const io = collectingIO(); + const result = await runInvoke([agentPath, '--schedule', 'scan'], io); + assert.ok(result && !Array.isArray(result)); + const slackWrite = result.actions.find((action) => action.kind === 'provider.write' && action.provider === 'slack'); + assert.ok(slackWrite); + }); } finally { - await rm(dir, { recursive: true, force: true }); + process.env.RELAYFILE_TOKEN = previousRelayfileToken; + process.env.RELAYFILE_URL = previousRelayfileUrl; + process.env.RELAYFILE_WORKSPACE_ID = previousRelayfileWorkspace; } -} +}); + +test('runInvoke: live GET is allowed while POST is denied and never reaches the sentinel', async () => { + let getHits = 0; + let postHits = 0; + const server = createServer((req, res) => { + if (req.method === 'GET') { + getHits += 1; + res.setHeader('content-type', 'application/json'); + res.end('{"ok":true}'); + return; + } + postHits += 1; + res.statusCode = 204; + res.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const baseUrl = `http://127.0.0.1:${address.port}`; + + try { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + id: 'live-get-post', + intent: 'documentation', + description: 'live GET allow and POST deny test', + skills: [], + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + capabilities: { + httpRead: { + allow: [{ method: 'GET', urlGlob: '${baseUrl}/allowed' }] + } + }, + handler: async () => { + await fetch('${baseUrl}/allowed'); + await fetch('${baseUrl}/blocked', { method: 'POST', body: 'nope' }); + } + }); + ` + }, async (_dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--schedule', 'scan', '--reads', 'live'], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 1); + assert.equal(getHits, 1); + assert.equal(postHits, 0); + assert.ok(result.actions.some((action) => action.kind === 'http.read' && action.status === 'denied')); + }); + } finally { + server.close(); + } +}); + +test('runInvoke: parent live fetch strips credential-bearing headers before upstream', async () => { + const seenHeaders: Record = {}; + const server = createServer((req, res) => { + seenHeaders.authorization = req.headers.authorization; + seenHeaders['proxy-authorization'] = req.headers['proxy-authorization'] as string | undefined; + seenHeaders.cookie = req.headers.cookie; + seenHeaders['set-cookie'] = req.headers['set-cookie'] as string | undefined; + seenHeaders['x-api-key'] = req.headers['x-api-key'] as string | undefined; + seenHeaders['x-session-token'] = req.headers['x-session-token'] as string | undefined; + seenHeaders['x-trace-id'] = req.headers['x-trace-id'] as string | undefined; + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const url = `http://127.0.0.1:${address.port}/allowed`; -test('runInvoke e2e: bundles, simulates, emits Cloud-compatible record', async () => { - await withE2EPersona(async (dir, personaPath) => { - const fixturePath = path.join(dir, 'event.json'); - await writeFile(fixturePath, JSON.stringify(ENVELOPE), 'utf8'); + try { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + id: 'header-sanitization', + intent: 'documentation', + description: 'credential-bearing header strip test', + skills: [], + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + capabilities: { + httpRead: { + allow: [{ method: 'GET', urlGlob: '${url}' }] + } + }, + handler: async () => { + const response = await fetch('${url}', { + headers: { + Authorization: 'Bearer should-not-leak', + 'Proxy-Authorization': 'Basic should-not-leak', + Cookie: 'sid=should-not-leak', + 'Set-Cookie': 'sid=should-not-leak', + 'X-Api-Key': 'sk-should-not-leak', + 'X-Session-Token': 'session-should-not-leak', + 'X-Trace-Id': 'trace-ok' + } + }); + await response.text(); + } + }); + ` + }, async (_dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--schedule', 'scan', '--reads', 'live'], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 0); + assert.equal(seenHeaders.authorization, undefined); + assert.equal(seenHeaders['proxy-authorization'], undefined); + assert.equal(seenHeaders.cookie, undefined); + assert.equal(seenHeaders['set-cookie'], undefined); + assert.equal(seenHeaders['x-api-key'], undefined); + assert.equal(seenHeaders['x-session-token'], undefined); + assert.equal(seenHeaders['x-trace-id'], 'trace-ok'); + const action = result.actions.find((entry) => entry.kind === 'http.read' && entry.status === 'previewed'); + assert.deepEqual((action?.data as Record).strippedHeaders, [ + 'authorization', + 'cookie', + 'proxy-authorization', + 'set-cookie', + 'x-api-key', + 'x-session-token' + ]); + }); + } finally { + server.close(); + } +}); + +test('runInvoke: raw node:https request fails closed with no network permission', async () => { + await withAgent({ + 'agent.ts': ` + import https from 'node:https'; + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async () => { + const req = https.request('https://example.test/escape', { method: 'POST' }); + req.end('escape'); + } + }); + ` + }, async (_dir, agentPath) => { const io = collectingIO(); const previousExitCode = process.exitCode; - const result = await runInvoke([personaPath, '--fixture', fixturePath], io); + const result = await runInvoke([agentPath, '--schedule', 'scan'], io); const exitCode = process.exitCode; process.exitCode = previousExitCode; - assert.ok(result, `expected a result; stderr: ${io.err.join('')}`); - assert.equal(exitCode, 0); - assert.equal(result.summary.total, 1); - assert.equal(result.runs[0].status, 'succeeded'); - assert.equal(result.runs[0].origin, 'local_dry_run'); - assert.equal(result.runs[0].summary, 'weekly digest sent'); - assert.equal(result.runs[0].failureClass, 'success'); - // The memory.save the handler made was recorded, not executed. - assert.ok(result.runs[0].simulation.sideEffects.some((e) => e.kind === 'memory.save')); - // ctx.log landed in the record's stdout stream. - assert.match(result.runs[0].logs.stdout, /e2e handler ran/); - - // stdout got the machine record; stderr got the human summary. - const machine = JSON.parse(io.out.join('')) as SimulationResult; - assert.equal(machine.runs[0].runId, result.runs[0].runId); - assert.match(io.err.join(''), /1 run\(s\) — 1 ok, 0 failed/); - - // Build dir was cleaned up. - const { readdir } = await import('node:fs/promises'); - const buildRoot = path.join(dir, '.workforce', 'invoke-build'); - const leftover = await readdir(buildRoot).catch(() => []); - assert.deepEqual(leftover, []); + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 1); + assert.match(String(result.error ?? ''), /raw network node:https.request/); }); }); -test('runInvoke e2e: handler failure → exit 1, error in record', async () => { - await withE2EPersona(async (dir, personaPath) => { - const fixturePath = path.join(dir, 'events.ndjson'); - const failing = { ...ENVELOPE, id: 'e-fail', cron: 'explode' }; - await writeFile( - fixturePath, - `${JSON.stringify(failing)}\n${JSON.stringify(ENVELOPE)}\n`, - 'utf8' - ); +test('runInvoke: dynamic computed node:http import is denied with zero raw hits', async () => { + let rawHits = 0; + const server = createServer((_req, res) => { + rawHits += 1; + res.statusCode = 204; + res.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const url = `http://127.0.0.1:${address.port}/escape`; + + try { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async () => { + const mod = await import('node:' + 'http'); + const req = mod.request('${url}', { method: 'POST' }); + req.end('escape'); + } + }); + ` + }, async (_dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--schedule', 'scan', '--reads', 'live'], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 1); + assert.equal(rawHits, 0); + assert.match(String(result.error ?? ''), /raw network node:http.request/); + }); + } finally { + server.close(); + } +}); + +test('runInvoke: alternate raw module paths are denied deterministically in the worker', async () => { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + import net, { Socket as NetSocket } from 'node:net'; + import tls, { TLSSocket } from 'node:tls'; + import dgram, { Socket as DgramSocket } from 'node:dgram'; + import dns from 'node:dns'; + import http2 from 'node:http2'; + import { Worker } from 'node:worker_threads'; + + async function attempt(fn) { + try { + const value = fn(); + if (value && typeof value.then === 'function') await value; + return 'ok'; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async (ctx) => { + const results = { + netSocket: await attempt(() => new NetSocket()), + netConnect: await attempt(() => net.connect(1, '127.0.0.1')), + tlsSocket: await attempt(() => new TLSSocket()), + tlsConnect: await attempt(() => tls.connect(1, '127.0.0.1')), + tlsContext: await attempt(() => tls.createSecureContext()), + dgramSocket: await attempt(() => new DgramSocket('udp4')), + dgramCreate: await attempt(() => dgram.createSocket('udp4')), + dnsLookup: await attempt(() => dns.promises.lookup('example.test')), + http2Connect: await attempt(() => http2.connect('http://127.0.0.1:1')), + worker: await attempt(() => new Worker('export {};', { eval: true })), + permissionNet: process.permission?.has?.('net'), + permissionWorker: process.permission?.has?.('worker') + }; + ctx.log('info', JSON.stringify(results)); + } + }); + ` + }, async (_dir, agentPath) => { const io = collectingIO(); const previousExitCode = process.exitCode; - const result = await runInvoke([personaPath, '--fixture', fixturePath], io); + const result = await runInvoke([agentPath, '--schedule', 'scan'], io); const exitCode = process.exitCode; process.exitCode = previousExitCode; - assert.ok(result, `expected a result; stderr: ${io.err.join('')}`); - assert.equal(exitCode, 1); - assert.equal(result.summary.failed, 1); - assert.equal(result.summary.succeeded, 1); - assert.equal(result.runs[0].status, 'failed'); - assert.equal(result.runs[0].error, 'fixture asked for failure'); - assert.equal(result.runs[0].failureClass, 'runner_error'); - assert.equal(result.runs[1].status, 'succeeded'); + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 0); + const messages = extractLoggedMessagePayloads(result); + assert.ok(messages.some((line) => line.includes('"permissionNet":false'))); + assert.ok(messages.some((line) => line.includes('node:net.Socket'))); + assert.ok(messages.some((line) => line.includes('node:net.connect'))); + assert.ok(messages.some((line) => line.includes('node:tls.TLSSocket'))); + assert.ok(messages.some((line) => line.includes('node:tls.connect'))); + assert.ok(messages.some((line) => line.includes('node:tls.createSecureContext'))); + assert.ok(messages.some((line) => line.includes('node:dgram.Socket'))); + assert.ok(messages.some((line) => line.includes('node:dgram.createSocket'))); + assert.ok(messages.some((line) => line.includes('node:dns.promises.lookup'))); + assert.ok(messages.some((line) => line.includes('node:http2.connect'))); + assert.ok(messages.some((line) => line.includes('node:worker_threads.Worker'))); + const deniedCalls = result.actions + .filter((entry) => entry.status === 'denied') + .map((entry) => `${String((entry.data as Record).module)}.${String((entry.data as Record).call)}`); + for (const expected of [ + 'node:net.Socket', + 'node:net.connect', + 'node:tls.TLSSocket', + 'node:tls.connect', + 'node:tls.createSecureContext', + 'node:dgram.Socket', + 'node:dgram.createSocket', + 'node:dns.promises.lookup', + 'node:http2.connect', + 'node:worker_threads.Worker' + ]) { + assert.ok(deniedCalls.includes(expected), `missing denied action for ${expected}`); + } }); }); -test('runInvoke e2e: --output writes the record to a file', async () => { - await withE2EPersona(async (dir, personaPath) => { - const fixturePath = path.join(dir, 'event.json'); - const outputPath = path.join(dir, 'run.json'); - await writeFile(fixturePath, JSON.stringify(ENVELOPE), 'utf8'); - +test('runInvoke: bare http request is denied before any network write lands', async () => { + await withAgent({ + 'agent.ts': ` + import http from 'http'; + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async () => { + const req = http.request('http://127.0.0.1:1/escape', { method: 'POST' }); + req.end('escape'); + } + }); + ` + }, async (_dir, agentPath) => { const io = collectingIO(); const previousExitCode = process.exitCode; - const result = await runInvoke( - [personaPath, '--fixture', fixturePath, '--output', outputPath], - io - ); + const result = await runInvoke([agentPath, '--schedule', 'scan'], io); + const exitCode = process.exitCode; process.exitCode = previousExitCode; - assert.ok(result); - assert.equal(io.out.join(''), ''); - const { readFile } = await import('node:fs/promises'); - const written = JSON.parse(await readFile(outputPath, 'utf8')) as SimulationResult; - assert.equal(written.origin, 'local_dry_run'); - assert.match(io.err.join(''), /run record written to/); + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 1); + assert.match(String(result.error ?? ''), /raw network node:http.request/); }); }); -test('runInvoke: usage error prints usage and sets exit 1, no throw', async () => { - const io = collectingIO(); - const previousExitCode = process.exitCode; - const result = await runInvoke(['--fixture', 'x.json'], io); - const exitCode = process.exitCode; - process.exitCode = previousExitCode; +test('runInvoke: createRequire http escape is denied with zero raw hits', async () => { + let rawHits = 0; + const server = createServer((_req, res) => { + rawHits += 1; + res.statusCode = 204; + res.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const url = `http://127.0.0.1:${address.port}/escape`; - assert.equal(result, undefined); - assert.equal(exitCode, 1); - assert.match(io.err.join(''), /missing persona path/); - assert.match(io.err.join(''), /usage: agentworkforce invoke/); -}); + try { + await withAgent({ + 'agent.ts': ` + import { createRequire } from 'node:module'; + import { defineAgent } from '@agentworkforce/runtime'; + const require = createRequire(import.meta.url); + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async () => { + const http = require('http'); + const req = http.request('${url}', { method: 'POST' }); + req.end('escape'); + } + }); + ` + }, async (_dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--schedule', 'scan', '--reads', 'live'], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; -// --------------------------------------------------------------------------- -// renderHumanSummary + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 1); + assert.equal(rawHits, 0); + assert.match(String(result.error ?? ''), /raw network node:http.request/); + }); + } finally { + server.close(); + } +}); -test('renderHumanSummary: lists runs with status, side-effect count, skips', () => { - const summary = renderHumanSummary({ - origin: 'local_dry_run', - mode: 'simulate', - startedAt: 't0', - endedAt: 't1', - durationMs: 5, - runs: [ - { - runId: 'sim_run_1', - deploymentId: 'd', - agentId: 'a', - status: 'failed', - exitCode: 1, - summary: null, - error: 'kaboom', - startedAt: 't0', - endedAt: 't1', - durationMs: 5, - trigger: { kind: 'clock', eventSource: 'cron' }, - sandbox: { id: null, name: 'local-simulation' }, - failureClass: 'runner_error', - origin: 'local_dry_run', - logs: { stdout: '', stderr: '', mountLogTail: '', stdoutTruncated: false, stderrTruncated: false }, - simulation: { mode: 'simulate', sideEffects: [], capturedLogs: [] } +test('runInvoke: helper module importing raw network builtin fails closed on use', async () => { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + import { escape } from './helper.ts'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async () => { + escape(); + } + }); + `, + 'helper.ts': ` + import http from 'node:http'; + export function escape() { + const req = http.request('http://127.0.0.1:1/escape', { method: 'POST' }); + req.end('escape'); } - ], - unsupported: [{ id: 'e9', type: 'mystery.event' }], - summary: { total: 1, succeeded: 0, failed: 1, unsupported: 1 }, - exitCode: 1 + ` + }, async (_dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--schedule', 'scan'], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 1); + assert.match(String(result.error ?? ''), /raw network node:http.request/); }); - assert.match(summary, /1 run\(s\) — 0 ok, 1 failed, 1 unsupported/); - assert.match(summary, /\[FAIL\] cron\/clock sim_run_1/); - assert.match(summary, /error: kaboom/); - assert.match(summary, /\[skip\] unsupported envelope e9 \(mystery\.event\)/); }); -// --------------------------------------------------------------------------- -// --scaffold (workforce#189) +test('runInvoke: child_process execution is denied under the isolated worker boundary', async () => { + await withAgent({ + 'agent.ts': ` + import { execFile } from 'node:child_process'; + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async () => { + execFile('echo', ['nope']); + } + }); + ` + }, async (_dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--schedule', 'scan'], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; -test('parseInvokeArgs: --scaffold needs no persona or fixture', () => { - const parsed = parseInvokeArgs(['--scaffold', 'cron.tick', '--output', './event.json']); - assert.ok('scaffold' in parsed); - if (!('scaffold' in parsed)) return; - assert.equal(parsed.scaffold, 'cron.tick'); - assert.equal(path.basename(parsed.outputPath ?? ''), 'event.json'); + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 1); + assert.match(String(result.error ?? ''), /child_process.execFile/); + }); }); -test('parseInvokeArgs: --scaffold rejects invoke-only args instead of ignoring them', () => { - assert.throws( - () => parseInvokeArgs(['./persona.json', '--scaffold', 'cron.tick']), - /only accepts --output.*/ - ); - assert.throws( - () => parseInvokeArgs(['--scaffold', 'cron.tick', '--fixture', './event.json']), - /only accepts --output.*--fixture/ - ); - assert.throws( - () => parseInvokeArgs(['--scaffold', 'cron.tick', '--input', 'FOO=bar', '--seed', '/x=./x.json']), - /only accepts --output.*--input, --seed/ - ); +test('runInvoke: isolated worker strips ambient creds, redacts secret-shaped inputs, and keeps safe inputs', async () => { + const previousRelayfileToken = process.env.RELAYFILE_TOKEN; + process.env.RELAYFILE_TOKEN = 'xoxb-sensitive-token'; + + try { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async (ctx) => { + ctx.log('info', JSON.stringify({ + ambientToken: process.env.RELAYFILE_TOKEN ?? 'missing', + secretEnv: process.env.SLACK_BOT_TOKEN ?? 'missing', + safeEnv: process.env.SLACK_CHANNEL ?? 'missing', + secretInput: ctx.persona.inputs.SLACK_BOT_TOKEN ?? 'missing', + safeInput: ctx.persona.inputs.SLACK_CHANNEL ?? 'missing' + })); + } + }); + ` + }, async (_dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([ + agentPath, + '--schedule', + 'scan', + '--input', + 'SLACK_BOT_TOKEN=xoxb-secret-input', + '--input', + 'SLACK_CHANNEL=C123' + ], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 0); + const messages = extractLoggedMessagePayloads(result); + assert.ok(messages.some((line) => line.includes('"ambientToken":"missing"'))); + assert.ok(messages.some((line) => line.includes('"secretEnv":"missing"'))); + assert.ok(messages.some((line) => line.includes('"safeEnv":"C123"'))); + assert.ok(messages.some((line) => line.includes('"secretInput":"missing"'))); + assert.ok(messages.some((line) => line.includes('"safeInput":"missing"'))); + assert.ok(messages.every((line) => !line.includes('xoxb-sensitive-token'))); + assert.ok(messages.every((line) => !line.includes('xoxb-secret-input'))); + assert.equal(process.env.RELAYFILE_TOKEN, 'xoxb-sensitive-token'); + }); + } finally { + process.env.RELAYFILE_TOKEN = previousRelayfileToken; + } }); -test('scaffoldFixture: cron.tick emits a complete frame with name/cron and no warnings', () => { - const { fixture, warnings } = scaffoldFixture('cron.tick'); - assert.deepEqual(warnings, []); - assert.equal(fixture.type, 'cron.tick'); - assert.ok(typeof fixture.occurredAt === 'string'); - assert.ok(String(fixture.name).includes('TODO')); - assert.ok(typeof fixture.cron === 'string'); - assert.ok(!('resource' in fixture)); +test('runInvoke: benign input keys with credential-shaped values are redacted from env, ctx, and logs', async () => { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async (ctx) => { + ctx.log('info', JSON.stringify({ + benignEnv: process.env.PROBE ?? 'missing', + benignCtx: ctx.persona.inputs.PROBE ?? 'missing' + })); + } + }); + ` + }, async (_dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([ + agentPath, + '--schedule', + 'scan', + '--input', + 'PROBE=xoxb-benign-looking-secret-token' + ], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; - const named = scaffoldFixture('cron.daily-report'); - assert.equal(named.fixture.type, 'cron.daily-report'); + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 0); + const messages = extractLoggedMessagePayloads(result); + assert.ok(messages.some((line) => line.includes('"benignEnv":"missing"'))); + assert.ok(messages.every((line) => !line.includes('xoxb-benign-looking-secret-token'))); + assert.ok(!JSON.stringify(result).includes('xoxb-benign-looking-secret-token')); + }); }); -test('scaffoldFixture: known provider event emits frame + explicit resource TODO hole', () => { - const { fixture, warnings } = scaffoldFixture('github.pull_request.opened'); - assert.deepEqual(warnings, []); - assert.equal(fixture.type, 'github.pull_request.opened'); - const resource = fixture.resource as Record; - assert.ok(String(resource.TODO).includes('runs export')); +test('runInvoke: mixed fetch/raw-http probe keeps allowed GET, blocks denied POST, blocks raw http, and exposes no net permission', async () => { + let allowedGetHits = 0; + let deniedPostHits = 0; + let rawHttpHits = 0; + const server = createServer((req, res) => { + if (req.url === '/allowed') { + allowedGetHits += 1; + res.setHeader('content-type', 'application/json'); + res.end('{"ok":true}'); + return; + } + if (req.url === '/blocked') { + deniedPostHits += 1; + res.statusCode = 204; + res.end(); + return; + } + rawHttpHits += 1; + res.statusCode = 204; + res.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const baseUrl = `http://127.0.0.1:${address.port}`; + + try { + await withAgent({ + 'agent.ts': ` + import http from 'node:http'; + import { defineAgent } from '@agentworkforce/runtime'; + + async function attempt(fn) { + try { + await fn(); + return 'ok'; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + + export default defineAgent({ + id: 'mixed-fetch-raw-http', + intent: 'documentation', + description: 'mixed fetch and raw http probe', + skills: [], + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + capabilities: { + httpRead: { + allow: [{ method: 'GET', urlGlob: '${baseUrl}/allowed' }] + } + }, + handler: async (ctx) => { + const permissionNet = process.permission?.has?.('net'); + const allowed = await attempt(async () => { + const response = await fetch('${baseUrl}/allowed'); + await response.text(); + }); + const deniedFetch = await attempt(async () => { + await fetch('${baseUrl}/blocked', { method: 'POST', body: 'nope' }); + }); + const deniedRaw = await attempt(async () => { + await new Promise((resolve, reject) => { + const req = http.request('${baseUrl}/raw-http', { method: 'POST' }, () => resolve(undefined)); + req.on('error', reject); + req.end('escape'); + }); + }); + ctx.log('info', JSON.stringify({ permissionNet, allowed, deniedFetch, deniedRaw })); + } + }); + ` + }, async (_dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--schedule', 'scan', '--reads', 'live'], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 0); + assert.equal(allowedGetHits, 1); + assert.equal(deniedPostHits, 0); + assert.equal(rawHttpHits, 0); + const messages = extractLoggedMessagePayloads(result); + assert.ok(messages.some((line) => line.includes('"permissionNet":false'))); + assert.ok(messages.some((line) => line.includes('"allowed":"ok"'))); + assert.ok(messages.some((line) => line.includes('invoke policy denied POST'))); + assert.ok(messages.some((line) => line.includes('raw network node:http.request'))); + }); + } finally { + server.close(); + } }); -test('scaffoldFixture: unknown provider/event warns but never blocks (lintTriggers stance)', () => { - const unknownProvider = scaffoldFixture('notaprovider.something'); - assert.equal(unknownProvider.warnings.length, 1); - assert.match(unknownProvider.warnings[0], /not in KNOWN_TRIGGER_CATALOG/); - assert.equal(unknownProvider.fixture.type, 'notaprovider.something'); +test('runInvoke: parent fetch failures return deterministic errors without leaking transport details', async () => { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + + async function attempt(fn) { + try { + await fn(); + return 'ok'; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } - const unknownEvent = scaffoldFixture('github.not_a_real_event'); - assert.equal(unknownEvent.warnings.length, 1); - assert.match(unknownEvent.warnings[0], /not a known github trigger/); + export default defineAgent({ + id: 'parent-fetch-failure', + intent: 'documentation', + description: 'parent fetch failure test', + skills: [], + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + capabilities: { + httpRead: { + allow: [{ method: 'GET', urlGlob: 'http://127.0.0.1:1/unreachable' }] + } + }, + handler: async (ctx) => { + const failed = await attempt(async () => { + await fetch('http://127.0.0.1:1/unreachable'); + }); + ctx.log('info', JSON.stringify({ failed })); + } + }); + ` + }, async (_dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--schedule', 'scan', '--reads', 'live'], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 0); + const messages = extractLoggedMessagePayloads(result); + assert.ok(messages.some((line) => line.includes('invoke parent fetch failed for GET http://127.0.0.1:1/unreachable: network error'))); + assert.ok(messages.every((line) => !line.includes('ECONNREFUSED'))); + }); }); -test('runInvoke --scaffold writes the skeleton and exits clean', async () => { - const io = collectingIO(); +test('runInvoke: process.getBuiltinModule http escape is denied with zero raw hits', async () => { + let rawHits = 0; + const server = createServer((_req, res) => { + rawHits += 1; + res.statusCode = 204; + res.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const url = `http://127.0.0.1:${address.port}/escape`; + + try { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async () => { + const http = process.getBuiltinModule('node:http'); + const req = http.request('${url}', { method: 'POST' }); + req.end('escape'); + } + }); + ` + }, async (_dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--schedule', 'scan', '--reads', 'live'], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; + + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 1); + assert.equal(rawHits, 0); + assert.match(String(result.error ?? ''), /raw network node:http.request/); + }); + } finally { + server.close(); + } +}); + +test('runInvoke: failed malicious run cleans up so a later safe run still succeeds', async () => { + const firstIO = collectingIO(); const previousExitCode = process.exitCode; - const result = await runInvoke(['--scaffold', 'cron.tick'], io); - const exitCode = process.exitCode; + + await withAgent({ + 'agent.ts': ` + import http from 'http'; + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async () => { + const req = http.request('http://127.0.0.1:1/escape', { method: 'POST' }); + req.end('escape'); + } + }); + ` + }, async (_dir, agentPath) => { + const first = await runInvoke([agentPath, '--schedule', 'scan'], firstIO); + assert.ok(first && !Array.isArray(first)); + assert.equal(first.status, 'failed'); + assert.match(String(first.error ?? ''), /raw network node:http.request/); + assert.equal(process.exitCode, 1); + }); + + const secondIO = collectingIO(); + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async (ctx) => { + await ctx.memory.save('ok', { scope: 'workspace' }); + } + }); + ` + }, async (_dir, agentPath) => { + const second = await runInvoke([agentPath, '--schedule', 'scan'], secondIO); + assert.ok(second && !Array.isArray(second)); + assert.equal(second.status, 'succeeded'); + }); + process.exitCode = previousExitCode; +}); + +test('runInvoke: replay bundle preserves provenance and unavailable state fidelity', async () => { + await withAgent({ + 'agent.ts': ` + import { defineAgent } from '@agentworkforce/runtime'; + export default defineAgent({ + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + handler: async (ctx) => { + ctx.log('info', 'replay.ran'); + } + }); + `, + 'replay.json': JSON.stringify({ + schemaVersion: 1, + manifest: { + schemaVersion: 1, + runId: 'run_hosted_123', + eventId: 'evt_hosted_123', + exportedAt: '2026-07-15T09:00:00.000Z', + redacted: true, + files: { + 'event.json': { fidelity: 'historical' }, + 'run.json': { fidelity: 'historical' }, + 'state/manifest.json': { fidelity: 'unavailable' }, + 'inputs.json': { fidelity: 'historical' } + } + }, + files: { + 'manifest.json': { + fidelity: 'historical', + content: { + schemaVersion: 1, + runId: 'run_hosted_123', + eventId: 'evt_hosted_123', + exportedAt: '2026-07-15T09:00:00.000Z', + redacted: true, + files: { + 'event.json': { fidelity: 'historical' }, + 'run.json': { fidelity: 'historical' }, + 'state/manifest.json': { fidelity: 'unavailable' }, + 'inputs.json': { fidelity: 'historical' } + } + } + }, + 'event.json': { + fidelity: 'historical', + content: { + schemaVersion: 1, + id: 'evt_hosted_123', + workspace: 'ws-hosted', + type: 'cron.tick', + contractVersion: 1, + occurredAt: '2026-07-15T09:00:00.000Z', + attempt: 1, + resource: { + path: '/cron/schedules/scan', + kind: 'cron.schedule', + id: 'scan', + provider: 'cron' + }, + summary: {}, + schedule: { + name: 'scan', + cron: '0 9 * * *' + } + } + }, + 'run.json': { + fidelity: 'historical', + content: { id: 'run_hosted_123' } + }, + 'state/manifest.json': { + fidelity: 'unavailable', + content: { available: false } + }, + 'inputs.json': { + fidelity: 'historical', + content: { SLACK_CHANNEL: 'C123' } + } + } + }, null, 2) + }, async (dir, agentPath) => { + const io = collectingIO(); + const previousExitCode = process.exitCode; + const result = await runInvoke([agentPath, '--fixture', path.join(dir, 'replay.json')], io); + const exitCode = process.exitCode; + process.exitCode = previousExitCode; - assert.equal(result, undefined); - assert.notEqual(exitCode, 1); - const skeleton = JSON.parse(io.out.join('')); - assert.equal(skeleton.type, 'cron.tick'); + assert.ok(result && !Array.isArray(result)); + assert.equal(exitCode, 0); + assert.equal(result.mode, 'replay'); + assert.equal(result.eventId, 'evt_hosted_123'); + const extensions = result.extensions as Record; + const stateSource = extensions.stateSource as Record; + const sourceFidelity = extensions.sourceFidelity as Record; + const provenance = extensions.provenance as Record; + assert.equal(stateSource.fidelity, 'unavailable'); + assert.deepEqual(sourceFidelity, { + state: 'unavailable', + inputs: 'historical', + http: 'unavailable', + model: 'unavailable' + }); + assert.equal(provenance.sourceRunId, 'run_hosted_123'); + assert.equal(provenance.sourceEventId, 'evt_hosted_123'); + }); +}); + +test('renderHumanSummary: renders single or multi-record previews', () => { + const record: RunRecordV2 = { + runId: 'run_1', + status: 'succeeded', + origin: 'local_dry_run', + mode: 'preview', + policy: { + reads: 'fixtures', + writes: 'preview', + model: 'stub', + shell: 'simulate', + compose: 'preview', + allowedHttp: [] + }, + eventId: 'evt_1', + eventContract: 'cron.tick@1', + trace: [], + actions: [], + artifacts: { artifacts: [] }, + stateDiff: {} + }; + assert.match(renderHumanSummary(record), /preview: 1 run\(s\) — 1 ok, 0 failed/); + assert.match(renderHumanSummary([record, { ...record, runId: 'run_2', status: 'failed' }]), /preview: 2 run\(s\) — 1 ok, 1 failed/); }); -test('scaffoldFixture: non-tick cron types are PRESERVED with a warning, never rewritten', () => { - const { fixture, warnings } = scaffoldFixture('cron.daily'); - assert.equal(fixture.type, 'cron.daily'); - assert.equal(warnings.length, 1); - assert.match(warnings[0], /preserving requested type "cron.daily"/); +test('scaffoldFixture: cron and provider scaffolds remain available', () => { + const cron = scaffoldFixture('cron.tick'); + const provider = scaffoldFixture('github.pull_request.opened'); + assert.equal(cron.fixture.type, 'cron.tick'); + assert.equal(provider.fixture.type, 'github.pull_request.opened'); }); diff --git a/packages/cli/src/invoke-command.ts b/packages/cli/src/invoke-command.ts index 290cd2f3..7058dbec 100644 --- a/packages/cli/src/invoke-command.ts +++ b/packages/cli/src/invoke-command.ts @@ -1,60 +1,63 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { randomUUID } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { bundleStager, preflightPersona } from '@agentworkforce/deploy'; -import { KNOWN_TRIGGER_CATALOG } from '@agentworkforce/persona-kit'; +import { bundleStager } from '@agentworkforce/deploy'; +import { decodeEventFrame } from '@agentworkforce/events'; import { - simulateInvocation, - type RawGatewayEnvelope, - type SimulationResult, - type WorkforceHandler + executeLocalRun, + mergeAllowedHttpRules, + resolvePersonaHttpReadRules, + resolveLocalEffectPolicy, + type EffectPolicyV1, + type LocalHttpFixture, + type LocalModelFixture, + type LocalPreviewState, + type RunRecordV2 } from '@agentworkforce/runtime'; +import { + parseInvokeCase, + type ParsedCaseModelFixture, + type ParsedCaseExpectationProviderAction, + type ParsedInvokeCase +} from './invoke/case-file.js'; +import { prepareInvokeTarget } from './invoke/prepare-target.js'; -export const INVOKE_USAGE = `usage: agentworkforce invoke --fixture [flags] +export const INVOKE_USAGE = `usage: agentworkforce invoke (--fixture | --schedule | --case ) [flags] agentworkforce invoke --scaffold [--output ] -Simulate an invocation: execute the persona's handler against fixture event -envelope(s) with every external side effect recorded, NOT executed, and emit -a machine-readable run record per envelope (Cloud-compatible shape, -origin "local_dry_run"). - -This is distinct from \`agentworkforce deploy --dry-run\`, which validates the -persona/config and exits without ever invoking the handler. +Run a local preview invocation through the composable runtime path. Effects are +previewed locally: no live writes, shell execution, or child runs occur. Flags: - --fixture Event fixture (required): a JSON envelope object, - a JSON array of envelopes, or NDJSON (one envelope - per line). Envelope shape: the runner's - RawGatewayEnvelope (id, workspace, type, - occurredAt, resource?, name?/cron? for cron). - --output Write the run-record JSON to ; a human - summary still prints to stderr. Default: stdout. - --input = Override a declared persona input (repeatable). - --seed = Seed the simulated filesystem: VFS gets - 's contents (repeatable). Use for provider - VFS data the handler reads (e.g. - /slack/channels/_index.json). - --workspace Workspace id for the simulated ctx; defaults to - the first envelope's workspace. - --scaffold Emit a fixture skeleton for an event type instead - of running a persona. Provider payloads are left - as explicit TODO holes; prefer runs export after - a real fire exists. + --fixture Event fixture, EventFrame, legacy gateway envelope, + replay bundle, JSON array, or NDJSON. + --schedule Invoke the compiled agent's declared schedule by name. + --case Run a checked-in YAML case with assertions. + --reads fixtures | live (default: fixtures) + --model stub | fixture | live (default: stub) + --watch Re-run the selected source when authored files change. + --output Write raw RunRecord JSON to . + --input = Override a declared input (repeatable). + --seed = Seed preview file state from a local file (repeatable). + --workspace Workspace id for locally synthesized events. + --scaffold Emit a fixture skeleton for an event type instead of running. -h, --help Print this message. - -Exit code: 0 when every dispatched envelope succeeded, 1 when any handler -invocation failed (or on usage/setup errors). `; +type InvokeSource = + | { kind: 'fixture'; path: string } + | { kind: 'schedule'; name: string } + | { kind: 'case'; path: string }; + export interface InvokeOptions { personaPath: string; - fixturePath: string; + source: InvokeSource; outputPath?: string; inputs?: Record; - /** VFS path → local file whose contents seed it. */ seeds?: Record; workspaceId?: string; + reads?: 'fixtures' | 'live'; + model?: 'stub' | 'fixture' | 'live'; + watch?: boolean; } export type ParsedInvokeArgs = @@ -62,324 +65,773 @@ export type ParsedInvokeArgs = | { help: true } | { scaffold: string; outputPath?: string }; -/** Parse `invoke` args. Throws on usage errors (caller maps to exit 1). */ +export interface RunInvokeIO { + stdout: (text: string) => void; + stderr: (text: string) => void; +} + +export interface InvokeInternals { + waitForWatchChange?: (files: readonly string[]) => Promise<'change' | 'stop'>; +} + +const defaultIO: RunInvokeIO = { + stdout: (text) => process.stdout.write(text), + stderr: (text) => process.stderr.write(text) +}; + +export async function runInvoke( + args: readonly string[], + io: RunInvokeIO = defaultIO, + internals: InvokeInternals = {} +): Promise { + let opts: ParsedInvokeArgs; + try { + opts = parseInvokeArgs(args); + } catch (error) { + io.stderr(`${error instanceof Error ? error.message : String(error)}\n\n${INVOKE_USAGE}`); + process.exitCode = 1; + return undefined; + } + + if ('help' in opts) { + io.stdout(INVOKE_USAGE); + return undefined; + } + if ('scaffold' in opts) { + const { fixture, warnings } = scaffoldFixture(opts.scaffold); + for (const warning of warnings) io.stderr(`warn: ${warning}\n`); + const text = `${JSON.stringify(fixture, null, 2)}\n`; + if (opts.outputPath) { + await writeFile(opts.outputPath, text, 'utf8'); + io.stderr(`fixture skeleton written to ${opts.outputPath}\n`); + } else { + io.stdout(text); + } + return undefined; + } + + try { + return opts.watch + ? await runInvokeWatchLoop(opts, io, internals) + : (await runInvokeOnce(opts, io)).output; + } catch (error) { + io.stderr(`invoke: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + return undefined; + } +} + export function parseInvokeArgs(args: readonly string[]): ParsedInvokeArgs { let personaPath: string | undefined; let fixturePath: string | undefined; + let scheduleName: string | undefined; + let casePath: string | undefined; let outputPath: string | undefined; let workspaceId: string | undefined; let scaffoldType: string | undefined; - let sawFixture = false; + let reads: InvokeOptions['reads']; + let model: InvokeOptions['model']; + let watch = false; const inputs: Record = {}; const seeds: Record = {}; for (let i = 0; i < args.length; i += 1) { - const a = args[i]; - if (a === '-h' || a === '--help') { + const arg = args[i]; + if (arg === '-h' || arg === '--help') { return { help: true }; - } else if (a === '--fixture') { - sawFixture = true; + } else if (arg === '--fixture') { fixturePath = expectValue('--fixture', args[++i]); - } else if (a.startsWith('--fixture=')) { - sawFixture = true; - fixturePath = expectInline('--fixture', a.slice('--fixture='.length)); - } else if (a === '--output') { + } else if (arg.startsWith('--fixture=')) { + fixturePath = expectInline('--fixture', arg.slice('--fixture='.length)); + } else if (arg === '--schedule') { + scheduleName = expectValue('--schedule', args[++i]); + } else if (arg.startsWith('--schedule=')) { + scheduleName = expectInline('--schedule', arg.slice('--schedule='.length)); + } else if (arg === '--case') { + casePath = expectValue('--case', args[++i]); + } else if (arg.startsWith('--case=')) { + casePath = expectInline('--case', arg.slice('--case='.length)); + } else if (arg === '--reads') { + reads = expectEnum('--reads', args[++i], ['fixtures', 'live']); + } else if (arg.startsWith('--reads=')) { + reads = expectEnum('--reads', arg.slice('--reads='.length), ['fixtures', 'live']); + } else if (arg === '--model') { + model = expectEnum('--model', args[++i], ['stub', 'fixture', 'live']); + } else if (arg.startsWith('--model=')) { + model = expectEnum('--model', arg.slice('--model='.length), ['stub', 'fixture', 'live']); + } else if (arg === '--watch') { + watch = true; + } else if (arg === '--output') { outputPath = expectValue('--output', args[++i]); - } else if (a.startsWith('--output=')) { - outputPath = expectInline('--output', a.slice('--output='.length)); - } else if (a === '--scaffold') { - scaffoldType = expectValue('--scaffold', args[++i]); - } else if (a.startsWith('--scaffold=')) { - scaffoldType = expectInline('--scaffold', a.slice('--scaffold='.length)); - } else if (a === '--workspace') { + } else if (arg.startsWith('--output=')) { + outputPath = expectInline('--output', arg.slice('--output='.length)); + } else if (arg === '--workspace') { workspaceId = expectValue('--workspace', args[++i]); - } else if (a.startsWith('--workspace=')) { - workspaceId = expectInline('--workspace', a.slice('--workspace='.length)); - } else if (a === '--input') { + } else if (arg.startsWith('--workspace=')) { + workspaceId = expectInline('--workspace', arg.slice('--workspace='.length)); + } else if (arg === '--scaffold') { + scaffoldType = expectValue('--scaffold', args[++i]); + } else if (arg.startsWith('--scaffold=')) { + scaffoldType = expectInline('--scaffold', arg.slice('--scaffold='.length)); + } else if (arg === '--input') { addKeyValue('--input', expectValue('--input', args[++i]), inputs); - } else if (a.startsWith('--input=')) { - addKeyValue('--input', expectInline('--input', a.slice('--input='.length)), inputs); - } else if (a === '--seed') { + } else if (arg.startsWith('--input=')) { + addKeyValue('--input', expectInline('--input', arg.slice('--input='.length)), inputs); + } else if (arg === '--seed') { addKeyValue('--seed', expectValue('--seed', args[++i]), seeds); - } else if (a.startsWith('--seed=')) { - addKeyValue('--seed', expectInline('--seed', a.slice('--seed='.length)), seeds); - } else if (a.startsWith('--')) { - throw new Error(`invoke: unknown flag "${a}"`); + } else if (arg.startsWith('--seed=')) { + addKeyValue('--seed', expectInline('--seed', arg.slice('--seed='.length)), seeds); + } else if (arg.startsWith('--')) { + throw new Error(`invoke: unknown flag "${arg}"`); } else if (!personaPath) { - personaPath = path.resolve(a); + personaPath = path.resolve(arg); } else { - throw new Error(`invoke: unexpected positional argument "${a}"`); + throw new Error(`invoke: unexpected positional argument "${arg}"`); } } if (scaffoldType) { - const invalidScaffoldFlags = [ - sawFixture ? '--fixture' : '', + const invalid = [ + fixturePath ? '--fixture' : '', + scheduleName ? '--schedule' : '', + casePath ? '--case' : '', workspaceId ? '--workspace' : '', Object.keys(inputs).length > 0 ? '--input' : '', Object.keys(seeds).length > 0 ? '--seed' : '', - personaPath ? '' : '' + personaPath ? '' : '' ].filter(Boolean); - if (invalidScaffoldFlags.length > 0) { - throw new Error( - `invoke: --scaffold only accepts --output; remove ${invalidScaffoldFlags.join(', ')}` - ); + if (invalid.length > 0) { + throw new Error(`invoke: --scaffold only accepts --output; remove ${invalid.join(', ')}`); } - // Scaffold mode authors a fixture skeleton; no persona/fixture needed. return { scaffold: scaffoldType, ...(outputPath ? { outputPath: path.resolve(outputPath) } : {}) }; } - if (!personaPath) { - throw new Error('invoke: missing persona path. Usage: agentworkforce invoke --fixture '); + + if (!personaPath) throw new Error('invoke: missing agent path'); + const sources = [ + fixturePath ? '--fixture' : '', + scheduleName ? '--schedule' : '', + casePath ? '--case' : '' + ].filter(Boolean); + if (sources.length === 0) { + throw new Error('invoke: choose exactly one event source: --fixture , --schedule , or --case '); } - if (!fixturePath) { - throw new Error('invoke: missing --fixture (a JSON envelope, JSON array, or NDJSON file)'); + if (sources.length > 1) { + throw new Error(`invoke: event sources are mutually exclusive; choose one of ${sources.join(', ')}`); } return { personaPath, - fixturePath: path.resolve(fixturePath), + source: fixturePath + ? { kind: 'fixture', path: path.resolve(fixturePath) } + : scheduleName + ? { kind: 'schedule', name: scheduleName } + : { kind: 'case', path: path.resolve(casePath!) }, ...(outputPath ? { outputPath: path.resolve(outputPath) } : {}), ...(workspaceId ? { workspaceId } : {}), + ...(reads ? { reads } : {}), + ...(model ? { model } : {}), + ...(watch ? { watch: true } : {}), ...(Object.keys(inputs).length > 0 ? { inputs } : {}), ...(Object.keys(seeds).length > 0 ? { seeds } : {}) }; } -function expectValue(flag: string, value: string | undefined): string { - if (value === undefined || value.startsWith('--')) { - throw new Error(`invoke: ${flag} expects a value`); - } - return value; -} - -function expectInline(flag: string, value: string): string { - if (!value) throw new Error(`invoke: ${flag} expects a value`); - return value; -} - -function addKeyValue(flag: string, raw: string, into: Record): void { - const eq = raw.indexOf('='); - if (eq <= 0) { - throw new Error(`invoke: ${flag} expects =; got "${raw}"`); - } - into[raw.slice(0, eq)] = raw.slice(eq + 1); -} - -/** - * Parse fixture file contents into raw envelopes. Accepts a single JSON - * object, a JSON array of objects, or NDJSON (one JSON object per line). - * Validation of envelope semantics happens downstream via the runner's own - * `shimEnvelope`; this only enforces "every entry is an object". - */ -export function parseFixtureEnvelopes(raw: string, label: string): RawGatewayEnvelope[] { +export function parseFixtureEnvelopes(raw: string, label: string): unknown[] { const trimmed = raw.trim(); - if (!trimmed) { - throw new Error(`invoke: fixture ${label} is empty`); - } - + if (!trimmed) throw new Error(`invoke: fixture ${label} is empty`); if (trimmed.startsWith('[')) { - const parsed = parseJson(trimmed, label) as unknown; - if (!Array.isArray(parsed)) { - throw new Error(`invoke: fixture ${label} starts with "[" but is not a JSON array`); - } - return parsed.map((entry, index) => asEnvelopeObject(entry, `${label}[${index}]`)); + const parsed = parseJson(trimmed, label); + if (!Array.isArray(parsed)) throw new Error(`invoke: fixture ${label} starts with "[" but is not a JSON array`); + return parsed; } - if (trimmed.startsWith('{')) { - // Either a single JSON object (possibly pretty-printed across lines) or - // NDJSON. Try whole-file JSON first; fall back to per-line parsing. try { - const single = JSON.parse(trimmed) as unknown; - return [asEnvelopeObject(single, label)]; + return [JSON.parse(trimmed) as unknown]; } catch { - /* fall through to NDJSON */ + return trimmed + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line, index) => parseJson(line, `${label}:${index + 1}`)); } - return trimmed - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line, index) => - asEnvelopeObject(parseJson(line, `${label}:${index + 1}`), `${label}:${index + 1}`) - ); } - - throw new Error( - `invoke: fixture ${label} must be a JSON envelope object, a JSON array, or NDJSON lines` - ); + throw new Error(`invoke: fixture ${label} must be a JSON object, JSON array, or NDJSON lines`); } function parseJson(text: string, label: string): unknown { try { - return JSON.parse(text); - } catch (err) { + return JSON.parse(text) as unknown; + } catch (error) { throw new Error( - `invoke: fixture ${label} is not valid JSON: ${err instanceof Error ? err.message : String(err)}` + `invoke: fixture ${label} is not valid JSON: ${error instanceof Error ? error.message : String(error)}` ); } } -function asEnvelopeObject(value: unknown, label: string): RawGatewayEnvelope { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new Error(`invoke: fixture ${label} must be a JSON object envelope`); +async function runInvokeOnce( + opts: InvokeOptions, + io: RunInvokeIO, + initialState?: LocalPreviewState +): Promise<{ output: RunRecordV2 | RunRecordV2[]; state?: LocalPreviewState }> { + const target = await prepareInvokeTarget(opts.personaPath); + for (const warning of target.warnings) io.stderr(`warn: ${warning}\n`); + + const buildRoot = path.join(path.dirname(target.personaPath), '.workforce', 'invoke-build'); + await mkdir(buildRoot, { recursive: true }); + const buildDir = await mkdtemp(path.join(buildRoot, `${target.compiled.persona.id}-`)); + + try { + const bundle = await bundleStager.stage({ + personaPath: target.personaPath, + persona: target.compiled.persona, + outDir: buildDir + }); + + const seededFiles = initialState ? undefined : await loadSeeds(opts.seeds); + const initialPreviewState = initialState ?? (seededFiles ? { files: seededFiles } : undefined); + const initialPolicy = resolveLocalEffectPolicy({ + ...(opts.reads ? { reads: opts.reads } : {}), + ...(opts.model ? { model: opts.model } : {}), + allowedHttp: resolvePersonaHttpReadRules(target.compiled.persona) + }); + + let output: RunRecordV2 | RunRecordV2[]; + let state: LocalPreviewState | undefined; + if (opts.source.kind === 'case') { + ({ output, state } = await runCaseInvoke( + target.compiled, + bundle.bundlePath, + opts as InvokeOptions & { source: { kind: 'case'; path: string } }, + initialPolicy, + initialPreviewState + )); + } else if (opts.source.kind === 'schedule') { + ({ output, state } = await runScheduleInvoke( + target.compiled, + bundle.bundlePath, + opts as InvokeOptions & { source: { kind: 'schedule'; name: string } }, + initialPolicy, + initialPreviewState + )); + } else { + ({ output, state } = await runFixtureInvoke( + target.compiled, + bundle.bundlePath, + opts as InvokeOptions & { source: { kind: 'fixture'; path: string } }, + initialPolicy, + initialPreviewState + )); + } + + io.stderr(renderHumanSummary(output)); + const json = `${JSON.stringify(output, null, 2)}\n`; + if (opts.outputPath) { + await writeFile(opts.outputPath, json, 'utf8'); + io.stderr(`run record written to ${opts.outputPath}\n`); + } else { + io.stdout(json); + } + process.exitCode = deriveExitCode(output); + return { output, state }; + } finally { + await rm(buildDir, { recursive: true, force: true }); } - return value as RawGatewayEnvelope; } -/** - * Extract the handler from a dynamically imported agent bundle. Mirrors the - * extraction the generated runner.mjs performs (deploy bundle.ts - * renderRunner): defineAgent default export → `.handler`; `{ handler }` - * object → `.handler`; bare function fallback. - */ -export function extractBundleHandler( - userModule: Record, - personaId: string -): WorkforceHandler { - const exported = (userModule.default ?? userModule.handler) as - | { __workforceAgent?: boolean; handler?: unknown } - | ((...args: unknown[]) => unknown) - | undefined; +async function runInvokeWatchLoop( + opts: InvokeOptions, + io: RunInvokeIO, + internals: InvokeInternals +): Promise { + const waitForWatchChange = internals.waitForWatchChange ?? waitForWatchedFilesChange; + let run = await runInvokeOnce(opts, io); + let latest = run.output; + let state = run.state; - let candidate: unknown; - if (exported && typeof exported === 'object' && exported.__workforceAgent) { - candidate = exported.handler; - } else if (exported && typeof exported === 'object' && typeof exported.handler === 'function') { - candidate = exported.handler; - } else { - candidate = exported; + while (true) { + const watchFiles = await collectWatchedFiles(opts); + io.stderr(`watch: waiting for changes across ${watchFiles.length} file(s); press Ctrl+C to stop\n`); + const next = await waitForWatchChange(watchFiles); + if (next === 'stop') return latest; + io.stderr('watch: change detected, rerunning preview...\n'); + run = await runInvokeOnce(opts, io, state); + latest = run.output; + state = run.state; } +} - if (typeof candidate !== 'function') { - throw new Error( - `invoke: ${personaId} did not default-export defineAgent({ ..., handler }). Did you forget \`export default defineAgent(...)\`?` - ); +async function runScheduleInvoke( + compiled: Awaited>['compiled'], + bundlePath: string, + opts: InvokeOptions & { source: { kind: 'schedule'; name: string } }, + policy: EffectPolicyV1, + initialState: LocalPreviewState | undefined +): Promise<{ output: RunRecordV2; state: LocalPreviewState }> { + const schedule = compiled.agent.schedules?.find((entry) => entry.name === opts.source.name); + if (!schedule) { + const valid = compiled.agent.schedules?.map((entry) => entry.name).join(', ') ?? '(none)'; + throw new Error(`invoke: unknown schedule "${opts.source.name}". Declared schedules: ${valid}`); } - return candidate as WorkforceHandler; + const event = decodeEventFrame({ + id: `cron_${schedule.name}`, + workspace: opts.workspaceId ?? 'ws-local', + type: 'cron.tick', + occurredAt: '2026-07-15T09:00:00.000Z', + name: schedule.name, + cron: schedule.cron + }).frame; + const result = await executeLocalRun({ + request: { + schemaVersion: 1, + agent: compiled, + event, + mode: 'preview', + inputs: opts.inputs ?? {}, + policy, + state: { + schemaVersion: 1, + kind: initialState?.files ? 'fixtures' : 'empty', + fidelity: initialState?.files ? 'fixture' : 'simulated' + } + }, + bundlePath, + sourcePath: compiled.handlerEntry, + inputs: opts.inputs, + state: initialState, + sourceFidelity: { + state: initialState?.files ? 'fixture' : 'simulated', + inputs: opts.inputs && Object.keys(opts.inputs).length > 0 ? 'current' : 'unavailable', + http: policy.reads === 'live' ? 'current' : 'unavailable', + model: policy.model === 'live' ? 'current' : policy.model === 'stub' ? 'simulated' : 'unavailable' + } + }); + return { output: result.record, state: result.state }; } -export interface RunInvokeIO { - stdout: (text: string) => void; - stderr: (text: string) => void; +async function runFixtureInvoke( + compiled: Awaited>['compiled'], + bundlePath: string, + opts: InvokeOptions & { source: { kind: 'fixture'; path: string } }, + policy: EffectPolicyV1, + initialState: LocalPreviewState | undefined +): Promise<{ output: RunRecordV2 | RunRecordV2[]; state: LocalPreviewState | undefined }> { + const raw = await readFile(opts.source.path, 'utf8'); + const entries = parseFixtureEnvelopes(raw, path.basename(opts.source.path)); + let state: LocalPreviewState = initialState ?? {}; + const records: RunRecordV2[] = []; + for (const entry of entries) { + const normalized = normalizeFixtureEntry(entry); + const { event, historicalState, stateFidelity, replayProvenance } = normalized; + const replaySource = stateFidelity !== undefined; + const inputs = normalized.historicalInputs ? { ...normalized.historicalInputs, ...(opts.inputs ?? {}) } : (opts.inputs ?? {}); + const result = await executeLocalRun({ + request: { + schemaVersion: 1, + agent: compiled, + event, + mode: replaySource ? 'replay' : 'preview', + inputs, + policy, + state: { + schemaVersion: 1, + kind: replaySource ? 'replay' : initialState?.files ? 'fixtures' : 'empty', + fidelity: stateFidelity ?? (initialState?.files ? 'fixture' : 'simulated') + } + }, + bundlePath, + sourcePath: compiled.handlerEntry, + inputs, + state: mergeState(state, historicalState), + ...(replayProvenance ? { replayProvenance } : {}), + sourceFidelity: normalized.sourceFidelity ?? { + state: stateFidelity ?? (initialState?.files ? 'fixture' : 'simulated'), + inputs: normalized.historicalInputs + ? Object.keys(opts.inputs ?? {}).length > 0 ? 'current' : 'historical' + : opts.inputs && Object.keys(opts.inputs).length > 0 ? 'current' : 'unavailable', + http: replaySource ? 'unavailable' : policy.reads === 'live' ? 'current' : 'unavailable', + model: replaySource + ? 'unavailable' + : policy.model === 'live' + ? 'current' + : policy.model === 'stub' + ? 'simulated' + : 'unavailable' + } + }); + state = result.state; + records.push(result.record); + } + return { output: records.length === 1 ? records[0] : records, state }; } -const defaultIO: RunInvokeIO = { - stdout: (text) => process.stdout.write(text), - stderr: (text) => process.stderr.write(text) -}; +async function runCaseInvoke( + compiled: Awaited>['compiled'], + bundlePath: string, + opts: InvokeOptions & { source: { kind: 'case'; path: string } }, + basePolicy: EffectPolicyV1, + initialState: LocalPreviewState | undefined +): Promise<{ output: RunRecordV2; state: LocalPreviewState }> { + const parsedCase = await parseInvokeCase(opts.source.path); + const httpFixtures = await loadCaseHttpFixtures(parsedCase); + const modelFixtures = await loadCaseModelFixtures(parsedCase); + const policy = resolveLocalEffectPolicy({ + ...basePolicy, + ...parsedCase.policy, + allowedHttp: mergeAllowedHttpRules( + basePolicy.allowedHttp, + httpFixtures.map((fixture) => ({ method: fixture.method, urlGlob: fixture.match })) + ) + }); + const inputs = { ...parsedCase.inputs, ...(opts.inputs ?? {}) }; + let state: LocalPreviewState = initialState ?? {}; + const records: RunRecordV2[] = []; + const allLogs: string[] = []; -/** - * `agentworkforce invoke` entry. Stages the agent bundle next to the - * persona (under `.workforce/invoke-build/`, mirroring deploy's build dir - * so the externalized `@agentworkforce/runtime` import resolves from the - * persona's project tree), imports the handler, replays the fixture through - * `simulateInvocation`, prints a human summary to stderr and the - * machine-readable record to stdout (or `--output`). - * - * Sets `process.exitCode` (never calls `process.exit`) so streams flush - * and tests can call this directly. - */ -export async function runInvoke( - args: readonly string[], - io: RunInvokeIO = defaultIO -): Promise { - let opts: ParsedInvokeArgs; - try { - opts = parseInvokeArgs(args); - } catch (err) { - io.stderr(`${err instanceof Error ? err.message : String(err)}\n\n${INVOKE_USAGE}`); + for (const event of parsedCase.events) { + const result = await executeLocalRun({ + request: { + schemaVersion: 1, + agent: compiled, + event: { + ...event, + workspace: opts.workspaceId ?? event.workspace ?? 'ws-local' + }, + mode: 'preview', + inputs, + policy, + state: { + schemaVersion: 1, + kind: 'fixtures', + fidelity: 'fixture' + } + }, + bundlePath, + sourcePath: compiled.handlerEntry, + inputs, + state, + httpFixtures, + modelFixtures, + sourceFidelity: { + state: 'fixture', + inputs: Object.keys(parsedCase.inputs).length > 0 + ? Object.keys(opts.inputs ?? {}).length > 0 ? 'current' : 'fixture' + : Object.keys(opts.inputs ?? {}).length > 0 ? 'current' : 'unavailable', + http: httpFixtures.length > 0 ? 'fixture' : policy.reads === 'live' ? 'current' : 'unavailable', + model: policy.model === 'live' + ? 'current' + : policy.model === 'stub' + ? 'simulated' + : modelFixtures.length > 0 + ? 'fixture' + : 'unavailable' + } + }); + state = result.state; + records.push(result.record); + allLogs.push(...result.logs); + } + + const aggregate = aggregateCaseRecords(parsedCase, records, allLogs); + const failures = evaluateCaseAssertions(parsedCase, aggregate, allLogs); + if (failures.length > 0) { process.exitCode = 1; - return undefined; + throw new Error(failures.join('\n')); } - if ('help' in opts) { - io.stdout(INVOKE_USAGE); - return undefined; + return { output: aggregate, state }; +} + +function aggregateCaseRecords( + parsedCase: ParsedInvokeCase, + records: RunRecordV2[], + logs: string[] +): RunRecordV2 { + const final = records[records.length - 1]!; + const failed = records.some((record) => record.status !== 'succeeded'); + return { + ...final, + status: failed ? 'failed' : 'succeeded', + actions: records.flatMap((record) => record.actions), + trace: records.flatMap((record) => record.trace), + stateDiff: final.stateDiff, + extensions: { + ...(typeof final.extensions === 'object' && final.extensions !== null ? final.extensions as Record : {}), + caseId: parsedCase.id, + logs, + turns: records.map((record) => ({ + runId: record.runId, + status: record.status, + eventContract: record.eventContract + })) + } + }; +} + +function evaluateCaseAssertions( + parsedCase: ParsedInvokeCase, + record: RunRecordV2, + logs: string[] +): string[] { + const failures: string[] = []; + const joinedLogs = logs.join('\n'); + + if (parsedCase.expect.status && record.status !== parsedCase.expect.status) { + failures.push(`$.expect.status: expected ${parsedCase.expect.status}, got ${record.status}`); } - if ('scaffold' in opts) { - const { fixture, warnings } = scaffoldFixture(opts.scaffold); - for (const warning of warnings) io.stderr(`warn: ${warning}\n`); - const text = `${JSON.stringify(fixture, null, 2)}\n`; - if (opts.outputPath) { - await writeFile(opts.outputPath, text, 'utf8'); - io.stderr(`fixture skeleton written to ${opts.outputPath}\n`); - } else { - io.stdout(text); + if (parsedCase.expect.eventSource) { + const actual = deriveEventSource(record.eventContract); + if (!actual || actual !== parsedCase.expect.eventSource) { + failures.push(`$.expect.eventSource: expected ${parsedCase.expect.eventSource}, got ${actual ?? 'unknown'}`); } - return undefined; } + for (const expected of parsedCase.expect.logsContain) { + if (!joinedLogs.includes(expected)) failures.push(`$.expect.logsContain: missing "${expected}"`); + } + for (const expected of parsedCase.expect.effectsContain) { + if (!record.actions.some((action) => action.kind === expected)) { + failures.push(`$.expect.effectsContain: missing effect kind "${expected}"`); + } + } + for (const expected of parsedCase.expect.providerActions) { + const matched = record.actions.find((action) => matchesExpectedProviderAction(action, expected)); + if (!matched) { + failures.push(`$.expect.providerActions: missing ${expected.provider}.${expected.resource}`); + } + } + return failures; +} - try { - return await runInvokeWithOptions(opts, io); - } catch (err) { - io.stderr(`invoke: ${err instanceof Error ? err.message : String(err)}\n`); - process.exitCode = 1; - return undefined; +export function matchesExpectedProviderAction( + action: RunRecordV2['actions'][number], + expected: ParsedCaseExpectationProviderAction +): boolean { + const data = asRecord(action.data); + if (action.kind !== 'provider.write') return false; + if (action.provider !== expected.provider) return false; + const threadedSlackReplyMatch = + expected.provider === 'slack' && + expected.resource === 'messages' && + expected.threaded === true && + action.resource === 'replies'; + if (action.resource !== expected.resource && !threadedSlackReplyMatch) return false; + if (expected.channel) { + const channel = typeof data?.parameters === 'object' && data.parameters !== null + ? String((data.parameters as Record).channelId ?? (data.parameters as Record).channel ?? '') + : ''; + const pathValue = typeof data?.path === 'string' ? data.path : ''; + if (channel !== expected.channel && !pathValue.includes(`/${encodeURIComponent(expected.channel)}/`)) return false; + } + if (expected.threaded === true) { + const body = asRecord(data?.body); + const threaded = + typeof body?.parentRef === 'string' || + typeof body?.thread_ts === 'string' || + action.resource === 'replies' || + (typeof data?.path === 'string' && data.path.includes('/replies/')) || + (typeof data?.parameters === 'object' && + data.parameters !== null && + typeof (data.parameters as Record).messageTs === 'string'); + if (!threaded) return false; + } + if (expected.textContains?.length) { + const body = asRecord(data?.body); + const text = typeof body?.text === 'string' ? body.text : typeof data?.text === 'string' ? data.text : ''; + if (!expected.textContains.every((snippet) => text.includes(snippet))) return false; } + return true; } -async function runInvokeWithOptions( - opts: InvokeOptions, - io: RunInvokeIO -): Promise { - const preflight = await preflightPersona(opts.personaPath); - for (const warning of preflight.warnings) { - io.stderr(`warn: ${warning}\n`); - } - - // Stage inside the persona's tree (not os.tmpdir) — the bundle leaves - // `@agentworkforce/runtime` external, so Node must be able to walk up - // from the bundle to a node_modules that provides it, exactly like - // deploy's `.workforce/build/` convention. - const buildRoot = path.join(preflight.personaDir, '.workforce', 'invoke-build'); - await mkdir(buildRoot, { recursive: true }); - const buildDir = await mkdtemp(path.join(buildRoot, `${preflight.persona.id}-`)); +export function deriveEventSource(eventContract: string): string | undefined { + const type = eventContract.split('@', 1)[0] ?? ''; + if (!type) return undefined; + if (type === 'cron.tick') return 'cron'; + if (type === 'startup') return 'startup'; + const dot = type.indexOf('.'); + if (dot <= 0) return undefined; + const prefix = type.slice(0, dot); + return prefix || undefined; +} - try { - const bundle = await bundleStager.stage({ - personaPath: preflight.personaPath, - persona: preflight.persona, - outDir: buildDir - }); +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Record + : null; +} - // Cache-bust the import so repeated invokes in one process (tests, - // watch flows) load the freshly staged bundle, not the ESM cache. - const bundleUrl = `${pathToFileURL(bundle.bundlePath).href}?invoke=${randomUUID()}`; - const userModule = (await import(bundleUrl)) as Record; - const handler = extractBundleHandler(userModule, preflight.persona.id); +async function loadCaseHttpFixtures(parsedCase: ParsedInvokeCase): Promise { + return await Promise.all(parsedCase.http.map(async (fixture) => ({ + method: fixture.method, + match: fixture.match, + body: await readFile(fixture.file, 'utf8'), + sourcePath: fixture.file + }))); +} - const fixtureRaw = await readFile(opts.fixturePath, 'utf8').catch((err: NodeJS.ErrnoException) => { - if (err.code === 'ENOENT') { - throw new Error(`fixture not found at ${opts.fixturePath}`); - } - throw err; - }); - const envelopes = parseFixtureEnvelopes(fixtureRaw, path.basename(opts.fixturePath)); +async function loadCaseModelFixtures(parsedCase: ParsedInvokeCase): Promise { + return await Promise.all(parsedCase.model.map(async (fixture) => ({ + output: fixture.output ?? await readFile(fixture.file!, 'utf8'), + ...(fixture.file ? { sourcePath: fixture.file } : {}) + }))); +} - const seeds = await loadSeeds(opts.seeds); +function normalizeFixtureEntry(entry: unknown): { + event: ReturnType['frame']; + historicalState?: LocalPreviewState; + stateFidelity?: 'historical' | 'unavailable'; + replayProvenance?: Record; + historicalInputs?: Record; + sourceFidelity?: { + state: 'historical' | 'current' | 'fixture' | 'simulated' | 'unavailable'; + inputs: 'historical' | 'current' | 'fixture' | 'simulated' | 'unavailable'; + http: 'historical' | 'current' | 'fixture' | 'simulated' | 'unavailable'; + model: 'historical' | 'current' | 'fixture' | 'simulated' | 'unavailable'; + }; +} { + const replayBundle = unwrapReplayBundle(entry); + if (replayBundle) return replayBundle; - const result = await simulateInvocation({ - persona: preflight.persona, - handler, - envelopes, - ...(opts.workspaceId ? { workspaceId: opts.workspaceId } : {}), - ...(opts.inputs ? { agent: { inputValues: opts.inputs } } : {}), - ...(seeds ? { files: seeds } : {}) - }); + const record = asRecord(entry); + if (record && record.event !== undefined) { + const state = asRecord(record.state); + const replayProvenance = extractReplayProvenance(record); + return { + event: decodeEventFrame(record.event).frame, + ...(state ? { historicalState: normalizeHistoricalState(state), stateFidelity: 'historical' as const } : {}), + ...(!state && Object.hasOwn(record, 'state') ? { stateFidelity: 'unavailable' as const } : {}), + ...(replayProvenance ? { replayProvenance } : {}) + }; + } + return { event: decodeEventFrame(entry).frame }; +} - io.stderr(renderHumanSummary(result)); +function unwrapReplayBundle(entry: unknown): { + event: ReturnType['frame']; + historicalState?: LocalPreviewState; + stateFidelity?: 'historical' | 'unavailable'; + replayProvenance?: Record; + historicalInputs?: Record; + sourceFidelity?: { + state: 'historical' | 'current' | 'fixture' | 'simulated' | 'unavailable'; + inputs: 'historical' | 'current' | 'fixture' | 'simulated' | 'unavailable'; + http: 'historical' | 'current' | 'fixture' | 'simulated' | 'unavailable'; + model: 'historical' | 'current' | 'fixture' | 'simulated' | 'unavailable'; + }; +} | null { + const bundle = asRecord(entry); + const files = asRecord(bundle?.files); + const manifest = asRecord(bundle?.manifest); + const eventFile = asRecord(files?.['event.json']); + if (!bundle || bundle.schemaVersion !== 1 || !files || !eventFile || eventFile.content === undefined) { + return null; + } - const json = `${JSON.stringify(result, null, 2)}\n`; - if (opts.outputPath) { - await writeFile(opts.outputPath, json, 'utf8'); - io.stderr(`run record written to ${opts.outputPath}\n`); - } else { - io.stdout(json); + const stateFile = asRecord(files['state/manifest.json']); + const stateFidelity = normalizeReplayFidelity( + stateFile?.fidelity, + asRecord(asRecord(manifest?.files)?.['state/manifest.json'])?.fidelity + ); + const inputsFile = asRecord(files['inputs.json']) ?? asRecord(files['inputs/manifest.json']); + const inputsFidelity = normalizeReplayFidelity( + inputsFile?.fidelity, + asRecord(asRecord(manifest?.files)?.['inputs.json'])?.fidelity, + asRecord(asRecord(manifest?.files)?.['inputs/manifest.json'])?.fidelity + ); + const historicalState = stateFidelity === 'historical' + ? normalizeHistoricalState(asRecord(stateFile?.content) ?? {}) + : undefined; + const historicalInputs = inputsFidelity === 'historical' + ? normalizeHistoricalInputs(asRecord(inputsFile?.content) ?? {}) + : undefined; + const replayProvenance = extractReplayBundleProvenance(manifest, files); + + return { + event: decodeEventFrame(eventFile.content).frame, + ...(historicalState ? { historicalState } : {}), + ...(stateFidelity ? { stateFidelity } : {}), + ...(historicalInputs ? { historicalInputs } : {}), + ...(replayProvenance ? { replayProvenance } : {}) + , + sourceFidelity: { + state: stateFidelity ?? 'unavailable', + inputs: historicalInputs ? 'historical' : inputsFidelity ?? 'unavailable', + http: 'unavailable', + model: 'unavailable' } + }; +} - process.exitCode = result.exitCode; - return result; - } finally { - await rm(buildDir, { recursive: true, force: true }); +function normalizeReplayFidelity(...values: unknown[]): 'historical' | 'unavailable' | undefined { + for (const value of values) { + if (value === 'historical' || value === 'unavailable') return value; } + return undefined; +} + +function normalizeHistoricalState(value: Record): LocalPreviewState { + const files = asRecord(value.files); + return { + ...(files + ? { + files: Object.fromEntries( + Object.entries(files).filter((entry): entry is [string, string] => typeof entry[1] === 'string') + ) + } + : {}) + }; +} + +function normalizeHistoricalInputs(value: Record): Record { + return Object.fromEntries( + Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string') + ); +} + +function extractReplayBundleProvenance( + manifest: Record | null, + files: Record +): Record | undefined { + const provenance: Record = {}; + const runFile = asRecord(files['run.json']); + const run = asRecord(runFile?.content); + const runId = typeof manifest?.runId === 'string' + ? manifest.runId + : typeof run?.id === 'string' + ? run.id + : typeof run?.runId === 'string' + ? run.runId + : undefined; + if (runId) provenance.sourceRunId = runId; + const eventId = typeof manifest?.eventId === 'string' ? manifest.eventId : undefined; + if (eventId) provenance.sourceEventId = eventId; + return Object.keys(provenance).length > 0 ? provenance : undefined; +} + +function extractReplayProvenance(record: Record): Record | undefined { + const provenance: Record = {}; + const run = asRecord(record.run); + const runId = typeof record.runId === 'string' + ? record.runId + : typeof run?.id === 'string' + ? run.id + : typeof run?.runId === 'string' + ? run.runId + : undefined; + if (runId) provenance.sourceRunId = runId; + const eventId = typeof record.eventId === 'string' ? record.eventId : undefined; + if (eventId) provenance.sourceEventId = eventId; + return Object.keys(provenance).length > 0 ? provenance : undefined; +} + +function mergeState( + base: LocalPreviewState, + overlay: LocalPreviewState | undefined +): LocalPreviewState { + if (!overlay) return base; + return { + ...(base.files || overlay.files ? { files: { ...(base.files ?? {}), ...(overlay.files ?? {}) } } : {}), + ...(base.memory || overlay.memory ? { memory: [...(overlay.memory ?? base.memory ?? [])] } : {}), + ...(base.transport || overlay.transport ? { transport: overlay.transport ?? base.transport } : {}), + ...(base.model || overlay.model ? { model: overlay.model ?? base.model } : {}) + }; } async function loadSeeds( @@ -388,44 +840,172 @@ async function loadSeeds( if (!seeds) return undefined; const loaded: Record = {}; for (const [vfsPath, localFile] of Object.entries(seeds)) { - loaded[vfsPath] = await readFile(path.resolve(localFile), 'utf8').catch( - (err: NodeJS.ErrnoException) => { - if (err.code === 'ENOENT') { - throw new Error(`--seed ${vfsPath}: local file not found at ${localFile}`); - } - throw err; - } - ); + loaded[vfsPath] = await readFile(path.resolve(localFile), 'utf8').catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') throw new Error(`--seed ${vfsPath}: local file not found at ${localFile}`); + throw error; + }); } return loaded; } -export function renderHumanSummary(result: SimulationResult): string { - const lines: string[] = []; - lines.push(`simulation: ${result.summary.total} run(s) — ${result.summary.succeeded} ok, ${result.summary.failed} failed${result.summary.unsupported > 0 ? `, ${result.summary.unsupported} unsupported envelope(s) skipped` : ''}`); - for (const run of result.runs) { - const head = ` [${run.status === 'succeeded' ? 'ok' : 'FAIL'}] ${run.trigger.eventSource}/${run.trigger.kind} ${run.runId} (${run.durationMs}ms, ${run.simulation.sideEffects.length} side effect(s) recorded)`; - lines.push(head); - if (run.summary) lines.push(` summary: ${run.summary}`); - if (run.error) lines.push(` error: ${run.error}`); +async function collectWatchedFiles(opts: InvokeOptions): Promise { + const files = await collectRelativeDependencyFiles(opts.personaPath); + if (opts.source.kind === 'fixture') { + files.add(opts.source.path); + } else if (opts.source.kind === 'case') { + files.add(opts.source.path); + const parsedCase = await parseInvokeCase(opts.source.path); + for (const fixture of parsedCase.http) files.add(fixture.file); + for (const fixture of parsedCase.model) { + if (fixture.file) files.add(fixture.file); + } + } + for (const localFile of Object.values(opts.seeds ?? {})) { + files.add(path.resolve(localFile)); + } + return [...files].sort(); +} + +async function collectRelativeDependencyFiles( + entryPath: string, + seen = new Set() +): Promise> { + const absPath = path.resolve(entryPath); + if (seen.has(absPath)) return seen; + seen.add(absPath); + + let sourceText: string; + try { + sourceText = await readFile(absPath, 'utf8'); + } catch { + return seen; } - for (const skipped of result.unsupported) { - lines.push(` [skip] unsupported envelope ${skipped.id} (${skipped.type})`); + + for (const specifier of extractLocalSpecifiers(sourceText)) { + const resolved = await resolveRelativeModule(absPath, specifier); + if (resolved) await collectRelativeDependencyFiles(resolved, seen); + } + return seen; +} + +function extractLocalSpecifiers(sourceText: string): string[] { + const matches = new Set(); + const patterns = [ + /from\s+['"]([^'"]+)['"]/g, + /import\s+['"]([^'"]+)['"]/g, + /import\(\s*['"]([^'"]+)['"]\s*\)/g, + /require\(\s*['"]([^'"]+)['"]\s*\)/g + ]; + for (const pattern of patterns) { + for (const match of sourceText.matchAll(pattern)) { + const specifier = match[1]; + if (specifier?.startsWith('.')) matches.add(specifier); + } + } + return [...matches]; +} + +async function resolveRelativeModule(fromPath: string, specifier: string): Promise { + const basePath = path.resolve(path.dirname(fromPath), specifier); + for (const candidate of moduleResolutionCandidates(basePath)) { + try { + const details = await stat(candidate); + if (details.isFile()) return candidate; + } catch { + continue; + } + } + return undefined; +} + +function moduleResolutionCandidates(basePath: string): string[] { + const directExt = path.extname(basePath); + const extensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json']; + const candidates = [basePath]; + if (!directExt) { + for (const ext of extensions) candidates.push(`${basePath}${ext}`); + for (const ext of extensions) candidates.push(path.join(basePath, `index${ext}`)); + } + return candidates; +} + +async function waitForWatchedFilesChange(files: readonly string[]): Promise<'change'> { + const baseline = await snapshotWatchedFiles(files); + for (;;) { + await new Promise((resolve) => setTimeout(resolve, 500)); + const current = await snapshotWatchedFiles(files); + if (!watchedSnapshotsEqual(baseline, current)) return 'change'; + } +} + +async function snapshotWatchedFiles(files: readonly string[]): Promise> { + const snapshot = new Map(); + for (const file of files) { + try { + const details = await stat(file); + snapshot.set(file, `${details.mtimeMs}:${details.size}`); + } catch { + snapshot.set(file, 'missing'); + } + } + return snapshot; +} + +function watchedSnapshotsEqual( + left: ReadonlyMap, + right: ReadonlyMap +): boolean { + if (left.size !== right.size) return false; + for (const [file, value] of left) { + if (right.get(file) !== value) return false; + } + return true; +} + +function deriveExitCode(output: RunRecordV2 | RunRecordV2[]): 0 | 1 { + return Array.isArray(output) + ? output.some((record) => record.status !== 'succeeded') ? 1 : 0 + : output.status === 'succeeded' ? 0 : 1; +} + +export function renderHumanSummary(output: RunRecordV2 | RunRecordV2[]): string { + const records = Array.isArray(output) ? output : [output]; + const failed = records.filter((record) => record.status !== 'succeeded').length; + const lines = [`preview: ${records.length} run(s) — ${records.length - failed} ok, ${failed} failed`]; + for (const record of records) { + lines.push(` [${record.status === 'succeeded' ? 'ok' : 'FAIL'}] ${record.eventContract} ${record.runId} (${record.actions.length} action(s))`); } return `${lines.join('\n')}\n`; } -/** - * Cold-start fixture authoring (workforce#189): emit a RawGatewayEnvelope - * skeleton for an event type before any real fire exists. The frame is - * filled in; for provider events the `resource` payload shape is decided by - * adapter normalization + cloud's buildEnvelope and CANNOT be guessed here, - * so it is left as an explicit TODO hole — prefer - * `agentworkforce runs export --fixture …` once a real fire exists. - * - * `` is validated against KNOWN_TRIGGER_CATALOG with the same - * warn-don't-block stance as lintTriggers. - */ +function expectValue(flag: string, value: string | undefined): string { + if (value === undefined || value.startsWith('--')) throw new Error(`invoke: ${flag} expects a value`); + return value; +} + +function expectInline(flag: string, value: string): string { + if (!value) throw new Error(`invoke: ${flag} expects a value`); + return value; +} + +function addKeyValue(flag: string, raw: string, into: Record): void { + const eq = raw.indexOf('='); + if (eq <= 0) throw new Error(`invoke: ${flag} expects =; got "${raw}"`); + into[raw.slice(0, eq)] = raw.slice(eq + 1); +} + +function expectEnum( + flag: string, + value: string | undefined, + allowed: T +): T[number] { + const text = expectValue(flag, value); + if (!(allowed as readonly string[]).includes(text)) { + throw new Error(`invoke: ${flag} must be one of ${allowed.join(', ')}`); + } + return text as T[number]; +} + export function scaffoldFixture(type: string): { fixture: Record; warnings: string[]; @@ -435,12 +1015,8 @@ export function scaffoldFixture(type: string): { if (type === 'cron.tick' || type.startsWith('cron.')) { if (type !== 'cron.tick') { - // Preserve what was asked for (a silent rewrite would scaffold a - // DIFFERENT event type than requested) but warn: the gateway - // delivers schedule fires as `cron.tick`, and the runner shim - // treats any `cron.*` as a cron event. warnings.push( - `the gateway delivers schedule fires as "cron.tick"; preserving requested type "${type}" (the runner shim still dispatches any cron.* as a cron event)` + `the gateway delivers schedule fires as "cron.tick"; preserving requested type "${type}" for authoring` ); } return { @@ -449,39 +1025,13 @@ export function scaffoldFixture(type: string): { workspace: 'ws-local', type, occurredAt, - name: 'TODO: your schedule name (persona schedules[].name)', + name: 'TODO: declared schedule name', cron: '0 9 * * 1' }, warnings }; } - const firstDot = type.indexOf('.'); - const provider = firstDot > 0 ? type.slice(0, firstDot) : ''; - const eventName = firstDot > 0 ? type.slice(firstDot + 1) : ''; - if (!provider || !eventName) { - warnings.push( - `"${type}" is not a recognized envelope type shape (expected "cron.tick" or "."); emitting a provider-style skeleton anyway` - ); - } else { - const catalog = KNOWN_TRIGGER_CATALOG as Record; - const events = catalog[provider]; - if (events === undefined) { - warnings.push( - `provider "${provider}" is not in KNOWN_TRIGGER_CATALOG (known: ${Object.keys(catalog).join(', ')}); scaffolding anyway` - ); - } else { - const known = Array.isArray(events) - ? events.includes(eventName) - : typeof events === 'object' && events !== null && eventName in (events as Record); - if (!known) { - warnings.push( - `event "${eventName}" is not a known ${provider} trigger in KNOWN_TRIGGER_CATALOG; scaffolding anyway` - ); - } - } - } - return { fixture: { id: 'evt_local_1', @@ -490,8 +1040,7 @@ export function scaffoldFixture(type: string): { occurredAt, resource: { TODO: - 'the provider payload shape is decided by adapter normalization + cloud buildEnvelope; ' + - 'export a real fire with `agentworkforce runs export --fixture event.json` to get the exact shape' + 'export a real run with `agentworkforce runs export --bundle replay.json` or `--fixture event.json` to capture the exact payload shape' } }, warnings diff --git a/packages/cli/src/invoke/case-file.ts b/packages/cli/src/invoke/case-file.ts new file mode 100644 index 00000000..2545fb7f --- /dev/null +++ b/packages/cli/src/invoke/case-file.ts @@ -0,0 +1,292 @@ +import path from 'node:path'; +import { readFile } from 'node:fs/promises'; +import YAML from 'yaml'; +import { decodeEventFrame } from '@agentworkforce/events'; +import type { EventFrameV1 } from '@agentworkforce/events'; +import type { EffectPolicyV1 } from '@agentworkforce/runtime'; + +export interface ParsedCaseHttpFixture { + method: string; + match: string; + file: string; +} + +export interface ParsedCaseExpectationProviderAction { + provider: string; + resource: string; + channel?: string; + threaded?: boolean; + textContains?: string[]; +} + +export interface ParsedCaseExpectation { + status?: 'succeeded' | 'failed'; + eventSource?: string; + logsContain: string[]; + effectsContain: string[]; + providerActions: ParsedCaseExpectationProviderAction[]; +} + +export interface ParsedCaseModelFixture { + output?: string; + file?: string; +} + +export interface ParsedInvokeCase { + schemaVersion: 1; + id: string; + agent?: string; + kind: 'scheduled' | 'chat'; + events: EventFrameV1[]; + inputs: Record; + policy: Partial; + http: ParsedCaseHttpFixture[]; + model: ParsedCaseModelFixture[]; + expect: ParsedCaseExpectation; + casePath: string; +} + +export async function parseInvokeCase(casePath: string): Promise { + const absPath = path.resolve(casePath); + const raw = await readFile(absPath, 'utf8'); + const parsed = YAML.parse(raw) as unknown; + return normalizeCase(absPath, parsed); +} + +function normalizeCase(casePath: string, value: unknown): ParsedInvokeCase { + const root = expectRecord(value, '$'); + assertOnlyKeys(root, '$', [ + 'schemaVersion', + 'id', + 'agent', + 'kind', + 'event', + 'turns', + 'inputs', + 'policy', + 'http', + 'model', + 'expect' + ]); + if (root.schemaVersion !== 1) throw new Error('$.schemaVersion: expected 1'); + const id = expectString(root.id, '$.id'); + const kind = expectEnum(root.kind, '$.kind', ['scheduled', 'chat'] as const); + const eventValue = root.event; + const turnsValue = root.turns; + if ((eventValue === undefined) === (turnsValue === undefined)) { + throw new Error('case: provide exactly one of $.event or $.turns'); + } + + const events = eventValue !== undefined + ? [normalizeCaseEvent(eventValue, '$.event')] + : expectArray(turnsValue, '$.turns').map((entry, index) => normalizeCaseEvent(entry, `$.turns[${index}]`)); + + return { + schemaVersion: 1, + id, + ...(typeof root.agent === 'string' ? { agent: root.agent } : {}), + kind, + events, + inputs: normalizeStringMap(root.inputs, '$.inputs'), + policy: normalizePolicy(root.policy, '$.policy'), + http: normalizeHttpFixtures(root.http, casePath), + model: normalizeModelFixtures(root.model, casePath), + expect: normalizeExpectation(root.expect, '$.expect'), + casePath + }; +} + +function normalizeCaseEvent(value: unknown, pathLabel: string): EventFrameV1 { + const record = expectRecord(value, pathLabel); + const type = expectString(record.type ?? (typeof record.schedule === 'string' ? 'cron.tick' : undefined), `${pathLabel}.type`); + + if (type === 'cron.tick') { + assertOnlyKeys(record, pathLabel, ['type', 'schedule', 'name', 'cron']); + const name = expectString(record.schedule ?? record.name, `${pathLabel}.schedule`); + const cron = optionalString(record.cron); + return decodeEventFrame({ + id: `${name}_${Math.random().toString(16).slice(2, 10)}`, + workspace: 'ws-local', + type: 'cron.tick', + occurredAt: '2026-07-15T09:00:00.000Z', + name, + ...(cron ? { cron } : {}) + }).frame; + } + + if (type.startsWith('slack.')) { + assertOnlyKeys(record, pathLabel, ['type', 'resource', 'channel', 'ts', 'thread_ts', 'threadTs', 'text', 'user']); + const resource = expectRecord(record.resource ?? record, `${pathLabel}.resource`); + if (record.resource !== undefined) { + assertOnlyKeys(resource, `${pathLabel}.resource`, ['channel', 'ts', 'thread_ts', 'threadTs', 'text', 'user']); + } + const channel = expectString(resource.channel ?? record.channel, `${pathLabel}.channel`); + const ts = expectString(resource.ts ?? record.ts, `${pathLabel}.ts`); + const threadTs = optionalString(resource.thread_ts ?? resource.threadTs ?? record.thread_ts ?? record.threadTs); + const text = expectString(resource.text ?? record.text, `${pathLabel}.text`); + const user = expectString(resource.user ?? record.user, `${pathLabel}.user`); + const token = ts.replace(/\./gu, '_'); + return decodeEventFrame({ + schemaVersion: 1, + id: `slack_${token}`, + workspace: 'ws-local', + type: 'slack.message.created', + contractVersion: 1, + occurredAt: '2026-07-15T09:00:00.000Z', + attempt: 1, + resource: { + path: `/slack/channels/${encodeURIComponent(channel)}/messages/${token}.json`, + kind: 'slack.message', + id: ts, + provider: 'slack' + }, + summary: { title: text.slice(0, 120), actor: user }, + message: { + channel, + messageId: ts, + ...(threadTs ? { threadId: threadTs } : {}) + }, + payload: { + channel, + ts, + ...(threadTs ? { thread_ts: threadTs } : {}), + text, + user, + eventType: type + } + }).frame; + } + + throw new Error(`${pathLabel}.type: unsupported case event type "${type}"`); +} + +function normalizePolicy(value: unknown, pathLabel: string): Partial { + if (value === undefined) return {}; + const record = expectRecord(value, pathLabel); + assertOnlyKeys(record, pathLabel, ['reads', 'writes', 'model', 'shell', 'compose']); + const policy: Partial = {}; + if (record.reads !== undefined) policy.reads = expectEnum(record.reads, `${pathLabel}.reads`, ['deny', 'fixtures', 'live'] as const); + if (record.writes !== undefined) policy.writes = expectEnum(record.writes, `${pathLabel}.writes`, ['deny', 'preview', 'sandbox', 'live'] as const); + if (record.model !== undefined) policy.model = expectEnum(record.model, `${pathLabel}.model`, ['stub', 'fixture', 'live'] as const); + if (record.shell !== undefined) policy.shell = expectEnum(record.shell, `${pathLabel}.shell`, ['deny', 'simulate', 'sandbox', 'live'] as const); + if (record.compose !== undefined) policy.compose = expectEnum(record.compose, `${pathLabel}.compose`, ['deny', 'preview', 'sandbox', 'live'] as const); + return policy; +} + +function normalizeHttpFixtures(value: unknown, casePath: string): ParsedCaseHttpFixture[] { + if (value === undefined) return []; + return expectArray(value, '$.http').map((entry, index) => { + const record = expectRecord(entry, `$.http[${index}]`); + assertOnlyKeys(record, `$.http[${index}]`, ['method', 'match', 'file']); + return { + method: expectString(record.method, `$.http[${index}].method`), + match: expectString(record.match, `$.http[${index}].match`), + file: path.resolve(path.dirname(casePath), expectString(record.file, `$.http[${index}].file`)) + }; + }); +} + +function normalizeModelFixtures(value: unknown, casePath: string): ParsedCaseModelFixture[] { + if (value === undefined) return []; + return expectArray(value, '$.model').map((entry, index) => { + const record = expectRecord(entry, `$.model[${index}]`); + assertOnlyKeys(record, `$.model[${index}]`, ['output', 'file']); + const output = optionalString(record.output); + const file = optionalString(record.file); + if (!output && !file) { + throw new Error(`$.model[${index}]: expected one of output or file`); + } + if (output && file) { + throw new Error(`$.model[${index}]: choose exactly one of output or file`); + } + return { + ...(output ? { output } : {}), + ...(file ? { file: path.resolve(path.dirname(casePath), file) } : {}) + }; + }); +} + +function normalizeExpectation(value: unknown, pathLabel: string): ParsedCaseExpectation { + if (value === undefined) { + return { logsContain: [], effectsContain: [], providerActions: [] }; + } + const record = expectRecord(value, pathLabel); + assertOnlyKeys(record, pathLabel, ['status', 'eventSource', 'logsContain', 'effectsContain', 'providerActions']); + return { + ...(record.status ? { status: expectEnum(record.status, `${pathLabel}.status`, ['succeeded', 'failed'] as const) } : {}), + ...(record.eventSource ? { eventSource: expectString(record.eventSource, `${pathLabel}.eventSource`) } : {}), + logsContain: normalizeStringArray(record.logsContain, `${pathLabel}.logsContain`), + effectsContain: normalizeStringArray(record.effectsContain, `${pathLabel}.effectsContain`), + providerActions: expectArray(record.providerActions ?? [], `${pathLabel}.providerActions`).map((entry, index) => { + const action = expectRecord(entry, `${pathLabel}.providerActions[${index}]`); + assertOnlyKeys(action, `${pathLabel}.providerActions[${index}]`, [ + 'provider', + 'resource', + 'channel', + 'threaded', + 'textContains' + ]); + return { + provider: expectString(action.provider, `${pathLabel}.providerActions[${index}].provider`), + resource: expectString(action.resource, `${pathLabel}.providerActions[${index}].resource`), + ...(action.channel ? { channel: expectString(action.channel, `${pathLabel}.providerActions[${index}].channel`) } : {}), + ...(action.threaded !== undefined ? { threaded: Boolean(action.threaded) } : {}), + ...(action.textContains ? { textContains: normalizeStringArray(action.textContains, `${pathLabel}.providerActions[${index}].textContains`) } : {}) + }; + }) + }; +} + +function normalizeStringMap(value: unknown, pathLabel: string): Record { + if (value === undefined) return {}; + const record = expectRecord(value, pathLabel); + return Object.fromEntries( + Object.entries(record).map(([key, entry]) => { + if (typeof entry !== 'string') throw new Error(`${pathLabel}.${key}: expected string`); + return [key, entry]; + }) + ); +} + +function normalizeStringArray(value: unknown, pathLabel: string): string[] { + return expectArray(value ?? [], pathLabel).map((entry, index) => expectString(entry, `${pathLabel}[${index}]`)); +} + +function expectRecord(value: unknown, pathLabel: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`${pathLabel}: expected object`); + } + return value as Record; +} + +function expectArray(value: unknown, pathLabel: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`${pathLabel}: expected array`); + return value; +} + +function expectString(value: unknown, pathLabel: string): string { + if (typeof value !== 'string' || value.trim() === '') throw new Error(`${pathLabel}: expected non-empty string`); + return value; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; +} + +function expectEnum( + value: unknown, + pathLabel: string, + allowed: T +): T[number] { + const text = expectString(value, pathLabel); + if (!(allowed as readonly string[]).includes(text)) { + throw new Error(`${pathLabel}: expected one of ${allowed.join(', ')}`); + } + return text as T[number]; +} + +function assertOnlyKeys(value: Record, pathLabel: string, allowed: readonly string[]): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) throw new Error(`${pathLabel}.${key}: unknown field`); + } +} diff --git a/packages/cli/src/invoke/prepare-target.ts b/packages/cli/src/invoke/prepare-target.ts new file mode 100644 index 00000000..2ce22264 --- /dev/null +++ b/packages/cli/src/invoke/prepare-target.ts @@ -0,0 +1,119 @@ +import path from 'node:path'; +import { stat } from 'node:fs/promises'; +import type { PersonaSpec } from '@agentworkforce/persona-kit'; +import { + compileAgentSource, + extractAgentSpec +} from '@agentworkforce/deploy'; +import type { CompiledAgentV1 } from '@agentworkforce/runtime'; + +export interface PreparedInvokeTarget { + compiled: CompiledAgentV1; + personaPath: string; + warnings: string[]; +} + +const SIBLING_PERSONA_FILENAMES = [ + 'persona.ts', + 'persona.tsx', + 'persona.mts', + 'persona.cts', + 'persona.js', + 'persona.jsx', + 'persona.mjs', + 'persona.cjs', + 'persona.json' +] as const; + +export async function prepareInvokeTarget(inputPath: string): Promise { + const absPath = path.resolve(inputPath); + try { + const compiled = await compileAgentSource(absPath); + return { compiled, personaPath: absPath, warnings: [] }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!/missing top-level "intent"/.test(message)) throw error; + } + + const siblingPersona = await findSiblingPersonaForHandler(absPath); + if (siblingPersona) { + return { + compiled: siblingPersona.compiled, + personaPath: siblingPersona.personaPath, + warnings: [] + }; + } + + const extracted = await extractAgentSpec(absPath); + const persona = syntheticInvokePersona(absPath); + const compiled: CompiledAgentV1 = { + schemaVersion: 1, + sourceKind: 'single-file', + sourcePath: absPath, + persona, + agent: extracted.agent, + handlerEntry: absPath, + sourceDigest: `invoke:${path.basename(absPath)}`, + compileWarnings: [] + }; + return { + compiled, + personaPath: absPath, + warnings: [ + `invoke synthesized a minimal preview persona for bare Agent source ${absPath}` + ] + }; +} + +async function findSiblingPersonaForHandler( + handlerPath: string +): Promise<{ compiled: CompiledAgentV1; personaPath: string } | undefined> { + const dir = path.dirname(handlerPath); + for (const filename of SIBLING_PERSONA_FILENAMES) { + const personaPath = path.join(dir, filename); + if (samePath(personaPath, handlerPath)) continue; + if (!(await isRegularFile(personaPath))) continue; + try { + const compiled = await compileAgentSource(personaPath); + if (samePath(compiled.handlerEntry, handlerPath)) { + return { compiled, personaPath }; + } + } catch { + continue; + } + } + return undefined; +} + +function syntheticInvokePersona(sourcePath: string): PersonaSpec { + const id = path.basename(sourcePath).replace(/\.[^.]+$/u, ''); + return { + id, + intent: 'local-preview', + tags: ['local-preview'], + description: `Synthetic local preview persona for ${id}`, + skills: [], + harness: 'claude', + model: 'local-preview-stub', + systemPrompt: 'Local preview invocation scaffold', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + cloud: true, + onEvent: `./${path.basename(sourcePath)}` + }; +} + +async function isRegularFile(filePath: string): Promise { + try { + return (await stat(filePath)).isFile(); + } catch { + return false; + } +} + +function samePath(left: string, right: string): boolean { + const normalize = (value: string) => path.normalize(value); + if (process.platform === 'win32') { + return normalize(left).toLowerCase() === normalize(right).toLowerCase(); + } + return normalize(left) === normalize(right); +} diff --git a/packages/cli/src/runs-command.test.ts b/packages/cli/src/runs-command.test.ts index 7b4b453c..91aa2eaf 100644 --- a/packages/cli/src/runs-command.test.ts +++ b/packages/cli/src/runs-command.test.ts @@ -1,7 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import path from 'node:path'; -import { interpretEnvelopeResponse, isProbeNotFound, parseRunsArgs } from './runs-command.js'; +import { + interpretEnvelopeResponse, + interpretReplayBundleResponse, + isProbeNotFound, + parseRunsArgs +} from './runs-command.js'; // --------------------------------------------------------------------------- // parseRunsArgs @@ -28,6 +33,17 @@ test('parseRunsArgs: export with full flags', () => { assert.equal(parsed.options.noPrompt, true); }); +test('parseRunsArgs: --bundle is accepted and exclusive with --fixture', () => { + const parsed = parseRunsArgs(['export', 'run-123', '--bundle', './replay.json']); + assert.ok(!('help' in parsed)); + if ('help' in parsed) return; + assert.equal(path.basename(parsed.options.bundlePath ?? ''), 'replay.json'); + assert.throws( + () => parseRunsArgs(['export', 'run-123', '--bundle', './replay.json', '--fixture', './event.json']), + /mutually exclusive/ + ); +}); + test('parseRunsArgs: bare/help/unknown-action contracts', () => { assert.deepEqual(parseRunsArgs([]), { help: true }); assert.deepEqual(parseRunsArgs(['-h']), { help: true }); @@ -125,3 +141,14 @@ test('interpretEnvelopeResponse: non-object envelopes are refused (string/number assert.match(result.error, /non-object envelope/); } }); + +test('interpretReplayBundleResponse: object payload is emitted verbatim', () => { + const result = interpretReplayBundleResponse( + { schemaVersion: 1, event: { id: 'evt_1', type: 'cron.tick' } }, + 'run-1', + 'hn-monitor' + ); + assert.ok(result.ok); + if (!result.ok) return; + assert.equal(JSON.parse(result.fixture).schemaVersion, 1); +}); diff --git a/packages/cli/src/runs-command.ts b/packages/cli/src/runs-command.ts index af87f4c3..f5b36b35 100644 --- a/packages/cli/src/runs-command.ts +++ b/packages/cli/src/runs-command.ts @@ -19,7 +19,8 @@ Flags: --agent Agent the run belongs to (agentId, deployedName, or persona slug). Without it, every agent in the workspace is checked for the run id. - --fixture Write the envelope fixture to . + --fixture Write the legacy replay fixture to . + --bundle Write the richer replay bundle to . Default: stdout. --workspace Workforce workspace; defaults to the active one. --cloud-url Override the workforce cloud base URL. @@ -33,6 +34,7 @@ export interface RunsExportOptions { runId: string; agent?: string; fixturePath?: string; + bundlePath?: string; workspace?: string; cloudUrl?: string; noPrompt?: boolean; @@ -52,6 +54,7 @@ export function parseRunsArgs(args: readonly string[]): ParsedRunsArgs { let runId: string | undefined; let agent: string | undefined; let fixturePath: string | undefined; + let bundlePath: string | undefined; let workspace: string | undefined; let cloudUrl: string | undefined; let noPrompt = false; @@ -68,6 +71,10 @@ export function parseRunsArgs(args: readonly string[]): ParsedRunsArgs { fixturePath = expectValue('--fixture', rest[++i]); } else if (a.startsWith('--fixture=')) { fixturePath = expectInline('--fixture', a.slice('--fixture='.length)); + } else if (a === '--bundle') { + bundlePath = expectValue('--bundle', rest[++i]); + } else if (a.startsWith('--bundle=')) { + bundlePath = expectInline('--bundle', a.slice('--bundle='.length)); } else if (a === '--workspace') { workspace = expectValue('--workspace', rest[++i]); } else if (a.startsWith('--workspace=')) { @@ -90,6 +97,9 @@ export function parseRunsArgs(args: readonly string[]): ParsedRunsArgs { if (!runId) { throw new Error('runs export: missing run id. Usage: agentworkforce runs export '); } + if (fixturePath && bundlePath) { + throw new Error('runs export: --fixture and --bundle are mutually exclusive'); + } return { action: 'export', @@ -97,6 +107,7 @@ export function parseRunsArgs(args: readonly string[]): ParsedRunsArgs { runId, ...(agent ? { agent } : {}), ...(fixturePath ? { fixturePath: path.resolve(fixturePath) } : {}), + ...(bundlePath ? { bundlePath: path.resolve(bundlePath) } : {}), ...(workspace ? { workspace } : {}), ...(cloudUrl ? { cloudUrl } : {}), ...(noPrompt ? { noPrompt: true } : {}) @@ -121,6 +132,7 @@ type EnvelopeResponse = { omitted?: unknown; envelope?: unknown; }; +type ReplayBundleResponse = Record; export interface RunsIO { stdout: (text: string) => void; @@ -179,13 +191,14 @@ async function runRunsExport(opts: RunsExportOptions, io: RunsIO): Promise throw new Error(`no deployed agents found in workspace ${ctx.workspace}`); } - let payload: EnvelopeResponse | null = null; + let payload: EnvelopeResponse | ReplayBundleResponse | null = null; let matchedAgent: DeploymentAgent | null = null; for (const agent of candidates) { + const endpoint = opts.bundlePath ? 'bundle' : 'envelope'; const url = new URL( `/api/v1/workspaces/${encodeURIComponent(ctx.workspace)}` + `/deployments/${encodeURIComponent(agent.agentId)}` + - `/runs/${encodeURIComponent(opts.runId)}/envelope`, + `/runs/${encodeURIComponent(opts.runId)}/${endpoint}`, ctx.cloudUrl ); try { @@ -213,20 +226,20 @@ async function runRunsExport(opts: RunsExportOptions, io: RunsIO): Promise ); } - const interpreted = interpretEnvelopeResponse(payload, opts.runId, matchedAgent.deployedName); - if (!interpreted.ok) { - throw new Error(interpreted.error); - } + const rendered = opts.bundlePath + ? interpretReplayBundleResponse(payload as ReplayBundleResponse, opts.runId, matchedAgent.deployedName) + : interpretEnvelopeResponse(payload as EnvelopeResponse, opts.runId, matchedAgent.deployedName); + if (!rendered.ok) throw new Error(rendered.error); - const fixture = interpreted.fixture; - if (opts.fixturePath) { - await writeFile(opts.fixturePath, fixture, 'utf8'); + if (opts.fixturePath || opts.bundlePath) { + const outputPath = opts.fixturePath ?? opts.bundlePath!; + await writeFile(outputPath, rendered.fixture, 'utf8'); io.stderr( - `exported envelope for run ${opts.runId} (agent ${matchedAgent.deployedName}) to ${opts.fixturePath}\n` + - `replay with: agentworkforce invoke --fixture ${opts.fixturePath}\n` + `exported ${opts.bundlePath ? 'bundle' : 'fixture'} for run ${opts.runId} (agent ${matchedAgent.deployedName}) to ${outputPath}\n` + + `replay with: agentworkforce invoke --fixture ${outputPath}\n` ); } else { - io.stdout(fixture); + io.stdout(rendered.fixture); } } @@ -272,6 +285,20 @@ export function interpretEnvelopeResponse( }; } +export function interpretReplayBundleResponse( + payload: ReplayBundleResponse, + runId: string, + agentName: string +): { ok: true; fixture: string } | { ok: false; error: string } { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return { + ok: false, + error: `run ${runId} (agent ${agentName}): replay bundle endpoint returned a non-object payload` + }; + } + return { ok: true, fixture: `${JSON.stringify(payload, null, 2)}\n` }; +} + /** * True only for the probe's "this agent does not own that run" outcome: * requestJson formats non-ok statuses as " failed: …" and diff --git a/packages/delivery/package.json b/packages/delivery/package.json index 7bfa9fa6..f5504a5a 100644 --- a/packages/delivery/package.json +++ b/packages/delivery/package.json @@ -33,6 +33,6 @@ }, "dependencies": { "@agentworkforce/runtime": "workspace:*", - "@relayfile/relay-helpers": "^0.4.2" + "@relayfile/relay-helpers": "^0.4.6" } } diff --git a/packages/deploy/src/index.ts b/packages/deploy/src/index.ts index dcb57153..816d0183 100644 --- a/packages/deploy/src/index.ts +++ b/packages/deploy/src/index.ts @@ -80,6 +80,10 @@ export { loadPersonaSourceFile, type PersonaSourceLoadResult } from './persona-source.js'; +export { + extractAgentSpec, + type ExtractedAgent +} from './extract-agent.js'; export { devLauncher } from './modes/dev.js'; export { sandboxLauncher, resolveSandboxAuth, type SandboxAuth } from './modes/sandbox.js'; export { cloudLauncher } from './modes/cloud/index.js'; diff --git a/packages/deploy/src/integrations-list.test.ts b/packages/deploy/src/integrations-list.test.ts index 8e9d39c7..79210ac1 100644 --- a/packages/deploy/src/integrations-list.test.ts +++ b/packages/deploy/src/integrations-list.test.ts @@ -55,10 +55,18 @@ test('listIntegrations merges cloud catalog, trigger catalog aliases, and connec }); } if (pathname.includes('/status?scope=deployer_user')) { - return json({ provider: pathname.includes('google-mail') ? 'google-mail' : 'other', status: 'pending' }); + return json({ + provider: pathname.includes('google-mail') ? 'google-mail' : 'other', + status: 'pending', + registrationHealth: { registered: pathname.includes('google-mail'), healthy: true } + }); } if (pathname.includes('/status?scope=workspace')) { - return json({ provider: pathname.includes('github') ? 'github' : 'other', status: 'pending' }); + return json({ + provider: pathname.includes('github') ? 'github' : 'other', + status: 'pending', + registrationHealth: { registered: pathname.includes('github'), healthy: true } + }); } return json({ error: 'unexpected' }, 500); } @@ -73,6 +81,10 @@ test('listIntegrations merges cloud catalog, trigger catalog aliases, and connec assert.ok(googleMail.triggers.length > 0); assert.equal(googleMail.triggerSource, 'catalog'); assert.equal(googleMail.connections?.some((c) => c.connectionId === 'conn-user-gmail'), true); + assert.deepEqual(googleMail.registrationHealth, { + deployer_user: { registered: true, healthy: true }, + workspace: { registered: false, healthy: true } + }); const acme = document.integrations.find((row) => row.id === 'acme-internal'); assert.ok(acme); diff --git a/packages/deploy/src/integrations-list.ts b/packages/deploy/src/integrations-list.ts index 46f2fa96..b675fab4 100644 --- a/packages/deploy/src/integrations-list.ts +++ b/packages/deploy/src/integrations-list.ts @@ -16,6 +16,7 @@ export interface IntegrationConnection { scope: IntegrationScope; serviceAccountName: string | null; status: string; + registrationHealth?: Record; } export interface IntegrationRow { @@ -26,6 +27,7 @@ export interface IntegrationRow { connections: IntegrationConnection[] | null; triggers: string[]; triggerSource: TriggerSource; + registrationHealth?: Record; } export interface IntegrationsDocument { @@ -291,6 +293,13 @@ async function addStatusConnection( const connections = row.connections ?? []; addConnectionsFromStatus(connections, body, row.id, scope); row.connections = connections; + const registrationHealth = readRegistrationHealth(body); + if (registrationHealth) { + row.registrationHealth = { + ...(row.registrationHealth ?? {}), + [scope]: registrationHealth + }; + } } function addConnectionsFromList( @@ -342,7 +351,8 @@ function connectionFromRecord( readString(value, 'serviceAccountName') ?? readString(value, 'name') ?? null, - status: readStatus(value) + status: readStatus(value), + ...(readRegistrationHealth(value) ? { registrationHealth: readRegistrationHealth(value) } : {}) }; } @@ -463,6 +473,20 @@ function readString(value: unknown, field: string): string | undefined { return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined; } +function readRegistrationHealth(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + const direct = record.registrationHealth; + if (direct && typeof direct === 'object' && !Array.isArray(direct)) { + return direct as Record; + } + const summary: Record = {}; + for (const key of ['registered', 'healthy', 'reason', 'adapter', 'checkedAt']) { + if (record[key] !== undefined) summary[key] = record[key]; + } + return Object.keys(summary).length > 0 ? summary : undefined; +} + function firstString(...candidates: Array): string | undefined { for (const candidate of candidates) { const trimmed = candidate?.trim(); diff --git a/packages/persona-kit/schemas/persona.schema.json b/packages/persona-kit/schemas/persona.schema.json index 00c0b89a..34f4beb8 100644 --- a/packages/persona-kit/schemas/persona.schema.json +++ b/packages/persona-kit/schemas/persona.schema.json @@ -505,6 +505,10 @@ "pullRequest": { "$ref": "#/definitions/CapabilityValue", "description": "Legacy alias of `review`." + }, + "httpRead": { + "$ref": "#/definitions/PersonaHttpReadCapability", + "description": "Declarative local live-read allowlist for GET/HEAD URLs." } }, "additionalProperties": { @@ -536,6 +540,40 @@ } ] }, + "PersonaHttpReadCapability": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "allow": { + "type": "array", + "items": { + "$ref": "#/definitions/PersonaHttpReadRule" + } + } + }, + "additionalProperties": {} + }, + "PersonaHttpReadRule": { + "type": "object", + "properties": { + "method": { + "type": "string", + "enum": [ + "GET", + "HEAD" + ] + }, + "urlGlob": { + "type": "string" + } + }, + "required": [ + "method", + "urlGlob" + ] + }, "PersonaMemory": { "anyOf": [ { diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index ec53b5b6..fd0dad9b 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -26,6 +26,8 @@ export type { McpServerSpec, PermissionMode, PersonaContext, + PersonaHttpReadCapability, + PersonaHttpReadRule, PersonaInputPicker, PersonaInputSpec, PersonaInstallContext, diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index a3793e95..e0decdde 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -1120,6 +1120,35 @@ test('parseCapabilities rejects arrays for the capabilities map and values', () ); }); +test('parseCapabilities validates the httpRead capability contract', () => { + const caps = parseCapabilities({ + httpRead: { + allow: [ + { method: 'GET', urlGlob: 'https://example.test/front-page' }, + { method: 'HEAD', urlGlob: 'https://example.test/*' } + ] + } + }, 'persona.capabilities'); + + assert.deepEqual(caps?.httpRead, { + allow: [ + { method: 'GET', urlGlob: 'https://example.test/front-page' }, + { method: 'HEAD', urlGlob: 'https://example.test/*' } + ] + }); + assert.throws( + () => parseCapabilities({ httpRead: true }, 'persona.capabilities'), + /persona\.capabilities\.httpRead must be an object if provided/ + ); + assert.throws( + () => parseCapabilities( + { httpRead: { allow: [{ method: 'POST', urlGlob: 'https://example.test/*' }] } }, + 'persona.capabilities' + ), + /persona\.capabilities\.httpRead\.allow\[0\]\.method must be "GET" or "HEAD"/ + ); +}); + test('parsePersonaSpec round-trip preserves a declared teamSolve capability', () => { const spec = parsePersonaSpec( validSpec({ @@ -1133,3 +1162,23 @@ test('parsePersonaSpec round-trip preserves a declared teamSolve capability', () maxMembers: 1 }); }); + +test('parsePersonaSpec preserves a declared httpRead capability', () => { + const spec = parsePersonaSpec( + validSpec({ + cloud: true, + capabilities: { + httpRead: { + enabled: true, + allow: [{ method: 'GET', urlGlob: 'https://example.test/*' }] + } + } + }), + 'documentation' + ); + + assert.deepEqual(spec.capabilities?.httpRead, { + enabled: true, + allow: [{ method: 'GET', urlGlob: 'https://example.test/*' }] + }); +}); diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index 10a43b1a..66d56e41 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -22,6 +22,8 @@ import type { PersonaIntegrationTrigger, PersonaIntent, PersonaAiMemoryConfig, + PersonaHttpReadCapability, + PersonaHttpReadRule, PersonaMemory, PersonaMemoryConfig, PersonaMemoryScope, @@ -1107,6 +1109,56 @@ function parseCapabilityValue(value: unknown, context: string): CapabilityValue return { ...value } as CapabilityValue; } +function parseHttpReadRule(value: unknown, context: string): PersonaHttpReadRule { + if (!isObject(value) || Array.isArray(value)) { + throw new Error(`${context} must be an object`); + } + const entry = value as Record; + for (const key of Object.keys(entry)) { + if (!['method', 'urlGlob'].includes(key)) { + throw new Error(`${context}.${key} is not allowed`); + } + } + if (entry.method !== 'GET' && entry.method !== 'HEAD') { + throw new Error(`${context}.method must be "GET" or "HEAD"`); + } + if (typeof entry.urlGlob !== 'string' || !entry.urlGlob.trim()) { + throw new Error(`${context}.urlGlob must be a non-empty string`); + } + return { + method: entry.method, + urlGlob: entry.urlGlob + }; +} + +function parseHttpReadCapability(value: unknown, context: string): PersonaHttpReadCapability { + if (!isObject(value) || Array.isArray(value)) { + throw new Error(`${context} must be an object if provided`); + } + const entry = value as Record; + for (const key of Object.keys(entry)) { + if (!['enabled', 'allow'].includes(key)) { + throw new Error(`${context}.${key} is not allowed`); + } + } + if (entry.enabled !== undefined && typeof entry.enabled !== 'boolean') { + throw new Error(`${context}.enabled must be a boolean if provided`); + } + if (entry.allow !== undefined && !Array.isArray(entry.allow)) { + throw new Error(`${context}.allow must be an array if provided`); + } + return { + ...(entry.enabled !== undefined ? { enabled: entry.enabled } : {}), + ...(entry.allow !== undefined + ? { + allow: entry.allow.map((rule, index) => + parseHttpReadRule(rule, `${context}.allow[${index}]`) + ) + } + : {}) + }; +} + export function parseCapabilities( value: unknown, context: string @@ -1127,7 +1179,9 @@ export function parseCapabilities( // drop capability keys it happens not to recognize. for (const [key, raw] of Object.entries(value)) { if (raw === undefined) continue; - out[key] = parseCapabilityValue(raw, `${context}.${key}`); + out[key] = key === 'httpRead' + ? parseHttpReadCapability(raw, `${context}.${key}`) + : parseCapabilityValue(raw, `${context}.${key}`); } return Object.keys(out).length > 0 ? out : undefined; diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index 01c2c4c1..c773639f 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -421,6 +421,17 @@ export type CapabilityValue = | boolean | { enabled?: boolean; [k: string]: unknown }; +export interface PersonaHttpReadRule { + method: 'GET' | 'HEAD'; + urlGlob: string; +} + +export interface PersonaHttpReadCapability { + enabled?: boolean; + allow?: PersonaHttpReadRule[]; + [k: string]: unknown; +} + /** * Portable proactive capability declarations. * @@ -438,6 +449,8 @@ export interface ProactiveCapabilities { conflictAutofix?: CapabilityValue; /** Legacy alias of `review`. */ pullRequest?: CapabilityValue; + /** Declarative local live-read allowlist for GET/HEAD URLs. */ + httpRead?: PersonaHttpReadCapability; /** * Consumer-defined capabilities pass through the parser unchanged. persona-kit * is platform-agnostic and must not drop capability keys it does not model diff --git a/packages/runtime/package.json b/packages/runtime/package.json index e3ab9a10..0ced069c 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -58,6 +58,7 @@ "@agentworkforce/events": "workspace:*", "@agentworkforce/persona-kit": "workspace:*", "@relayfile/adapter-core": "^0.5.1", + "@relayfile/relay-helpers": "^0.4.6", "agent-trajectories": "^0.5.3" } } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 2eabcdc4..b09dc710 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -85,6 +85,8 @@ export { envelopeToAgentEvent } from './to-agent-event.js'; // the existing simulation record while hosted/local producers migrate. export { LOCAL_EFFECT_POLICY_DEFAULTS, + mergeAllowedHttpRules, + resolvePersonaHttpReadRules, resolveLocalEffectPolicy, type CompiledAgentV1, type Diagnostic, @@ -99,6 +101,15 @@ export { type StateDiff, type StateSourceV1 } from './run-contracts.js'; +export { + executeLocalRun, + type ExecuteLocalRunOptions, + type ExecuteLocalRunResult, + type LocalHttpFixture, + type LocalModelFixture, + type LocalPreviewMemoryEntry, + type LocalPreviewState +} from './local-preview.js'; export type { LinearAgentActivity, diff --git a/packages/runtime/src/local-preview-child.ts b/packages/runtime/src/local-preview-child.ts new file mode 100644 index 00000000..59e18600 --- /dev/null +++ b/packages/runtime/src/local-preview-child.ts @@ -0,0 +1,199 @@ +import type { + LocalPreviewFetchRequestMessage, + LocalPreviewFetchResponseMessage, + LocalPreviewModelRequestMessage, + LocalPreviewModelResponseMessage, + LocalPreviewWorkerInboundMessage, + LocalPreviewWorkerInitMessage, + LocalPreviewWorkerReadyMessage, + LocalPreviewWorkerResult, + LocalPreviewWorkerResultMessage +} from './local-preview-contract.js'; +import { executeLocalRunInWorkerProcess } from './local-preview-executor.js'; +import { getPreviewProcessState, installPreviewProcessGuards } from './local-preview-hooks.js'; + +const pendingFetches = new Map void; + reject: (error: Error) => void; + timeout: ReturnType; +}>(); +const pendingModels = new Map void; + reject: (error: Error) => void; + timeout: ReturnType; +}>(); +const LOCAL_PREVIEW_FETCH_IPC_TIMEOUT_MS = readTimeoutEnv('WF_LOCAL_PREVIEW_FETCH_TIMEOUT_MS', 10_000); +const LOCAL_PREVIEW_MODEL_IPC_TIMEOUT_MS = readTimeoutEnv('WF_LOCAL_PREVIEW_MODEL_TIMEOUT_MS', 30_000); + +process.on('message', (message: LocalPreviewWorkerInboundMessage) => { + if (message?.type === 'fetch-response') { + resolvePendingFetch(message); + } else if (message?.type === 'model-response') { + resolvePendingModel(message); + } +}); + +process.on('disconnect', () => { + for (const pending of pendingFetches.values()) { + pending.reject(new Error('invoke: preview worker parent disconnected during fetch')); + } + pendingFetches.clear(); + for (const pending of pendingModels.values()) { + pending.reject(new Error('invoke: preview worker parent disconnected during model')); + } + pendingModels.clear(); +}); + +await sendReady(); +const init = await receiveInitMessage(); +installPreviewProcessGuards({ + config: init.config, + fetchFromParent: sendFetchToParent, + completeModelFromParent: sendModelToParent +}); + +try { + const result = await executeLocalRunInWorkerProcess(init.payload); + await sendResult({ + ok: true, + ...result + }); +} catch (error) { + await sendResult({ + ok: false, + error: error instanceof Error ? error.message : String(error), + ...(error instanceof Error && error.stack ? { stack: error.stack } : {}) + }); + process.exitCode = 1; +} finally { + getPreviewProcessState()?.cleanup(); + process.disconnect?.(); +} + +function receiveInitMessage(): Promise { + return new Promise((resolve, reject) => { + const onMessage = (message: LocalPreviewWorkerInboundMessage) => { + if (!message) return; + if (message.type === 'init') { + cleanup(); + resolve(message); + return; + } + if (message.type === 'fetch-response') { + resolvePendingFetch(message); + } else if (message.type === 'model-response') { + resolvePendingModel(message); + } + }; + const onDisconnect = () => { + cleanup(); + reject(new Error('invoke: preview worker parent disconnected before init')); + }; + const cleanup = () => { + process.off('message', onMessage); + process.off('disconnect', onDisconnect); + }; + process.on('message', onMessage); + process.on('disconnect', onDisconnect); + }); +} + +function sendFetchToParent( + request: LocalPreviewFetchRequestMessage +): Promise { + return new Promise((resolve, reject) => { + if (typeof process.send !== 'function') { + reject(new Error('invoke: preview worker missing parent IPC channel')); + return; + } + const timeout = setTimeout(() => { + pendingFetches.delete(request.requestId); + reject(new Error( + `invoke: preview worker fetch IPC timeout after ${LOCAL_PREVIEW_FETCH_IPC_TIMEOUT_MS}ms for ${request.method} ${request.url}` + )); + }, LOCAL_PREVIEW_FETCH_IPC_TIMEOUT_MS); + pendingFetches.set(request.requestId, { resolve, reject, timeout }); + process.send(request, (error) => { + if (!error) return; + clearTimeout(timeout); + pendingFetches.delete(request.requestId); + reject(error); + }); + }); +} + +async function sendReady(): Promise { + if (typeof process.send !== 'function') { + throw new Error('invoke: preview worker missing parent IPC channel'); + } + const message: LocalPreviewWorkerReadyMessage = { type: 'ready' }; + await new Promise((resolve, reject) => { + process.send?.(message, (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +function sendModelToParent( + request: LocalPreviewModelRequestMessage +): Promise { + return new Promise((resolve, reject) => { + if (typeof process.send !== 'function') { + reject(new Error('invoke: preview worker missing parent IPC channel')); + return; + } + const timeout = setTimeout(() => { + pendingModels.delete(request.requestId); + reject(new Error( + `invoke: preview worker model IPC timeout after ${LOCAL_PREVIEW_MODEL_IPC_TIMEOUT_MS}ms` + )); + }, LOCAL_PREVIEW_MODEL_IPC_TIMEOUT_MS); + pendingModels.set(request.requestId, { resolve, reject, timeout }); + process.send(request, (error) => { + if (!error) return; + clearTimeout(timeout); + pendingModels.delete(request.requestId); + reject(error); + }); + }); +} + +async function sendResult(result: LocalPreviewWorkerResult): Promise { + if (typeof process.send !== 'function') { + throw new Error('invoke: preview worker missing parent IPC channel'); + } + const message: LocalPreviewWorkerResultMessage = { + type: 'result', + result + }; + await new Promise((resolve, reject) => { + process.send?.(message, (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +function resolvePendingFetch(message: LocalPreviewFetchResponseMessage): void { + const pending = pendingFetches.get(message.requestId); + if (!pending) return; + pendingFetches.delete(message.requestId); + clearTimeout(pending.timeout); + pending.resolve(message); +} + +function resolvePendingModel(message: LocalPreviewModelResponseMessage): void { + const pending = pendingModels.get(message.requestId); + if (!pending) return; + pendingModels.delete(message.requestId); + clearTimeout(pending.timeout); + pending.resolve(message); +} + +function readTimeoutEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} diff --git a/packages/runtime/src/local-preview-contract.ts b/packages/runtime/src/local-preview-contract.ts new file mode 100644 index 00000000..0ec4371b --- /dev/null +++ b/packages/runtime/src/local-preview-contract.ts @@ -0,0 +1,185 @@ +import type { + EffectPolicyV1, + PreviewAction, + RunRecordV2, + RunRequestV1 +} from './run-contracts.js'; +import type { MemoryItem } from './types.js'; + +export interface LocalHttpFixture { + method: string; + match: string; + body: string; + contentType?: string; + sourcePath?: string; +} + +export interface LocalPreviewMemoryEntry extends MemoryItem { + scope: 'workspace' | 'user' | 'global'; +} + +export interface LocalPreviewTransportReceipt { + id: string; + timestamp: string; +} + +export interface LocalPreviewTransportState { + sequence: number; + data: Record; + writtenPaths: string[]; + receiptsByReference: Record; +} + +export interface LocalPreviewModelState { + fixtureCursor: number; +} + +export interface LocalPreviewState { + files?: Record; + memory?: LocalPreviewMemoryEntry[]; + transport?: LocalPreviewTransportState; + model?: LocalPreviewModelState; +} + +export interface LocalModelFixture { + output: string; + sourcePath?: string; +} + +export interface LocalSourceFidelity { + state: 'historical' | 'current' | 'fixture' | 'simulated' | 'unavailable'; + inputs: 'historical' | 'current' | 'fixture' | 'simulated' | 'unavailable'; + http: 'historical' | 'current' | 'fixture' | 'simulated' | 'unavailable'; + model: 'historical' | 'current' | 'fixture' | 'simulated' | 'unavailable'; + extensions?: Record; +} + +export interface ExecuteLocalRunOptions { + request: RunRequestV1; + bundlePath: string; + sourcePath?: string; + inputs?: Record; + state?: LocalPreviewState; + httpFixtures?: readonly LocalHttpFixture[]; + modelFixtures?: readonly LocalModelFixture[]; + replayProvenance?: Record; + sourceFidelity?: LocalSourceFidelity; + modelAdapter?: { + complete: (prompt: string, opts?: { maxTokens?: number }) => Promise; + }; + now?: () => Date; +} + +export interface ExecuteLocalRunResult { + record: RunRecordV2; + exitCode: 0 | 1; + logs: string[]; + state: LocalPreviewState; +} + +export interface LocalPreviewGuardConfig { + policy: EffectPolicyV1; + fixtures: readonly LocalHttpFixture[]; + transportState?: LocalPreviewTransportState; + clockNow?: string; +} + +export interface LocalPreviewWorkerPayload { + request: RunRequestV1; + bundlePath: string; + inputs?: Record; + state?: LocalPreviewState; + replayProvenance?: Record; + sourceFidelity?: LocalSourceFidelity; +} + +export interface LocalPreviewWorkerInitMessage { + type: 'init'; + config: LocalPreviewGuardConfig; + payload: LocalPreviewWorkerPayload; +} + +export interface LocalPreviewWorkerReadyMessage { + type: 'ready'; +} + +export interface LocalPreviewFetchRequestMessage { + type: 'fetch'; + requestId: string; + method: string; + url: string; + headers: Array<[string, string]>; + bodyBase64?: string; +} + +export interface LocalPreviewFetchResponseMessage { + type: 'fetch-response'; + requestId: string; + ok: boolean; + action: PreviewAction; + response?: { + status: number; + headers: Array<[string, string]>; + bodyBase64: string; + }; + error?: string; +} + +export interface LocalPreviewModelRequestMessage { + type: 'model'; + requestId: string; + prompt: string; + maxTokens?: number; +} + +export interface LocalPreviewModelResponseMessage { + type: 'model-response'; + requestId: string; + ok: boolean; + action: PreviewAction; + output?: string; + error?: string; +} + +export interface LocalPreviewWorkerResultMessage { + type: 'result'; + result: LocalPreviewWorkerResult; +} + +export type LocalPreviewWorkerInboundMessage = + | LocalPreviewWorkerInitMessage + | LocalPreviewFetchResponseMessage + | LocalPreviewModelResponseMessage; + +export type LocalPreviewWorkerOutboundMessage = + | LocalPreviewWorkerReadyMessage + | LocalPreviewFetchRequestMessage + | LocalPreviewModelRequestMessage + | LocalPreviewWorkerResultMessage; + +export interface LocalPreviewWorkerSuccess extends ExecuteLocalRunResult { + ok: true; +} + +export interface LocalPreviewWorkerFailure { + ok: false; + error: string; + stack?: string; +} + +export type LocalPreviewWorkerResult = LocalPreviewWorkerSuccess | LocalPreviewWorkerFailure; + +export interface PreviewProcessState { + activateUserImportGuard: () => void; + cleanup: () => void; + fetchFromParent: (request: LocalPreviewFetchRequestMessage) => Promise; + completeModelFromParent: (request: LocalPreviewModelRequestMessage) => Promise; + now: () => Date; + previewTransport: { + actions: PreviewAction[]; + accesses: PreviewAction[]; + }; + snapshotTransportState: () => LocalPreviewTransportState; + recordAction: (action: PreviewAction) => void; + recordedActions: PreviewAction[]; +} diff --git a/packages/runtime/src/local-preview-executor.ts b/packages/runtime/src/local-preview-executor.ts new file mode 100644 index 00000000..47b69654 --- /dev/null +++ b/packages/runtime/src/local-preview-executor.ts @@ -0,0 +1,519 @@ +import { randomUUID } from 'node:crypto'; +import { pathToFileURL } from 'node:url'; +import type { EventFrameV1 } from '@agentworkforce/events'; +import { buildCtx } from './ctx.js'; +import { getPreviewProcessState, transportActionToPreviewAction } from './local-preview-hooks.js'; +import { redactLocalPreviewValue } from './local-preview-redaction.js'; +import type { + ExecuteLocalRunResult, + LocalPreviewMemoryEntry, + LocalPreviewState, + LocalPreviewWorkerPayload +} from './local-preview-contract.js'; +import { envelopeToAgentEvent } from './to-agent-event.js'; +import type { RawGatewayEnvelope } from './shim.js'; +import type { + PreviewAction, + RunRecordV2, + RunTraceEventV1 +} from './run-contracts.js'; +import type { + FilesContext, + MemoryContext, + RelayContext, + SandboxContext, + ScheduleContext, + WorkflowContext, + WorkforceCtx, + WorkforceHandler +} from './types.js'; + +export async function executeLocalRunInWorkerProcess( + payload: LocalPreviewWorkerPayload +): Promise { + const previewState = getPreviewProcessState(); + if (!previewState) { + throw new Error('invoke: preview worker hooks were not installed before bundle import'); + } + + const logs: string[] = []; + const files = new Map(Object.entries(payload.state?.files ?? {})); + const memory = [...(payload.state?.memory ?? [])]; + const filesBefore = new Map(files); + const runId = `run_local_${randomUUID()}`; + let relaySeq = 0; + let memorySeq = 0; + let workflowSeq = 0; + let scheduleSeq = 0; + + const recordAction = (action: PreviewAction): void => { + previewState.recordAction(redactLocalPreviewValue(action)); + }; + + const log: WorkforceCtx['log'] = (level, message, attrs) => { + const payloadLine = redactLocalPreviewValue({ + t: previewState.now().toISOString(), + level, + message, + ...(attrs ?? {}) + }); + logs.push(JSON.stringify(payloadLine)); + }; + + const sandbox: SandboxContext = { + cwd: '/preview', + async exec(cmd) { + if (payload.request.policy.shell === 'deny') { + recordAction({ + kind: 'shell.exec', + status: 'denied', + data: { cmd } + }); + throw new Error(`invoke policy denied shell execution: ${cmd}`); + } + recordAction({ + kind: 'shell.exec', + status: 'previewed', + data: { cmd } + }); + return { output: '', exitCode: 0 }; + }, + async readFile(filePath) { + const value = files.get(filePath); + if (value === undefined) throw new Error(`preview file not found: ${filePath}`); + return value; + }, + async writeFile(filePath, contents) { + files.set(filePath, contents); + recordAction({ + kind: 'files.write', + status: 'previewed', + data: { path: filePath, bytes: contents.length } + }); + } + }; + + const fileCtx: FilesContext = { + async read(filePath) { + const value = files.get(filePath); + if (value === undefined) throw new Error(`preview file not found: ${filePath}`); + return value; + }, + async write(filePath, contents) { + files.set(filePath, contents); + recordAction({ + kind: 'files.write', + status: 'previewed', + data: { path: filePath, bytes: contents.length } + }); + } + }; + + const memoryCtx: MemoryContext = { + async save(content, opts) { + const entry: LocalPreviewMemoryEntry = { + id: `mem_${++memorySeq}`, + content, + tags: [...(opts?.tags ?? [])], + scope: opts?.scope ?? 'workspace', + createdAt: previewState.now().toISOString() + }; + memory.push(entry); + recordAction({ + kind: 'memory.save', + status: 'previewed', + data: { + id: entry.id, + scope: entry.scope, + tags: entry.tags + } + }); + return { id: entry.id }; + }, + async recall(_query, opts) { + const scope = opts?.scope ?? opts?.scopes?.[0] ?? 'workspace'; + const tags = new Set(opts?.tags ?? []); + const filtered = memory + .filter((entry) => entry.scope === scope) + .filter((entry) => tags.size === 0 || entry.tags.some((tag) => tags.has(tag))) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); + const limited = filtered.slice(0, opts?.limit ?? filtered.length); + recordAction({ + kind: 'memory.recall', + status: 'previewed', + data: { + scope, + tags: [...tags], + items: limited.length + } + }); + return limited; + } + }; + + const llm = { + async complete(prompt: string, opts?: { maxTokens?: number }) { + if (payload.request.policy.model === 'stub') { + const output = stubModelOutput(prompt); + recordAction({ + kind: 'model.complete', + status: 'previewed', + data: { + mode: 'stub', + promptChars: prompt.length, + outputChars: output.length, + source: 'simulated' + }, + extensions: { + sourceFidelity: 'simulated' + } + }); + return output; + } + const response = await previewState.completeModelFromParent({ + type: 'model', + requestId: `model_${randomUUID()}`, + prompt, + ...(opts?.maxTokens ? { maxTokens: opts.maxTokens } : {}) + }); + recordAction(redactLocalPreviewValue(response.action)); + if (!response.ok || response.output === undefined) { + throw new Error(response.error ?? 'invoke: parent model bridge failed'); + } + return response.output; + } + }; + + const workflow: WorkflowContext = { + async run(name, args) { + if (payload.request.policy.compose === 'deny') { + recordAction({ + kind: 'compose.run', + status: 'denied', + data: { name, args } + }); + throw new Error(`invoke policy denied workflow.run("${name}")`); + } + const workflowRunId = `wf_${++workflowSeq}`; + recordAction({ + kind: 'compose.run', + status: 'previewed', + data: { name, args, runId: workflowRunId } + }); + return { + runId: workflowRunId, + async completion() { + return { output: null, status: 'success' as const }; + } + }; + }, + async status(existingRunId) { + recordAction({ + kind: 'compose.status', + status: 'previewed', + data: { runId: existingRunId, status: 'success' } + }); + return { status: 'success' as const }; + } + }; + + const schedule: ScheduleContext = { + async at(when, triggerPayload) { + recordAction({ + kind: 'schedule.at', + status: 'previewed', + data: { + id: `schedule_${++scheduleSeq}`, + when: when.toISOString(), + payload: triggerPayload + } + }); + }, + async cancel(name) { + recordAction({ + kind: 'schedule.cancel', + status: 'previewed', + data: { name } + }); + } + }; + + const relay: RelayContext = { + async dm(to, text) { + const messageId = `relay_${++relaySeq}`; + recordAction({ + kind: 'provider.write', + status: 'previewed', + provider: 'relaycast', + resource: 'messages', + data: { to, text, messageId } + }); + return { ok: true, messageId }; + }, + async post(channel, text) { + const messageId = `relay_${++relaySeq}`; + recordAction({ + kind: 'provider.write', + status: 'previewed', + provider: 'relaycast', + resource: 'messages', + data: { channel, text, messageId } + }); + return { ok: true, messageId }; + } + }; + + const routeStartTrace: RunTraceEventV1 = { + schemaVersion: 1, + seq: 1, + at: previewState.now().toISOString(), + runId, + spanId: `${runId}_span_1`, + kind: 'invoke.start', + phase: 'route', + status: 'started', + summary: 'invoke.start', + data: { + eventType: payload.request.event.type + } + }; + + previewState.activateUserImportGuard(); + const bundleUrl = `${pathToFileURL(payload.bundlePath).href}?invoke=${randomUUID()}`; + let userModule: Record; + try { + userModule = (await import(bundleUrl)) as Record; + } catch (error) { + const fsReadAllowed = process.permission?.has?.('fs.read', payload.bundlePath); + throw new Error( + `invoke: preview worker could not import bundle ${payload.bundlePath} (fs.read=${String(fsReadAllowed)}): ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + const handler = extractHandler(userModule); + const event = envelopeToAgentEvent(eventFrameToRawEnvelope(payload.request.event)); + if (!event) throw new Error(`invoke: unsupported event ${payload.request.event.type}`); + + const ctx = buildCtx({ + persona: payload.request.agent.persona, + workspaceId: payload.request.event.workspace, + agent: { + id: 'local-agent', + deployedName: payload.request.agent.persona.id, + spawnedByAgentId: null, + inputValues: payload.inputs ?? {} + }, + deployment: { + id: 'local-deployment', + triggerKind: event.type === 'cron.tick' ? 'clock' : 'inbox', + parentDeploymentId: null + }, + sandbox, + files: fileCtx, + llm, + memory: memoryCtx, + workflow, + schedule, + relay, + log, + harnessRunner: async ({ prompt }) => { + recordAction({ + kind: 'harness.run', + status: 'previewed', + data: { promptChars: prompt.length } + }); + return { output: '', exitCode: 0, durationMs: 0 }; + } + }); + + let error: string | undefined; + try { + await (handler as WorkforceHandler)(ctx, event); + } catch (caught) { + error = caught instanceof Error ? caught.message : String(caught); + } + + for (const action of previewState.previewTransport.actions) { + recordAction(transportActionToPreviewAction(action as unknown as never)); + } + for (const access of previewState.previewTransport.accesses) { + recordAction(transportActionToPreviewAction(access as unknown as never)); + } + + const actionTraces = buildActionTraces(runId, previewState.recordedActions, previewState.now); + const resultTrace: RunTraceEventV1 = { + schemaVersion: 1, + seq: actionTraces.length + 2, + at: previewState.now().toISOString(), + runId, + spanId: `${runId}_span_${actionTraces.length + 2}`, + kind: 'invoke.result', + phase: 'result', + status: error ? 'failed' : 'succeeded', + summary: error ?? 'invoke.ok' + }; + + const record: RunRecordV2 = { + runId, + status: error ? 'failed' : 'succeeded', + origin: 'local_dry_run', + mode: payload.request.mode, + policy: clonePolicy(payload.request.policy), + eventId: payload.request.event.id, + eventContract: `${payload.request.event.type}@${payload.request.event.contractVersion}`, + trace: [routeStartTrace, ...actionTraces, resultTrace], + actions: [...previewState.recordedActions], + artifacts: { artifacts: [] }, + stateDiff: { + files: diffFiles(filesBefore, files), + memory: memory.map((entry) => ({ + id: entry.id, + scope: entry.scope, + tags: entry.tags, + createdAt: entry.createdAt + })) + }, + extensions: { + logs, + stateSource: payload.request.state, + ...(payload.sourceFidelity ? { sourceFidelity: payload.sourceFidelity } : {}), + ...(payload.replayProvenance ? { provenance: payload.replayProvenance } : {}) + }, + ...(error ? { error } : {}) + }; + const safeRecord = redactLocalPreviewValue(record) as RunRecordV2; + + return { + record: safeRecord, + exitCode: error ? 1 : 0, + logs, + state: { + files: Object.fromEntries(files.entries()), + memory, + transport: previewState.snapshotTransportState() + } + }; +} + +function buildActionTraces( + runId: string, + actions: readonly PreviewAction[], + now: () => Date +): RunTraceEventV1[] { + return actions.map((action, index) => ({ + schemaVersion: 1, + seq: index + 2, + at: now().toISOString(), + runId, + spanId: `${runId}_span_${index + 2}`, + kind: action.kind, + phase: action.kind === 'provider.write' + ? 'write' + : action.kind === 'provider.read' || action.kind === 'http.read' + ? 'read' + : action.kind === 'model.complete' + ? 'model' + : action.kind.startsWith('compose.') + ? 'compose' + : 'result', + status: action.status === 'denied' + ? 'denied' + : action.status === 'previewed' + ? 'previewed' + : 'succeeded', + summary: action.kind, + ...(action.data ? { data: action.data } : {}) + })); +} + +function extractHandler(userModule: Record): WorkforceHandler { + const exported = (userModule.default ?? userModule.handler) as + | { __workforceAgent?: boolean; handler?: unknown } + | ((...args: unknown[]) => unknown) + | undefined; + let candidate: unknown; + if (exported && typeof exported === 'object' && exported.__workforceAgent) { + candidate = exported.handler; + } else if (exported && typeof exported === 'object' && typeof exported.handler === 'function') { + candidate = exported.handler; + } else { + candidate = exported; + } + if (typeof candidate !== 'function') { + throw new Error('invoke: bundle did not export a callable handler'); + } + return candidate as WorkforceHandler; +} + +function eventFrameToRawEnvelope(frame: EventFrameV1): RawGatewayEnvelope { + return { + id: frame.id, + workspace: frame.workspace, + type: frame.type, + occurredAt: frame.occurredAt, + attempt: frame.attempt, + resource: frame.payload ?? frame.resource, + summary: frame.summary, + digest: frame.digest, + provider: frame.resource.provider, + deliveryId: frame.delivery?.id, + paths: frame.paths ?? [frame.resource.path], + name: frame.schedule?.name, + cron: frame.schedule?.cron, + channel: frame.message?.channel, + messageId: frame.message?.messageId, + threadId: frame.message?.threadId + }; +} + +function stubModelOutput(prompt: string): string { + if (prompt.includes('Return ONLY compact JSON with this shape:')) { + const lines = prompt.trim().split('\n'); + const rawStories = lines[lines.length - 1] ?? '[]'; + try { + const parsed = JSON.parse(rawStories) as Array<{ id?: number; title?: string }>; + return JSON.stringify({ + theme: 'Agent infrastructure stories worth monitoring.', + stories: parsed.map((story) => ({ + id: story.id, + why: `Relevant to agent builders: ${String(story.title ?? 'story').slice(0, 120)}` + })) + }); + } catch { + return JSON.stringify({ + theme: 'Agent infrastructure stories worth monitoring.', + stories: [] + }); + } + } + return 'Agent-focused summary: the grounded story matters for runtime and orchestration work.'; +} + +function clonePolicy(policy: LocalPreviewWorkerPayload['request']['policy']) { + return { + ...policy, + allowedHttp: policy.allowedHttp.map((rule) => ({ ...rule })), + ...(policy.allowedProviders ? { allowedProviders: [...policy.allowedProviders] } : {}) + }; +} + +function diffFiles( + before: ReadonlyMap, + after: ReadonlyMap +): Array<{ path: string; before?: string; after?: string }> { + const paths = new Set([...before.keys(), ...after.keys()]); + return [...paths] + .sort() + .flatMap((filePath) => { + const prev = before.get(filePath); + const next = after.get(filePath); + return prev === next + ? [] + : [{ + path: filePath, + ...(prev !== undefined ? { before: prev } : {}), + ...(next !== undefined ? { after: next } : {}) + }]; + }); +} diff --git a/packages/runtime/src/local-preview-hooks.ts b/packages/runtime/src/local-preview-hooks.ts new file mode 100644 index 00000000..780b2c29 --- /dev/null +++ b/packages/runtime/src/local-preview-hooks.ts @@ -0,0 +1,355 @@ +import { Buffer } from 'node:buffer'; +import { createRequire, syncBuiltinESMExports } from 'node:module'; +import { bindPreviewTransport, PreviewTransport, type TransportPreviewAction } from '@relayfile/relay-helpers'; +import type { PreviewAction } from './run-contracts.js'; +import type { + LocalPreviewFetchRequestMessage, + LocalPreviewFetchResponseMessage, + LocalPreviewGuardConfig, + LocalPreviewModelRequestMessage, + LocalPreviewModelResponseMessage, + LocalPreviewTransportReceipt, + LocalPreviewTransportState, + PreviewProcessState +} from './local-preview-contract.js'; + +const PREVIEW_PROCESS_STATE = Symbol.for('agentworkforce.local-preview.process-state'); +const require = createRequire(import.meta.url); +const childProcess = require('node:child_process') as typeof import('node:child_process'); +const dns = require('node:dns') as typeof import('node:dns'); +const http = require('node:http') as typeof import('node:http'); +const http2 = require('node:http2') as typeof import('node:http2'); +const https = require('node:https') as typeof import('node:https'); +const net = require('node:net') as typeof import('node:net'); +const tls = require('node:tls') as typeof import('node:tls'); +const dgram = require('node:dgram') as typeof import('node:dgram'); +const workerThreads = require('node:worker_threads') as typeof import('node:worker_threads'); + +const DNS_DENIED_FUNCTIONS = [ + 'lookup', + 'lookupService', + 'resolve', + 'resolve4', + 'resolve6', + 'resolveAny', + 'resolveCaa', + 'resolveCname', + 'resolveMx', + 'resolveNaptr', + 'resolveNs', + 'resolvePtr', + 'resolveSoa', + 'resolveSrv', + 'resolveTxt', + 'reverse' +] as const; + +type PreviewModuleState = PreviewProcessState; + +export function installPreviewProcessGuards(args: { + config: LocalPreviewGuardConfig; + fetchFromParent: (request: LocalPreviewFetchRequestMessage) => Promise; + completeModelFromParent: (request: LocalPreviewModelRequestMessage) => Promise; +}): PreviewProcessState { + const existing = getPreviewProcessState(); + if (existing) return existing; + + const previewTransport = new PreviewTransport(); + if (args.config.transportState) restorePreviewTransportState(previewTransport, args.config.transportState); + const restoreTransport = bindPreviewTransport(previewTransport); + const recordedActions: PreviewAction[] = []; + const clockNow = args.config.clockNow; + const now = clockNow ? () => new Date(clockNow) : () => new Date(); + const originalFetch = globalThis.fetch.bind(globalThis); + const originalHttp = { + request: http.request, + get: http.get + }; + const originalHttps = { + request: https.request, + get: https.get + }; + const originalNet = { + connect: net.connect, + createConnection: net.createConnection, + createServer: net.createServer, + Socket: net.Socket + }; + const originalTls = { + connect: tls.connect, + createServer: tls.createServer, + createSecureContext: tls.createSecureContext, + TLSSocket: tls.TLSSocket + }; + const originalDgram = { + createSocket: dgram.createSocket, + Socket: dgram.Socket + }; + const originalHttp2 = { + connect: http2.connect, + createServer: http2.createServer, + createSecureServer: http2.createSecureServer + }; + const originalDns = { + Resolver: dns.Resolver, + promisesLookup: dns.promises.lookup, + promisesResolver: dns.promises.Resolver, + functions: new Map( + DNS_DENIED_FUNCTIONS + .filter((name) => typeof dns[name] === 'function') + .map((name) => [name, dns[name]]) + ), + promiseFunctions: new Map( + DNS_DENIED_FUNCTIONS + .filter((name) => typeof dns.promises[name] === 'function') + .map((name) => [name, dns.promises[name]]) + ) + }; + const originalWorkerThreads = { + Worker: workerThreads.Worker + }; + const originalChildProcess = { + exec: childProcess.exec, + execFile: childProcess.execFile, + execFileSync: childProcess.execFileSync, + execSync: childProcess.execSync, + fork: childProcess.fork, + spawn: childProcess.spawn, + spawnSync: childProcess.spawnSync + }; + + const state: PreviewModuleState = { + activateUserImportGuard() { + // Permission-mode worker + patched builtins are the safety boundary. + }, + cleanup() { + globalThis.fetch = originalFetch; + http.request = originalHttp.request; + http.get = originalHttp.get; + https.request = originalHttps.request; + https.get = originalHttps.get; + net.connect = originalNet.connect; + net.createConnection = originalNet.createConnection; + net.createServer = originalNet.createServer; + net.Socket = originalNet.Socket; + tls.connect = originalTls.connect; + tls.createServer = originalTls.createServer; + tls.createSecureContext = originalTls.createSecureContext; + tls.TLSSocket = originalTls.TLSSocket; + dgram.createSocket = originalDgram.createSocket; + dgram.Socket = originalDgram.Socket; + http2.connect = originalHttp2.connect; + http2.createServer = originalHttp2.createServer; + http2.createSecureServer = originalHttp2.createSecureServer; + dns.Resolver = originalDns.Resolver; + dns.promises.lookup = originalDns.promisesLookup; + dns.promises.Resolver = originalDns.promisesResolver; + for (const [name, fn] of originalDns.functions) (dns as Record)[name] = fn; + for (const [name, fn] of originalDns.promiseFunctions) (dns.promises as Record)[name] = fn; + workerThreads.Worker = originalWorkerThreads.Worker; + childProcess.exec = originalChildProcess.exec; + childProcess.execFile = originalChildProcess.execFile; + childProcess.execFileSync = originalChildProcess.execFileSync; + childProcess.execSync = originalChildProcess.execSync; + childProcess.fork = originalChildProcess.fork; + childProcess.spawn = originalChildProcess.spawn; + childProcess.spawnSync = originalChildProcess.spawnSync; + syncBuiltinESMExports(); + restoreTransport(); + delete (globalThis as Record)[PREVIEW_PROCESS_STATE]; + }, + fetchFromParent: args.fetchFromParent, + completeModelFromParent: args.completeModelFromParent, + now, + previewTransport: previewTransport as unknown as PreviewProcessState['previewTransport'], + snapshotTransportState() { + return snapshotPreviewTransportState(previewTransport); + }, + recordAction(action) { + recordedActions.push(action); + }, + recordedActions + }; + + http.request = ((..._args: unknown[]) => denyRawNetwork(state, 'node:http', 'request')) as typeof http.request; + http.get = ((..._args: unknown[]) => denyRawNetwork(state, 'node:http', 'get')) as typeof http.get; + https.request = ((..._args: unknown[]) => denyRawNetwork(state, 'node:https', 'request')) as typeof https.request; + https.get = ((..._args: unknown[]) => denyRawNetwork(state, 'node:https', 'get')) as typeof https.get; + net.connect = ((..._args: unknown[]) => denyRawNetwork(state, 'node:net', 'connect')) as typeof net.connect; + net.createConnection = ((..._args: unknown[]) => denyRawNetwork(state, 'node:net', 'createConnection')) as typeof net.createConnection; + net.createServer = ((..._args: unknown[]) => denyRawNetwork(state, 'node:net', 'createServer')) as typeof net.createServer; + net.Socket = createDeniedConstructor(state, 'node:net', 'Socket') as unknown as typeof net.Socket; + tls.connect = ((..._args: unknown[]) => denyRawNetwork(state, 'node:tls', 'connect')) as typeof tls.connect; + tls.createServer = ((..._args: unknown[]) => denyRawNetwork(state, 'node:tls', 'createServer')) as typeof tls.createServer; + tls.createSecureContext = ((..._args: unknown[]) => denyRawNetwork(state, 'node:tls', 'createSecureContext')) as typeof tls.createSecureContext; + tls.TLSSocket = createDeniedConstructor(state, 'node:tls', 'TLSSocket') as unknown as typeof tls.TLSSocket; + dgram.createSocket = ((..._args: unknown[]) => denyRawNetwork(state, 'node:dgram', 'createSocket')) as typeof dgram.createSocket; + dgram.Socket = createDeniedConstructor(state, 'node:dgram', 'Socket') as unknown as typeof dgram.Socket; + http2.connect = ((..._args: unknown[]) => denyRawNetwork(state, 'node:http2', 'connect')) as typeof http2.connect; + http2.createServer = ((..._args: unknown[]) => denyRawNetwork(state, 'node:http2', 'createServer')) as typeof http2.createServer; + http2.createSecureServer = ((..._args: unknown[]) => denyRawNetwork(state, 'node:http2', 'createSecureServer')) as typeof http2.createSecureServer; + dns.Resolver = createDeniedConstructor(state, 'node:dns', 'Resolver') as typeof dns.Resolver; + dns.promises.Resolver = createDeniedConstructor(state, 'node:dns', 'promises.Resolver') as typeof dns.promises.Resolver; + for (const name of DNS_DENIED_FUNCTIONS) { + if (typeof dns[name] === 'function') { + (dns as Record)[name] = (..._args: unknown[]) => denyRawNetwork(state, 'node:dns', name); + } + if (typeof dns.promises[name] === 'function') { + (dns.promises as Record)[name] = (..._args: unknown[]) => + denyRawNetwork(state, 'node:dns', `promises.${name}`); + } + } + dns.promises.lookup = ((..._args: unknown[]) => denyRawNetwork(state, 'node:dns', 'promises.lookup')) as typeof dns.promises.lookup; + workerThreads.Worker = createDeniedConstructor(state, 'node:worker_threads', 'Worker', 'shell.exec') as unknown as typeof workerThreads.Worker; + + childProcess.exec = ((command: string, ..._args: unknown[]) => denyChildProcess(state, 'exec', command)) as unknown as typeof childProcess.exec; + childProcess.execFile = ((file: string, ..._args: unknown[]) => denyChildProcess(state, 'execFile', file)) as unknown as typeof childProcess.execFile; + childProcess.execFileSync = ((file: string, ..._args: unknown[]) => denyChildProcess(state, 'execFileSync', file)) as typeof childProcess.execFileSync; + childProcess.execSync = ((command: string, ..._args: unknown[]) => denyChildProcess(state, 'execSync', command)) as typeof childProcess.execSync; + childProcess.fork = ((modulePath: string, ..._args: unknown[]) => denyChildProcess(state, 'fork', modulePath)) as typeof childProcess.fork; + childProcess.spawn = ((command: string, ..._args: unknown[]) => denyChildProcess(state, 'spawn', command)) as typeof childProcess.spawn; + childProcess.spawnSync = ((command: string, ..._args: unknown[]) => denyChildProcess(state, 'spawnSync', command)) as typeof childProcess.spawnSync; + + syncBuiltinESMExports(); + globalThis.fetch = installFetchBridge(state); + (globalThis as Record)[PREVIEW_PROCESS_STATE] = state; + return state; +} + +function snapshotPreviewTransportState(previewTransport: PreviewTransport): LocalPreviewTransportState { + const internal = previewTransport as unknown as { + sequence: number; + data: Map; + writtenPaths: Set; + receiptsByReference: Map; + }; + return { + sequence: internal.sequence ?? 0, + data: Object.fromEntries((internal.data ?? new Map()).entries()), + writtenPaths: [...(internal.writtenPaths ?? new Set())], + receiptsByReference: Object.fromEntries((internal.receiptsByReference ?? new Map()).entries()) + }; +} + +function restorePreviewTransportState( + previewTransport: PreviewTransport, + state: LocalPreviewTransportState +): void { + const internal = previewTransport as unknown as { + sequence: number; + data: Map; + writtenPaths: Set; + receiptsByReference: Map; + }; + internal.sequence = state.sequence; + internal.data = new Map(Object.entries(state.data ?? {})); + internal.writtenPaths = new Set(state.writtenPaths ?? []); + internal.receiptsByReference = new Map(Object.entries(state.receiptsByReference ?? {})); +} + +export function getPreviewProcessState(): PreviewProcessState | undefined { + return (globalThis as Record)[PREVIEW_PROCESS_STATE] as + | PreviewProcessState + | undefined; +} + +export function transportActionToPreviewAction(action: TransportPreviewAction): PreviewAction { + return { + kind: action.kind, + status: action.status, + provider: action.provider, + resource: action.resource, + ...(action.id ? { id: action.id } : {}), + data: { + ...action.data, + ...(action.method ? { method: action.method } : {}), + ...(action.path ? { path: action.path } : {}), + ...(action.parameters ? { parameters: action.parameters } : {}), + ...(action.body !== undefined ? { body: action.body } : {}), + ...(action.simulatedReceipt ? { simulatedReceipt: action.simulatedReceipt } : {}) + } + }; +} + +function installFetchBridge(state: PreviewProcessState): typeof globalThis.fetch { + return (async (input: URL | RequestInfo, init?: RequestInit) => { + const request = toRequest(input, init); + const message: LocalPreviewFetchRequestMessage = { + type: 'fetch', + requestId: `fetch_${Math.random().toString(16).slice(2, 10)}`, + method: request.method.toUpperCase(), + url: request.url, + headers: [...request.headers.entries()], + ...(request.method !== 'GET' && request.method !== 'HEAD' + ? { bodyBase64: Buffer.from(await request.arrayBuffer()).toString('base64') } + : {}) + }; + const response = await state.fetchFromParent(message); + state.recordAction(response.action); + if (!response.ok || !response.response) { + throw new Error(response.error ?? `invoke parent fetch bridge failed for ${request.method} ${request.url}`); + } + return new Response(Buffer.from(response.response.bodyBase64, 'base64'), { + status: response.response.status, + headers: response.response.headers + }); + }) as typeof globalThis.fetch; +} + +function denyRawNetwork(state: PreviewProcessState, moduleName: string, call: string): never { + state.recordAction({ + kind: 'http.read', + status: 'denied', + data: { + module: moduleName, + call + } + }); + throw new Error(`invoke: preview worker denied raw network ${moduleName}.${call}`); +} + +function createDeniedConstructor( + state: PreviewProcessState, + moduleName: string, + call: string, + kind: PreviewAction['kind'] = 'http.read' +): new (...args: unknown[]) => never { + return class DeniedConstructor { + constructor(..._args: unknown[]) { + if (kind === 'shell.exec') { + state.recordAction({ + kind, + status: 'denied', + data: { + module: moduleName, + call + } + }); + throw new Error(`invoke: preview worker denied ${moduleName}.${call}`); + } + denyRawNetwork(state, moduleName, call); + } + } as unknown as new (...args: unknown[]) => never; +} + +function denyChildProcess(state: PreviewProcessState, call: string, cmd?: unknown): never { + state.recordAction({ + kind: 'shell.exec', + status: 'denied', + data: { + call, + ...(cmd !== undefined ? { cmd: stringifyCommand(cmd) } : {}) + } + }); + throw new Error(`invoke: preview worker denied child_process.${call}`); +} + +function stringifyCommand(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value); +} + +function toRequest(input: URL | RequestInfo, init?: RequestInit): Request { + if (input instanceof Request) return new Request(input, init); + if (input instanceof URL) return new Request(input, init); + return new Request(input, init); +} diff --git a/packages/runtime/src/local-preview-redaction.ts b/packages/runtime/src/local-preview-redaction.ts new file mode 100644 index 00000000..1f21302f --- /dev/null +++ b/packages/runtime/src/local-preview-redaction.ts @@ -0,0 +1,55 @@ +import { redactEventValue } from '@agentworkforce/events'; + +const REDACTED = '[REDACTED]'; +const SECRET_TEXT_PATTERNS: readonly RegExp[] = [ + /\bBearer\s+[A-Za-z0-9._~+/=-]+\b/gi, + /gh[pousr]_[A-Za-z0-9]{20,}/g, + /xox[baprs]-[A-Za-z0-9-]+/g, + /sk-(?:proj-)?[A-Za-z0-9_-]{16,}/g, + /relay_(?:pa|ws)_[A-Za-z0-9._-]+/g, + /x-access-token:[^@\s/'"]+/gi, + /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+/g, + /(OPENAI|ANTHROPIC|OPENCODE|OPENROUTER|GOOGLE|AWS|S3|DAYTONA|RELAY|SLACK|CLOUD|WORKFORCE|CODEX|CLAUDE)[A-Z0-9_]*(?:API)?_?(?:KEY|TOKEN|SECRET|CREDENTIALS?)\s*[:=]\s*["']?[^"'\s,}]+/gi, + /"?(?:access_token|refresh_token|api_key|secret|password|private_key|client_secret|credential_json)"?\s*:\s*"(?:\\.|[^"\\])*"/gi, +]; + +const EXTRA_SENSITIVE_KEYS = [ + /access[_-]?token/i, + /refresh[_-]?token/i, + /client[_-]?secret/i, + /credential_json/i, + /session[_-]?token/i, +]; + +export function redactLocalPreviewValue(value: T): T { + return redactUnknown(redactEventValue(value, { additionalSensitiveKeys: EXTRA_SENSITIVE_KEYS })) as T; +} + +export function redactLocalPreviewText(value: string): string { + return SECRET_TEXT_PATTERNS.reduce((redacted, pattern) => redactPattern(redacted, pattern), value); +} + +export function isCredentialLikeValue(value: string): boolean { + return redactLocalPreviewText(value) !== value; +} + +function redactUnknown(value: unknown): unknown { + if (typeof value === 'string') return redactLocalPreviewText(value); + if (Array.isArray(value)) return value.map((entry) => redactUnknown(entry)); + if (!value || typeof value !== 'object') return value; + + const out: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + out[key] = redactUnknown(nested); + } + return out; +} + +function redactPattern(value: string, pattern: RegExp): string { + return value.replace(pattern, (match) => { + const separator = match.includes(':') ? ':' : match.includes('=') ? '=' : ''; + if (!separator) return REDACTED; + const [prefix] = match.split(separator); + return `${prefix}${separator}${REDACTED}`; + }); +} diff --git a/packages/runtime/src/local-preview.test.ts b/packages/runtime/src/local-preview.test.ts new file mode 100644 index 00000000..5541c0a8 --- /dev/null +++ b/packages/runtime/src/local-preview.test.ts @@ -0,0 +1,818 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { createServer } from 'node:http'; +import { copyFile, cp, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { decodeEventFrame } from '@agentworkforce/events'; +import { + __setPreviewWorkerSpawnForTest, + executeLocalRun, + stopChildProcess +} from './local-preview.js'; +import type { RunRequestV1 } from './run-contracts.js'; + +function previewRequest(args: { + tempDir: string; + id: string; + allowedHttp: Array<{ method: string; urlGlob: string }>; + model?: 'stub' | 'fixture' | 'live'; + personaModel?: string; +}): RunRequestV1 { + return { + schemaVersion: 1, + agent: { + schemaVersion: 1, + sourceKind: 'single-file', + sourcePath: path.join(args.tempDir, 'agent.ts'), + sourceDigest: 'test-digest', + handlerEntry: path.join(args.tempDir, 'agent.ts'), + compileWarnings: [], + persona: { + id: args.id, + intent: 'local-preview', + tags: [], + description: `${args.id} persona`, + skills: [], + harness: 'claude', + model: args.personaModel ?? 'local-preview-stub', + systemPrompt: 'test', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + cloud: true, + onEvent: './agent.ts' + }, + agent: { + triggers: {}, + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + watch: [] + } + }, + event: decodeEventFrame({ + id: `evt_${args.id}`, + workspace: 'ws-local', + type: 'cron.tick', + occurredAt: '2026-07-15T09:00:00.000Z', + name: 'scan', + cron: '0 9 * * *' + }).frame, + mode: 'preview', + inputs: {}, + policy: { + reads: 'live', + writes: 'preview', + model: args.model ?? 'stub', + shell: 'simulate', + compose: 'preview', + allowedHttp: args.allowedHttp + }, + state: { + schemaVersion: 1, + kind: 'empty', + fidelity: 'simulated' + } + } as unknown as RunRequestV1; +} + +test('executeLocalRun: empty live allowlist denies GET before network', async () => { + let hits = 0; + const server = createServer((_req, res) => { + hits += 1; + res.statusCode = 200; + res.end('ok'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const url = `http://127.0.0.1:${address.port}/denied`; + + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'wf-preview-empty-allow-')); + const bundlePath = path.join(tempDir, 'bundle.mjs'); + await writeFile( + bundlePath, + `export default async function handler() { await fetch('${url}'); }\n`, + 'utf8' + ); + + try { + const result = await executeLocalRun({ + request: previewRequest({ tempDir, id: 'empty-allowlist-test', allowedHttp: [] }), + bundlePath + }); + assert.equal(result.exitCode, 1); + assert.equal(result.record.status, 'failed'); + assert.match(String(result.record.error ?? ''), /undeclared live read/i); + assert.equal(hits, 0); + } finally { + server.close(); + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('executeLocalRun: method mismatch denies before network even when another method is declared', async () => { + let hits = 0; + const server = createServer((_req, res) => { + hits += 1; + res.statusCode = 200; + res.end('ok'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const url = `http://127.0.0.1:${address.port}/method-mismatch`; + + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'wf-preview-method-mismatch-')); + const bundlePath = path.join(tempDir, 'bundle.mjs'); + await writeFile( + bundlePath, + `export default async function handler() { await fetch('${url}'); }\n`, + 'utf8' + ); + + try { + const result = await executeLocalRun({ + request: previewRequest({ + tempDir, + id: 'method-mismatch-test', + allowedHttp: [{ method: 'HEAD', urlGlob: url }] + }), + bundlePath + }); + assert.equal(result.exitCode, 1); + assert.equal(result.record.status, 'failed'); + assert.match(String(result.record.error ?? ''), /undeclared live read/i); + assert.equal(hits, 0); + } finally { + server.close(); + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('executeLocalRun: exact and glob live allow rules both permit declared GETs', async () => { + const hits: string[] = []; + const server = createServer((req, res) => { + hits.push(req.url ?? '/'); + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ ok: true, path: req.url ?? '/' })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + const baseUrl = `http://127.0.0.1:${address.port}`; + + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'wf-preview-allow-')); + const bundlePath = path.join(tempDir, 'bundle.mjs'); + await writeFile( + bundlePath, + `export default async function handler() { + await fetch('${baseUrl}/exact'); + await fetch('${baseUrl}/glob/front-page'); + }\n`, + 'utf8' + ); + + try { + const result = await executeLocalRun({ + request: previewRequest({ + tempDir, + id: 'allow-rules-test', + allowedHttp: [ + { method: 'GET', urlGlob: `${baseUrl}/exact` }, + { method: 'GET', urlGlob: `${baseUrl}/glob/*` } + ] + }), + bundlePath + }); + assert.equal(result.exitCode, 0); + assert.equal(result.record.status, 'succeeded'); + assert.deepEqual(hits, ['/exact', '/glob/front-page']); + } finally { + server.close(); + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('executeLocalRun: redirected live read is denied before blocked target fetch', async () => { + let allowedHits = 0; + let blockedHits = 0; + const blockedServer = createServer((_req, res) => { + blockedHits += 1; + res.statusCode = 204; + res.end(); + }); + await new Promise((resolve) => blockedServer.listen(0, '127.0.0.1', () => resolve())); + const blockedAddress = blockedServer.address(); + assert.ok(blockedAddress && typeof blockedAddress === 'object'); + const blockedUrl = `http://127.0.0.1:${blockedAddress.port}/blocked`; + + const allowedServer = createServer((_req, res) => { + allowedHits += 1; + res.statusCode = 302; + res.setHeader('location', blockedUrl); + res.end(); + }); + await new Promise((resolve) => allowedServer.listen(0, '127.0.0.1', () => resolve())); + const allowedAddress = allowedServer.address(); + assert.ok(allowedAddress && typeof allowedAddress === 'object'); + const allowedUrl = `http://127.0.0.1:${allowedAddress.port}/allowed`; + + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'wf-preview-test-')); + const bundlePath = path.join(tempDir, 'bundle.mjs'); + await writeFile( + bundlePath, + `export default async function handler() { await fetch('${allowedUrl}'); }\n`, + 'utf8' + ); + + const request = { + schemaVersion: 1, + agent: { + schemaVersion: 1, + sourceKind: 'single-file', + sourcePath: path.join(tempDir, 'agent.ts'), + sourceDigest: 'test-digest', + handlerEntry: path.join(tempDir, 'agent.ts'), + compileWarnings: [], + persona: { + id: 'redirect-test', + intent: 'local-preview', + tags: [], + description: 'redirect test persona', + skills: [], + harness: 'claude', + model: 'local-preview-stub', + systemPrompt: 'test', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + cloud: true, + onEvent: './agent.ts' + }, + agent: { + triggers: [], + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + watch: [] + } + }, + event: decodeEventFrame({ + id: 'evt_redirect', + workspace: 'ws-local', + type: 'cron.tick', + occurredAt: '2026-07-15T09:00:00.000Z', + name: 'scan', + cron: '0 9 * * *' + }).frame, + mode: 'preview', + inputs: {}, + policy: { + reads: 'live', + writes: 'preview', + model: 'stub', + shell: 'simulate', + compose: 'preview', + allowedHttp: [{ method: 'GET', urlGlob: allowedUrl }] + }, + state: { + schemaVersion: 1, + kind: 'empty', + fidelity: 'simulated' + } + } as unknown as RunRequestV1; + + try { + const result = await executeLocalRun({ request, bundlePath }); + assert.equal(result.exitCode, 1); + assert.equal(result.record.status, 'failed'); + assert.match(String(result.record.error ?? ''), /redirected live read/i); + assert.equal(allowedHits, 1); + assert.equal(blockedHits, 0); + } finally { + allowedServer.close(); + blockedServer.close(); + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('executeLocalRun: model stub stays deterministic inside the worker', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'wf-preview-model-stub-')); + const bundlePath = path.join(tempDir, 'bundle.mjs'); + await writeFile( + bundlePath, + ` + export default async function handler(ctx) { + ctx.log('info', await ctx.llm.complete('Return ONLY compact JSON with this shape:\\n[{"id":1,"title":"Agent"}]')); + } + `, + 'utf8' + ); + + try { + const result = await executeLocalRun({ + request: previewRequest({ tempDir, id: 'model-stub', allowedHttp: [], model: 'stub' }), + bundlePath + }); + assert.equal(result.exitCode, 0); + assert.equal(result.record.status, 'succeeded'); + const action = result.record.actions.find((entry) => entry.kind === 'model.complete'); + assert.equal(action?.data?.source, 'simulated'); + const logs = ((result.record.extensions as Record).logs ?? []) as string[]; + assert.ok(logs.some((line) => line.includes('Agent infrastructure stories worth monitoring.'))); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('executeLocalRun: model fixture mode consumes explicit deterministic fixtures', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'wf-preview-model-fixture-')); + const bundlePath = path.join(tempDir, 'bundle.mjs'); + await writeFile( + bundlePath, + ` + export default async function handler(ctx) { + ctx.log('info', await ctx.llm.complete('fixture please')); + } + `, + 'utf8' + ); + + try { + const result = await executeLocalRun({ + request: previewRequest({ tempDir, id: 'model-fixture', allowedHttp: [], model: 'fixture' }), + bundlePath, + modelFixtures: [{ output: 'fixture-output' }] + }); + assert.equal(result.exitCode, 0); + assert.equal(result.record.status, 'succeeded'); + const action = result.record.actions.find((entry) => entry.kind === 'model.complete'); + assert.equal(action?.data?.source, 'fixture'); + assert.equal(result.state.model?.fixtureCursor, 1); + const logs = ((result.record.extensions as Record).logs ?? []) as string[]; + assert.ok(logs.some((line) => line.includes('fixture-output'))); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('executeLocalRun: model live mode uses the parent-side adapter without exposing credentials to the worker', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'wf-preview-model-live-')); + const bundlePath = path.join(tempDir, 'bundle.mjs'); + const previousOpenAi = process.env.OPENAI_API_KEY; + await writeFile( + bundlePath, + ` + export default async function handler(ctx) { + const output = await ctx.llm.complete('live please'); + if (process.env.OPENAI_API_KEY) throw new Error('worker unexpectedly received OPENAI_API_KEY'); + ctx.log('info', output); + } + `, + 'utf8' + ); + + try { + process.env.OPENAI_API_KEY = 'sk-live-parent-only-secret'; + const result = await executeLocalRun({ + request: previewRequest({ + tempDir, + id: 'model-live', + allowedHttp: [], + model: 'live', + personaModel: 'openai/gpt-5.5' + }), + bundlePath, + modelAdapter: { + complete: async (prompt) => `live-output:${prompt}` + } + }); + assert.equal(result.exitCode, 0); + assert.equal(result.record.status, 'succeeded'); + const action = result.record.actions.find((entry) => entry.kind === 'model.complete'); + assert.equal(action?.data?.source, 'current'); + const logs = ((result.record.extensions as Record).logs ?? []) as string[]; + assert.ok(logs.some((line) => line.includes('live-output:live please'))); + } finally { + if (previousOpenAi === undefined) delete process.env.OPENAI_API_KEY; + else process.env.OPENAI_API_KEY = previousOpenAi; + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('executeLocalRun: overall timeout tears the worker down and leaves no orphan staging directories', async () => { + const previousOverall = process.env.WF_LOCAL_PREVIEW_OVERALL_TIMEOUT_MS; + const previousForceKill = process.env.WF_LOCAL_PREVIEW_FORCE_KILL_TIMEOUT_MS; + process.env.WF_LOCAL_PREVIEW_OVERALL_TIMEOUT_MS = '200'; + process.env.WF_LOCAL_PREVIEW_FORCE_KILL_TIMEOUT_MS = '100'; + + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'wf-preview-timeout-')); + const bundlePath = path.join(tempDir, 'bundle.mjs'); + await writeFile( + bundlePath, + 'export default async function handler() { await new Promise(() => {}); }\n', + 'utf8' + ); + + const request = { + schemaVersion: 1, + agent: { + schemaVersion: 1, + sourceKind: 'single-file', + sourcePath: path.join(tempDir, 'agent.ts'), + sourceDigest: 'test-timeout-digest', + handlerEntry: path.join(tempDir, 'agent.ts'), + compileWarnings: [], + persona: { + id: 'timeout-test', + intent: 'local-preview', + tags: [], + description: 'timeout test persona', + skills: [], + harness: 'claude', + model: 'local-preview-stub', + systemPrompt: 'test', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + cloud: true, + onEvent: './agent.ts' + }, + agent: { + triggers: [], + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + watch: [] + } + }, + event: decodeEventFrame({ + id: 'evt_timeout', + workspace: 'ws-local', + type: 'cron.tick', + occurredAt: '2026-07-15T09:00:00.000Z', + name: 'scan', + cron: '0 9 * * *' + }).frame, + mode: 'preview', + inputs: {}, + policy: { + reads: 'fixtures', + writes: 'preview', + model: 'stub', + shell: 'simulate', + compose: 'preview', + allowedHttp: [] + }, + state: { + schemaVersion: 1, + kind: 'empty', + fidelity: 'simulated' + } + } as unknown as RunRequestV1; + + const stagingRoot = path.join(process.cwd(), '.workforce'); + const before = await readdir(stagingRoot).catch((): string[] => []); + + try { + await assert.rejects( + () => executeLocalRun({ request, bundlePath }), + /invoke worker timed out after 200ms/ + ); + const after = await readdir(stagingRoot).catch((): string[] => []); + const leaked = after.filter((entry) => + entry.startsWith('local-preview-worker-') && !before.includes(entry) + ); + assert.deepEqual(leaked, []); + } finally { + process.env.WF_LOCAL_PREVIEW_OVERALL_TIMEOUT_MS = previousOverall; + process.env.WF_LOCAL_PREVIEW_FORCE_KILL_TIMEOUT_MS = previousForceKill; + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('stopChildProcess: settles after SIGKILL timeout even when close never arrives', async () => { + const previousForceKill = process.env.WF_LOCAL_PREVIEW_FORCE_KILL_TIMEOUT_MS; + const previousKillSettle = process.env.WF_LOCAL_PREVIEW_KILL_SETTLE_TIMEOUT_MS; + process.env.WF_LOCAL_PREVIEW_FORCE_KILL_TIMEOUT_MS = '20'; + process.env.WF_LOCAL_PREVIEW_KILL_SETTLE_TIMEOUT_MS = '20'; + + class FakeChild extends EventEmitter { + connected = true; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + kills: string[] = []; + kill(signal: NodeJS.Signals): boolean { + this.kills.push(signal); + return true; + } + } + + const child = new FakeChild(); + try { + await assert.doesNotReject(() => stopChildProcess(child as unknown as import('node:child_process').ChildProcess)); + assert.deepEqual(child.kills, ['SIGTERM', 'SIGKILL']); + assert.equal(child.listenerCount('close'), 0); + } finally { + process.env.WF_LOCAL_PREVIEW_FORCE_KILL_TIMEOUT_MS = previousForceKill; + process.env.WF_LOCAL_PREVIEW_KILL_SETTLE_TIMEOUT_MS = previousKillSettle; + } +}); + +test('executeLocalRun: no-close child stop path still rejects promptly and cleans staged dirs', async () => { + const previousOverall = process.env.WF_LOCAL_PREVIEW_OVERALL_TIMEOUT_MS; + const previousForceKill = process.env.WF_LOCAL_PREVIEW_FORCE_KILL_TIMEOUT_MS; + const previousKillSettle = process.env.WF_LOCAL_PREVIEW_KILL_SETTLE_TIMEOUT_MS; + process.env.WF_LOCAL_PREVIEW_OVERALL_TIMEOUT_MS = '40'; + process.env.WF_LOCAL_PREVIEW_FORCE_KILL_TIMEOUT_MS = '10'; + process.env.WF_LOCAL_PREVIEW_KILL_SETTLE_TIMEOUT_MS = '10'; + + class FakeChild extends EventEmitter { + connected = true; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + stderr = new EventEmitter() as EventEmitter & { setEncoding: (encoding: string) => void }; + kills: string[] = []; + + constructor() { + super(); + this.stderr.setEncoding = () => undefined; + setTimeout(() => { + this.emit('message', { type: 'ready' }); + }, 0); + } + + send(_message: unknown, callback?: (error: Error | null) => void): boolean { + callback?.(null); + return true; + } + + kill(signal: NodeJS.Signals): boolean { + this.kills.push(signal); + return true; + } + } + + let fakeChild: FakeChild | undefined; + __setPreviewWorkerSpawnForTest((() => { + fakeChild = new FakeChild(); + return fakeChild; + }) as unknown as typeof import('node:child_process').spawn); + + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'wf-preview-no-close-')); + const bundlePath = path.join(tempDir, 'bundle.mjs'); + await writeFile( + bundlePath, + 'export default async function handler() { return undefined; }\n', + 'utf8' + ); + + const request = { + schemaVersion: 1, + agent: { + schemaVersion: 1, + sourceKind: 'single-file', + sourcePath: path.join(tempDir, 'agent.ts'), + sourceDigest: 'test-no-close-digest', + handlerEntry: path.join(tempDir, 'agent.ts'), + compileWarnings: [], + persona: { + id: 'no-close-test', + intent: 'local-preview', + tags: [], + description: 'no close test persona', + skills: [], + harness: 'claude', + model: 'local-preview-stub', + systemPrompt: 'test', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, + cloud: true, + onEvent: './agent.ts' + }, + agent: { + triggers: [], + schedules: [{ name: 'scan', cron: '0 9 * * *' }], + watch: [] + } + }, + event: decodeEventFrame({ + id: 'evt_no_close', + workspace: 'ws-local', + type: 'cron.tick', + occurredAt: '2026-07-15T09:00:00.000Z', + name: 'scan', + cron: '0 9 * * *' + }).frame, + mode: 'preview', + inputs: {}, + policy: { + reads: 'fixtures', + writes: 'preview', + model: 'stub', + shell: 'simulate', + compose: 'preview', + allowedHttp: [] + }, + state: { + schemaVersion: 1, + kind: 'empty', + fidelity: 'simulated' + } + } as unknown as RunRequestV1; + + const stagingRoot = path.join(process.cwd(), '.workforce'); + const before = await readdir(stagingRoot).catch((): string[] => []); + + try { + await assert.rejects( + () => executeLocalRun({ request, bundlePath }), + /invoke worker timed out after 40ms/ + ); + const after = await readdir(stagingRoot).catch((): string[] => []); + const leaked = after.filter((entry) => + entry.startsWith('local-preview-worker-') && !before.includes(entry) + ); + assert.deepEqual(leaked, []); + assert.ok(fakeChild); + assert.deepEqual(fakeChild.kills, ['SIGTERM', 'SIGKILL']); + assert.equal(fakeChild.listenerCount('close'), 0); + } finally { + __setPreviewWorkerSpawnForTest(undefined); + process.env.WF_LOCAL_PREVIEW_OVERALL_TIMEOUT_MS = previousOverall; + process.env.WF_LOCAL_PREVIEW_FORCE_KILL_TIMEOUT_MS = previousForceKill; + process.env.WF_LOCAL_PREVIEW_KILL_SETTLE_TIMEOUT_MS = previousKillSettle; + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test('executeLocalRun: installed runtime stages under the invocation workspace and grants consumer/runtime roots', async () => { + await withInstalledRuntimeCopy(async (consumerRoot, installed) => { + class ReadyChild extends EventEmitter { + connected = true; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + stderr = new EventEmitter() as EventEmitter & { setEncoding: (encoding: string) => void }; + + constructor() { + super(); + this.stderr.setEncoding = () => undefined; + setTimeout(() => { + this.emit('message', { type: 'ready' }); + }, 0); + } + + send(message: unknown, callback?: (error: Error | null) => void): boolean { + if ((message as { type?: string } | undefined)?.type === 'init') { + setTimeout(() => { + this.emit('message', { + type: 'result', + result: { + ok: true, + exitCode: 0, + record: { + schemaVersion: 2, + status: 'succeeded', + actions: [], + extensions: {} + }, + state: {} + } + }); + this.connected = false; + this.exitCode = 0; + this.emit('close', 0); + }, 0); + } + callback?.(null); + return true; + } + + kill(_signal: NodeJS.Signals): boolean { + return true; + } + } + + const previousCwd = process.cwd(); + const tempDir = await mkdtemp(path.join(consumerRoot, 'preview-root-')); + const bundlePath = path.join(tempDir, 'bundle.mjs'); + await writeFile(bundlePath, 'export default async function handler() { return undefined; }\n', 'utf8'); + + let spawnArgs: string[] | undefined; + let spawnCwd: string | URL | undefined; + installed.__setPreviewWorkerSpawnForTest((( + _cmd: string, + args: readonly string[], + options?: import('node:child_process').SpawnOptions + ) => { + spawnArgs = [...args]; + spawnCwd = options?.cwd; + return new ReadyChild(); + }) as unknown as typeof import('node:child_process').spawn); + + try { + process.chdir(consumerRoot); + const result = await installed.executeLocalRun({ + request: previewRequest({ tempDir, id: 'installed-layout', allowedHttp: [] }), + bundlePath + }); + assert.equal(result.exitCode, 0); + assert.ok(spawnArgs); + const readRoots = spawnArgs + .filter((entry) => entry.startsWith('--allow-fs-read=')) + .map((entry) => entry.slice('--allow-fs-read='.length)); + assert.equal(spawnCwd, consumerRoot); + assert.ok(readRoots.includes(path.join(consumerRoot, 'node_modules'))); + assert.ok(readRoots.some((entry) => + entry.startsWith(path.join(consumerRoot, '.workforce', 'local-preview-worker-')) + )); + assert.ok(!readRoots.some((entry) => entry.startsWith(path.join(consumerRoot, 'node_modules', '.workforce')))); + assert.ok(!readRoots.some((entry) => entry.startsWith(path.join(consumerRoot, 'node_modules', 'node_modules')))); + } finally { + installed.__setPreviewWorkerSpawnForTest(undefined); + process.chdir(previousCwd); + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); + +test('executeLocalRun: installed runtime surfaces redacted child stderr when the worker exits before readiness', async () => { + await withInstalledRuntimeCopy(async (consumerRoot, installed) => { + class EarlyExitChild extends EventEmitter { + connected = true; + exitCode: number | null = null; + signalCode: NodeJS.Signals | null = null; + stderr = new EventEmitter() as EventEmitter & { setEncoding: (encoding: string) => void }; + + constructor() { + super(); + this.stderr.setEncoding = () => undefined; + setTimeout(() => { + this.stderr.emit( + 'data', + 'Error: ERR_ACCESS_DENIED: fs.read denied for /tmp/preview\nOPENAI_API_KEY=sk-live-secret-value\n' + ); + this.connected = false; + this.exitCode = 1; + this.emit('close', 1); + }, 0); + } + + send(_message: unknown, callback?: (error: Error | null) => void): boolean { + callback?.(null); + return true; + } + + kill(_signal: NodeJS.Signals): boolean { + return true; + } + } + + const previousCwd = process.cwd(); + const tempDir = await mkdtemp(path.join(consumerRoot, 'preview-root-')); + const bundlePath = path.join(tempDir, 'bundle.mjs'); + await writeFile(bundlePath, 'export default async function handler() { return undefined; }\n', 'utf8'); + installed.__setPreviewWorkerSpawnForTest((() => new EarlyExitChild()) as unknown as typeof import('node:child_process').spawn); + + try { + process.chdir(consumerRoot); + await assert.rejects( + () => installed.executeLocalRun({ + request: previewRequest({ tempDir, id: 'installed-layout-stderr', allowedHttp: [] }), + bundlePath + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /exited before signaling readiness/); + assert.match(error.message, /worker stderr:/); + assert.match(error.message, /ERR_ACCESS_DENIED/); + assert.match(error.message, /\[REDACTED\]/); + assert.doesNotMatch(error.message, /sk-live-secret-value/); + return true; + } + ); + } finally { + installed.__setPreviewWorkerSpawnForTest(undefined); + process.chdir(previousCwd); + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); + +async function withInstalledRuntimeCopy( + fn: ( + consumerRoot: string, + installed: { + executeLocalRun: typeof executeLocalRun; + __setPreviewWorkerSpawnForTest: typeof __setPreviewWorkerSpawnForTest; + } + ) => Promise +): Promise { + const runtimePackageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const consumerRoot = await mkdtemp(path.join(runtimePackageRoot, '.installed-preview-consumer-')); + const installedRuntimeRoot = path.join(consumerRoot, 'node_modules', '@agentworkforce', 'runtime'); + await mkdir(path.dirname(installedRuntimeRoot), { recursive: true }); + await cp(path.join(runtimePackageRoot, 'dist'), path.join(installedRuntimeRoot, 'dist'), { recursive: true }); + await copyFile(path.join(runtimePackageRoot, 'package.json'), path.join(installedRuntimeRoot, 'package.json')); + try { + const installed = await import(`${pathToFileURL(path.join(installedRuntimeRoot, 'dist', 'local-preview.js')).href}?installed=${Date.now()}`) as { + executeLocalRun: typeof executeLocalRun; + __setPreviewWorkerSpawnForTest: typeof __setPreviewWorkerSpawnForTest; + }; + return await fn(consumerRoot, installed); + } finally { + await rm(consumerRoot, { recursive: true, force: true }); + } +} diff --git a/packages/runtime/src/local-preview.ts b/packages/runtime/src/local-preview.ts new file mode 100644 index 00000000..7fb37b76 --- /dev/null +++ b/packages/runtime/src/local-preview.ts @@ -0,0 +1,966 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { copyFile, mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createDefaultLlm } from './cloud-llm.js'; +import { + isCredentialLikeValue, + redactLocalPreviewText +} from './local-preview-redaction.js'; +import type { + ExecuteLocalRunOptions, + ExecuteLocalRunResult, + LocalHttpFixture, + LocalPreviewFetchRequestMessage, + LocalPreviewFetchResponseMessage, + LocalPreviewGuardConfig, + LocalPreviewModelRequestMessage, + LocalPreviewModelResponseMessage, + LocalPreviewMemoryEntry, + LocalPreviewState, + LocalPreviewWorkerInboundMessage, + LocalPreviewWorkerInitMessage, + LocalPreviewWorkerOutboundMessage, + LocalPreviewWorkerPayload, + LocalPreviewWorkerResult +} from './local-preview-contract.js'; + +export type { + ExecuteLocalRunOptions, + ExecuteLocalRunResult, + LocalHttpFixture, + LocalModelFixture, + LocalPreviewMemoryEntry, + LocalPreviewState +} from './local-preview-contract.js'; + +const WORKER_ENV_KEEP = new Set([ + 'HOME', + 'LANG', + 'LC_ALL', + 'LOGNAME', + 'NODE_ENV', + 'PATH', + 'PWD', + 'SHELL', + 'TERM', + 'TMP', + 'TMPDIR', + 'TZ', + 'USER', + 'WF_LOCAL_PREVIEW_FETCH_TIMEOUT_MS', + 'WF_LOCAL_PREVIEW_MODEL_TIMEOUT_MS', + 'WF_LOCAL_PREVIEW_FORCE_KILL_TIMEOUT_MS', + 'WF_LOCAL_PREVIEW_OVERALL_TIMEOUT_MS', + 'WF_LOCAL_PREVIEW_READY_TIMEOUT_MS' +]); + +const SECRET_INPUT_NAMES = new Set([ + 'ANTHROPIC_API_KEY', + 'CLAUDE_CODE_OAUTH_TOKEN', + 'CLOUD_API_ACCESS_TOKEN', + 'CODEX_OAUTH_TOKEN', + 'OPENAI_API_KEY', + 'OPENCODE_API_KEY', + 'RELAYFILE_TOKEN', + 'RELAY_API_KEY', + 'SLACK_BOT_TOKEN', + 'SLACK_TOKEN', + 'TELEGRAM_BOT_TOKEN', + 'WORKFORCE_AGENT_TOKEN', + 'WORKFORCE_WORKSPACE_TOKEN' +]); + +const SECRET_INPUT_SEGMENT = /(^|_)(ACCESS_KEY|API_KEY|AUTH|BEARER|CLIENT_SECRET|COOKIE|CREDENTIAL|CREDENTIALS|OAUTH|PASSWORD|PASSWD|PRIVATE_KEY|SECRET|SESSION|TOKEN|WEBHOOK_SECRET)(_|$)/u; +const STRIP_LIVE_REQUEST_HEADERS = new Set([ + 'authorization', + 'proxy-authorization', + 'cookie', + 'set-cookie' +]); +const MAX_REDIRECTS = 8; +const REDACTED_INPUT_VALUE = '[redacted]'; +const WORKER_ENTRY_PATH = fileURLToPath(new URL('./local-preview-child.js', import.meta.url)); +const WORKER_RUNTIME_ROOT = fileURLToPath(new URL('.', import.meta.url)); +const require = createRequire(import.meta.url); +let spawnPreviewWorkerProcess: typeof spawn = spawn; + +export function __setPreviewWorkerSpawnForTest( + value: typeof spawn | undefined +): void { + spawnPreviewWorkerProcess = value ?? spawn; +} + +export async function executeLocalRun( + options: ExecuteLocalRunOptions +): Promise { + assertPermissionBoundarySupport(); + + const sanitizedInputs = sanitizeWorkerInputs(options.inputs ?? {}); + const control: LocalPreviewGuardConfig = { + policy: options.request.policy, + fixtures: options.httpFixtures ?? [], + ...(options.state?.transport ? { transportState: options.state.transport } : {}), + ...(options.now ? { clockNow: options.now().toISOString() } : {}) + }; + const payload: LocalPreviewWorkerPayload = { + request: { + ...options.request, + inputs: { ...sanitizedInputs.ctxInputs } + }, + bundlePath: options.bundlePath, + inputs: sanitizedInputs.ctxInputs, + ...(options.state ? { state: options.state } : {}), + ...(options.replayProvenance ? { replayProvenance: options.replayProvenance } : {}), + ...(options.sourceFidelity ? { sourceFidelity: options.sourceFidelity } : {}) + }; + const liveModelAdapter = options.modelAdapter ?? createDefaultLlm({ + persona: options.request.agent.persona, + env: process.env, + log: () => undefined + }); + const modelFixtures = options.modelFixtures ?? []; + let modelFixtureCursor = options.state?.model?.fixtureCursor ?? 0; + + const stagedBundle = await stageWorkerBundle(options.bundlePath); + try { + payload.bundlePath = stagedBundle.bundlePath; + const workerResult = await runPreviewWorker({ + bundlePath: stagedBundle.bundlePath, + extraReadRoots: stagedBundle.readRoots, + env: buildWorkerEnv(sanitizedInputs.envInputs), + init: { + type: 'init', + config: control, + payload + }, + onModelRequest: async (request) => { + const sourceFidelity = payload.sourceFidelity?.model + ?? (options.request.policy.model === 'live' + ? 'current' + : options.request.policy.model === 'fixture' + ? 'fixture' + : 'simulated'); + if (options.request.policy.model === 'fixture') { + const fixture = modelFixtures[modelFixtureCursor]; + if (!fixture) { + return { + type: 'model-response', + requestId: request.requestId, + ok: false, + action: { + kind: 'model.complete', + status: 'denied', + data: { + mode: 'fixture', + promptChars: request.prompt.length, + source: 'unavailable', + fixtureIndex: modelFixtureCursor + 1 + }, + extensions: { + sourceFidelity: 'unavailable' + } + }, + error: modelFixtures.length === 0 + ? 'invoke: model fixture mode requires explicit case model fixtures' + : `invoke: model fixture ${modelFixtureCursor + 1} requested but only ${modelFixtures.length} fixture(s) are available` + }; + } + modelFixtureCursor += 1; + return { + type: 'model-response', + requestId: request.requestId, + ok: true, + action: { + kind: 'model.complete', + status: 'previewed', + data: { + mode: 'fixture', + promptChars: request.prompt.length, + outputChars: fixture.output.length, + source: 'fixture', + fixtureIndex: modelFixtureCursor, + ...(fixture.sourcePath ? { sourceDetail: fixture.sourcePath } : {}) + }, + extensions: { + sourceFidelity: sourceFidelity + } + }, + output: fixture.output + }; + } + + if (options.request.policy.model !== 'live') { + return { + type: 'model-response', + requestId: request.requestId, + ok: false, + action: { + kind: 'model.complete', + status: 'denied', + data: { + mode: options.request.policy.model, + promptChars: request.prompt.length, + source: 'unavailable' + }, + extensions: { + sourceFidelity: 'unavailable' + } + }, + error: `invoke: preview worker unexpectedly requested parent model bridge in ${options.request.policy.model} mode` + }; + } + + if (!liveModelAdapter) { + return { + type: 'model-response', + requestId: request.requestId, + ok: false, + action: { + kind: 'model.complete', + status: 'denied', + data: { + mode: 'live', + promptChars: request.prompt.length, + source: 'unavailable' + }, + extensions: { + sourceFidelity: 'unavailable' + } + }, + error: 'invoke: live model mode requires a parent-side model adapter with supported current credentials' + }; + } + + try { + const output = await promiseWithTimeout( + liveModelAdapter.complete(request.prompt, request.maxTokens ? { maxTokens: request.maxTokens } : undefined), + localPreviewModelTimeoutMs(), + 'invoke: parent model adapter timed out' + ); + return { + type: 'model-response', + requestId: request.requestId, + ok: true, + action: { + kind: 'model.complete', + status: 'previewed', + data: { + mode: 'live', + promptChars: request.prompt.length, + outputChars: output.length, + source: 'current' + }, + extensions: { + sourceFidelity: sourceFidelity + } + }, + output + }; + } catch (error) { + const message = redactLocalPreviewText(error instanceof Error ? error.message : String(error)); + return { + type: 'model-response', + requestId: request.requestId, + ok: false, + action: { + kind: 'model.complete', + status: 'denied', + data: { + mode: 'live', + promptChars: request.prompt.length, + source: 'current' + }, + extensions: { + sourceFidelity: 'current' + } + }, + error: `invoke: parent model adapter failed: ${message}` + }; + } + } + }); + + if (!workerResult.ok) { + throw new Error(workerResult.stack ? `${workerResult.error}\n${workerResult.stack}` : workerResult.error); + } + workerResult.state = { + ...workerResult.state, + model: { fixtureCursor: modelFixtureCursor } + }; + return workerResult; + } finally { + if (process.env.WF_KEEP_LOCAL_PREVIEW_STAGE !== '1') { + await rm(stagedBundle.dir, { recursive: true, force: true }); + } + } +} + +async function runPreviewWorker(args: { + bundlePath: string; + extraReadRoots: readonly string[]; + env: NodeJS.ProcessEnv; + init: LocalPreviewWorkerInitMessage; + onModelRequest: (request: LocalPreviewModelRequestMessage) => Promise; +}): Promise { + const permissionArgs = await buildWorkerPermissionArgs(args.bundlePath, args.extraReadRoots); + const child = spawnPreviewWorkerProcess( + process.execPath, + [...permissionArgs, WORKER_ENTRY_PATH], + { + cwd: process.cwd(), + env: args.env, + stdio: ['ignore', 'ignore', 'pipe', 'ipc'] + } + ); + + let stderr = ''; + let result: LocalPreviewWorkerResult | undefined; + let ready = false; + const pendingResponses = new Map, AbortController>(); + + child.stderr?.setEncoding('utf8'); + child.stderr?.on('data', (chunk) => { + stderr += chunk; + }); + + child.on('message', (message: LocalPreviewWorkerOutboundMessage) => { + if (message?.type === 'ready') { + ready = true; + return; + } + if (message?.type === 'result') { + result = message.result; + return; + } + if (message?.type === 'model') { + const work = respondToModel(child, message, args.onModelRequest) + .finally(() => pendingResponses.delete(work)); + pendingResponses.set(work, new AbortController()); + return; + } + if (message?.type !== 'fetch') return; + const controller = new AbortController(); + const work = respondToFetch(child, args.init.config, message, controller) + .finally(() => pendingResponses.delete(work)); + pendingResponses.set(work, controller); + }); + + const failAndStop = async (error: unknown): Promise => { + abortPendingResponses(pendingResponses); + await stopChildProcess(child); + throw error; + }; + + try { + await waitForWorkerReady(child, () => ready, localPreviewReadyTimeoutMs(), () => stderr); + await sendToChild(child, args.init); + } catch (error) { + return await failAndStop(error); + } + + let exitCode: number | null; + try { + exitCode = await waitForChildClose(child, localPreviewOverallTimeoutMs()); + } catch (error) { + return await failAndStop(error); + } + + abortPendingResponses(pendingResponses); + await Promise.allSettled([...pendingResponses.keys()]); + + if (!result) { + throw new Error(stderr.trim() || `invoke worker exited with code ${String(exitCode)}`); + } + if (exitCode !== 0 && result.ok) { + throw new Error(stderr.trim() || `invoke worker exited with code ${String(exitCode)}`); + } + return result; +} + +async function respondToFetch( + child: import('node:child_process').ChildProcess, + config: LocalPreviewGuardConfig, + request: LocalPreviewFetchRequestMessage, + controller: AbortController +): Promise { + const response = await resolveParentFetch(config, request, controller); + await sendToChild(child, response); +} + +async function respondToModel( + child: import('node:child_process').ChildProcess, + request: LocalPreviewModelRequestMessage, + onModelRequest: (request: LocalPreviewModelRequestMessage) => Promise +): Promise { + const response = await onModelRequest(request); + await sendToChild(child, response); +} + +async function resolveParentFetch( + config: LocalPreviewGuardConfig, + request: LocalPreviewFetchRequestMessage, + controller: AbortController +): Promise { + const method = request.method.toUpperCase(); + const fixture = config.fixtures.find((candidate) => + candidate.method.toUpperCase() === method && request.url.includes(candidate.match) + ); + if (fixture) { + return { + type: 'fetch-response', + requestId: request.requestId, + ok: true, + action: { + kind: 'http.read', + status: 'previewed', + data: { + method, + url: request.url, + source: 'fixture', + ...(fixture.sourcePath ? { sourceDetail: fixture.sourcePath } : {}) + }, + extensions: { + sourceFidelity: 'fixture' + } + }, + response: { + status: 200, + headers: [['content-type', fixture.contentType ?? 'application/json']], + bodyBase64: Buffer.from(fixture.body, 'utf8').toString('base64') + } + }; + } + + try { + return await resolveLiveParentFetch(config, request, 0, controller); + } catch { + if (controller.signal.aborted) { + return parentFetchErrorResponse( + request, + { + method, + url: request.url, + reason: 'parent_fetch_timeout' + }, + `invoke parent fetch timeout after ${localPreviewFetchTimeoutMs()}ms for ${method} ${request.url}` + ); + } + return parentFetchErrorResponse( + request, + { + method, + url: request.url, + reason: 'parent_fetch_failed' + }, + `invoke parent fetch failed for ${method} ${request.url}: network error` + ); + } +} + +async function resolveLiveParentFetch( + config: LocalPreviewGuardConfig, + request: LocalPreviewFetchRequestMessage, + redirects: number, + controller: AbortController +): Promise { + const method = request.method.toUpperCase(); + const url = request.url; + + if (config.policy.reads !== 'live' || !['GET', 'HEAD'].includes(method)) { + return deniedFetchResponse(request, { + method, + url + }, `invoke policy denied ${method} ${url}`); + } + + if ( + !config.policy.allowedHttp.some((rule) => httpRuleMatches(rule, method, url)) + ) { + return deniedFetchResponse(request, { + method, + url + }, `invoke policy denied undeclared live read ${method} ${url}`); + } + + const timeout = setTimeout(() => controller.abort(), localPreviewFetchTimeoutMs()); + let response: Response; + const { headers, strippedHeaders } = sanitizeLiveRequestHeaders(request.headers); + try { + response = await fetch(new Request(url, { + method, + headers, + ...(request.bodyBase64 ? { body: Buffer.from(request.bodyBase64, 'base64') } : {}), + redirect: 'manual', + signal: controller.signal + })); + } finally { + clearTimeout(timeout); + } + + if (!isRedirect(response.status)) { + return { + type: 'fetch-response', + requestId: request.requestId, + ok: true, + action: { + kind: 'http.read', + status: 'previewed', + data: { + method, + url, + source: 'current', + ...(strippedHeaders.length > 0 ? { strippedHeaders } : {}) + }, + extensions: { + sourceFidelity: 'current' + } + }, + response: { + status: response.status, + headers: [...response.headers.entries()], + bodyBase64: Buffer.from(await response.arrayBuffer()).toString('base64') + } + }; + } + + const location = response.headers.get('location'); + if (!location) { + return { + type: 'fetch-response', + requestId: request.requestId, + ok: true, + action: { + kind: 'http.read', + status: 'previewed', + data: { + method, + url, + source: 'current', + ...(strippedHeaders.length > 0 ? { strippedHeaders } : {}) + }, + extensions: { + sourceFidelity: 'current' + } + }, + response: { + status: response.status, + headers: [...response.headers.entries()], + bodyBase64: Buffer.from(await response.arrayBuffer()).toString('base64') + } + }; + } + + if (redirects >= MAX_REDIRECTS) { + return deniedFetchResponse(request, { + method, + url, + reason: 'too_many_redirects' + }, `invoke policy denied redirect chain for ${method} ${url}: too many redirects`); + } + + const redirectedUrl = new URL(location, url).toString(); + if ( + !config.policy.allowedHttp.some((rule) => httpRuleMatches(rule, method, redirectedUrl)) + ) { + return deniedFetchResponse(request, { + method, + url: redirectedUrl, + redirectedFrom: url + }, `invoke policy denied redirected live read ${method} ${redirectedUrl}`); + } + + return await resolveLiveParentFetch(config, { + ...request, + method: response.status === 303 ? 'GET' : method, + url: redirectedUrl, + ...(response.status === 303 ? { bodyBase64: undefined } : {}) + }, redirects + 1, controller); +} + +function deniedFetchResponse( + request: LocalPreviewFetchRequestMessage, + data: Record, + error: string +): LocalPreviewFetchResponseMessage { + return { + type: 'fetch-response', + requestId: request.requestId, + ok: false, + action: { + kind: 'http.read', + status: 'denied', + data + }, + error + }; +} + +function parentFetchErrorResponse( + request: LocalPreviewFetchRequestMessage, + data: Record, + error: string +): LocalPreviewFetchResponseMessage { + return { + type: 'fetch-response', + requestId: request.requestId, + ok: false, + action: { + kind: 'http.read', + status: 'denied', + data + }, + error + }; +} + +function buildWorkerEnv(inputs: Record): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (WORKER_ENV_KEEP.has(key)) env[key] = value; + } + for (const [key, value] of Object.entries(inputs)) { + const safeValue = redactLocalPreviewText(value); + env[key] = safeValue; + env[`WORKFORCE_INPUT_${key}`] = safeValue; + } + return env; +} + +async function buildWorkerPermissionArgs(bundlePath: string, extraReadRoots: readonly string[]): Promise { + const bundleDirPath = path.dirname(bundlePath); + const bundleDir = await realpath(path.dirname(bundlePath)); + const bundleFile = await realpath(bundlePath); + const runtimeRoot = await realpath(WORKER_RUNTIME_ROOT); + const runtimePackageRoot = await resolveRuntimePackageRoot(); + const readRoots = uniquePaths([ + runtimeRoot, + runtimePackageRoot, + ...collectRuntimeDependencyRoots(runtimePackageRoot), + ...extraReadRoots, + bundleDirPath, + bundlePath, + bundleDir, + bundleFile + ]); + return [ + '--permission', + ...readRoots.map((entry) => `--allow-fs-read=${entry}`) + ]; +} + +async function stageWorkerBundle(bundlePath: string): Promise<{ + dir: string; + bundlePath: string; + readRoots: string[]; +}> { + const stagingRoot = path.join(resolveLocalPreviewWorkspaceRoot(), '.workforce'); + await mkdir(stagingRoot, { recursive: true }); + const dir = await mkdtemp(path.join(stagingRoot, 'local-preview-worker-')); + const stagedBundlePath = path.join(dir, path.basename(bundlePath)); + await copyFile(bundlePath, stagedBundlePath); + const runtimePackageRoot = await resolveRuntimePackageRoot(); + const runtimeNodeModulesDir = path.join(dir, 'node_modules', '@agentworkforce'); + await mkdir(runtimeNodeModulesDir, { recursive: true }); + await symlink(runtimePackageRoot, path.join(runtimeNodeModulesDir, 'runtime')); + return { + dir, + bundlePath: stagedBundlePath, + readRoots: uniquePaths([ + path.dirname(bundlePath), + bundlePath, + runtimePackageRoot + ]) + }; +} + +function sanitizeWorkerInputs(inputs: Record): { + ctxInputs: Record; + envInputs: Record; +} { + const ctxInputs: Record = {}; + const envInputs: Record = {}; + for (const [key, value] of Object.entries(inputs)) { + if (isSecretInputKey(key) || isCredentialLikeValue(value)) { + ctxInputs[key] = REDACTED_INPUT_VALUE; + continue; + } + ctxInputs[key] = value; + envInputs[key] = value; + } + return { ctxInputs, envInputs }; +} + +function isSecretInputKey(key: string): boolean { + const normalized = key.trim().toUpperCase().replace(/[^A-Z0-9]+/gu, '_'); + return SECRET_INPUT_NAMES.has(normalized) || SECRET_INPUT_SEGMENT.test(normalized); +} + +function assertPermissionBoundarySupport(): void { + const detectedVersion = process.versions.node; + if ( + compareNodeVersions(detectedVersion, '26.3.1') < 0 + || !process.allowedNodeEnvironmentFlags.has('--permission') + || !process.allowedNodeEnvironmentFlags.has('--allow-fs-read') + || !process.allowedNodeEnvironmentFlags.has('--allow-net') + ) { + throw new Error( + `invoke: local preview requires supported patched Node >=26.3.1 with --permission, --allow-fs-read, and --allow-net support; detected ${detectedVersion}` + ); + } +} + +function compareNodeVersions(left: string, right: string): number { + const a = left.split('.').map((part) => Number(part)); + const b = right.split('.').map((part) => Number(part)); + for (let index = 0; index < Math.max(a.length, b.length); index += 1) { + const delta = (a[index] ?? 0) - (b[index] ?? 0); + if (delta !== 0) return delta; + } + return 0; +} + +function uniquePaths(values: readonly string[]): string[] { + return [...new Set(values.map((value) => path.resolve(value)))]; +} + +function resolveLocalPreviewWorkspaceRoot(): string { + return path.resolve(process.cwd()); +} + +async function resolveRuntimePackageRoot(): Promise { + return await realpath(path.dirname(require.resolve('@agentworkforce/runtime/package.json'))); +} + +function collectRuntimeDependencyRoots(runtimePackageRoot: string): string[] { + const roots = [path.join(runtimePackageRoot, 'node_modules')]; + let cursor = path.resolve(runtimePackageRoot); + while (true) { + const parent = path.dirname(cursor); + if (parent === cursor) break; + const base = path.basename(parent); + if (base === 'node_modules') { + roots.push(parent); + } else if (base === 'packages') { + roots.push(parent, path.join(path.dirname(parent), 'node_modules')); + } + cursor = parent; + } + return roots; +} + +function httpRuleMatches( + rule: { method: string; urlGlob: string }, + method: string, + url: string +): boolean { + if (rule.method.toUpperCase() !== method.toUpperCase()) return false; + const pattern = rule.urlGlob.includes('*') + ? new RegExp(`^${rule.urlGlob.split('*').map(escapeRegExp).join('.*')}$`) + : null; + return pattern ? pattern.test(url) : url === rule.urlGlob; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); +} + +function isRedirect(status: number): boolean { + return [301, 302, 303, 307, 308].includes(status); +} + +function sanitizeLiveRequestHeaders( + entries: ReadonlyArray<[string, string]> +): { headers: Headers; strippedHeaders: string[] } { + const headers = new Headers(); + const stripped = new Set(); + for (const [name, value] of entries) { + const normalized = name.toLowerCase(); + if ( + STRIP_LIVE_REQUEST_HEADERS.has(normalized) || + (normalized.startsWith('x-') && (isCredentialLikeHeaderName(normalized) || isCredentialLikeValue(value))) + ) { + stripped.add(normalized); + continue; + } + headers.append(name, value); + } + return { headers, strippedHeaders: [...stripped].sort() }; +} + +function isCredentialLikeHeaderName(name: string): boolean { + return /(?:access|api|auth|bearer|cookie|credential|key|oauth|proxy|secret|session|token)/u.test(name); +} + +function sendToChild( + child: import('node:child_process').ChildProcess, + message: LocalPreviewWorkerInboundMessage | LocalPreviewWorkerOutboundMessage +): Promise { + return new Promise((resolve, reject) => { + if (!child.connected) { + reject(new Error('invoke worker IPC channel closed')); + return; + } + child.send(message, (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +function waitForWorkerReady( + child: import('node:child_process').ChildProcess, + isReady: () => boolean, + timeoutMs: number, + getStderr: () => string +): Promise { + if (isReady()) return Promise.resolve(); + return new Promise((resolve, reject) => { + const interval = setInterval(() => { + if (!isReady()) return; + cleanup(); + resolve(); + }, 10); + const timer = setTimeout(() => { + cleanup(); + reject(new Error(withWorkerStderr( + `invoke worker timed out waiting for readiness after ${timeoutMs}ms`, + getStderr() + ))); + }, timeoutMs); + const onError = (error: Error) => { + cleanup(); + reject(new Error(withWorkerStderr(error.message, getStderr()))); + }; + const onClose = () => { + cleanup(); + reject(new Error(withWorkerStderr('invoke worker exited before signaling readiness', getStderr()))); + }; + const cleanup = () => { + clearInterval(interval); + clearTimeout(timer); + child.off('error', onError); + child.off('close', onClose); + }; + child.on('error', onError); + child.on('close', onClose); + }); +} + +function withWorkerStderr(message: string, stderr: string): string { + const trimmed = redactLocalPreviewText(stderr).trim(); + return trimmed ? `${message}\nworker stderr:\n${trimmed}` : message; +} + +function waitForChildClose( + child: import('node:child_process').ChildProcess, + timeoutMs: number +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`invoke worker timed out after ${timeoutMs}ms`)); + }, timeoutMs); + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onClose = (code: number | null) => { + cleanup(); + resolve(code); + }; + const cleanup = () => { + clearTimeout(timer); + child.off('error', onError); + child.off('close', onClose); + }; + child.once('error', onError); + child.once('close', onClose); + }); +} + +export async function stopChildProcess(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve) => { + let forceKill: ReturnType | undefined; + let hardStop: ReturnType | undefined; + const onClose = () => { + cleanup(); + resolve(); + }; + const cleanup = () => { + if (forceKill) clearTimeout(forceKill); + if (hardStop) clearTimeout(hardStop); + child.off('close', onClose); + }; + forceKill = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + } + hardStop = setTimeout(() => { + cleanup(); + resolve(); + }, localPreviewKillSettleTimeoutMs()); + }, localPreviewForceKillTimeoutMs()); + child.once('close', onClose); + child.kill('SIGTERM'); + }); +} + +function abortPendingResponses(pendingResponses: ReadonlyMap, AbortController>): void { + for (const controller of pendingResponses.values()) { + controller.abort(); + } +} + +function readTimeoutEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function localPreviewReadyTimeoutMs(): number { + return readTimeoutEnv('WF_LOCAL_PREVIEW_READY_TIMEOUT_MS', 5_000); +} + +function localPreviewOverallTimeoutMs(): number { + return readTimeoutEnv('WF_LOCAL_PREVIEW_OVERALL_TIMEOUT_MS', 30_000); +} + +function localPreviewFetchTimeoutMs(): number { + return readTimeoutEnv('WF_LOCAL_PREVIEW_FETCH_TIMEOUT_MS', 10_000); +} + +function localPreviewModelTimeoutMs(): number { + return readTimeoutEnv('WF_LOCAL_PREVIEW_MODEL_TIMEOUT_MS', 30_000); +} + +function localPreviewForceKillTimeoutMs(): number { + return readTimeoutEnv('WF_LOCAL_PREVIEW_FORCE_KILL_TIMEOUT_MS', 1_000); +} + +function localPreviewKillSettleTimeoutMs(): number { + return readTimeoutEnv('WF_LOCAL_PREVIEW_KILL_SETTLE_TIMEOUT_MS', 1_000); +} + +async function promiseWithTimeout( + value: Promise, + timeoutMs: number, + message: string +): Promise { + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + value.then( + (result) => { + clearTimeout(timeout); + resolve(result); + }, + (error) => { + clearTimeout(timeout); + reject(error); + } + ); + }); +} diff --git a/packages/runtime/src/run-contracts.test.ts b/packages/runtime/src/run-contracts.test.ts index 83f68aef..a3e7393d 100644 --- a/packages/runtime/src/run-contracts.test.ts +++ b/packages/runtime/src/run-contracts.test.ts @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { LOCAL_EFFECT_POLICY_DEFAULTS, + mergeAllowedHttpRules, + resolvePersonaHttpReadRules, resolveLocalEffectPolicy, type PreviewAction, type RunRecordV2 @@ -41,6 +43,64 @@ test('local effect policy cannot be escalated to live writes, shell, or compose' assert.equal(resolveLocalEffectPolicy({ writes: 'deny' }).writes, 'deny'); }); +test('persona httpRead capability resolves to explicit local allowedHttp rules', () => { + assert.deepEqual(resolvePersonaHttpReadRules({ + id: 'http-reader', + capabilities: { + httpRead: { + allow: [ + { method: 'GET', urlGlob: 'https://example.test/front-page' }, + { method: 'HEAD', urlGlob: 'https://example.test/*' } + ] + } + } + }), [ + { method: 'GET', urlGlob: 'https://example.test/front-page' }, + { method: 'HEAD', urlGlob: 'https://example.test/*' } + ]); + assert.deepEqual(resolvePersonaHttpReadRules({ + id: 'http-reader', + capabilities: { + httpRead: { + enabled: false, + allow: [{ method: 'GET', urlGlob: 'https://example.test/*' }] + } + } + }), []); +}); + +test('persona httpRead capability fails closed on malformed compiled values', () => { + assert.throws( + () => resolvePersonaHttpReadRules({ + id: 'broken-http-reader', + capabilities: { httpRead: true } as unknown as NonNullable[0]['capabilities']> + }), + /capabilities\.httpRead must be an object/ + ); + assert.throws( + () => resolvePersonaHttpReadRules({ + id: 'broken-http-reader', + capabilities: { + httpRead: { allow: [{ method: 'POST', urlGlob: 'https://example.test/*' }] } + } as unknown as NonNullable[0]['capabilities']> + }), + /capabilities\.httpRead\.allow\[0\]\.method must be "GET" or "HEAD"/ + ); +}); + +test('allowedHttp rules merge additively without duplicates', () => { + assert.deepEqual(mergeAllowedHttpRules( + [{ method: 'GET', urlGlob: 'https://example.test/*' }], + [ + { method: 'get', urlGlob: 'https://example.test/*' }, + { method: 'HEAD', urlGlob: 'https://example.test/front-page' } + ] + ), [ + { method: 'GET', urlGlob: 'https://example.test/*' }, + { method: 'HEAD', urlGlob: 'https://example.test/front-page' } + ]); +}); + test('RunRecordV2 retains additive existing and extension fields', () => { const record: RunRecordV2 = { runId: 'run_1', diff --git a/packages/runtime/src/run-contracts.ts b/packages/runtime/src/run-contracts.ts index 11311dd3..dee1aa42 100644 --- a/packages/runtime/src/run-contracts.ts +++ b/packages/runtime/src/run-contracts.ts @@ -145,3 +145,74 @@ export function resolveLocalEffectPolicy( ...(requested.allowedProviders ? { allowedProviders: [...requested.allowedProviders] } : {}) }; } + +export function mergeAllowedHttpRules( + ...groups: ReadonlyArray> +): EffectPolicyV1['allowedHttp'] { + const seen = new Set(); + const merged: EffectPolicyV1['allowedHttp'] = []; + for (const group of groups) { + for (const rule of group) { + const method = rule.method.toUpperCase(); + const key = `${method}\u0000${rule.urlGlob}`; + if (seen.has(key)) continue; + seen.add(key); + merged.push({ method, urlGlob: rule.urlGlob }); + } + } + return merged; +} + +export function resolvePersonaHttpReadRules( + persona: Pick +): EffectPolicyV1['allowedHttp'] { + const declared = persona.capabilities?.httpRead; + if (declared === undefined) return []; + if (typeof declared !== 'object' || declared === null || Array.isArray(declared)) { + throw new Error( + `invoke: persona "${persona.id}" capabilities.httpRead must be an object with optional enabled and allow fields` + ); + } + + const entry = declared as Record; + for (const key of Object.keys(entry)) { + if (!['enabled', 'allow'].includes(key)) { + throw new Error(`invoke: persona "${persona.id}" capabilities.httpRead.${key} is not allowed`); + } + } + if (entry.enabled !== undefined && typeof entry.enabled !== 'boolean') { + throw new Error(`invoke: persona "${persona.id}" capabilities.httpRead.enabled must be a boolean if provided`); + } + if (entry.allow !== undefined && !Array.isArray(entry.allow)) { + throw new Error(`invoke: persona "${persona.id}" capabilities.httpRead.allow must be an array if provided`); + } + + const rules = (entry.allow ?? []).map((value, index) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`invoke: persona "${persona.id}" capabilities.httpRead.allow[${index}] must be an object`); + } + const rule = value as Record; + for (const key of Object.keys(rule)) { + if (!['method', 'urlGlob'].includes(key)) { + throw new Error(`invoke: persona "${persona.id}" capabilities.httpRead.allow[${index}].${key} is not allowed`); + } + } + if (rule.method !== 'GET' && rule.method !== 'HEAD') { + throw new Error( + `invoke: persona "${persona.id}" capabilities.httpRead.allow[${index}].method must be "GET" or "HEAD"` + ); + } + if (typeof rule.urlGlob !== 'string' || !rule.urlGlob.trim()) { + throw new Error( + `invoke: persona "${persona.id}" capabilities.httpRead.allow[${index}].urlGlob must be a non-empty string` + ); + } + return { + method: rule.method, + urlGlob: rule.urlGlob + }; + }); + + if (entry.enabled === false) return []; + return mergeAllowedHttpRules(rules); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19863ab7..298a9474 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: '@agentworkforce/deploy': specifier: workspace:* version: link:../deploy + '@agentworkforce/events': + specifier: workspace:* + version: link:../events '@agentworkforce/local-surface': specifier: workspace:* version: link:../local-surface @@ -64,6 +67,9 @@ importers: ora: specifier: ^9.4.0 version: 9.4.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 packages/compose: {} @@ -79,8 +85,8 @@ importers: specifier: workspace:* version: link:../runtime '@relayfile/relay-helpers': - specifier: ^0.4.2 - version: 0.4.2(@relayfile/sdk@0.7.40) + specifier: ^0.4.6 + version: 0.4.6(@relayfile/sdk@0.7.40) packages/deploy: dependencies: @@ -186,6 +192,9 @@ importers: '@relayfile/adapter-core': specifier: ^0.5.1 version: 0.5.1(@relayfile/sdk@0.7.40) + '@relayfile/relay-helpers': + specifier: ^0.4.6 + version: 0.4.6(@relayfile/sdk@0.7.40) agent-trajectories: specifier: ^0.5.3 version: 0.5.3 @@ -1044,22 +1053,28 @@ packages: '@relaycast/types@6.0.5': resolution: {integrity: sha512-wTxtHHCZF13wa28BWcTuG4sf2fcrFGgI+MSywvdR6MjWGVJdriJMZekT9iVy9We1CpXjzKUmHIhhxjH/PQoPHw==} - '@relayfile/adapter-core@0.4.3': - resolution: {integrity: sha512-VoGco3xYab+Ij9v2wSi6qsYg15gDfYWlZsdbiKrqM/YN+t0IhPS8Shy5dRA18kYCBKEr7bH7QdFfa8PCXj6LFg==} + '@relayfile/adapter-core@0.5.1': + resolution: {integrity: sha512-iEhkB5qVFWgAxoJ2D8x4Pfzg6LUvI0BN0sXjkbIDBx9RDIkLs9zqsg5SCaSwNw8pxQS0Gyx//kC8xPbCzhpiLQ==} engines: {node: '>=18'} hasBin: true peerDependencies: '@relayfile/sdk': '>=0.6.0 <1' - '@relayfile/adapter-core@0.5.1': - resolution: {integrity: sha512-iEhkB5qVFWgAxoJ2D8x4Pfzg6LUvI0BN0sXjkbIDBx9RDIkLs9zqsg5SCaSwNw8pxQS0Gyx//kC8xPbCzhpiLQ==} + '@relayfile/adapter-core@0.5.5': + resolution: {integrity: sha512-CvI5DTmuP0tdXvhYmiPBBazGUzlr9/0g/aUsII23v07Zx86a4+J9KV0D/I2X1/uqI4KVC4mjpveyHkoGyX5SQQ==} engines: {node: '>=18'} hasBin: true peerDependencies: '@relayfile/sdk': '>=0.6.0 <1' - '@relayfile/adapter-linear@0.4.2': - resolution: {integrity: sha512-Yg0hv/RqEB5WJ4RA4LX9JinCxxT/01GSdd+0HeT0L9P36EX27kEEuzWmNwWEpjDHQMcYykEocruEJHuV4XXGCQ==} + '@relayfile/adapter-linear@0.4.5': + resolution: {integrity: sha512-IRsOyHu3yCITeumek1wOy1Tb0DTAMNh5zRhBY7N6BMsqMflBDJ3XHCaAxwvC2pMUdc4KHFc/zNGyHsWOOLy+6w==} + engines: {node: '>=18'} + peerDependencies: + '@relayfile/sdk': '>=0.6.0 <1' + + '@relayfile/adapter-reddit@0.2.4': + resolution: {integrity: sha512-mBohPpQJrbHMMWsvk4wP+SEmZyO4+WXkt8n74Mjpr4U7fwsC6afGR4qhf85CCW9y1ZA3QsNQ/wXU9hCcGfK92w==} engines: {node: '>=18'} peerDependencies: '@relayfile/sdk': '>=0.6.0 <1' @@ -1072,8 +1087,8 @@ packages: resolution: {integrity: sha512-eWy52O7Dt1Vwy162IJCbGAoSFFejNVVOy4eMrNEmIWld+YMdpR0hEvuplKP0O75xwD8be4PJcADoedi3X1R9Ng==} engines: {node: '>=18'} - '@relayfile/relay-helpers@0.4.2': - resolution: {integrity: sha512-/SnZW5vEtNBJkYslLOuw8JIMU1edYmY9AXEJF52k4H3Q1Z/I3GPAShXCbQmCry07Q42CM/+1SEk1mLzDaU5cVw==} + '@relayfile/relay-helpers@0.4.6': + resolution: {integrity: sha512-kf5zShWiRN5+h2v+dpNrfO26aZRJkSic6bI3mixqE6aHRtsGZt/1KQKImKFpgG1qmm18XNVY4sXU4ZXIXtUSxg==} '@relayfile/sdk@0.7.40': resolution: {integrity: sha512-9qPQ/qSexD11x/CKZlbZUFC7KDdxJ2yYBQd1nuzcoHNqnvErplq5bDBy5Dqr1AXID9i1Q7sMxnY8M1raMTQsAg==} @@ -3462,7 +3477,7 @@ snapshots: dependencies: zod: 4.4.3 - '@relayfile/adapter-core@0.4.3(@relayfile/sdk@0.7.40)': + '@relayfile/adapter-core@0.5.1(@relayfile/sdk@0.7.40)': dependencies: '@relayfile/sdk': 0.7.40 '@scalar/postman-to-openapi': 0.6.3 @@ -3470,7 +3485,7 @@ snapshots: minimatch: 10.2.5 yaml: 2.9.0 - '@relayfile/adapter-core@0.5.1(@relayfile/sdk@0.7.40)': + '@relayfile/adapter-core@0.5.5(@relayfile/sdk@0.7.40)': dependencies: '@relayfile/sdk': 0.7.40 '@scalar/postman-to-openapi': 0.6.3 @@ -3478,9 +3493,14 @@ snapshots: minimatch: 10.2.5 yaml: 2.9.0 - '@relayfile/adapter-linear@0.4.2(@relayfile/sdk@0.7.40)': + '@relayfile/adapter-linear@0.4.5(@relayfile/sdk@0.7.40)': + dependencies: + '@relayfile/adapter-core': 0.5.5(@relayfile/sdk@0.7.40) + '@relayfile/sdk': 0.7.40 + + '@relayfile/adapter-reddit@0.2.4(@relayfile/sdk@0.7.40)': dependencies: - '@relayfile/adapter-core': 0.4.3(@relayfile/sdk@0.7.40) + '@relayfile/adapter-core': 0.5.5(@relayfile/sdk@0.7.40) '@relayfile/sdk': 0.7.40 '@relayfile/core@0.7.40': {} @@ -3490,10 +3510,11 @@ snapshots: '@parcel/watcher': 2.5.6 ignore: 7.0.5 - '@relayfile/relay-helpers@0.4.2(@relayfile/sdk@0.7.40)': + '@relayfile/relay-helpers@0.4.6(@relayfile/sdk@0.7.40)': dependencies: - '@relayfile/adapter-core': 0.4.3(@relayfile/sdk@0.7.40) - '@relayfile/adapter-linear': 0.4.2(@relayfile/sdk@0.7.40) + '@relayfile/adapter-core': 0.5.5(@relayfile/sdk@0.7.40) + '@relayfile/adapter-linear': 0.4.5(@relayfile/sdk@0.7.40) + '@relayfile/adapter-reddit': 0.2.4(@relayfile/sdk@0.7.40) transitivePeerDependencies: - '@relayfile/sdk'