@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
@@ -1,22 +1,95 @@
1
- import type { FlowStep, RunRecord } from "./store.ts";
1
+ import type { FlowInfo, FlowStep, RunRecord } from "./store.ts";
2
2
  import type { RunInContainerArgs, ContainerStepOutcome } from "./run-container.ts";
3
3
  import type { EgressHooks } from "./egress.ts";
4
4
  export type IsolatedStepRunner = (args: RunInContainerArgs) => Promise<ContainerStepOutcome>;
5
+ /** Who this install is for. `hosted` means someone is paying the operator
6
+ * for it — a wallet, a plan, a bill. `self-hosted` is everything else: a
7
+ * laptop, a company's own box, the open-source download. */
8
+ export type Edition = "self-hosted" | "hosted";
9
+ /** What a trigger gate decided. `admit: false` is never an error: the
10
+ * trigger was received and understood, and the flow's own file says this
11
+ * one does not become a run. `detail` is what the sender and the log are
12
+ * told, because a silent drop reads exactly like a broken hook. */
13
+ export type TriggerAdmission = {
14
+ admit: true;
15
+ } | {
16
+ admit: false;
17
+ reason: "duplicate" | "throttled" | "quarantined" | "paused" | "debounced";
18
+ detail: string;
19
+ firesAt?: number;
20
+ };
5
21
  export interface PlatformHooks {
22
+ /** Is this a hosted install? The ONE question every commercial surface —
23
+ * the wallet, top-ups, plan limits, whatever is added next that assumes
24
+ * a customer — asks before it renders or answers. Default: self-hosted,
25
+ * which is what a process that never registered a platform is, and what
26
+ * the open-source image is unless its operator configures billing. One
27
+ * predicate, one place, so "does the community build show X" is a test
28
+ * and not an audit. */
29
+ edition(): Edition;
6
30
  /** Put a flow run where a worker will pick it up. Default: start it here, now. */
7
- enqueueFlowRun(tenant: string, workspace: string, steps: FlowStep[], flowName: string, modelOverride?: string | null, tags?: string[]): Promise<RunRecord>;
31
+ enqueueFlowRun(tenant: string, workspace: string, steps: FlowStep[], flowName: string, modelOverride?: string | null, tags?: string[],
32
+ /** The person who asked, when one did — their email. A schedule, a
33
+ * webhook and a chained flow leave it unset, which is what makes
34
+ * "nobody approves their own run" a rule the record can enforce. */
35
+ startedBy?: string | null,
36
+ /** `test: true` marks the run a test run (see test-mode.ts). Unset
37
+ * lets the platform decide — a preview workspace's runs are tests
38
+ * unless the flow says `live: true`. */
39
+ opts?: {
40
+ test?: boolean;
41
+ }): Promise<RunRecord>;
8
42
  /** A parked run was approved and has no driver; line it up. Default: nothing —
9
43
  * locally the starter that parked it is still polling the record. */
10
44
  enqueueResume(tenant: string, workspace: string, runId: string): Promise<void>;
45
+ /**
46
+ * What a run has spent so far, in the money the customer is actually
47
+ * billed — tokens AND the sandbox seconds behind them.
48
+ *
49
+ * `budget:` says "the most one run may spend", and for a long time it
50
+ * only ever compared token cost. A step that called no model — a script
51
+ * looping on a page that never loads, a browser waiting on a selector
52
+ * that never appears — spent nothing by that reading and was capped by
53
+ * nothing, while the sandbox billed by the second. The platform has no
54
+ * clock of its own by design, so the cap is the only backstop there is,
55
+ * and it has to count the whole bill for that to be true.
56
+ *
57
+ * Default: token cost, which is the entire bill on an install that
58
+ * prices no compute.
59
+ */
60
+ runSpend(run: RunRecord): number;
61
+ /**
62
+ * Should a trigger become a run? The flow file's `idempotency:`,
63
+ * `throttle:`, `debounce:` and `disable_after:` are all answered here,
64
+ * because all four need state that outlives one delivery — what has been
65
+ * seen, when the last run started, what is still being waited out.
66
+ *
67
+ * Default: yes, always. A laptop has no fleet to protect and no duplicate
68
+ * deliveries to remember; the local runner is the one asking, and it asked
69
+ * because a person did.
70
+ */
71
+ admitTrigger(tenant: string, workspace: string, flow: FlowInfo, opts?: {
72
+ deliveryKey?: string | null;
73
+ body?: string;
74
+ tag?: string;
75
+ }): Promise<TriggerAdmission>;
76
+ /** An admitted trigger whose start then threw. Undoes what admitTrigger
77
+ * recorded — the delivery as seen, the throttle window — so the sender's
78
+ * retry is a fresh event rather than a "duplicate" of a run that never
79
+ * existed. Default: nothing, because the default gate records nothing. */
80
+ rollbackTrigger(tenant: string, workspace: string, flow: FlowInfo, opts?: {
81
+ deliveryKey?: string | null;
82
+ reason?: string;
83
+ }): Promise<void>;
11
84
  /** Step executors by FOLDRUN_RUN_ISOLATION value, beyond the `container`
12
85
  * one core ships. The platform adds `k8s`. */
13
86
  isolation: Record<string, IsolatedStepRunner>;
14
87
  /** Destroy whatever sandboxes a stopped run still has. Default: nothing to destroy. */
15
88
  killRunSandboxes(runId: string): void;
16
89
  /** Publish storage/public/ as share links after a run. Default: no links. */
17
- syncPublicShares(tenant: string, workspace: string): {
90
+ syncPublicShares(tenant: string, workspace: string): Promise<{
18
91
  added: string[];
19
- };
92
+ }>;
20
93
  /** The workspace a preview was branched from, for inherited secrets. Default: none. */
21
94
  previewSourceOf(tenant: string, workspace: string): string | null;
22
95
  /** This account's own data key, or null to use the install key. */
@@ -34,9 +107,76 @@ export interface PlatformHooks {
34
107
  * on its record is resumed rather than destroyed and run again. Default:
35
108
  * nothing is. */
36
109
  sandboxResumable(kind: string): boolean;
110
+ /**
111
+ * Where the platform's built-in tools sit on disk, materialised at boot —
112
+ * a shelf every account reads, beneath its own library. A name found in
113
+ * the workspace wins, then the account's copy, then this. Default: none,
114
+ * which is what a laptop has; there the gallery is a dashboard feature and
115
+ * not a directory, and an agent grants only what it or its account owns.
116
+ */
117
+ galleryDir(): string | null;
118
+ /**
119
+ * An oauth2 secret was just refreshed — `error` null when it worked, the
120
+ * provider's reason when it did not. The platform keeps each connection's
121
+ * health, emails once when a grant is dead, and answers with the one-click
122
+ * reconnect address, which the failing step's error then quotes. Default:
123
+ * nothing to say, which is a laptop.
124
+ */
125
+ noteOAuthRefresh(ctx: {
126
+ tenant: string;
127
+ workspace?: string;
128
+ }, name: string, error: string | null): Promise<string | undefined>;
129
+ /**
130
+ * fetch() for a URL a tenant chose — a webhook, an OAuth token endpoint,
131
+ * a provider base URL, an OpenAPI source. On a platform these run on the
132
+ * worker or web tier, which can reach what a sandbox cannot, so the
133
+ * platform refuses private and cluster addresses. Default: plain fetch,
134
+ * which is a laptop calling its own LAN on purpose. Core calls this only
135
+ * through untrusted-fetch.ts, once per hop with `redirect: "manual"`, so
136
+ * every redirect target is checked here like the first URL was.
137
+ */
138
+ fetchUntrusted(url: string, init?: RequestInit): Promise<Response>;
139
+ /**
140
+ * Hand a notify: webhook to whatever delivers it. The platform records
141
+ * the delivery, makes the first attempt now and retries on a backoff from
142
+ * the worker (`retry: true`); `retry: false` is one attempt, recorded —
143
+ * the test button. Default: one attempt now, recorded nowhere, which is a
144
+ * laptop: there is no worker to retry from and nobody to read the log.
145
+ */
146
+ deliverWebhook(tenant: string, workspace: string, delivery: {
147
+ event: string;
148
+ body: string;
149
+ retry: boolean;
150
+ }): Promise<WebhookDeliveryOutcome>;
151
+ /**
152
+ * Does this person take this optional category of mail — for this
153
+ * workspace, when the category is per workspace? Asked by mail.ts at send
154
+ * time, never for required mail. Default: yes; a laptop keeps no
155
+ * preferences and sends what it always sent.
156
+ */
157
+ mailPreference(tenant: string, email: string, category: string, workspace: string | null): Promise<boolean>;
158
+ }
159
+ /** What handing a webhook over came to: the delivery's id (the
160
+ * X-Foldrun-Delivery header) and its first attempt, when one was made. */
161
+ export interface WebhookDeliveryOutcome {
162
+ ok: boolean;
163
+ deliveryId: string;
164
+ statusCode: number | null;
165
+ /** The receiver's words or the error, trimmed — what the test reports. */
166
+ detail: string;
167
+ /** True when a failed first attempt has later ones scheduled. */
168
+ retrying?: boolean;
37
169
  }
38
170
  export declare const platform: PlatformHooks;
39
171
  /** Install the platform's implementations. Partial: what is not given keeps its local default. */
40
172
  export declare function registerPlatform(hooks: Partial<PlatformHooks>): void;
173
+ /**
174
+ * Does a platform own the queue? True once something registered its own
175
+ * enqueueFlowRun — whatever process asks. An eval used to decide this from
176
+ * FOLDRUN_ROLE === "web", so on a worker it started the run in-process and
177
+ * walked around the queue the platform was counting on (the account cap,
178
+ * the lanes, the per-pod lease). The seam is the fact; the role is a guess.
179
+ */
180
+ export declare function platformQueues(): boolean;
41
181
  /** Back to the local defaults — for tests that register and must not leak. */
42
182
  export declare function resetPlatform(): void;
@@ -12,20 +12,34 @@
12
12
  // and calls registerPlatform() once at boot. A process that forgets to is a
13
13
  // local install — which is exactly the failure that is safe.
14
14
  const local = {
15
- async enqueueFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = []) {
15
+ edition: () => "self-hosted",
16
+ async enqueueFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], startedBy = null, opts = {}) {
16
17
  // Imported here, not at the top: runner.ts imports this file.
17
18
  const { startFlowRun } = await import("./runner.js");
18
- return startFlowRun(tenant, workspace, steps, flowName, modelOverride ?? null, tags);
19
+ return startFlowRun(tenant, workspace, steps, flowName, modelOverride ?? null, tags, null, startedBy, opts);
19
20
  },
