@foldrun/core 0.5.0 → 0.7.0

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 (99) hide show
  1. package/README.md +7 -4
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.js +4 -0
  4. package/dist/src/agent-import.d.ts +21 -0
  5. package/dist/src/agent-import.js +116 -0
  6. package/dist/src/api-tools.js +14 -2
  7. package/dist/src/approvals.d.ts +8 -1
  8. package/dist/src/approvals.js +10 -5
  9. package/dist/src/browser-pod.d.ts +20 -1
  10. package/dist/src/browser-pod.js +58 -9
  11. package/dist/src/completions.js +1 -0
  12. package/dist/src/confine.d.ts +1 -0
  13. package/dist/src/confine.js +33 -4
  14. package/dist/src/container.d.ts +9 -0
  15. package/dist/src/container.js +115 -5
  16. package/dist/src/context-tools.d.ts +1 -1
  17. package/dist/src/deploy.d.ts +9 -0
  18. package/dist/src/deploy.js +31 -3
  19. package/dist/src/evals.js +24 -4
  20. package/dist/src/host-env.d.ts +14 -0
  21. package/dist/src/host-env.js +31 -0
  22. package/dist/src/install-helper.d.ts +17 -0
  23. package/dist/src/install-helper.js +134 -0
  24. package/dist/src/install-tool.d.ts +128 -0
  25. package/dist/src/install-tool.js +133 -0
  26. package/dist/src/mail.js +3 -1
  27. package/dist/src/model-credential.d.ts +30 -0
  28. package/dist/src/model-credential.js +98 -0
  29. package/dist/src/model-env.d.ts +17 -0
  30. package/dist/src/model-env.js +57 -0
  31. package/dist/src/package.d.ts +67 -0
  32. package/dist/src/package.js +594 -0
  33. package/dist/src/platform.d.ts +5 -0
  34. package/dist/src/platform.js +1 -0
  35. package/dist/src/provider-health.js +3 -0
  36. package/dist/src/run-container.d.ts +6 -1
  37. package/dist/src/run-container.js +93 -20
  38. package/dist/src/runner.d.ts +239 -0
  39. package/dist/src/runner.js +380 -72
  40. package/dist/src/runtime.d.ts +77 -0
  41. package/dist/src/runtime.js +230 -5
  42. package/dist/src/script-tools.d.ts +16 -0
  43. package/dist/src/script-tools.js +50 -8
  44. package/dist/src/secrets.d.ts +5 -0
  45. package/dist/src/secrets.js +37 -6
  46. package/dist/src/starter.d.ts +12 -0
  47. package/dist/src/starter.js +36 -4
  48. package/dist/src/step-exec.d.ts +22 -2
  49. package/dist/src/step-exec.js +70 -7
  50. package/dist/src/storage.d.ts +7 -1
  51. package/dist/src/storage.js +14 -1
  52. package/dist/src/store.d.ts +24 -0
  53. package/dist/src/store.js +91 -10
  54. package/dist/src/subagents.js +1 -1
  55. package/dist/src/test-mode.d.ts +17 -2
  56. package/dist/src/test-mode.js +53 -13
  57. package/dist/src/tool-names.d.ts +3 -1
  58. package/dist/src/tool-names.js +4 -2
  59. package/dist/src/tool-test.js +8 -1
  60. package/dist/src/zip.d.ts +17 -0
  61. package/dist/src/zip.js +184 -0
  62. package/index.ts +4 -0
  63. package/package.json +1 -1
  64. package/src/agent-import.ts +154 -0
  65. package/src/api-tools.ts +16 -1
  66. package/src/approvals.ts +10 -3
  67. package/src/browser-pod.ts +46 -9
  68. package/src/completions.ts +1 -0
  69. package/src/confine.ts +36 -5
  70. package/src/container.ts +112 -5
  71. package/src/context-tools.ts +1 -1
  72. package/src/deploy.ts +28 -3
  73. package/src/evals.ts +23 -2
  74. package/src/host-env.ts +32 -0
  75. package/src/install-helper.ts +143 -0
  76. package/src/install-tool.ts +154 -0
  77. package/src/mail.ts +3 -1
  78. package/src/model-credential.ts +112 -0
  79. package/src/model-env.ts +66 -0
  80. package/src/package.ts +664 -0
  81. package/src/platform.ts +6 -0
  82. package/src/provider-health.ts +3 -0
  83. package/src/run-container.ts +92 -19
  84. package/src/runner.ts +385 -72
  85. package/src/runtime.ts +235 -5
  86. package/src/script-tools.ts +48 -6
  87. package/src/secrets.ts +34 -6
  88. package/src/starter.ts +42 -4
  89. package/src/step-exec.ts +75 -10
  90. package/src/storage.ts +13 -0
  91. package/src/store.ts +91 -9
  92. package/src/subagents.ts +1 -1
  93. package/src/test-mode.ts +51 -11
  94. package/src/tool-names.ts +4 -2
  95. package/src/tool-test.ts +8 -1
  96. package/src/zip.ts +189 -0
  97. package/templates/blog-desk/agents/writer/agent.md +1 -1
  98. package/templates/hello/agents/curator/agent.md +3 -3
  99. package/templates/hello/agents/notetaker/agent.md +2 -2
