@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/README.md CHANGED
@@ -208,16 +208,19 @@ To *run* an agent you need one of:
208
208
 
209
209
  - **`ANTHROPIC_API_KEY`** — an API key from
210
210
  [console.anthropic.com](https://console.anthropic.com).
211
+ - **`CLAUDE_CODE_OAUTH_TOKEN`** — a Claude login token, from
212
+ `claude setup-token`. Used when no API key is set.
211
213
  - **Your own provider.** An agent that names a `provider:` needs no
212
214
  Anthropic key: 29 endpoints are known by name, including `ollama`,
213
215
  `lmstudio` and `vllm`, so a local model is a complete answer. Anything
214
216
  not in that list works by spelling out `base_url:`, `format:` and
215
217
  `auth:`. See [providers](https://docs.foldrun.io/providers).
216
218
 
217
- A **Claude Code or claude.ai subscription login is not one of them**, and
218
- the CLI will not look for one. Anthropic does not permit products built on
219
- its Agent SDK to run on consumer subscriptions, so using that login here
220
- would put you outside their terms — an API key is the supported path.
219
+ The first one set is used, and every step records which. The login Claude
220
+ Code keeps on the machine is never picked up by itself. Anthropic does not
221
+ allow products built on its Agent SDK to offer claude.ai login unless
222
+ Anthropic has approved them, so check that the terms cover your use before
223
+ you rely on a login token.
221
224
 
222
225
  ## Status
223
226
 
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from "./src/layout.ts";
2
2
  export * from "./src/paths.ts";
3
3
  export * from "./src/store.ts";
4
+ export * from "./src/model-credential.ts";
4
5
  export * from "./src/starter.ts";
5
6
  export * from "./src/runner.ts";
6
7
  export * from "./src/okf.ts";
@@ -21,6 +22,8 @@ export * from "./src/linkable.ts";
21
22
  export * from "./src/refs.ts";
22
23
  export * from "./src/tool-names.ts";
23
24
  export * from "./src/history.ts";
25
+ export * from "./src/zip.ts";
26
+ export * from "./src/package.ts";
24
27
  export * from "./src/gitrepo.ts";
25
28
  export * from "./src/storage.ts";
26
29
  export * from "./src/secrets.ts";
@@ -30,6 +33,7 @@ export * from "./src/notify.ts";
30
33
  export * from "./src/webhook.ts";
31
34
  export * from "./src/approvals.ts";
32
35
  export * from "./src/deploy.ts";
36
+ export * from "./src/agent-import.ts";
33
37
  export * from "./src/account.ts";
34
38
  export * from "./src/tar.ts";
35
39
  export * from "./src/git.ts";
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@
12
12
  export * from "./src/layout.js";
13
13
  export * from "./src/paths.js";
14
14
  export * from "./src/store.js";
15
+ export * from "./src/model-credential.js";
15
16
  export * from "./src/starter.js";
16
17
  export * from "./src/runner.js";
17
18
  export * from "./src/okf.js";
@@ -32,6 +33,8 @@ export * from "./src/linkable.js";
32
33
  export * from "./src/refs.js";
33
34
  export * from "./src/tool-names.js";
34
35
  export * from "./src/history.js";
36
+ export * from "./src/zip.js";
37
+ export * from "./src/package.js";
35
38
  export * from "./src/gitrepo.js";
36
39
  export * from "./src/storage.js";
37
40
  export * from "./src/secrets.js";
@@ -41,6 +44,7 @@ export * from "./src/notify.js";
41
44
  export * from "./src/webhook.js";
42
45
  export * from "./src/approvals.js";
43
46
  export * from "./src/deploy.js";
47
+ export * from "./src/agent-import.js";
44
48
  export * from "./src/account.js";
45
49
  export * from "./src/tar.js";
46
50
  export * from "./src/git.js";
@@ -0,0 +1,21 @@
1
+ export interface AgentImportResult {
2
+ /** The name the agent has in the target workspace. */
3
+ name: string;
4
+ /** Workspace-relative paths written. */
5
+ files: string[];
6
+ /** What the copy refers to that the target workspace does not have. The
7
+ * import still happens: a missing secret is set in a minute, and refusing
8
+ * would make the person copy the file by hand instead. */
9
+ warnings: string[];
10
+ }
11
+ /** An error the API layer turns into a status rather than a 500. */
12
+ export declare class AgentImportError extends Error {
13
+ readonly status: number;
14
+ constructor(message: string, status: number);
15
+ }
16
+ /** The files `importAgent` would copy, without writing anything. */
17
+ export declare function agentSourceFiles(tenant: string, from: string, agent: string): string[];
18
+ export declare function importAgent(tenant: string, from: string, agent: string, to: string, opts?: {
19
+ as?: string;
20
+ by?: string;
21
+ }): AgentImportResult;
@@ -0,0 +1,116 @@
1
+ // Bring an agent you already have into another workspace of the same account.
2
+ //
3
+ // A new workspace starts blank (blankWorkspaceFiles), and the agents worth
4
+ // having in it are usually ones already written and tuned elsewhere — a
5
+ // researcher from one desk, a reviewer from another. Copying them by hand
6
+ // meant opening each file, and missing the skills and scripts that live in
7
+ // the agent's own folder. This copies the folder: what the agent IS, never
8
+ // what it has accumulated.
9
+ //
10
+ // Copied: agents/<name>/agent.md and everything authored beside it — its own
11
+ // skills/, scripts/, knowledge/. Not copied: memory/ (what it learned in the
12
+ // other workspace's runs), outputs/, state/, the step's workspace link, and
13
+ // the SDK's .claude session files. A fresh copy starts with no lessons that
14
+ // were about somebody else's desk.
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import matter from "gray-matter";
18
+ import { assertSafeName, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, workspaceDir, workspaceTools, listWorkspaces, } from "./store.js";
19
+ import { libraryDir, libraryTools } from "./library.js";
20
+ import { isRuntimeTool, toolEntryName } from "./tool-names.js";
21
+ import { discoverSkills } from "./runner.js";
22
+ import { listSecrets } from "./secrets.js";
23
+ /** An error the API layer turns into a status rather than a 500. */
24
+ export class AgentImportError extends Error {
25
+ status;
26
+ constructor(message, status) {
27
+ super(message);
28
+ this.status = status;
29
+ }
30
+ }
31
+ /** Accumulated or generated, not authored — never carried to another workspace. */
32
+ const NOT_COPIED = /^agents\/[^/]+\/(memory|outputs|state|workspace|\.claude)(\/|$)|^agents\/[^/]+\/\.claude\.json$/;
33
+ /** The files `importAgent` would copy, without writing anything. */
34
+ export function agentSourceFiles(tenant, from, agent) {
35
+ const prefix = `agents/${agent}/`;
36
+ return listWorkspaceFiles(tenant, from).filter((p) => p.startsWith(prefix) && !NOT_COPIED.test(p));
37
+ }
38
+ const list = (v) => Array.isArray(v) ? v.map((x) => toolEntryName(x)).filter(Boolean) : typeof v === "string" && v.trim() ? [v.trim()] : [];
39
+ export function importAgent(tenant, from, agent, to, opts = {}) {
40
+ assertSafeName(from, "workspace");
41
+ assertSafeName(to, "workspace");
42
+ assertSafeName(agent, "agent");
43
+ const name = opts.as?.trim() || agent;
44
+ assertSafeName(name, "agent");
45
+ const known = new Set(listWorkspaces(tenant).map((w) => w.name));
46
+ if (!known.has(from))
47
+ throw new AgentImportError(`no workspace called "${from}" in this account`, 404);
48
+ if (!known.has(to))
49
+ throw new AgentImportError(`no workspace called "${to}" in this account`, 404);
50
+ if (from === to && name === agent) {
51
+ throw new AgentImportError(`${agent} is already in ${to} — give the copy another name with "as"`, 409);
52
+ }
53
+ const files = agentSourceFiles(tenant, from, agent);
54
+ if (!files.includes(`agents/${agent}/agent.md`)) {
55
+ throw new AgentImportError(`no agent called "${agent}" in ${from}`, 404);
56
+ }
57
+ if (fs.existsSync(path.join(workspaceDir(tenant, to), "agents", name))) {
58
+ throw new AgentImportError(`${to} already has an agent called "${name}" — import it under another name with "as"`, 409);
59
+ }
60
+ const written = [];
61
+ const message = `import agents/${agent} from ${from}${name !== agent ? ` as ${name}` : ""}`;
62
+ let front = {};
63
+ for (const src of files) {
64
+ let content = readWorkspaceFile(tenant, from, src);
65
+ const rel = src.slice(`agents/${agent}/`.length);
66
+ if (rel === "agent.md") {
67
+ front = matter(content).data;
68
+ // The folder is the name flows address; a `name:` that disagrees with
69
+ // it is the first thing check would complain about.
70
+ if (name !== agent)
71
+ content = content.replace(/^(---\r?\n(?:[\s\S]*?\r?\n)?)name:[^\n]*$/m, `$1name: ${name}`);
72
+ }
73
+ const dest = `agents/${name}/${rel}`;
74
+ writeWorkspaceFile(tenant, to, dest, content, { ...(opts.by ? { by: opts.by } : {}), message });
75
+ written.push(dest);
76
+ }
77
+ return { name, files: written, warnings: missingFor(tenant, to, name, front) };
78
+ }
79
+ /** What the imported agent names that the target workspace cannot provide. */
80
+ function missingFor(tenant, to, name, front) {
81
+ const out = [];
82
+ const ws = workspaceDir(tenant, to);
83
+ const tools = { ...libraryTools(tenant), ...workspaceTools(tenant, to) };
84
+ const noTool = list(front.tools).filter((t) => !isRuntimeTool(t) && !tools[t]);
85
+ if (noTool.length)
86
+ out.push(`tools not in ${to} or the account library: ${noTool.join(", ")} — import or add them under tools/`);
87
+ const skills = new Set();
88
+ for (const [base, sub] of [
89
+ [path.join(ws, "agents", name), "skills"],
90
+ [ws, "skills"],
91
+ [ws, ".agents/skills"],
92
+ [libraryDir(tenant), "skills"],
93
+ ]) {
94
+ for (const s of discoverSkills(base, sub))
95
+ skills.add(s.name);
96
+ }
97
+ const noSkill = list(front.skills).filter((s) => !skills.has(s));
98
+ if (noSkill.length)
99
+ out.push(`skills not in ${to} or the account library: ${noSkill.join(", ")}`);
100
+ const scripts = list(front.scripts).filter((s) => !fs.existsSync(path.join(ws, "agents", name, "scripts", s)) &&
101
+ !fs.existsSync(path.join(ws, "scripts", s)) &&
102
+ !fs.existsSync(path.join(libraryDir(tenant), "scripts", s)));
103
+ if (scripts.length)
104
+ out.push(`scripts not in ${to}: ${scripts.join(", ")} — copy them into scripts/`);
105
+ let have;
106
+ try {
107
+ have = new Set(listSecrets(tenant, to).map((s) => s.name));
108
+ }
109
+ catch {
110
+ have = new Set();
111
+ }
112
+ const noSecret = list(front.secrets).filter((s) => !have.has(s));
113
+ if (noSecret.length)
114
+ out.push(`secrets not set for ${to}: ${noSecret.join(", ")} — add them with \`foldrun secrets set\` or Settings → Secrets`);
115
+ return out;
116
+ }
@@ -240,7 +240,8 @@ function genericTool(api, env, bucket, log) {
240
240
  `Allowed methods: ${api.methods.join(", ")}. Provide only the path after the base URL. ` +
241
241
  `Authentication is added automatically — never ask the user for credentials.` +
242
242
  typed +
243
- rateSentence(api), {
243
+ rateSentence(api) +
244
+ (api.guide ? `\n\nHow to use it (from its tool.md):\n${api.guide}` : ""), {
244
245
  method: z.enum(api.methods).describe("HTTP method"),
245
246
  path: z.string().describe("Path appended to the base URL, e.g. /customers/123/campaigns"),
246
247
  query: z.record(z.string(), z.string()).optional().describe("Query parameters"),
@@ -291,6 +292,14 @@ resolved) {
291
292
  `so no tool is exposed. Report it as blocked; do not reach this API another way.`);
292
293
  continue;
293
294
  }
295
+ // A base still holding `${FOLDRUN_API_URL}` was not filled: this install
296
+ // has no public address configured (a laptop, usually). Say so rather
297
+ // than hand the model a URL that cannot resolve.
298
+ if (/\$\{[A-Z_]+\}/.test(api.base)) {
299
+ promptLines.push(`- **${api.name}** — unavailable this run: its base \`${api.base}\` names a variable this ` +
300
+ `install does not set (FOLDRUN_API_URL or FOLDRUN_PUBLIC_URL). Report it as blocked.`);
301
+ continue;
302
+ }
294
303
  const typedNames = [];
295
304
  for (const op of ops) {
296
305
  tools.push(typedTool(api, op, env, bucket, log));
@@ -311,7 +320,10 @@ resolved) {
311
320
  promptLines.push(`- **${api.name}** — ${api.description} Base URL \`${api.base}\`. ` +
312
321
  `${ops.length} typed tool${ops.length === 1 ? "" : "s"}` +
313
322
  (withGeneric ? `, plus \`call_${api.name}\` for anything they do not cover` : "") +
314
- `. Credentials are injected for you.${rateLine}`, ...listed, ...more);
323
+ `. Credentials are injected for you.${rateLine}`, ...listed, ...more,
324
+ // Typed-only (an `operations:` allowlist) has no generic tool whose
325
+ // description could carry the guide, so it rides here.
326
+ ...(!withGeneric && api.guide ? [` How to use it (from its tool.md):\n${api.guide}`] : []));
315
327
  }
316
328
  else {
317
329
  promptLines.push(`- **${api.name}** — call with the \`call_${api.name}\` tool. ${api.description} ` +
@@ -135,8 +135,15 @@ export declare function expireStaleGate(tenant: string, workspace: string, run:
135
135
  *
136
136
  * Only steps waiting on an event are released: a run that is also asking a
137
137
  * person a question keeps asking.
138
+ *
139
+ * `by` names who delivered it when that is known — a signed-in person or a
140
+ * key through the dashboard's approve route — and `step` releases one
141
+ * waiting step rather than all of them.
138
142
  */
139
- export declare function deliverEvent(tenant: string, workspace: string, runId: string, payload: string): Promise<{
143
+ export declare function deliverEvent(tenant: string, workspace: string, runId: string, payload: string, opts?: {
144
+ by?: string;
145
+ step?: number;
146
+ }): Promise<{
140
147
  run: RunRecord;
141
148
  steps: number[];
142
149
  }>;
@@ -271,22 +271,27 @@ export async function expireStaleGate(tenant, workspace, run, now = Date.now())
271
271
  *
272
272
  * Only steps waiting on an event are released: a run that is also asking a
273
273
  * person a question keeps asking.
274
+ *
275
+ * `by` names who delivered it when that is known — a signed-in person or a
276
+ * key through the dashboard's approve route — and `step` releases one
277
+ * waiting step rather than all of them.
274
278
  */
275
- export async function deliverEvent(tenant, workspace, runId, payload) {
279
+ export async function deliverEvent(tenant, workspace, runId, payload, opts = {}) {
276
280
  const run = readRun(tenant, workspace, runId);
277
281
  if (!run)
278
282
  throw new ApprovalError("run not found", 404);
279
283
  const waiting = run.steps
280
284
  .map((s, i) => ({ s, i }))
281
- .filter(({ s }) => s.status === "awaiting-approval" && s.waitFor === "event");
282
- if (waiting.length === 0)
283
- throw new ApprovalError("this run is not waiting for an event", 409);
285
+ .filter(({ s, i }) => s.status === "awaiting-approval" && s.waitFor === "event" && (opts.step === undefined || opts.step === i));
286
+ if (waiting.length === 0) {
287
+ throw new ApprovalError(opts.step === undefined ? "this run is not waiting for an event" : `step ${opts.step} is not waiting for an event`, 409);
288
+ }
284
289
  for (const { s } of waiting)
285
290
  s.eventPayload = payload.slice(0, 20_000);
286
291
  writeRun(tenant, workspace, run);
287
292
  let decided = [];
288
293
  for (const { i } of waiting) {
289
- const out = await decideApproval(tenant, workspace, runId, { decision: "approve", step: i, by: "by an external event" });
294
+ const out = await decideApproval(tenant, workspace, runId, { decision: "approve", step: i, by: opts.by ?? "by an external event" });
290
295
  decided = decided.concat(out.steps);
291
296
  }
292
297
  return { run: readRun(tenant, workspace, runId) ?? run, steps: decided };
@@ -30,8 +30,27 @@ export interface ClassifyContext {
30
30
  /** Web actions a paid provider answers this step — from the env's
31
31
  * FOLDRUN_WEB_<ACTION>_VIA. */
32
32
  paidWeb?: Record<string, string>;
33
+ /** Page scripts the agent's web.browse block runs in every page — the
34
+ * env's FOLDRUN_BROWSER_INIT. They make every browse call a write. */
35
+ browseInit?: string;
33
36
  }
34
- /** The browse actions a call asked for, nested `if` branches included. */
37
+ /**
38
+ * The browse actions a call asked for, nested `if` branches included.
39
+ *
40
+ * Read the way the web tool reads them (platform gallery.ts, browse.mjs
41
+ * checkSteps): `actions` is an array of steps, a step's action is one of its
42
+ * OWN keys (the first the tool knows), and the only steps inside a step are
43
+ * an `if`'s `then` and `else`. Every own key of a step is returned — the
44
+ * caller keeps the ones it cares about, and a step holding a write key beside
45
+ * a read key counts as the write. What sits inside an option's value — an
46
+ * extract's `fields` (column names), a mock's `json` body, a webmcp `input` —
47
+ * is data, not an action: `{"fields": {"type": "td.kind"}}` is no typing.
48
+ *
49
+ * A shape the tool would refuse (an object where the array belongs, a step
50
+ * that is not an object, `then` that is not a list) is walked key by key at
51
+ * every depth, as before; nesting past MAX_DEPTH is unreadable, so a write
52
+ * buried deep is never taken for a read.
53
+ */
35
54
  export declare function browseActionNames(actions: unknown): string[];
36
55
  /** What a browse call's arguments would write, or null when it only reads. */
37
56
  export declare function browseWrites(input: Record<string, unknown>): string | null;
@@ -45,8 +45,9 @@ export const BROWSE_WRITE_ACTIONS = [
45
45
  "select", "check", "uncheck", "upload", "dialog", "login", "eval", "webmcp", "solve",
46
46
  ];
47
47
  /** Call arguments of web action=browse that are writes whatever the actions:
48
- * page script, a paid captcha solver, a confirmation of a credential step. */
49
- const BROWSE_WRITE_ARGS = ["js", "captcha", "captcha_solver", "confirm"];
48
+ * page script (js=, or init= scripts run in every page), a paid captcha
49
+ * solver, a confirmation of a credential step. */
50
+ const BROWSE_WRITE_ARGS = ["js", "init", "captcha", "captcha_solver", "confirm"];
50
51
  /** Web actions that only read — when foldrun's own answers them. A paid
51
52
  * provider answering one (FOLDRUN_WEB_<ACTION>_VIA) makes it a write: doing
52
53
  * it twice is paying twice. `monitor` keeps state, so it always writes. */
@@ -57,32 +58,78 @@ const READ_BUILTINS = new Set(["Read", "Glob", "Grep", "WebSearch", "WebFetch",
57
58
  * answer; a delegation's own calls come through on their own. */
58
59
  const READ_SERVERS = new Set(["foldrun_search", "foldrun_history", "foldrun_desks", "foldrun_agents"]);
59
60
  const DELEGATE = new Set(["Agent", "Task"]);
60
- /** The browse actions a call asked for, nested `if` branches included. */
61
+ /**
62
+ * The browse actions a call asked for, nested `if` branches included.
63
+ *
64
+ * Read the way the web tool reads them (platform gallery.ts, browse.mjs
65
+ * checkSteps): `actions` is an array of steps, a step's action is one of its
66
+ * OWN keys (the first the tool knows), and the only steps inside a step are
67
+ * an `if`'s `then` and `else`. Every own key of a step is returned — the
68
+ * caller keeps the ones it cares about, and a step holding a write key beside
69
+ * a read key counts as the write. What sits inside an option's value — an
70
+ * extract's `fields` (column names), a mock's `json` body, a webmcp `input` —
71
+ * is data, not an action: `{"fields": {"type": "td.kind"}}` is no typing.
72
+ *
73
+ * A shape the tool would refuse (an object where the array belongs, a step
74
+ * that is not an object, `then` that is not a list) is walked key by key at
75
+ * every depth, as before; nesting past MAX_DEPTH is unreadable, so a write
76
+ * buried deep is never taken for a read.
77
+ */
61
78
  export function browseActionNames(actions) {
79
+ const UNREADABLE = "(unreadable actions)";
80
+ const MAX_DEPTH = 32;
62
81
  let steps = actions;
63
82
  if (typeof steps === "string") {
64
83
  try {
65
84
  steps = JSON.parse(steps);
66
85
  }
67
86
  catch {
68
- return ["(unreadable actions)"];
87
+ return [UNREADABLE];
69
88
  }
70
89
  }
71
90
  const out = [];
72
- const walk = (v, depth) => {
73
- if (depth > 8 || v === null || typeof v !== "object")
91
+ // Unknown shapes: every key anywhere is a candidate action.
92
+ const walkAny = (v, depth) => {
93
+ if (v === null || typeof v !== "object")
94
+ return;
95
+ if (depth > MAX_DEPTH) {
96
+ out.push(UNREADABLE);
74
97
  return;
98
+ }
75
99
  if (Array.isArray(v)) {
76
100
  for (const x of v)
77
- walk(x, depth + 1);
101
+ walkAny(x, depth + 1);
78
102
  return;
79
103
  }
80
104
  for (const [k, x] of Object.entries(v)) {
81
105
  out.push(k);
82
- walk(x, depth + 1);
106
+ walkAny(x, depth + 1);
107
+ }
108
+ };
109
+ const walkSteps = (v, depth) => {
110
+ if (v === null || v === undefined)
111
+ return;
112
+ if (depth > MAX_DEPTH) {
113
+ out.push(UNREADABLE);
114
+ return;
115
+ }
116
+ if (!Array.isArray(v)) {
117
+ walkAny(v, depth);
118
+ return;
119
+ }
120
+ for (const step of v) {
121
+ if (step === null || typeof step !== "object" || Array.isArray(step)) {
122
+ walkAny(step, depth + 1);
123
+ continue;
124
+ }
125
+ for (const [k, x] of Object.entries(step)) {
126
+ out.push(k);
127
+ if (k === "then" || k === "else")
128
+ walkSteps(x, depth + 1);
129
+ }
83
130
  }
84
131
  };
85
- walk(steps, 0);
132
+ walkSteps(steps, 0);
86
133
  return out;
87
134
  }
88
135
  /** What a browse call's arguments would write, or null when it only reads. */
@@ -135,6 +182,8 @@ export function classifyCall(tool, input, ctx = {}) {
135
182
  const w = browseWrites(args);
136
183
  if (w)
137
184
  return write(w);
185
+ if (ctx.browseInit)
186
+ return write(`browse init scripts (${ctx.browseInit})`);
138
187
  }
139
188
  return { write: false };
140
189
  }
@@ -36,6 +36,7 @@ const TOOL_GROUPS = [
36
36
  { label: "code", hint: "Bash — runs anything in the sandbox" },
37
37
  { label: "web", hint: "search, fetch, browse, crawl, map, extract, answer, monitor — providers under web:" },
38
38
  { label: "ask", hint: "ask_person — ask the person running the desk mid-step, and wait for the answer" },
39
+ { label: "install", hint: "install apt, pip or npm packages mid-step — every agent has it; disallowedTools: [install] removes it" },
39
40
  ];
40
41
  const SDK_TOOL_NAMES = [
41
42
  { label: "Read", hint: "exact SDK name" },
@@ -36,6 +36,7 @@ export declare function isWithin(root: string, abs: string): boolean;
36
36
  export declare function expandVirtual(raw: string, roots: Roots): {
37
37
  abs: string;
38
38
  readOnly: boolean;
39
+ prefix: string;
39
40
  } | null;
40
41
  export declare function checkPaths(toolName: string, input: Record<string, unknown>, roots: Roots): ConfineVerdict;
41
42
  export declare function checkBash(command: string): ConfineVerdict;
@@ -176,11 +176,22 @@ export function expandVirtual(raw, roots) {
176
176
  const candidate = rel !== null && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : raw;
177
177
  for (const v of VIRTUAL) {
178
178
  if (candidate.startsWith(v.prefix)) {
179
- return { abs: path.resolve(v.root(roots), candidate.slice(v.prefix.length)), readOnly: v.readOnly };
179
+ return { abs: path.resolve(v.root(roots), candidate.slice(v.prefix.length)), readOnly: v.readOnly, prefix: v.prefix };
180
180
  }
181
181
  }
182
182
  return null;
183
183
  }
184
+ /** Whether `<agentDir>/workspace` is the step's link to the workspace root
185
+ * (linkWorkspace), so a `workspace/…` path opens the right file as written. */
186
+ function linkedWorkspace(roots) {
187
+ const link = path.join(roots.agentDir, "workspace");
188
+ try {
189
+ return fs.lstatSync(link).isSymbolicLink() && real(link) === real(path.resolve(roots.workspaceRoot));
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ }
184
195
  export function checkPaths(toolName, input, roots) {
185
196
  const workspace = real(path.resolve(roots.workspaceRoot));
186
197
  const library = real(path.resolve(roots.libraryRoot));
@@ -264,9 +275,16 @@ export function checkPaths(toolName, input, roots) {
264
275
  };
265
276
  }
266
277
  // Hand the real path back to the tool, so the virtual prefix the prompt
267
- // advertises is one the agent can actually open.
268
- if (virtual)
278
+ // advertises is one the agent can actually open — unless the step's
279
+ // `workspace` link already opens it. Then the path is left as written,
280
+ // because the SDK remembers what was Read under the path the tool ran
281
+ // with but checks an Edit against the one the model wrote, before this
282
+ // hook sees it: Read workspace/x (rewritten) then Edit workspace/x was
283
+ // refused "File has not been read yet" on every try, and blog-desk's
284
+ // fact-checker spent a pass on it (run-muuhguk3-vhrg, 2026-10-05).
285
+ if (virtual && !(virtual.prefix === "workspace/" && linkedWorkspace(roots))) {
269
286
  rewritten = { ...(rewritten ?? input), [key]: abs };
287
+ }
270
288
  }
271
289
  // Glob searches from `path` (the cwd when absent) and its PATTERN never
272
290
  // climbs: `../../storage/draft/*.md` answered "No files found" with the
@@ -350,6 +368,16 @@ const PROTECTED_BASH = [
350
368
  "instead.",
351
369
  },
352
370
  ];
371
+ /**
372
+ * A web address, which names a place on a server rather than in the workspace.
373
+ *
374
+ * The protected-path patterns match a bare `runs/`, so an agent calling the
375
+ * platform's own API — `curl https://…/api/workspaces/x/runs/<id>` — was told
376
+ * it was reading the run journal. The weekly retros read their week through
377
+ * that API and were refused on 9 Oct 2026. Only http(s): a `file://` URL is a
378
+ * path, and stays checked.
379
+ */
380
+ const WEB_URL = /\bhttps?:\/\/[^\s'"`)]+/gi;
353
381
  export function checkBash(command) {
354
382
  for (const re of BASH_ESCAPES) {
355
383
  if (re.test(command)) {
@@ -366,10 +394,11 @@ export function checkBash(command) {
366
394
  if (!segment.trim())
367
395
  continue;
368
396
  const writes = WRITES_TO_A_FILE.test(segment);
397
+ const paths = segment.replace(WEB_URL, " ");
369
398
  for (const rule of PROTECTED_BASH) {
370
399
  if (rule.writeOnly && !writes)
371
400
  continue;
372
- if (rule.re.test(segment))
401
+ if (rule.re.test(paths))
373
402
  return { ok: false, reason: `Bash was denied: ${rule.reason}` };
374
403
  }
375
404
  }
@@ -15,6 +15,13 @@ export interface ContainerRunOptions {
15
15
  agentDir: string;
16
16
  /** Extra read-only mounts: host path → container path. */
17
17
  readOnly?: Record<string, string>;
18
+ /** Workspace paths a script was handed as `workspace/…`: copied in at
19
+ * `container` before it runs, and what it created or changed under them
20
+ * copied back to `host` after — the host's workspace link, as a copy. */
21
+ staged?: {
22
+ host: string;
23
+ container: string;
24
+ }[];
18
25
  image: string;
19
26
  argv: string[];
20
27
  env: Record<string, string>;
@@ -28,3 +35,5 @@ export declare function runInContainer(opts: ContainerRunOptions): Promise<{
28
35
  code: number | null;
29
36
  out: string;
30
37
  }>;
38
+ /** Write `from` over `to` where the bytes differ; recurse into folders. */
39
+ export declare function syncChanged(from: string, to: string): void;