@foldrun/core 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/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/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 +5 -0
- package/dist/src/run-container.js +66 -13
- package/dist/src/runner.d.ts +204 -0
- package/dist/src/runner.js +257 -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 +36 -4
- package/dist/src/step-exec.d.ts +11 -2
- package/dist/src/step-exec.js +52 -5
- package/dist/src/storage.d.ts +7 -1
- package/dist/src/storage.js +14 -1
- package/dist/src/store.d.ts +20 -0
- package/dist/src/store.js +89 -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 +1 -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/host-env.ts +27 -0
- package/src/install-helper.ts +143 -0
- package/src/install-tool.ts +154 -0
- package/src/model-credential.ts +41 -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 +65 -12
- package/src/runner.ts +264 -36
- package/src/runtime.ts +235 -5
- package/src/script-tools.ts +5 -1
- package/src/secrets.ts +34 -6
- package/src/starter.ts +42 -4
- package/src/step-exec.ts +56 -8
- package/src/storage.ts +13 -0
- package/src/store.ts +85 -7
- package/src/tool-names.ts +4 -2
- package/src/tool-test.ts +8 -1
- package/src/zip.ts +189 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
// Export a workspace, a flow or an agent as one .zip, and import one.
|
|
2
|
+
//
|
|
3
|
+
// Agent import (agent-import.ts) copies between workspaces of ONE account;
|
|
4
|
+
// `foldrun pull` then `foldrun deploy` moves a whole workspace, but only from
|
|
5
|
+
// a terminal. Neither gets a desk from one account or installation to
|
|
6
|
+
// another from the dashboard, and nothing exported a single flow. A package
|
|
7
|
+
// is the unit for that: the authored source a thing needs to run, as a zip
|
|
8
|
+
// with a small manifest, which any account can preview and take in.
|
|
9
|
+
//
|
|
10
|
+
// What is IN a package is what an author wrote — never what runs made
|
|
11
|
+
// (memory/, state/, outputs/, storage), never the vault (secret names are
|
|
12
|
+
// listed so the importer can set them; values never leave), never the
|
|
13
|
+
// engine's .claude session files.
|
|
14
|
+
//
|
|
15
|
+
// workspace the whole authored tree
|
|
16
|
+
// flow the flow, every flow it runs as a step, every agent those
|
|
17
|
+
// steps and their colleagues name, and the workspace tools,
|
|
18
|
+
// skills and scripts those agents grant
|
|
19
|
+
// agent the agent's folder (what agent import copies), and the
|
|
20
|
+
// workspace tools, skills and scripts it grants
|
|
21
|
+
//
|
|
22
|
+
// What a package needs and does not carry — secrets, the account library,
|
|
23
|
+
// an agent it consults — is worked out at IMPORT, from the files and the
|
|
24
|
+
// target, not taken on trust from the manifest: a manifest is the sender's
|
|
25
|
+
// claim, the files are what will run.
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import matter from "gray-matter";
|
|
29
|
+
import { assertSafeName, isEditablePath, listWorkspaceFiles, listWorkspaces, listFlows, parseFlow, readWorkspaceFile, saveWorkspace, blankTemplateFiles, syncBundleFor, notifyWorkspaceChanged, workspaceDir, } from "./store.js";
|
|
30
|
+
import { recordRevision } from "./history.js";
|
|
31
|
+
import { libraryDir, libraryTools } from "./library.js";
|
|
32
|
+
import { discoverSkills } from "./runner.js";
|
|
33
|
+
import { listSecrets } from "./secrets.js";
|
|
34
|
+
import { isRuntimeTool } from "./tool-names.js";
|
|
35
|
+
import { refNames } from "./refs.js";
|
|
36
|
+
import { zip, unzip, ZipError } from "./zip.js";
|
|
37
|
+
/** An error the API layer turns into a status rather than a 500. */
|
|
38
|
+
export class PackageError extends Error {
|
|
39
|
+
status;
|
|
40
|
+
constructor(message, status) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.status = status;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export const MANIFEST_FILE = "foldrun-package.json";
|
|
46
|
+
const MAX_FILES = 500;
|
|
47
|
+
/** The authored source of a workspace: AGENTS.md and these folders. A
|
|
48
|
+
* path anywhere else is never written by an import — isEditablePath alone
|
|
49
|
+
* accepts a scripts/ folder at any depth, which let a zip write
|
|
50
|
+
* runs/scripts/… and storage/scripts/… into a desk (audit, 7 Oct). */
|
|
51
|
+
const AUTHORED_TOP = new Set(["agents", "flows", "evals", "skills", "knowledge", "tools", "scripts"]);
|
|
52
|
+
/** Inside those, what runs or the engine made, and .env files — never in a
|
|
53
|
+
* package. A package carries secret NAMES; a value in a tool's .env would
|
|
54
|
+
* leave with it. */
|
|
55
|
+
const NOT_PACKAGED = /^agents\/[^/]+\/(memory|outputs|state|workspace|\.claude)(\/|$)|^agents\/[^/]+\/\.claude[^/]*$|(^|\/)\.claude(\/|$)|(^|\/)(\.env(\.[^/]*)?|[^/]+\.env)$/;
|
|
56
|
+
export function isPackagedPath(rel) {
|
|
57
|
+
if (rel === "AGENTS.md" || rel === "project.md")
|
|
58
|
+
return true;
|
|
59
|
+
if (!AUTHORED_TOP.has(rel.split("/")[0]))
|
|
60
|
+
return false;
|
|
61
|
+
return !NOT_PACKAGED.test(rel) && isEditablePath(rel);
|
|
62
|
+
}
|
|
63
|
+
/** Largest one file in a package — what a deploy carries, not the 512 KB a
|
|
64
|
+
* person edits: a deployed 800 KB CSV exported and then could not come back. */
|
|
65
|
+
const MAX_PACKAGE_FILE = 10 * 1024 * 1024;
|
|
66
|
+
// ---------------------------------------------------------------- export
|
|
67
|
+
export function packageOf(tenant, workspace, kind, name) {
|
|
68
|
+
assertSafeName(workspace, "workspace");
|
|
69
|
+
if (!listWorkspaces(tenant).some((w) => w.name === workspace)) {
|
|
70
|
+
throw new PackageError(`no workspace called "${workspace}" in this account`, 404);
|
|
71
|
+
}
|
|
72
|
+
const all = listWorkspaceFiles(tenant, workspace).filter(isPackagedPath);
|
|
73
|
+
const read = (p) => readWorkspaceFile(tenant, workspace, p);
|
|
74
|
+
let paths;
|
|
75
|
+
let label;
|
|
76
|
+
if (kind === "workspace") {
|
|
77
|
+
paths = all;
|
|
78
|
+
label = workspace;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
if (!name)
|
|
82
|
+
throw new PackageError(`name the ${kind} to export`, 400);
|
|
83
|
+
assertSafeName(name, kind);
|
|
84
|
+
label = name;
|
|
85
|
+
paths = kind === "flow" ? flowClosure(tenant, workspace, name, all, read) : agentClosure([name], all, read, false);
|
|
86
|
+
}
|
|
87
|
+
const files = paths.sort().map((p) => ({ path: p, content: read(p) }));
|
|
88
|
+
return {
|
|
89
|
+
manifest: {
|
|
90
|
+
format: "foldrun-package",
|
|
91
|
+
version: 1,
|
|
92
|
+
kind,
|
|
93
|
+
name: label,
|
|
94
|
+
workspace,
|
|
95
|
+
exportedAt: new Date().toISOString(),
|
|
96
|
+
files: files.map((f) => f.path),
|
|
97
|
+
},
|
|
98
|
+
files,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function packageZip(pkg) {
|
|
102
|
+
return zip([
|
|
103
|
+
{ path: MANIFEST_FILE, data: Buffer.from(`${JSON.stringify(pkg.manifest, null, 2)}\n`) },
|
|
104
|
+
...pkg.files.map((f) => ({ path: f.path, data: Buffer.from(f.content, "utf8") })),
|
|
105
|
+
]);
|
|
106
|
+
}
|
|
107
|
+
/** What the download is called: `<workspace>.zip`, `<workspace>-flow-<name>.zip`. */
|
|
108
|
+
export function packageFilename(m) {
|
|
109
|
+
return m.kind === "workspace" ? `${m.workspace}.zip` : `${m.workspace}-${m.kind}-${m.name}.zip`;
|
|
110
|
+
}
|
|
111
|
+
function flowClosure(tenant, workspace, flow, all, read) {
|
|
112
|
+
// By name, the way a step addresses a flow — the file may be called
|
|
113
|
+
// something else (flows/publish-v2.md with name: publish was left out).
|
|
114
|
+
const byName = new Map(listFlows(tenant, workspace).map((f) => [f.name, f]));
|
|
115
|
+
const flows = new Set();
|
|
116
|
+
const files = new Set();
|
|
117
|
+
const agents = new Set();
|
|
118
|
+
const visit = (name) => {
|
|
119
|
+
if (flows.has(name))
|
|
120
|
+
return;
|
|
121
|
+
const info = byName.get(name);
|
|
122
|
+
if (!info) {
|
|
123
|
+
if (flows.size === 0)
|
|
124
|
+
throw new PackageError(`no flow called "${name}" in ${workspace}`, 404);
|
|
125
|
+
return; // a missing subflow — import reports it, export carries what exists
|
|
126
|
+
}
|
|
127
|
+
flows.add(name);
|
|
128
|
+
files.add(`flows/${info.file}`);
|
|
129
|
+
for (const s of info.steps) {
|
|
130
|
+
if (s.subflow)
|
|
131
|
+
visit(s.subflow);
|
|
132
|
+
else if (s.agent)
|
|
133
|
+
agents.add(s.agent);
|
|
134
|
+
if (s.onFail)
|
|
135
|
+
agents.add(s.onFail);
|
|
136
|
+
for (const d of s.delegate ?? [])
|
|
137
|
+
agents.add(d);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
visit(flow);
|
|
141
|
+
const flowFiles = all.filter((p) => files.has(p));
|
|
142
|
+
return [...new Set([...flowFiles, ...agentClosure([...agents], all, read, true)])];
|
|
143
|
+
}
|
|
144
|
+
/** The agents' own folders and the workspace-level tools, skills and scripts
|
|
145
|
+
* they grant. With `colleagues`, every agent they consult or delegate to as
|
|
146
|
+
* well — a flow does not run without them; a single agent's export names
|
|
147
|
+
* them instead, and import says which are missing. */
|
|
148
|
+
function agentClosure(names, all, read, colleagues) {
|
|
149
|
+
const out = new Set();
|
|
150
|
+
const seen = new Set();
|
|
151
|
+
const tools = toolFiles(all, read);
|
|
152
|
+
const skills = skillFiles(all, read);
|
|
153
|
+
const queue = [...names];
|
|
154
|
+
while (queue.length) {
|
|
155
|
+
const agent = queue.shift();
|
|
156
|
+
if (seen.has(agent))
|
|
157
|
+
continue;
|
|
158
|
+
seen.add(agent);
|
|
159
|
+
const own = all.filter((p) => p.startsWith(`agents/${agent}/`));
|
|
160
|
+
if (!own.includes(`agents/${agent}/agent.md`)) {
|
|
161
|
+
if (seen.size === 1 && !colleagues)
|
|
162
|
+
throw new PackageError(`no agent called "${agent}"`, 404);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
own.forEach((p) => out.add(p));
|
|
166
|
+
const front = frontOf(read(`agents/${agent}/agent.md`));
|
|
167
|
+
for (const t of front.tools) {
|
|
168
|
+
for (const p of tools.get(t) ?? []) {
|
|
169
|
+
out.add(p);
|
|
170
|
+
// A single-file tool's program lives elsewhere in the workspace
|
|
171
|
+
// (`run: workspace/scripts/x.mjs`); without it the tool has nothing
|
|
172
|
+
// to run. onpage-desk's apply-onpage was exported without its script.
|
|
173
|
+
const prog = /^tools\/[^/]+\.md$/.test(p) ? toolProgram(read(p)) : null;
|
|
174
|
+
if (prog && all.includes(prog))
|
|
175
|
+
out.add(prog);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// No `skills:` line: the runtime gives the agent every skill in scope, so
|
|
179
|
+
// the package carries every workspace skill.
|
|
180
|
+
for (const s of front.skills ?? [...skills.keys()])
|
|
181
|
+
for (const p of skills.get(s) ?? [])
|
|
182
|
+
out.add(p);
|
|
183
|
+
for (const s of front.scripts)
|
|
184
|
+
if (all.includes(`scripts/${s}`))
|
|
185
|
+
out.add(`scripts/${s}`);
|
|
186
|
+
if (colleagues)
|
|
187
|
+
queue.push(...front.agents);
|
|
188
|
+
}
|
|
189
|
+
return [...out];
|
|
190
|
+
}
|
|
191
|
+
// ---------------------------------------------------------------- import
|
|
192
|
+
export function readPackage(buf) {
|
|
193
|
+
let entries;
|
|
194
|
+
try {
|
|
195
|
+
entries = unzip(buf, { maxEntries: 5000, maxFileBytes: MAX_PACKAGE_FILE, maxTotalBytes: 50 * 1024 * 1024 });
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
if (err instanceof ZipError)
|
|
199
|
+
throw new PackageError(err.message, 400);
|
|
200
|
+
throw err;
|
|
201
|
+
}
|
|
202
|
+
// A folder zipped by hand ("Compress" on a Mac) puts everything under one
|
|
203
|
+
// top directory. Look through it when that is all there is.
|
|
204
|
+
const tops = new Set(entries.map((e) => e.path.split("/")[0]));
|
|
205
|
+
const top1 = [...tops][0];
|
|
206
|
+
if (tops.size === 1 && entries.every((e) => e.path.includes("/")) && !AUTHORED_TOP.has(top1) && !["memory", "state"].includes(top1)) {
|
|
207
|
+
const top = `${[...tops][0]}/`;
|
|
208
|
+
entries = entries.map((e) => ({ ...e, path: e.path.slice(top.length) }));
|
|
209
|
+
}
|
|
210
|
+
let manifest = {};
|
|
211
|
+
const files = [];
|
|
212
|
+
// Not authored source: memory, state, a .gitignore or CLAUDE.md beside a
|
|
213
|
+
// hand-zipped folder, runs/… — never written, and listed, so the person
|
|
214
|
+
// sees what was left out rather than the whole zip being refused for it.
|
|
215
|
+
const skipped = [];
|
|
216
|
+
for (const e of entries) {
|
|
217
|
+
if (e.path === MANIFEST_FILE) {
|
|
218
|
+
try {
|
|
219
|
+
manifest = JSON.parse(e.data.toString("utf8"));
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
throw new PackageError(`${MANIFEST_FILE} is not valid JSON`, 400);
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (!isPackagedPath(e.path)) {
|
|
227
|
+
skipped.push(e.path);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const content = e.data.toString("utf8");
|
|
231
|
+
if (!Buffer.from(content, "utf8").equals(e.data))
|
|
232
|
+
throw new PackageError(`${e.path} is not UTF-8 text`, 400);
|
|
233
|
+
try {
|
|
234
|
+
matter(content);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
throw new PackageError(`${e.path}: its frontmatter does not parse`, 400);
|
|
238
|
+
}
|
|
239
|
+
files.push({ path: e.path, content });
|
|
240
|
+
}
|
|
241
|
+
if (files.length === 0) {
|
|
242
|
+
throw new PackageError(`the package has no files to import${skipped.length ? ` — only what an import never writes (${skipped.slice(0, 5).join(", ")}${skipped.length > 5 ? " …" : ""})` : ""}. A package carries AGENTS.md, agents, flows, evals, knowledge, skills, tools and scripts`, 400);
|
|
243
|
+
}
|
|
244
|
+
if (files.length > MAX_FILES)
|
|
245
|
+
throw new PackageError(`too many files: ${files.length} (at most ${MAX_FILES})`, 400);
|
|
246
|
+
// No manifest: a workspace folder zipped by hand is still importable.
|
|
247
|
+
const kind = manifest.kind === "flow" || manifest.kind === "agent" || manifest.kind === "workspace"
|
|
248
|
+
? manifest.kind
|
|
249
|
+
: files.some((f) => f.path === "AGENTS.md")
|
|
250
|
+
? "workspace"
|
|
251
|
+
: "flow";
|
|
252
|
+
if (manifest.format !== undefined && manifest.format !== "foldrun-package") {
|
|
253
|
+
throw new PackageError(`${MANIFEST_FILE} is not a foldrun package manifest`, 400);
|
|
254
|
+
}
|
|
255
|
+
if (manifest.version !== undefined && manifest.version !== 1) {
|
|
256
|
+
throw new PackageError(`package version ${manifest.version} is newer than this installation reads — update foldrun`, 400);
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
manifest: {
|
|
260
|
+
format: "foldrun-package",
|
|
261
|
+
version: 1,
|
|
262
|
+
kind,
|
|
263
|
+
name: typeof manifest.name === "string" ? manifest.name : "",
|
|
264
|
+
workspace: typeof manifest.workspace === "string" ? manifest.workspace : "",
|
|
265
|
+
exportedAt: typeof manifest.exportedAt === "string" ? manifest.exportedAt : "",
|
|
266
|
+
files: files.map((f) => f.path),
|
|
267
|
+
},
|
|
268
|
+
files: files.sort((a, b) => a.path.localeCompare(b.path)),
|
|
269
|
+
skipped: skipped.sort(),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
export function planImport(tenant, workspace, pkg) {
|
|
273
|
+
assertSafeName(workspace, "workspace");
|
|
274
|
+
const exists = listWorkspaces(tenant).some((w) => w.name === workspace);
|
|
275
|
+
if (!exists && pkg.manifest.kind !== "workspace") {
|
|
276
|
+
throw new PackageError(`no workspace called "${workspace}" — a ${pkg.manifest.kind} imports into one that exists`, 404);
|
|
277
|
+
}
|
|
278
|
+
const dir = workspaceDir(tenant, workspace);
|
|
279
|
+
const added = [];
|
|
280
|
+
const overwritten = [];
|
|
281
|
+
const unchanged = [];
|
|
282
|
+
const blocked = [];
|
|
283
|
+
for (const f of pkg.files) {
|
|
284
|
+
const abs = path.join(dir, f.path);
|
|
285
|
+
const why = exists ? targetProblem(dir, f.path) : null;
|
|
286
|
+
if (why) {
|
|
287
|
+
blocked.push(`${f.path}: ${why}`);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (!exists || !fs.existsSync(abs))
|
|
291
|
+
added.push(f.path);
|
|
292
|
+
else if (sameText(fs.readFileSync(abs, "utf8"), f.content))
|
|
293
|
+
unchanged.push(f.path);
|
|
294
|
+
else
|
|
295
|
+
overwritten.push(f.path);
|
|
296
|
+
}
|
|
297
|
+
// Checked before anything is written, so an import is all or nothing: a
|
|
298
|
+
// zip naming knowledge/x.md/y.md where knowledge/x.md is a file failed
|
|
299
|
+
// half-way and left what it had written outside History (audit, 7 Oct).
|
|
300
|
+
if (blocked.length) {
|
|
301
|
+
throw new PackageError(`the package cannot be written into ${workspace}: ${blocked.slice(0, 5).join("; ")}${blocked.length > 5 ? " …" : ""}`, 409);
|
|
302
|
+
}
|
|
303
|
+
// A workspace with no AGENTS.md exports fine (sched-lab had none) but a new
|
|
304
|
+
// workspace needs one: it gets the blank one "Create workspace" makes.
|
|
305
|
+
if (!exists && !pkg.files.some((f) => f.path === "AGENTS.md"))
|
|
306
|
+
added.push("AGENTS.md");
|
|
307
|
+
return {
|
|
308
|
+
kind: pkg.manifest.kind,
|
|
309
|
+
name: pkg.manifest.name,
|
|
310
|
+
from: pkg.manifest.workspace,
|
|
311
|
+
workspace,
|
|
312
|
+
creates: !exists,
|
|
313
|
+
added,
|
|
314
|
+
overwritten,
|
|
315
|
+
unchanged,
|
|
316
|
+
skipped: pkg.skipped ?? [],
|
|
317
|
+
needs: needsOf(tenant, workspace, exists, pkg.files),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
export function applyImport(tenant, workspace, pkg, opts = {}) {
|
|
321
|
+
const plan = planImport(tenant, workspace, pkg);
|
|
322
|
+
// The caller checked permissions for one of these; if the workspace came
|
|
323
|
+
// or went since, refuse rather than skip the check that applied.
|
|
324
|
+
if (opts.expect && (opts.expect === "create") !== plan.creates) {
|
|
325
|
+
throw new PackageError(`${workspace} ${plan.creates ? "no longer exists" : "was created meanwhile"} — preview the import again`, 409);
|
|
326
|
+
}
|
|
327
|
+
if (plan.overwritten.length && !opts.overwrite) {
|
|
328
|
+
throw new PackageError(`${plan.overwritten.length} file${plan.overwritten.length === 1 ? "" : "s"} in ${workspace} would be overwritten: ${plan.overwritten.slice(0, 8).join(", ")}${plan.overwritten.length > 8 ? " …" : ""} — import with overwrite to replace them`, 409);
|
|
329
|
+
}
|
|
330
|
+
const what = plan.kind === "workspace" ? "workspace" : `${plan.kind} ${plan.name}`;
|
|
331
|
+
const message = `import ${what}${plan.from ? ` from ${plan.from}` : ""}`.trim();
|
|
332
|
+
const meta = { ...(opts.by ? { by: opts.by } : {}), message };
|
|
333
|
+
if (plan.creates) {
|
|
334
|
+
const files = pkg.files.some((f) => f.path === "AGENTS.md") ? pkg.files : [...pkg.files, ...blankTemplateFiles(workspace)];
|
|
335
|
+
saveWorkspace(tenant, workspace, files, meta);
|
|
336
|
+
for (const f of files)
|
|
337
|
+
if (isCode(f.path))
|
|
338
|
+
fs.chmodSync(path.join(workspaceDir(tenant, workspace), f.path), 0o755);
|
|
339
|
+
return { ...plan, written: files.map((f) => f.path), revision: null };
|
|
340
|
+
}
|
|
341
|
+
// One revision for the whole import, so History and restore treat it as
|
|
342
|
+
// the single change it is. Never deletes: a file the package lacks stays.
|
|
343
|
+
const dir = workspaceDir(tenant, workspace);
|
|
344
|
+
const write = new Set([...plan.added, ...plan.overwritten]);
|
|
345
|
+
const changes = [];
|
|
346
|
+
try {
|
|
347
|
+
for (const f of pkg.files) {
|
|
348
|
+
if (!write.has(f.path))
|
|
349
|
+
continue;
|
|
350
|
+
const abs = path.join(dir, f.path);
|
|
351
|
+
const before = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : null;
|
|
352
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
353
|
+
fs.writeFileSync(abs, normalized(f.content));
|
|
354
|
+
changes.push({ path: f.path, before, after: null });
|
|
355
|
+
// Same rule as writeWorkspaceFile: code is executable wherever it lives.
|
|
356
|
+
if (isCode(f.path))
|
|
357
|
+
fs.chmodSync(abs, 0o755);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
// Put back what this import wrote; nothing reaches History.
|
|
362
|
+
for (const c of changes.reverse()) {
|
|
363
|
+
const abs = path.join(dir, c.path);
|
|
364
|
+
try {
|
|
365
|
+
if (c.before === null)
|
|
366
|
+
fs.rmSync(abs, { force: true });
|
|
367
|
+
else
|
|
368
|
+
fs.writeFileSync(abs, c.before);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// best effort — the error below is the one to report
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
throw new PackageError(`the import could not be written, and nothing was kept: ${err instanceof Error ? err.message : String(err)}`, 500);
|
|
375
|
+
}
|
|
376
|
+
for (const c of changes) {
|
|
377
|
+
const abs = path.join(dir, c.path);
|
|
378
|
+
syncBundleFor(abs, c.before === null ? "Creation" : "Update");
|
|
379
|
+
c.after = fs.readFileSync(abs, "utf8");
|
|
380
|
+
}
|
|
381
|
+
const rev = recordRevision(tenant, workspace, changes, meta);
|
|
382
|
+
if (changes.length)
|
|
383
|
+
notifyWorkspaceChanged(tenant, workspace, "write");
|
|
384
|
+
return { ...plan, written: changes.map((c) => c.path), revision: rev?.id ?? null };
|
|
385
|
+
}
|
|
386
|
+
/** Code is executable wherever it lives — writeWorkspaceFile's rule. */
|
|
387
|
+
const isCode = (rel) => /(^|\/)scripts\//.test(rel) || (/^tools\//.test(rel) && !rel.endsWith(".md"));
|
|
388
|
+
/** Why a file cannot be written at `rel` in `dir`, or null: a parent that is
|
|
389
|
+
* a file, a target that is a directory, or a symlink on the way. */
|
|
390
|
+
function targetProblem(dir, rel) {
|
|
391
|
+
const parts = rel.split("/");
|
|
392
|
+
let cur = dir;
|
|
393
|
+
for (let i = 0; i < parts.length; i++) {
|
|
394
|
+
cur = path.join(cur, parts[i]);
|
|
395
|
+
let st;
|
|
396
|
+
try {
|
|
397
|
+
st = fs.lstatSync(cur);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return null; // the rest does not exist yet — it will be made
|
|
401
|
+
}
|
|
402
|
+
if (st.isSymbolicLink())
|
|
403
|
+
return `${parts.slice(0, i + 1).join("/")} is a link`;
|
|
404
|
+
const last = i === parts.length - 1;
|
|
405
|
+
if (!last && !st.isDirectory())
|
|
406
|
+
return `${parts.slice(0, i + 1).join("/")} is a file`;
|
|
407
|
+
if (last && st.isDirectory())
|
|
408
|
+
return "a folder is there";
|
|
409
|
+
}
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
/** As writeWorkspaceFile stores it: one trailing newline. */
|
|
413
|
+
const normalized = (s) => `${s.trimEnd()}\n`;
|
|
414
|
+
/** The same text, whatever the line endings and trailing whitespace. A file
|
|
415
|
+
* a deploy stored without a final newline, or a CSV with CRLF endings, is
|
|
416
|
+
* byte-different from what an import would write and identical to read —
|
|
417
|
+
* comparing bytes listed six such files of a live desk as "would replace"
|
|
418
|
+
* when the desk was re-imported into itself. */
|
|
419
|
+
const sameText = (a, b) => a.replace(/\r\n/g, "\n").trimEnd() === b.replace(/\r\n/g, "\n").trimEnd();
|
|
420
|
+
function needsOf(tenant, workspace, exists, files) {
|
|
421
|
+
const inPkg = new Map(files.map((f) => [f.path, f.content]));
|
|
422
|
+
const there = exists ? listWorkspaceFiles(tenant, workspace) : [];
|
|
423
|
+
const readThere = (p) => readWorkspaceFile(tenant, workspace, p);
|
|
424
|
+
// Everything the target will have once the package is in.
|
|
425
|
+
const paths = [...new Set([...there, ...inPkg.keys()])];
|
|
426
|
+
const read = (p) => inPkg.get(p) ?? readThere(p);
|
|
427
|
+
const tools = new Set([...toolFiles(paths, read).keys(), ...Object.keys(libraryTools(tenant))]);
|
|
428
|
+
const skills = new Set([...skillFiles(paths, read).keys(), ...discoverSkills(libraryDir(tenant), "skills").map((s) => s.name)]);
|
|
429
|
+
const agents = new Set(paths.map((p) => /^agents\/([^/]+)\/agent\.md$/.exec(p)?.[1]).filter((x) => Boolean(x)));
|
|
430
|
+
// Flow names as steps address them: the frontmatter name, else the file name.
|
|
431
|
+
const flows = new Set(paths
|
|
432
|
+
.filter((p) => /^flows\/.+\.md$/.test(p))
|
|
433
|
+
.map((p) => {
|
|
434
|
+
try {
|
|
435
|
+
return parseFlow(p.slice("flows/".length), read(p)).name;
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
return p.slice("flows/".length, -3);
|
|
439
|
+
}
|
|
440
|
+
}));
|
|
441
|
+
const need = { secrets: new Set(), tools: new Set(), skills: new Set(), scripts: new Set(), agents: new Set(), flows: new Set() };
|
|
442
|
+
for (const f of files) {
|
|
443
|
+
const agent = /^agents\/([^/]+)\/agent\.md$/.exec(f.path)?.[1];
|
|
444
|
+
if (agent) {
|
|
445
|
+
const front = frontOf(f.content);
|
|
446
|
+
for (const t of front.tools)
|
|
447
|
+
if (!isRuntimeTool(t) && !tools.has(t) && !front.inline.has(t))
|
|
448
|
+
need.tools.add(t);
|
|
449
|
+
for (const s of front.skills ?? [])
|
|
450
|
+
if (!skills.has(s) && !agentSkill(paths, read, agent, s))
|
|
451
|
+
need.skills.add(s);
|
|
452
|
+
for (const s of front.scripts) {
|
|
453
|
+
if (![`agents/${agent}/scripts/${s}`, `scripts/${s}`].some((p) => paths.includes(p)) && !fs.existsSync(path.join(libraryDir(tenant), "scripts", s)))
|
|
454
|
+
need.scripts.add(s);
|
|
455
|
+
}
|
|
456
|
+
for (const a of front.agents)
|
|
457
|
+
if (!agents.has(a))
|
|
458
|
+
need.agents.add(a);
|
|
459
|
+
front.secrets.forEach((s) => need.secrets.add(s));
|
|
460
|
+
}
|
|
461
|
+
if (/^tools\//.test(f.path) && f.path.endsWith(".md")) {
|
|
462
|
+
const data = safeFront(f.content);
|
|
463
|
+
list(data.secrets).forEach((s) => need.secrets.add(s));
|
|
464
|
+
const prog = /^tools\/[^/]+\.md$/.test(f.path) ? toolProgram(f.content) : null;
|
|
465
|
+
if (prog && !paths.includes(prog))
|
|
466
|
+
need.scripts.add(prog);
|
|
467
|
+
}
|
|
468
|
+
// ${NAME} placeholders in the frontmatter (an API's headers, an MCP
|
|
469
|
+
// server's env) — not the prose, where ${PATH} is a shell variable.
|
|
470
|
+
if (/^(agents\/[^/]+\/agent\.md|tools\/.+\.md)$/.test(f.path)) {
|
|
471
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(f.content)?.[1] ?? "";
|
|
472
|
+
for (const m of fm.matchAll(/\$\{([A-Z][A-Z0-9_]*)\}/g))
|
|
473
|
+
need.secrets.add(m[1]);
|
|
474
|
+
}
|
|
475
|
+
if (/^flows\/.+\.md$/.test(f.path)) {
|
|
476
|
+
let steps = [];
|
|
477
|
+
try {
|
|
478
|
+
steps = parseFlow(f.path, f.content).steps;
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
// an unparseable flow is check's to report, not import's
|
|
482
|
+
}
|
|
483
|
+
for (const s of steps) {
|
|
484
|
+
if (s.subflow) {
|
|
485
|
+
if (!flows.has(s.subflow))
|
|
486
|
+
need.flows.add(s.subflow);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
for (const a of [s.agent, s.onFail, ...(s.delegate ?? [])])
|
|
490
|
+
if (a && !agents.has(a))
|
|
491
|
+
need.agents.add(a);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
let have;
|
|
496
|
+
try {
|
|
497
|
+
have = new Set(listSecrets(tenant, exists ? workspace : undefined).map((s) => s.name));
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
have = new Set();
|
|
501
|
+
}
|
|
502
|
+
const sorted = (s) => [...s].sort();
|
|
503
|
+
return {
|
|
504
|
+
secrets: sorted(new Set([...need.secrets].filter((s) => !have.has(s)))),
|
|
505
|
+
tools: sorted(need.tools),
|
|
506
|
+
skills: sorted(need.skills),
|
|
507
|
+
scripts: sorted(need.scripts),
|
|
508
|
+
agents: sorted(need.agents),
|
|
509
|
+
flows: sorted(need.flows),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
// ---------------------------------------------------------------- shared
|
|
513
|
+
/** A frontmatter list as the runtime reads it — arrays, comma strings, map
|
|
514
|
+
* entries (refs.ts). */
|
|
515
|
+
const list = (v) => refNames(v).map((s) => s.trim()).filter(Boolean);
|
|
516
|
+
function safeFront(content) {
|
|
517
|
+
try {
|
|
518
|
+
return matter(content).data;
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return {};
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function frontOf(content) {
|
|
525
|
+
const d = safeFront(content);
|
|
526
|
+
const apis = Array.isArray(d.apis) ? d.apis.map((a) => (a && typeof a === "object" ? String(a.name ?? "") : "")) : [];
|
|
527
|
+
const mcp = d.mcpServers && typeof d.mcpServers === "object" ? Object.keys(d.mcpServers) : [];
|
|
528
|
+
const scripts = list(d.scripts);
|
|
529
|
+
return {
|
|
530
|
+
tools: list(d.tools),
|
|
531
|
+
skills: d.skills === undefined || d.skills === null ? null : list(d.skills),
|
|
532
|
+
scripts,
|
|
533
|
+
secrets: list(d.secrets),
|
|
534
|
+
agents: [...list(d.agents), ...list(d.subagents)],
|
|
535
|
+
inline: new Set([...apis, ...mcp, ...scripts, ...scripts.map((s) => s.replace(/\.[^.]+$/, ""))].filter(Boolean)),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
/** The workspace file a single-file tool's `run:` names, as a workspace
|
|
539
|
+
* path (`scripts/x.mjs`), or null when it names none or a place outside
|
|
540
|
+
* the workspace (the account library, the gallery). */
|
|
541
|
+
function toolProgram(content) {
|
|
542
|
+
const run = safeFront(content).run;
|
|
543
|
+
if (typeof run !== "string")
|
|
544
|
+
return null;
|
|
545
|
+
const rel = run.trim().replace(/^\.\//, "").replace(/^workspace\//, "").replace(/^(\.\.\/)+/, "");
|
|
546
|
+
if (/^(account|library|shared)\//.test(run.trim()) || rel.includes("..") || rel.startsWith("/"))
|
|
547
|
+
return null;
|
|
548
|
+
return rel || null;
|
|
549
|
+
}
|
|
550
|
+
/** Workspace tool name → its files: `tools/<x>.md`, or a folder tool's whole folder. */
|
|
551
|
+
function toolFiles(paths, read) {
|
|
552
|
+
const out = new Map();
|
|
553
|
+
for (const p of paths) {
|
|
554
|
+
let m = /^tools\/([^/]+)\.md$/.exec(p);
|
|
555
|
+
if (m) {
|
|
556
|
+
const name = String(safeFront(read(p)).name ?? m[1]);
|
|
557
|
+
out.set(name, [p]);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
m = /^tools\/([^/]+)\/tool\.md$/.exec(p);
|
|
561
|
+
if (m) {
|
|
562
|
+
const name = String(safeFront(read(p)).name ?? m[1]);
|
|
563
|
+
out.set(name, paths.filter((q) => q.startsWith(`tools/${m[1]}/`)));
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return out;
|
|
567
|
+
}
|
|
568
|
+
/** Workspace skill name → its files: a skill folder (`skills/x/SKILL.md`
|
|
569
|
+
* and what is beside it) or a single file (`skills/x.md`), as discoverSkills
|
|
570
|
+
* finds them. */
|
|
571
|
+
function skillFiles(paths, read) {
|
|
572
|
+
const out = new Map();
|
|
573
|
+
for (const p of paths) {
|
|
574
|
+
const folder = /^skills\/([^/]+)\/SKILL\.md$/.exec(p);
|
|
575
|
+
if (folder) {
|
|
576
|
+
out.set(String(safeFront(read(p)).name ?? folder[1]), paths.filter((q) => q.startsWith(`skills/${folder[1]}/`)));
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const single = /^skills\/([^/]+)\.md$/.exec(p);
|
|
580
|
+
if (single)
|
|
581
|
+
out.set(String(safeFront(read(p)).name ?? single[1]), [p]);
|
|
582
|
+
}
|
|
583
|
+
return out;
|
|
584
|
+
}
|
|
585
|
+
function agentSkill(paths, read, agent, skill) {
|
|
586
|
+
const prefix = `agents/${agent}/skills/`;
|
|
587
|
+
return paths.some((p) => {
|
|
588
|
+
if (!p.startsWith(prefix))
|
|
589
|
+
return false;
|
|
590
|
+
const rest = p.slice(prefix.length);
|
|
591
|
+
const m = /^([^/]+)\/SKILL\.md$/.exec(rest) ?? /^([^/]+)\.md$/.exec(rest);
|
|
592
|
+
return Boolean(m) && (m[1] === skill || safeFront(read(p)).name === skill);
|
|
593
|
+
});
|
|
594
|
+
}
|
package/dist/src/platform.d.ts
CHANGED
|
@@ -107,6 +107,11 @@ export interface PlatformHooks {
|
|
|
107
107
|
* on its record is resumed rather than destroyed and run again. Default:
|
|
108
108
|
* nothing is. */
|
|
109
109
|
sandboxResumable(kind: string): boolean;
|
|
110
|
+
/** May this account's steps run on the platform's own model credential
|
|
111
|
+
* (the host's key or Claude login)? A hosted platform lets only its
|
|
112
|
+
* operator's accounts do so; every customer brings their own API key.
|
|
113
|
+
* Default: yes — a laptop or a self-hosted install is its own operator. */
|
|
114
|
+
platformKeyAllowed(tenant: string): boolean;
|
|
110
115
|
/**
|
|
111
116
|
* Where the platform's built-in tools sit on disk, materialised at boot —
|
|
112
117
|
* a shelf every account reads, beneath its own library. A name found in
|
package/dist/src/platform.js
CHANGED
|
@@ -30,6 +30,7 @@ const local = {
|
|
|
30
30
|
egress: { lease: async () => null },
|
|
31
31
|
workspaceChanged() { },
|
|
32
32
|
sandboxResumable: () => false,
|
|
33
|
+
platformKeyAllowed: () => true,
|
|
33
34
|
galleryDir: () => null,
|
|
34
35
|
noteOAuthRefresh: async () => undefined,
|
|
35
36
|
fetchUntrusted: (url, init) => fetch(url, init),
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
//
|
|
18
18
|
// It reads the same provider block a run reads, resolved the same way, so a
|
|
19
19
|
// pass here means a run would reach the same place with the same key.
|
|
20
|
+
import { accountModelBlock } from "./model-credential.js";
|
|
20
21
|
import fs from "node:fs";
|
|
21
22
|
import path from "node:path";
|
|
22
23
|
import matter from "gray-matter";
|
|
@@ -199,6 +200,8 @@ export function providersInUse(tenant) {
|
|
|
199
200
|
out.push({ spec, workspace, declaredIn, model: cheapestModel(spec) });
|
|
200
201
|
};
|
|
201
202
|
add(frontmatterOf(path.join(accountDir(tenant), "AGENTS.md")).provider, null, "account");
|
|
203
|
+
// The account's own key from Settings → Model, which needs no block.
|
|
204
|
+
add(accountModelBlock((name) => getSecret(tenant, name)?.value ?? null), null, "account (Settings → Model)");
|
|
202
205
|
for (const ws of safeList(() => listWorkspaces(tenant))) {
|
|
203
206
|
const dir = workspaceDir(tenant, ws.name);
|
|
204
207
|
add(frontmatterOf(path.join(dir, "AGENTS.md")).provider, ws.name, ws.name);
|
|
@@ -201,6 +201,11 @@ export declare function applyContainerChanges(hostWs: string, containerWs: strin
|
|
|
201
201
|
* that fills it.
|
|
202
202
|
*/
|
|
203
203
|
export declare const RUNTIME_CACHE = "/home/agent/.foldrun/runner/.runtimes";
|
|
204
|
+
/** Starts the install helper as root, in the background, before the drop to
|
|
205
|
+
* the agent user: the root half of the `install` tool (install-helper.ts).
|
|
206
|
+
* The agent reaches it only through the socket; it installs only validated
|
|
207
|
+
* Debian package names, and dies with the container. */
|
|
208
|
+
export declare const INSTALL_HELPER_SH = "FOLDRUN_INSTALL_SOCKET=/opt/runner/job/install.sock; export FOLDRUN_INSTALL_SOCKET\nnode /opt/runner/node_modules/@foldrun/core/dist/src/install-helper.js \"$FOLDRUN_INSTALL_SOCKET\" /home/agent/.foldrun/runner/.runtimes >/dev/null 2>&1 &";
|
|
204
209
|
/**
|
|
205
210
|
* Where the platform's shared runtime layer is mounted, read-only, in a run
|
|
206
211
|
* pod — and read-write, one entry at a time, in the pod that builds an entry
|