@foldrun/core 0.5.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 (99) hide show
  1. package/README.md +7 -4
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.js +4 -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/browser-pod.d.ts +20 -1
  10. package/dist/src/browser-pod.js +58 -9
  11. package/dist/src/completions.js +1 -0
  12. package/dist/src/confine.d.ts +1 -0
  13. package/dist/src/confine.js +33 -4
  14. package/dist/src/container.d.ts +9 -0
  15. package/dist/src/container.js +115 -5
  16. package/dist/src/context-tools.d.ts +1 -1
  17. package/dist/src/deploy.d.ts +9 -0
  18. package/dist/src/deploy.js +31 -3
  19. package/dist/src/evals.js +24 -4
  20. package/dist/src/host-env.d.ts +14 -0
  21. package/dist/src/host-env.js +31 -0
  22. package/dist/src/install-helper.d.ts +17 -0
  23. package/dist/src/install-helper.js +134 -0
  24. package/dist/src/install-tool.d.ts +128 -0
  25. package/dist/src/install-tool.js +133 -0
  26. package/dist/src/mail.js +3 -1
  27. package/dist/src/model-credential.d.ts +30 -0
  28. package/dist/src/model-credential.js +98 -0
  29. package/dist/src/model-env.d.ts +17 -0
  30. package/dist/src/model-env.js +57 -0
  31. package/dist/src/package.d.ts +67 -0
  32. package/dist/src/package.js +594 -0
  33. package/dist/src/platform.d.ts +5 -0
  34. package/dist/src/platform.js +1 -0
  35. package/dist/src/provider-health.js +3 -0
  36. package/dist/src/run-container.d.ts +6 -1
  37. package/dist/src/run-container.js +93 -20
  38. package/dist/src/runner.d.ts +239 -0
  39. package/dist/src/runner.js +380 -72
  40. package/dist/src/runtime.d.ts +77 -0
  41. package/dist/src/runtime.js +230 -5
  42. package/dist/src/script-tools.d.ts +16 -0
  43. package/dist/src/script-tools.js +50 -8
  44. package/dist/src/secrets.d.ts +5 -0
  45. package/dist/src/secrets.js +37 -6
  46. package/dist/src/starter.d.ts +12 -0
  47. package/dist/src/starter.js +36 -4
  48. package/dist/src/step-exec.d.ts +22 -2
  49. package/dist/src/step-exec.js +70 -7
  50. package/dist/src/storage.d.ts +7 -1
  51. package/dist/src/storage.js +14 -1
  52. package/dist/src/store.d.ts +24 -0
  53. package/dist/src/store.js +91 -10
  54. package/dist/src/subagents.js +1 -1
  55. package/dist/src/test-mode.d.ts +17 -2
  56. package/dist/src/test-mode.js +53 -13
  57. package/dist/src/tool-names.d.ts +3 -1
  58. package/dist/src/tool-names.js +4 -2
  59. package/dist/src/tool-test.js +8 -1
  60. package/dist/src/zip.d.ts +17 -0
  61. package/dist/src/zip.js +184 -0
  62. package/index.ts +4 -0
  63. package/package.json +1 -1
  64. package/src/agent-import.ts +154 -0
  65. package/src/api-tools.ts +16 -1
  66. package/src/approvals.ts +10 -3
  67. package/src/browser-pod.ts +46 -9
  68. package/src/completions.ts +1 -0
  69. package/src/confine.ts +36 -5
  70. package/src/container.ts +112 -5
  71. package/src/context-tools.ts +1 -1
  72. package/src/deploy.ts +28 -3
  73. package/src/evals.ts +23 -2
  74. package/src/host-env.ts +32 -0
  75. package/src/install-helper.ts +143 -0
  76. package/src/install-tool.ts +154 -0
  77. package/src/mail.ts +3 -1
  78. package/src/model-credential.ts +112 -0
  79. package/src/model-env.ts +66 -0
  80. package/src/package.ts +664 -0
  81. package/src/platform.ts +6 -0
  82. package/src/provider-health.ts +3 -0
  83. package/src/run-container.ts +92 -19
  84. package/src/runner.ts +385 -72
  85. package/src/runtime.ts +235 -5
  86. package/src/script-tools.ts +48 -6
  87. package/src/secrets.ts +34 -6
  88. package/src/starter.ts +42 -4
  89. package/src/step-exec.ts +75 -10
  90. package/src/storage.ts +13 -0
  91. package/src/store.ts +91 -9
  92. package/src/subagents.ts +1 -1
  93. package/src/test-mode.ts +51 -11
  94. package/src/tool-names.ts +4 -2
  95. package/src/tool-test.ts +8 -1
  96. package/src/zip.ts +189 -0
  97. package/templates/blog-desk/agents/writer/agent.md +1 -1
  98. package/templates/hello/agents/curator/agent.md +3 -3
  99. package/templates/hello/agents/notetaker/agent.md +2 -2
