@foldrun/core 0.3.0 → 0.5.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 (175) hide show
  1. package/README.md +27 -10
  2. package/dist/index.d.ts +3 -0
  3. package/dist/index.js +3 -0
  4. package/dist/src/account.d.ts +45 -0
  5. package/dist/src/account.js +130 -0
  6. package/dist/src/api-tools.d.ts +8 -0
  7. package/dist/src/api-tools.js +22 -4
  8. package/dist/src/approvals.d.ts +104 -1
  9. package/dist/src/approvals.js +192 -2
  10. package/dist/src/browser-pod.d.ts +129 -0
  11. package/dist/src/browser-pod.js +284 -0
  12. package/dist/src/budget.d.ts +32 -0
  13. package/dist/src/budget.js +177 -0
  14. package/dist/src/catalog.js +3 -1
  15. package/dist/src/clock.d.ts +49 -0
  16. package/dist/src/clock.js +121 -0
  17. package/dist/src/completions.js +17 -8
  18. package/dist/src/confine.d.ts +22 -0
  19. package/dist/src/confine.js +224 -4
  20. package/dist/src/deploy.d.ts +33 -0
  21. package/dist/src/deploy.js +145 -6
  22. package/dist/src/egress.d.ts +26 -2
  23. package/dist/src/egress.js +8 -1
  24. package/dist/src/eol.d.ts +2 -0
  25. package/dist/src/eol.js +7 -0
  26. package/dist/src/evals.d.ts +9 -0
  27. package/dist/src/evals.js +26 -13
  28. package/dist/src/flow-lint.d.ts +12 -0
  29. package/dist/src/flow-lint.js +331 -1
  30. package/dist/src/flow-patterns.d.ts +141 -0
  31. package/dist/src/flow-patterns.js +568 -0
  32. package/dist/src/gitrepo.d.ts +18 -0
  33. package/dist/src/gitrepo.js +49 -0
  34. package/dist/src/host-env.d.ts +8 -0
  35. package/dist/src/host-env.js +48 -0
  36. package/dist/src/json-schema.d.ts +12 -0
  37. package/dist/src/json-schema.js +202 -0
  38. package/dist/src/kinds.js +1 -1
  39. package/dist/src/language.d.ts +17 -0
  40. package/dist/src/language.js +55 -0
  41. package/dist/src/layout.d.ts +69 -0
  42. package/dist/src/layout.js +200 -0
  43. package/dist/src/library.d.ts +9 -0
  44. package/dist/src/library.js +43 -4
  45. package/dist/src/limits.d.ts +84 -0
  46. package/dist/src/limits.js +287 -0
  47. package/dist/src/locale.d.ts +47 -0
  48. package/dist/src/locale.js +177 -0
  49. package/dist/src/mail.d.ts +88 -0
  50. package/dist/src/mail.js +245 -0
  51. package/dist/src/model-probe.d.ts +3 -0
  52. package/dist/src/model-probe.js +1 -0
  53. package/dist/src/notify.d.ts +132 -8
  54. package/dist/src/notify.js +360 -58
  55. package/dist/src/openapi.js +5 -3
  56. package/dist/src/operator.d.ts +201 -0
  57. package/dist/src/operator.js +251 -0
  58. package/dist/src/paths.d.ts +33 -0
  59. package/dist/src/paths.js +87 -0
  60. package/dist/src/platform.d.ts +144 -4
  61. package/dist/src/platform.js +27 -3
  62. package/dist/src/provider-health.d.ts +55 -0
  63. package/dist/src/provider-health.js +233 -0
  64. package/dist/src/providers.d.ts +481 -0
  65. package/dist/src/providers.js +1105 -6
  66. package/dist/src/refusal.d.ts +83 -0
  67. package/dist/src/refusal.js +281 -0
  68. package/dist/src/run-container.d.ts +206 -17
  69. package/dist/src/run-container.js +547 -80
  70. package/dist/src/runner.d.ts +179 -10
  71. package/dist/src/runner.js +1423 -169
  72. package/dist/src/runtime.d.ts +52 -0
  73. package/dist/src/runtime.js +493 -58
  74. package/dist/src/script-tools.d.ts +15 -2
  75. package/dist/src/script-tools.js +44 -4
  76. package/dist/src/secret-files.d.ts +6 -5
  77. package/dist/src/secret-files.js +24 -11
  78. package/dist/src/secret-health.d.ts +83 -0
  79. package/dist/src/secret-health.js +177 -0
  80. package/dist/src/secrets.d.ts +38 -1
  81. package/dist/src/secrets.js +197 -96
  82. package/dist/src/starter.d.ts +9 -0
  83. package/dist/src/starter.js +95 -11
  84. package/dist/src/step-exec.d.ts +106 -1
  85. package/dist/src/step-exec.js +558 -85
  86. package/dist/src/step-removal.d.ts +15 -0
  87. package/dist/src/step-removal.js +49 -0
  88. package/dist/src/storage.d.ts +23 -4
  89. package/dist/src/storage.js +125 -23
  90. package/dist/src/store.d.ts +492 -38
  91. package/dist/src/store.js +882 -69
  92. package/dist/src/subagents.d.ts +75 -0
  93. package/dist/src/subagents.js +149 -0
  94. package/dist/src/tar.js +2 -1
  95. package/dist/src/test-mode.d.ts +111 -0
  96. package/dist/src/test-mode.js +285 -0
  97. package/dist/src/tool-names.d.ts +13 -1
  98. package/dist/src/tool-names.js +38 -11
  99. package/dist/src/tool-programs.d.ts +26 -0
  100. package/dist/src/tool-programs.js +172 -0
  101. package/dist/src/tool-test.js +152 -33
  102. package/dist/src/translator.d.ts +8 -2
  103. package/dist/src/translator.js +71 -24
  104. package/dist/src/trigger-log.d.ts +43 -0
  105. package/dist/src/trigger-log.js +122 -0
  106. package/dist/src/triggers.d.ts +23 -0
  107. package/dist/src/triggers.js +101 -12
  108. package/dist/src/untrusted-fetch.d.ts +11 -0
  109. package/dist/src/untrusted-fetch.js +77 -0
  110. package/dist/src/web-actions.d.ts +91 -0
  111. package/dist/src/web-actions.js +199 -0
  112. package/dist/src/webhook.d.ts +19 -3
  113. package/dist/src/webhook.js +50 -11
  114. package/index.ts +3 -0
  115. package/package.json +5 -5
  116. package/src/account.ts +149 -0
  117. package/src/api-tools.ts +32 -4
  118. package/src/approvals.ts +253 -2
  119. package/src/browser-pod.ts +313 -0
  120. package/src/budget.ts +178 -0
  121. package/src/catalog.ts +3 -1
  122. package/src/clock.ts +149 -0
  123. package/src/completions.ts +17 -8
  124. package/src/confine.ts +224 -4
  125. package/src/deploy.ts +137 -6
  126. package/src/egress.ts +32 -4
  127. package/src/eol.ts +7 -0
  128. package/src/evals.ts +36 -11
  129. package/src/flow-lint.ts +371 -1
  130. package/src/flow-patterns.ts +597 -0
  131. package/src/gitrepo.ts +48 -0
  132. package/src/host-env.ts +51 -0
  133. package/src/json-schema.ts +184 -0
  134. package/src/kinds.ts +1 -1
  135. package/src/language.ts +62 -0
  136. package/src/layout.ts +228 -0
  137. package/src/library.ts +50 -4
  138. package/src/limits.ts +290 -0
  139. package/src/locale.ts +217 -0
  140. package/src/mail.ts +316 -0
  141. package/src/model-probe.ts +4 -0
  142. package/src/notify.ts +391 -44
  143. package/src/openapi.ts +5 -3
  144. package/src/operator.ts +300 -0
  145. package/src/paths.ts +83 -0
  146. package/src/platform.ts +158 -5
  147. package/src/provider-health.ts +275 -0
  148. package/src/providers.ts +1368 -6
  149. package/src/refusal.ts +320 -0
  150. package/src/run-container.ts +611 -76
  151. package/src/runner.ts +1463 -165
  152. package/src/runtime.ts +487 -55
  153. package/src/script-tools.ts +55 -6
  154. package/src/secret-files.ts +22 -10
  155. package/src/secret-health.ts +230 -0
  156. package/src/secrets.ts +191 -95
  157. package/src/starter.ts +96 -11
  158. package/src/step-exec.ts +548 -41
  159. package/src/step-removal.ts +59 -0
  160. package/src/storage.ts +149 -24
  161. package/src/store.ts +1227 -74
  162. package/src/subagents.ts +189 -0
  163. package/src/tar.ts +2 -1
  164. package/src/test-mode.ts +311 -0
  165. package/src/tool-names.ts +40 -11
  166. package/src/tool-programs.ts +179 -0
  167. package/src/tool-test.ts +159 -32
  168. package/src/translator.ts +74 -23
  169. package/src/trigger-log.ts +160 -0
  170. package/src/triggers.ts +102 -12
  171. package/src/untrusted-fetch.ts +88 -0
  172. package/src/web-actions.ts +237 -0
  173. package/src/webhook.ts +58 -11
  174. package/templates/hello/agents/curator/agent.md +1 -1
  175. package/templates/hello/agents/notetaker/agent.md +1 -1
