@foldrun/core 0.6.0 → 1.0.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 +14 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -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/completions.js +1 -0
- package/dist/src/confine.d.ts +1 -0
- package/dist/src/confine.js +33 -4
- package/dist/src/deploy.d.ts +9 -0
- package/dist/src/deploy.js +31 -3
- package/dist/src/evals.js +22 -5
- package/dist/src/hooks.d.ts +114 -0
- package/dist/src/hooks.js +370 -0
- package/dist/src/host-env.d.ts +14 -0
- package/dist/src/host-env.js +26 -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/kinds.d.ts +1 -1
- package/dist/src/kinds.js +26 -0
- package/dist/src/library.d.ts +1 -1
- package/dist/src/library.js +17 -1
- package/dist/src/mail.js +1 -1
- package/dist/src/model-credential.d.ts +16 -0
- package/dist/src/model-credential.js +40 -0
- package/dist/src/model-env.d.ts +17 -0
- package/dist/src/model-env.js +57 -0
- package/dist/src/notify.d.ts +8 -0
- package/dist/src/notify.js +10 -1
- 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/providers.d.ts +5 -0
- package/dist/src/providers.js +21 -1
- package/dist/src/run-container.d.ts +10 -0
- package/dist/src/run-container.js +72 -14
- package/dist/src/runner.d.ts +205 -0
- package/dist/src/runner.js +273 -36
- package/dist/src/runtime.d.ts +77 -0
- package/dist/src/runtime.js +230 -5
- package/dist/src/script-tools.d.ts +2 -0
- package/dist/src/script-tools.js +3 -1
- 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 +38 -4
- package/dist/src/step-exec.d.ts +21 -2
- package/dist/src/step-exec.js +165 -7
- package/dist/src/storage.d.ts +7 -1
- package/dist/src/storage.js +14 -1
- package/dist/src/store.d.ts +31 -1
- package/dist/src/store.js +90 -8
- 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 +3 -0
- package/package.json +18 -1
- package/src/agent-import.ts +154 -0
- package/src/api-tools.ts +16 -1
- package/src/approvals.ts +10 -3
- package/src/completions.ts +1 -0
- package/src/confine.ts +36 -5
- package/src/deploy.ts +28 -3
- package/src/evals.ts +21 -3
- package/src/hooks.ts +406 -0
- package/src/host-env.ts +27 -0
- package/src/install-helper.ts +143 -0
- package/src/install-tool.ts +154 -0
- package/src/kinds.ts +28 -0
- package/src/library.ts +18 -2
- package/src/mail.ts +1 -1
- package/src/model-credential.ts +41 -0
- package/src/model-env.ts +66 -0
- package/src/notify.ts +11 -1
- package/src/package.ts +664 -0
- package/src/platform.ts +6 -0
- package/src/provider-health.ts +3 -0
- package/src/providers.ts +27 -1
- package/src/run-container.ts +75 -14
- package/src/runner.ts +279 -36
- package/src/runtime.ts +235 -5
- package/src/script-tools.ts +5 -1
- package/src/secrets.ts +34 -6
- package/src/starter.ts +44 -4
- package/src/step-exec.ts +171 -11
- package/src/storage.ts +13 -0
- package/src/store.ts +89 -7
- package/src/tool-names.ts +4 -2
- package/src/tool-test.ts +8 -1
- package/src/zip.ts +189 -0
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
|
@@ -34,6 +34,8 @@ export * from "./src/linkable.ts";
|
|
|
34
34
|
export * from "./src/refs.ts";
|
|
35
35
|
export * from "./src/tool-names.ts";
|
|
36
36
|
export * from "./src/history.ts";
|
|
37
|
+
export * from "./src/zip.ts";
|
|
38
|
+
export * from "./src/package.ts";
|
|
37
39
|
export * from "./src/gitrepo.ts";
|
|
38
40
|
export * from "./src/storage.ts";
|
|
39
41
|
export * from "./src/secrets.ts";
|
|
@@ -43,6 +45,7 @@ export * from "./src/notify.ts";
|
|
|
43
45
|
export * from "./src/webhook.ts";
|
|
44
46
|
export * from "./src/approvals.ts";
|
|
45
47
|
export * from "./src/deploy.ts";
|
|
48
|
+
export * from "./src/agent-import.ts";
|
|
46
49
|
export * from "./src/account.ts";
|
|
47
50
|
export * from "./src/tar.ts";
|
|
48
51
|
export * from "./src/git.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foldrun/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Agents are just folders. The format, the runner, the checks.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -15,6 +15,23 @@
|
|
|
15
15
|
"default": "./dist/src/*.js"
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
|
+
"foldrun": {
|
|
19
|
+
"publicApi": [
|
|
20
|
+
".",
|
|
21
|
+
"./git-hook",
|
|
22
|
+
"./agents-md",
|
|
23
|
+
"./browser-pod",
|
|
24
|
+
"./flow-patterns",
|
|
25
|
+
"./history",
|
|
26
|
+
"./hooks",
|
|
27
|
+
"./kinds",
|
|
28
|
+
"./layout",
|
|
29
|
+
"./limits",
|
|
30
|
+
"./providers",
|
|
31
|
+
"./subagents",
|
|
32
|
+
"./tool-names"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
18
35
|
"main": "./dist/index.js",
|
|
19
36
|
"types": "./dist/index.d.ts",
|
|
20
37
|
"files": [
|
|
@@ -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 };
|
package/src/completions.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
310
|
-
|
|
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
|