@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.
- package/README.md +56 -0
- package/assets/defaults/spf.config.yaml +75 -0
- package/assets/skill/references/config.md +98 -4
- package/dist/chains/index.d.ts +2 -0
- package/dist/chains/index.js +4 -0
- package/dist/cli/commands/doctor.js +339 -2
- package/dist/cli/commands/fanout.d.ts +7 -14
- package/dist/cli/commands/fanout.js +45 -39
- package/dist/cli/commands/loop.d.ts +2 -0
- package/dist/cli/commands/loop.js +198 -0
- package/dist/cli/commands/run.js +14 -4
- package/dist/cli/commands/watch.d.ts +29 -1
- package/dist/cli/commands/watch.js +219 -64
- package/dist/cli/index.js +14 -0
- package/dist/core/agent_cc.d.ts +11 -0
- package/dist/core/agent_cc.js +25 -2
- package/dist/core/agent_flue.js +14 -5
- package/dist/core/agents.d.ts +61 -1
- package/dist/core/agents.js +363 -6
- package/dist/core/data_types.d.ts +316 -0
- package/dist/core/data_types.js +143 -0
- package/dist/core/loop.d.ts +230 -0
- package/dist/core/loop.js +290 -0
- package/dist/core/quality.d.ts +1 -2
- package/dist/core/sandbox.d.ts +236 -0
- package/dist/core/sandbox.js +655 -0
- package/dist/core/sandbox_cloudflare.d.ts +137 -0
- package/dist/core/sandbox_cloudflare.js +505 -0
- package/dist/core/sandbox_opensandbox.d.ts +59 -0
- package/dist/core/sandbox_opensandbox.js +484 -0
- package/dist/core/sandbox_sdk_types.d.ts +171 -0
- package/dist/core/sandbox_sdk_types.js +20 -0
- package/dist/core/watch.d.ts +56 -0
- package/dist/core/watch.js +358 -52
- package/dist/core/worktree_data.d.ts +1 -0
- package/dist/core/worktree_data.js +37 -0
- package/package.json +1 -1
package/dist/core/agents.js
CHANGED
|
@@ -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
|
-
|
|
414
|
-
|
|
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) {
|