@@ -20,6 +20,9 @@ import { spawn } from "node:child_process";
20
20
  import { z } from "zod";
21
21
  import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
22
22
  import { runInContainer } from "./container.ts";
23
+ import { hostSafeEnv } from "./host-env.ts";
24
+ import { workspaceLinkEnv } from "./confine.ts";
25
+ import { platform } from "./platform.ts";
23
26
 
24
27
  // A script's only clock is the `timeout:` its own tool.md declares. There
25
28
  // was a two-minute default and a ten-minute cap here; both were the
@@ -38,8 +41,9 @@ export interface ScriptSpec {
38
41
  args: Record<string, string>; // arg name → description
39
42
  interpreter?: string; // optional override, e.g. "python3", "bash"
40
43
  /** Seconds this script may run — for the crawl that legitimately takes
41
- * five minutes. Defaults to 120, capped at 600: a limit an author can
42
- * raise is a budget; one they can remove is a hang. */
44
+ * five minutes. Unset means no platform clock; there was a two-minute
45
+ * default and a ten-minute cap here and both were removed (see the note
46
+ * above `timeoutFor`). */
43
47
  timeout?: number;
44
48
  /** The script itself, when the tool is a single markdown file with its
45
49
  * code in a fenced block. Materialised to a file at call time — the
@@ -55,6 +59,14 @@ export interface ScriptSpec {
55
59
  * default, `materialised`, is a program that reads values from its
56
60
  * environment and needs them real — a browser seeding cookies. */
57
61
  secrets?: "proxied" | "materialised";
62
+ /** `outward: true` — this program makes something happen outside (sends,
63
+ * posts, orders). On a test run a step that grants it is handed no real
64
+ * secret at all, whatever their names. */
65
+ outward?: boolean;
66
+ /** `test_mode: allow` — a reader that happens to use a send-capable
67
+ * secret's name (a GBP reader on a GBP token): on a test run the step
68
+ * still gets the real values. An `outward` tool in the same step wins. */
69
+ testMode?: "allow";
58
70
  }
