@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.
Files changed (80) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.js +3 -0
  3. package/dist/src/agent-import.d.ts +21 -0
  4. package/dist/src/agent-import.js +116 -0
  5. package/dist/src/api-tools.js +14 -2
  6. package/dist/src/approvals.d.ts +8 -1
  7. package/dist/src/approvals.js +10 -5
  8. package/dist/src/completions.js +1 -0
  9. package/dist/src/confine.d.ts +1 -0
  10. package/dist/src/confine.js +33 -4
  11. package/dist/src/deploy.d.ts +9 -0
  12. package/dist/src/deploy.js +31 -3
  13. package/dist/src/evals.js +22 -5
  14. package/dist/src/host-env.d.ts +14 -0
  15. package/dist/src/host-env.js +26 -0
  16. package/dist/src/install-helper.d.ts +17 -0
  17. package/dist/src/install-helper.js +134 -0
  18. package/dist/src/install-tool.d.ts +128 -0
  19. package/dist/src/install-tool.js +133 -0
  20. package/dist/src/model-credential.d.ts +16 -0
  21. package/dist/src/model-credential.js +40 -0
  22. package/dist/src/model-env.d.ts +17 -0
  23. package/dist/src/model-env.js +57 -0
  24. package/dist/src/package.d.ts +67 -0
  25. package/dist/src/package.js +594 -0
  26. package/dist/src/platform.d.ts +5 -0
  27. package/dist/src/platform.js +1 -0
  28. package/dist/src/provider-health.js +3 -0
  29. package/dist/src/run-container.d.ts +5 -0
  30. package/dist/src/run-container.js +66 -13
  31. package/dist/src/runner.d.ts +204 -0
  32. package/dist/src/runner.js +257 -36
  33. package/dist/src/runtime.d.ts +77 -0
  34. package/dist/src/runtime.js +230 -5
  35. package/dist/src/script-tools.d.ts +2 -0
  36. package/dist/src/script-tools.js +3 -1
  37. package/dist/src/secrets.d.ts +5 -0
  38. package/dist/src/secrets.js +37 -6
  39. package/dist/src/starter.d.ts +12 -0
  40. package/dist/src/starter.js +36 -4
  41. package/dist/src/step-exec.d.ts +11 -2
  42. package/dist/src/step-exec.js +52 -5
  43. package/dist/src/storage.d.ts +7 -1
  44. package/dist/src/storage.js +14 -1
  45. package/dist/src/store.d.ts +20 -0
  46. package/dist/src/store.js +89 -8
  47. package/dist/src/tool-names.d.ts +3 -1
  48. package/dist/src/tool-names.js +4 -2
  49. package/dist/src/tool-test.js +8 -1
  50. package/dist/src/zip.d.ts +17 -0
  51. package/dist/src/zip.js +184 -0
  52. package/index.ts +3 -0
  53. package/package.json +1 -1
  54. package/src/agent-import.ts +154 -0
  55. package/src/api-tools.ts +16 -1
  56. package/src/approvals.ts +10 -3
  57. package/src/completions.ts +1 -0
  58. package/src/confine.ts +36 -5
  59. package/src/deploy.ts +28 -3
  60. package/src/evals.ts +21 -3
  61. package/src/host-env.ts +27 -0
  62. package/src/install-helper.ts +143 -0
  63. package/src/install-tool.ts +154 -0
  64. package/src/model-credential.ts +41 -0
  65. package/src/model-env.ts +66 -0
  66. package/src/package.ts +664 -0
  67. package/src/platform.ts +6 -0
  68. package/src/provider-health.ts +3 -0
  69. package/src/run-container.ts +65 -12
  70. package/src/runner.ts +264 -36
  71. package/src/runtime.ts +235 -5
  72. package/src/script-tools.ts +5 -1
  73. package/src/secrets.ts +34 -6
  74. package/src/starter.ts +42 -4
  75. package/src/step-exec.ts +56 -8
  76. package/src/storage.ts +13 -0
  77. package/src/store.ts +85 -7
  78. package/src/tool-names.ts +4 -2
  79. package/src/tool-test.ts +8 -1
  80. package/src/zip.ts +189 -0