package/src/step-exec.ts CHANGED
@@ -329,6 +329,13 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
329
329
  // declares no tools gets none, instead of seeing the full Claude
330
330
  // Code toolset and burning turns on denied calls.
331
331
  tools: opts.allowed,
332
+ // Stated, never left to the SDK. Unset, it chose its own mode for the
333
+ // login it ran under, and in auto mode its classifier refused a granted
334
+ // script before canUseTool saw it: "denied by the Claude Code auto mode
335
+ // classifier. Reason: [Irreversible Local Destruction]" for backlink-
336
+ // desk's outreach_reset, which only marks old bodies STALE (2026-10-04
337
+ // and 05). The decision is ours: canUseTool and the PreToolUse hook.
338
+ permissionMode: "default",
332
339
  // Deliberately NOT allowedTools: listing them there auto-approves and
333
340
  // skips canUseTool entirely, which is where confinement happens. The
334
341
  // toolset is still restricted by `tools` above.
@@ -354,8 +361,10 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
354
361
  // With sub-agents, the SDK would also offer its built-in ones
355
362
  // (general-purpose has every tool the session has). Only the declared
356
363
  // ones may run; the guard in the hook below refuses the rest as well.
357
- env: subagents ? { ...(opts.env ?? process.env), CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS: "1" } : opts.env,
364
+ env: sdkEnv(opts.env, subagents ? { CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS: "1" } : {}),
358
365
  mcpServers: opts.mcpServers,
366
+ // Only the servers foldrun passes. See sdkEnv for the claude.ai half.
367
+ strictMcpConfig: true,
359
368
  // canUseTool is only asked when the SDK wants permission, and it never
360
369
  // asks for a read inside the cwd — so `Read workspace/storage/x` went
361
370
  // to `<agentDir>/workspace/storage/x` unchecked and unrewritten. On
@@ -630,11 +639,23 @@ async function executeStepInner(opts: ExecOptions, runQuery: QueryFn): Promise<E
630
639
  ...(block.is_error ? { err: true } : {}),
631
640
  ...(open.subagent ? { subagent: open.subagent } : {}),
632
641
  });
642
+ // Why it failed, on the trail. The span above carried only `err`,
643
+ // so a refused call left the agent's own guess as the only account
644
+ // of it: backlink-desk's outreach_reset "denied by the permission
645
+ // classifier" (run-muufboma-2t6l, 2026-10-05) could not be checked.
646
+ if (block.is_error) {
647
+ const why = toolResultText(block.content).replace(/\s+/g, " ").trim().slice(0, 300);
648
+ if (why) emit("error", `${open.name}: ${why}`, { call: block.tool_use_id, ...(open.subagent ? { subagent: open.subagent } : {}) });
649
+ }
633
650
  readPod(block.tool_use_id);
634
651
  }
635
652
  }
