@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.
- package/README.md +27 -10
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/approvals.d.ts +104 -1
- package/dist/src/approvals.js +192 -2
- package/dist/src/browser-pod.d.ts +129 -0
- package/dist/src/browser-pod.js +284 -0
- package/dist/src/budget.d.ts +32 -0
- package/dist/src/budget.js +177 -0
- package/dist/src/catalog.js +3 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +224 -4
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +26 -2
- package/dist/src/egress.js +8 -1
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +9 -0
- package/dist/src/evals.js +26 -13
- package/dist/src/flow-lint.d.ts +12 -0
- package/dist/src/flow-lint.js +331 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.d.ts +8 -0
- package/dist/src/host-env.js +48 -0
- package/dist/src/json-schema.d.ts +12 -0
- package/dist/src/json-schema.js +202 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +43 -4
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +245 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +132 -8
- package/dist/src/notify.js +360 -58
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +87 -0
- package/dist/src/platform.d.ts +144 -4
- package/dist/src/platform.js +27 -3
- package/dist/src/provider-health.d.ts +55 -0
- package/dist/src/provider-health.js +233 -0
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +206 -17
- package/dist/src/run-container.js +547 -80
- package/dist/src/runner.d.ts +179 -10
- package/dist/src/runner.js +1423 -169
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +15 -2
- package/dist/src/script-tools.js +44 -4
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +83 -0
- package/dist/src/secret-health.js +177 -0
- package/dist/src/secrets.d.ts +38 -1
- package/dist/src/secrets.js +197 -96
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +95 -11
- package/dist/src/step-exec.d.ts +106 -1
- package/dist/src/step-exec.js +558 -85
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +492 -38
- package/dist/src/store.js +882 -69
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/tar.js +2 -1
- package/dist/src/test-mode.d.ts +111 -0
- package/dist/src/test-mode.js +285 -0
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +152 -33
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +71 -24
- package/dist/src/trigger-log.d.ts +43 -0
- package/dist/src/trigger-log.js +122 -0
- package/dist/src/triggers.d.ts +23 -0
- package/dist/src/triggers.js +101 -12
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +19 -3
- package/dist/src/webhook.js +50 -11
- package/index.ts +3 -0
- package/package.json +5 -5
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/approvals.ts +253 -2
- package/src/browser-pod.ts +313 -0
- package/src/budget.ts +178 -0
- package/src/catalog.ts +3 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +224 -4
- package/src/deploy.ts +137 -6
- package/src/egress.ts +32 -4
- package/src/eol.ts +7 -0
- package/src/evals.ts +36 -11
- package/src/flow-lint.ts +371 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +51 -0
- package/src/json-schema.ts +184 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +50 -4
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +316 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +391 -44
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +83 -0
- package/src/platform.ts +158 -5
- package/src/provider-health.ts +275 -0
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +611 -76
- package/src/runner.ts +1463 -165
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +55 -6
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +230 -0
- package/src/secrets.ts +191 -95
- package/src/starter.ts +96 -11
- package/src/step-exec.ts +548 -41
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +149 -24
- package/src/store.ts +1227 -74
- package/src/subagents.ts +189 -0
- package/src/tar.ts +2 -1
- package/src/test-mode.ts +311 -0
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +159 -32
- package/src/translator.ts +74 -23
- package/src/trigger-log.ts +160 -0
- package/src/triggers.ts +102 -12
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +58 -11
- package/templates/hello/agents/curator/agent.md +1 -1
- package/templates/hello/agents/notetaker/agent.md +1 -1
|
@@ -9,8 +9,9 @@ export interface ScriptSpec {
|
|
|
9
9
|
args: Record<string, string>;
|
|
10
10
|
interpreter?: string;
|
|
11
11
|
/** Seconds this script may run — for the crawl that legitimately takes
|
|
12
|
-
* five minutes.
|
|
13
|
-
*
|
|
12
|
+
* five minutes. Unset means no platform clock; there was a two-minute
|
|
13
|
+
* default and a ten-minute cap here and both were removed (see the note
|
|
14
|
+
* above `timeoutFor`). */
|
|
14
15
|
timeout?: number;
|
|
15
16
|
/** The script itself, when the tool is a single markdown file with its
|
|
16
17
|
* code in a fenced block. Materialised to a file at call time — the
|
|
@@ -26,12 +27,24 @@ export interface ScriptSpec {
|
|
|
26
27
|
* default, `materialised`, is a program that reads values from its
|
|
27
28
|
* environment and needs them real — a browser seeding cookies. */
|
|
28
29
|
secrets?: "proxied" | "materialised";
|
|
30
|
+
/** `outward: true` — this program makes something happen outside (sends,
|
|
31
|
+
* posts, orders). On a test run a step that grants it is handed no real
|
|
32
|
+
* secret at all, whatever their names. */
|
|
33
|
+
outward?: boolean;
|
|
34
|
+
/** `test_mode: allow` — a reader that happens to use a send-capable
|
|
35
|
+
* secret's name (a GBP reader on a GBP token): on a test run the step
|
|
36
|
+
* still gets the real values. An `outward` tool in the same step wins. */
|
|
37
|
+
testMode?: "allow";
|
|
29
38
|
}
|
|
30
39
|
export declare function parseScripts(raw: unknown): ScriptSpec[];
|
|
31
40
|
export declare function commandFor(spec: ScriptSpec, abs: string, overrides?: Record<string, string>): {
|
|
32
41
|
cmd: string;
|
|
33
42
|
args: string[];
|
|
34
43
|
};
|
|
44
|
+
export declare function runScript(cwd: string, spec: ScriptSpec, values: Record<string, string>, env: Record<string, string>, libraryScripts: string, interpreters: Record<string, string>, exec: ExecutionContext | null): Promise<{
|
|
45
|
+
code: number | null;
|
|
46
|
+
out: string;
|
|
47
|
+
}>;
|
|
35
48
|
export interface ExecutionContext {
|
|
36
49
|
/** "docker" nests each script in its own container; "sandbox" spawns it
|
|
37
50
|
* directly inside the isolation the step already has. */
|
package/dist/src/script-tools.js
CHANGED
|
@@ -19,6 +19,9 @@ import { spawn } from "node:child_process";
|
|
|
19
19
|
import { z } from "zod";
|
|
20
20
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
21
21
|
import { runInContainer } from "./container.js";
|
|
22
|
+
import { hostSafeEnv } from "./host-env.js";
|
|
23
|
+
import { workspaceLinkEnv } from "./confine.js";
|
|
24
|
+
import { platform } from "./platform.js";
|
|
22
25
|
// A script's only clock is the `timeout:` its own tool.md declares. There
|
|
23
26
|
// was a two-minute default and a ten-minute cap here; both were the
|
|
24
27
|
// platform's opinion about how long a person's script may take, and the
|
|
@@ -55,11 +58,21 @@ export function parseScripts(raw) {
|
|
|
55
58
|
args,
|
|
56
59
|
interpreter: typeof e.interpreter === "string" ? e.interpreter : undefined,
|
|
57
60
|
secrets: e.secrets === "proxied" ? "proxied" : undefined,
|
|
61
|
+
outward: e.outward === true ? true : undefined,
|
|
62
|
+
testMode: e.test_mode === "allow" ? "allow" : undefined,
|
|
58
63
|
timeout: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
|
|
59
64
|
});
|
|
60
65
|
}
|
|
61
66
|
return out;
|
|
62
67
|
}
|
|
68
|
+
/** Interpreter names that mean a language rather than a binary, and the
|
|
69
|
+
* extension whose prepared interpreter serves them. */
|
|
70
|
+
const GENERIC_INTERPRETER = {
|
|
71
|
+
python: ".py",
|
|
72
|
+
python3: ".py",
|
|
73
|
+
"/usr/bin/python3": ".py",
|
|
74
|
+
"/usr/bin/env python3": ".py",
|
|
75
|
+
};
|
|
63
76
|
// Pick how to execute a file when no interpreter is declared.
|
|
64
77
|
//
|
|
65
78
|
// Exported because the tool tester has to make the same choice, and used to
|
|
@@ -67,8 +80,19 @@ export function parseScripts(raw) {
|
|
|
67
80
|
// Test button spawned every .mjs tool as a program and reported EACCES for a
|
|
68
81
|
// tool that runs perfectly. Two tables, one of them wrong.
|
|
69
82
|
export function commandFor(spec, abs, overrides = {}) {
|
|
70
|
-
if (spec.interpreter)
|
|
83
|
+
if (spec.interpreter) {
|
|
84
|
+
// `interpreter: python3` names the language, not a particular binary, and
|
|
85
|
+
// it is how most tool files are written. Taken literally it ran the bare
|
|
86
|
+
// system python beside a venv built for this very tool, and the tool
|
|
87
|
+
// died on its first import ("No module named openpyxl") while the log
|
|
88
|
+
// above it said the runtime was cached. A generic python name is served
|
|
89
|
+
// by the prepared venv when there is one; a path or anything else is
|
|
90
|
+
// still obeyed as written.
|
|
91
|
+
const lang = GENERIC_INTERPRETER[spec.interpreter.trim()];
|
|
92
|
+
if (lang && overrides[lang])
|
|
93
|
+
return { cmd: overrides[lang], args: [abs] };
|
|
71
94
|
return { cmd: spec.interpreter, args: [abs] };
|
|
95
|
+
}
|
|
72
96
|
const ext = path.extname(abs).toLowerCase();
|
|
73
97
|
// A prepared runtime (venv, npm prefix) wins over the host interpreter.
|
|
74
98
|
if (overrides[ext])
|
|
@@ -96,7 +120,7 @@ function containerInterpreter(abs) {
|
|
|
96
120
|
};
|
|
97
121
|
return byExt[path.extname(abs).toLowerCase()] ?? abs;
|
|
98
122
|
}
|
|
99
|
-
function runScript(cwd, spec, values, env, libraryScripts, interpreters, exec) {
|
|
123
|
+
export function runScript(cwd, spec, values, env, libraryScripts, interpreters, exec) {
|
|
100
124
|
// An inline-code tool has no file yet: write it inside the agent's own
|
|
101
125
|
// directory — already an allowed root — and run it like any other script.
|
|
102
126
|
// Materialised per call rather than per build, so an edit to the tool's
|
|
@@ -188,9 +212,14 @@ function runScript(cwd, spec, values, env, libraryScripts, interpreters, exec) {
|
|
|
188
212
|
}).then(resolve, (err) => resolve({ code: null, out: `container error: ${err instanceof Error ? err.message : String(err)}` }));
|
|
189
213
|
return;
|
|
190
214
|
}
|
|
215
|
+
// Not process.env: on a platform that is every tenant's root key and
|
|
216
|
+
// the datastore credentials. The child starts from the allowlisted
|
|
217
|
+
// base and gets what the agent declared, by name — see host-env.ts.
|
|
218
|
+
// In a run container `env` already carries the container's whole
|
|
219
|
+
// environment, which is the boundary, so nothing is lost there.
|
|
191
220
|
const child = spawn(cmd, args, {
|
|
192
221
|
cwd,
|
|
193
|
-
env: { ...
|
|
222
|
+
env: workspaceLinkEnv({ ...hostSafeEnv(), ...env }, cwd),
|
|
194
223
|
timeout: timeoutFor(spec),
|
|
195
224
|
});
|
|
196
225
|
let out = "";
|
|
@@ -228,7 +257,18 @@ export function resolveRunPath(agentDir, run, libraryScripts) {
|
|
|
228
257
|
return path.resolve(workspaceRoot, "tools", run.slice("workspace/tools/".length));
|
|
229
258
|
}
|
|
230
259
|
if (run.startsWith("account/tools/")) {
|
|
231
|
-
|
|
260
|
+
const rel = run.slice("account/tools/".length);
|
|
261
|
+
const own = path.resolve(libraryRoot, "tools", rel);
|
|
262
|
+
// A gallery tool nobody installed has no copy in the library. In a
|
|
263
|
+
// sandbox the gallery is laid down under the library, so the path above
|
|
264
|
+
// is it; a run on the host has no such merge, so it looks on the shelf.
|
|
265
|
+
const gallery = platform.galleryDir();
|
|
266
|
+
if (!fs.existsSync(own) && gallery) {
|
|
267
|
+
const shelf = path.resolve(gallery, "tools", rel);
|
|
268
|
+
if (fs.existsSync(shelf))
|
|
269
|
+
return shelf;
|
|
270
|
+
}
|
|
271
|
+
return own;
|
|
232
272
|
}
|
|
233
273
|
// Canonical prefixes, then the legacy spellings they replaced.
|
|
234
274
|
for (const p of ["workspace/scripts/", "shared/"]) {
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Turn any @file/@ssh/@api values in `env` into files under
|
|
3
|
-
* `<agentDir>/.secret-files`, rewriting each to its path.
|
|
4
|
-
* directory written (for cleanup) and the transformed env.
|
|
5
|
-
* pass through untouched.
|
|
2
|
+
* Turn any @file/@ssh/@api values in `env` into files under a fresh
|
|
3
|
+
* `<agentDir>/.secret-files/step-XXXXXX`, rewriting each to its path.
|
|
4
|
+
* Returns the directory written (for cleanup) and the transformed env.
|
|
5
|
+
* Plain values pass through untouched.
|
|
6
6
|
*/
|
|
7
7
|
export declare function materializeFileSecrets(agentDir: string, env: Record<string, string>): {
|
|
8
8
|
env: Record<string, string>;
|
|
9
9
|
dir: string | null;
|
|
10
10
|
};
|
|
11
|
-
/** Best-effort removal of a materialised secret-files directory
|
|
11
|
+
/** Best-effort removal of a materialised secret-files directory — this
|
|
12
|
+
* step's own, and the shared parent once no other step is using it. */
|
|
12
13
|
export declare function cleanupFileSecrets(dir: string | null): void;
|
package/dist/src/secret-files.js
CHANGED
|
@@ -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-
|
|
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
|
import fs from "node:fs";
|
|
16
19
|
import path from "node:path";
|
|
17
20
|
import { isFileValue, fileContent, isSshValue, sshConfigOf, isApiValue, apiConfigOf, } from "./secrets.js";
|
|
@@ -25,17 +28,19 @@ function writeWrapper(dir, name, body) {
|
|
|
25
28
|
return file;
|
|
26
29
|
}
|
|
27
30
|
/**
|
|
28
|
-
* Turn any @file/@ssh/@api values in `env` into files under
|
|
29
|
-
* `<agentDir>/.secret-files`, rewriting each to its path.
|
|
30
|
-
* directory written (for cleanup) and the transformed env.
|
|
31
|
-
* pass through untouched.
|
|
31
|
+
* Turn any @file/@ssh/@api values in `env` into files under a fresh
|
|
32
|
+
* `<agentDir>/.secret-files/step-XXXXXX`, rewriting each to its path.
|
|
33
|
+
* Returns the directory written (for cleanup) and the transformed env.
|
|
34
|
+
* Plain values pass through untouched.
|
|
32
35
|
*/
|
|
33
36
|
export function materializeFileSecrets(agentDir, env) {
|
|
34
37
|
const entries = Object.entries(env).filter(([, v]) => typeof v === "string" && (isFileValue(v) || isSshValue(v) || isApiValue(v)));
|
|
35
38
|
if (entries.length === 0)
|
|
36
39
|
return { env, dir: null };
|
|
37
|
-
const
|
|
38
|
-
fs.mkdirSync(
|
|
40
|
+
const parent = path.join(agentDir, ".secret-files");
|
|
41
|
+
fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
42
|
+
const dir = fs.mkdtempSync(path.join(parent, "step-"));
|
|
43
|
+
fs.chmodSync(dir, 0o700);
|
|
39
44
|
const out = { ...env };
|
|
40
45
|
for (const [name, value] of entries) {
|
|
41
46
|
// Lowercased name as the filename keeps it predictable without leaking
|
|
@@ -85,8 +90,16 @@ export function materializeFileSecrets(agentDir, env) {
|
|
|
85
90
|
}
|
|
86
91
|
return { env: out, dir };
|
|
87
92
|
}
|
|
88
|
-
/** Best-effort removal of a materialised secret-files directory
|
|
93
|
+
/** Best-effort removal of a materialised secret-files directory — this
|
|
94
|
+
* step's own, and the shared parent once no other step is using it. */
|
|
89
95
|
export function cleanupFileSecrets(dir) {
|
|
90
|
-
if (dir)
|
|
91
|
-
|
|
96
|
+
if (!dir)
|
|
97
|
+
return;
|
|
98
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
99
|
+
try {
|
|
100
|
+
fs.rmdirSync(path.dirname(dir));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// another step's directory is still in there
|
|
104
|
+
}
|
|
92
105
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** How a credential is identified here. A name alone is not enough: the
|
|
2
|
+
* account and each workspace can hold a secret by the same name, and they
|
|
3
|
+
* are different credentials with different health. */
|
|
4
|
+
export declare function healthKey(name: string, scope: "account" | "workspace", workspace?: string | null): string;
|
|
5
|
+
export interface SecretUse {
|
|
6
|
+
/** ISO time of the most recent use. */
|
|
7
|
+
at: string;
|
|
8
|
+
/** The host it was sent to, so "which of my four keys is this" has an
|
|
9
|
+
* answer without opening a run. */
|
|
10
|
+
host: string;
|
|
11
|
+
/** The response status, when there was one. */
|
|
12
|
+
status: number | null;
|
|
13
|
+
/** Refused means the credential itself was rejected: 401, 402 or 403.
|
|
14
|
+
* A 500 is the other end failing, which says nothing about the key. */
|
|
15
|
+
outcome: "accepted" | "refused" | "error";
|
|
16
|
+
}
|
|
17
|
+
export interface SecretRecord {
|
|
18
|
+
last: SecretUse;
|
|
19
|
+
/** Consecutive refusals, newest first — one is a fluke, five is a dead
|
|
20
|
+
* key. Reset by any acceptance. */
|
|
21
|
+
refusals: number;
|
|
22
|
+
/** The last acceptance, kept through a run of refusals so a page can say
|
|
23
|
+
* "worked until Tuesday", which is the sentence that dates the breakage. */
|
|
24
|
+
lastAccepted: string | null;
|
|
25
|
+
}
|
|
26
|
+
export type HealthFile = Record<string, SecretRecord>;
|
|
27
|
+
/** One use to fold into a record. */
|
|
28
|
+
export interface HealthUpdate {
|
|
29
|
+
key: string;
|
|
30
|
+
use: {
|
|
31
|
+
host: string;
|
|
32
|
+
status: number | null;
|
|
33
|
+
at: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Where health is kept. A file per account by default — what the CLI and a
|
|
38
|
+
* single-box install use. The platform plugs in Postgres
|
|
39
|
+
* (setSecretHealthStore), where each use is one atomic upsert: two pods
|
|
40
|
+
* recording at once on a shared file was last-writer-wins, and a refusal
|
|
41
|
+
* could vanish.
|
|
42
|
+
*/
|
|
43
|
+
export interface SecretHealthStore {
|
|
44
|
+
apply(tenant: string, updates: HealthUpdate[]): Promise<void>;
|
|
45
|
+
read(tenant: string): Promise<HealthFile>;
|
|
46
|
+
forget(tenant: string, key: string): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
/** The platform's store (Postgres), or back to files with null. */
|
|
49
|
+
export declare function setSecretHealthStore(next: SecretHealthStore | null): void;
|
|
50
|
+
/** 401, 402 and 403 are the credential being refused. Everything else is
|
|
51
|
+
* the request, the model or the provider — none of which is the key's
|
|
52
|
+
* fault, and calling them a credential failure would send people to
|
|
53
|
+
* rotate a key that was never the problem. */
|
|
54
|
+
export declare function outcomeFor(status: number | null): SecretUse["outcome"];
|
|
55
|
+
/**
|
|
56
|
+
* Record one use. `key` is a health key (see healthKey), never a bare name.
|
|
57
|
+
* Never throws and never blocks: the write is queued and happens off the
|
|
58
|
+
* caller's path, because this runs on every proxied request.
|
|
59
|
+
*/
|
|
60
|
+
export declare function noteSecretUse(tenant: string, key: string, use: {
|
|
61
|
+
host: string;
|
|
62
|
+
status: number | null;
|
|
63
|
+
at?: string;
|
|
64
|
+
}): void;
|
|
65
|
+
/** Wait for queued writes to land — for tests, and for a process that is
|
|
66
|
+
* about to exit. */
|
|
67
|
+
export declare function flushSecretHealth(): Promise<void>;
|
|
68
|
+
/** Everything known about this account's credentials, by name. Names the
|
|
69
|
+
* vault holds but nothing has used are simply absent, which is the honest
|
|
70
|
+
* answer: unused is not the same as broken. */
|
|
71
|
+
export declare function secretHealth(tenant: string): Promise<HealthFile>;
|
|
72
|
+
/** The credentials worth saying something about: refused the last time they
|
|
73
|
+
* were used. Sorted worst first — most consecutive refusals, then oldest
|
|
74
|
+
* last-success, which is the order someone would work through them. */
|
|
75
|
+
export declare function failingSecrets(tenant: string): Promise<{
|
|
76
|
+
name: string;
|
|
77
|
+
record: SecretRecord;
|
|
78
|
+
}[]>;
|
|
79
|
+
/** Drop what is known about a secret — for a delete, and for a rotation,
|
|
80
|
+
* where the old key's refusals say nothing about the new one. */
|
|
81
|
+
export declare function forgetSecretHealth(tenant: string, name: string): Promise<void>;
|
|
82
|
+
/** The account's file, for the platform's one-time import into its store. */
|
|
83
|
+
export declare function readSecretHealthFile(tenant: string): HealthFile;
|
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import crypto from "node:crypto";
|
|
25
|
+
import { accountDir } from "./store.js";
|
|
26
|
+
/** How a credential is identified here. A name alone is not enough: the
|
|
27
|
+
* account and each workspace can hold a secret by the same name, and they
|
|
28
|
+
* are different credentials with different health. */
|
|
29
|
+
export function healthKey(name, scope, workspace) {
|
|
30
|
+
return scope === "workspace" && workspace ? `workspace:${workspace}:${name}` : `account:${name}`;
|
|
31
|
+
}
|
|
32
|
+
const healthFile = (tenant) => path.join(accountDir(tenant), "secret-health.json");
|
|
33
|
+
function read(tenant) {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(healthFile(tenant), "utf8"));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function write(tenant, data) {
|
|
42
|
+
const file = healthFile(tenant);
|
|
43
|
+
if (!fs.existsSync(path.dirname(file)))
|
|
44
|
+
return;
|
|
45
|
+
// pid alone is not unique across pods on a shared volume.
|
|
46
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString("hex")}`;
|
|
47
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
48
|
+
fs.renameSync(tmp, file);
|
|
49
|
+
}
|
|
50
|
+
const fileStore = {
|
|
51
|
+
async apply(tenant, updates) {
|
|
52
|
+
const data = read(tenant);
|
|
53
|
+
for (const { key, use } of updates)
|
|
54
|
+
apply(data, key, use);
|
|
55
|
+
write(tenant, data);
|
|
56
|
+
},
|
|
57
|
+
async read(tenant) {
|
|
58
|
+
return read(tenant);
|
|
59
|
+
},
|
|
60
|
+
async forget(tenant, key) {
|
|
61
|
+
const data = read(tenant);
|
|
62
|
+
if (!(key in data))
|
|
63
|
+
return;
|
|
64
|
+
delete data[key];
|
|
65
|
+
write(tenant, data);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
let store = fileStore;
|
|
69
|
+
/** The platform's store (Postgres), or back to files with null. */
|
|
70
|
+
export function setSecretHealthStore(next) {
|
|
71
|
+
store = next ?? fileStore;
|
|
72
|
+
}
|
|
73
|
+
// Writes are queued and coalesced off the caller's path. The egress proxy
|
|
74
|
+
// notes a use per secret per request, before it streams the response body,
|
|
75
|
+
// and a synchronous read-modify-write of a JSON file there is latency on
|
|
76
|
+
// every proxied call — "never blocks" has to be true, not aspirational.
|
|
77
|
+
// One writer per process; the queue drains in order.
|
|
78
|
+
const pending = new Map();
|
|
79
|
+
let draining = false;
|
|
80
|
+
function drain() {
|
|
81
|
+
if (draining)
|
|
82
|
+
return;
|
|
83
|
+
draining = true;
|
|
84
|
+
setImmediate(async () => {
|
|
85
|
+
try {
|
|
86
|
+
for (const [tenant, updates] of pending) {
|
|
87
|
+
pending.delete(tenant);
|
|
88
|
+
try {
|
|
89
|
+
await store.apply(tenant, updates);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// best effort, always
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
draining = false;
|
|
98
|
+
if (pending.size)
|
|
99
|
+
drain();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function apply(data, name, use) {
|
|
104
|
+
const outcome = outcomeFor(use.status);
|
|
105
|
+
const at = use.at ?? new Date().toISOString();
|
|
106
|
+
const prev = data[name];
|
|
107
|
+
data[name] = {
|
|
108
|
+
last: { at, host: use.host, status: use.status, outcome },
|
|
109
|
+
// Only a refusal counts against a key. An error at the other end is
|
|
110
|
+
// the other end's problem and must not accumulate into a false alarm.
|
|
111
|
+
refusals: outcome === "refused" ? (prev?.refusals ?? 0) + 1 : outcome === "accepted" ? 0 : prev?.refusals ?? 0,
|
|
112
|
+
lastAccepted: outcome === "accepted" ? at : prev?.lastAccepted ?? null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** 401, 402 and 403 are the credential being refused. Everything else is
|
|
116
|
+
* the request, the model or the provider — none of which is the key's
|
|
117
|
+
* fault, and calling them a credential failure would send people to
|
|
118
|
+
* rotate a key that was never the problem. */
|
|
119
|
+
export function outcomeFor(status) {
|
|
120
|
+
if (status === null)
|
|
121
|
+
return "error";
|
|
122
|
+
if (status === 401 || status === 402 || status === 403)
|
|
123
|
+
return "refused";
|
|
124
|
+
if (status >= 200 && status < 400)
|
|
125
|
+
return "accepted";
|
|
126
|
+
return "error";
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Record one use. `key` is a health key (see healthKey), never a bare name.
|
|
130
|
+
* Never throws and never blocks: the write is queued and happens off the
|
|
131
|
+
* caller's path, because this runs on every proxied request.
|
|
132
|
+
*/
|
|
133
|
+
export function noteSecretUse(tenant, key, use) {
|
|
134
|
+
if (!key)
|
|
135
|
+
return;
|
|
136
|
+
const list = pending.get(tenant) ?? [];
|
|
137
|
+
list.push({ key, use: { ...use, at: use.at ?? new Date().toISOString() } });
|
|
138
|
+
pending.set(tenant, list);
|
|
139
|
+
drain();
|
|
140
|
+
}
|
|
141
|
+
/** Wait for queued writes to land — for tests, and for a process that is
|
|
142
|
+
* about to exit. */
|
|
143
|
+
export function flushSecretHealth() {
|
|
144
|
+
return new Promise((resolve) => {
|
|
145
|
+
const check = () => (pending.size === 0 && !draining ? resolve() : setImmediate(check));
|
|
146
|
+
check();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
/** Everything known about this account's credentials, by name. Names the
|
|
150
|
+
* vault holds but nothing has used are simply absent, which is the honest
|
|
151
|
+
* answer: unused is not the same as broken. */
|
|
152
|
+
export async function secretHealth(tenant) {
|
|
153
|
+
return store.read(tenant);
|
|
154
|
+
}
|
|
155
|
+
/** The credentials worth saying something about: refused the last time they
|
|
156
|
+
* were used. Sorted worst first — most consecutive refusals, then oldest
|
|
157
|
+
* last-success, which is the order someone would work through them. */
|
|
158
|
+
export async function failingSecrets(tenant) {
|
|
159
|
+
return Object.entries(await store.read(tenant))
|
|
160
|
+
.filter(([, r]) => r.last.outcome === "refused")
|
|
161
|
+
.map(([name, record]) => ({ name, record }))
|
|
162
|
+
.sort((a, b) => b.record.refusals - a.record.refusals || (a.record.lastAccepted ?? "").localeCompare(b.record.lastAccepted ?? ""));
|
|
163
|
+
}
|
|
164
|
+
/** Drop what is known about a secret — for a delete, and for a rotation,
|
|
165
|
+
* where the old key's refusals say nothing about the new one. */
|
|
166
|
+
export async function forgetSecretHealth(tenant, name) {
|
|
167
|
+
try {
|
|
168
|
+
await store.forget(tenant, name);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// best effort
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** The account's file, for the platform's one-time import into its store. */
|
|
175
|
+
export function readSecretHealthFile(tenant) {
|
|
176
|
+
return read(tenant);
|
|
177
|
+
}
|
package/dist/src/secrets.d.ts
CHANGED
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
/** Which store a secret came from. */
|
|
2
2
|
export type SecretScope = "account" | "workspace";
|
|
3
|
+
/**
|
|
4
|
+
* The install's root secret, as text: FOLDRUN_SECRET_KEY when set, else the
|
|
5
|
+
* contents of `.secret-key` under the data root — created here, the first
|
|
6
|
+
* time anything asks, so there is never a moment when a derived token has
|
|
7
|
+
* nothing real to hang off. The vault, hook tokens, approval links and git
|
|
8
|
+
* push secrets all derive from this one value; rotating it invalidates every
|
|
9
|
+
* one of them together. Exported for webhook.ts, which used to fall back to
|
|
10
|
+
* a fixed dev string until the vault happened to create this file — so hook
|
|
11
|
+
* URLs were computable by anyone who read the source, and then changed the
|
|
12
|
+
* first time a secret was stored.
|
|
13
|
+
*/
|
|
14
|
+
export declare function installKeyMaterial(): string;
|
|
3
15
|
/** How the stored value is used at run time — "oauth2"/"service-account"
|
|
4
16
|
* are refreshed into live tokens, "file"/"ssh"/"api" are materialised into
|
|
5
17
|
* the run's scratch. Plain values have no kind. */
|
|
6
18
|
export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api";
|
|
19
|
+
interface StoredSecret {
|
|
20
|
+
iv: string;
|
|
21
|
+
tag: string;
|
|
22
|
+
data: string;
|
|
23
|
+
updatedAt: string;
|
|
24
|
+
kind?: SecretKind;
|
|
25
|
+
/** Which key opens this. Absent means the install key, which is what every
|
|
26
|
+
* record written before per-account keys existed is encrypted with — so an
|
|
27
|
+
* old vault stays readable without a migration having to run first. */
|
|
28
|
+
k?: "tenant";
|
|
29
|
+
}
|
|
30
|
+
type SecretsFile = Record<string, StoredSecret>;
|
|
31
|
+
/**
|
|
32
|
+
* Change one vault file safely: `change` gets what is stored now and returns
|
|
33
|
+
* the new contents, or null to leave the file untouched. Returns what is
|
|
34
|
+
* stored after.
|
|
35
|
+
*/
|
|
36
|
+
export declare function mutateVaultFile(file: string, change: (all: SecretsFile) => SecretsFile | null): SecretsFile;
|
|
7
37
|
export declare const SECRET_NAME: RegExp;
|
|
8
38
|
export declare function setSecret(tenant: string, name: string, value: string, workspace?: string, kind?: SecretKind): void;
|
|
9
39
|
/** Encrypt one value for an account — the same envelope secrets use, exported
|
|
@@ -102,7 +132,13 @@ export declare function isOAuth2Value(value: string): boolean;
|
|
|
102
132
|
* refresh throws with the secret's name and the provider's reason — a run
|
|
103
133
|
* should fail saying "GOOGLE_ADS_TOKEN: invalid_grant", not 401 somewhere.
|
|
104
134
|
*/
|
|
105
|
-
export declare function materializeSecrets(env: Record<string, string
|
|
135
|
+
export declare function materializeSecrets(env: Record<string, string>,
|
|
136
|
+
/** Whose secrets these are, when the caller knows: lets the platform keep
|
|
137
|
+
* each OAuth connection's health and quote a reconnect link on failure. */
|
|
138
|
+
ctx?: {
|
|
139
|
+
tenant: string;
|
|
140
|
+
workspace?: string;
|
|
141
|
+
}): Promise<Record<string, string>>;
|
|
106
142
|
export interface ServiceAccountConfig {
|
|
107
143
|
token_url: string;
|
|
108
144
|
issuer: string;
|
|
@@ -145,3 +181,4 @@ export declare function rewrapTenantSecrets(tenant: string): {
|
|
|
145
181
|
moved: number;
|
|
146
182
|
unreadable: string[];
|
|
147
183
|
};
|
|
184
|
+
export {};
|