@foldrun/core 5.0.0 → 7.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.
- package/dist/src/evals.js +11 -2
- package/dist/src/model-env.d.ts +12 -3
- package/dist/src/model-env.js +44 -6
- package/dist/src/platform.d.ts +24 -0
- package/dist/src/platform.js +35 -1
- package/dist/src/runner.d.ts +2 -0
- package/dist/src/secrets.d.ts +22 -0
- package/dist/src/secrets.js +174 -25
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +73 -0
- package/dist/src/webhook.js +46 -6
- package/package.json +1 -1
- package/src/evals.ts +9 -2
- package/src/model-env.ts +42 -7
- package/src/platform.ts +45 -1
- package/src/secrets.ts +189 -26
- package/src/translator.ts +83 -2
- package/src/webhook.ts +44 -5
package/dist/src/evals.js
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
// Deterministic checks run first and cost nothing. The judge only runs if
|
|
31
31
|
// they pass — no point paying a model to grade output already known to be
|
|
32
32
|
// wrong.
|
|
33
|
-
import { accountModelEnv, withModelEnv } from "./model-env.js";
|
|
33
|
+
import { accountModelEnv, withModelEnv, ModelKeyError } from "./model-env.js";
|
|
34
34
|
import { sdkEnv } from "./step-exec.js";
|
|
35
35
|
import { platformModelEnv } from "./runner.js";
|
|
36
36
|
import fs from "node:fs";
|
|
@@ -582,7 +582,16 @@ export async function checkAssertion(assertion, output, agentDir, model, effort,
|
|
|
582
582
|
catch (err) {
|
|
583
583
|
return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
|
|
584
584
|
}
|
|
585
|
-
|
|
585
|
+
let said;
|
|
586
|
+
try {
|
|
587
|
+
said = await withModelEnv(m, (env) => judgeCall(assertion.value, output, model, effort, env));
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
// The provider's URL refused on the host (FOL-95), like a missing key above.
|
|
591
|
+
if (!(err instanceof ModelKeyError))
|
|
592
|
+
throw err;
|
|
593
|
+
return { assertion, passed: false, detail: `judge not run — ${err.message}` };
|
|
594
|
+
}
|
|
586
595
|
const { passed, detail } = judgeVerdict(said.text);
|
|
587
596
|
return { assertion, passed, detail, costUsd: said.costUsd };
|
|
588
597
|
}
|
package/dist/src/model-env.d.ts
CHANGED
|
@@ -19,11 +19,20 @@ export declare function accountModelEnv(tenant: string, workspace: string, platf
|
|
|
19
19
|
translator: TranslatorSpec | null;
|
|
20
20
|
};
|
|
21
21
|
/**
|
|
22
|
-
* Run `fn` with the model env, through a translator on this host
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* Run `fn` with the model env, through a translator on this host — the one a
|
|
23
|
+
* step uses (runner.ts) when the provider speaks Chat Completions or
|
|
24
|
+
* Responses, and a passthrough when it speaks Anthropic's Messages (FOL-95):
|
|
25
|
+
* either way the SDK talks only to loopback, and the tenant's endpoint is
|
|
26
|
+
* reached through fetchUntrusted, after the platform's check of its URL.
|
|
27
|
+
* The platform's own credential (`supply: "platform"`) goes as it always did.
|
|
28
|
+
* Started for this call and closed after it.
|
|
25
29
|
*/
|
|
26
30
|
export declare function withModelEnv<T>(m: {
|
|
27
31
|
env: Record<string, string | undefined>;
|
|
28
32
|
translator?: TranslatorSpec | null;
|
|
33
|
+
supply?: "provider" | "platform";
|
|
29
34
|
}, fn: (env: Record<string, string | undefined>) => Promise<T>): Promise<T>;
|
|
35
|
+
/** The passthrough for an Anthropic-format provider, read back from the env
|
|
36
|
+
* providerEnvFor (store.ts) built for it: the base URL, the key in the slot
|
|
37
|
+
* that names its header, and ANTHROPIC_CUSTOM_HEADERS. Null with no base. */
|
|
38
|
+
export declare function anthropicPassthrough(env: Record<string, string | undefined>): TranslatorSpec | null;
|
package/dist/src/model-env.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { accountDir, workspaceDir, parseProvider, providerEnvFor } from "./store.js";
|
|
9
9
|
import { readAgentsMd } from "./runner.js";
|
|
10
10
|
import { getSecret } from "./secrets.js";
|
|
11
|
-
import { platform } from "./platform.js";
|
|
11
|
+
import { platform, hostUrlProblem } from "./platform.js";
|
|
12
12
|
import { accountModelBlock, isClaudeLoginToken } from "./model-credential.js";
|
|
13
13
|
import { providerPreset } from "./providers.js";
|
|
14
14
|
import { startTranslator, translatorSpecFor } from "./translator.js";
|
|
@@ -39,6 +39,13 @@ export function accountModelEnv(tenant, workspace, platformEnv) {
|
|
|
39
39
|
throw new ModelKeyError(NO_MODEL_KEY);
|
|
40
40
|
return { env: platformEnv(), supply: "platform", format: "anthropic", translator: null };
|
|
41
41
|
}
|
|
42
|
+
// A host-side call to a URL the tenant wrote: on a platform, https only
|
|
43
|
+
// (platform.ts hostUrlProblem); withModelEnv asks the platform's own check
|
|
44
|
+
// too, and the guard sees every byte (FOL-95). A step resolves its
|
|
45
|
+
// provider elsewhere (runner.ts) and is not touched by this.
|
|
46
|
+
const refused = hostUrlProblem(spec.baseUrl);
|
|
47
|
+
if (refused)
|
|
48
|
+
throw new ModelKeyError(`provider base_url refused: ${refused}`, 422);
|
|
42
49
|
const sub = (text) => text.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => getSecret(tenant, name, workspace)?.value ?? whole);
|
|
43
50
|
const token = sub(spec.token);
|
|
44
51
|
if (token.includes("${"))
|
|
@@ -65,14 +72,22 @@ export function accountModelEnv(tenant, workspace, platformEnv) {
|
|
|
65
72
|
};
|
|
66
73
|
}
|
|
67
74
|
/**
|
|
68
|
-
* Run `fn` with the model env, through a translator on this host
|
|
69
|
-
*
|
|
70
|
-
*
|
|
75
|
+
* Run `fn` with the model env, through a translator on this host — the one a
|
|
76
|
+
* step uses (runner.ts) when the provider speaks Chat Completions or
|
|
77
|
+
* Responses, and a passthrough when it speaks Anthropic's Messages (FOL-95):
|
|
78
|
+
* either way the SDK talks only to loopback, and the tenant's endpoint is
|
|
79
|
+
* reached through fetchUntrusted, after the platform's check of its URL.
|
|
80
|
+
* The platform's own credential (`supply: "platform"`) goes as it always did.
|
|
81
|
+
* Started for this call and closed after it.
|
|
71
82
|
*/
|
|
72
83
|
export async function withModelEnv(m, fn) {
|
|
73
|
-
|
|
84
|
+
const spec = m.translator ?? (m.supply === "provider" ? anthropicPassthrough(m.env) : null);
|
|
85
|
+
if (!spec)
|
|
74
86
|
return fn(m.env);
|
|
75
|
-
const
|
|
87
|
+
const why = await platform.checkUntrustedUrl(spec.upstreamBase);
|
|
88
|
+
if (why)
|
|
89
|
+
throw new ModelKeyError(`provider base_url refused: ${why}`, 422);
|
|
90
|
+
const t = await startTranslator(spec);
|
|
76
91
|
try {
|
|
77
92
|
return await fn({ ...m.env, ...t.env });
|
|
78
93
|
}
|
|
@@ -80,3 +95,26 @@ export async function withModelEnv(m, fn) {
|
|
|
80
95
|
await t.close();
|
|
81
96
|
}
|
|
82
97
|
}
|
|
98
|
+
/** The passthrough for an Anthropic-format provider, read back from the env
|
|
99
|
+
* providerEnvFor (store.ts) built for it: the base URL, the key in the slot
|
|
100
|
+
* that names its header, and ANTHROPIC_CUSTOM_HEADERS. Null with no base. */
|
|
101
|
+
export function anthropicPassthrough(env) {
|
|
102
|
+
const base = env.ANTHROPIC_BASE_URL;
|
|
103
|
+
if (!base)
|
|
104
|
+
return null;
|
|
105
|
+
const apiKey = env.ANTHROPIC_API_KEY ?? "";
|
|
106
|
+
const headers = {};
|
|
107
|
+
for (const line of (env.ANTHROPIC_CUSTOM_HEADERS ?? "").split("\n")) {
|
|
108
|
+
const at = line.indexOf(":");
|
|
109
|
+
if (at > 0)
|
|
110
|
+
headers[line.slice(0, at).trim()] = line.slice(at + 1).trim();
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
wire: "anthropic",
|
|
114
|
+
upstreamBase: base,
|
|
115
|
+
upstreamKey: apiKey || (env.ANTHROPIC_AUTH_TOKEN ?? ""),
|
|
116
|
+
upstreamAuth: apiKey ? "x-api-key" : "bearer",
|
|
117
|
+
headers,
|
|
118
|
+
label: base,
|
|
119
|
+
};
|
|
120
|
+
}
|
package/dist/src/platform.d.ts
CHANGED
|
@@ -175,6 +175,16 @@ export interface PlatformHooks {
|
|
|
175
175
|
fetchUntrusted(url: string, init?: RequestInit, opts?: {
|
|
176
176
|
maxBytes?: number;
|
|
177
177
|
}): Promise<Response>;
|
|
178
|
+
/**
|
|
179
|
+
* May the host call this URL a tenant chose — a provider's base_url,
|
|
180
|
+
* before an eval's judge or a dashboard draft talks to it? A reason it may
|
|
181
|
+
* not, or null. This is the early refusal people can read; the guarantee
|
|
182
|
+
* is that every byte of such a call goes through fetchUntrusted (FOL-95).
|
|
183
|
+
* Default: hostUrlProblem below — https on an install that guards
|
|
184
|
+
* untrusted fetches, anything on a laptop (its own localhost included).
|
|
185
|
+
* A platform may bind a stricter check (the addresses a name resolves to).
|
|
186
|
+
*/
|
|
187
|
+
checkUntrustedUrl(url: string): Promise<string | null>;
|
|
178
188
|
/**
|
|
179
189
|
* Hand a notify: webhook to whatever delivers it. The platform records
|
|
180
190
|
* the delivery, makes the first attempt now and retries on a backoff from
|
|
@@ -207,6 +217,20 @@ export interface WebhookDeliveryOutcome {
|
|
|
207
217
|
retrying?: boolean;
|
|
208
218
|
}
|
|
209
219
|
export declare const platform: PlatformHooks;
|
|
220
|
+
/** True when this process fetches tenant-chosen URLs through a guard: a
|
|
221
|
+
* platform bound fetchUntrusted. A laptop has not, and calls its own LAN on
|
|
222
|
+
* purpose. */
|
|
223
|
+
export declare function guardsUntrustedUrls(): boolean;
|
|
224
|
+
/**
|
|
225
|
+
* Why the host may not call a tenant's model endpoint at this URL, or null.
|
|
226
|
+
* On an install that guards untrusted fetches the endpoint must be https: the
|
|
227
|
+
* key rides in every request, and a plain-http base is also how a tenant
|
|
228
|
+
* names a cluster service. Private and internal addresses are refused by the
|
|
229
|
+
* guard itself, on every hop. FOLDRUN_EGRESS_ALLOW_PRIVATE=1 — the operator's
|
|
230
|
+
* own install calling its own network — lifts this as it lifts the guard. A
|
|
231
|
+
* laptop: always null.
|
|
232
|
+
*/
|
|
233
|
+
export declare function hostUrlProblem(url: string): string | null;
|
|
210
234
|
/** Install the platform's implementations. Partial: what is not given keeps its local default. */
|
|
211
235
|
export declare function registerPlatform(hooks: Partial<PlatformHooks>): void;
|
|
212
236
|
/**
|
package/dist/src/platform.js
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
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
|
import { matchFilter } from "./trigger-filter.js";
|
|
15
|
+
/** Marks the laptop's fetchUntrusted. A symbol from the global registry, not
|
|
16
|
+
* the function itself: every bundle has its own copy of this module (see
|
|
17
|
+
* KEY below), and only the shared object says which one was registered. */
|
|
18
|
+
const LAPTOP_FETCH = Symbol.for("foldrun.laptop-fetch");
|
|
15
19
|
const local = {
|
|
16
20
|
edition: () => "self-hosted",
|
|
17
21
|
async enqueueFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], startedBy = null, opts = {}) {
|
|
@@ -40,7 +44,8 @@ const local = {
|
|
|
40
44
|
platformKeyAllowed: () => true,
|
|
41
45
|
galleryDir: () => null,
|
|
42
46
|
noteOAuthRefresh: async () => undefined,
|
|
43
|
-
fetchUntrusted: (url, init) => fetch(url, init),
|
|
47
|
+
fetchUntrusted: Object.assign((url, init) => fetch(url, init), { [LAPTOP_FETCH]: true }),
|
|
48
|
+
checkUntrustedUrl: async (url) => hostUrlProblem(url),
|
|
44
49
|
async deliverWebhook(tenant, workspace, delivery) {
|
|
45
50
|
const { attemptWebhook, newDeliveryId } = await import("./notify.js");
|
|
46
51
|
const deliveryId = newDeliveryId();
|
|
@@ -60,6 +65,35 @@ const local = {
|
|
|
60
65
|
const KEY = Symbol.for("foldrun.platform");
|
|
61
66
|
const g = globalThis;
|
|
62
67
|
export const platform = (g[KEY] ??= { ...local });
|
|
68
|
+
/** True when this process fetches tenant-chosen URLs through a guard: a
|
|
69
|
+
* platform bound fetchUntrusted. A laptop has not, and calls its own LAN on
|
|
70
|
+
* purpose. */
|
|
71
|
+
export function guardsUntrustedUrls() {
|
|
72
|
+
return !platform.fetchUntrusted[LAPTOP_FETCH];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Why the host may not call a tenant's model endpoint at this URL, or null.
|
|
76
|
+
* On an install that guards untrusted fetches the endpoint must be https: the
|
|
77
|
+
* key rides in every request, and a plain-http base is also how a tenant
|
|
78
|
+
* names a cluster service. Private and internal addresses are refused by the
|
|
79
|
+
* guard itself, on every hop. FOLDRUN_EGRESS_ALLOW_PRIVATE=1 — the operator's
|
|
80
|
+
* own install calling its own network — lifts this as it lifts the guard. A
|
|
81
|
+
* laptop: always null.
|
|
82
|
+
*/
|
|
83
|
+
export function hostUrlProblem(url) {
|
|
84
|
+
if (!guardsUntrustedUrls() || process.env.FOLDRUN_EGRESS_ALLOW_PRIVATE === "1")
|
|
85
|
+
return null;
|
|
86
|
+
let u;
|
|
87
|
+
try {
|
|
88
|
+
u = new URL(url);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return `${url} is not a URL`;
|
|
92
|
+
}
|
|
93
|
+
if (u.protocol !== "https:")
|
|
94
|
+
return `${u.origin} is not https — this platform calls a model endpoint only over https`;
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
63
97
|
/** Install the platform's implementations. Partial: what is not given keeps its local default. */
|
|
64
98
|
export function registerPlatform(hooks) {
|
|
65
99
|
Object.assign(platform, hooks, {
|
package/dist/src/runner.d.ts
CHANGED
|
@@ -387,6 +387,7 @@ grantsOnly?: boolean): {
|
|
|
387
387
|
upstreamBase: string;
|
|
388
388
|
wire?: import("./translator.ts").TranslatorWire;
|
|
389
389
|
upstreamKey: string;
|
|
390
|
+
upstreamAuth?: "bearer" | "x-api-key";
|
|
390
391
|
headers?: Record<string, string>;
|
|
391
392
|
maxTokensParam?: "max_tokens" | "max_completion_tokens";
|
|
392
393
|
reasoningEffort?: boolean;
|
|
@@ -400,6 +401,7 @@ grantsOnly?: boolean): {
|
|
|
400
401
|
upstreamBase: string;
|
|
401
402
|
wire?: import("./translator.ts").TranslatorWire;
|
|
402
403
|
upstreamKey: string;
|
|
404
|
+
upstreamAuth?: "bearer" | "x-api-key";
|
|
403
405
|
headers?: Record<string, string>;
|
|
404
406
|
maxTokensParam?: "max_tokens" | "max_completion_tokens";
|
|
405
407
|
reasoningEffort?: boolean;
|
package/dist/src/secrets.d.ts
CHANGED
|
@@ -31,8 +31,12 @@ interface StoredSecret {
|
|
|
31
31
|
* these sites alone. Metadata, not a value, so it is stored in the clear
|
|
32
32
|
* beside the ciphertext and survives the value being set again. */
|
|
33
33
|
sites?: string[];
|
|
34
|
+
/** 2: sealed with the record's place as AAD — account, scope, name, kind,
|
|
35
|
+
* key and sites (vaultAad). Absent: the older, unbound form. */
|
|
36
|
+
v?: 2;
|
|
34
37
|
}
|
|
35
38
|
type SecretsFile = Record<string, StoredSecret>;
|
|
39
|
+
declare const writeBound: () => boolean;
|
|
36
40
|
/**
|
|
37
41
|
* Change one vault file safely: `change` gets what is stored now and returns
|
|
38
42
|
* the new contents, or null to leave the file untouched. Returns what is
|
|
@@ -183,6 +187,11 @@ export declare function materializeSecrets(env: Record<string, string>,
|
|
|
183
187
|
ctx?: {
|
|
184
188
|
tenant: string;
|
|
185
189
|
workspace?: string;
|
|
190
|
+
/** A rotated refresh token goes here INSTEAD of the vault: a caller that
|
|
191
|
+
* cannot open the vault (the egress proxy without the install key)
|
|
192
|
+
* keeps it itself and has it written by a process that can. `next` is
|
|
193
|
+
* the stored value with the new refresh token. */
|
|
194
|
+
onRotate?: (name: string, usedValue: string, next: string) => unknown;
|
|
186
195
|
}): Promise<Record<string, string>>;
|
|
187
196
|
export interface ServiceAccountConfig {
|
|
188
197
|
token_url: string;
|
|
@@ -254,6 +263,19 @@ export declare function basicAuthorization(username: string, password: string):
|
|
|
254
263
|
export declare function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string): void;
|
|
255
264
|
export declare function isApiValue(value: string): boolean;
|
|
256
265
|
export declare function apiConfigOf(value: string): ApiConfig;
|
|
266
|
+
/**
|
|
267
|
+
* Seal every unbound record of an account for its place (v2). Run at boot
|
|
268
|
+
* once the install writes bound records (FOLDRUN_VAULT_WRITE_BOUND=1), and by
|
|
269
|
+
* scripts/seal-vault-bindings.mjs. Per file, all or nothing: every unbound
|
|
270
|
+
* value is opened first, and a file holding one that will not open is left
|
|
271
|
+
* exactly as it is and reported. Idempotent: bound records are skipped.
|
|
272
|
+
*/
|
|
273
|
+
export declare function sealVaultBindings(tenant: string): {
|
|
274
|
+
sealed: number;
|
|
275
|
+
unreadable: string[];
|
|
276
|
+
};
|
|
277
|
+
/** Whether this install writes bound vault records (and migrates at boot). */
|
|
278
|
+
export declare const vaultWritesBound: typeof writeBound;
|
|
257
279
|
/**
|
|
258
280
|
* Move an account's secrets from the install key onto its own key.
|
|
259
281
|
*
|
package/dist/src/secrets.js
CHANGED
|
@@ -56,6 +56,12 @@ export function installKeyMaterial() {
|
|
|
56
56
|
const fromEnv = process.env.FOLDRUN_SECRET_KEY;
|
|
57
57
|
if (fromEnv)
|
|
58
58
|
return fromEnv;
|
|
59
|
+
// A process that must never hold the install key (the egress proxy) says
|
|
60
|
+
// so; without this, asking for it would quietly mint a NEW random key on
|
|
61
|
+
// the shared data volume — a second root key nobody chose.
|
|
62
|
+
if (process.env.FOLDRUN_INSTALL_KEY_ABSENT === "1") {
|
|
63
|
+
throw new Error("this process runs without the install key (FOLDRUN_INSTALL_KEY_ABSENT=1) — whatever asked for it belongs in the worker");
|
|
64
|
+
}
|
|
59
65
|
fs.mkdirSync(dataRoot(), { recursive: true });
|
|
60
66
|
if (!fs.existsSync(keyFile())) {
|
|
61
67
|
fs.writeFileSync(keyFile(), crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
|
|
@@ -76,6 +82,81 @@ function secretsFile(tenant, workspace) {
|
|
|
76
82
|
const base = fs.existsSync(legacy) ? legacy : path.join(dataRoot(), tenant, "workspaces", workspace);
|
|
77
83
|
return path.join(base, "secrets.json");
|
|
78
84
|
}
|
|
85
|
+
// ------------------------------------------------------------ binding
|
|
86
|
+
//
|
|
87
|
+
// A v2 record is sealed with WHERE it belongs as additional authenticated
|
|
88
|
+
// data: the account, the scope (account or which workspace), the name, its
|
|
89
|
+
// kind, which key sealed it, and the sites it is bound to. Before 29 Oct 2026
|
|
90
|
+
// (OWASP review) a record was bound to nothing: anyone who could write the
|
|
91
|
+
// data volume or a backup could move one secret's ciphertext under another
|
|
92
|
+
// name, scope or account, or rewrite its clear `sites` and have the egress
|
|
93
|
+
// proxy fill it for their own host. Now any of those makes it fail to open,
|
|
94
|
+
// which reads as "not set" — never as unbound.
|
|
95
|
+
//
|
|
96
|
+
// The place comes from the FILE the record is read from, never from fields
|
|
97
|
+
// inside it. Sites stay in clear beside the ciphertext (listing needs no
|
|
98
|
+
// key, and a pod from before v2 reads them there) and are authenticated by
|
|
99
|
+
// the AAD.
|
|
100
|
+
//
|
|
101
|
+
// Rolled out in steps: every release since reads v2; FOLDRUN_VAULT_WRITE_BOUND=1
|
|
102
|
+
// writes it and migrates existing records at boot; FOLDRUN_VAULT_REQUIRE_BOUND=1
|
|
103
|
+
// refuses the old form once none is left.
|
|
104
|
+
// The scope is account or workspace, not WHICH workspace: a workspace can be
|
|
105
|
+
// renamed (its folder moves with its vault), and binding the name would make
|
|
106
|
+
// every rename a re-seal racing other writers. What that leaves open — a
|
|
107
|
+
// ciphertext moved between two workspaces of the same account by someone who
|
|
108
|
+
// can already write the volume — is the residual, and stays inside one account.
|
|
109
|
+
function vaultAad(tenant, workspace, name, rec) {
|
|
110
|
+
if (rec.sites !== undefined && (!Array.isArray(rec.sites) || rec.sites.some((x) => typeof x !== "string"))) {
|
|
111
|
+
throw new Error("malformed sites");
|
|
112
|
+
}
|
|
113
|
+
return Buffer.from(JSON.stringify(["foldrun-vault/2", tenant, workspace ? "w" : "a", name, rec.kind ?? "", rec.k ?? "install", [...(rec.sites ?? [])].sort()]), "utf8");
|
|
114
|
+
}
|
|
115
|
+
const writeBound = () => process.env.FOLDRUN_VAULT_WRITE_BOUND === "1";
|
|
116
|
+
const requireBound = () => process.env.FOLDRUN_VAULT_REQUIRE_BOUND === "1";
|
|
117
|
+
/** One record, sealed for its place. Bound (v2) when the install writes bound
|
|
118
|
+
* records or `bound` says so; the unbound form otherwise. */
|
|
119
|
+
function sealRecord(tenant, workspace, name, value, meta, bound = writeBound()) {
|
|
120
|
+
const { key, mark } = keyFor(tenant);
|
|
121
|
+
const rec = {
|
|
122
|
+
iv: "",
|
|
123
|
+
tag: "",
|
|
124
|
+
data: "",
|
|
125
|
+
updatedAt: meta.updatedAt ?? new Date().toISOString(),
|
|
126
|
+
...(meta.kind ? { kind: meta.kind } : {}),
|
|
127
|
+
...(mark ? { k: mark } : {}),
|
|
128
|
+
...(meta.sites?.length ? { sites: [...meta.sites] } : {}),
|
|
129
|
+
};
|
|
130
|
+
const iv = crypto.randomBytes(12);
|
|
131
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
|
|
132
|
+
if (bound) {
|
|
133
|
+
cipher.setAAD(vaultAad(tenant, workspace, name, rec));
|
|
134
|
+
rec.v = 2;
|
|
135
|
+
}
|
|
136
|
+
const enc = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
137
|
+
rec.iv = iv.toString("base64");
|
|
138
|
+
rec.tag = cipher.getAuthTag().toString("base64");
|
|
139
|
+
rec.data = enc.toString("base64");
|
|
140
|
+
return rec;
|
|
141
|
+
}
|
|
142
|
+
/** A record's value, opened for its place; null when it will not open. */
|
|
143
|
+
function openRecord(tenant, workspace, name, rec) {
|
|
144
|
+
if (!rec)
|
|
145
|
+
return null;
|
|
146
|
+
if (rec.v === 2) {
|
|
147
|
+
// Metadata someone rewrote into a shape that is not a list fails closed,
|
|
148
|
+
// like a bad tag — never as an exception up the read path.
|
|
149
|
+
let aad;
|
|
150
|
+
try {
|
|
151
|
+
aad = vaultAad(tenant, workspace, name, rec);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
return decrypt(tenant, rec, aad);
|
|
157
|
+
}
|
|
158
|
+
return requireBound() ? null : decrypt(tenant, rec);
|
|
159
|
+
}
|
|
79
160
|
function read(tenant, workspace) {
|
|
80
161
|
return readFile(secretsFile(tenant, workspace));
|
|
81
162
|
}
|
|
@@ -162,18 +243,15 @@ export function setSecret(tenant, name, value, workspace, kind) {
|
|
|
162
243
|
}
|
|
163
244
|
if (value.length > 8192)
|
|
164
245
|
throw new Error("secret too long");
|
|
165
|
-
const sealed = encryptValue(tenant, value);
|
|
166
246
|
mutateVaultFile(secretsFile(tenant, workspace), (all) => {
|
|
167
247
|
// A new value keeps the sites the old one was bound to: binding is a
|
|
168
248
|
// property of the credential's use, not of its current value, and a
|
|
169
249
|
// rotation that silently unbound it would put the new value in pods.
|
|
250
|
+
// Sealed here, under the lock: a bound record's AAD names those sites.
|
|
170
251
|
const sites = all[name]?.sites;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
...(kind ? { kind } : {}),
|
|
175
|
-
...(sites?.length ? { sites } : {}),
|
|
176
|
-
};
|
|
252
|
+
// Once bound, always bound: a re-save with the switch off must not
|
|
253
|
+
// quietly drop the record's authentication.
|
|
254
|
+
all[name] = sealRecord(tenant, workspace, name, value, { kind, sites }, all[name]?.v === 2 || writeBound());
|
|
177
255
|
return all;
|
|
178
256
|
});
|
|
179
257
|
}
|
|
@@ -205,6 +283,16 @@ export function setSecretSites(tenant, name, sites, workspace) {
|
|
|
205
283
|
if (rec.kind === "client-cert" && clean.some((s) => s.startsWith("*."))) {
|
|
206
284
|
throw new Error(`${name} is a client certificate — bind it to exact hosts, not a wildcard`);
|
|
207
285
|
}
|
|
286
|
+
// A bound record names its sites in its seal: open it with the old
|
|
287
|
+
// binding and seal it again with the new one. One that will not open is
|
|
288
|
+
// refused, never relabelled.
|
|
289
|
+
if (rec.v === 2) {
|
|
290
|
+
const value = openRecord(tenant, workspace, name, rec);
|
|
291
|
+
if (value === null)
|
|
292
|
+
throw new Error(`${name} cannot be read with this install's keys — set its value again before binding it`);
|
|
293
|
+
all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, updatedAt: rec.updatedAt }, true);
|
|
294
|
+
return all;
|
|
295
|
+
}
|
|
208
296
|
if (clean.length)
|
|
209
297
|
rec.sites = clean;
|
|
210
298
|
else
|
|
@@ -225,7 +313,11 @@ export function secretSites(tenant, names, workspace) {
|
|
|
225
313
|
const own = workspace ? read(tenant, workspace) : {};
|
|
226
314
|
const account = read(tenant);
|
|
227
315
|
for (const name of names) {
|
|
228
|
-
|
|
316
|
+
// The record getSecret would take the VALUE from — the workspace's only
|
|
317
|
+
// when it opens, else the account's. Picking by presence paired an
|
|
318
|
+
// account value with a workspace record's (unauthenticated) sites.
|
|
319
|
+
const ownRec = own[name];
|
|
320
|
+
const rec = ownRec && openRecord(tenant, workspace, name, ownRec) !== null ? ownRec : account[name];
|
|
229
321
|
if (rec?.sites?.length)
|
|
230
322
|
out[name] = [...rec.sites];
|
|
231
323
|
}
|
|
@@ -260,9 +352,12 @@ export function encryptValue(tenant, value) {
|
|
|
260
352
|
export function decryptValue(tenant, rec) {
|
|
261
353
|
return decrypt(tenant, rec);
|
|
262
354
|
}
|
|
263
|
-
function decrypt(tenant, rec) {
|
|
355
|
+
function decrypt(tenant, rec, aad) {
|
|
264
356
|
if (!rec)
|
|
265
357
|
return null;
|
|
358
|
+
// A bound record opens only with its binding: one without is not a guess.
|
|
359
|
+
if (rec.v === 2 && !aad)
|
|
360
|
+
return null;
|
|
266
361
|
try {
|
|
267
362
|
// The record says which key wrote it. An unmarked one predates per-account
|
|
268
363
|
// keys and is under the install key; guessing either way would turn a
|
|
@@ -286,6 +381,8 @@ function decrypt(tenant, rec) {
|
|
|
286
381
|
}
|
|
287
382
|
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(rec.iv, "base64"), GCM);
|
|
288
383
|
decipher.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
384
|
+
if (aad)
|
|
385
|
+
decipher.setAAD(aad);
|
|
289
386
|
return Buffer.concat([
|
|
290
387
|
decipher.update(Buffer.from(rec.data, "base64")),
|
|
291
388
|
decipher.final(),
|
|
@@ -298,11 +395,11 @@ function decrypt(tenant, rec) {
|
|
|
298
395
|
/** Nearest wins: a workspace secret shadows an account one of the same name. */
|
|
299
396
|
export function getSecret(tenant, name, workspace) {
|
|
300
397
|
if (workspace) {
|
|
301
|
-
const own =
|
|
398
|
+
const own = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
|
|
302
399
|
if (own !== null)
|
|
303
400
|
return { value: own, scope: "workspace" };
|
|
304
401
|
}
|
|
305
|
-
const shared =
|
|
402
|
+
const shared = openRecord(tenant, undefined, name, read(tenant)[name]);
|
|
306
403
|
return shared === null ? null : { value: shared, scope: "account" };
|
|
307
404
|
}
|
|
308
405
|
export function deleteSecret(tenant, name, workspace) {
|
|
@@ -408,7 +505,7 @@ export function rotateMasterKey(tenant, from, to) {
|
|
|
408
505
|
const newKey = crypto.createHash("sha256").update(to).digest();
|
|
409
506
|
const rewritten = [];
|
|
410
507
|
const unreadable = [];
|
|
411
|
-
for (const { file } of secretFiles(tenant)) {
|
|
508
|
+
for (const { file, workspace } of secretFiles(tenant)) {
|
|
412
509
|
let count = 0;
|
|
413
510
|
mutateVaultFile(file, (all) => {
|
|
414
511
|
const next = {};
|
|
@@ -423,10 +520,16 @@ export function rotateMasterKey(tenant, from, to) {
|
|
|
423
520
|
next[name] = rec;
|
|
424
521
|
continue;
|
|
425
522
|
}
|
|
523
|
+
// A bound record keeps its binding: same place, same AAD.
|
|
524
|
+
let aad = null;
|
|
426
525
|
let plain;
|
|
427
526
|
try {
|
|
527
|
+
if (rec.v === 2)
|
|
528
|
+
aad = vaultAad(tenant, workspace, name, rec);
|
|
428
529
|
const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"), GCM);
|
|
429
530
|
d.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
531
|
+
if (aad)
|
|
532
|
+
d.setAAD(aad);
|
|
430
533
|
plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
|
|
431
534
|
}
|
|
432
535
|
catch {
|
|
@@ -436,6 +539,8 @@ export function rotateMasterKey(tenant, from, to) {
|
|
|
436
539
|
}
|
|
437
540
|
const iv = crypto.randomBytes(12);
|
|
438
541
|
const c = crypto.createCipheriv("aes-256-gcm", newKey, iv, GCM);
|
|
542
|
+
if (aad)
|
|
543
|
+
c.setAAD(aad);
|
|
439
544
|
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
|
|
440
545
|
next[name] = {
|
|
441
546
|
iv: iv.toString("base64"),
|
|
@@ -445,6 +550,10 @@ export function rotateMasterKey(tenant, from, to) {
|
|
|
445
550
|
// Kept: without it an oauth2 secret stops being refreshed after a
|
|
446
551
|
// rotation, and a file/ssh/api secret stops being materialised.
|
|
447
552
|
...(rec.kind ? { kind: rec.kind } : {}),
|
|
553
|
+
// Kept too (it was dropped until 29 Oct 2026): a rotation that
|
|
554
|
+
// unbinds every bound secret puts their values into pods.
|
|
555
|
+
...(rec.sites?.length ? { sites: rec.sites } : {}),
|
|
556
|
+
...(aad ? { v: 2 } : {}),
|
|
448
557
|
};
|
|
449
558
|
count++;
|
|
450
559
|
}
|
|
@@ -519,8 +628,11 @@ export function replaceOAuth2RefreshToken(tenant, name, workspace, usedValue, re
|
|
|
519
628
|
let replaced = false;
|
|
520
629
|
mutateVaultFile(secretsFile(tenant, scope), (all) => {
|
|
521
630
|
const rec = all[name];
|
|
522
|
-
if (rec &&
|
|
523
|
-
|
|
631
|
+
if (rec && openRecord(tenant, scope, name, rec) === usedValue) {
|
|
632
|
+
// Built whole, not spread over the old record: a spread kept a stale
|
|
633
|
+
// `k: "tenant"` when this seal fell back to the install key, and the
|
|
634
|
+
// record then opened with neither.
|
|
635
|
+
all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites }, rec.v === 2 || writeBound());
|
|
524
636
|
replaced = true;
|
|
525
637
|
}
|
|
526
638
|
return all;
|
|
@@ -641,7 +753,9 @@ ctx) {
|
|
|
641
753
|
// the next run does not refresh again.
|
|
642
754
|
const next = OAUTH2_PREFIX + JSON.stringify({ ...config, refresh_token: live.rotated });
|
|
643
755
|
try {
|
|
644
|
-
if (ctx)
|
|
756
|
+
if (ctx?.onRotate)
|
|
757
|
+
await ctx.onRotate(name, value, next);
|
|
758
|
+
else if (ctx)
|
|
645
759
|
replaceOAuth2RefreshToken(ctx.tenant, name, ctx.workspace, value, live.rotated);
|
|
646
760
|
}
|
|
647
761
|
catch {
|
|
@@ -849,7 +963,7 @@ export function setClientCertSecret(tenant, name, config, workspace) {
|
|
|
849
963
|
};
|
|
850
964
|
if (!SECRET_NAME.test(name))
|
|
851
965
|
throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
852
|
-
const
|
|
966
|
+
const plainCert = CLIENT_CERT_PREFIX + JSON.stringify(stored);
|
|
853
967
|
// Written under the vault's own lock, with the binding checked in the same
|
|
854
968
|
// step: a value set over a secret keeps its site binding, and a wildcard
|
|
855
969
|
// one would present the certificate to a whole domain.
|
|
@@ -858,7 +972,7 @@ export function setClientCertSecret(tenant, name, config, workspace) {
|
|
|
858
972
|
if (sites?.some((site) => site.startsWith("*."))) {
|
|
859
973
|
throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
|
|
860
974
|
}
|
|
861
|
-
all[name] =
|
|
975
|
+
all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites }, all[name]?.v === 2 || writeBound());
|
|
862
976
|
return all;
|
|
863
977
|
});
|
|
864
978
|
}
|
|
@@ -944,6 +1058,46 @@ export function isApiValue(value) {
|
|
|
944
1058
|
export function apiConfigOf(value) {
|
|
945
1059
|
return JSON.parse(value.slice(API_PREFIX.length));
|
|
946
1060
|
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Seal every unbound record of an account for its place (v2). Run at boot
|
|
1063
|
+
* once the install writes bound records (FOLDRUN_VAULT_WRITE_BOUND=1), and by
|
|
1064
|
+
* scripts/seal-vault-bindings.mjs. Per file, all or nothing: every unbound
|
|
1065
|
+
* value is opened first, and a file holding one that will not open is left
|
|
1066
|
+
* exactly as it is and reported. Idempotent: bound records are skipped.
|
|
1067
|
+
*/
|
|
1068
|
+
export function sealVaultBindings(tenant) {
|
|
1069
|
+
assertSafeName(tenant, "tenant");
|
|
1070
|
+
let sealed = 0;
|
|
1071
|
+
const unreadable = [];
|
|
1072
|
+
for (const { file, workspace } of secretFiles(tenant)) {
|
|
1073
|
+
mutateVaultFile(file, (all) => {
|
|
1074
|
+
const plain = new Map();
|
|
1075
|
+
const bad = [];
|
|
1076
|
+
for (const [name, rec] of Object.entries(all)) {
|
|
1077
|
+
if (rec.v === 2)
|
|
1078
|
+
continue;
|
|
1079
|
+
const value = decrypt(tenant, rec);
|
|
1080
|
+
if (value === null)
|
|
1081
|
+
bad.push(workspace ? `${workspace}/${name}` : name);
|
|
1082
|
+
else
|
|
1083
|
+
plain.set(name, value);
|
|
1084
|
+
}
|
|
1085
|
+
unreadable.push(...bad);
|
|
1086
|
+
if (plain.size === 0 || bad.length)
|
|
1087
|
+
return null;
|
|
1088
|
+
const next = { ...all };
|
|
1089
|
+
for (const [name, value] of plain) {
|
|
1090
|
+
const rec = all[name];
|
|
1091
|
+
next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, updatedAt: rec.updatedAt }, true);
|
|
1092
|
+
}
|
|
1093
|
+
sealed += plain.size;
|
|
1094
|
+
return next;
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
return { sealed, unreadable };
|
|
1098
|
+
}
|
|
1099
|
+
/** Whether this install writes bound vault records (and migrates at boot). */
|
|
1100
|
+
export const vaultWritesBound = writeBound;
|
|
947
1101
|
/**
|
|
948
1102
|
* Move an account's secrets from the install key onto its own key.
|
|
949
1103
|
*
|
|
@@ -958,7 +1112,7 @@ export function rewrapTenantSecrets(tenant) {
|
|
|
958
1112
|
return { moved: 0, unreadable: [] };
|
|
959
1113
|
let moved = 0;
|
|
960
1114
|
const unreadable = [];
|
|
961
|
-
for (const { file } of secretFiles(tenant)) {
|
|
1115
|
+
for (const { file, workspace } of secretFiles(tenant)) {
|
|
962
1116
|
// Under the vault's lock: every pod runs this at boot, and a save made
|
|
963
1117
|
// between our read and our write must not be lost.
|
|
964
1118
|
mutateVaultFile(file, (all) => {
|
|
@@ -967,7 +1121,7 @@ export function rewrapTenantSecrets(tenant) {
|
|
|
967
1121
|
for (const [name, rec] of Object.entries(all)) {
|
|
968
1122
|
if (rec.k === "tenant")
|
|
969
1123
|
continue; // already moved
|
|
970
|
-
const value =
|
|
1124
|
+
const value = openRecord(tenant, workspace, name, rec);
|
|
971
1125
|
if (value === null) {
|
|
972
1126
|
unreadableHere.push(name);
|
|
973
1127
|
continue;
|
|
@@ -982,12 +1136,7 @@ export function rewrapTenantSecrets(tenant) {
|
|
|
982
1136
|
return null;
|
|
983
1137
|
const next = { ...all };
|
|
984
1138
|
for (const [name, value] of plain) {
|
|
985
|
-
next[name] = {
|
|
986
|
-
...encryptValue(tenant, value),
|
|
987
|
-
updatedAt: all[name].updatedAt,
|
|
988
|
-
...(all[name].kind ? { kind: all[name].kind } : {}),
|
|
989
|
-
...(all[name].sites?.length ? { sites: all[name].sites } : {}),
|
|
990
|
-
};
|
|
1139
|
+
next[name] = sealRecord(tenant, workspace, name, value, { kind: all[name].kind, sites: all[name].sites, updatedAt: all[name].updatedAt }, all[name].v === 2 || writeBound());
|
|
991
1140
|
}
|
|
992
1141
|
moved += plain.size;
|
|
993
1142
|
return next;
|
package/dist/src/translator.d.ts
CHANGED
|
@@ -11,15 +11,21 @@ export declare function anthropicUsage(u: Json | undefined, wire: TranslatorWire
|
|
|
11
11
|
cached: number;
|
|
12
12
|
output: number;
|
|
13
13
|
};
|
|
14
|
-
|
|
14
|
+
/** `anthropic` is no translation at all: the host-side passthrough for an
|
|
15
|
+
* endpoint that already speaks Messages, so the SDK on the host talks only
|
|
16
|
+
* to loopback and every byte to the tenant's URL goes through the guard
|
|
17
|
+
* (FOL-95). A step never gets it (translatorSpecFor returns null there). */
|
|
18
|
+
export type TranslatorWire = "chat" | "responses" | "anthropic";
|
|
15
19
|
export interface TranslatorSpec {
|
|
16
20
|
/** The provider's base, e.g. https://api.openai.com/v1 — `/chat/completions`
|
|
17
21
|
* or `/responses` is appended, by `wire`. */
|
|
18
22
|
upstreamBase: string;
|
|
19
23
|
/** Which OpenAI shape the endpoint speaks. Default chat. */
|
|
20
24
|
wire?: TranslatorWire;
|
|
21
|
-
/** Already resolved. Sent as `Authorization: Bearer
|
|
25
|
+
/** Already resolved. Sent as `Authorization: Bearer`, or as `x-api-key`
|
|
26
|
+
* where `upstreamAuth` says so (the anthropic wire only). */
|
|
22
27
|
upstreamKey: string;
|
|
28
|
+
upstreamAuth?: "bearer" | "x-api-key";
|
|
23
29
|
/** Extra headers the provider wants, already resolved. */
|
|
24
30
|
headers?: Record<string, string>;
|
|
25
31
|
maxTokensParam?: "max_tokens" | "max_completion_tokens";
|