@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,154 @@
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
+
18
+ import fs from "node:fs";
19
+ import net from "node:net";
20
+ import path from "node:path";
21
+ import { spawnSync } from "node:child_process";
22
+ import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
23
+ import { z } from "zod";
24
+ import { DEB_PACKAGE, PIP_REQUIREMENT, NPM_REQUIREMENT, summarizeInstallFailure } from "./runtime.ts";
25
+
26
+ type Emit = (type: "text" | "tool" | "info" | "error", text: string) => void;
27
+
28
+ export const INSTALL_TOOL = "mcp__foldrun_install__install";
29
+
30
+ const PATTERNS = { apt: DEB_PACKAGE, pip: PIP_REQUIREMENT, npm: NPM_REQUIREMENT } as const;
31
+ const RUNTIME_KEY = { apt: "system", pip: "packages", npm: "npm" } as const;
32
+
33
+ function findUv(): string | null {
34
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
35
+ if (dir && fs.existsSync(path.join(dir, "uv"))) return path.join(dir, "uv");
36
+ }
37
+ return null;
38
+ }
39
+
40
+ /** Ask the root helper; resolves with its reply or an error. */
41
+ export function askHelper(socket: string, packages: string[], timeoutMs = 600_000): Promise<{ ok: boolean; mode?: string; seconds?: number; error?: string }> {
42
+ return new Promise((resolve) => {
43
+ const conn = net.createConnection(socket);
44
+ let buf = "";
45
+ const timer = setTimeout(() => {
46
+ conn.destroy();
47
+ resolve({ ok: false, error: `the install helper did not answer in ${Math.round(timeoutMs / 1000)}s` });
48
+ }, timeoutMs);
49
+ conn.on("connect", () => conn.write(JSON.stringify({ manager: "apt", packages }) + "\n"));
50
+ conn.on("data", (c) => (buf += c.toString("utf8")));
51
+ conn.on("end", () => {
52
+ clearTimeout(timer);
53
+ try {
54
+ resolve(JSON.parse(buf.trim()));
55
+ } catch {
56
+ resolve({ ok: false, error: "the install helper sent no answer" });
57
+ }
58
+ });
59
+ conn.on("error", (err) => {
60
+ clearTimeout(timer);
61
+ resolve({ ok: false, error: `the install helper is not reachable (${err.message})` });
62
+ });
63
+ });
64
+ }
65
+
66
+ export function buildInstallTool(opts: {
67
+ /** The per-step directory pip and npm install into (stepInstallEnv's). */
68
+ dir: string;
69
+ /** The root helper's socket; null where there is none (a laptop). */
70
+ socket: string | null;
71
+ /** The account's runtime cache, for uv's and npm's download caches. */
72
+ cacheRoot: string;
73
+ /** The interpreter pip installs for: the declared venv's, else python3. */
74
+ python: string;
75
+ env: Record<string, string | undefined>;
76
+ emit: Emit;
77
+ }) {
78
+ const installed: string[] = [];
79
+ const text = (t: string, isError = false) => ({ content: [{ type: "text" as const, text: t }], ...(isError ? { isError: true } : {}) });
80
+
81
+ const install = tool(
82
+ "install",
83
+ "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\").",
84
+ {
85
+ manager: z.enum(["apt", "pip", "npm"]).describe("apt for Debian programs and system libraries, pip for Python, npm for Node"),
86
+ packages: z.array(z.string()).min(1).max(30).describe("Package names, e.g. [\"ffmpeg\"] or [\"manim\", \"numpy<2\"]"),
87
+ },
88
+ async ({ manager, packages }) => {
89
+ const names = packages.map((p) => String(p).trim()).filter(Boolean);
90
+ const bad = names.filter((p) => !PATTERNS[manager].test(p));
91
+ if (bad.length) return text(`Not ${manager} package names: ${bad.join(", ")}. Nothing was installed.`, true);
92
+ const t0 = Date.now();
93
+ let how = "";
94
+ if (manager === "apt") {
95
+ if (!opts.socket || !fs.existsSync(opts.socket)) {
96
+ return text("Debian packages can only be installed inside a run container, and this step is not running in one. Nothing was installed.", true);
97
+ }
98
+ const r = await askHelper(opts.socket, names);
99
+ if (!r.ok) {
100
+ opts.emit("error", `install: apt ${names.join(" ")} — ${String(r.error).split("\n")[0]}`);
101
+ return text(r.error ?? "apt failed", true);
102
+ }
103
+ how = `${r.mode ?? "apt"}, ${r.seconds ?? Math.round((Date.now() - t0) / 1000)}s`;
104
+ } else if (manager === "pip") {
105
+ const target = path.join(opts.dir, "py");
106
+ fs.mkdirSync(target, { recursive: true });
107
+ const uv = findUv();
108
+ const res = uv
109
+ ? spawnSync(uv, ["pip", "install", "-q", "--python", opts.python, "--target", target, ...names], {
110
+ env: { ...opts.env, UV_CACHE_DIR: path.join(opts.cacheRoot, ".uv-cache"), UV_LINK_MODE: "copy", UV_NO_PROGRESS: "1" } as unknown as NodeJS.ProcessEnv,
111
+ encoding: "utf8",
112
+ timeout: 600_000,
113
+ })
114
+ : spawnSync(opts.python, ["-m", "pip", "install", "-q", "--disable-pip-version-check", "--target", target, ...names], {
115
+ env: opts.env as unknown as NodeJS.ProcessEnv,
116
+ encoding: "utf8",
117
+ timeout: 600_000,
118
+ });
119
+ if (res.status !== 0) {
120
+ const msg = summarizeInstallFailure(uv ? "uv pip install" : "pip install", `${res.stdout ?? ""}${res.stderr ?? ""}${res.error ? res.error.message : ""}`);
121
+ opts.emit("error", `install: pip ${names.join(" ")} — ${msg.split("\n")[0]}`);
122
+ return text(msg, true);
123
+ }
124
+ how = `pip, ${Math.round((Date.now() - t0) / 1000)}s`;
125
+ } else {
126
+ const prefix = path.join(opts.dir, "npm");
127
+ fs.mkdirSync(prefix, { recursive: true });
128
+ const res = spawnSync("npm", ["install", "--no-fund", "--no-audit", "--no-progress", "--prefix", prefix, ...names], {
129
+ env: { ...opts.env, npm_config_cache: path.join(opts.cacheRoot, ".npm-cache") } as unknown as NodeJS.ProcessEnv,
130
+ encoding: "utf8",
131
+ timeout: 600_000,
132
+ });
133
+ if (res.status !== 0) {
134
+ const msg = summarizeInstallFailure("npm install", `${res.stdout ?? ""}${res.stderr ?? ""}${res.error ? res.error.message : ""}`);
135
+ opts.emit("error", `install: npm ${names.join(" ")} — ${msg.split("\n")[0]}`);
136
+ return text(msg, true);
137
+ }
138
+ how = `npm, ${Math.round((Date.now() - t0) / 1000)}s`;
139
+ }
140
+ installed.push(...names.map((n) => `${manager}:${n}`));
141
+ opts.emit("info", `install: ${manager} ${names.join(" ")} — ready (${how}). To have it before the step starts: runtime: ${RUNTIME_KEY[manager]}: [${names.join(", ")}]`);
142
+ return text(`Installed ${names.join(", ")} (${manager}). Usable now in this step — run it or import it directly.`);
143
+ },
144
+ );
145
+
146
+ return {
147
+ server: createSdkMcpServer({ name: "foldrun_install", version: "1.0.0", tools: [install] }),
148
+ toolName: INSTALL_TOOL,
149
+ /** What this step installed, `manager:name`, for the run record. */
150
+ installed: () => [...installed],
151
+ /** The handler, for tests. */
152
+ call: (args: { manager: "apt" | "pip" | "npm"; packages: string[] }) => install.handler(args, {}),
153
+ };
154
+ }
@@ -69,3 +69,44 @@ export function credentialLine(c: ModelCredential): { type: "info" | "error"; te
69
69
  "Set ANTHROPIC_API_KEY, or CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token`",
70
70
  };