@@ -0,0 +1,134 @@
1
+ // The root half of the `install` tool: a tiny server the run container's root
2
+ // start-up leaves running before it drops to the agent user.
3
+ //
4
+ // node install-helper.js <socket> <runtime cache dir>
5
+ //
6
+ // The agent never becomes root. It can only ask this process, over a unix
7
+ // socket, to install Debian packages BY NAME — each name re-checked against
8
+ // Debian's naming rule here, so nothing but a package name reaches apt, and
9
+ // apt is driven by the same snippet the start-up uses (systemInstallSh): the
10
+ // account's .deb cache and snapshots, so a package anyone installed before is
11
+ // restored in about a second. Requests are served one at a time; the process
12
+ // dies with the container.
13
+ //
14
+ // pip and npm need no root and are installed by the tool itself, as the agent.
15
+ import fs from "node:fs";
16
+ import net from "node:net";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { spawn } from "node:child_process";
20
+ import { DEB_PACKAGE, systemInstallSh, summarizeInstallFailure } from "./runtime.js";
21
+ /** Validate one request; null when it may run. Exported for the tests. */
22
+ export function refuse(req) {
23
+ const r = req;
24
+ if (!r || typeof r !== "object")
25
+ return "not a request";
26
+ if (r.manager !== "apt")
27
+ return "this helper installs Debian packages (manager: apt) only";
28
+ if (!Array.isArray(r.packages) || r.packages.length === 0)
29
+ return "no packages named";
30
+ if (r.packages.length > 50)
31
+ return "at most 50 packages per request";
32
+ const bad = r.packages.filter((p) => typeof p !== "string" || !DEB_PACKAGE.test(p));
33
+ if (bad.length)
34
+ return `not Debian package names: ${bad.map(String).join(", ")}`;
35
+ return null;
36
+ }
37
+ /** Install one validated request with the start-up's own snippet. */
38
+ export function installApt(packages, cacheDir) {
39
+ const logDir = fs.mkdtempSync(path.join(os.tmpdir(), "foldrun-install-"));
40
+ return new Promise((resolve) => {
41
+ const child = spawn("sh", ["-c", systemInstallSh(cacheDir, logDir)], {
42
+ env: { ...process.env, FOLDRUN_SYSTEM_PACKAGES: packages.join(" ") },
43
+ stdio: "ignore",
44
+ });
45
+ child.on("close", () => {
46
+ let status = {};
47
+ try {
48
+ status = JSON.parse(fs.readFileSync(path.join(logDir, "system-install.json"), "utf8"));
49
+ }
50
+ catch {
51
+ status = { ok: false };
52
+ }
53
+ let log = "";
54
+ try {
55
+ log = fs.readFileSync(path.join(logDir, "system-install.log"), "utf8");
56
+ }
57
+ catch {
58
+ log = "";
59
+ }
60
+ fs.rmSync(logDir, { recursive: true, force: true });
61
+ resolve(status.ok
62
+ ? { ok: true, mode: status.mode, seconds: status.seconds }
63
+ : { ok: false, error: summarizeInstallFailure("apt-get install", log || "no output") });
64
+ });
65
+ child.on("error", (err) => {
66
+ fs.rmSync(logDir, { recursive: true, force: true });
67
+ resolve({ ok: false, error: `could not run the installer: ${err.message}` });
68
+ });
69
+ });
70
+ }
71
+ export function serve(socket, cacheDir) {
72
+ try {
73
+ fs.rmSync(socket, { force: true });
74
+ }
75
+ catch {
76
+ /* nothing there */
77
+ }
78
+ let queue = Promise.resolve();
79
+ const server = net.createServer((conn) => {
80
+ let buf = "";
81
+ conn.on("data", (chunk) => {
82
+ buf += chunk.toString("utf8");
83
+ if (buf.length > 64_000) {
84
+ conn.end(JSON.stringify({ ok: false, error: "request too large" }) + "\n");
85
+ return;
86
+ }
87
+ const nl = buf.indexOf("\n");
88
+ if (nl < 0)
89
+ return;
90
+ const line = buf.slice(0, nl);
91
+ buf = "";
92
+ let req;
93
+ try {
94
+ req = JSON.parse(line);
95
+ }
96
+ catch {
97
+ conn.end(JSON.stringify({ ok: false, error: "not JSON" }) + "\n");
98
+ return;
99
+ }
100
+ const why = refuse(req);
101
+ if (why) {
102
+ conn.end(JSON.stringify({ ok: false, error: why }) + "\n");
103
+ return;
104
+ }
105
+ // One install at a time: apt holds a lock, and two dpkg runs in one
106
+ // container would fight over it.
107
+ queue = queue.then(async () => {
108
+ const reply = await installApt(req.packages, cacheDir);
109
+ conn.end(JSON.stringify(reply) + "\n");
110
+ });
111
+ });
112
+ conn.on("error", () => {
113
+ /* the client went away */
114
+ });
115
+ });
116
+ server.listen(socket, () => {
117
+ // The agent user must be able to connect; nothing else runs in here.
118
+ try {
119
+ fs.chmodSync(socket, 0o666);
120
+ }
121
+ catch {
122
+ /* best effort */
123
+ }
124
+ });
125
+ return server;
126
+ }
127
+ if (process.argv[1] && /install-helper\.(js|ts|mjs)$/.test(process.argv[1])) {
128
+ const [socket, cacheDir] = process.argv.slice(2);
129
+ if (!socket || !cacheDir) {
130
+ console.error("usage: install-helper <socket> <runtime cache dir>");
131
+ process.exit(2);
132
+ }
133
+ serve(socket, cacheDir);
134
+ }
@@ -0,0 +1,128 @@
1
+ type Emit = (type: "text" | "tool" | "info" | "error", text: string) => void;
2
+ export declare const INSTALL_TOOL = "mcp__foldrun_install__install";
3
+ /** Ask the root helper; resolves with its reply or an error. */
4
+ export declare function askHelper(socket: string, packages: string[], timeoutMs?: number): Promise<{
5
+ ok: boolean;
6
+ mode?: string;
7
+ seconds?: number;
8
+ error?: string;
9
+ }>;
10
+ export declare function buildInstallTool(opts: {
11
+ /** The per-step directory pip and npm install into (stepInstallEnv's). */
12
+ dir: string;
13
+ /** The root helper's socket; null where there is none (a laptop). */
14
+ socket: string | null;
15
+ /** The account's runtime cache, for uv's and npm's download caches. */
16
+ cacheRoot: string;
17
+ /** The interpreter pip installs for: the declared venv's, else python3. */
18
+ python: string;
19
+ env: Record<string, string | undefined>;
20
+ emit: Emit;
21
+ }): {
22
+ server: import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
23
+ toolName: string;
24
+ /** What this step installed, `manager:name`, for the run record. */
25
+ installed: () => string[];
26
+ /** The handler, for tests. */
27
+ call: (args: {
28
+ manager: "apt" | "pip" | "npm";
29
+ packages: string[];
30
+ }) => Promise<{
31
+ [x: string]: unknown;
32
+ _meta?: {
33
+ [x: string]: unknown;
34
+ progressToken?: string | number | undefined;
35
+ "io.modelcontextprotocol/related-task"?: {
36
+ taskId: string;
37
+ } | undefined;
38
+ } | undefined;
39
+ content: ({
40
+ type: "text";
41
+ text: string;
42
+ annotations?: {
43
+ audience?: ("assistant" | "user")[] | undefined;
44
+ priority?: number | undefined;
45
+ lastModified?: string | undefined;
46
+ } | undefined;
47
+ _meta?: {
48
+ [x: string]: unknown;
49
+ } | undefined;
50
+ } | {
51
+ type: "image";
52
+ data: string;
53
+ mimeType: string;
54
+ annotations?: {
55
+ audience?: ("assistant" | "user")[] | undefined;
56
+ priority?: number | undefined;
57
+ lastModified?: string | undefined;
58
+ } | undefined;
59
+ _meta?: {
60
+ [x: string]: unknown;
61
+ } | undefined;
62
+ } | {
63
+ type: "audio";
64
+ data: string;
65
+ mimeType: string;
66
+ annotations?: {
67
+ audience?: ("assistant" | "user")[] | undefined;
68
+ priority?: number | undefined;
69
+ lastModified?: string | undefined;
70
+ } | undefined;
71
+ _meta?: {
72
+ [x: string]: unknown;
73
+ } | undefined;
74
+ } | {
75
+ uri: string;
76
+ description?: string | undefined;
77
+ mimeType?: string | undefined;
78
+ size?: number | undefined;
79
+ annotations?: {
80
+ audience?: ("assistant" | "user")[] | undefined;
81
+ priority?: number | undefined;
82
+ lastModified?: string | undefined;
83
+ } | undefined;
84
+ _meta?: {
85
+ [x: string]: unknown;
86
+ } | undefined;
87
+ icons?: {
88
+ src: string;
89
+ mimeType?: string | undefined;
90
+ sizes?: string[] | undefined;
91
+ theme?: "dark" | "light" | undefined;
92
+ }[] | undefined;
93
+ name: string;
94
+ title?: string | undefined;
95
+ type: "resource_link";
96
+ } | {
97
+ type: "resource";
98
+ resource: {
99
+ uri: string;
100
+ mimeType?: string | undefined;
101
+ _meta?: {
102
+ [x: string]: unknown;
103
+ } | undefined;
104
+ text: string;
105
+ } | {
106
+ uri: string;
107
+ mimeType?: string | undefined;
108
+ _meta?: {
109
+ [x: string]: unknown;
110
+ } | undefined;
111
+ blob: string;
112
+ };
113
+ annotations?: {
114
+ audience?: ("assistant" | "user")[] | undefined;
115
+ priority?: number | undefined;
116
+ lastModified?: string | undefined;
117
+ } | undefined;
118
+ _meta?: {
119
+ [x: string]: unknown;
120
+ } | undefined;
121
+ })[];
122
+ structuredContent?: {
123
+ [x: string]: unknown;
124
+ } | undefined;
125
+ isError?: boolean | undefined;
126
+ }>;
127
+ };
128
+ export {};
@@ -0,0 +1,133 @@
1
+ // The `install` tool: an agent installs what it finds it needs, mid-step,
2
+ // without anyone having declared it. Built in and granted to every agent
3
+ // (take it away with `disallowedTools: [install]`).
4
+ //
5
+ // install(manager: "apt", packages: ["ffmpeg"]) — Debian programs and libraries
6
+ // install(manager: "pip", packages: ["manim>=0.19"]) — Python packages
7
+ // install(manager: "npm", packages: ["sharp"]) — Node packages
8
+ //
9
+ // apt needs root, which the agent never has: it asks the root start-up's
10
+ // install helper over a unix socket (install-helper.ts), which installs only
11
+ // validated names through the same cache and snapshots as `runtime: system:`.
12
+ // pip and npm need no root and run here, as the agent, into a per-step
13
+ // directory already on PATH / PYTHONPATH / NODE_PATH (stepInstallEnv), so the
14
+ // next command finds what this one installed. Inside a run container all of
15
+ // it goes when the container does. Every install is logged with the line to
16
+ // add to the agent's `runtime:` to have it before the step starts next time.
17
+ import fs from "node:fs";
18
+ import net from "node:net";
19
+ import path from "node:path";
20
+ import { spawnSync } from "node:child_process";
21
+ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
22
+ import { z } from "zod";
23
+ import { DEB_PACKAGE, PIP_REQUIREMENT, NPM_REQUIREMENT, summarizeInstallFailure } from "./runtime.js";
24
+ export const INSTALL_TOOL = "mcp__foldrun_install__install";
25
+ const PATTERNS = { apt: DEB_PACKAGE, pip: PIP_REQUIREMENT, npm: NPM_REQUIREMENT };
26
+ const RUNTIME_KEY = { apt: "system", pip: "packages", npm: "npm" };
27
+ function findUv() {
28
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
29
+ if (dir && fs.existsSync(path.join(dir, "uv")))
30
+ return path.join(dir, "uv");
31
+ }
32
+ return null;
33
+ }
34
+ /** Ask the root helper; resolves with its reply or an error. */
35
+ export function askHelper(socket, packages, timeoutMs = 600_000) {
36
+ return new Promise((resolve) => {
37
+ const conn = net.createConnection(socket);
38
+ let buf = "";
39
+ const timer = setTimeout(() => {
40
+ conn.destroy();
41
+ resolve({ ok: false, error: `the install helper did not answer in ${Math.round(timeoutMs / 1000)}s` });
42
+ }, timeoutMs);
43
+ conn.on("connect", () => conn.write(JSON.stringify({ manager: "apt", packages }) + "\n"));
44
+ conn.on("data", (c) => (buf += c.toString("utf8")));
45
+ conn.on("end", () => {
46
+ clearTimeout(timer);
47
+ try {
48
+ resolve(JSON.parse(buf.trim()));
49
+ }
50
+ catch {
51
+ resolve({ ok: false, error: "the install helper sent no answer" });
52
+ }
53
+ });
54
+ conn.on("error", (err) => {
55
+ clearTimeout(timer);
56
+ resolve({ ok: false, error: `the install helper is not reachable (${err.message})` });
57
+ });
58
+ });
59
+ }
60
+ export function buildInstallTool(opts) {
61
+ const installed = [];
62
+ const text = (t, isError = false) => ({ content: [{ type: "text", text: t }], ...(isError ? { isError: true } : {}) });
63
+ const install = tool("install", "Install a program or library this step needs and does not have: Debian packages with manager \"apt\" (ffmpeg, imagemagick, tesseract-ocr, libcairo2-dev, build-essential), Python packages with \"pip\", Node packages with \"npm\". Use this instead of apt-get, pip or npm in Bash — you are not root and those would fail. What you install is usable by your next command (on PATH, importable) and lasts for this step. Name real packages only; versions as the manager writes them (\"pandas>=2\", \"sharp@^0.34\").", {
64
+ manager: z.enum(["apt", "pip", "npm"]).describe("apt for Debian programs and system libraries, pip for Python, npm for Node"),
65
+ packages: z.array(z.string()).min(1).max(30).describe("Package names, e.g. [\"ffmpeg\"] or [\"manim\", \"numpy<2\"]"),
66
+ }, async ({ manager, packages }) => {
67
+ const names = packages.map((p) => String(p).trim()).filter(Boolean);
68
+ const bad = names.filter((p) => !PATTERNS[manager].test(p));
69
+ if (bad.length)
70
+ return text(`Not ${manager} package names: ${bad.join(", ")}. Nothing was installed.`, true);
71
+ const t0 = Date.now();
72
+ let how = "";
73
+ if (manager === "apt") {
74
+ if (!opts.socket || !fs.existsSync(opts.socket)) {
75
+ return text("Debian packages can only be installed inside a run container, and this step is not running in one. Nothing was installed.", true);
76
+ }
77
+ const r = await askHelper(opts.socket, names);
78
+ if (!r.ok) {
79
+ opts.emit("error", `install: apt ${names.join(" ")} — ${String(r.error).split("\n")[0]}`);
80
+ return text(r.error ?? "apt failed", true);
81
+ }
82
+ how = `${r.mode ?? "apt"}, ${r.seconds ?? Math.round((Date.now() - t0) / 1000)}s`;
83
+ }
84
+ else if (manager === "pip") {
85
+ const target = path.join(opts.dir, "py");
86
+ fs.mkdirSync(target, { recursive: true });
87
+ const uv = findUv();
88
+ const res = uv
89
+ ? spawnSync(uv, ["pip", "install", "-q", "--python", opts.python, "--target", target, ...names], {
90
+ env: { ...opts.env, UV_CACHE_DIR: path.join(opts.cacheRoot, ".uv-cache"), UV_LINK_MODE: "copy", UV_NO_PROGRESS: "1" },
91
+ encoding: "utf8",
92
+ timeout: 600_000,
93
+ })
94
+ : spawnSync(opts.python, ["-m", "pip", "install", "-q", "--disable-pip-version-check", "--target", target, ...names], {
95
+ env: opts.env,
96
+ encoding: "utf8",
97
+ timeout: 600_000,
98
+ });
99
+ if (res.status !== 0) {
100
+ const msg = summarizeInstallFailure(uv ? "uv pip install" : "pip install", `${res.stdout ?? ""}${res.stderr ?? ""}${res.error ? res.error.message : ""}`);
101
+ opts.emit("error", `install: pip ${names.join(" ")} — ${msg.split("\n")[0]}`);
102
+ return text(msg, true);
103
+ }
104
+ how = `pip, ${Math.round((Date.now() - t0) / 1000)}s`;
105
+ }
106
+ else {
107
+ const prefix = path.join(opts.dir, "npm");
108
+ fs.mkdirSync(prefix, { recursive: true });
109
+ const res = spawnSync("npm", ["install", "--no-fund", "--no-audit", "--no-progress", "--prefix", prefix, ...names], {
110
+ env: { ...opts.env, npm_config_cache: path.join(opts.cacheRoot, ".npm-cache") },
111
+ encoding: "utf8",
112
+ timeout: 600_000,
113
+ });
114
+ if (res.status !== 0) {
115
+ const msg = summarizeInstallFailure("npm install", `${res.stdout ?? ""}${res.stderr ?? ""}${res.error ? res.error.message : ""}`);
116
+ opts.emit("error", `install: npm ${names.join(" ")} — ${msg.split("\n")[0]}`);
117
+ return text(msg, true);
118
+ }
119
+ how = `npm, ${Math.round((Date.now() - t0) / 1000)}s`;
120
+ }
121
+ installed.push(...names.map((n) => `${manager}:${n}`));
122
+ opts.emit("info", `install: ${manager} ${names.join(" ")} — ready (${how}). To have it before the step starts: runtime: ${RUNTIME_KEY[manager]}: [${names.join(", ")}]`);
123
+ return text(`Installed ${names.join(", ")} (${manager}). Usable now in this step — run it or import it directly.`);
124
+ });
125
+ return {
126
+ server: createSdkMcpServer({ name: "foldrun_install", version: "1.0.0", tools: [install] }),
127
+ toolName: INSTALL_TOOL,
128
+ /** What this step installed, `manager:name`, for the run record. */
129
+ installed: () => [...installed],
130
+ /** The handler, for tests. */
131
+ call: (args) => install.handler(args, {}),
132
+ };
133
+ }
@@ -12,3 +12,19 @@ export declare function credentialLine(c: ModelCredential): {
12
12
  type: "info" | "error";
13
13
  text: string;
14
14
  };
