@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
@@ -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`.
@@ -247,21 +249,27 @@ export function snapshotDivertedDirs(wsRoot) {
247
249
  }
248
250
  /** Undo a step's writes to the diverted directories, keeping each changed
249
251
  * or new file under `to` by its workspace-relative path. Deletions are
250
- * undone too — the original is put back. Returns what was set aside. */
251
- export function restoreDivertedDirs(wsRoot, before, to, note) {
252
+ * undone too — the original is put back. `handed` is what the step was
253
+ * given when that was more than the workspace (the run's earlier test
254
+ * writes, laid over it): only what the step changed from THAT is its
255
+ * write. Returns what was set aside. */
256
+ export function restoreDivertedDirs(wsRoot, before, to, note, handed = before) {
252
257
  const now = snapshotDivertedDirs(wsRoot);
253
258
  const aside = [];
254
259
  for (const [rel, next] of now) {
255
- const was = before.get(rel) ?? null;
256
- if (was && was.equals(next))
257
- continue;
258
- const dest = path.join(to, rel);
259
- fs.mkdirSync(path.dirname(dest), { recursive: true });
260
- fs.writeFileSync(dest, next);
261
- aside.push(rel);
262
- note(rel, divertedSummary(rel, was, next));
263
- if (was)
264
- fs.writeFileSync(path.join(wsRoot, rel), was);
260
+ const given = handed.get(rel) ?? null;
261
+ if (!(given && given.equals(next))) {
262
+ const dest = path.join(to, rel);
263
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
264
+ fs.writeFileSync(dest, next);
265
+ aside.push(rel);
266
+ note(rel, divertedSummary(rel, before.get(rel) ?? null, next));
267
+ }
268
+ const orig = before.get(rel);
269
+ if (orig) {
270
+ if (!orig.equals(next))
271
+ fs.writeFileSync(path.join(wsRoot, rel), orig);
272
+ }
265
273
  else
266
274
  fs.rmSync(path.join(wsRoot, rel), { force: true });
267
275
  }
@@ -275,6 +283,38 @@ export function restoreDivertedDirs(wsRoot, before, to, note) {
275
283
  }
276
284
  return aside;
277
285
  }
286
+ /**
287
+ * A test run's later steps see what its earlier steps wrote. Those writes
288
+ * are kept under runs/<id>/test-writes/, never on the workspace, so each
289
+ * step's copy of the workspace gets them laid over it — otherwise a step
290
+ * reads the real files, and a flow that hands work on through storage/
291
+ * tests something no live run does: on 2026-10-03 a strata-desk test run's
292
+ * compiler wrote today's PASS report, and the mailer was handed the
293
+ * morning's FAIL one and refused. Times are kept, so a verify that asks
294
+ * whether a file is fresh answers for the step that wrote it. Returns the
295
+ * workspace-relative paths laid over.
296
+ */
297
+ export function overlayTestWrites(wsCopy, from) {
298
+ const laid = [];
299
+ for (const dir of TEST_DIVERTED_DIRS) {
300
+ const root = path.join(from, dir);
301
+ if (!fs.existsSync(root))
302
+ continue;
303
+ for (const entry of fs.readdirSync(root, { recursive: true, withFileTypes: true })) {
304
+ if (!entry.isFile())
305
+ continue;
306
+ const abs = path.join(entry.parentPath, entry.name);
307
+ const rel = path.relative(from, abs).replaceAll("\\", "/");
308
+ const dest = path.join(wsCopy, rel);
309
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
310
+ fs.copyFileSync(abs, dest);
311
+ const st = fs.statSync(abs);
312
+ fs.utimesSync(dest, st.atime, st.mtime);
313
+ laid.push(rel);
314
+ }
315
+ }
316
+ return laid;
317
+ }
278
318
  /** `[test] ` in front of a headline, once. */
279
319
  export function testHeadline(summary) {
280
320
  return summary.startsWith("[test]") ? summary : `[test] ${summary}`;
@@ -12,7 +12,9 @@ export declare const RETIRED_TOOLS: Record<string, string>;
12
12
  * and a model provider's own search (`web: {search: zai}`) is the runtime's
13
13
  * to swap in, not the author's to name. */
14
14
  export declare const BUILTIN_TOOLS: Set<string>;
15
- /** The platform's own groups, served in-process rather than by the SDK. */
15
+ /** The platform's own groups, served in-process rather than by the SDK.
16
+ * `install` is granted to every agent anyway (install-tool.ts); naming it is
17
+ * accepted so a file that says so is not flagged. */
16
18
  export declare const PLATFORM_GROUPS: Set<string>;
17
19
  /** Would this name resolve to something the runtime provides? Built-ins win a
18
20
  * clash, so a tool file of the same name is shadowed rather than granted. */
@@ -44,8 +44,10 @@ export const BUILTIN_TOOLS = new Set([
44
44
  "Read", "Write", "Edit", "MultiEdit", "Glob", "Grep", "Bash",
45
45
  "NotebookEdit", "TodoWrite",
46
46
  ]);
47
- /** The platform's own groups, served in-process rather than by the SDK. */
48
- export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask"]);
47
+ /** The platform's own groups, served in-process rather than by the SDK.
48
+ * `install` is granted to every agent anyway (install-tool.ts); naming it is
49
+ * accepted so a file that says so is not flagged. */
50
+ export const PLATFORM_GROUPS = new Set(["search", "history", "desks", "ask", "install"]);
49
51
  /** Would this name resolve to something the runtime provides? Built-ins win a
50
52
  * clash, so a tool file of the same name is shadowed rather than granted. */
51
53
  export function isRuntimeTool(name) {
@@ -229,7 +229,14 @@ export async function testTool(tenant, workspace, def, input = {}) {
229
229
  detail: `looked for ${file}\n\nThis is the commonest way to get a tool that silently never loads.`,
230
230
  });
231
231
  }
232
- const names = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
232
+ // The tool's own secrets, and the calling agent's `secrets:` — a run
233
+ // hands a script both. Testing gbp_read (whose agent, not the tool,
234
+ // declares GBP_OAUTH) said "GBP_OAUTH is not set" while every run of the
235
+ // same call worked (9 Oct 2026). Only an agent that grants the tool: the
236
+ // agents[0] stand-in above is there for its depth, not its keys.
237
+ const own = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
238
+ const granting = caller && caller.ownTools.includes(def.name) ? caller.secrets : [];
239
+ const names = [...new Set([...own, ...granting])];
233
240
  const { env: stored, missing } = resolveSecrets(tenant, names, workspace);
234
241
  const live = await liveSecrets(stored, { tenant, workspace });
235
242
  if ("error" in live) {
@@ -0,0 +1,17 @@
1
+ export interface ZipEntry {
2
+ path: string;
3
+ data: Buffer;
4
+ }
5
+ export declare class ZipError extends Error {
6
+ }
7
+ export interface UnzipLimits {
8
+ /** Most entries the archive may hold. */
9
+ maxEntries?: number;
10
+ /** Largest one file, uncompressed. */
11
+ maxFileBytes?: number;
12
+ /** All files together, uncompressed. */
13
+ maxTotalBytes?: number;
14
+ }
15
+ export declare function crc32(buf: Buffer): number;
16
+ export declare function zip(entries: ZipEntry[], now?: Date): Buffer;
17
+ export declare function unzip(buf: Buffer, limits?: UnzipLimits): ZipEntry[];
@@ -0,0 +1,184 @@
1
+ // A small zip reader and writer — enough for an export package, no dependency.
2
+ //
3
+ // What a person downloads from "Export" has to open with a double-click on
4
+ // any machine, which is a zip, not a tarball. The format here is the plain
5
+ // one: deflate or stored entries, a central directory, no zip64, no
6
+ // encryption. The reader refuses anything outside that rather than guessing,
7
+ // and it is written to be handed untrusted bytes: every offset is bounds
8
+ // checked, sizes are capped before anything is inflated, and a path that
9
+ // climbs out (`..`, absolute, a drive letter) is an error, not a skip.
10
+ import zlib from "node:zlib";
11
+ export class ZipError extends Error {
12
+ }
13
+ const CRC_TABLE = (() => {
14
+ const t = new Uint32Array(256);
15
+ for (let n = 0; n < 256; n++) {
16
+ let c = n;
17
+ for (let k = 0; k < 8; k++)
18
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
19
+ t[n] = c >>> 0;
20
+ }
21
+ return t;
22
+ })();
23
+ export function crc32(buf) {
24
+ let c = 0xffffffff;
25
+ for (let i = 0; i < buf.length; i++)
26
+ c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
27
+ return (c ^ 0xffffffff) >>> 0;
28
+ }
29
+ /** DOS date and time for the entry headers. */
30
+ function dosTime(d) {
31
+ return {
32
+ time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
33
+ date: (Math.max(0, d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
34
+ };
35
+ }
36
+ export function zip(entries, now = new Date()) {
37
+ const { time, date } = dosTime(now);
38
+ const locals = [];
39
+ const centrals = [];
40
+ let offset = 0;
41
+ const seen = new Set();
42
+ for (const e of entries) {
43
+ const name = safeEntryPath(e.path);
44
+ if (seen.has(name))
45
+ throw new ZipError(`duplicate entry: ${name}`);
46
+ seen.add(name);
47
+ const nameBuf = Buffer.from(name, "utf8");
48
+ const deflated = zlib.deflateRawSync(e.data);
49
+ const stored = deflated.length >= e.data.length;
50
+ const body = stored ? e.data : deflated;
51
+ const crc = crc32(e.data);
52
+ const local = Buffer.alloc(30);
53
+ local.writeUInt32LE(0x04034b50, 0);
54
+ local.writeUInt16LE(20, 4); // version needed
55
+ local.writeUInt16LE(0x0800, 6); // UTF-8 names
56
+ local.writeUInt16LE(stored ? 0 : 8, 8);
57
+ local.writeUInt16LE(time, 10);
58
+ local.writeUInt16LE(date, 12);
59
+ local.writeUInt32LE(crc, 14);
60
+ local.writeUInt32LE(body.length, 18);
61
+ local.writeUInt32LE(e.data.length, 22);
62
+ local.writeUInt16LE(nameBuf.length, 26);
63
+ local.writeUInt16LE(0, 28);
64
+ locals.push(local, nameBuf, body);
65
+ const central = Buffer.alloc(46);
66
+ central.writeUInt32LE(0x02014b50, 0);
67
+ central.writeUInt16LE(0x0314, 4); // made by: unix, 2.0
68
+ central.writeUInt16LE(20, 6);
69
+ central.writeUInt16LE(0x0800, 8);
70
+ central.writeUInt16LE(stored ? 0 : 8, 10);
71
+ central.writeUInt16LE(time, 12);
72
+ central.writeUInt16LE(date, 14);
73
+ central.writeUInt32LE(crc, 16);
74
+ central.writeUInt32LE(body.length, 20);
75
+ central.writeUInt32LE(e.data.length, 24);
76
+ central.writeUInt16LE(nameBuf.length, 28);
77
+ central.writeUInt32LE((0o100644 << 16) >>> 0, 38); // a regular file, rw-r--r--
78
+ central.writeUInt32LE(offset, 42);
79
+ centrals.push(central, nameBuf);
80
+ offset += local.length + nameBuf.length + body.length;
81
+ }
82
+ const cd = Buffer.concat(centrals);
83
+ const end = Buffer.alloc(22);
84
+ end.writeUInt32LE(0x06054b50, 0);
85
+ end.writeUInt16LE(entries.length, 8);
86
+ end.writeUInt16LE(entries.length, 10);
87
+ end.writeUInt32LE(cd.length, 12);
88
+ end.writeUInt32LE(offset, 16);
89
+ return Buffer.concat([...locals, cd, end]);
90
+ }
91
+ export function unzip(buf, limits = {}) {
92
+ const maxEntries = limits.maxEntries ?? 2000;
93
+ const maxFile = limits.maxFileBytes ?? 1024 * 1024;
94
+ const maxTotal = limits.maxTotalBytes ?? 50 * 1024 * 1024;
95
+ // The end-of-central-directory record: within the last 22 + 65535 bytes.
96
+ let eocd = -1;
97
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i--) {
98
+ if (buf.readUInt32LE(i) === 0x06054b50) {
99
+ eocd = i;
100
+ break;
101
+ }
102
+ }
103
+ if (eocd < 0)
104
+ throw new ZipError("not a zip file");
105
+ const count = buf.readUInt16LE(eocd + 10);
106
+ const cdSize = buf.readUInt32LE(eocd + 12);
107
+ const cdOffset = buf.readUInt32LE(eocd + 16);
108
+ if (count === 0xffff || cdOffset === 0xffffffff)
109
+ throw new ZipError("zip64 archives are not supported");
110
+ if (count > maxEntries)
111
+ throw new ZipError(`too many files: ${count} (at most ${maxEntries})`);
112
+ if (cdOffset + cdSize > eocd)
113
+ throw new ZipError("corrupt zip: central directory out of range");
114
+ const out = [];
115
+ const seen = new Set();
116
+ let total = 0;
117
+ let p = cdOffset;
118
+ for (let n = 0; n < count; n++) {
119
+ if (p + 46 > eocd || buf.readUInt32LE(p) !== 0x02014b50)
120
+ throw new ZipError("corrupt zip: bad central directory entry");
121
+ const flags = buf.readUInt16LE(p + 8);
122
+ const method = buf.readUInt16LE(p + 10);
123
+ const crc = buf.readUInt32LE(p + 16);
124
+ const csize = buf.readUInt32LE(p + 20);
125
+ const usize = buf.readUInt32LE(p + 24);
126
+ const nameLen = buf.readUInt16LE(p + 28);
127
+ const extraLen = buf.readUInt16LE(p + 30);
128
+ const commentLen = buf.readUInt16LE(p + 32);
129
+ const localAt = buf.readUInt32LE(p + 42);
130
+ if (p + 46 + nameLen > eocd)
131
+ throw new ZipError("corrupt zip: name out of range");
132
+ const rawName = buf.subarray(p + 46, p + 46 + nameLen).toString("utf8");
133
+ p += 46 + nameLen + extraLen + commentLen;
134
+ if (rawName.endsWith("/"))
135
+ continue; // a directory entry
136
+ // macOS "Compress" adds these beside every file; they are not content.
137
+ if (/(^|\/)__MACOSX\//.test(rawName) || /(^|\/)\.DS_Store$/.test(rawName))
138
+ continue;
139
+ if (flags & 0x1)
140
+ throw new ZipError(`${rawName}: encrypted entries are not supported`);
141
+ if (method !== 0 && method !== 8)
142
+ throw new ZipError(`${rawName}: compression method ${method} is not supported`);
143
+ const name = safeEntryPath(rawName);
144
+ if (seen.has(name))
145
+ throw new ZipError(`duplicate entry: ${name}`);
146
+ seen.add(name);
147
+ if (usize > maxFile)
148
+ throw new ZipError(`${name} is too large (${usize} bytes, at most ${maxFile})`);
149
+ total += usize;
150
+ if (total > maxTotal)
151
+ throw new ZipError(`the files are too large together (more than ${maxTotal} bytes)`);
152
+ if (localAt + 30 > buf.length || buf.readUInt32LE(localAt) !== 0x04034b50)
153
+ throw new ZipError(`${name}: corrupt local header`);
154
+ const start = localAt + 30 + buf.readUInt16LE(localAt + 26) + buf.readUInt16LE(localAt + 28);
155
+ if (start + csize > buf.length)
156
+ throw new ZipError(`${name}: data out of range`);
157
+ const raw = buf.subarray(start, start + csize);
158
+ let data;
159
+ try {
160
+ // maxOutputLength: the header's size is the attacker's to choose, so the
161
+ // inflate itself is capped too — a bomb stops here, not in memory.
162
+ data = method === 0 ? Buffer.from(raw) : zlib.inflateRawSync(raw, { maxOutputLength: maxFile + 1 });
163
+ }
164
+ catch {
165
+ throw new ZipError(`${name}: could not be decompressed`);
166
+ }
167
+ if (data.length !== usize)
168
+ throw new ZipError(`${name}: size does not match its header`);
169
+ if (crc32(data) !== crc)
170
+ throw new ZipError(`${name}: checksum mismatch`);
171
+ out.push({ path: name, data });
172
+ }
173
+ return out;
174
+ }
175
+ /** A relative, forward-slash path that stays inside the archive's root. */
176
+ function safeEntryPath(raw) {
177
+ const p = raw.replaceAll("\\", "/");
178
+ if (p.startsWith("/") || /^[a-zA-Z]:/.test(p) || p.includes("\0"))
179
+ throw new ZipError(`unsafe path: ${raw}`);
180
+ const parts = p.split("/").filter((s) => s !== "" && s !== ".");
181
+ if (parts.length === 0 || parts.some((s) => s === ".."))
182
+ throw new ZipError(`unsafe path: ${raw}`);
183
+ return parts.join("/");
184
+ }
package/index.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  export * from "./src/layout.ts";
14
14
  export * from "./src/paths.ts";
15
15
  export * from "./src/store.ts";
16
+ export * from "./src/model-credential.ts";
16
17
  export * from "./src/starter.ts";
17
18
  export * from "./src/runner.ts";
18
19
  export * from "./src/okf.ts";
@@ -33,6 +34,8 @@ export * from "./src/linkable.ts";
33
34
  export * from "./src/refs.ts";
34
35
  export * from "./src/tool-names.ts";
35
36
  export * from "./src/history.ts";
37
+ export * from "./src/zip.ts";
38
+ export * from "./src/package.ts";
36
39
  export * from "./src/gitrepo.ts";
37
40
  export * from "./src/storage.ts";
38
41
  export * from "./src/secrets.ts";
@@ -42,6 +45,7 @@ export * from "./src/notify.ts";
42
45
  export * from "./src/webhook.ts";
43
46
  export * from "./src/approvals.ts";
44
47
  export * from "./src/deploy.ts";
48
+ export * from "./src/agent-import.ts";
45
49
  export * from "./src/account.ts";
46
50
  export * from "./src/tar.ts";
47
51
  export * from "./src/git.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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 };