@foldrun/core 0.6.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 (80) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.js +3 -0
  3. package/dist/src/agent-import.d.ts +21 -0
  4. package/dist/src/agent-import.js +116 -0
  5. package/dist/src/api-tools.js +14 -2
  6. package/dist/src/approvals.d.ts +8 -1
  7. package/dist/src/approvals.js +10 -5
  8. package/dist/src/completions.js +1 -0
  9. package/dist/src/confine.d.ts +1 -0
  10. package/dist/src/confine.js +33 -4
  11. package/dist/src/deploy.d.ts +9 -0
  12. package/dist/src/deploy.js +31 -3
  13. package/dist/src/evals.js +22 -5
  14. package/dist/src/host-env.d.ts +14 -0
  15. package/dist/src/host-env.js +26 -0
  16. package/dist/src/install-helper.d.ts +17 -0
  17. package/dist/src/install-helper.js +134 -0
  18. package/dist/src/install-tool.d.ts +128 -0
  19. package/dist/src/install-tool.js +133 -0
  20. package/dist/src/model-credential.d.ts +16 -0
  21. package/dist/src/model-credential.js +40 -0
  22. package/dist/src/model-env.d.ts +17 -0
  23. package/dist/src/model-env.js +57 -0
  24. package/dist/src/package.d.ts +67 -0
  25. package/dist/src/package.js +594 -0
  26. package/dist/src/platform.d.ts +5 -0
  27. package/dist/src/platform.js +1 -0
  28. package/dist/src/provider-health.js +3 -0
  29. package/dist/src/run-container.d.ts +5 -0
  30. package/dist/src/run-container.js +66 -13
  31. package/dist/src/runner.d.ts +204 -0
  32. package/dist/src/runner.js +257 -36
  33. package/dist/src/runtime.d.ts +77 -0
  34. package/dist/src/runtime.js +230 -5
  35. package/dist/src/script-tools.d.ts +2 -0
  36. package/dist/src/script-tools.js +3 -1
  37. package/dist/src/secrets.d.ts +5 -0
  38. package/dist/src/secrets.js +37 -6
  39. package/dist/src/starter.d.ts +12 -0
  40. package/dist/src/starter.js +36 -4
  41. package/dist/src/step-exec.d.ts +11 -2
  42. package/dist/src/step-exec.js +52 -5
  43. package/dist/src/storage.d.ts +7 -1
  44. package/dist/src/storage.js +14 -1
  45. package/dist/src/store.d.ts +20 -0
  46. package/dist/src/store.js +89 -8
  47. package/dist/src/tool-names.d.ts +3 -1
  48. package/dist/src/tool-names.js +4 -2
  49. package/dist/src/tool-test.js +8 -1
  50. package/dist/src/zip.d.ts +17 -0
  51. package/dist/src/zip.js +184 -0
  52. package/index.ts +3 -0
  53. package/package.json +1 -1
  54. package/src/agent-import.ts +154 -0
  55. package/src/api-tools.ts +16 -1
  56. package/src/approvals.ts +10 -3
  57. package/src/completions.ts +1 -0
  58. package/src/confine.ts +36 -5
  59. package/src/deploy.ts +28 -3
  60. package/src/evals.ts +21 -3
  61. package/src/host-env.ts +27 -0
  62. package/src/install-helper.ts +143 -0
  63. package/src/install-tool.ts +154 -0
  64. package/src/model-credential.ts +41 -0
  65. package/src/model-env.ts +66 -0
  66. package/src/package.ts +664 -0
  67. package/src/platform.ts +6 -0
  68. package/src/provider-health.ts +3 -0
  69. package/src/run-container.ts +65 -12
  70. package/src/runner.ts +264 -36
  71. package/src/runtime.ts +235 -5
  72. package/src/script-tools.ts +5 -1
  73. package/src/secrets.ts +34 -6
  74. package/src/starter.ts +42 -4
  75. package/src/step-exec.ts +56 -8
  76. package/src/storage.ts +13 -0
  77. package/src/store.ts +85 -7
  78. package/src/tool-names.ts +4 -2
  79. package/src/tool-test.ts +8 -1
  80. package/src/zip.ts +189 -0