@@ -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
+ }
package/src/mail.ts CHANGED
@@ -168,7 +168,9 @@ export function readUnsubscribeToken(
168
168
  const [payload, sig, extra] = token.split(".");
169
169
  if (!payload || !sig || extra !== undefined) return { error: "invalid token", status: 401 };
170
170
  const want = sign(payload);
171
- if (sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) {
171
+ // Only base64url reaches the compare: a multibyte character would make the
172
+ // byte buffers differ in length, and timingSafeEqual throws on that.
173
+ if (!/^[A-Za-z0-9_-]+$/.test(sig) || sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) {
172
174
  return { error: "invalid token", status: 401 };
173
175
  }
174
176
  let raw: { t?: unknown; e?: unknown; c?: unknown; w?: unknown; x?: unknown };
@@ -0,0 +1,112 @@
1
+ // Which Anthropic credential a step runs on, decided once and said on the
2
+ // record — never whatever happens to be signed in on the machine.
3
+ //
4
+ // In order: an API key (ANTHROPIC_API_KEY), a bearer for a gateway
5
+ // (ANTHROPIC_AUTH_TOKEN), then a Claude login token (CLAUDE_CODE_OAUTH_TOKEN,
6
+ // made by `claude setup-token`). Before this, an in-process step was handed
7
+ // none of them — the host allowlist drops credentials — so Claude Code fell
8
+ // back to the machine's own claude.ai login whatever key was set: a key the
9
+ // CLI insisted on was checked and then ignored.
10
+ //
11
+ // Note on terms: Anthropic does not allow third-party products built on the
12
+ // Agent SDK to offer claude.ai login unless Anthropic has approved it. The
13
+ // login token is accepted here because the account owner chose to support
14
+ // it; offering it to customers needs that approval.
15
+ //
16
+ // What an agent's own shell sees: Claude Code keeps CLAUDE_CODE_OAUTH_TOKEN
17
+ // out of its Bash by itself; an API key is visible there, as it is when you
18
+ // run Claude Code with one in your shell. Its scrub switch
19
+ // (CLAUDE_CODE_SUBPROCESS_ENV_SCRUB) is not used: it also strips the agent's
20
+ // own declared secrets (RESEND_API_KEY, DATAFORSEO_PASSWORD) from Bash, and on
21
+ // Linux it refuses to start without bubblewrap. Script tools and shell
22
+ // `verify:` never see any of these: they start from the host allowlist.
23
+
24
+ export type ModelCredentialKind = "api-key" | "auth-token" | "claude-login" | "none";
25
+
26
+ export interface ModelCredential {
27
+ kind: ModelCredentialKind;
28
+ /** What the run record and the CLI say: which credential, by its variable. */
29
+ label: string;
30
+ /** The variables the step's Claude Code is started with. */
31
+ env: Record<string, string>;
32
+ }
33
+
34
+ const set = (v: string | undefined) => typeof v === "string" && v.trim() !== "";
35
+
36
+ export function resolveModelCredential(source: Record<string, string | undefined> = process.env): ModelCredential {
37
+ const base: Record<string, string> = set(source.ANTHROPIC_BASE_URL) ? { ANTHROPIC_BASE_URL: source.ANTHROPIC_BASE_URL! } : {};
38
+ if (set(source.ANTHROPIC_API_KEY)) {
39
+ return {
40
+ kind: "api-key",
41
+ label: "API key (ANTHROPIC_API_KEY)",
42
+ env: { ...base, ANTHROPIC_API_KEY: source.ANTHROPIC_API_KEY!, CLAUDE_CODE_OAUTH_TOKEN: "" },
43
+ };
44
+ }
45
+ if (set(source.ANTHROPIC_AUTH_TOKEN)) {
46
+ return {
47
+ kind: "auth-token",
48
+ label: `bearer token (ANTHROPIC_AUTH_TOKEN)${base.ANTHROPIC_BASE_URL ? ` to ${base.ANTHROPIC_BASE_URL}` : ""}`,
49
+ env: { ...base, ANTHROPIC_AUTH_TOKEN: source.ANTHROPIC_AUTH_TOKEN!, ANTHROPIC_API_KEY: "", CLAUDE_CODE_OAUTH_TOKEN: "" },
50
+ };
51
+ }
52
+ if (set(source.CLAUDE_CODE_OAUTH_TOKEN)) {
53
+ return {
54
+ kind: "claude-login",
55
+ label: "Claude login token (CLAUDE_CODE_OAUTH_TOKEN)",
56
+ env: { ...base, CLAUDE_CODE_OAUTH_TOKEN: source.CLAUDE_CODE_OAUTH_TOKEN!, ANTHROPIC_API_KEY: "" },
57
+ };
58
+ }
59
+ return { kind: "none", label: "none set", env: {} };
60
+ }
61
+
62
+ /** The line a step's trail carries, and what goes wrong when there is none. */
63
+ export function credentialLine(c: ModelCredential): { type: "info" | "error"; text: string } {
64
+ if (c.kind !== "none") return { type: "info", text: `model credential: ${c.label}` };
65
+ return {
66
+ type: "error",
67
+ text:
68
+ "model credential: none set — Claude Code will fall back to whatever login this machine has, if any. " +
69
+ "Set ANTHROPIC_API_KEY, or CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token`",
70
+ };
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
+ }