@gr8ful/spf 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +56 -0
  2. package/assets/defaults/spf.config.yaml +75 -0
  3. package/assets/skill/references/config.md +98 -4
  4. package/dist/chains/index.d.ts +2 -0
  5. package/dist/chains/index.js +4 -0
  6. package/dist/cli/commands/doctor.js +339 -2
  7. package/dist/cli/commands/fanout.d.ts +7 -14
  8. package/dist/cli/commands/fanout.js +45 -39
  9. package/dist/cli/commands/loop.d.ts +2 -0
  10. package/dist/cli/commands/loop.js +198 -0
  11. package/dist/cli/commands/run.js +14 -4
  12. package/dist/cli/commands/watch.d.ts +29 -1
  13. package/dist/cli/commands/watch.js +219 -64
  14. package/dist/cli/index.js +14 -0
  15. package/dist/core/agent_cc.d.ts +11 -0
  16. package/dist/core/agent_cc.js +25 -2
  17. package/dist/core/agent_flue.js +14 -5
  18. package/dist/core/agents.d.ts +61 -1
  19. package/dist/core/agents.js +363 -6
  20. package/dist/core/data_types.d.ts +316 -0
  21. package/dist/core/data_types.js +143 -0
  22. package/dist/core/loop.d.ts +230 -0
  23. package/dist/core/loop.js +290 -0
  24. package/dist/core/quality.d.ts +1 -2
  25. package/dist/core/sandbox.d.ts +236 -0
  26. package/dist/core/sandbox.js +655 -0
  27. package/dist/core/sandbox_cloudflare.d.ts +137 -0
  28. package/dist/core/sandbox_cloudflare.js +505 -0
  29. package/dist/core/sandbox_opensandbox.d.ts +59 -0
  30. package/dist/core/sandbox_opensandbox.js +484 -0
  31. package/dist/core/sandbox_sdk_types.d.ts +171 -0
  32. package/dist/core/sandbox_sdk_types.js +20 -0
  33. package/dist/core/watch.d.ts +56 -0
  34. package/dist/core/watch.js +358 -52
  35. package/dist/core/worktree_data.d.ts +1 -0
  36. package/dist/core/worktree_data.js +37 -0
  37. package/package.json +1 -1
@@ -16,6 +16,7 @@ import * as agentFlue from "./agent_flue.js";
16
16
  import * as paths from "./paths.js";
17
17
  import * as permissions from "./permissions.js";
18
18
  import * as prompts from "./prompts.js";
19
+ import * as sandbox from "./sandbox.js";
19
20
  import { effectiveAgent } from "./tiering.js";
20
21
  import { GateReport, UsageBreakdown, makeEventRecord, SFConfigSchema, } from "./data_types.js";
21
22
  import { newId, operatorEnv } from "./utils.js";
@@ -177,6 +178,14 @@ function mergeRawConfig(base, override) {
177
178
  // half-merged role map would route some agents by the base's ladder and
178
179
  // some by the override's. Pinned by src/test/data_types.test.ts.
179
180
  tiering: { ...(base.tiering || {}), ...(override.tiering || {}) },
181
+ // sandbox.backend / .workspace_dir / .scope / ... — see data_types.ts's
182
+ // SandboxConfigSchema. Nested opensandbox/cloudflare/egress/transport/
183
+ // credentials are WHOLE-OBJECT replaces on override, same rule as
184
+ // observability.otel (a half-merged base_url/api_key_env pair would
185
+ // authenticate to the wrong control plane); sandbox's own scalar keys
186
+ // still merge key-by-key around them. Pinned by a merge-survival test
187
+ // in src/test/data_types.test.ts.
188
+ sandbox: { ...(base.sandbox || {}), ...(override.sandbox || {}) },
180
189
  agents: mergeAgentLists(base.agents || [], override.agents || []),
181
190
  };
182
191
  }
@@ -330,7 +339,17 @@ export function validate(cfg, required, requiredSuites = [], cwd) {
330
339
  problems.push(`agent ${JSON.stringify(name)}: unknown tool ${JSON.stringify(toolName)} — known: read, write, edit, bash, grep, glob, find (alias for glob), ls (dropped, covered by bash/glob)`);
331
340
  }
332
341
  }
