@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.
Files changed (98) hide show
  1. package/README.md +14 -1
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/agent-import.d.ts +21 -0
  5. package/dist/src/agent-import.js +116 -0
  6. package/dist/src/api-tools.js +14 -2
  7. package/dist/src/approvals.d.ts +8 -1
  8. package/dist/src/approvals.js +10 -5
  9. package/dist/src/completions.js +1 -0
  10. package/dist/src/confine.d.ts +1 -0
  11. package/dist/src/confine.js +33 -4
  12. package/dist/src/deploy.d.ts +9 -0
  13. package/dist/src/deploy.js +31 -3
  14. package/dist/src/evals.js +22 -5
  15. package/dist/src/hooks.d.ts +114 -0
  16. package/dist/src/hooks.js +370 -0
  17. package/dist/src/host-env.d.ts +14 -0
  18. package/dist/src/host-env.js +26 -0
  19. package/dist/src/install-helper.d.ts +17 -0
  20. package/dist/src/install-helper.js +134 -0
  21. package/dist/src/install-tool.d.ts +128 -0
  22. package/dist/src/install-tool.js +133 -0
  23. package/dist/src/kinds.d.ts +1 -1
  24. package/dist/src/kinds.js +26 -0
  25. package/dist/src/library.d.ts +1 -1
  26. package/dist/src/library.js +17 -1
  27. package/dist/src/mail.js +1 -1
  28. package/dist/src/model-credential.d.ts +16 -0
  29. package/dist/src/model-credential.js +40 -0
  30. package/dist/src/model-env.d.ts +17 -0
  31. package/dist/src/model-env.js +57 -0
  32. package/dist/src/notify.d.ts +8 -0
  33. package/dist/src/notify.js +10 -1
  34. package/dist/src/package.d.ts +67 -0
  35. package/dist/src/package.js +594 -0
  36. package/dist/src/platform.d.ts +5 -0
  37. package/dist/src/platform.js +1 -0
  38. package/dist/src/provider-health.js +3 -0
  39. package/dist/src/providers.d.ts +5 -0
  40. package/dist/src/providers.js +21 -1
  41. package/dist/src/run-container.d.ts +10 -0
  42. package/dist/src/run-container.js +72 -14
  43. package/dist/src/runner.d.ts +205 -0
  44. package/dist/src/runner.js +273 -36
  45. package/dist/src/runtime.d.ts +77 -0
  46. package/dist/src/runtime.js +230 -5
  47. package/dist/src/script-tools.d.ts +2 -0
  48. package/dist/src/script-tools.js +3 -1
  49. package/dist/src/secrets.d.ts +5 -0
  50. package/dist/src/secrets.js +37 -6
  51. package/dist/src/starter.d.ts +12 -0
  52. package/dist/src/starter.js +38 -4
  53. package/dist/src/step-exec.d.ts +21 -2
  54. package/dist/src/step-exec.js +165 -7
  55. package/dist/src/storage.d.ts +7 -1
  56. package/dist/src/storage.js +14 -1
  57. package/dist/src/store.d.ts +31 -1
  58. package/dist/src/store.js +90 -8
  59. package/dist/src/tool-names.d.ts +3 -1
  60. package/dist/src/tool-names.js +4 -2
  61. package/dist/src/tool-test.js +8 -1
  62. package/dist/src/zip.d.ts +17 -0
  63. package/dist/src/zip.js +184 -0
  64. package/index.ts +3 -0
  65. package/package.json +18 -1
  66. package/src/agent-import.ts +154 -0
  67. package/src/api-tools.ts +16 -1
  68. package/src/approvals.ts +10 -3
  69. package/src/completions.ts +1 -0
  70. package/src/confine.ts +36 -5
  71. package/src/deploy.ts +28 -3
  72. package/src/evals.ts +21 -3
  73. package/src/hooks.ts +406 -0
  74. package/src/host-env.ts +27 -0
  75. package/src/install-helper.ts +143 -0
  76. package/src/install-tool.ts +154 -0
  77. package/src/kinds.ts +28 -0
  78. package/src/library.ts +18 -2
  79. package/src/mail.ts +1 -1
  80. package/src/model-credential.ts +41 -0
  81. package/src/model-env.ts +66 -0
  82. package/src/notify.ts +11 -1
  83. package/src/package.ts +664 -0
  84. package/src/platform.ts +6 -0
  85. package/src/provider-health.ts +3 -0
  86. package/src/providers.ts +27 -1
  87. package/src/run-container.ts +75 -14
  88. package/src/runner.ts +279 -36
  89. package/src/runtime.ts +235 -5
  90. package/src/script-tools.ts +5 -1
  91. package/src/secrets.ts +34 -6
  92. package/src/starter.ts +44 -4
  93. package/src/step-exec.ts +171 -11
  94. package/src/storage.ts +13 -0
  95. package/src/store.ts +89 -7
  96. package/src/tool-names.ts +4 -2
  97. package/src/tool-test.ts +8 -1
  98. 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
+ }
@@ -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
@@ -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);
@@ -346,6 +346,11 @@ export interface BrowseSettings {
346
346
  ignore_https_errors?: boolean;
347
347
  /** The NAME of a vault secret whose value encrypts state= logins at rest. */
348
348
  state_key?: string;
349
+ /** A saved login every call uses by default: loaded from
350
+ * workspace/state/browser/<name>.json and saved back when the call ends.
351
+ * Beside `cookies:` (with `state_key:`), the secret seeds it once and the
352
+ * session the site keeps rotating is what later runs load. */
353
+ state?: string;
349
354
  /** true records every call, so the run page always has the recording to play. */
350
355
  video?: boolean;
351
356
  /** false stops the tool streaming its page to the run page while the step