71
71
  }
72
+
73
+
74
+ // ---------------------------------------------------------------- the account's own key
75
+ //
76
+ // An account brings its own model key without writing a provider: block:
77
+ // three secrets in the ACCOUNT vault (set from Settings → Model, the API's
78
+ // /api/account/model, or `foldrun model set`). A provider: block at any
79
+ // level still wins; this is what a step falls to when none names one, before
80
+ // the platform's own credential.
81
+
82
+ /** The account vault names the account's own model key lives under. */
83
+ export const ACCOUNT_MODEL_SECRETS = {
84
+ provider: "FOLDRUN_MODEL_PROVIDER",
85
+ key: "FOLDRUN_MODEL_API_KEY",
86
+ baseUrl: "FOLDRUN_MODEL_BASE_URL",
87
+ format: "FOLDRUN_MODEL_FORMAT",
88
+ } as const;
89
+
90
+ /** A provider: block built from the account's own model secrets, or
91
+ * undefined when it has set no key. `read` is the account-scope secret
92
+ * reader (getSecret without a workspace). */
93
+ export function accountModelBlock(read: (name: string) => string | null): Record<string, unknown> | undefined {
94
+ const key = read(ACCOUNT_MODEL_SECRETS.key);
95
+ if (!key) return undefined;
96
+ const block: Record<string, unknown> = { token: `\${${ACCOUNT_MODEL_SECRETS.key}}` };
97
+ const name = read(ACCOUNT_MODEL_SECRETS.provider);
98
+ const baseUrl = read(ACCOUNT_MODEL_SECRETS.baseUrl);
99
+ const format = read(ACCOUNT_MODEL_SECRETS.format);
100
+ if (name) block.name = name;
101
+ if (baseUrl) block.base_url = baseUrl;
102
+ if (format) block.format = format;
103
+ return block;
104
+ }
105
+
106
+ /** A Claude login token (`claude setup-token`, sk-ant-oat…), not an API key.
107
+ * Anthropic's terms do not let a third-party product run customers on
108
+ * claude.ai logins, so on a hosted platform only the operator's own
109
+ * accounts may; a customer's key must be an API key. */
110
+ export function isClaudeLoginToken(value: string | null | undefined): boolean {
111
+ return typeof value === "string" && /^sk-ant-oat\d*-/.test(value.trim());
112
+ }
@@ -0,0 +1,66 @@
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
+
9
+ import { accountDir, workspaceDir, parseProvider, providerEnvFor } from "./store.ts";
10
+ import { readAgentsMd } from "./runner.ts";
11
+ import { getSecret } from "./secrets.ts";
12
+ import { platform } from "./platform.ts";
13
+ import { accountModelBlock, isClaudeLoginToken } from "./model-credential.ts";
14
+
15
+ /** Refused for want of a usable key; `status` is what an HTTP layer answers. */
16
+ export class ModelKeyError extends Error {
17
+ readonly status: number;
18
+ constructor(message: string, status = 402) {
19
+ super(message);
20
+ this.status = status;
21
+ }
22
+ }
23
+
24
+ export const NO_MODEL_KEY =
25
+ "no model key: this account runs on its own API key, and none is set — add one in Settings → Model " +
26
+ "(or `foldrun model set <provider> --key …`), or write a provider: block in AGENTS.md";
27
+
28
+ /**
29
+ * `platform` is a function: the caller's own way to build the platform's env
30
+ * (a step's, or the SDK's host env), called only when this account may use
31
+ * it. `format` says what the endpoint speaks — a caller with no translator
32
+ * refuses anything but anthropic.
33
+ */
34
+ export function accountModelEnv(
35
+ tenant: string,
36
+ workspace: string,
37
+ platformEnv: () => Record<string, string | undefined>,
38
+ ): { env: Record<string, string | undefined>; supply: "provider" | "platform"; format: string } {
39
+ const raw =
40
+ (readAgentsMd(workspaceDir(tenant, workspace))?.data?.provider as Record<string, unknown> | undefined) ??
41
+ (readAgentsMd(accountDir(tenant))?.data?.provider as Record<string, unknown> | undefined) ??
42
+ accountModelBlock((name) => getSecret(tenant, name)?.value ?? null);
43
+ const spec = parseProvider(raw);
44
+ if (!spec?.baseUrl) {
45
+ if (!platform.platformKeyAllowed(tenant)) throw new ModelKeyError(NO_MODEL_KEY);
46
+ return { env: platformEnv(), supply: "platform", format: "anthropic" };
47
+ }
48
+ const sub = (text: string) =>
49
+ text.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => getSecret(tenant, name, workspace)?.value ?? whole);
50
+ const token = sub(spec.token);
51
+ if (token.includes("${")) throw new ModelKeyError("the provider's token secret is not set — `foldrun secrets` sets it", 422);
52
+ if (!platform.platformKeyAllowed(tenant) && isClaudeLoginToken(token)) {
53
+ throw new ModelKeyError("model key: a Claude login token (sk-ant-oat…) cannot be used here — use an API key from your provider", 422);
54
+ }
55
+ return {
56
+ env: providerEnvFor({
57
+ baseUrl: spec.baseUrl,
58
+ token,
59
+ auth: spec.auth,
60
+ models: spec.models,
61
+ headers: Object.fromEntries(Object.entries(spec.headers).map(([k, v]) => [k, sub(v)])),
62
+ }),
63
+ supply: "provider",
64
+ format: spec.format,
65
+ };
66
+ }