20
21
  async enqueueResume() { },
22
+ admitTrigger: async () => ({ admit: true }),
23
+ async rollbackTrigger() { },
24
+ runSpend: (run) => run.steps.reduce((sum, s) => sum + (s.costUsd ?? 0), 0),
21
25
  isolation: {},
22
26
  killRunSandboxes() { },
23
- syncPublicShares: () => ({ added: [] }),
27
+ syncPublicShares: async () => ({ added: [] }),
24
28
  previewSourceOf: () => null,
25
29
  tenantKey: () => null,
26
30
  egress: { lease: async () => null },
27
31
  workspaceChanged() { },
28
32
  sandboxResumable: () => false,
33
+ galleryDir: () => null,
34
+ noteOAuthRefresh: async () => undefined,
35
+ fetchUntrusted: (url, init) => fetch(url, init),
36
+ async deliverWebhook(tenant, workspace, delivery) {
37
+ const { attemptWebhook, newDeliveryId } = await import("./notify.js");
38
+ const deliveryId = newDeliveryId();
39
+ const a = await attemptWebhook(tenant, workspace, { id: deliveryId, event: delivery.event, body: delivery.body, attempt: 1 });
40
+ return { ok: a.ok, deliveryId, statusCode: a.statusCode, detail: a.error ?? a.response ?? "" };
41
+ },
42
+ mailPreference: async () => true,
29
43
  };