342
+ // SPF #15 — sandbox backends. Chain-scoped, like every check above in
343
+ // this loop: it fires before anything spawns, for the agents this run
344
+ // will actually use. spf doctor's roster-wide equivalents (checks
345
+ // #7/#12/#13/#14/#15/#17) are a deliberately different, wider scope —
346
+ // see validateSandboxConfig's own doc comment.
347
+ problems.push(...validateSandboxConfig(cfg, agent));
333
348
  }
349
+ // SPF #15 — sandbox.scope: "run" is admitted only when the run's agents
350
+ // are provably interchangeable. Once, after the per-agent loop, over the
351
+ // SAME required list — see validateSandboxRunScope's own doc comment.
352
+ problems.push(...validateSandboxRunScope(cfg, required));
334
353
  // Tiering (SPF #14) — every check below lives inside this ONE guard. A
335
354
  // disabled ladder is not a config error, it is a config that is off: none
336
355
  // of this fires for `enabled: false`, no matter what `tiers`/`roles` say —
@@ -396,6 +415,311 @@ export function validate(cfg, required, requiredSuites = [], cwd) {
396
415
  throw new Error("config validation failed:\n- " + problems.join("\n- "));
397
416
  }
398
417
  }
418
+ // ── sandbox config validation (SPF #15) ─────────────────────────────────────
419
+ /** Repo-relative POSIX form of `child` under `root`, or null when `child` is not under `root` — both already-absolute POSIX paths. */
420
+ function posixUnderOrEqual(root, child) {
421
+ const normRoot = path.posix.normalize(root).replace(/\/+$/, "") || "/";
422
+ const normChild = path.posix.normalize(child);
423
+ return normChild === normRoot || normChild.startsWith(`${normRoot}/`);
424
+ }
425
+ /**
426
+ * THIS AGENT's resolved keys: sandbox.env_allowlist ∩ (agent.env_allowlist
427
+ * ?? everything), through the SAME discipline `agentEnv` uses (same source,
428
+ * `operatorEnv()`; same never-log rule) — but deliberately WITHOUT
429
+ * `ENV_BASELINE_KEYS`: PATH/HOME/TMPDIR from this (macOS/Linux) host would
430
+ * be actively wrong inside a Linux container and would break the mandatory
431
+ * `git`/`tar`/`base64` preflight itself. See sandboxSpecFor's own comment.
432
+ */
433
+ function sandboxEnvFor(sb, agent) {
434
+ const operator = operatorEnv();
435
+ const agentAllow = agent.env_allowlist; // undefined/null = no further narrowing
436
+ const env = {};
437
+ for (const key of sb.env_allowlist) {
438
+ if (agentAllow != null && !agentAllow.includes(key))
439
+ continue;
440
+ if (operator[key] !== undefined)
441
+ env[key] = operator[key];
442
+ }
443
+ return env;
444
+ }
445
+ /**
446
+ * The sorted key NAMES `sandboxEnvFor` would resolve for this agent — no
447
+ * operator env read at all, so this is safe in CI where an allowlisted key
448
+ * legitimately may not exist. Used by `validateSandboxRunScope` to compare
449
+ * agents' resolved env shape without ever touching a value.
450
+ */
451
+ function sandboxEnvKeyNames(sb, agent) {
452
+ const agentAllow = agent.env_allowlist;
453
+ const names = agentAllow == null ? sb.env_allowlist : sb.env_allowlist.filter((k) => agentAllow.includes(k));
454
+ return [...new Set(names)].sort();
455
+ }
456
+ function sameStringArray(a, b) {
457
+ return a.length === b.length && a.every((value, i) => value === b[i]);
458
+ }
459
+ /**
460
+ * Config-only, per-agent — no `Run`, no session id, no network, no fs
461
+ * beyond the config already in hand. Called from `validate()`'s existing
462
+ * per-agent loop, chain-scoped (only agents the resolved chain requires).
463
+ * `spf doctor`'s equivalents (checks #7/#12/#13/#14/#17) walk the WHOLE
464
+ * roster instead — a deliberately wider, separate scope; see `validate()`'s
465
+ * own comment for why the two are not the same check.
466
+ */
467
+ export function validateSandboxConfig(cfg, agent) {
468
+ const problems = [];
469
+ const sb = cfg.sandbox;
470
+ const backend = agent.sandbox ?? sb.backend;
471
+ // claude_code spawns a host process (agent_cc.ts's spawn()) with no
472
+ // sandbox seam at all — sandboxing it would mean running the `claude` CLI
473
+ // INSIDE the container, a different feature, not this config flag.
474
+ if (agent.coding_agent === "claude_code" && backend !== "local") {
475
+ problems.push(`agent ${JSON.stringify(agent.name)}: coding_agent "claude_code" cannot use sandbox backend ${JSON.stringify(backend)} — ` +
476
+ `claude_code always spawns a host process with no sandbox seam; set agent.sandbox: local (or sandbox.backend: local) for this agent`);
477
+ }
478
+ if (backend === "opensandbox") {
479
+ if (!sb.opensandbox.base_url.trim()) {
480
+ problems.push('sandbox.opensandbox.base_url is empty — required when the resolved backend is "opensandbox"');
481
+ }
482
+ if (!sb.image.trim()) {
483
+ problems.push('sandbox.image is empty — required when the resolved backend is "opensandbox": the image MUST contain git, tar and base64 ' +
484
+ "(the workspace transport shells out to all three) — see sandbox.setup if your base image needs one of them added");
485
+ }
486
+ }
487
+ if (backend === "cloudflare") {
488
+ if (!sb.cloudflare.bridge_url.trim()) {
489
+ problems.push('sandbox.cloudflare.bridge_url is empty — required when the resolved backend is "cloudflare"');
490
+ }
491
+ if (!sb.cloudflare.api_token_env.trim()) {
492
+ problems.push('sandbox.cloudflare.api_token_env is unset — required when the resolved backend is "cloudflare"');
493
+ }
494
+ }
495
+ // sandbox.fanout: "sandbox" (fan-out THROUGH sandboxes) is honored as of
496
+ // PR B — src/cli/commands/fanout.ts's runAttempt already derives each
497
+ // attempt's own SandboxSpec from that attempt's own worktree/adw_id via
498
+ // the ordinary sandboxSpecFor() path (src/core/fanout.ts is unmodified;
499
+ // see its module comment). Nothing to reject here any more.
500
+ // SPF #15 PR B (§6.1) — an unregistered credential broker name is a hard
501
+ // config error, never a silent fallback to "static": the only broker this
502
+ // build ever registers. Compared against the REGISTRY (sandbox.ts), not a
503
+ // hardcoded literal, so a future broker (§6.3) is a code addition there,
504
+ // not a second place this string has to be kept in sync.
505
+ if (!sandbox.KNOWN_CREDENTIAL_BROKER_IDS.includes(sb.credentials.broker)) {
506
+ problems.push(`sandbox.credentials.broker ${JSON.stringify(sb.credentials.broker)} is not a registered credential broker — ` +
507
+ `known: ${sandbox.KNOWN_CREDENTIAL_BROKER_IDS.join(", ")} (never falls back to "static" silently)`);
508
+ }
509
+ if (sb.max_total_lifetime_seconds < sb.lifetime_seconds) {
510
+ problems.push(`sandbox.max_total_lifetime_seconds (${sb.max_total_lifetime_seconds}) must be >= sandbox.lifetime_seconds (${sb.lifetime_seconds})`);
511
+ }
512
+ // The DEFAULT exec deadline has to fit inside the client-side control-plane
513
+ // timeout; a caller-supplied timeoutMs still passes through unmodified
514
+ // (no clamp — see sandbox.ts), so this relation covers only the default.
515
+ if (sb.request_timeout_seconds < sb.exec_timeout_seconds) {
516
+ problems.push(`sandbox.request_timeout_seconds (${sb.request_timeout_seconds}) must be >= sandbox.exec_timeout_seconds (${sb.exec_timeout_seconds})`);
517
+ }
518
+ for (const [key, value] of [
519
+ ["workspace_dir", sb.workspace_dir],
520
+ ["handoff_dir", sb.handoff_dir],
521
+ ["scratch_dir", sb.scratch_dir],
522
+ ]) {
523
+ if (!path.posix.isAbsolute(value))
524
+ problems.push(`sandbox.${key} must be an absolute path (got ${JSON.stringify(value)})`);
525
+ }
526
+ // The blocker: nested handoff/scratch dirs ride the extract's `git add -A`
527
+ // onto the host at `<repo>/.spf/...`, which spf init's GITIGNORE_ENTRIES
528
+ // does not cover, so permissions.enforce kills the phase. One shared rule
529
+ // applied to BOTH planes — not two similar checks that can drift apart.
530
+ if (path.posix.isAbsolute(sb.workspace_dir)) {
531
+ for (const key of ["handoff_dir", "scratch_dir"]) {
532
+ const value = sb[key];
533
+ if (path.posix.isAbsolute(value) && posixUnderOrEqual(sb.workspace_dir, value)) {
534
+ problems.push(`sandbox.${key} (${value}) must not be inside sandbox.workspace_dir (${sb.workspace_dir}) — ` +
535
+ `it would be swept into the extract's "git add -A" onto the host and rejected by permissions.enforce`);
536
+ }
537
+ }
538
+ }
539
+ if (path.posix.isAbsolute(sb.handoff_dir) &&
540
+ path.posix.isAbsolute(sb.scratch_dir) &&
541
+ path.posix.normalize(sb.handoff_dir) === path.posix.normalize(sb.scratch_dir)) {
542
+ problems.push(`sandbox.scratch_dir must not equal sandbox.handoff_dir (${sb.handoff_dir}) — the seed's housekeeping would delete handoff payloads`);
543
+ }
544
+ return problems;
545
+ }
546
+ /**
547
+ * `scope: "run"` is safe only when every required agent's create-time spec
548
+ * is provably identical — compared as backend + resolved env KEY NAMES
549
+ * (never values) + the create-time-reachable rest of the spec (image,
550
+ * setup, egress, workspace_dir, handoff_dir). A no-op for `scope: "agent"`
551
+ * (the default) and for a chain whose required agents all resolve to
552
+ * `local` — the common path pays nothing.
553
+ */
554
+ export function validateSandboxRunScope(cfg, required) {
555
+ const problems = [];
556
+ if (cfg.sandbox.scope !== "run")
557
+ return problems;
558
+ const tuples = [];
559
+ for (const name of required) {
560
+ const agent = cfg.agents.find((a) => a.name === name);
561
+ if (!agent)
562
+ continue; // reported by validate()'s own per-agent loop
563
+ const backend = agent.sandbox ?? cfg.sandbox.backend;
564
+ if (backend === "local")
565
+ continue;
566
+ tuples.push({ agent: name, backend, envKeys: sandboxEnvKeyNames(cfg.sandbox, agent) });
567
+ }
568
+ if (tuples.length < 2)
569
+ return problems;
570
+ const first = tuples[0];
571
+ for (const other of tuples.slice(1)) {
572
+ if (other.backend !== first.backend) {
573
+ problems.push(`sandbox.scope: "run" requires every required agent to resolve to the same sandbox backend, but ` +
574
+ `${JSON.stringify(first.agent)} resolves to ${JSON.stringify(first.backend)} while ${JSON.stringify(other.agent)} resolves to ` +
575
+ `${JSON.stringify(other.backend)} — use scope: agent (the default) or align the agents' backends`);
576
+ continue;
577
+ }
578
+ if (!sameStringArray(first.envKeys, other.envKeys)) {
579
+ const onlyFirst = first.envKeys.filter((k) => !other.envKeys.includes(k));
580
+ const onlyOther = other.envKeys.filter((k) => !first.envKeys.includes(k));
581
+ problems.push(`sandbox.scope: "run" requires every required agent to resolve to the SAME env key set, but ` +
582
+ `${JSON.stringify(first.agent)} and ${JSON.stringify(other.agent)} differ ` +
583
+ `(only in ${JSON.stringify(first.agent)}: ${JSON.stringify(onlyFirst)}; only in ${JSON.stringify(other.agent)}: ${JSON.stringify(onlyOther)}) — ` +
584
+ `use scope: agent (the default) or align sandbox.env_allowlist / the agents' own env_allowlist`);
585
+ }
586
+ }
587
+ return problems;
588
+ }
589
+ /**
590
+ * PURE, SYNC — no network, no filesystem, no session id (session ids exist
591
+ * only after `agentSessionId()`, which runs after this — SPF #15's design
592
+ * doc §4.4 walks through exactly why the ordering forces that). `run.cfg` +
593
+ * `run.adw_id` + `run.repo_root` + `run.context_handoff_dir` + the agent in,
594
+ * `SandboxSpec | undefined` out. `undefined` (the resolved backend is
595
+ * "local") means `AgentRequest.sandbox` stays unset — byte-identical to
596
+ * before this feature existed.
597
+ *
598
+ * `handoff_sandbox` PRESERVES `handoff_host`'s shape —
599
+ * `<handoff_dir>/sessions/<adw_id>/context_handoff`, mirroring
600
+ * `run.context_handoff_dir` — because two shipped prompts
601
+ * (planner/user.md, documenter/user.md) parse `<adw_id>` out of that exact
602
+ * shape to name `specs/<adw_id>_<slug>.md`/`app_docs/<adw_id>_<slug>.md`. A
603
+ * bare `handoff_dir` would silently cost those filenames their provenance,
604
+ * with `gates.artifactsExist` still green (it only checks the CLAIMED path).
605
+ */
606
+ export function sandboxSpecFor(run, agent) {
607
+ const sb = run.cfg.sandbox;
608
+ const backend = agent.sandbox ?? sb.backend;
609
+ if (backend === "local")
610
+ return undefined;
611
+ const scope = sb.scope;
612
+ const lease_key = scope === "run" ? run.adw_id : `${run.adw_id}/${agent.name}`;
613
+ const handoff_sandbox = path.posix.join(sb.handoff_dir, "sessions", run.adw_id, "context_handoff");
614
+ return {
615
+ backend,
616
+ adw_id: run.adw_id,
617
+ agent: agent.name,
618
+ lease_key,
619
+ scope,
620
+ host_root: run.repo_root,
621
+ workspace_dir: sb.workspace_dir,
622
+ handoff_host: run.context_handoff_dir,
623
+ handoff_sandbox,
624
+ scratch_dir: sb.scratch_dir,
625
+ env: sandboxEnvFor(sb, agent),
626
+ image: sb.image,
627
+ setup: sb.setup,
628
+ egress: sb.egress,
629
+ lifetime_seconds: sb.lifetime_seconds,
630
+ max_total_lifetime_seconds: sb.max_total_lifetime_seconds,
631
+ request_timeout_seconds: sb.request_timeout_seconds,
632
+ exec_timeout_seconds: sb.exec_timeout_seconds,
633
+ transport: sb.transport,
634
+ opensandbox: sb.opensandbox,
635
+ cloudflare: sb.cloudflare,
636
+ };
637
+ }
638
+ /**
639
+ * Rewrite one path field's string entries by prefix, first rule to match
640
+ * wins, everything else (including an unrelated absolute path) passes
641
+ * through untouched.
642
+ */
643
+ function translatePath(value, rules) {
644
+ for (const [from, to] of rules) {
645
+ if (value === from)
646
+ return to;
647
+ const prefix = from.endsWith("/") ? from : `${from}/`;
648
+ if (value.startsWith(prefix))
649
+ return `${to}/${value.slice(prefix.length)}`;
650
+ }
651
+ return value;
652
+ }
653
+ /**
654
+ * Envelope fields (besides `artifacts`/`changed_files`) that ALSO carry a
655
+ * single absolute path, so both translate functions below must rewrite
656
+ * them too. `diff_path` (`ChangesOutput`, `data_types.ts`) is the known
657
+ * case: `changes.asEnvelope` sets it as an absolute host path alongside
658
+ * (and duplicating) `artifacts[0]`, and the documenter is hard-instructed
659
+ * to read it directly (`prompts.ts`, the documenter's own system/user
660
+ * prompts) — a sandboxed documenter otherwise gets a host path it cannot
661
+ * open. Add a new field here, not a new one-off `if`, the next time an
662
+ * envelope schema grows a path-bearing scalar.
663
+ */
664
+ const SINGLE_PATH_FIELDS = ["diff_path"];
665
+ function translateSinglePathFields(envelope, rules) {
666
+ const out = {};
667
+ const withFields = envelope;
668
+ for (const field of SINGLE_PATH_FIELDS) {
669
+ const value = withFields[field];
670
+ if (typeof value === "string" && value)
671
+ out[field] = translatePath(value, rules);
672
+ }
673
+ return out;
674
+ }
675
+ /**
676
+ * OUTBOUND (sandbox -> host), mutates the freshly parsed envelope in place.
677
+ * Rewrites `artifacts` and, when present, `changed_files` — the handoff
678
+ * rule is checked FIRST (disjoint from workspace_dir by validation, so
679
+ * order is not load-bearing here, but the implementation still checks it
680
+ * first so it stays correct if that invariant is ever relaxed). Applied
681
+ * immediately after every parse (the first prompt AND every JSON-repair/
682
+ * gate-correction re-parse), so it is total over every dispatch.
683
+ */
684
+ export function translateEnvelopePaths(envelope, spec) {
685
+ const rules = [
686
+ [spec.handoff_sandbox, spec.handoff_host],
687
+ [spec.workspace_dir, spec.host_root],
688
+ ];
689
+ envelope.artifacts = envelope.artifacts.map((p) => translatePath(p, rules));
690
+ const withChangedFiles = envelope;
691
+ if (Array.isArray(withChangedFiles.changed_files)) {
692
+ withChangedFiles.changed_files = withChangedFiles.changed_files.map((p) => translatePath(p, rules));
693
+ }
694
+ Object.assign(envelope, translateSinglePathFields(envelope, rules));
695
+ }
696
+ /**
697
+ * INBOUND (host -> sandbox), the exact inverse — applied to `call.previous`
698
+ * at the `variables` build site ONLY, before `JSON.stringify`. Returns a
699
+ * COPY: the persisted envelope and the caller's own object must keep host
700
+ * paths, so this one does not mutate where `translateEnvelopePaths` does.
701
+ *
702
+ * Rule order IS load-bearing here, unlike the outbound direction:
703
+ * `handoff_host` is `<data_dir>/sessions/<adw_id>/context_handoff` and
704
+ * `data_dir` resolves against `repo_root` by default, so `handoff_host` is
705
+ * normally UNDER `host_root`. Checking `host_root` first would rewrite a
706
+ * handoff-plane path to somewhere under `workspace_dir` the mirror never
707
+ * populates — so `handoff_host` is checked FIRST, always.
708
+ */
709
+ export function translateEnvelopeToSandbox(envelope, spec) {
710
+ const rules = [
711
+ [spec.handoff_host, spec.handoff_sandbox],
712
+ [spec.host_root, spec.workspace_dir],
713
+ ];
714
+ const copy = { ...envelope };
715
+ copy.artifacts = (envelope.artifacts ?? []).map((p) => translatePath(p, rules));
716
+ const withChangedFiles = envelope;
717
+ if (Array.isArray(withChangedFiles.changed_files)) {
718
+ copy.changed_files = withChangedFiles.changed_files.map((p) => translatePath(p, rules));
719
+ }
720
+ Object.assign(copy, translateSinglePathFields(envelope, rules));
721
+ return copy;
722
+ }
399
723
  /** One agent call: render prompts -> pi run -> typed parse -> gates -> envelope. */