15
+ /** The account vault names the account's own model key lives under. */
16
+ export declare const ACCOUNT_MODEL_SECRETS: {
17
+ readonly provider: "FOLDRUN_MODEL_PROVIDER";
18
+ readonly key: "FOLDRUN_MODEL_API_KEY";
19
+ readonly baseUrl: "FOLDRUN_MODEL_BASE_URL";
20
+ readonly format: "FOLDRUN_MODEL_FORMAT";
21
+ };
22
+ /** A provider: block built from the account's own model secrets, or
23
+ * undefined when it has set no key. `read` is the account-scope secret
24
+ * reader (getSecret without a workspace). */
25
+ export declare function accountModelBlock(read: (name: string) => string | null): Record<string, unknown> | undefined;
26
+ /** A Claude login token (`claude setup-token`, sk-ant-oat…), not an API key.
27
+ * Anthropic's terms do not let a third-party product run customers on
28
+ * claude.ai logins, so on a hosted platform only the operator's own
29
+ * accounts may; a customer's key must be an API key. */
30
+ export declare function isClaudeLoginToken(value: string | null | undefined): boolean;
@@ -56,3 +56,43 @@ export function credentialLine(c) {
56
56
  "Set ANTHROPIC_API_KEY, or CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token`",
57
57
  };
