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