400
724
  export async function execute(run, phase, call) {
401
725
  // The single dispatch-site change tiering makes: one effective AgentConfig,
@@ -408,10 +732,31 @@ export async function execute(run, phase, call) {
408
732
  const agent = effectiveAgent(run, resolve(run.cfg, phase.params.owner));
409
733
  const agentDir = path.join(run.session_dir, agent.name);
410
734
  mkdirSync(agentDir, { recursive: true });
735
+ // SPF #15 — built here, before `variables`/`prompts.render`, and NOT keyed
736
+ // on the session id (unavailable this early — see sandboxSpecFor's own
737
+ // comment). Both `context_handoff_dir` below and the inbound envelope
738
+ // translation need the spec baked into the rendered prompt text.
739
+ const spec = sandboxSpecFor(run, agent);
740
+ if (spec) {
741
+ sandbox.registerRunLog(spec.adw_id, (e) => run.tracer.event(makeEventRecord({
742
+ adw_id: run.adw_id,
743
+ phase_id: phase.phase_id,
744
+ type: "log",
745
+ name: "sandbox",
746
+ payload: { level: e.level, msg: e.msg, ...(e.data ? { data: e.data } : {}) },
747
+ })));
748
+ }
411
749
  const variables = {
412
750
  prompt: call.prompt,
413
- previous_envelope: call.previous ? JSON.stringify(call.previous, null, 2) : "(none)",
414
- context_handoff_dir: run.context_handoff_dir,
751
+ // Inverse translation (§5.3a): `call.previous` is the previous phase's
752
+ // ALREADY-TRANSLATED host-path envelope; handing it to a sandboxed agent
753
+ // verbatim gives it absolute host paths that do not exist in the
754
+ // container. `changes.asEnvelope`'s `diff_path`/`artifacts` are the
755
+ // sharp case — code-generated, so no prompt instruction can fix them.
756
+ previous_envelope: call.previous
757
+ ? JSON.stringify(spec ? translateEnvelopeToSandbox(call.previous, spec) : call.previous, null, 2)
758
+ : "(none)",
759
+ context_handoff_dir: spec ? spec.handoff_sandbox : run.context_handoff_dir,
415
760
  };
416
761
  const systemText = prompts.render(paths.resolvePromptRef(run, agent.prompt_engineering.system), variables);
417
762
  const userText = prompts.render(paths.resolvePromptRef(run, agent.prompt_engineering.user), variables);
@@ -457,19 +802,26 @@ export async function execute(run, phase, call) {
457
802
  tools: agent.tools ?? undefined,
458
803
  output_schema: call.output_type.schema,
459
804
  output_type_name: call.output_type.name,
460
- cwd: run.repo_root,
805
+ cwd: spec ? spec.workspace_dir : run.repo_root,
461
806
  flue_db_path: path.join(run.data_dir, "flue.db"),
462
807
  env: agentEnv(agent),
808
+ sandbox: spec,
463
809
  };
464
810
  const forward = eventForwarder(run, phase, agent.name, agent.coding_agent);
465
811
  const onSpawn = (pid) => run.tracer.processStart(run.adw_id, "agent", agent.name, pid, `${agent.coding_agent} ${agent.name} ${agent.model}`);
466
812
  const onExit = (pid) => run.tracer.processEnd(run.adw_id, pid);
813
+ if (spec)
814
+ await sandbox.reconcileWorkspace(spec);
467
815
  const result = agent.coding_agent === "claude_code"
468
816
  ? await agentCc.run(request, forward, onSpawn, onExit)
469
817
  : await agentFlue.run(request, forward, onSpawn, onExit);
818
+ // SPEND IS RECORDED BEFORE THE EXTRACT CAN THROW: a failed extract must
819
+ // not also lose this call's tokens/cost off the Run's ledger.
470
820
  run.addUsage(result.tokens, result.cost);
471
821
  spent.merge(result.usage);
472
822
  latest = result;
823
+ if (spec)
824
+ await sandbox.extractWorkspace(spec);
473
825
  return result;
474
826
  }
475
827
  // What the tree looked like before this agent got its hands on it. Every
@@ -477,7 +829,7 @@ export async function execute(run, phase, call) {
477
829
  // measured against this one baseline.
478
830
  const treeBefore = permissions.snapshot(run);
479
831
  let result = await send(userText);
480
- let parsed = await parseWithRetries(run, phase, call, result, send);
832
+ let parsed = await parseWithRetries(run, phase, call, result, send, spec);
481
833
  let envelope = parsed.envelope;
482
834
  // claim gates — violations flow back into the SAME session as corrections
483
835
  for (let gateAttempt = 1; gateAttempt <= Math.max(1, phase.params.retries + 1); gateAttempt++) {
@@ -507,7 +859,7 @@ export async function execute(run, phase, call) {
507
859
  violations.join("\n- ") +
508
860
  "\n\nFix these problems, then re-emit ONLY your Report JSON.";
509
861
  result = await send(correction);
510
- parsed = await parseWithRetries(run, phase, call, result, send);
862
+ parsed = await parseWithRetries(run, phase, call, result, send, spec);
511
863
  envelope = parsed.envelope;
512
864
  }
513
865
  // Permission is checked after every send is done, and before the envelope is
@@ -647,7 +999,7 @@ function extractJson(text) {
647
999
  * Parse the final response against the declared output type; on failure,
648
1000
  * continue the SAME session with a correction (bounded).
649
1001
  */
650
- async function parseWithRetries(run, phase, call, result, send) {
1002
+ async function parseWithRetries(run, phase, call, result, send, spec) {
651
1003
  let current = result;
652
1004
  for (let attempt = 1; attempt <= JSON_FIX_ATTEMPTS + 1; attempt++) {
653
1005
  try {
@@ -655,6 +1007,11 @@ async function parseWithRetries(run, phase, call, result, send) {
655
1007
  // back to extracting JSON from the text in that case, same as before.
656
1008
  const payload = current.report ?? extractJson(current.text);
657
1009
  const envelope = v.parse(call.output_type.schema, payload);
1010
+ // Outbound translation (§5.3a) — total over every parse: the first
1011
+ // prompt AND every JSON-repair/gate-correction re-parse, both call
1012
+ // sites of this function.
1013
+ if (spec)
1014
+ translateEnvelopePaths(envelope, spec);
658
1015
  return { envelope, attempt };
659
1016
  }
660
1017
  catch (error) {