@foldrun/core 0.4.0 → 0.6.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 +7 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -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/browser-pod.d.ts +148 -0
- package/dist/src/browser-pod.js +333 -0
- package/dist/src/catalog.js +2 -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 +218 -3
- package/dist/src/container.d.ts +9 -0
- package/dist/src/container.js +115 -5
- package/dist/src/context-tools.d.ts +1 -1
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +7 -0
- package/dist/src/egress.js +6 -0
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +5 -0
- package/dist/src/evals.js +9 -2
- package/dist/src/flow-lint.d.ts +8 -0
- package/dist/src/flow-lint.js +223 -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.js +5 -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 +30 -3
- 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 +247 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +58 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +53 -7
- package/dist/src/notify.js +194 -126
- 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 +28 -0
- package/dist/src/paths.js +55 -0
- package/dist/src/platform.d.ts +43 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/provider-health.js +3 -2
- 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 +149 -13
- package/dist/src/run-container.js +424 -43
- package/dist/src/runner.d.ts +177 -9
- package/dist/src/runner.js +1146 -154
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +17 -2
- package/dist/src/script-tools.js +82 -10
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +30 -6
- package/dist/src/secret-health.js +42 -20
- package/dist/src/secrets.d.ts +19 -0
- package/dist/src/secrets.js +155 -86
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +92 -11
- package/dist/src/step-exec.d.ts +72 -2
- package/dist/src/step-exec.js +431 -37
- 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 +222 -37
- package/dist/src/store.js +349 -62
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/test-mode.d.ts +17 -2
- package/dist/src/test-mode.js +53 -13
- 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 +104 -12
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +69 -23
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +3 -0
- package/dist/src/triggers.js +10 -2
- 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 +4 -0
- package/dist/src/webhook.js +39 -0
- package/index.ts +4 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +350 -0
- package/src/catalog.ts +2 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +218 -3
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +14 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +5 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +37 -3
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +318 -0
- package/src/model-credential.ts +71 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +189 -103
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +54 -0
- package/src/platform.ts +52 -3
- package/src/provider-health.ts +3 -2
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +460 -44
- package/src/runner.ts +1168 -155
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +81 -10
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +64 -19
- package/src/secrets.ts +149 -87
- package/src/starter.ts +93 -11
- package/src/step-exec.ts +447 -38
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +506 -70
- package/src/subagents.ts +189 -0
- package/src/test-mode.ts +51 -11
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +110 -12
- package/src/translator.ts +72 -22
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +10 -2
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +41 -0
- package/templates/blog-desk/agents/writer/agent.md +1 -1
- package/templates/hello/agents/curator/agent.md +4 -4
- package/templates/hello/agents/notetaker/agent.md +3 -3
|
@@ -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
|
|
@@ -44,6 +45,20 @@ export declare function runScript(cwd: string, spec: ScriptSpec, values: Record<
|
|
|
44
45
|
code: number | null;
|
|
45
46
|
out: string;
|
|
46
47
|
}>;
|
|
48
|
+
/**
|
|
49
|
+
* A host path as the script's container sees it: under the agent's own
|
|
50
|
+
* directory it is /workspace, under a mounted folder it is that mount, and
|
|
51
|
+
* anything else (an argument that only looks like a path) is left alone.
|
|
52
|
+
* Containment, not a text prefix: `<ws>/scripts-old/x.py` is not under
|
|
53
|
+
* `<ws>/scripts`. The longest mount wins, so a nested one is not shadowed.
|
|
54
|
+
*/
|
|
55
|
+
export declare function containerPath(p: string, agentDir: string, mounts: Record<string, string>): string;
|
|
56
|
+
/** Where a `workspace/…` script argument is staged in a script container,
|
|
57
|
+
* or null when it is not one (or climbs out of the workspace). */
|
|
58
|
+
export declare function stageWorkspaceArg(arg: string, workspaceRoot: string): {
|
|
59
|
+
host: string;
|
|
60
|
+
container: string;
|
|
61
|
+
} | null;
|
|
47
62
|
export interface ExecutionContext {
|
|
48
63
|
/** "docker" nests each script in its own container; "sandbox" spawns it
|
|
49
64
|
* directly inside the isolation the step already has. */
|
package/dist/src/script-tools.js
CHANGED
|
@@ -20,6 +20,8 @@ import { z } from "zod";
|
|
|
20
20
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
21
21
|
import { runInContainer } from "./container.js";
|
|
22
22
|
import { hostSafeEnv } from "./host-env.js";
|
|
23
|
+
import { workspaceLinkEnv } from "./confine.js";
|
|
24
|
+
import { platform } from "./platform.js";
|
|
23
25
|
// A script's only clock is the `timeout:` its own tool.md declares. There
|
|
24
26
|
// was a two-minute default and a ten-minute cap here; both were the
|
|
25
27
|
// platform's opinion about how long a person's script may take, and the
|
|
@@ -63,6 +65,14 @@ export function parseScripts(raw) {
|
|
|
63
65
|
}
|
|
64
66
|
return out;
|
|
65
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
|
+
};
|
|
66
76
|
// Pick how to execute a file when no interpreter is declared.
|
|
67
77
|
//
|
|
68
78
|
// Exported because the tool tester has to make the same choice, and used to
|
|
@@ -70,8 +80,19 @@ export function parseScripts(raw) {
|
|
|
70
80
|
// Test button spawned every .mjs tool as a program and reported EACCES for a
|
|
71
81
|
// tool that runs perfectly. Two tables, one of them wrong.
|
|
72
82
|
export function commandFor(spec, abs, overrides = {}) {
|
|
73
|
-
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] };
|
|
74
94
|
return { cmd: spec.interpreter, args: [abs] };
|
|
95
|
+
}
|
|
75
96
|
const ext = path.extname(abs).toLowerCase();
|
|
76
97
|
// A prepared runtime (venv, npm prefix) wins over the host interpreter.
|
|
77
98
|
if (overrides[ext])
|
|
@@ -162,14 +183,18 @@ export function runScript(cwd, spec, values, env, libraryScripts, interpreters,
|
|
|
162
183
|
// Container path: same command, executed inside an isolated container
|
|
163
184
|
// with only the agent's directory (plus declared shared dirs) mounted.
|
|
164
185
|
if (exec && exec.executor === "docker") {
|
|
186
|
+
// `workspace/…` is the workspace root on the host (the step's link)
|
|
187
|
+
// and in a run container; here there is no link, so each such
|
|
188
|
+
// argument is staged: copied in, pointed at, and copied back after.
|
|
189
|
+
const workspaceRoot = path.resolve(cwd, "..", "..");
|
|
190
|
+
const staged = [];
|
|
165
191
|
const inContainer = (p) => {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return p;
|
|
192
|
+
const w = stageWorkspaceArg(p, workspaceRoot);
|
|
193
|
+
if (!w)
|
|
194
|
+
return containerPath(p, cwd, exec.mounts);
|
|
195
|
+
if (!staged.some((s) => s.host === w.host))
|
|
196
|
+
staged.push(w);
|
|
197
|
+
return w.container;
|
|
173
198
|
};
|
|
174
199
|
const argv = [
|
|
175
200
|
// The interpreter inside the image, not the host's venv path.
|
|
@@ -182,6 +207,7 @@ export function runScript(cwd, spec, values, env, libraryScripts, interpreters,
|
|
|
182
207
|
runInContainer({
|
|
183
208
|
agentDir: path.resolve(cwd),
|
|
184
209
|
readOnly: exec.mounts,
|
|
210
|
+
staged,
|
|
185
211
|
image: exec.image,
|
|
186
212
|
argv,
|
|
187
213
|
env,
|
|
@@ -198,7 +224,7 @@ export function runScript(cwd, spec, values, env, libraryScripts, interpreters,
|
|
|
198
224
|
// environment, which is the boundary, so nothing is lost there.
|
|
199
225
|
const child = spawn(cmd, args, {
|
|
200
226
|
cwd,
|
|
201
|
-
env: { ...hostSafeEnv(), ...env },
|
|
227
|
+
env: workspaceLinkEnv({ ...hostSafeEnv(), ...env }, cwd),
|
|
202
228
|
timeout: timeoutFor(spec),
|
|
203
229
|
});
|
|
204
230
|
let out = "";
|
|
@@ -217,6 +243,41 @@ export function runScript(cwd, spec, values, env, libraryScripts, interpreters,
|
|
|
217
243
|
}));
|
|
218
244
|
});
|
|
219
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* A host path as the script's container sees it: under the agent's own
|
|
248
|
+
* directory it is /workspace, under a mounted folder it is that mount, and
|
|
249
|
+
* anything else (an argument that only looks like a path) is left alone.
|
|
250
|
+
* Containment, not a text prefix: `<ws>/scripts-old/x.py` is not under
|
|
251
|
+
* `<ws>/scripts`. The longest mount wins, so a nested one is not shadowed.
|
|
252
|
+
*/
|
|
253
|
+
export function containerPath(p, agentDir, mounts) {
|
|
254
|
+
if (!path.isAbsolute(p))
|
|
255
|
+
return p;
|
|
256
|
+
const under = (root) => {
|
|
257
|
+
const rel = path.relative(root, p);
|
|
258
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) ? rel : null;
|
|
259
|
+
};
|
|
260
|
+
const own = under(path.resolve(agentDir));
|
|
261
|
+
if (own !== null)
|
|
262
|
+
return path.posix.join("/workspace", own.split(path.sep).join("/"));
|
|
263
|
+
const hit = Object.entries(mounts)
|
|
264
|
+
.map(([host, mount]) => ({ mount, rel: under(path.resolve(host)), len: host.length }))
|
|
265
|
+
.filter((m) => m.rel !== null)
|
|
266
|
+
.sort((a, b) => b.len - a.len)[0];
|
|
267
|
+
return hit ? path.posix.join(hit.mount, hit.rel.split(path.sep).join("/")) : p;
|
|
268
|
+
}
|
|
269
|
+
/** Where a `workspace/…` script argument is staged in a script container,
|
|
270
|
+
* or null when it is not one (or climbs out of the workspace). */
|
|
271
|
+
export function stageWorkspaceArg(arg, workspaceRoot) {
|
|
272
|
+
if (arg !== "workspace" && !arg.startsWith("workspace/"))
|
|
273
|
+
return null;
|
|
274
|
+
const rel = arg.slice("workspace".length).replace(/^\/+/, "");
|
|
275
|
+
const host = path.resolve(workspaceRoot, rel);
|
|
276
|
+
const within = path.relative(workspaceRoot, host);
|
|
277
|
+
if (within.startsWith("..") || path.isAbsolute(within))
|
|
278
|
+
return null;
|
|
279
|
+
return { host, container: path.posix.join("/workspace-root", within.split(path.sep).join("/")) };
|
|
280
|
+
}
|
|
220
281
|
// `run:` accepts:
|
|
221
282
|
// scripts/x.py this agent's own script
|
|
222
283
|
// workspace/tools/<t>/x.py a folder tool's own code, in this workspace
|
|
@@ -236,7 +297,18 @@ export function resolveRunPath(agentDir, run, libraryScripts) {
|
|
|
236
297
|
return path.resolve(workspaceRoot, "tools", run.slice("workspace/tools/".length));
|
|
237
298
|
}
|
|
238
299
|
if (run.startsWith("account/tools/")) {
|
|
239
|
-
|
|
300
|
+
const rel = run.slice("account/tools/".length);
|
|
301
|
+
const own = path.resolve(libraryRoot, "tools", rel);
|
|
302
|
+
// A gallery tool nobody installed has no copy in the library. In a
|
|
303
|
+
// sandbox the gallery is laid down under the library, so the path above
|
|
304
|
+
// is it; a run on the host has no such merge, so it looks on the shelf.
|
|
305
|
+
const gallery = platform.galleryDir();
|
|
306
|
+
if (!fs.existsSync(own) && gallery) {
|
|
307
|
+
const shelf = path.resolve(gallery, "tools", rel);
|
|
308
|
+
if (fs.existsSync(shelf))
|
|
309
|
+
return shelf;
|
|
310
|
+
}
|
|
311
|
+
return own;
|
|
240
312
|
}
|
|
241
313
|
// Canonical prefixes, then the legacy spellings they replaced.
|
|
242
314
|
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
|
}
|
|
@@ -23,7 +23,30 @@ export interface SecretRecord {
|
|
|
23
23
|
* "worked until Tuesday", which is the sentence that dates the breakage. */
|
|
24
24
|
lastAccepted: string | null;
|
|
25
25
|
}
|
|
26
|
-
type HealthFile = Record<string, SecretRecord>;
|
|
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;
|
|
27
50
|
/** 401, 402 and 403 are the credential being refused. Everything else is
|
|
28
51
|
* the request, the model or the provider — none of which is the key's
|
|
29
52
|
* fault, and calling them a credential failure would send people to
|
|
@@ -45,15 +68,16 @@ export declare function flushSecretHealth(): Promise<void>;
|
|
|
45
68
|
/** Everything known about this account's credentials, by name. Names the
|
|
46
69
|
* vault holds but nothing has used are simply absent, which is the honest
|
|
47
70
|
* answer: unused is not the same as broken. */
|
|
48
|
-
export declare function secretHealth(tenant: string): HealthFile
|
|
71
|
+
export declare function secretHealth(tenant: string): Promise<HealthFile>;
|
|
49
72
|
/** The credentials worth saying something about: refused the last time they
|
|
50
73
|
* were used. Sorted worst first — most consecutive refusals, then oldest
|
|
51
74
|
* last-success, which is the order someone would work through them. */
|
|
52
|
-
export declare function failingSecrets(tenant: string): {
|
|
75
|
+
export declare function failingSecrets(tenant: string): Promise<{
|
|
53
76
|
name: string;
|
|
54
77
|
record: SecretRecord;
|
|
55
|
-
}[]
|
|
78
|
+
}[]>;
|
|
56
79
|
/** Drop what is known about a secret — for a delete, and for a rotation,
|
|
57
80
|
* where the old key's refusals say nothing about the new one. */
|
|
58
|
-
export declare function forgetSecretHealth(tenant: string, name: string): void
|
|
59
|
-
|
|
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;
|
|
@@ -47,6 +47,29 @@ function write(tenant, data) {
|
|
|
47
47
|
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
48
48
|
fs.renameSync(tmp, file);
|
|
49
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
|
+
}
|
|
50
73
|
// Writes are queued and coalesced off the caller's path. The egress proxy
|
|
51
74
|
// notes a use per secret per request, before it streams the response body,
|
|
52
75
|
// and a synchronous read-modify-write of a JSON file there is latency on
|
|
@@ -58,19 +81,18 @@ function drain() {
|
|
|
58
81
|
if (draining)
|
|
59
82
|
return;
|
|
60
83
|
draining = true;
|
|
61
|
-
setImmediate(() => {
|
|
84
|
+
setImmediate(async () => {
|
|
62
85
|
try {
|
|
63
|
-
for (const [tenant,
|
|
86
|
+
for (const [tenant, updates] of pending) {
|
|
64
87
|
pending.delete(tenant);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
88
|
+
try {
|
|
89
|
+
await store.apply(tenant, updates);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// best effort, always
|
|
93
|
+
}
|
|
69
94
|
}
|
|
70
95
|
}
|
|
71
|
-
catch {
|
|
72
|
-
// best effort, always
|
|
73
|
-
}
|
|
74
96
|
finally {
|
|
75
97
|
draining = false;
|
|
76
98
|
if (pending.size)
|
|
@@ -112,7 +134,7 @@ export function noteSecretUse(tenant, key, use) {
|
|
|
112
134
|
if (!key)
|
|
113
135
|
return;
|
|
114
136
|
const list = pending.get(tenant) ?? [];
|
|
115
|
-
list.push({
|
|
137
|
+
list.push({ key, use: { ...use, at: use.at ?? new Date().toISOString() } });
|
|
116
138
|
pending.set(tenant, list);
|
|
117
139
|
drain();
|
|
118
140
|
}
|
|
@@ -127,29 +149,29 @@ export function flushSecretHealth() {
|
|
|
127
149
|
/** Everything known about this account's credentials, by name. Names the
|
|
128
150
|
* vault holds but nothing has used are simply absent, which is the honest
|
|
129
151
|
* answer: unused is not the same as broken. */
|
|
130
|
-
export function secretHealth(tenant) {
|
|
131
|
-
return read(tenant);
|
|
152
|
+
export async function secretHealth(tenant) {
|
|
153
|
+
return store.read(tenant);
|
|
132
154
|
}
|
|
133
155
|
/** The credentials worth saying something about: refused the last time they
|
|
134
156
|
* were used. Sorted worst first — most consecutive refusals, then oldest
|
|
135
157
|
* last-success, which is the order someone would work through them. */
|
|
136
|
-
export function failingSecrets(tenant) {
|
|
137
|
-
return Object.entries(read(tenant))
|
|
158
|
+
export async function failingSecrets(tenant) {
|
|
159
|
+
return Object.entries(await store.read(tenant))
|
|
138
160
|
.filter(([, r]) => r.last.outcome === "refused")
|
|
139
161
|
.map(([name, record]) => ({ name, record }))
|
|
140
162
|
.sort((a, b) => b.record.refusals - a.record.refusals || (a.record.lastAccepted ?? "").localeCompare(b.record.lastAccepted ?? ""));
|
|
141
163
|
}
|
|
142
164
|
/** Drop what is known about a secret — for a delete, and for a rotation,
|
|
143
165
|
* where the old key's refusals say nothing about the new one. */
|
|
144
|
-
export function forgetSecretHealth(tenant, name) {
|
|
166
|
+
export async function forgetSecretHealth(tenant, name) {
|
|
145
167
|
try {
|
|
146
|
-
|
|
147
|
-
if (!(name in data))
|
|
148
|
-
return;
|
|
149
|
-
delete data[name];
|
|
150
|
-
write(tenant, data);
|
|
168
|
+
await store.forget(tenant, name);
|
|
151
169
|
}
|
|
152
170
|
catch {
|
|
153
171
|
// best effort
|
|
154
172
|
}
|
|
155
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
|
@@ -16,6 +16,24 @@ export declare function installKeyMaterial(): string;
|
|
|
16
16
|
* are refreshed into live tokens, "file"/"ssh"/"api" are materialised into
|
|
17
17
|
* the run's scratch. Plain values have no kind. */
|
|
18
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;
|
|
19
37
|
export declare const SECRET_NAME: RegExp;
|
|
20
38
|
export declare function setSecret(tenant: string, name: string, value: string, workspace?: string, kind?: SecretKind): void;
|
|
21
39
|
/** Encrypt one value for an account — the same envelope secrets use, exported
|
|
@@ -163,3 +181,4 @@ export declare function rewrapTenantSecrets(tenant: string): {
|
|
|
163
181
|
moved: number;
|
|
164
182
|
unreadable: string[];
|
|
165
183
|
};
|
|
184
|
+
export {};
|