@@ -0,0 +1,154 @@
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
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import matter from "gray-matter";
19
+ import {
20
+ assertSafeName,
21
+ listWorkspaceFiles,
22
+ readWorkspaceFile,
23
+ writeWorkspaceFile,
24
+ workspaceDir,
25
+ workspaceTools,
26
+ listWorkspaces,
27
+ } from "./store.ts";
28
+ import { libraryDir, libraryTools } from "./library.ts";
29
+ import { isRuntimeTool, toolEntryName } from "./tool-names.ts";
30
+ import { discoverSkills } from "./runner.ts";
31
+ import { listSecrets } from "./secrets.ts";
32
+
33
+ export interface AgentImportResult {
34
+ /** The name the agent has in the target workspace. */
35
+ name: string;
36
+ /** Workspace-relative paths written. */
37
+ files: string[];
38
+ /** What the copy refers to that the target workspace does not have. The
39
+ * import still happens: a missing secret is set in a minute, and refusing
40
+ * would make the person copy the file by hand instead. */
41
+ warnings: string[];
42
+ }
43
+
44
+ /** An error the API layer turns into a status rather than a 500. */
45
+ export class AgentImportError extends Error {
46
+ readonly status: number;
47
+ constructor(message: string, status: number) {
48
+ super(message);
49
+ this.status = status;
50
+ }
51
+ }
52
+
53
+ /** Accumulated or generated, not authored — never carried to another workspace. */
54
+ const NOT_COPIED = /^agents\/[^/]+\/(memory|outputs|state|workspace|\.claude)(\/|$)|^agents\/[^/]+\/\.claude\.json$/;
55
+
56
+ /** The files `importAgent` would copy, without writing anything. */
57
+ export function agentSourceFiles(tenant: string, from: string, agent: string): string[] {
58
+ const prefix = `agents/${agent}/`;
59
+ return listWorkspaceFiles(tenant, from).filter((p) => p.startsWith(prefix) && !NOT_COPIED.test(p));
60
+ }
61
+
62
+ const list = (v: unknown): string[] =>
63
+ Array.isArray(v) ? v.map((x) => toolEntryName(x)).filter(Boolean) : typeof v === "string" && v.trim() ? [v.trim()] : [];
64
+
65
+ export function importAgent(
66
+ tenant: string,
67
+ from: string,
68
+ agent: string,
69
+ to: string,
70
+ opts: { as?: string; by?: string } = {},
71
+ ): AgentImportResult {
72
+ assertSafeName(from, "workspace");
73
+ assertSafeName(to, "workspace");
74
+ assertSafeName(agent, "agent");
75
+ const name = opts.as?.trim() || agent;
76
+ assertSafeName(name, "agent");
77
+ const known = new Set(listWorkspaces(tenant).map((w) => w.name));
78
+ if (!known.has(from)) throw new AgentImportError(`no workspace called "${from}" in this account`, 404);
79
+ if (!known.has(to)) throw new AgentImportError(`no workspace called "${to}" in this account`, 404);
80
+ if (from === to && name === agent) {
81
+ throw new AgentImportError(`${agent} is already in ${to} — give the copy another name with "as"`, 409);
82
+ }
83
+
84
+ const files = agentSourceFiles(tenant, from, agent);
85
+ if (!files.includes(`agents/${agent}/agent.md`)) {
86
+ throw new AgentImportError(`no agent called "${agent}" in ${from}`, 404);
87
+ }
88
+ if (fs.existsSync(path.join(workspaceDir(tenant, to), "agents", name))) {
89
+ throw new AgentImportError(
90
+ `${to} already has an agent called "${name}" — import it under another name with "as"`,
91
+ 409,
92
+ );
93
+ }
94
+
95
+ const written: string[] = [];
96
+ const message = `import agents/${agent} from ${from}${name !== agent ? ` as ${name}` : ""}`;
97
+ let front: Record<string, unknown> = {};
98
+ for (const src of files) {
99
+ let content = readWorkspaceFile(tenant, from, src);
100
+ const rel = src.slice(`agents/${agent}/`.length);
101
+ if (rel === "agent.md") {
102
+ front = matter(content).data as Record<string, unknown>;
103
+ // The folder is the name flows address; a `name:` that disagrees with
104
+ // it is the first thing check would complain about.
105
+ if (name !== agent) content = content.replace(/^(---\r?\n(?:[\s\S]*?\r?\n)?)name:[^\n]*$/m, `$1name: ${name}`);
106
+ }
107
+ const dest = `agents/${name}/${rel}`;
108
+ writeWorkspaceFile(tenant, to, dest, content, { ...(opts.by ? { by: opts.by } : {}), message });
109
+ written.push(dest);
110
+ }
111
+
112
+ return { name, files: written, warnings: missingFor(tenant, to, name, front) };
113
+ }
114
+
115
+ /** What the imported agent names that the target workspace cannot provide. */
116
+ function missingFor(tenant: string, to: string, name: string, front: Record<string, unknown>): string[] {
117
+ const out: string[] = [];
118
+ const ws = workspaceDir(tenant, to);
119
+
120
+ const tools = { ...libraryTools(tenant), ...workspaceTools(tenant, to) };
121
+ const noTool = list(front.tools).filter((t) => !isRuntimeTool(t) && !tools[t]);
122
+ if (noTool.length) out.push(`tools not in ${to} or the account library: ${noTool.join(", ")} — import or add them under tools/`);
123
+
124
+ const skills = new Set<string>();
125
+ for (const [base, sub] of [
126
+ [path.join(ws, "agents", name), "skills"],
127
+ [ws, "skills"],
128
+ [ws, ".agents/skills"],
129
+ [libraryDir(tenant), "skills"],
130
+ ] as const) {
131
+ for (const s of discoverSkills(base, sub)) skills.add(s.name);
132
+ }
133
+ const noSkill = list(front.skills).filter((s) => !skills.has(s));
134
+ if (noSkill.length) out.push(`skills not in ${to} or the account library: ${noSkill.join(", ")}`);
135
+
136
+ const scripts = list(front.scripts).filter(
137
+ (s) =>
138
+ !fs.existsSync(path.join(ws, "agents", name, "scripts", s)) &&
139
+ !fs.existsSync(path.join(ws, "scripts", s)) &&
140
+ !fs.existsSync(path.join(libraryDir(tenant), "scripts", s)),
141
+ );
142
+ if (scripts.length) out.push(`scripts not in ${to}: ${scripts.join(", ")} — copy them into scripts/`);
143
+
144
+ let have: Set<string>;
145
+ try {
146
+ have = new Set(listSecrets(tenant, to).map((s) => s.name));
147
+ } catch {
148
+ have = new Set();
149
+ }
150
+ const noSecret = list(front.secrets).filter((s) => !have.has(s));
151
+ if (noSecret.length) out.push(`secrets not set for ${to}: ${noSecret.join(", ")} — add them with \`foldrun secrets set\` or Settings → Secrets`);
152
+
153
+ return out;
154
+ }
package/src/api-tools.ts CHANGED
@@ -299,7 +299,8 @@ function genericTool(api: ApiSpec, env: Record<string, string>, bucket: TokenBuc
299
299
  `Allowed methods: ${api.methods.join(", ")}. Provide only the path after the base URL. ` +
300
300
  `Authentication is added automatically — never ask the user for credentials.` +
301
301
  typed +
302
- rateSentence(api),
302
+ rateSentence(api) +
303
+ (api.guide ? `\n\nHow to use it (from its tool.md):\n${api.guide}` : ""),
303
304
  {
304
305
  method: z.enum(api.methods as [string, ...string[]]).describe("HTTP method"),
305
306
  path: z.string().describe("Path appended to the base URL, e.g. /customers/123/campaigns"),
@@ -369,6 +370,17 @@ export function buildApiTools(
369
370
  continue;
370
371
  }
371
372
 
373
+ // A base still holding `${FOLDRUN_API_URL}` was not filled: this install
374
+ // has no public address configured (a laptop, usually). Say so rather
375
+ // than hand the model a URL that cannot resolve.
376
+ if (/\$\{[A-Z_]+\}/.test(api.base)) {
377
+ promptLines.push(
378
+ `- **${api.name}** — unavailable this run: its base \`${api.base}\` names a variable this ` +
379
+ `install does not set (FOLDRUN_API_URL or FOLDRUN_PUBLIC_URL). Report it as blocked.`,
380
+ );
381
+ continue;
382
+ }
383
+
372
384
  const typedNames: string[] = [];
373
385
  for (const op of ops) {
374
386
  tools.push(typedTool(api, op, env, bucket, log));
@@ -394,6 +406,9 @@ export function buildApiTools(
394
406
  `. Credentials are injected for you.${rateLine}`,
395
407
  ...listed,
396
408
  ...more,
409
+ // Typed-only (an `operations:` allowlist) has no generic tool whose
410
+ // description could carry the guide, so it rides here.
411
+ ...(!withGeneric && api.guide ? [` How to use it (from its tool.md):\n${api.guide}`] : []),
397
412
  );
398
413
  } else {
399
414
  promptLines.push(
package/src/approvals.ts CHANGED
@@ -356,24 +356,31 @@ export async function expireStaleGate(
356
356
  *
357
357
  * Only steps waiting on an event are released: a run that is also asking a
358
358
  * person a question keeps asking.
359
+ *
360
+ * `by` names who delivered it when that is known — a signed-in person or a
361
+ * key through the dashboard's approve route — and `step` releases one
362
+ * waiting step rather than all of them.
359
363
  */
360
364
  export async function deliverEvent(
361
365
  tenant: string,
362
366
  workspace: string,
363
367
  runId: string,
364
368
  payload: string,
369
+ opts: { by?: string; step?: number } = {},
365
370
  ): Promise<{ run: RunRecord; steps: number[] }> {
366
371
  const run = readRun(tenant, workspace, runId);
367
372
  if (!run) throw new ApprovalError("run not found", 404);
368
373
  const waiting = run.steps
369
374
  .map((s, i) => ({ s, i }))
370
- .filter(({ s }) => s.status === "awaiting-approval" && s.waitFor === "event");
371
- if (waiting.length === 0) throw new ApprovalError("this run is not waiting for an event", 409);
375
+ .filter(({ s, i }) => s.status === "awaiting-approval" && s.waitFor === "event" && (opts.step === undefined || opts.step === i));
376
+ if (waiting.length === 0) {
377
+ throw new ApprovalError(opts.step === undefined ? "this run is not waiting for an event" : `step ${opts.step} is not waiting for an event`, 409);
378
+ }
372
379
  for (const { s } of waiting) s.eventPayload = payload.slice(0, 20_000);
373
380
  writeRun(tenant, workspace, run);
374
381
  let decided: number[] = [];
375
382
  for (const { i } of waiting) {
376
- const out = await decideApproval(tenant, workspace, runId, { decision: "approve", step: i, by: "by an external event" });
383
+ const out = await decideApproval(tenant, workspace, runId, { decision: "approve", step: i, by: opts.by ?? "by an external event" });
377
384
  decided = decided.concat(out.steps);
378
385
  }
379
386
  return { run: readRun(tenant, workspace, runId) ?? run, steps: decided };
@@ -76,6 +76,7 @@ const TOOL_GROUPS: Completion[] = [
76
76
  { label: "code", hint: "Bash — runs anything in the sandbox" },
77
77
  { label: "web", hint: "search, fetch, browse, crawl, map, extract, answer, monitor — providers under web:" },
78
78
  { label: "ask", hint: "ask_person — ask the person running the desk mid-step, and wait for the answer" },
79
+ { label: "install", hint: "install apt, pip or npm packages mid-step — every agent has it; disallowedTools: [install] removes it" },
79
80
  ];
80
81
 
81
82
  const SDK_TOOL_NAMES: Completion[] = [
package/src/confine.ts CHANGED
@@ -193,7 +193,7 @@ export function isWithin(root: string, abs: string): boolean {
193
193
  }
194
194
 
195
195
  /** Expand a virtual prefix to a real absolute path, or null if it has none. */
196
- export function expandVirtual(raw: string, roots: Roots): { abs: string; readOnly: boolean } | null {
196
+ export function expandVirtual(raw: string, roots: Roots): { abs: string; readOnly: boolean; prefix: string } | null {
197
197
  // The SDK resolves a relative path against the cwd BEFORE canUseTool sees
198
198
  // it, so `workspace/storage/x.json` arrives as
199
199
  // `<agentDir>/workspace/storage/x.json` and never matched a prefix. The
@@ -205,12 +205,23 @@ export function expandVirtual(raw: string, roots: Roots): { abs: string; readOnl
205
205
  const candidate = rel !== null && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : raw;
206
206
  for (const v of VIRTUAL) {
207
207
  if (candidate.startsWith(v.prefix)) {
208
- return { abs: path.resolve(v.root(roots), candidate.slice(v.prefix.length)), readOnly: v.readOnly };
208
+ return { abs: path.resolve(v.root(roots), candidate.slice(v.prefix.length)), readOnly: v.readOnly, prefix: v.prefix };
209
209
  }
210
210
  }
211
211
  return null;
212
212
  }
213
213
 
214
+ /** Whether `<agentDir>/workspace` is the step's link to the workspace root
215
+ * (linkWorkspace), so a `workspace/…` path opens the right file as written. */
216
+ function linkedWorkspace(roots: Roots): boolean {
217
+ const link = path.join(roots.agentDir, "workspace");
218
+ try {
219
+ return fs.lstatSync(link).isSymbolicLink() && real(link) === real(path.resolve(roots.workspaceRoot));
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+
214
225
  export function checkPaths(
215
226
  toolName: string,
216
227
  input: Record<string, unknown>,
@@ -312,8 +323,16 @@ export function checkPaths(
312
323
  }
313
324
 
314
325
  // Hand the real path back to the tool, so the virtual prefix the prompt
315
- // advertises is one the agent can actually open.
316
- if (virtual) rewritten = { ...(rewritten ?? input), [key]: abs };
326
+ // advertises is one the agent can actually open — unless the step's
327
+ // `workspace` link already opens it. Then the path is left as written,
328
+ // because the SDK remembers what was Read under the path the tool ran
329
+ // with but checks an Edit against the one the model wrote, before this
330
+ // hook sees it: Read workspace/x (rewritten) then Edit workspace/x was
331
+ // refused "File has not been read yet" on every try, and blog-desk's
332
+ // fact-checker spent a pass on it (run-muuhguk3-vhrg, 2026-10-05).
333
+ if (virtual && !(virtual.prefix === "workspace/" && linkedWorkspace(roots))) {
334
+ rewritten = { ...(rewritten ?? input), [key]: abs };
335
+ }
317
336
  }
318
337
 
319
338
  // Glob searches from `path` (the cwd when absent) and its PATTERN never
@@ -407,6 +426,17 @@ const PROTECTED_BASH: { re: RegExp; writeOnly: boolean; reason: string }[] = [
407
426
  },
408
427
  ];
409
428
 
429
+ /**
430
+ * A web address, which names a place on a server rather than in the workspace.
431
+ *
432
+ * The protected-path patterns match a bare `runs/`, so an agent calling the
433
+ * platform's own API — `curl https://…/api/workspaces/x/runs/<id>` — was told
434
+ * it was reading the run journal. The weekly retros read their week through
435
+ * that API and were refused on 9 Oct 2026. Only http(s): a `file://` URL is a
436
+ * path, and stays checked.
437
+ */
438
+ const WEB_URL = /\bhttps?:\/\/[^\s'"`)]+/gi;
439
+
410
440
  export function checkBash(command: string): ConfineVerdict {
411
441
  for (const re of BASH_ESCAPES) {
412
442
  if (re.test(command)) {
@@ -424,9 +454,10 @@ export function checkBash(command: string): ConfineVerdict {
424
454
  for (const segment of command.split(/[;|&]+|\n/)) {
425
455
  if (!segment.trim()) continue;
426
456
  const writes = WRITES_TO_A_FILE.test(segment);
457
+ const paths = segment.replace(WEB_URL, " ");
427
458
  for (const rule of PROTECTED_BASH) {
428
459
  if (rule.writeOnly && !writes) continue;
429
- if (rule.re.test(segment)) return { ok: false, reason: `Bash was denied: ${rule.reason}` };
460
+ if (rule.re.test(paths)) return { ok: false, reason: `Bash was denied: ${rule.reason}` };
430
461
  }
431
462
  }
432
463
  return { ok: true };
package/src/deploy.ts CHANGED
@@ -208,6 +208,25 @@ export function readTree(dir: string): DeployFile[] {
208
208
  * only the deployed files, so a library or gallery outward tool is not seen
209
209
  * here — `foldrun check`, which resolves every scope, remains the real gate.
210
210
  * Non-blocking by decision: the deploy proceeds and says so. */
211
+ /**
212
+ * A frontmatter block that lost its opening `---`: `key: value` lines, then a
213
+ * lone `---`, with nothing above them. Nothing is read from it — the parser
214
+ * sees prose — so the agent runs with no tools and the default model, and a
215
+ * model told to call a tool it was never given writes the call out as text.
216
+ * seo-digest's editor did that every Sunday from 20 Sep to 5 Oct 2026.
217
+ * Returns the problem, or null.
218
+ */
219
+ export function unopenedFrontmatter(raw: string): string | null {
220
+ const text = raw.replace(/^\uFEFF/, "");
221
+ if (/^---\r?\n/.test(text)) return null;
222
+ const lines = text.split(/\r?\n/);
223
+ const close = lines.findIndex((l) => l.trim() === "---");
224
+ if (close < 1) return null;
225
+ const above = lines.slice(0, close).filter((l) => l.trim() && !l.trim().startsWith("#"));
226
+ if (!above.length || !above.every((l) => /^[A-Za-z_][\w-]*:(\s|$)/.test(l) || /^\s+\S/.test(l))) return null;
227
+ return "the frontmatter has no opening `---`, so none of it is read: the agent runs with no tools and the default model. Add `---` as the first line.";
228
+ }
229
+
211
230
  export function deployWarnings(files: DeployFile[]): DeployIssue[] {
212
231
  const front = (content: string): Record<string, unknown> => {
213
232
  try { return matter(content).data as Record<string, unknown>; } catch { return {}; }
@@ -227,6 +246,9 @@ export function deployWarnings(files: DeployFile[]): DeployIssue[] {
227
246
  const outwardAgents: string[] = [];
228
247
  const agentNames: string[] = [];
229
248
  const out: DeployIssue[] = [];
249
+ if (!files.some((f) => /^agents\/[^/]+\/agent\.md$/.test(f.path)) && !files.some((f) => /^\.(claude|agents)\/agents\/[^/]+\.md$/.test(f.path))) {
250
+ out.push({ where: "agents/", message: "no agents yet — add one (agents/<name>/agent.md), or import one from another workspace" });
251
+ }
230
252
  // The web: warnings check prints — the old session: key, and a pinned
231
253
  // user agent behind the runner image's Chrome, which the platform knows
232
254
  // from FOLDRUN_RUNNER_ENGINES (the deploy read it from the image).
@@ -284,6 +306,8 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
284
306
  // written, not discovered as a date that is a day out.
285
307
  for (const f of files) {
286
308
  if (!/^(AGENTS\.md|agents\/[^/]+\/agent\.md)$/.test(f.path)) continue;
309
+ const unopened = unopenedFrontmatter(f.content);
310
+ if (unopened) at(f.path, unopened);
287
311
  let front: Record<string, unknown> = {};
288
312
  try {
289
313
  front = matter(f.content).data as Record<string, unknown>;
@@ -306,9 +330,10 @@ export function deployIssues(files: DeployFile[]): DeployIssue[] {
306
330
  if (lang) at(f.path, lang);
307
331
  for (const w of localeProblems(front)) at(f.path, w);
308
332
  }
309
- if (agents.size === 0) {
310
- at("agents/", "no agents — a workspace needs at least one agents/<name>/agent.md");
311
- }
333
+ // No agents is a workspace that has not started yet — "Create workspace"
334
+ // makes exactly that — and deploying it is harmless: a flow that names an
335
+ // agent this push lacks is refused below, step by step. deployWarnings says
336
+ // it, so the empty state is visible without blocking the first push.
312
337
 
313
338
  // Flows, parsed from the incoming text rather than from disk. A step naming
314
339
  // an agent that this push does not ship is the single most common way a
package/src/evals.ts CHANGED
@@ -31,6 +31,7 @@
31
31
  // they pass — no point paying a model to grade output already known to be
32
32
  // wrong.
33
33
 
34
+ import { accountModelEnv } from "./model-env.ts";
34
35
  import { sdkEnv } from "./step-exec.ts";
35
36
  import fs from "node:fs";
36
37
  import path from "node:path";
@@ -477,6 +478,7 @@ async function judge(
477
478
  output: string,
478
479
  model: string,
479
480
  effort: Effort | null,
481
+ env: Record<string, string | undefined>,
480
482
  ): Promise<AssertionResult["detail"] & string> {
481
483
  const prompt =
482
484
  `You are grading one piece of output against one criterion. Answer with exactly ` +
@@ -493,7 +495,7 @@ async function judge(
493
495
  allowedTools: [],
494
496
  settingSources: [],
495
497
  strictMcpConfig: true,
496
- env: sdkEnv(undefined),
498
+ env,
497
499
  maxTurns: 1,
498
500
  },
499
501
  });
@@ -513,6 +515,7 @@ async function checkAssertion(
513
515
  agentDir: string,
514
516
  model: string,
515
517
  effort: Effort | null,
518
+ judgeEnv: () => Record<string, string | undefined> = () => sdkEnv(undefined),
516
519
  ): Promise<AssertionResult> {
517
520
  const hay = output.toLowerCase();
518
521
  switch (assertion.type) {
@@ -545,7 +548,15 @@ async function checkAssertion(
545
548
  return { assertion, passed: code === 0, detail: `exit ${code ?? "error"}${out ? ` — ${out.slice(0, 200)}` : ""}` };
546
549
  }
547
550
  case "judge": {
548
- const verdict = await judge(assertion.value, output, model, effort);
551
+ // The judge runs on the account's own model key, never quietly on the
552
+ // platform's: a customer's eval is not the operator's bill.
553
+ let env: Record<string, string | undefined>;
554
+ try {
555
+ env = judgeEnv();
556
+ } catch (err) {
557
+ return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
558
+ }
559
+ const verdict = await judge(assertion.value, output, model, effort, env);
549
560
  const passed = /^\s*PASS\b/i.test(verdict);
550
561
  return { assertion, passed, detail: verdict.slice(0, 300) || "judge returned nothing" };
551
562
  }
@@ -628,7 +639,14 @@ export async function runEval(
628
639
  const judges = testCase.expect.filter((a) => a.type === "judge");
629
640
  for (const a of cheap) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
630
641
  if (assertions.every((r) => r.passed)) {
631
- for (const a of judges) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort));
642
+ const judgeEnv = () => {
643
+ const m = accountModelEnv(tenant, workspace, () => sdkEnv(undefined));
644
+ if (m.format !== "anthropic") {
645
+ throw new Error(`this account's provider speaks ${m.format}; a judge needs an Anthropic-format provider`);
646
+ }
647
+ return m.env;
648
+ };
649
+ for (const a of judges) assertions.push(await checkAssertion(a, output, agentDir, info.model, info.effort, judgeEnv));
632
650
  } else {
633
651
  for (const a of judges) {
634
652
  assertions.push({ assertion: a, passed: false, detail: "not run — an earlier check failed" });
package/src/host-env.ts CHANGED
@@ -54,3 +54,30 @@ export function hostSafeEnv(from: NodeJS.ProcessEnv = process.env): NodeJS.Proce
54
54
  // every spawn. The value is the same either way.
55
55
  return out as NodeJS.ProcessEnv;
56
56
  }
57
+
58
+ /**
59
+ * Where this install's own API answers, for a step to call: `FOLDRUN_API_URL`
60
+ * when the operator set it, else `FOLDRUN_PUBLIC_URL` + `/api`. Undefined on a
61
+ * laptop with neither.
62
+ *
63
+ * A desk that reads its own run history used to write the host into its
64
+ * files (`https://dev.foldrun.io/api`), so the same workspace pointed at the
65
+ * wrong server the day it moved. Now a script reads `$FOLDRUN_API_URL` and an
66
+ * `apis:` entry writes `base: ${FOLDRUN_API_URL}`.
67
+ */
68
+ export function platformApiUrl(from: NodeJS.ProcessEnv = process.env): string | undefined {
69
+ const explicit = from.FOLDRUN_API_URL?.trim();
70
+ if (explicit) return explicit.replace(/\/+$/, "");
71
+ const origin = from.FOLDRUN_PUBLIC_URL?.trim();
72
+ return origin ? `${origin.replace(/\/+$/, "")}/api` : undefined;
73
+ }
74
+
75
+ /** The install's own addresses an `apis:` base may name. Not secrets: they
76
+ * are printed in the prompt as the tool's base URL. */
77
+ export function expandPlatformVars(value: string, from: NodeJS.ProcessEnv = process.env): string {
78
+ const vars: Record<string, string | undefined> = {
79
+ FOLDRUN_API_URL: platformApiUrl(from),
80
+ FOLDRUN_PUBLIC_URL: from.FOLDRUN_PUBLIC_URL?.trim().replace(/\/+$/, "") || undefined,
81
+ };
82
+ return value.replace(/\$\{(FOLDRUN_API_URL|FOLDRUN_PUBLIC_URL)\}/g, (whole, name: string) => vars[name] ?? whole);
83
+ }
@@ -0,0 +1,143 @@
1
+ // The root half of the `install` tool: a tiny server the run container's root
2
+ // start-up leaves running before it drops to the agent user.
3
+ //
4
+ // node install-helper.js <socket> <runtime cache dir>
5
+ //
6
+ // The agent never becomes root. It can only ask this process, over a unix
7
+ // socket, to install Debian packages BY NAME — each name re-checked against
8
+ // Debian's naming rule here, so nothing but a package name reaches apt, and
9
+ // apt is driven by the same snippet the start-up uses (systemInstallSh): the
10
+ // account's .deb cache and snapshots, so a package anyone installed before is
11
+ // restored in about a second. Requests are served one at a time; the process
12
+ // dies with the container.
13
+ //
14
+ // pip and npm need no root and are installed by the tool itself, as the agent.
15
+
16
+ import fs from "node:fs";
17
+ import net from "node:net";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import { spawn } from "node:child_process";
21
+ import { DEB_PACKAGE, systemInstallSh, summarizeInstallFailure } from "./runtime.ts";
22
+
23
+ export interface InstallRequest {
24
+ manager: "apt";
25
+ packages: string[];
26
+ }
27
+
28
+ export interface InstallReply {
29
+ ok: boolean;
30
+ /** "snapshot" | "apt" */
31
+ mode?: string;
32
+ seconds?: number;
33
+ error?: string;
34
+ }
35
+
36
+ /** Validate one request; null when it may run. Exported for the tests. */
37
+ export function refuse(req: unknown): string | null {
38
+ const r = req as Partial<InstallRequest> | null;
39
+ if (!r || typeof r !== "object") return "not a request";
40
+ if (r.manager !== "apt") return "this helper installs Debian packages (manager: apt) only";
41
+ if (!Array.isArray(r.packages) || r.packages.length === 0) return "no packages named";
42
+ if (r.packages.length > 50) return "at most 50 packages per request";
43
+ const bad = r.packages.filter((p) => typeof p !== "string" || !DEB_PACKAGE.test(p));
44
+ if (bad.length) return `not Debian package names: ${bad.map(String).join(", ")}`;
45
+ return null;
46
+ }
47
+
48
+ /** Install one validated request with the start-up's own snippet. */
49
+ export function installApt(packages: string[], cacheDir: string): Promise<InstallReply> {
50
+ const logDir = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-install-"));
51
+ return new Promise((resolve) => {
52
+ const child = spawn("sh", ["-c", systemInstallSh(cacheDir, logDir)], {
53
+ env: { ...process.env, FOLDRUN_SYSTEM_PACKAGES: packages.join(" ") },
54
+ stdio: "ignore",
55
+ });
56
+ child.on("close", () => {
57
+ let status: { ok?: boolean; mode?: string; seconds?: number } = {};
58
+ try {
59
+ status = JSON.parse(fs.readFileSync(path.join(logDir, "system-install.json"), "utf8"));
60
+ } catch {
61
+ status = { ok: false };
62
+ }
63
+ let log = "";
64
+ try {
65
+ log = fs.readFileSync(path.join(logDir, "system-install.log"), "utf8");
66
+ } catch {
67
+ log = "";
68
+ }
69
+ fs.rmSync(logDir, { recursive: true, force: true });
70
+ resolve(
71
+ status.ok
72
+ ? { ok: true, mode: status.mode, seconds: status.seconds }
73
+ : { ok: false, error: summarizeInstallFailure("apt-get install", log || "no output") },
74
+ );
75
+ });
76
+ child.on("error", (err) => {
77
+ fs.rmSync(logDir, { recursive: true, force: true });
78
+ resolve({ ok: false, error: `could not run the installer: ${err.message}` });
79
+ });
80
+ });
81
+ }
82
+
83
+ export function serve(socket: string, cacheDir: string): net.Server {
84
+ try {
85
+ fs.rmSync(socket, { force: true });
86
+ } catch {
87
+ /* nothing there */
88
+ }
89
+ let queue: Promise<unknown> = Promise.resolve();
90
+ const server = net.createServer((conn) => {
91
+ let buf = "";
92
+ conn.on("data", (chunk) => {
93
+ buf += chunk.toString("utf8");
94
+ if (buf.length > 64_000) {
95
+ conn.end(JSON.stringify({ ok: false, error: "request too large" }) + "\n");
96
+ return;
97
+ }
98
+ const nl = buf.indexOf("\n");
99
+ if (nl < 0) return;
100
+ const line = buf.slice(0, nl);
101
+ buf = "";
102
+ let req: unknown;
103
+ try {
104
+ req = JSON.parse(line);
105
+ } catch {
106
+ conn.end(JSON.stringify({ ok: false, error: "not JSON" }) + "\n");
107
+ return;
108
+ }
109
+ const why = refuse(req);
110
+ if (why) {
111
+ conn.end(JSON.stringify({ ok: false, error: why }) + "\n");
112
+ return;
113
+ }
114
+ // One install at a time: apt holds a lock, and two dpkg runs in one
115
+ // container would fight over it.
116
+ queue = queue.then(async () => {
117
+ const reply = await installApt((req as InstallRequest).packages, cacheDir);
118
+ conn.end(JSON.stringify(reply) + "\n");
119
+ });
120
+ });
121
+ conn.on("error", () => {
122
+ /* the client went away */
123
+ });
124
+ });
125
+ server.listen(socket, () => {
126
+ // The agent user must be able to connect; nothing else runs in here.
127
+ try {
128
+ fs.chmodSync(socket, 0o666);
129
+ } catch {
130
+ /* best effort */
131
+ }
132
+ });
133
+ return server;
134
+ }
135
+
136
+ if (process.argv[1] && /install-helper\.(js|ts|mjs)$/.test(process.argv[1])) {
137
+ const [socket, cacheDir] = process.argv.slice(2);
138
+ if (!socket || !cacheDir) {
139
+ console.error("usage: install-helper <socket> <runtime cache dir>");
140
+ process.exit(2);
141
+ }
142
+ serve(socket, cacheDir);
143
+ }