636
653
  } else if (message.type === "result") {
637
654
  status = message.subtype === "success" ? "completed" : "failed";
655
+ // Calls the SDK itself refused, which canUseTool never saw.
656
+ for (const d of ("permission_denials" in message && Array.isArray(message.permission_denials) ? message.permission_denials : [])) {
657
+ emit("error", `refused before it ran: ${String((d as { tool_name?: unknown }).tool_name ?? "a tool")}`);
658
+ }
638
659
  if (message.subtype === "error_max_turns") {
639
660
  emit("error", `stopped after ${opts.maxTurns ?? "its"} turns (max_turns: in the flow file) — the step did not finish`);
640
661
  }
@@ -875,8 +896,8 @@ export async function checkVerify(
875
896
  * and `matches:` test this, because it is also what the run's headline
876
897
  * is read from: a reporter that narrated between tool calls ("Now let
877
898
  * me write the report…") failed `matches: ^BAD\b` with a correct
878
- * headline while the check read every turn joined. `judge:` still grades
879
- * the whole result. */
899
+ * headline while the check read every turn joined. `judge:` grades this
900
+ * too, as "the reply", with the whole result beside it as the work. */
880
901
  conclusion?: string | null;
881
902
  data?: unknown;
882
903
  /** The step's own model environment, for `judge:` — it grades on the
@@ -939,7 +960,13 @@ export async function checkVerify(
939
960
  return { ok, headline: ok ? "present and non-empty" : `${value} is missing or empty`, detail: "" };
940
961
  }
941
962
  case "judge": {
942
- const verdict = await judgeReply(value, output, ctx.modelEnv ?? {}, ctx.signal);
963
+ // The reply is the final turn, as everywhere else; every turn goes
964
+ // beside it as the work, so a claim about what the step did can still
965
+ // be checked. Both as one <reply> failed `judge: the reply opens with
966
+ // GOOD` on a reply that did (fix-desk, run-muwevg72-7mza): the joined
967
+ // text opened with the narration before it.
968
+ const reply = ctx.conclusion?.trim() ? ctx.conclusion : output;
969
+ const verdict = await judgeReply(value, reply, output, ctx.modelEnv ?? {}, ctx.signal);
943
970
  const ok = /^\s*PASS\b/i.test(verdict);
944
971
  return { ok, headline: ok ? "PASS" : "FAIL", detail: verdict.slice(0, 300) };
945
972
  }
@@ -952,9 +979,24 @@ export async function checkVerify(
952
979
  * Same shape as the eval judge, and answered with one word first so the
953
980
  * verdict is a prefix test rather than a reading.
954
981
  */
982
+ export function judgePrompt(rubric: string, reply: string, work: string): string {
983
+ const shown = reply.slice(0, 20_000);
984
+ const context = work.trim() && work.trim() !== reply.trim()
985
+ ? `Everything the step wrote while working, for context. "The reply" means only the reply block below, ` +
986
+ `never this:\n<work>\n${work.slice(-20_000)}\n</work>\n\n`
987
+ : "";
988
+ return (
989
+ `You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
990
+ `first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
991
+ context +
992
+ `<reply>\n${shown}\n</reply>`
993
+ );
994
+ }
995
+
955
996
  async function judgeReply(
956
997
  rubric: string,
957
- output: string,
998
+ reply: string,
999
+ work: string,
958
1000
  env: Record<string, string | undefined>,
959
1001
  signal?: AbortSignal,
960
1002
  ): Promise<string> {
@@ -964,16 +1006,14 @@ async function judgeReply(
964
1006
  signal?.addEventListener("abort", () => abortController.abort(), { once: true });
965
1007
  try {
966
1008
  const q = query({
967
- prompt:
968
- `You are grading a reply against one requirement. Answer with PASS or FAIL as the ` +
969
- `first word, then one sentence of reason.\n\nRequirement: ${rubric}\n\n` +
970
- `<reply>\n${output.slice(0, 40_000)}\n</reply>`,
1009
+ prompt: judgePrompt(rubric, reply, work),
971
1010
  options: {
972
1011
  model: "haiku",
973
1012
  systemPrompt: "You grade text against a stated requirement. Be strict and literal.",
974
1013
  tools: [],
975
1014
  settingSources: [],
976
- env,
1015
+ env: sdkEnv(env),
1016
+ strictMcpConfig: true,
977
1017
  maxTurns: 1,
978
1018
  abortController,
979
1019
  },
@@ -1043,3 +1083,28 @@ function runVerify(
1043
1083
  child.on("close", (code) => resolve({ code, out }));
1044
1084
  });
1045
1085
  }
1086
+
1087
+ /**
1088
+ * The environment an Agent SDK session starts with: the step's, minus the
1089
+ * connectors of whoever is signed in to claude.ai on this machine. Claude
1090
+ * Code loads an account's claude.ai MCP servers (Docs, Drive, …) unless told
1091
+ * not to, and `settingSources: []` does not reach them — they come with the
1092
+ * login, not from a settings file. A local `foldrun run` handed every agent
1093
+ * the person's own connectors: the hello template's agents reported that
1094
+ * "the only tools I have are the Claude Docs ones". `strictMcpConfig` keeps
1095
+ * the rest of the on-disk MCP configuration out; this keeps the account's.
1096
+ */
1097
+ export function sdkEnv(base: Record<string, string | undefined> | undefined, extra: Record<string, string> = {}): Record<string, string | undefined> {
1098
+ return { ...(base ?? process.env), ...extra, ENABLE_CLAUDEAI_MCP_SERVERS: "false" };
1099
+ }
1100
+
1101
+ /** The text of a tool result, whichever shape it came in: a string, or a
1102
+ * list of content blocks of which only the text ones say anything. */
1103
+ export function toolResultText(content: unknown): string {
1104
+ if (typeof content === "string") return content;
1105
+ if (!Array.isArray(content)) return "";
1106
+ return content
1107
+ .filter((b): b is { type: "text"; text: string } => !!b && (b as { type?: unknown }).type === "text" && typeof (b as { text?: unknown }).text === "string")
1108
+ .map((b) => b.text)
1109
+ .join(" ");
1110
+ }
package/src/storage.ts CHANGED
@@ -839,6 +839,14 @@ export async function materializeFiles(tenant: string, workspace: string): Promi
839
839
  return brought;
840
840
  }
841
841
 
842
+ /** path → sha of every stored file: what `storage/` holds once
843
+ * materializeFiles has run. A run keeps it to tell its own writes from the
844
+ * files it was handed. */
845
+ export function storageBaseline(tenant: string, workspace: string): Record<string, string> {
846
+ if (!fileStoreEnabled()) return {};
847
+ return Object.fromEntries(readIndex(tenant, workspace).files.map((f) => [f.path, f.sha]));
848
+ }
849
+
842
850
  /**
843
851
  * Take what the run left in `storage/` back into the store, stamped with the
844
852
  * run that wrote it.
@@ -851,6 +859,7 @@ export async function harvestFiles(
851
859
  tenant: string,
852
860
  workspace: string,
853
861
  by: string,
862
+ opts: { changedFrom?: Record<string, string> } = {},
854
863
  ): Promise<{ saved: string[]; errors: string[] }> {
855
864
  if (!fileStoreEnabled()) return { saved: [], errors: [] };
856
865
  adoptLegacyFilesDir(workspaceDir(tenant, workspace));
@@ -885,6 +894,10 @@ export async function harvestFiles(
885
894
  // Same size is not proof, so confirm by hash before skipping a write.
886
895
  if (sha256(fs.readFileSync(abs)) === previous.sha) continue;
887
896
  }
897
+ // Only what the run itself changed: a file still holding the bytes it
898
+ // was handed is not this run's work, and the index may have moved on
899
+ // since (an upload while the run was going) — that must win.
900
+ if (opts.changedFrom && opts.changedFrom[norm] === sha256(fs.readFileSync(abs))) continue;
888
901
  await putFileQuietly(tenant, workspace, norm, fs.readFileSync(abs), by);
889
902
  saved.push(norm);
890
903
  } catch (err) {
package/src/store.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  //
21
21
  // An account is a named directory (per OKF: tenancy is a hosting concern).
22
22
 
23
+ import { expandPlatformVars } from "./host-env.ts";
23
24
  import type { BrowserPodOutcome } from "./browser-pod.ts";
24
25
  import fs from "node:fs";
25
26
  import path from "node:path";
@@ -57,7 +58,7 @@ import {
57
58
  } from "./okf.ts";
58
59
  import { readTransport, KINDS } from "./kinds.ts";
59
60
  import { providerPreset, looksOpenAiShaped, PROTECTED_PARAMS, type WireFormat, type AuthShape } from "./providers.ts";
60
- import { starterFiles, accountFiles } from "./starter.ts";
61
+ import { starterFiles, accountFiles, blankWorkspaceFiles } from "./starter.ts";
61
62
  import { trimSlashes, MAX_EDITABLE_FILE } from "./paths.ts";
62
63
 
63
64
  // Where workspaces live. The hosted app keeps many under data/; the CLI runs
@@ -317,6 +318,19 @@ export function assertCanonicalCase(rel: string) {
317
318
  // fire (trigger-log.ts). Before it was listed here every deploy erased it.
318
319
  export const PLATFORM_FILES = ["secrets.json", "hooks.json", "hook-deliveries.jsonl", ".repo.json", "trigger-log.jsonl"];
319
320
 
321
+ // The agent engine's own home files. Each step's sandbox runs the Claude
322
+ // Agent SDK — the engine, whatever model answers — with its home in the
323
+ // agent's folder, and the engine writes its settings (.claude.json, its
324
+ // backups), plugin caches and the full transcript of every session
325
+ // (.claude/projects/*.jsonl) there. Nothing in foldrun reads them. Copied
326
+ // back after every step, they had piled up to 5,274 files and 19 MB in one
327
+ // agent's folder on the box by 6 Oct 2026, carried every past conversation
328
+ // into every sandbox, and their clashing rewrites were the "write-back
329
+ // conflict on .claude.json" lines (and the big merges) of that day. A
330
+ // workspace's own .claude/agents/*.md — Claude Code subagents an author
331
+ // writes, imported as agents — sits at the workspace root and is untouched.
332
+ const ENGINE_HOME_RE = /^agents\/[^/]+\/\.claude([./]|$)/;
333
+
320
334
  /** True for anything the platform, not the author or the agent, writes. */
321
335
  export function isPlatformPath(rel: string): boolean {
322
336
  const norm = rel.replaceAll("\\", "/");
@@ -325,6 +339,7 @@ export function isPlatformPath(rel: string): boolean {
325
339
  // are the vault, as far as anything outside the platform is concerned.
326
340
  if (/(^|\/)secrets\.json\./.test(norm)) return true;
327
341
  if (norm === "trigger-log.jsonl.tmp") return true;
342
+ if (ENGINE_HOME_RE.test(norm)) return true;
328
343
  return norm === "runs" || norm.startsWith("runs/") || norm === ".foldrun" || norm.startsWith(".foldrun/");
329
344
  }
330
345
 
@@ -531,7 +546,7 @@ export function buildMemoryIndex(dir: string, prefix = ""): string | null {
531
546
  // Workspace-scoped shared assets: skills/, memory/, tools/ sit beside agents/
532
547
  // and are available to every agent in that workspace.
533
548
  export function workspaceMemoryIndex(tenant: string, workspace: string): string | null {
534
- return buildMemoryIndex(path.join(workspaceDir(tenant, workspace), "memory"), "../../memory/");
549
+ return buildMemoryIndex(path.join(workspaceDir(tenant, workspace), "memory"), "workspace/memory/");
535
550
  }
536
551
 
537
552
  // Knowledge is what you *gave* an agent — stable reference material an agent
@@ -543,7 +558,7 @@ export function knowledgeIndex(dir: string, prefix = ""): string | null {
543
558
  }
544
559
 
545
560
  export function workspaceKnowledgeIndex(tenant: string, workspace: string): string | null {
546
- return knowledgeIndex(path.join(workspaceDir(tenant, workspace), "knowledge"), "../../knowledge/");
561
+ return knowledgeIndex(path.join(workspaceDir(tenant, workspace), "knowledge"), "workspace/knowledge/");
547
562
  }
548
563
 
549
564
  // One noun for capability. A tools/<name>.md file declares either an HTTP
@@ -625,6 +640,27 @@ export function toolIsOutward(data: Record<string, unknown>): boolean {
625
640
  return data.outward === true;
626
641
  }
627
642
 
643
+ /** How much of a tool.md body rides to the model with the tool. */
644
+ export const TOOL_GUIDE_MAX = 6000;
645
+
646
+ /**
647
+ * The prose under a tool.md's frontmatter: how to call it, what it refuses,
648
+ * what its output means. Only `description:` used to reach the model, so
649
+ * agents invented what the body documented (5 Oct 2026). The fenced program
650
+ * of a single-file tool is code, not guidance, and stays out.
651
+ */
652
+ export function toolGuide(body: string | undefined, inlineProgram = false): string | undefined {
653
+ if (!body) return undefined;
654
+ let text = body.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
655
+ // Only a single-file tool's program is cut; in a `run:` tool every fenced
656
+ // block is an example of a call, which is exactly what the model needs.
657
+ const inline = inlineProgram ? fencedCodeBlock(text) : null;
658
+ if (inline) text = text.slice(0, inline.start) + text.slice(inline.end);
659
+ text = text.trim();
660
+ if (!text) return undefined;
661
+ return text.length > TOOL_GUIDE_MAX ? `${text.slice(0, TOOL_GUIDE_MAX)}\n\n[the rest of tool.md is cut here]` : text;
662
+ }
663
+
628
664
  export function parseToolDef(data: Record<string, unknown>, fallbackName: string, body?: string): ToolDef | null {
629
665
  const name = typeof data.name === "string" ? data.name : fallbackName;
630
666
  // `transport:`, or a legacy `type: http|script|mcp` from before `type:` meant
@@ -659,16 +695,24 @@ export function parseToolDef(data: Record<string, unknown>, fallbackName: string
659
695
  };
660
696
  }
661
697
  if (kind === "script") {
698
+ // `guide` is the body's, never the frontmatter's: an author's `guide:`
699
+ // would otherwise ride to the model in place of the manual.
700
+ if ("guide" in data) { data = { ...data }; delete data.guide; }
662
701
  // The program is a run: path, or the file's own fenced code block — the
663
702
  // single-file form, which is what lets a script tool read and edit like
664
703
  // every other markdown document.
665
- if (typeof data.run === "string") return { kind: "script", name, spec: { ...data, name } };
704
+ if (typeof data.run === "string") {
705
+ const guide = toolGuide(body);
706
+ return { kind: "script", name, spec: { ...data, name, ...(guide ? { guide } : {}) } };
707
+ }
666
708
  const inline = body ? fencedCode(body) : null;
667
709
  if (!inline) return null;
668
- return { kind: "script", name, spec: { ...data, name, code: inline.code, codeExt: inline.ext } };
710
+ const guide = toolGuide(body, true);
711
+ return { kind: "script", name, spec: { ...data, name, code: inline.code, codeExt: inline.ext, ...(guide ? { guide } : {}) } };
669
712
  }
670
713
  const [spec] = parseApis([{ ...data, name }]);
671
- return spec ? { kind: "http", name, spec } : null;
714
+ const guide = toolGuide(body);
715
+ return spec ? { kind: "http", name, spec: guide ? { ...spec, guide } : spec } : null;
672
716
  }
673
717
 
674
718
  /**
@@ -864,6 +908,8 @@ export interface ApiSpec {
864
908
  name: string;
865
909
  base: string;
866
910
  description: string;
911
+ /** The tool.md body, for the model (see toolGuide). */
912
+ guide?: string;
867
913
  headers: Record<string, string>; // values may contain ${SECRET_NAME}
868
914
  query: Record<string, string>; // always-appended query params (e.g. api keys)
869
915
  methods: string[]; // allowed HTTP methods; default GET only
@@ -1136,7 +1182,9 @@ export function parseApis(raw: unknown): ApiSpec[] {
1136
1182
  out.push({
1137
1183
  name: name.replace(/[^a-zA-Z0-9_]/g, "_"),
1138
1184
  ...(Number(e.timeout) > 0 ? { timeout: Number(e.timeout) } : {}),
1139
- base: trimSlashes(base),
1185
+ // `${FOLDRUN_API_URL}` and `${FOLDRUN_PUBLIC_URL}` are the install's own
1186
+ // addresses, filled here on the host so a desk never names one.
1187
+ base: trimSlashes(expandPlatformVars(base)),
1140
1188
  description: typeof e.description === "string" ? e.description : "",
1141
1189
  headers: asRecordLocal(e.headers),
1142
1190
  query: asRecordLocal(e.query),
@@ -2875,6 +2923,12 @@ export function setWorkspaceDescription(tenant: string, workspace: string, descr
2875
2923
  * this copy still said `type: Agent` after the CLI's had moved to `kind:`,
2876
2924
  * so every workspace made from the dashboard was born in the old format.
2877
2925
  */
2926
+ /** What the dashboard's "Create workspace" makes: blank (blankWorkspaceFiles),
2927
+ * minus the laptop-only files, for the same reason as templateFiles. */
2928
+ export function blankTemplateFiles(workspace: string): DeployFile[] {
2929
+ return blankWorkspaceFiles(workspace).filter((f) => f.path === "AGENTS.md" || IN_WORKSPACE_DIR.test(f.path));
2930
+ }
2931
+
2878
2932
  export function templateFiles(workspace: string): DeployFile[] {
2879
2933
  // The starter minus what only matters on a laptop's disk: .gitignore
2880
2934
  // guards a local clone's secrets, but the hosted store never keeps
@@ -3085,6 +3139,10 @@ export interface StepRecord {
3085
3139
  * through the account's browser pod. The last attempt's; absent where
3086
3140
  * the executor has one image (docker, in-process). */
3087
3141
  image?: { variant: "full" | "slim"; why?: string; pod?: boolean } | null;
3142
+ /** Which model credential the step ran on, by its variable — "API key
3143
+ * (ANTHROPIC_API_KEY)", "Claude login token (CLAUDE_CODE_OAUTH_TOKEN)",
3144
+ * "provider: …" — or "none set" (model-credential.ts). */
3145
+ credential?: string;
3088
3146
  /** Slim browsing through the account's pod (browser-pod.ts): reconnect
3089
3147
  * tries and how many got through; when the pod was lost, why and what
3090
3148
  * the step had written by then; and what happened — `fallback` (the
@@ -3384,7 +3442,23 @@ export function runOutputPath(
3384
3442
 
3385
3443
  export function readRun(tenant: string, workspace: string, runId: string): RunRecord | null {
3386
3444
  const p = runFilePath(tenant, workspace, runId);
3387
- return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null;
3445
+ // EFS/NFS: writeRun() replaces this file by rename, so an open()+read()
3446
+ // here can land on the just-unlinked inode and come back ESTALE (errno
3447
+ // -116, surfaced by Node as "Unknown system error -116", syscall read).
3448
+ // That isn't corruption — the next open() by path sees the committed new
3449
+ // inode — so re-read a few times on ESTALE and rethrow anything else.
3450
+ // (On dev-aws this surfaced as a CLI `--wait` call returning HTTP 500
3451
+ // from waitForRun's poll while the run itself completed fine.)
3452
+ const attempts = 5;
3453
+ for (let i = 1; ; i++) {
3454
+ try {
3455
+ return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null;
3456
+ } catch (e) {
3457
+ const err = e as NodeJS.ErrnoException;
3458
+ if ((err.code === "ESTALE" || err.errno === -116) && i < attempts) continue;
3459
+ throw e;
3460
+ }
3461
+ }
3388
3462
  }
3389
3463
 
3390
3464
  export function writeRun(tenant: string, workspace: string, run: RunRecord) {
@@ -3702,6 +3776,12 @@ export function runCost(run: RunRecord): number {
3702
3776
  * fan-out template is a row in the trace rather than work — billing any of
3703
3777
  * them would charge for steps the customer can see never executed, which is
3704
3778
  * the fastest way to lose an argument about an invoice.
3779
+ *
3780
+ * Tokens count only for steps that ran on the platform's own model
3781
+ * credential. A step on the account's own key (`credential` "provider: …" —
3782
+ * a provider: block, or Settings → Model) was paid for at the provider, and
3783
+ * billing it again here charged a BYOK customer twice. And a carried step's
3784
+ * tokens were billed where it ran, like its compute.
3705
3785
  */
3706
3786
  export function runMeter(run: RunRecord): {
3707
3787
  tokenCostUsd: number;
@@ -3716,17 +3796,19 @@ export function runMeter(run: RunRecord): {
3716
3796
  let computeSecs = 0;
3717
3797
  let smallSecs = 0;
3718
3798
  let netBytes = 0;
3799
+ let tokenCostUsd = 0;
3719
3800
  for (const s of run.steps) {
3720
3801
  if (s.status !== "completed" && s.status !== "failed") continue;
3721
3802
  // A carried step ran — and was billed — in the run it was carried from.
3722
3803
  if (s.carriedFrom) continue;
3723
3804
  steps += 1;
3805
+ if (!(s.credential ?? "").startsWith("provider:")) tokenCostUsd += s.costUsd ?? 0;
3724
3806
  const secs = s.computeSecs ?? 0;
3725
3807
  computeSecs += secs;
3726
3808
  if (s.size === "small") smallSecs += secs;
3727
3809
  netBytes += (s.actual?.rxBytes ?? 0) + (s.actual?.txBytes ?? 0);
3728
3810
  }
3729
- return { tokenCostUsd: runCost(run), steps, computeSecs, smallSecs, netBytes };
3811
+ return { tokenCostUsd, steps, computeSecs, smallSecs, netBytes };
3730
3812
  }
3731
3813
 
3732
3814
  /**
package/src/subagents.ts CHANGED
@@ -97,7 +97,7 @@ export function subagentPrompt(o: {
97
97
  `# Where you are\n\n` +
98
98
  `You are ${o.name}, a sub-agent started by ${o.parent} to do one job and report back. ` +
99
99
  `You work in ${o.parent}'s directory, \`agents/${o.parent}/\` in the \`${o.workspace}\` workspace: ` +
100
- `\`../../\` is the workspace root, its knowledge is at \`../../knowledge/\`, and absolute paths are refused. ` +
100
+ `\`workspace/\` is the workspace root, its knowledge is at \`workspace/knowledge/\`, and absolute paths are refused. ` +
101
101
  `You have only the tools listed for you. When the job is done, reply with what you found or made — ` +
102
102
  `your reply is all ${o.parent} sees of your work, so put the facts and file paths in it.`,
103
103
  ...(o.shared?.trim() ? [o.shared.trim()] : []),
package/src/test-mode.ts CHANGED
@@ -18,7 +18,9 @@
18
18
  // a test run; the env var says TEST_MODE_WITHHELD instead, so a script
19
19
  // that forgot to check fails at the provider with an auth error
20
20
  // - the write-back: state/ and storage/ go INTO the sandbox as usual and
21
- // come out into runs/<id>/test-writes/ instead of the real directories
21
+ // come out into runs/<id>/test-writes/ instead of the real directories;
22
+ // the next step's copy has those writes laid over it (overlayTestWrites),
23
+ // so the run reads its own work and the workspace never sees it
22
24
  //
23
25
  // Nothing here weakens a live run: every function is a no-op unless the run
24
26
  // says `test: true`.
@@ -270,25 +272,32 @@ export function snapshotDivertedDirs(wsRoot: string): Map<string, Buffer> {
270
272
 
271
273
  /** Undo a step's writes to the diverted directories, keeping each changed
272
274
  * or new file under `to` by its workspace-relative path. Deletions are
273
- * undone too — the original is put back. Returns what was set aside. */
275
+ * undone too — the original is put back. `handed` is what the step was
276
+ * given when that was more than the workspace (the run's earlier test
277
+ * writes, laid over it): only what the step changed from THAT is its
278
+ * write. Returns what was set aside. */
274
279
  export function restoreDivertedDirs(
275
280
  wsRoot: string,
276
281
  before: Map<string, Buffer>,
277
282
  to: string,
278
283
  note: (rel: string, summary: string) => void,
284
+ handed: Map<string, Buffer> = before,
279
285
  ): string[] {
280
286
  const now = snapshotDivertedDirs(wsRoot);
281
287
  const aside: string[] = [];
282
288
  for (const [rel, next] of now) {
283
- const was = before.get(rel) ?? null;
284
- if (was && was.equals(next)) continue;
285
- const dest = path.join(to, rel);
286
- fs.mkdirSync(path.dirname(dest), { recursive: true });
287
- fs.writeFileSync(dest, next);
288
- aside.push(rel);
289
- note(rel, divertedSummary(rel, was, next));
290
- if (was) fs.writeFileSync(path.join(wsRoot, rel), was);
291
- else fs.rmSync(path.join(wsRoot, rel), { force: true });
289
+ const given = handed.get(rel) ?? null;
290
+ if (!(given && given.equals(next))) {
291
+ const dest = path.join(to, rel);
292
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
293
+ fs.writeFileSync(dest, next);
294
+ aside.push(rel);
295
+ note(rel, divertedSummary(rel, before.get(rel) ?? null, next));
296
+ }
297
+ const orig = before.get(rel);
298
+ if (orig) {
299
+ if (!orig.equals(next)) fs.writeFileSync(path.join(wsRoot, rel), orig);
300
+ } else fs.rmSync(path.join(wsRoot, rel), { force: true });
292
301
  }
293
302
  for (const [rel, was] of before) {
294
303
  if (now.has(rel)) continue;
@@ -300,6 +309,37 @@ export function restoreDivertedDirs(
300
309
  return aside;
301
310
  }
302
311
 
312
+ /**
313
+ * A test run's later steps see what its earlier steps wrote. Those writes
314
+ * are kept under runs/<id>/test-writes/, never on the workspace, so each
315
+ * step's copy of the workspace gets them laid over it — otherwise a step
316
+ * reads the real files, and a flow that hands work on through storage/
317
+ * tests something no live run does: on 2026-10-03 a strata-desk test run's
318
+ * compiler wrote today's PASS report, and the mailer was handed the
319
+ * morning's FAIL one and refused. Times are kept, so a verify that asks
320
+ * whether a file is fresh answers for the step that wrote it. Returns the
321
+ * workspace-relative paths laid over.
322
+ */
323
+ export function overlayTestWrites(wsCopy: string, from: string): string[] {
324
+ const laid: string[] = [];
325
+ for (const dir of TEST_DIVERTED_DIRS) {
326
+ const root = path.join(from, dir);
327
+ if (!fs.existsSync(root)) continue;
328
+ for (const entry of fs.readdirSync(root, { recursive: true, withFileTypes: true })) {
329
+ if (!entry.isFile()) continue;
330
+ const abs = path.join(entry.parentPath, entry.name);
331
+ const rel = path.relative(from, abs).replaceAll("\\", "/");
332
+ const dest = path.join(wsCopy, rel);
333
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
334
+ fs.copyFileSync(abs, dest);
335
+ const st = fs.statSync(abs);
336
+ fs.utimesSync(dest, st.atime, st.mtime);
337
+ laid.push(rel);
338
+ }
339
+ }
340
+ return laid;
341
+ }
342
+
303
343
  /** `[test] ` in front of a headline, once. */
304
344
  export function testHeadline(summary: string): string {
305
345
  return summary.startsWith("[test]") ? summary : `[test] ${summary}`;
package/src/tool-names.ts CHANGED
@@ -49,8 +49,10 @@ export const BUILTIN_TOOLS = new Set([
49
49
  "NotebookEdit", "TodoWrite",
50
50
  ]);
51
51
 
52
- /** The platform's own groups, served in-process rather than by the SDK. */
53
- export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask"]);
52
+ /** The platform's own groups, served in-process rather than by the SDK.
53
+ * `install` is granted to every agent anyway (install-tool.ts); naming it is
54
+ * accepted so a file that says so is not flagged. */
55
+ export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask", "install"]);
54
56
 
55
57
  /** Would this name resolve to something the runtime provides? Built-ins win a
56
58
  * clash, so a tool file of the same name is shadowed rather than granted. */
package/src/tool-test.ts CHANGED
@@ -277,7 +277,14 @@ export async function testTool(
277
277
  });
278
278
  }
279
279
 
280
- const names = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
280
+ // The tool's own secrets, and the calling agent's `secrets:` — a run
281
+ // hands a script both. Testing gbp_read (whose agent, not the tool,
282
+ // declares GBP_OAUTH) said "GBP_OAUTH is not set" while every run of the
283
+ // same call worked (9 Oct 2026). Only an agent that grants the tool: the
284
+ // agents[0] stand-in above is there for its depth, not its keys.
285
+ const own = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
286
+ const granting = caller && caller.ownTools.includes(def.name) ? caller.secrets : [];
287
+ const names = [...new Set([...own, ...granting])];
281
288
  const { env: stored, missing } = resolveSecrets(tenant, names, workspace);
282
289
  const live = await liveSecrets(stored, { tenant, workspace });
283
290
  if ("error" in live) {