58
58
  }
59
+ // ---------------------------------------------------------------- the account's own key
60
+ //
61
+ // An account brings its own model key without writing a provider: block:
62
+ // three secrets in the ACCOUNT vault (set from Settings → Model, the API's
63
+ // /api/account/model, or `foldrun model set`). A provider: block at any
64
+ // level still wins; this is what a step falls to when none names one, before
65
+ // the platform's own credential.
66
+ /** The account vault names the account's own model key lives under. */
67
+ export const ACCOUNT_MODEL_SECRETS = {
68
+ provider: "FOLDRUN_MODEL_PROVIDER",
69
+ key: "FOLDRUN_MODEL_API_KEY",
70
+ baseUrl: "FOLDRUN_MODEL_BASE_URL",
71
+ format: "FOLDRUN_MODEL_FORMAT",
72
+ };
73
+ /** A provider: block built from the account's own model secrets, or
74
+ * undefined when it has set no key. `read` is the account-scope secret
75
+ * reader (getSecret without a workspace). */
76
+ export function accountModelBlock(read) {
77
+ const key = read(ACCOUNT_MODEL_SECRETS.key);
78
+ if (!key)
79
+ return undefined;
80
+ const block = { token: `\${${ACCOUNT_MODEL_SECRETS.key}}` };
81
+ const name = read(ACCOUNT_MODEL_SECRETS.provider);
82
+ const baseUrl = read(ACCOUNT_MODEL_SECRETS.baseUrl);
83
+ const format = read(ACCOUNT_MODEL_SECRETS.format);
84
+ if (name)
85
+ block.name = name;
86
+ if (baseUrl)
87
+ block.base_url = baseUrl;
88
+ if (format)
89
+ block.format = format;
90
+ return block;
91
+ }
92
+ /** A Claude login token (`claude setup-token`, sk-ant-oat…), not an API key.
93
+ * Anthropic's terms do not let a third-party product run customers on
94
+ * claude.ai logins, so on a hosted platform only the operator's own
95
+ * accounts may; a customer's key must be an API key. */
96
+ export function isClaudeLoginToken(value) {
97
+ return typeof value === "string" && /^sk-ant-oat\d*-/.test(value.trim());
98
+ }
@@ -0,0 +1,17 @@
1
+ /** Refused for want of a usable key; `status` is what an HTTP layer answers. */
2
+ export declare class ModelKeyError extends Error {
3
+ readonly status: number;
4
+ constructor(message: string, status?: number);
5
+ }
6
+ export declare const NO_MODEL_KEY: string;
7
+ /**
8
+ * `platform` is a function: the caller's own way to build the platform's env
9
+ * (a step's, or the SDK's host env), called only when this account may use
10
+ * it. `format` says what the endpoint speaks — a caller with no translator
11
+ * refuses anything but anthropic.
12
+ */
13
+ export declare function accountModelEnv(tenant: string, workspace: string, platformEnv: () => Record<string, string | undefined>): {
14
+ env: Record<string, string | undefined>;
15
+ supply: "provider" | "platform";
16
+ format: string;
17
+ };
@@ -0,0 +1,57 @@
1
+ // The model environment for work an ACCOUNT asks for outside a step: an eval's
2
+ // judge, the dashboard's "draft a flow with AI". Resolved the way a step
3
+ // resolves it — the workspace's provider: block, else the account's, else the
4
+ // account's own key from Settings → Model — and only then the platform's own
5
+ // credential, which a hosted platform keeps for its operator's accounts
6
+ // (platform.platformKeyAllowed). Every other account brings an API key;
7
+ // asking for the platform's here is refused, not quietly billed to it.
8
+ import { accountDir, workspaceDir, parseProvider, providerEnvFor } from "./store.js";
9
+ import { readAgentsMd } from "./runner.js";
10
+ import { getSecret } from "./secrets.js";
11
+ import { platform } from "./platform.js";
12
+ import { accountModelBlock, isClaudeLoginToken } from "./model-credential.js";
13
+ /** Refused for want of a usable key; `status` is what an HTTP layer answers. */
14
+ export class ModelKeyError extends Error {
15
+ status;
16
+ constructor(message, status = 402) {
17
+ super(message);
18
+ this.status = status;
19
+ }
20
+ }
21
+ export const NO_MODEL_KEY = "no model key: this account runs on its own API key, and none is set — add one in Settings → Model " +
22
+ "(or `foldrun model set <provider> --key …`), or write a provider: block in AGENTS.md";
23
+ /**
24
+ * `platform` is a function: the caller's own way to build the platform's env
25
+ * (a step's, or the SDK's host env), called only when this account may use
26
+ * it. `format` says what the endpoint speaks — a caller with no translator
27
+ * refuses anything but anthropic.
28
+ */
29
+ export function accountModelEnv(tenant, workspace, platformEnv) {
30
+ const raw = readAgentsMd(workspaceDir(tenant, workspace))?.data?.provider ??
31
+ readAgentsMd(accountDir(tenant))?.data?.provider ??
32
+ accountModelBlock((name) => getSecret(tenant, name)?.value ?? null);
33
+ const spec = parseProvider(raw);
34
+ if (!spec?.baseUrl) {
35
+ if (!platform.platformKeyAllowed(tenant))
36
+ throw new ModelKeyError(NO_MODEL_KEY);
37
+ return { env: platformEnv(), supply: "platform", format: "anthropic" };
38
+ }
39
+ const sub = (text) => text.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => getSecret(tenant, name, workspace)?.value ?? whole);
40
+ const token = sub(spec.token);
41
+ if (token.includes("${"))
42
+ throw new ModelKeyError("the provider's token secret is not set — `foldrun secrets` sets it", 422);
43
+ if (!platform.platformKeyAllowed(tenant) && isClaudeLoginToken(token)) {
44
+ throw new ModelKeyError("model key: a Claude login token (sk-ant-oat…) cannot be used here — use an API key from your provider", 422);
45
+ }
46
+ return {
47
+ env: providerEnvFor({
48
+ baseUrl: spec.baseUrl,
49
+ token,
50
+ auth: spec.auth,
51
+ models: spec.models,
52
+ headers: Object.fromEntries(Object.entries(spec.headers).map(([k, v]) => [k, sub(v)])),
53
+ }),
54
+ supply: "provider",
55
+ format: spec.format,
56
+ };
57
+ }
@@ -0,0 +1,67 @@
1
+ import { type DeployFile } from "./store.ts";
2
+ export type PackageKind = "workspace" | "flow" | "agent";
3
+ export interface PackageManifest {
4
+ format: "foldrun-package";
5
+ version: 1;
6
+ kind: PackageKind;
7
+ /** The workspace's, flow's or agent's name. */
8
+ name: string;
9
+ /** The workspace it was exported from. */
10
+ workspace: string;
11
+ exportedAt: string;
12
+ files: string[];
13
+ }
14
+ export interface Package {
15
+ manifest: PackageManifest;
16
+ files: DeployFile[];
17
+ /** Entries in the zip that are not authored source, left out (readPackage). */
18
+ skipped?: string[];
19
+ }
20
+ /** An error the API layer turns into a status rather than a 500. */
21
+ export declare class PackageError extends Error {
22
+ readonly status: number;
23
+ constructor(message: string, status: number);
24
+ }
25
+ export declare const MANIFEST_FILE = "foldrun-package.json";
26
+ export declare function isPackagedPath(rel: string): boolean;
27
+ export declare function packageOf(tenant: string, workspace: string, kind: PackageKind, name?: string): Package;
28
+ export declare function packageZip(pkg: Package): Buffer;
29
+ /** What the download is called: `<workspace>.zip`, `<workspace>-flow-<name>.zip`. */
30
+ export declare function packageFilename(m: PackageManifest): string;
31
+ export declare function readPackage(buf: Buffer): Package;
32
+ export interface ImportPlan {
33
+ kind: PackageKind;
34
+ name: string;
35
+ /** Where it came from, as the package says. */
36
+ from: string;
37
+ workspace: string;
38
+ /** True when the workspace does not exist yet and the import makes it. */
39
+ creates: boolean;
40
+ added: string[];
41
+ /** Files that exist with different text — written only with `overwrite`. */
42
+ overwritten: string[];
43
+ unchanged: string[];
44
+ /** In the zip, not written: memory, state, .gitignore, anything outside
45
+ * the authored folders. */
46
+ skipped: string[];
47
+ /** What the imported files name that the target cannot provide. The import
48
+ * still happens — a missing secret is set in a minute. */
49
+ needs: {
50
+ secrets: string[];
51
+ tools: string[];
52
+ skills: string[];
53
+ scripts: string[];
54
+ agents: string[];
55
+ flows: string[];
56
+ };
57
+ }
58
+ export interface ImportResult extends ImportPlan {
59
+ written: string[];
60
+ revision: string | null;
61
+ }
62
+ export declare function planImport(tenant: string, workspace: string, pkg: Package): ImportPlan;
63
+ export declare function applyImport(tenant: string, workspace: string, pkg: Package, opts?: {
64
+ overwrite?: boolean;
65
+ by?: string;
66
+ expect?: "create" | "merge";
67
+ }): ImportResult;