@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.
- package/README.md +7 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/src/agent-import.d.ts +21 -0
- package/dist/src/agent-import.js +116 -0
- package/dist/src/api-tools.js +14 -2
- package/dist/src/approvals.d.ts +8 -1
- package/dist/src/approvals.js +10 -5
- package/dist/src/browser-pod.d.ts +20 -1
- package/dist/src/browser-pod.js +58 -9
- package/dist/src/completions.js +1 -0
- package/dist/src/confine.d.ts +1 -0
- package/dist/src/confine.js +33 -4
- package/dist/src/container.d.ts +9 -0
- package/dist/src/container.js +115 -5
- package/dist/src/context-tools.d.ts +1 -1
- package/dist/src/deploy.d.ts +9 -0
- package/dist/src/deploy.js +31 -3
- package/dist/src/evals.js +24 -4
- package/dist/src/host-env.d.ts +14 -0
- package/dist/src/host-env.js +31 -0
- package/dist/src/install-helper.d.ts +17 -0
- package/dist/src/install-helper.js +134 -0
- package/dist/src/install-tool.d.ts +128 -0
- package/dist/src/install-tool.js +133 -0
- package/dist/src/mail.js +3 -1
- package/dist/src/model-credential.d.ts +30 -0
- package/dist/src/model-credential.js +98 -0
- package/dist/src/model-env.d.ts +17 -0
- package/dist/src/model-env.js +57 -0
- package/dist/src/package.d.ts +67 -0
- package/dist/src/package.js +594 -0
- package/dist/src/platform.d.ts +5 -0
- package/dist/src/platform.js +1 -0
- package/dist/src/provider-health.js +3 -0
- package/dist/src/run-container.d.ts +6 -1
- package/dist/src/run-container.js +93 -20
- package/dist/src/runner.d.ts +239 -0
- package/dist/src/runner.js +380 -72
- package/dist/src/runtime.d.ts +77 -0
- package/dist/src/runtime.js +230 -5
- package/dist/src/script-tools.d.ts +16 -0
- package/dist/src/script-tools.js +50 -8
- package/dist/src/secrets.d.ts +5 -0
- package/dist/src/secrets.js +37 -6
- package/dist/src/starter.d.ts +12 -0
- package/dist/src/starter.js +36 -4
- package/dist/src/step-exec.d.ts +22 -2
- package/dist/src/step-exec.js +70 -7
- package/dist/src/storage.d.ts +7 -1
- package/dist/src/storage.js +14 -1
- package/dist/src/store.d.ts +24 -0
- package/dist/src/store.js +91 -10
- package/dist/src/subagents.js +1 -1
- package/dist/src/test-mode.d.ts +17 -2
- package/dist/src/test-mode.js +53 -13
- package/dist/src/tool-names.d.ts +3 -1
- package/dist/src/tool-names.js +4 -2
- package/dist/src/tool-test.js +8 -1
- package/dist/src/zip.d.ts +17 -0
- package/dist/src/zip.js +184 -0
- package/index.ts +4 -0
- package/package.json +1 -1
- package/src/agent-import.ts +154 -0
- package/src/api-tools.ts +16 -1
- package/src/approvals.ts +10 -3
- package/src/browser-pod.ts +46 -9
- package/src/completions.ts +1 -0
- package/src/confine.ts +36 -5
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/deploy.ts +28 -3
- package/src/evals.ts +23 -2
- package/src/host-env.ts +32 -0
- package/src/install-helper.ts +143 -0
- package/src/install-tool.ts +154 -0
- package/src/mail.ts +3 -1
- package/src/model-credential.ts +112 -0
- package/src/model-env.ts +66 -0
- package/src/package.ts +664 -0
- package/src/platform.ts +6 -0
- package/src/provider-health.ts +3 -0
- package/src/run-container.ts +92 -19
- package/src/runner.ts +385 -72
- package/src/runtime.ts +235 -5
- package/src/script-tools.ts +48 -6
- package/src/secrets.ts +34 -6
- package/src/starter.ts +42 -4
- package/src/step-exec.ts +75 -10
- package/src/storage.ts +13 -0
- package/src/store.ts +91 -9
- package/src/subagents.ts +1 -1
- package/src/test-mode.ts +51 -11
- package/src/tool-names.ts +4 -2
- package/src/tool-test.ts +8 -1
- package/src/zip.ts +189 -0
- package/templates/blog-desk/agents/writer/agent.md +1 -1
- package/templates/hello/agents/curator/agent.md +3 -3
- package/templates/hello/agents/notetaker/agent.md +2 -2
package/dist/src/test-mode.js
CHANGED
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
// a test run; the env var says TEST_MODE_WITHHELD instead, so a script
|
|
19
19
|
// that forgot to check fails at the provider with an auth error
|
|
20
20
|
// - the write-back: state/ and storage/ go INTO the sandbox as usual and
|
|
21
|
-
// come out into runs/<id>/test-writes/ instead of the real directories
|
|
21
|
+
// come out into runs/<id>/test-writes/ instead of the real directories;
|
|
22
|
+
// the next step's copy has those writes laid over it (overlayTestWrites),
|
|
23
|
+
// so the run reads its own work and the workspace never sees it
|
|
22
24
|
//
|
|
23
25
|
// Nothing here weakens a live run: every function is a no-op unless the run
|
|
24
26
|
// says `test: true`.
|
|
@@ -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.
|
|
251
|
-
|
|
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
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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}`;
|
package/dist/src/tool-names.d.ts
CHANGED
|
@@ -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. */
|
package/dist/src/tool-names.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/dist/src/tool-test.js
CHANGED
|
@@ -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
|
-
|
|
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[];
|
package/dist/src/zip.js
ADDED
|
@@ -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
|
@@ -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)
|
|
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 };
|