30
44
  // One object per PROCESS, not per module instance. A bundler that compiles
31
45
  // the boot file and each route handler separately (Next does) gives every
@@ -44,6 +58,16 @@ export function registerPlatform(hooks) {
44
58
  isolation: { ...platform.isolation, ...(hooks.isolation ?? {}) },
45
59
  });
46
60
  }
61
+ /**
62
+ * Does a platform own the queue? True once something registered its own
63
+ * enqueueFlowRun — whatever process asks. An eval used to decide this from
64
+ * FOLDRUN_ROLE === "web", so on a worker it started the run in-process and
65
+ * walked around the queue the platform was counting on (the account cap,
66
+ * the lanes, the per-pod lease). The seam is the fact; the role is a guess.
67
+ */
68
+ export function platformQueues() {
69
+ return platform.enqueueFlowRun !== local.enqueueFlowRun;
70
+ }
47
71
  /** Back to the local defaults — for tests that register and must not leak. */
48
72
  export function resetPlatform() {
49
73
  Object.assign(platform, local, { isolation: {} });
@@ -0,0 +1,55 @@
1
+ import { type ProviderSpec } from "./store.ts";
2
+ export type HealthVerdict = "ok" | "credential" | "not-found" | "busy" | "unreachable" | "provider-error";
3
+ export interface ProviderCheck {
4
+ /** How this provider is written in the file: its preset name, or its host. */
5
+ provider: string;
6
+ /** The model id actually asked for — what a run would send. */
7
+ model: string;
8
+ verdict: HealthVerdict;
9
+ ok: boolean;
10
+ status: number | null;
11
+ ms: number;
12
+ /** One sentence a person can act on, in the provider's own words where
13
+ * they said anything useful. */
14
+ detail: string;
15
+ }
16
+ /**
17
+ * One request at the provider, in the wire format it speaks, asking for a
18
+ * single token. Never throws: an unreachable host is a verdict, not an
19
+ * exception, because this runs on a tick.
20
+ */
21
+ export declare function checkProvider(tenant: string, workspace: string | null, spec: ProviderSpec, model: string): Promise<ProviderCheck>;
22
+ /** A provider block in use, and where it is declared — because "which file
23
+ * do I edit" is the next question after "this key is dead". */
24
+ export interface ProviderInUse {
25
+ spec: ProviderSpec;
26
+ /** The workspace whose vault resolves this block's secrets. Null for the
27
+ * account's own block, which resolves against the account vault. */
28
+ workspace: string | null;
29
+ /** Human-readable origin: "account", "rank-desk", "rank-desk/writer". */
30
+ declaredIn: string;
31
+ /** The model id a run would send for the cheapest tier, which is what a
32
+ * health check should spend. */
33
+ model: string;
34
+ }
35
+ /**
36
+ * Every distinct provider block this account would actually use, read from
37
+ * the same three places a run reads them: the account, each workspace, each
38
+ * agent.
39
+ *
40
+ * Deduplicated by endpoint and token, because ten agents sharing one gateway
41
+ * are one credential and checking it ten times spends ten times as much to
42
+ * learn the same fact. The origin recorded is the first one seen, which is
43
+ * the broadest — an account block covers everything under it.
44
+ */
45
+ export declare function providersInUse(tenant: string): ProviderInUse[];
46
+ /**
47
+ * Check every provider this account uses, one at a time.
48
+ *
49
+ * Serial on purpose: this is housekeeping on a tick, the whole point is that
50
+ * it is cheap, and firing twelve model requests at once to save eight
51
+ * seconds nobody is waiting for would be a strange trade.
52
+ */
53
+ export declare function checkAccountProviders(tenant: string): Promise<(ProviderCheck & {
54
+ declaredIn: string;
55
+ })[]>;
@@ -0,0 +1,233 @@
1
+ // Is the key still good?
2
+ //
3
+ // A customer's own model key can be revoked, expire, run out of credit or
4
+ // have its base URL moved, and today the platform finds out the way the
5
+ // customer does: a desk fails at five in the morning. Twenty-seven of the
6
+ // thirty provider presets have never had an agent run through them from
7
+ // this runtime at all, so "it worked when we set it up" is doing a lot of
8
+ // work in that sentence.
9
+ //
10
+ // This is deliberately NOT the tool-loop probe next door. `foldrun probe`
11
+ // asks "can this model drive an agent", which is a question about a model
12
+ // and is asked once. This asks "does this endpoint still take our key",
13
+ // which is a question about a credential and has to be asked repeatedly —
14
+ // so it has to be cheap. One request, one token of output, no SDK, no tool
15
+ // loop, no agent. A fraction of a cent, and it answers the thing that
16
+ // actually breaks.
17
+ //
18
+ // It reads the same provider block a run reads, resolved the same way, so a
19
+ // pass here means a run would reach the same place with the same key.
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import matter from "gray-matter";
23
+ import { getSecret } from "./secrets.js";
24
+ import { noteSecretUse, healthKey } from "./secret-health.js";
25
+ import { accountDir, workspaceDir, listWorkspaces, listAgents, parseProvider } from "./store.js";
26
+ import { fetchUntrusted } from "./untrusted-fetch.js";
27
+ /** How long to wait. A provider slower than this is not one a step should
28
+ * be waiting on either, and the check must never hold a tick. */
29
+ const TIMEOUT_MS = 15_000;
30
+ /** What each status means for a credential, and what a person does about
31
+ * it. Kept as a table because the mapping IS the feature: "401" tells you
32
+ * nothing you can act on, "the key is no longer accepted" tells you
33
+ * everything. */
34
+ function verdictFor(status) {
35
+ if (status >= 200 && status < 300)
36
+ return { verdict: "ok", detail: "the key was accepted and the model answered" };
37
+ if (status === 401 || status === 403) {
38
+ return { verdict: "credential", detail: "the key is no longer accepted — revoked, rotated, or wrong for this endpoint" };
39
+ }
40
+ if (status === 402)
41
+ return { verdict: "credential", detail: "the account behind this key is out of credit" };
42
+ if (status === 404) {
43
+ return { verdict: "not-found", detail: "the endpoint or the model id does not exist there — check base_url and models:" };
44
+ }
45
+ if (status === 429) {
46
+ // Not a health problem. A busy provider is a provider that is working,
47
+ // and reporting it as broken would train people to ignore the alert.
48
+ return { verdict: "busy", detail: "rate limited right now — the key is fine" };
49
+ }
50
+ if (status >= 500)
51
+ return { verdict: "provider-error", detail: "the provider is failing at its end" };
52
+ return { verdict: "provider-error", detail: `unexpected HTTP ${status}` };
53
+ }
54
+ /** `${SECRET}` in a token or header, filled from this account's vault. */
55
+ function resolve(tenant, workspace, raw) {
56
+ return raw.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
57
+ const hit = getSecret(tenant, name, workspace ?? undefined);
58
+ return hit ? hit.value : whole;
59
+ });
60
+ }
61
+ /**
62
+ * One request at the provider, in the wire format it speaks, asking for a
63
+ * single token. Never throws: an unreachable host is a verdict, not an
64
+ * exception, because this runs on a tick.
65
+ */
66
+ export async function checkProvider(tenant, workspace, spec, model) {
67
+ const label = spec.name || (() => {
68
+ try {
69
+ return new URL(spec.baseUrl).host;
70
+ }
71
+ catch {
72
+ return spec.baseUrl || "provider";
73
+ }
74
+ })();
75
+ const began = Date.now();
76
+ const fail = (verdict, detail, status = null) => ({
77
+ provider: label,
78
+ model,
79
+ verdict,
80
+ ok: false,
81
+ status,
82
+ ms: Date.now() - began,
83
+ detail,
84
+ });
85
+ const token = resolve(tenant, workspace, spec.token);
86
+ if (!token || token.includes("${")) {
87
+ return fail("credential", `the secret named in provider.token is not in this account's vault (${spec.token})`);
88
+ }
89
+ if (!spec.baseUrl)
90
+ return fail("unreachable", "this provider block has no base_url");
91
+ // The three wire formats, each asking for the smallest possible answer.
92
+ const base = spec.baseUrl.replace(/\/+$/, "");
93
+ const anthropic = spec.format === "anthropic";
94
+ const url = anthropic
95
+ ? `${base}/v1/messages`
96
+ : spec.format === "responses"
97
+ ? `${base}/responses`
98
+ : `${base}/chat/completions`;
99
+ const body = anthropic
100
+ ? { model, max_tokens: 1, messages: [{ role: "user", content: "hi" }] }
101
+ : spec.format === "responses"
102
+ ? { model, max_output_tokens: 16, input: "hi" }
103
+ : { model, max_tokens: 1, messages: [{ role: "user", content: "hi" }] };
104
+ const headers = { "content-type": "application/json" };
105
+ // An Anthropic-format endpoint takes the key in whichever header its
106
+ // `auth:` says; everything else is a bearer token.
107
+ if (anthropic && spec.auth === "x-api-key") {
108
+ headers["x-api-key"] = token;
109
+ headers["anthropic-version"] = "2023-06-01";
110
+ }
111
+ else {
112
+ headers.authorization = `Bearer ${token}`;
113
+ if (anthropic)
114
+ headers["anthropic-version"] = "2023-06-01";
115
+ }
116
+ for (const [k, v] of Object.entries(spec.headers ?? {}))
117
+ headers[k.toLowerCase()] = resolve(tenant, workspace, v);
118
+ try {
119
+ const res = await fetchUntrusted(url, {
120
+ method: "POST",
121
+ headers,
122
+ body: JSON.stringify(body),
123
+ signal: AbortSignal.timeout(TIMEOUT_MS),
124
+ }, { sameOrigin: true });
125
+ const { verdict, detail } = verdictFor(res.status);
126
+ // The check is itself a use of the credential, and the most informative
127
+ // one there is: it was made for exactly this purpose.
128
+ const named = spec.token.match(/^\$\{([A-Z][A-Z0-9_]*)\}$/)?.[1];
129
+ if (named) {
130
+ let host = spec.baseUrl;
131
+ try {
132
+ host = new URL(spec.baseUrl).host;
133
+ }
134
+ catch {
135
+ // an unparseable base_url is its own problem, reported above
136
+ }
137
+ const hit = getSecret(tenant, named, workspace ?? undefined);
138
+ noteSecretUse(tenant, healthKey(named, hit?.scope === "workspace" ? "workspace" : "account", workspace), { host, status: res.status });
139
+ }
140
+ // The provider's own words when it refused. They are the difference
141
+ // between "fix your key" and "fix your model id", and they are only
142
+ // ever in the body.
143
+ let said = "";
144
+ if (!res.ok) {
145
+ said = (await res.text().catch(() => "")).replace(/\s+/g, " ").slice(0, 300);
146
+ }
147
+ return {
148
+ provider: label,
149
+ model,
150
+ verdict,
151
+ // Busy counts as healthy: the key worked, the provider is simply full.
152
+ ok: verdict === "ok" || verdict === "busy",
153
+ status: res.status,
154
+ ms: Date.now() - began,
155
+ detail: said ? `${detail} — ${said}` : detail,
156
+ };
157
+ }
158
+ catch (err) {
159
+ const message = err instanceof Error ? err.message : String(err);
160
+ return fail("unreachable", /timeout|aborted/i.test(message) ? `no answer within ${TIMEOUT_MS / 1000}s` : message);
161
+ }
162
+ }
163
+ /** The cheapest tier's id for a provider block: what it calls `fast`, or
164
+ * our own fast model when it remaps nothing. */
165
+ function cheapestModel(spec) {
166
+ return spec.models.fast || spec.models.default || spec.models.max || "claude-haiku-4-5-20251001";
167
+ }
168
+ function frontmatterOf(file) {
169
+ try {
170
+ return (matter(fs.readFileSync(file, "utf8")).data ?? {});
171
+ }
172
+ catch {
173
+ return {};
174
+ }
175
+ }
176
+ /**
177
+ * Every distinct provider block this account would actually use, read from
178
+ * the same three places a run reads them: the account, each workspace, each
179
+ * agent.
180
+ *
181
+ * Deduplicated by endpoint and token, because ten agents sharing one gateway
182
+ * are one credential and checking it ten times spends ten times as much to
183
+ * learn the same fact. The origin recorded is the first one seen, which is
184
+ * the broadest — an account block covers everything under it.
185
+ */
186
+ export function providersInUse(tenant) {
187
+ const out = [];
188
+ const seen = new Set();
189
+ const add = (raw, workspace, declaredIn) => {
190
+ const spec = parseProvider(raw);
191
+ if (!spec || !spec.baseUrl)
192
+ return;
193
+ // The token is part of the identity: the same gateway with two different
194
+ // keys is two credentials, and either can die on its own.
195
+ const key = `${workspace ?? ""}|${spec.baseUrl}|${spec.token}|${cheapestModel(spec)}`;
196
+ if (seen.has(key))
197
+ return;
198
+ seen.add(key);
199
+ out.push({ spec, workspace, declaredIn, model: cheapestModel(spec) });
200
+ };
201
+ add(frontmatterOf(path.join(accountDir(tenant), "AGENTS.md")).provider, null, "account");
202
+ for (const ws of safeList(() => listWorkspaces(tenant))) {
203
+ const dir = workspaceDir(tenant, ws.name);
204
+ add(frontmatterOf(path.join(dir, "AGENTS.md")).provider, ws.name, ws.name);
205
+ for (const agent of safeList(() => listAgents(tenant, ws.name))) {
206
+ add(frontmatterOf(path.join(dir, "agents", agent.name, "agent.md")).provider, ws.name, `${ws.name}/${agent.name}`);
207
+ }
208
+ }
209
+ return out;
210
+ }
211
+ function safeList(fn) {
212
+ try {
213
+ return fn();
214
+ }
215
+ catch {
216
+ return [];
217
+ }
218
+ }
219
+ /**
220
+ * Check every provider this account uses, one at a time.
221
+ *
222
+ * Serial on purpose: this is housekeeping on a tick, the whole point is that
223
+ * it is cheap, and firing twelve model requests at once to save eight
224
+ * seconds nobody is waiting for would be a strange trade.
225
+ */
226
+ export async function checkAccountProviders(tenant) {
227
+ const results = [];
228
+ for (const use of providersInUse(tenant)) {
229
+ const check = await checkProvider(tenant, use.workspace, use.spec, use.model);
230
+ results.push({ ...check, declaredIn: use.declaredIn });
231
+ }
232
+ return results;
233
+ }