@foldrun/core 0.6.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +14 -1
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/agent-import.d.ts +21 -0
  5. package/dist/src/agent-import.js +116 -0
  6. package/dist/src/api-tools.js +14 -2
  7. package/dist/src/approvals.d.ts +8 -1
  8. package/dist/src/approvals.js +10 -5
  9. package/dist/src/completions.js +1 -0
  10. package/dist/src/confine.d.ts +1 -0
  11. package/dist/src/confine.js +33 -4
  12. package/dist/src/deploy.d.ts +9 -0
  13. package/dist/src/deploy.js +31 -3
  14. package/dist/src/evals.js +22 -5
  15. package/dist/src/hooks.d.ts +114 -0
  16. package/dist/src/hooks.js +370 -0
  17. package/dist/src/host-env.d.ts +14 -0
  18. package/dist/src/host-env.js +26 -0
  19. package/dist/src/install-helper.d.ts +17 -0
  20. package/dist/src/install-helper.js +134 -0
  21. package/dist/src/install-tool.d.ts +128 -0
  22. package/dist/src/install-tool.js +133 -0
  23. package/dist/src/kinds.d.ts +1 -1
  24. package/dist/src/kinds.js +26 -0
  25. package/dist/src/library.d.ts +1 -1
  26. package/dist/src/library.js +17 -1
  27. package/dist/src/mail.js +1 -1
  28. package/dist/src/model-credential.d.ts +16 -0
  29. package/dist/src/model-credential.js +40 -0
  30. package/dist/src/model-env.d.ts +17 -0
  31. package/dist/src/model-env.js +57 -0
  32. package/dist/src/notify.d.ts +8 -0
  33. package/dist/src/notify.js +10 -1
  34. package/dist/src/package.d.ts +67 -0
  35. package/dist/src/package.js +594 -0
  36. package/dist/src/platform.d.ts +5 -0
  37. package/dist/src/platform.js +1 -0
  38. package/dist/src/provider-health.js +3 -0
  39. package/dist/src/providers.d.ts +5 -0
  40. package/dist/src/providers.js +21 -1
  41. package/dist/src/run-container.d.ts +10 -0
  42. package/dist/src/run-container.js +72 -14
  43. package/dist/src/runner.d.ts +205 -0
  44. package/dist/src/runner.js +273 -36
  45. package/dist/src/runtime.d.ts +77 -0
  46. package/dist/src/runtime.js +230 -5
  47. package/dist/src/script-tools.d.ts +2 -0
  48. package/dist/src/script-tools.js +3 -1
  49. package/dist/src/secrets.d.ts +5 -0
  50. package/dist/src/secrets.js +37 -6
  51. package/dist/src/starter.d.ts +12 -0
  52. package/dist/src/starter.js +38 -4
  53. package/dist/src/step-exec.d.ts +21 -2
  54. package/dist/src/step-exec.js +165 -7
  55. package/dist/src/storage.d.ts +7 -1
  56. package/dist/src/storage.js +14 -1
  57. package/dist/src/store.d.ts +31 -1
  58. package/dist/src/store.js +90 -8
  59. package/dist/src/tool-names.d.ts +3 -1
  60. package/dist/src/tool-names.js +4 -2
  61. package/dist/src/tool-test.js +8 -1
  62. package/dist/src/zip.d.ts +17 -0
  63. package/dist/src/zip.js +184 -0
  64. package/index.ts +3 -0
  65. package/package.json +18 -1
  66. package/src/agent-import.ts +154 -0
  67. package/src/api-tools.ts +16 -1
  68. package/src/approvals.ts +10 -3
  69. package/src/completions.ts +1 -0
  70. package/src/confine.ts +36 -5
  71. package/src/deploy.ts +28 -3
  72. package/src/evals.ts +21 -3
  73. package/src/hooks.ts +406 -0
  74. package/src/host-env.ts +27 -0
  75. package/src/install-helper.ts +143 -0
  76. package/src/install-tool.ts +154 -0
  77. package/src/kinds.ts +28 -0
  78. package/src/library.ts +18 -2
  79. package/src/mail.ts +1 -1
  80. package/src/model-credential.ts +41 -0
  81. package/src/model-env.ts +66 -0
  82. package/src/notify.ts +11 -1
  83. package/src/package.ts +664 -0
  84. package/src/platform.ts +6 -0
  85. package/src/provider-health.ts +3 -0
  86. package/src/providers.ts +27 -1
  87. package/src/run-container.ts +75 -14
  88. package/src/runner.ts +279 -36
  89. package/src/runtime.ts +235 -5
  90. package/src/script-tools.ts +5 -1
  91. package/src/secrets.ts +34 -6
  92. package/src/starter.ts +44 -4
  93. package/src/step-exec.ts +171 -11
  94. package/src/storage.ts +13 -0
  95. package/src/store.ts +89 -7
  96. package/src/tool-names.ts +4 -2
  97. package/src/tool-test.ts +8 -1
  98. package/src/zip.ts +189 -0
@@ -0,0 +1,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/kinds.ts CHANGED
@@ -44,6 +44,7 @@ export type Kind =
44
44
  | "flows"
45
45
  | "evals"
46
46
  | "tools"
47
+ | "hooks"
47
48
  | "skills"
48
49
  | "memory"
49
50
  | "knowledge"