59
71
 
60
72
  export function parseScripts(raw: unknown): ScriptSpec[] {
@@ -82,12 +94,23 @@ export function parseScripts(raw: unknown): ScriptSpec[] {
82
94
  args,
83
95
  interpreter: typeof e.interpreter === "string" ? e.interpreter : undefined,
84
96
  secrets: e.secrets === "proxied" ? "proxied" : undefined,
97
+ outward: e.outward === true ? true : undefined,
98
+ testMode: e.test_mode === "allow" ? "allow" : undefined,
85
99
  timeout: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
86
100
  });
87
101
  }
88
102
  return out;
89
103
  }
90
104
 
105
+ /** Interpreter names that mean a language rather than a binary, and the
106
+ * extension whose prepared interpreter serves them. */
107
+ const GENERIC_INTERPRETER: Record<string, string> = {
108
+ python: ".py",
109
+ python3: ".py",
110
+ "/usr/bin/python3": ".py",
111
+ "/usr/bin/env python3": ".py",
112
+ };
113
+
91
114
  // Pick how to execute a file when no interpreter is declared.
92
115
  //
93
116
  // Exported because the tool tester has to make the same choice, and used to
@@ -99,7 +122,18 @@ export function commandFor(
99
122
  abs: string,
100
123
  overrides: Record<string, string> = {},
101
124
  ): { cmd: string; args: string[] } {
102
- if (spec.interpreter) return { cmd: spec.interpreter, args: [abs] };
125
+ if (spec.interpreter) {
126
+ // `interpreter: python3` names the language, not a particular binary, and
127
+ // it is how most tool files are written. Taken literally it ran the bare
128
+ // system python beside a venv built for this very tool, and the tool
129
+ // died on its first import ("No module named openpyxl") while the log
130
+ // above it said the runtime was cached. A generic python name is served
131
+ // by the prepared venv when there is one; a path or anything else is
132
+ // still obeyed as written.
133
+ const lang = GENERIC_INTERPRETER[spec.interpreter.trim()];
134
+ if (lang && overrides[lang]) return { cmd: overrides[lang], args: [abs] };
135
+ return { cmd: spec.interpreter, args: [abs] };
136
+ }
103
137
  const ext = path.extname(abs).toLowerCase();
104
138
  // A prepared runtime (venv, npm prefix) wins over the host interpreter.
105
139
  if (overrides[ext]) return { cmd: overrides[ext], args: [abs] };
@@ -128,7 +162,7 @@ function containerInterpreter(abs: string): string {
128
162
  return byExt[path.extname(abs).toLowerCase()] ?? abs;
129
163
  }
130
164
 
131
- function runScript(
165
+ export function runScript(
132
166
  cwd: string,
133
167
  spec: ScriptSpec,
134
168
  values: Record<string, string>,
@@ -229,9 +263,14 @@ function runScript(
229
263
  return;
230
264
  }
231
265
 
266
+ // Not process.env: on a platform that is every tenant's root key and
267
+ // the datastore credentials. The child starts from the allowlisted
268
+ // base and gets what the agent declared, by name — see host-env.ts.
269
+ // In a run container `env` already carries the container's whole
270
+ // environment, which is the boundary, so nothing is lost there.
232
271
  const child = spawn(cmd, args, {
233
272
  cwd,
234
- env: { ...process.env, ...env },
273
+ env: workspaceLinkEnv({ ...hostSafeEnv(), ...env }, cwd),
235
274
  timeout: timeoutFor(spec),
236
275
  });
237
276
  let out = "";
@@ -291,7 +330,17 @@ export function resolveRunPath(agentDir: string, run: string, libraryScripts: st
291
330
  return path.resolve(workspaceRoot, "tools", run.slice("workspace/tools/".length));
292
331
  }
293
332
  if (run.startsWith("account/tools/")) {
294
- return path.resolve(libraryRoot, "tools", run.slice("account/tools/".length));
333
+ const rel = run.slice("account/tools/".length);
334
+ const own = path.resolve(libraryRoot, "tools", rel);
335
+ // A gallery tool nobody installed has no copy in the library. In a
336
+ // sandbox the gallery is laid down under the library, so the path above
337
+ // is it; a run on the host has no such merge, so it looks on the shelf.
338
+ const gallery = platform.galleryDir();
339
+ if (!fs.existsSync(own) && gallery) {
340
+ const shelf = path.resolve(gallery, "tools", rel);
341
+ if (fs.existsSync(shelf)) return shelf;
342
+ }
343
+ return own;
295
344
  }
296
345
  // Canonical prefixes, then the legacy spellings they replaced.
297
346
  for (const p of ["workspace/scripts/", "shared/"]) {
@@ -9,9 +9,12 @@
9
9
  // `"$PROD_VM" 'uptime'` or `"$STRIPE" /v1/charges` and never learns whether
10
10
  // it was key or password, one header or five.
11
11
  //
12
- // Everything lands in a per-run scratch directory inside the agent's own
12
+ // Everything lands in a per-step scratch directory inside the agent's own
13
13
  // tree (so it crosses into a container with the workspace) but dot-prefixed
14
- // and never archived or copied back — it holds live credentials.
14
+ // and never archived or copied back — it holds live credentials. Per step,
15
+ // not per agent: two steps of one agent run in parallel on the in-process
16
+ // path (a fan-out), and with one shared directory the first to finish
17
+ // deleted the keys the other was still using.
15
18
 
16
19
  import fs from "node:fs";
17
20
  import path from "node:path";
@@ -33,10 +36,10 @@ function writeWrapper(dir: string, name: string, body: string): string {
33
36
  }
34
37
 
35
38
  /**
36
- * Turn any @file/@ssh/@api values in `env` into files under
37
- * `<agentDir>/.secret-files`, rewriting each to its path. Returns the
38
- * directory written (for cleanup) and the transformed env. Plain values
39
- * pass through untouched.
39
+ * Turn any @file/@ssh/@api values in `env` into files under a fresh
40
+ * `<agentDir>/.secret-files/step-XXXXXX`, rewriting each to its path.
41
+ * Returns the directory written (for cleanup) and the transformed env.
42
+ * Plain values pass through untouched.
40
43
  */
41
44
  export function materializeFileSecrets(
42
45
  agentDir: string,
@@ -47,8 +50,10 @@ export function materializeFileSecrets(
47
50
  );
48
51
  if (entries.length === 0) return { env, dir: null };
49
52
 
50
- const dir = path.join(agentDir, ".secret-files");
51
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
53
+ const parent = path.join(agentDir, ".secret-files");
54
+ fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
55
+ const dir = fs.mkdtempSync(path.join(parent, "step-"));
56
+ fs.chmodSync(dir, 0o700);
52
57
  const out = { ...env };
53
58
  for (const [name, value] of entries) {
54
59
  // Lowercased name as the filename keeps it predictable without leaking
@@ -95,7 +100,14 @@ export function materializeFileSecrets(
95
100
  return { env: out, dir };
96
101
  }
97
102
 
98
- /** Best-effort removal of a materialised secret-files directory. */
103
+ /** Best-effort removal of a materialised secret-files directory — this
104
+ * step's own, and the shared parent once no other step is using it. */
99
105
  export function cleanupFileSecrets(dir: string | null) {
100
- if (dir) fs.rmSync(dir, { recursive: true, force: true });
106
+ if (!dir) return;
107
+ fs.rmSync(dir, { recursive: true, force: true });
108
+ try {
109
+ fs.rmdirSync(path.dirname(dir));
110
+ } catch {
111
+ // another step's directory is still in there
112
+ }
101
113
  }
@@ -0,0 +1,230 @@
1
+ // Does this credential still work?
2
+ //
3
+ // The vault knows a secret's name, its scope and its shape. It knows nothing
4
+ // about whether the thing on the other end still accepts it, and that is the
5
+ // only question anyone actually has. A key that has quietly stopped working
6
+ // looks identical to a key nobody has used yet, and both look identical to a
7
+ // key that is fine — three very different situations rendered the same way.
8
+ //
9
+ // Two facts, recorded where they are already known and nowhere else:
10
+ //
11
+ // when it was last used, and against which host
12
+ // what the other end said the last time — accepted, or refused
13
+ //
14
+ // The egress proxy sees both for every http tool and every model call,
15
+ // because filling the credential is its whole job. Notifications and the
16
+ // provider check report their own. Nothing new is instrumented, and a secret
17
+ // materialised into a sandbox reports nothing, which the page says plainly
18
+ // rather than guessing.
19
+ //
20
+ // Never the value, never a hash of the value, never the request body. A
21
+ // name, a host, a status and a time.
22
+
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import crypto from "node:crypto";
26
+ import { accountDir } from "./store.ts";
27
+
28
+ /** How a credential is identified here. A name alone is not enough: the
29
+ * account and each workspace can hold a secret by the same name, and they
30
+ * are different credentials with different health. */
31
+ export function healthKey(name: string, scope: "account" | "workspace", workspace?: string | null): string {
32
+ return scope === "workspace" && workspace ? `workspace:${workspace}:${name}` : `account:${name}`;
33
+ }
34
+
35
+ export interface SecretUse {
36
+ /** ISO time of the most recent use. */
37
+ at: string;
38
+ /** The host it was sent to, so "which of my four keys is this" has an
39
+ * answer without opening a run. */
40
+ host: string;
41
+ /** The response status, when there was one. */
42
+ status: number | null;
43
+ /** Refused means the credential itself was rejected: 401, 402 or 403.
44
+ * A 500 is the other end failing, which says nothing about the key. */
45
+ outcome: "accepted" | "refused" | "error";
46
+ }
47
+
48
+ export interface SecretRecord {
49
+ last: SecretUse;
50
+ /** Consecutive refusals, newest first — one is a fluke, five is a dead
51
+ * key. Reset by any acceptance. */
52
+ refusals: number;
53
+ /** The last acceptance, kept through a run of refusals so a page can say
54
+ * "worked until Tuesday", which is the sentence that dates the breakage. */
55
+ lastAccepted: string | null;
56
+ }
57
+
58
+ export type HealthFile = Record<string, SecretRecord>;
59
+
60
+ /** One use to fold into a record. */
61
+ export interface HealthUpdate {
62
+ key: string;
63
+ use: { host: string; status: number | null; at: string };
64
+ }
65
+
66
+ /**
67
+ * Where health is kept. A file per account by default — what the CLI and a
68
+ * single-box install use. The platform plugs in Postgres
69
+ * (setSecretHealthStore), where each use is one atomic upsert: two pods
70
+ * recording at once on a shared file was last-writer-wins, and a refusal
71
+ * could vanish.
72
+ */
73
+ export interface SecretHealthStore {
74
+ apply(tenant: string, updates: HealthUpdate[]): Promise<void>;
75
+ read(tenant: string): Promise<HealthFile>;
76
+ forget(tenant: string, key: string): Promise<void>;
77
+ }
78
+
79
+ const healthFile = (tenant: string) => path.join(accountDir(tenant), "secret-health.json");
80
+
81
+ function read(tenant: string): HealthFile {
82
+ try {
83
+ return JSON.parse(fs.readFileSync(healthFile(tenant), "utf8")) as HealthFile;
84
+ } catch {
85
+ return {};
86
+ }
87
+ }
88
+
89
+ function write(tenant: string, data: HealthFile): void {
90
+ const file = healthFile(tenant);
91
+ if (!fs.existsSync(path.dirname(file))) return;
92
+ // pid alone is not unique across pods on a shared volume.
93
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}`;
94
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
95
+ fs.renameSync(tmp, file);
96
+ }
97
+
98
+ const fileStore: SecretHealthStore = {
99
+ async apply(tenant, updates) {
100
+ const data = read(tenant);
101
+ for (const { key, use } of updates) apply(data, key, use);
102
+ write(tenant, data);
103
+ },
104
+ async read(tenant) {
105
+ return read(tenant);
106
+ },
107
+ async forget(tenant, key) {
108
+ const data = read(tenant);
109
+ if (!(key in data)) return;
110
+ delete data[key];
111
+ write(tenant, data);
112
+ },
113
+ };
114
+
115
+ let store: SecretHealthStore = fileStore;
116
+
117
+ /** The platform's store (Postgres), or back to files with null. */
118
+ export function setSecretHealthStore(next: SecretHealthStore | null): void {
119
+ store = next ?? fileStore;
120
+ }
121
+
122
+ // Writes are queued and coalesced off the caller's path. The egress proxy
123
+ // notes a use per secret per request, before it streams the response body,
124
+ // and a synchronous read-modify-write of a JSON file there is latency on
125
+ // every proxied call — "never blocks" has to be true, not aspirational.
126
+ // One writer per process; the queue drains in order.
127
+ const pending = new Map<string, HealthUpdate[]>();
128
+ let draining = false;
129
+
130
+ function drain(): void {
131
+ if (draining) return;
132
+ draining = true;
133
+ setImmediate(async () => {
134
+ try {
135
+ for (const [tenant, updates] of pending) {
136
+ pending.delete(tenant);
137
+ try {
138
+ await store.apply(tenant, updates);
139
+ } catch {
140
+ // best effort, always
141
+ }
142
+ }
143
+ } finally {
144
+ draining = false;
145
+ if (pending.size) drain();
146
+ }
147
+ });
148
+ }
149
+
150
+ function apply(data: HealthFile, name: string, use: { host: string; status: number | null; at?: string }): void {
151
+ const outcome = outcomeFor(use.status);
152
+ const at = use.at ?? new Date().toISOString();
153
+ const prev = data[name];
154
+ data[name] = {
155
+ last: { at, host: use.host, status: use.status, outcome },
156
+ // Only a refusal counts against a key. An error at the other end is
157
+ // the other end's problem and must not accumulate into a false alarm.
158
+ refusals: outcome === "refused" ? (prev?.refusals ?? 0) + 1 : outcome === "accepted" ? 0 : prev?.refusals ?? 0,
159
+ lastAccepted: outcome === "accepted" ? at : prev?.lastAccepted ?? null,
160
+ };
161
+ }
162
+
163
+ /** 401, 402 and 403 are the credential being refused. Everything else is
164
+ * the request, the model or the provider — none of which is the key's
165
+ * fault, and calling them a credential failure would send people to
166
+ * rotate a key that was never the problem. */
167
+ export function outcomeFor(status: number | null): SecretUse["outcome"] {
168
+ if (status === null) return "error";
169
+ if (status === 401 || status === 402 || status === 403) return "refused";
170
+ if (status >= 200 && status < 400) return "accepted";
171
+ return "error";
172
+ }
173
+
174
+ /**
175
+ * Record one use. `key` is a health key (see healthKey), never a bare name.
176
+ * Never throws and never blocks: the write is queued and happens off the
177
+ * caller's path, because this runs on every proxied request.
178
+ */
179
+ export function noteSecretUse(
180
+ tenant: string,
181
+ key: string,
182
+ use: { host: string; status: number | null; at?: string },
183
+ ): void {
184
+ if (!key) return;
185
+ const list = pending.get(tenant) ?? [];
186
+ list.push({ key, use: { ...use, at: use.at ?? new Date().toISOString() } });
187
+ pending.set(tenant, list);
188
+ drain();
189
+ }
190
+
191
+ /** Wait for queued writes to land — for tests, and for a process that is
192
+ * about to exit. */
193
+ export function flushSecretHealth(): Promise<void> {
194
+ return new Promise((resolve) => {
195
+ const check = () => (pending.size === 0 && !draining ? resolve() : setImmediate(check));
196
+ check();
197
+ });
198
+ }
199
+
200
+ /** Everything known about this account's credentials, by name. Names the
201
+ * vault holds but nothing has used are simply absent, which is the honest
202
+ * answer: unused is not the same as broken. */
203
+ export async function secretHealth(tenant: string): Promise<HealthFile> {
204
+ return store.read(tenant);
205
+ }
206
+
207
+ /** The credentials worth saying something about: refused the last time they
208
+ * were used. Sorted worst first — most consecutive refusals, then oldest
209
+ * last-success, which is the order someone would work through them. */
210
+ export async function failingSecrets(tenant: string): Promise<{ name: string; record: SecretRecord }[]> {
211
+ return Object.entries(await store.read(tenant))
212
+ .filter(([, r]) => r.last.outcome === "refused")
213
+ .map(([name, record]) => ({ name, record }))
214
+ .sort((a, b) => b.record.refusals - a.record.refusals || (a.record.lastAccepted ?? "").localeCompare(b.record.lastAccepted ?? ""));
215
+ }
216
+
217
+ /** Drop what is known about a secret — for a delete, and for a rotation,
218
+ * where the old key's refusals say nothing about the new one. */
219
+ export async function forgetSecretHealth(tenant: string, name: string): Promise<void> {
220
+ try {
221
+ await store.forget(tenant, name);
222
+ } catch {
223
+ // best effort
224
+ }
225
+ }
226
+
227
+ /** The account's file, for the platform's one-time import into its store. */
228
+ export function readSecretHealthFile(tenant: string): HealthFile {
229
+ return read(tenant);
230
+ }