@@ -192,6 +193,33 @@ Notes for whoever maintains this. Agents opt in with:
192
193
  \`\`\`yaml
193
194
  tools: [${name}]
194
195
  \`\`\`
196
+ `,
197
+ },
198
+
199
+ hooks: {
200
+ kind: "hooks",
201
+ one: "hook",
202
+ docType: null,
203
+ docKey: null,
204
+ // Turned on per agent (`hooks:`), or for every agent from an AGENTS.md;
205
+ // shared ones live in the account library.
206
+ scopes: ["account", "workspace"],
207
+ placeholder: "no-force-push",
208
+ hint: "Your code at every tool call, step start or step end — on every model. Folder: hook.md and run.mjs.",
209
+ file: (name) => `hooks/${name}/hook.md`,
210
+ template: (name) => `---
211
+ name: ${name}
212
+ on: tool.call
213
+ tool: Bash
214
+ description: What this hook guards, in one line.
215
+ ---
216
+
217
+ # ${name}
218
+
219
+ What it refuses or rewrites, and why. The code is run.mjs beside this file:
220
+ it reads the event as JSON on stdin and prints one JSON answer —
221
+ {"decision": "deny", "reason": "..."} to refuse, {"input": {...}} to rewrite,
222
+ {} to let the call through. A hook that fails refuses the call.
195
223
  `,
196
224
  },
197
225
  // Last, and not navigable: a folder tool holds its own code, so scripts
package/src/library.ts CHANGED
@@ -32,12 +32,12 @@ import {
32
32
  } from "./store.ts";
33
33
 
34
34
 
35
- export type LibraryKind = "skills" | "scripts" | "memory" | "knowledge" | "tools";
35
+ export type LibraryKind = "skills" | "scripts" | "memory" | "knowledge" | "tools" | "hooks";
36
36
  // Same order as the workspace nav and asset pages — one list, one order.
37
37
  // Order matters and is asserted: it is the order every noun list in the
38
38
  // product uses. Tools then scripts, because a script is material a tool
39
39
  // points at — see the KINDS table, which is where the order is declared.
40
- export const LIBRARY_KINDS: LibraryKind[] = ["tools", "knowledge", "memory", "skills", "scripts"];
40
+ export const LIBRARY_KINDS: LibraryKind[] = ["tools", "hooks", "knowledge", "memory", "skills", "scripts"];
41
41
 
42
42
  export function libraryDir(tenant: string, kind?: LibraryKind) {
43
43
  // Alongside the workspace in single-workspace mode: ./library, so a shared
@@ -159,6 +159,22 @@ export function listLibrary(tenant: string, kind: LibraryKind): LibraryEntry[] {
159
159
  return out.sort((a, b) => a.path.localeCompare(b.path));
160
160
  }
161
161
 
162
+ // A hook is a folder: hook.md says when it runs, run.mjs is the code.
163
+ if (kind === "hooks") {
164
+ for (const entry of fs.readdirSync(dir).sort()) {
165
+ const full = path.join(dir, entry);
166
+ const hookMd = path.join(full, "hook.md");
167
+ if (!fs.statSync(full).isDirectory() || !fs.existsSync(hookMd)) continue;
168
+ out.push({
169
+ ...describe(hookMd, entry),
170
+ path: `${entry}/hook.md`,
171
+ files: folderFiles(full, entry),
172
+ updatedAt: fs.statSync(hookMd).mtime.toISOString(),
173
+ });
174
+ }
175
+ return out;
176
+ }
177
+
162
178
  if (kind === "skills") {
163
179
  for (const entry of fs.readdirSync(dir).sort()) {
164
180
  const full = path.join(dir, entry);
package/src/mail.ts CHANGED
@@ -81,7 +81,7 @@ export const MAIL_CATEGORIES: readonly MailCategoryInfo[] = [
81
81
  label: "Account notices",
82
82
  required: true,
83
83
  perWorkspace: false,
84
- description: "Something about the account stopped working on its own — a notification webhook switched off after days of failures, a close request.",
84
+ description: "Something about the account stopped working on its own, or is about to — a notification webhook switched off after days of failures, a close request, an API route it still calls being switched off.",
85
85
  why: "each one is the only message saying a thing you rely on has stopped",
86
86
  },
87
87
  {
@@ -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
+ }
package/src/notify.ts CHANGED
@@ -137,6 +137,15 @@ export function notifyConfig(tenant: string, workspace: string): NotifyConfig |
137
137
  // line per parked run would bury the failures around it.
138
138
  let warnedNoPublicUrl = false;
139
139
 
140
+ /**
141
+ * The version of the webhook body's shape, sent on every delivery as
142
+ * `x-foldrun-webhook-version` (and in the user agent). Within a version the
143
+ * body only gains fields — a receiver must ignore the ones it does not know.
144
+ * A field removed, renamed or retyped is a new version, announced in What's
145
+ * new at least six months before the old shape stops being sent.
146
+ */
147
+ export const WEBHOOK_PAYLOAD_VERSION = 1;
148
+
140
149
  /**
141
150
  * The headers that prove a delivery came from this install.
142
151
  *
@@ -252,7 +261,8 @@ export async function attemptWebhook(
252
261
  method: "POST",
253
262
  headers: {
254
263
  "content-type": "application/json",
255
- "user-agent": "foldrun-webhooks/1",
264
+ "user-agent": `foldrun-webhooks/${WEBHOOK_PAYLOAD_VERSION}`,
265
+ "x-foldrun-webhook-version": String(WEBHOOK_PAYLOAD_VERSION),
256
266
  "x-foldrun-delivery": d.id,
257
267
  "x-foldrun-event": d.event,
258
268
  "x-foldrun-attempt": String(d.attempt),