@foldrun/core 0.2.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/LICENSE +202 -0
- package/README.md +239 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +43 -0
- package/dist/src/agent-tools.d.ts +31 -0
- package/dist/src/agent-tools.js +99 -0
- package/dist/src/agents-md.d.ts +9 -0
- package/dist/src/agents-md.js +83 -0
- package/dist/src/api-tools.d.ts +38 -0
- package/dist/src/api-tools.js +313 -0
- package/dist/src/approvals.d.ts +39 -0
- package/dist/src/approvals.js +103 -0
- package/dist/src/arrange.d.ts +8 -0
- package/dist/src/arrange.js +40 -0
- package/dist/src/catalog.d.ts +108 -0
- package/dist/src/catalog.js +255 -0
- package/dist/src/completions.d.ts +61 -0
- package/dist/src/completions.js +578 -0
- package/dist/src/confine.d.ts +31 -0
- package/dist/src/confine.js +298 -0
- package/dist/src/container.d.ts +30 -0
- package/dist/src/container.js +172 -0
- package/dist/src/context-tools.d.ts +81 -0
- package/dist/src/context-tools.js +347 -0
- package/dist/src/deploy.d.ts +100 -0
- package/dist/src/deploy.js +377 -0
- package/dist/src/egress.d.ts +75 -0
- package/dist/src/egress.js +133 -0
- package/dist/src/evals.d.ts +150 -0
- package/dist/src/evals.js +651 -0
- package/dist/src/flow-lint.d.ts +16 -0
- package/dist/src/flow-lint.js +177 -0
- package/dist/src/git-hook.d.ts +1 -0
- package/dist/src/git-hook.js +70 -0
- package/dist/src/git.d.ts +37 -0
- package/dist/src/git.js +101 -0
- package/dist/src/gitrepo.d.ts +95 -0
- package/dist/src/gitrepo.js +355 -0
- package/dist/src/history.d.ts +82 -0
- package/dist/src/history.js +259 -0
- package/dist/src/kinds.d.ts +95 -0
- package/dist/src/kinds.js +421 -0
- package/dist/src/library.d.ts +94 -0
- package/dist/src/library.js +341 -0
- package/dist/src/linkable.d.ts +20 -0
- package/dist/src/linkable.js +113 -0
- package/dist/src/model-probe.d.ts +21 -0
- package/dist/src/model-probe.js +81 -0
- package/dist/src/notify.d.ts +61 -0
- package/dist/src/notify.js +252 -0
- package/dist/src/oauth-presets.d.ts +13 -0
- package/dist/src/oauth-presets.js +39 -0
- package/dist/src/okf.d.ts +220 -0
- package/dist/src/okf.js +656 -0
- package/dist/src/openapi.d.ts +66 -0
- package/dist/src/openapi.js +342 -0
- package/dist/src/paths.d.ts +33 -0
- package/dist/src/paths.js +78 -0
- package/dist/src/platform.d.ts +32 -0
- package/dist/src/platform.js +48 -0
- package/dist/src/providers.d.ts +47 -0
- package/dist/src/providers.js +114 -0
- package/dist/src/refs.d.ts +20 -0
- package/dist/src/refs.js +78 -0
- package/dist/src/run-container.d.ts +227 -0
- package/dist/src/run-container.js +760 -0
- package/dist/src/runner.d.ts +241 -0
- package/dist/src/runner.js +3215 -0
- package/dist/src/runtime.d.ts +41 -0
- package/dist/src/runtime.js +357 -0
- package/dist/src/script-tools.d.ts +62 -0
- package/dist/src/script-tools.js +296 -0
- package/dist/src/secret-files.d.ts +12 -0
- package/dist/src/secret-files.js +92 -0
- package/dist/src/secrets.d.ts +147 -0
- package/dist/src/secrets.js +626 -0
- package/dist/src/starter.d.ts +21 -0
- package/dist/src/starter.js +399 -0
- package/dist/src/step-exec.d.ts +136 -0
- package/dist/src/step-exec.js +391 -0
- package/dist/src/storage.d.ts +123 -0
- package/dist/src/storage.js +640 -0
- package/dist/src/store.d.ts +1052 -0
- package/dist/src/store.js +2378 -0
- package/dist/src/tar.d.ts +30 -0
- package/dist/src/tar.js +111 -0
- package/dist/src/tool-names.d.ts +39 -0
- package/dist/src/tool-names.js +79 -0
- package/dist/src/tool-programs.d.ts +18 -0
- package/dist/src/tool-programs.js +52 -0
- package/dist/src/tool-test.d.ts +19 -0
- package/dist/src/tool-test.js +335 -0
- package/dist/src/translator.d.ts +159 -0
- package/dist/src/translator.js +986 -0
- package/dist/src/triggers.d.ts +60 -0
- package/dist/src/triggers.js +270 -0
- package/dist/src/webhook.d.ts +47 -0
- package/dist/src/webhook.js +148 -0
- package/index.ts +44 -0
- package/package.json +64 -0
- package/src/agent-tools.ts +138 -0
- package/src/agents-md.ts +81 -0
- package/src/api-tools.ts +389 -0
- package/src/approvals.ts +129 -0
- package/src/arrange.ts +42 -0
- package/src/catalog.ts +321 -0
- package/src/completions.ts +652 -0
- package/src/confine.ts +350 -0
- package/src/container.ts +220 -0
- package/src/context-tools.ts +435 -0
- package/src/deploy.ts +458 -0
- package/src/egress.ts +176 -0
- package/src/evals.ts +812 -0
- package/src/flow-lint.ts +215 -0
- package/src/git-hook.ts +73 -0
- package/src/git.ts +117 -0
- package/src/gitrepo.ts +391 -0
- package/src/history.ts +329 -0
- package/src/kinds.ts +507 -0
- package/src/library.ts +419 -0
- package/src/linkable.ts +123 -0
- package/src/model-probe.ts +112 -0
- package/src/notify.ts +281 -0
- package/src/oauth-presets.ts +54 -0
- package/src/okf.ts +768 -0
- package/src/openapi.ts +383 -0
- package/src/paths.ts +79 -0
- package/src/platform.ts +87 -0
- package/src/providers.ts +147 -0
- package/src/refs.ts +83 -0
- package/src/run-container.ts +883 -0
- package/src/runner.ts +3446 -0
- package/src/runtime.ts +404 -0
- package/src/script-tools.ts +383 -0
- package/src/secret-files.ts +101 -0
- package/src/secrets.ts +799 -0
- package/src/starter.ts +415 -0
- package/src/step-exec.ts +498 -0
- package/src/storage.ts +771 -0
- package/src/store.ts +2985 -0
- package/src/tar.ts +124 -0
- package/src/tool-names.ts +91 -0
- package/src/tool-programs.ts +65 -0
- package/src/tool-test.ts +401 -0
- package/src/translator.ts +1029 -0
- package/src/triggers.ts +287 -0
- package/src/webhook.ts +183 -0
- package/templates/blog-desk/AGENTS.md +13 -0
- package/templates/blog-desk/agents/researcher/agent.md +11 -0
- package/templates/blog-desk/agents/writer/agent.md +8 -0
- package/templates/blog-desk/evals/writer-quality.md +10 -0
- package/templates/blog-desk/flows/publish.md +10 -0
- package/templates/blog-desk/knowledge/house-style.md +9 -0
- package/templates/blog-desk/knowledge/index.md +9 -0
- package/templates/hello/AGENTS.md +18 -0
- package/templates/hello/agents/curator/agent.md +32 -0
- package/templates/hello/agents/notetaker/agent.md +24 -0
- package/templates/hello/evals/house-style.md +16 -0
- package/templates/hello/flows/memory-review.md +7 -0
- package/templates/hello/flows/note.md +7 -0
- package/templates/hello/knowledge/house-style.md +15 -0
- package/templates/hello/knowledge/index.md +9 -0
- package/templates/hello/skills/plain-english/SKILL.md +11 -0
- package/templates/hello/state/publishing.md +7 -0
- package/templates/hello/tools/wordcount/run.py +13 -0
- package/templates/hello/tools/wordcount/tool.md +22 -0
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
// Secret store. Values are encrypted at rest with AES-256-GCM using a key
|
|
2
|
+
// derived from FOLDRUN_SECRET_KEY (falling back to a generated per-install
|
|
3
|
+
// key file, so dev works without configuration).
|
|
4
|
+
//
|
|
5
|
+
// Two scopes, nearest wins — the same rule knowledge uses:
|
|
6
|
+
//
|
|
7
|
+
// data/<account>/secrets.json account: credentials
|
|
8
|
+
// genuinely shared by every
|
|
9
|
+
// workspace, rotated once
|
|
10
|
+
// data/<account>/workspaces/<workspace>/secrets.json workspace: the default.
|
|
11
|
+
// ads-desk's ad token is not
|
|
12
|
+
// newsroom's to read
|
|
13
|
+
//
|
|
14
|
+
// There is deliberately no agent scope. A credential buried in one agent's
|
|
15
|
+
// folder can't be rotated or audited, and per-agent *grant* already exists:
|
|
16
|
+
// an agent only receives what it names in `secrets:`. Definition is shared,
|
|
17
|
+
// grant is per-agent — the same split tools use.
|
|
18
|
+
//
|
|
19
|
+
// Secrets are injected as environment variables into the agent's tools at run
|
|
20
|
+
// time and are never returned by the API.
|
|
21
|
+
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { dataRoot } from "./paths.ts";
|
|
25
|
+
import { platform } from "./platform.ts";
|
|
26
|
+
|
|
27
|
+
// The account's own data key, when the platform holds one; the install key
|
|
28
|
+
// otherwise. See platform.ts — locally there is no account key.
|
|
29
|
+
const tenantKey = (tenant: string) => platform.tenantKey(tenant);
|
|
30
|
+
import crypto from "node:crypto";
|
|
31
|
+
import { assertSafeName } from "./store.ts";
|
|
32
|
+
|
|
33
|
+
const keyFile = () => path.join(dataRoot(), ".secret-key");
|
|
34
|
+
|
|
35
|
+
/** Which store a secret came from. */
|
|
36
|
+
export type SecretScope = "account" | "workspace";
|
|
37
|
+
|
|
38
|
+
function masterKey(): Buffer {
|
|
39
|
+
const fromEnv = process.env.FOLDRUN_SECRET_KEY;
|
|
40
|
+
if (fromEnv) return crypto.createHash("sha256").update(fromEnv).digest();
|
|
41
|
+
fs.mkdirSync(dataRoot(), { recursive: true });
|
|
42
|
+
if (!fs.existsSync(keyFile())) {
|
|
43
|
+
fs.writeFileSync(keyFile(), crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
|
|
44
|
+
}
|
|
45
|
+
return crypto.createHash("sha256").update(fs.readFileSync(keyFile(), "utf8")).digest();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `workspace` undefined means the account store.
|
|
49
|
+
function secretsFile(tenant: string, workspace?: string) {
|
|
50
|
+
assertSafeName(tenant, "tenant");
|
|
51
|
+
if (!workspace) return path.join(dataRoot(), tenant, "secrets.json");
|
|
52
|
+
assertSafeName(workspace, "workspace");
|
|
53
|
+
// Follow the workspace wherever it lives — see WORKSPACES in store.ts.
|
|
54
|
+
const legacy = path.join(dataRoot(), tenant, "projects", workspace);
|
|
55
|
+
const base = fs.existsSync(legacy) ? legacy : path.join(dataRoot(), tenant, "workspaces", workspace);
|
|
56
|
+
return path.join(base, "secrets.json");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** How the stored value is used at run time — "oauth2"/"service-account"
|
|
60
|
+
* are refreshed into live tokens, "file"/"ssh"/"api" are materialised into
|
|
61
|
+
* the run's scratch. Plain values have no kind. */
|
|
62
|
+
export type SecretKind = "oauth2" | "service-account" | "file" | "ssh" | "api";
|
|
63
|
+
|
|
64
|
+
interface StoredSecret {
|
|
65
|
+
iv: string;
|
|
66
|
+
tag: string;
|
|
67
|
+
data: string;
|
|
68
|
+
updatedAt: string;
|
|
69
|
+
kind?: SecretKind;
|
|
70
|
+
/** Which key opens this. Absent means the install key, which is what every
|
|
71
|
+
* record written before per-account keys existed is encrypted with — so an
|
|
72
|
+
* old vault stays readable without a migration having to run first. */
|
|
73
|
+
k?: "tenant";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type SecretsFile = Record<string, StoredSecret>;
|
|
77
|
+
|
|
78
|
+
function read(tenant: string, workspace?: string): SecretsFile {
|
|
79
|
+
const f = secretsFile(tenant, workspace);
|
|
80
|
+
if (!fs.existsSync(f)) return {};
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(fs.readFileSync(f, "utf8"));
|
|
83
|
+
} catch {
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function write(tenant: string, workspace: string | undefined, data: SecretsFile) {
|
|
89
|
+
const f = secretsFile(tenant, workspace);
|
|
90
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
91
|
+
fs.writeFileSync(f, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
95
|
+
|
|
96
|
+
export function setSecret(
|
|
97
|
+
tenant: string,
|
|
98
|
+
name: string,
|
|
99
|
+
value: string,
|
|
100
|
+
workspace?: string,
|
|
101
|
+
kind?: SecretKind,
|
|
102
|
+
) {
|
|
103
|
+
if (!SECRET_NAME.test(name)) {
|
|
104
|
+
throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
|
|
105
|
+
}
|
|
106
|
+
if (value.length > 8192) throw new Error("secret too long");
|
|
107
|
+
const sealed = encryptValue(tenant, value);
|
|
108
|
+
const all = read(tenant, workspace);
|
|
109
|
+
all[name] = {
|
|
110
|
+
...sealed,
|
|
111
|
+
updatedAt: new Date().toISOString(),
|
|
112
|
+
...(kind ? { kind } : {}),
|
|
113
|
+
};
|
|
114
|
+
write(tenant, workspace, all);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Which key writes this account's records.
|
|
119
|
+
*
|
|
120
|
+
* The account's own where there is one, the install key otherwise — a laptop,
|
|
121
|
+
* a self-hoster with no database, or an account whose key has not been minted.
|
|
122
|
+
* Returned with the marker to store beside it, so the reader never has to
|
|
123
|
+
* guess which key a record was written with.
|
|
124
|
+
*/
|
|
125
|
+
function keyFor(tenant: string): { key: Buffer; mark: "tenant" | undefined } {
|
|
126
|
+
const own = tenantKey(tenant);
|
|
127
|
+
return own ? { key: own, mark: "tenant" } : { key: masterKey(), mark: undefined };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Encrypt one value for an account — the same envelope secrets use, exported
|
|
131
|
+
* so sibling stores (OAuth clients) don't invent a second crypto path. */
|
|
132
|
+
export function encryptValue(
|
|
133
|
+
tenant: string,
|
|
134
|
+
value: string,
|
|
135
|
+
): { iv: string; tag: string; data: string; k?: "tenant" } {
|
|
136
|
+
const { key, mark } = keyFor(tenant);
|
|
137
|
+
const iv = crypto.randomBytes(12);
|
|
138
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
|
139
|
+
const enc = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
|
140
|
+
return {
|
|
141
|
+
iv: iv.toString("base64"),
|
|
142
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
143
|
+
data: enc.toString("base64"),
|
|
144
|
+
...(mark ? { k: mark } : {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function decryptValue(
|
|
149
|
+
tenant: string,
|
|
150
|
+
rec: { iv: string; tag: string; data: string; k?: "tenant" } | undefined,
|
|
151
|
+
): string | null {
|
|
152
|
+
return decrypt(tenant, rec as StoredSecret | undefined);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function decrypt(tenant: string, rec: StoredSecret | undefined): string | null {
|
|
156
|
+
if (!rec) return null;
|
|
157
|
+
try {
|
|
158
|
+
// The record says which key wrote it. An unmarked one predates per-account
|
|
159
|
+
// keys and is under the install key; guessing either way would turn a
|
|
160
|
+
// readable vault into an unreadable one on the first upgrade.
|
|
161
|
+
let key: Buffer;
|
|
162
|
+
if (rec.k === "tenant") {
|
|
163
|
+
const own = tenantKey(tenant);
|
|
164
|
+
if (!own) {
|
|
165
|
+
// Say so. Without this the read returns null and the caller reports
|
|
166
|
+
// "no such secret" — a process that forgot to load the account keys
|
|
167
|
+
// would look exactly like an account that never set one, which is the
|
|
168
|
+
// most expensive kind of wrong answer to debug.
|
|
169
|
+
console.error(
|
|
170
|
+
`[secrets] ${tenant} has account-keyed secrets but no key is loaded — ` +
|
|
171
|
+
`call loadTenantKeys() before reading, or the value will read as absent`,
|
|
172
|
+
);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
key = own;
|
|
176
|
+
} else {
|
|
177
|
+
key = masterKey();
|
|
178
|
+
}
|
|
179
|
+
const decipher = crypto.createDecipheriv(
|
|
180
|
+
"aes-256-gcm",
|
|
181
|
+
key,
|
|
182
|
+
Buffer.from(rec.iv, "base64"),
|
|
183
|
+
);
|
|
184
|
+
decipher.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
185
|
+
return Buffer.concat([
|
|
186
|
+
decipher.update(Buffer.from(rec.data, "base64")),
|
|
187
|
+
decipher.final(),
|
|
188
|
+
]).toString("utf8");
|
|
189
|
+
} catch {
|
|
190
|
+
return null; // key rotated or file tampered with
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Nearest wins: a workspace secret shadows an account one of the same name. */
|
|
195
|
+
export function getSecret(
|
|
196
|
+
tenant: string,
|
|
197
|
+
name: string,
|
|
198
|
+
workspace?: string,
|
|
199
|
+
): { value: string; scope: SecretScope } | null {
|
|
200
|
+
if (workspace) {
|
|
201
|
+
const own = decrypt(tenant, read(tenant, workspace)[name]);
|
|
202
|
+
if (own !== null) return { value: own, scope: "workspace" };
|
|
203
|
+
// A preview is a branch of its source workspace, and the branch's
|
|
204
|
+
// agents need the same credentials the source's do. Its own store is
|
|
205
|
+
// still consulted first, so a preview can override one on purpose.
|
|
206
|
+
const source = platform.previewSourceOf(tenant, workspace);
|
|
207
|
+
if (source) {
|
|
208
|
+
const inherited = decrypt(tenant, read(tenant, source)[name]);
|
|
209
|
+
if (inherited !== null) return { value: inherited, scope: "workspace" };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const shared = decrypt(tenant, read(tenant)[name]);
|
|
213
|
+
return shared === null ? null : { value: shared, scope: "account" };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function deleteSecret(tenant: string, name: string, workspace?: string) {
|
|
217
|
+
const all = read(tenant, workspace);
|
|
218
|
+
delete all[name];
|
|
219
|
+
write(tenant, workspace, all);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface SecretEntry {
|
|
223
|
+
name: string;
|
|
224
|
+
updatedAt: string;
|
|
225
|
+
scope: SecretScope;
|
|
226
|
+
/** An account secret this workspace overrides — worth showing, not hiding. */
|
|
227
|
+
shadowed?: boolean;
|
|
228
|
+
/** How this credential works: auto-refreshing oauth2, a signed service
|
|
229
|
+
* account, a file/ssh/api materialised into runs — absent for static values. */
|
|
230
|
+
kind?: SecretKind;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Metadata only — never values. Without a workspace this lists the account
|
|
234
|
+
// store; with one it lists what that workspace actually resolves, marking
|
|
235
|
+
// which scope each name comes from.
|
|
236
|
+
export function listSecrets(tenant: string, workspace?: string): SecretEntry[] {
|
|
237
|
+
const account = Object.entries(read(tenant)).map(([name, rec]) => ({
|
|
238
|
+
name,
|
|
239
|
+
updatedAt: rec.updatedAt,
|
|
240
|
+
scope: "account" as const,
|
|
241
|
+
...(rec.kind ? { kind: rec.kind } : {}),
|
|
242
|
+
}));
|
|
243
|
+
if (!workspace) return account.sort((a, b) => a.name.localeCompare(b.name));
|
|
244
|
+
|
|
245
|
+
const own = Object.entries(read(tenant, workspace)).map(([name, rec]) => ({
|
|
246
|
+
name,
|
|
247
|
+
updatedAt: rec.updatedAt,
|
|
248
|
+
scope: "workspace" as const,
|
|
249
|
+
shadowed: account.some((a) => a.name === name),
|
|
250
|
+
...(rec.kind ? { kind: rec.kind } : {}),
|
|
251
|
+
}));
|
|
252
|
+
const ownNames = new Set(own.map((s) => s.name));
|
|
253
|
+
return [...own, ...account.filter((a) => !ownNames.has(a.name))].sort((a, b) =>
|
|
254
|
+
a.name.localeCompare(b.name),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Resolve the secrets an agent declared into an env map for its tools.
|
|
259
|
+
// `from` records where each one came from, so a workspace silently falling
|
|
260
|
+
// back to an account credential is visible in the run trace instead of assumed.
|
|
261
|
+
export function resolveSecrets(tenant: string, names: string[], workspace?: string) {
|
|
262
|
+
const env: Record<string, string> = {};
|
|
263
|
+
const from: Record<string, SecretScope> = {};
|
|
264
|
+
const missing: string[] = [];
|
|
265
|
+
for (const name of names) {
|
|
266
|
+
const hit = getSecret(tenant, name, workspace);
|
|
267
|
+
if (hit === null) missing.push(name);
|
|
268
|
+
else {
|
|
269
|
+
env[name] = hit.value;
|
|
270
|
+
from[name] = hit.scope;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return { env, from, missing };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------- rotation
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Where the master key came from.
|
|
280
|
+
*
|
|
281
|
+
* The key and the ciphertext it protects live on the same disk by default, so
|
|
282
|
+
* one filesystem read gets both. `FOLDRUN_SECRET_KEY` fixes that — a managed
|
|
283
|
+
* secret store holds the key, the disk holds only the encrypted values — and
|
|
284
|
+
* this reports which of the two is in force so the platform can say so rather
|
|
285
|
+
* than leaving it to be discovered.
|
|
286
|
+
*/
|
|
287
|
+
export function masterKeySource(): { source: "env" | "file"; path: string | null } {
|
|
288
|
+
return process.env.FOLDRUN_SECRET_KEY
|
|
289
|
+
? { source: "env", path: null }
|
|
290
|
+
: { source: "file", path: keyFile() };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Every secrets.json under an account: the account's, then each workspace's. */
|
|
294
|
+
function secretFiles(tenant: string): { file: string; workspace?: string }[] {
|
|
295
|
+
const out: { file: string; workspace?: string }[] = [{ file: secretsFile(tenant) }];
|
|
296
|
+
for (const dirName of ["workspaces", "projects"]) {
|
|
297
|
+
const dir = path.join(dataRoot(), tenant, dirName);
|
|
298
|
+
if (!fs.existsSync(dir)) continue;
|
|
299
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
300
|
+
if (!entry.isDirectory()) continue;
|
|
301
|
+
const f = path.join(dir, entry.name, "secrets.json");
|
|
302
|
+
if (fs.existsSync(f)) out.push({ file: f, workspace: entry.name });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export interface RotationResult {
|
|
309
|
+
/** Files rewritten, and how many values each held. */
|
|
310
|
+
rewritten: { file: string; secrets: number }[];
|
|
311
|
+
/** Names that could not be read with `from` — left untouched, never dropped. */
|
|
312
|
+
unreadable: string[];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Re-encrypt every secret in an account from one master key to another.
|
|
317
|
+
*
|
|
318
|
+
* Without this, moving the key into a secret manager is unsafe: change the key
|
|
319
|
+
* and every stored value fails to decrypt, which the platform reports as a
|
|
320
|
+
* *missing* secret. You would not lose the ciphertext, but you would lose the
|
|
321
|
+
* ability to read it, and the error would tell you the secret was never set.
|
|
322
|
+
*
|
|
323
|
+
* Both keys are passed in raw, exactly as FOLDRUN_SECRET_KEY would be, and
|
|
324
|
+
* hashed here the same way masterKey does — so the caller never has to know
|
|
325
|
+
* how a key becomes an AES key.
|
|
326
|
+
*
|
|
327
|
+
* A value that will not decrypt with `from` is reported and left exactly as it
|
|
328
|
+
* is. Half-rotating is bad; silently discarding is worse.
|
|
329
|
+
*/
|
|
330
|
+
export function rotateMasterKey(
|
|
331
|
+
tenant: string,
|
|
332
|
+
from: string,
|
|
333
|
+
to: string,
|
|
334
|
+
): RotationResult {
|
|
335
|
+
assertSafeName(tenant, "tenant");
|
|
336
|
+
const oldKey = crypto.createHash("sha256").update(from).digest();
|
|
337
|
+
const newKey = crypto.createHash("sha256").update(to).digest();
|
|
338
|
+
|
|
339
|
+
const rewritten: RotationResult["rewritten"] = [];
|
|
340
|
+
const unreadable: string[] = [];
|
|
341
|
+
|
|
342
|
+
for (const { file } of secretFiles(tenant)) {
|
|
343
|
+
const all: SecretsFile = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
344
|
+
const next: SecretsFile = {};
|
|
345
|
+
let count = 0;
|
|
346
|
+
|
|
347
|
+
for (const [name, rec] of Object.entries(all)) {
|
|
348
|
+
// A record under the ACCOUNT's key is not under the install key, so
|
|
349
|
+
// rotating the install key must not touch it — re-encrypting it here
|
|
350
|
+
// with a key it was never sealed with is how a rotation would destroy a
|
|
351
|
+
// vault it was meant to protect. Rotating the install key now re-wraps
|
|
352
|
+
// the account keys instead, which is a handful of small values rather
|
|
353
|
+
// than every secret on the box.
|
|
354
|
+
if (rec.k === "tenant") {
|
|
355
|
+
next[name] = rec;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
let plain: string;
|
|
359
|
+
try {
|
|
360
|
+
const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"));
|
|
361
|
+
d.setAuthTag(Buffer.from(rec.tag, "base64"));
|
|
362
|
+
plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
|
|
363
|
+
} catch {
|
|
364
|
+
unreadable.push(name);
|
|
365
|
+
next[name] = rec; // untouched
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const iv = crypto.randomBytes(12);
|
|
369
|
+
const c = crypto.createCipheriv("aes-256-gcm", newKey, iv);
|
|
370
|
+
const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
|
|
371
|
+
next[name] = {
|
|
372
|
+
iv: iv.toString("base64"),
|
|
373
|
+
tag: c.getAuthTag().toString("base64"),
|
|
374
|
+
data: enc.toString("base64"),
|
|
375
|
+
updatedAt: rec.updatedAt, // rotation is not an edit
|
|
376
|
+
};
|
|
377
|
+
count++;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Write via a temp file in the same directory: a half-written secrets.json
|
|
381
|
+
// is an account that cannot authenticate anything.
|
|
382
|
+
const tmp = `${file}.rotating`;
|
|
383
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
384
|
+
fs.renameSync(tmp, file);
|
|
385
|
+
rewritten.push({ file, secrets: count });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return { rewritten, unreadable };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---------------------------------------------------------------- oauth2
|
|
392
|
+
|
|
393
|
+
// Auto-refreshing credentials. A static value covers most APIs; the Google
|
|
394
|
+
// family (Ads, Search Console, Drive, …) hands out a long-lived refresh
|
|
395
|
+
// token that must be exchanged for a short-lived access token before every
|
|
396
|
+
// use. An oauth2 secret stores the exchange recipe, encrypted like any
|
|
397
|
+
// value; what an agent ever sees is the fresh access token, materialised at
|
|
398
|
+
// the last host-side moment before injection. The refresh credentials
|
|
399
|
+
// themselves never cross into a run.
|
|
400
|
+
|
|
401
|
+
export interface OAuth2Config {
|
|
402
|
+
token_url: string;
|
|
403
|
+
client_id: string;
|
|
404
|
+
client_secret: string;
|
|
405
|
+
/** Required for the refresh grant; absent for client_credentials. */
|
|
406
|
+
refresh_token?: string;
|
|
407
|
+
/** "refresh_token" (default — user-consented) or "client_credentials"
|
|
408
|
+
* (machine-to-machine: no user, no refresh token, re-exchanged on
|
|
409
|
+
* expiry). One machinery, two grants. */
|
|
410
|
+
grant_type?: "refresh_token" | "client_credentials";
|
|
411
|
+
/** Extra form fields some providers want (audience, scope, …). */
|
|
412
|
+
extra?: Record<string, string>;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const OAUTH2_PREFIX = "@oauth2 ";
|
|
416
|
+
|
|
417
|
+
export function setOAuth2Secret(
|
|
418
|
+
tenant: string,
|
|
419
|
+
name: string,
|
|
420
|
+
config: OAuth2Config,
|
|
421
|
+
workspace?: string,
|
|
422
|
+
) {
|
|
423
|
+
for (const field of ["token_url", "client_id", "client_secret"] as const) {
|
|
424
|
+
if (!config[field]?.trim()) throw new Error(`oauth2 secret needs ${field}`);
|
|
425
|
+
}
|
|
426
|
+
if (config.grant_type !== "client_credentials" && !config.refresh_token?.trim()) {
|
|
427
|
+
throw new Error("oauth2 secret needs refresh_token (or grant_type: client_credentials)");
|
|
428
|
+
}
|
|
429
|
+
// Loopback is exempt: a token endpoint on this same machine (tests, a
|
|
430
|
+
// local mock, a sidecar) crosses no wire for http to leak on.
|
|
431
|
+
if (!/^https:\/\//.test(config.token_url) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(config.token_url)) {
|
|
432
|
+
throw new Error("token_url must be https — a refresh token over http is a leaked one");
|
|
433
|
+
}
|
|
434
|
+
setSecret(tenant, name, OAUTH2_PREFIX + JSON.stringify(config), workspace, "oauth2");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function isOAuth2Value(value: string): boolean {
|
|
438
|
+
return value.startsWith(OAUTH2_PREFIX);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Fresh tokens are cached until shortly before expiry, and concurrent
|
|
442
|
+
// requests for the same secret share one exchange — a fan-out of twenty
|
|
443
|
+
// steps must not hit Google twenty times in one second.
|
|
444
|
+
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
|
445
|
+
const inFlight = new Map<string, Promise<string>>();
|
|
446
|
+
|
|
447
|
+
async function exchange(config: OAuth2Config, cacheKey: string): Promise<string> {
|
|
448
|
+
const cached = tokenCache.get(cacheKey);
|
|
449
|
+
if (cached && cached.expiresAt > Date.now()) return cached.token;
|
|
450
|
+
|
|
451
|
+
const pending = inFlight.get(cacheKey);
|
|
452
|
+
if (pending) return pending;
|
|
453
|
+
|
|
454
|
+
const flight = (async () => {
|
|
455
|
+
const body = new URLSearchParams({
|
|
456
|
+
grant_type: config.grant_type ?? "refresh_token",
|
|
457
|
+
...(config.grant_type === "client_credentials" ? {} : { refresh_token: config.refresh_token! }),
|
|
458
|
+
client_id: config.client_id,
|
|
459
|
+
client_secret: config.client_secret,
|
|
460
|
+
...(config.extra ?? {}),
|
|
461
|
+
});
|
|
462
|
+
const res = await fetch(config.token_url, {
|
|
463
|
+
method: "POST",
|
|
464
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
465
|
+
body: body.toString(),
|
|
466
|
+
signal: AbortSignal.timeout(15_000),
|
|
467
|
+
});
|
|
468
|
+
const payload = (await res.json().catch(() => ({}))) as {
|
|
469
|
+
access_token?: string;
|
|
470
|
+
expires_in?: number;
|
|
471
|
+
error?: string;
|
|
472
|
+
error_description?: string;
|
|
473
|
+
};
|
|
474
|
+
if (!res.ok || !payload.access_token) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
`token refresh failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token in the reply"}`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
const ttl = Math.max(60, (payload.expires_in ?? 3600) - 60) * 1000;
|
|
480
|
+
tokenCache.set(cacheKey, { token: payload.access_token, expiresAt: Date.now() + ttl });
|
|
481
|
+
return payload.access_token;
|
|
482
|
+
})();
|
|
483
|
+
|
|
484
|
+
inFlight.set(cacheKey, flight);
|
|
485
|
+
try {
|
|
486
|
+
return await flight;
|
|
487
|
+
} finally {
|
|
488
|
+
inFlight.delete(cacheKey);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Swap every oauth2 recipe in an env map for a live access token. The one
|
|
494
|
+
* async moment in secret resolution, kept out of the sync paths: callers
|
|
495
|
+
* materialise right before values are injected or substituted. A failed
|
|
496
|
+
* refresh throws with the secret's name and the provider's reason — a run
|
|
497
|
+
* should fail saying "GOOGLE_ADS_TOKEN: invalid_grant", not 401 somewhere.
|
|
498
|
+
*/
|
|
499
|
+
export async function materializeSecrets(
|
|
500
|
+
env: Record<string, string>,
|
|
501
|
+
): Promise<Record<string, string>> {
|
|
502
|
+
const out: Record<string, string> = { ...env };
|
|
503
|
+
for (const [name, value] of Object.entries(env)) {
|
|
504
|
+
if (typeof value !== "string") continue;
|
|
505
|
+
if (isOAuth2Value(value)) {
|
|
506
|
+
let config: OAuth2Config;
|
|
507
|
+
try {
|
|
508
|
+
config = JSON.parse(value.slice(OAUTH2_PREFIX.length));
|
|
509
|
+
} catch {
|
|
510
|
+
throw new Error(`secret ${name}: stored oauth2 config is unreadable`);
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
// Fingerprint the WHOLE refresh token, not its first 8 chars: Google
|
|
514
|
+
// refresh tokens share a long common prefix, so an 8-char slice could
|
|
515
|
+
// collide two distinct grants onto one cache entry and hand one the
|
|
516
|
+
// other's access token. Hashed so the token is not held in a Map key.
|
|
517
|
+
const grantMaterial = config.refresh_token ?? config.grant_type ?? "";
|
|
518
|
+
const fp = crypto.createHash("sha256").update(grantMaterial).digest("hex").slice(0, 16);
|
|
519
|
+
out[name] = await exchange(config, `${name}:${config.client_id}:${fp}`);
|
|
520
|
+
} catch (err) {
|
|
521
|
+
throw new Error(`secret ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
522
|
+
}
|
|
523
|
+
} else if (value.startsWith(SERVICE_ACCOUNT_PREFIX)) {
|
|
524
|
+
let config: ServiceAccountConfig;
|
|
525
|
+
try {
|
|
526
|
+
config = JSON.parse(value.slice(SERVICE_ACCOUNT_PREFIX.length));
|
|
527
|
+
} catch {
|
|
528
|
+
throw new Error(`secret ${name}: stored service account config is unreadable`);
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
out[name] = await exchangeServiceAccount(config, `sa:${name}:${config.issuer}`);
|
|
532
|
+
} catch (err) {
|
|
533
|
+
throw new Error(`secret ${name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// @file/@ssh/@api values are left as-is here; the run layer materialises
|
|
537
|
+
// them (paths, wrapper scripts), because only it knows the sandbox's
|
|
538
|
+
// writable scratch.
|
|
539
|
+
}
|
|
540
|
+
return out;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ---------------------------------------------------------------- service account
|
|
544
|
+
|
|
545
|
+
// Signed-key credentials: a Google service account, or anything that mints a
|
|
546
|
+
// token by self-signing a JWT with a private key and exchanging it. No user,
|
|
547
|
+
// no browser, no refresh token — the key IS the identity. Better than the
|
|
548
|
+
// consent flow for server-side access: no consent to expire, IAM per service.
|
|
549
|
+
//
|
|
550
|
+
// Stored as the JSON key, encrypted. Materialised the same way oauth2 is: a
|
|
551
|
+
// live access token appears at the last host-side moment, cached until expiry.
|
|
552
|
+
|
|
553
|
+
const SERVICE_ACCOUNT_PREFIX = "@service-account ";
|
|
554
|
+
|
|
555
|
+
export interface ServiceAccountConfig {
|
|
556
|
+
token_url: string; // where the signed JWT is exchanged
|
|
557
|
+
issuer: string; // JWT iss/sub — the service account's email
|
|
558
|
+
private_key: string; // PEM
|
|
559
|
+
scope: string; // space-separated
|
|
560
|
+
/** JWT audience — usually the same as token_url. */
|
|
561
|
+
audience?: string;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function setServiceAccountSecret(
|
|
565
|
+
tenant: string,
|
|
566
|
+
name: string,
|
|
567
|
+
config: ServiceAccountConfig,
|
|
568
|
+
workspace?: string,
|
|
569
|
+
) {
|
|
570
|
+
for (const field of ["token_url", "issuer", "private_key", "scope"] as const) {
|
|
571
|
+
if (!config[field]?.trim()) throw new Error(`service account secret needs ${field}`);
|
|
572
|
+
}
|
|
573
|
+
if (!/BEGIN [A-Z ]*PRIVATE KEY/.test(config.private_key)) {
|
|
574
|
+
throw new Error("private_key must be a PEM key (from the service account JSON)");
|
|
575
|
+
}
|
|
576
|
+
setSecret(tenant, name, SERVICE_ACCOUNT_PREFIX + JSON.stringify(config), workspace, "service-account");
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function base64url(input: Buffer | string): string {
|
|
580
|
+
return Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function exchangeServiceAccount(config: ServiceAccountConfig, cacheKey: string): Promise<string> {
|
|
584
|
+
const cached = tokenCache.get(cacheKey);
|
|
585
|
+
if (cached && cached.expiresAt > Date.now()) return cached.token;
|
|
586
|
+
const pending = inFlight.get(cacheKey);
|
|
587
|
+
if (pending) return pending;
|
|
588
|
+
|
|
589
|
+
const flight = (async () => {
|
|
590
|
+
const now = Math.floor(Date.now() / 1000);
|
|
591
|
+
const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
592
|
+
const claim = base64url(
|
|
593
|
+
JSON.stringify({
|
|
594
|
+
iss: config.issuer,
|
|
595
|
+
sub: config.issuer,
|
|
596
|
+
scope: config.scope,
|
|
597
|
+
aud: config.audience ?? config.token_url,
|
|
598
|
+
iat: now,
|
|
599
|
+
exp: now + 3600,
|
|
600
|
+
}),
|
|
601
|
+
);
|
|
602
|
+
const signer = crypto.createSign("RSA-SHA256");
|
|
603
|
+
signer.update(`${header}.${claim}`);
|
|
604
|
+
const signature = base64url(signer.sign(config.private_key));
|
|
605
|
+
const jwt = `${header}.${claim}.${signature}`;
|
|
606
|
+
|
|
607
|
+
const res = await fetch(config.token_url, {
|
|
608
|
+
method: "POST",
|
|
609
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
610
|
+
body: new URLSearchParams({
|
|
611
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
612
|
+
assertion: jwt,
|
|
613
|
+
}).toString(),
|
|
614
|
+
signal: AbortSignal.timeout(15_000),
|
|
615
|
+
});
|
|
616
|
+
const payload = (await res.json().catch(() => ({}))) as {
|
|
617
|
+
access_token?: string;
|
|
618
|
+
expires_in?: number;
|
|
619
|
+
error?: string;
|
|
620
|
+
error_description?: string;
|
|
621
|
+
};
|
|
622
|
+
if (!res.ok || !payload.access_token) {
|
|
623
|
+
throw new Error(
|
|
624
|
+
`service account exchange failed (${res.status}): ${payload.error_description ?? payload.error ?? "no access_token"}`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
const ttl = Math.max(60, (payload.expires_in ?? 3600) - 60) * 1000;
|
|
628
|
+
tokenCache.set(cacheKey, { token: payload.access_token, expiresAt: Date.now() + ttl });
|
|
629
|
+
return payload.access_token;
|
|
630
|
+
})();
|
|
631
|
+
|
|
632
|
+
inFlight.set(cacheKey, flight);
|
|
633
|
+
try {
|
|
634
|
+
return await flight;
|
|
635
|
+
} finally {
|
|
636
|
+
inFlight.delete(cacheKey);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ---------------------------------------------------------------- file secrets
|
|
641
|
+
|
|
642
|
+
// Some credentials are files, not strings: an SSH private key ssh insists on
|
|
643
|
+
// reading at 0600, a client certificate + key for mTLS. The value is stored
|
|
644
|
+
// like any secret; what differs is delivery — the run materialises it to a
|
|
645
|
+
// path and the agent's env var holds the *path*, not the bytes. The
|
|
646
|
+
// materialising happens in the sandbox layer (run-container/host), which is
|
|
647
|
+
// the only place that knows where a run's writable scratch is; here we only
|
|
648
|
+
// mark the kind and carry the content.
|
|
649
|
+
|
|
650
|
+
const FILE_PREFIX = "@file ";
|
|
651
|
+
|
|
652
|
+
export function setFileSecret(tenant: string, name: string, content: string, workspace?: string) {
|
|
653
|
+
if (!content) throw new Error("a file secret needs content");
|
|
654
|
+
setSecret(tenant, name, FILE_PREFIX + content, workspace, "file");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
export function isFileValue(value: string): boolean {
|
|
658
|
+
return value.startsWith(FILE_PREFIX);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export function fileContent(value: string): string {
|
|
662
|
+
return value.slice(FILE_PREFIX.length);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ---------------------------------------------------------------- ssh
|
|
666
|
+
|
|
667
|
+
// A whole SSH destination, stored as one connection: host, port, user, and
|
|
668
|
+
// either a private key or a password. The user describes the connection the
|
|
669
|
+
// way the service handed it to them; how it is plumbed (key files, sshpass,
|
|
670
|
+
// ssh flags) is the platform's business — the run layer materialises the
|
|
671
|
+
// config into an executable wrapper, so the agent runs `"$NAME" 'uptime'`
|
|
672
|
+
// and never learns which auth flavour it is.
|
|
673
|
+
|
|
674
|
+
const SSH_PREFIX = "@ssh ";
|
|
675
|
+
|
|
676
|
+
export interface SshConfig {
|
|
677
|
+
host: string;
|
|
678
|
+
port?: number;
|
|
679
|
+
user: string;
|
|
680
|
+
/** Exactly one of these two. */
|
|
681
|
+
private_key?: string;
|
|
682
|
+
password?: string;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string) {
|
|
686
|
+
const host = config.host?.trim();
|
|
687
|
+
const user = config.user?.trim();
|
|
688
|
+
if (!host || /[\s'"`$\\]/.test(host)) throw new Error("ssh secret needs a host (no spaces or quotes)");
|
|
689
|
+
if (!user || /[\s'"`$\\]/.test(user)) throw new Error("ssh secret needs a username (no spaces or quotes)");
|
|
690
|
+
const port = config.port ?? 22;
|
|
691
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("ssh port must be 1-65535");
|
|
692
|
+
const hasKey = Boolean(config.private_key?.trim());
|
|
693
|
+
const hasPassword = Boolean(config.password);
|
|
694
|
+
if (hasKey === hasPassword) throw new Error("ssh secret needs a private key or a password (not both)");
|
|
695
|
+
if (hasKey && !/BEGIN [A-Z ]*PRIVATE KEY/.test(config.private_key!)) {
|
|
696
|
+
throw new Error("private_key must be a PEM/OpenSSH private key");
|
|
697
|
+
}
|
|
698
|
+
if (hasPassword && /[\n\r]/.test(config.password!)) throw new Error("ssh password cannot contain newlines");
|
|
699
|
+
const stored: SshConfig = {
|
|
700
|
+
host, user, port,
|
|
701
|
+
...(hasKey ? { private_key: config.private_key } : { password: config.password }),
|
|
702
|
+
};
|
|
703
|
+
setSecret(tenant, name, SSH_PREFIX + JSON.stringify(stored), workspace, "ssh");
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export function isSshValue(value: string): boolean {
|
|
707
|
+
return value.startsWith(SSH_PREFIX);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export function sshConfigOf(value: string): SshConfig {
|
|
711
|
+
return JSON.parse(value.slice(SSH_PREFIX.length));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ---------------------------------------------------------------- api
|
|
715
|
+
|
|
716
|
+
// An HTTP API credential that is more than one bare token: a base URL plus
|
|
717
|
+
// the headers the service wants (Cloudflare's X-Auth-Email + X-Auth-Key,
|
|
718
|
+
// Stripe's Authorization + Stripe-Version, ...). Materialised as a curl
|
|
719
|
+
// wrapper with the headers baked in — `"$NAME" /v1/charges` — so the secret
|
|
720
|
+
// never appears in the bash command at all.
|
|
721
|
+
|
|
722
|
+
const API_PREFIX = "@api ";
|
|
723
|
+
|
|
724
|
+
export interface ApiConfig {
|
|
725
|
+
base_url?: string;
|
|
726
|
+
headers: Record<string, string>;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
export function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string) {
|
|
730
|
+
const headers = Object.entries(config.headers ?? {});
|
|
731
|
+
if (headers.length === 0) throw new Error("api secret needs at least one header");
|
|
732
|
+
for (const [h, v] of headers) {
|
|
733
|
+
if (!/^[A-Za-z0-9-]+$/.test(h)) throw new Error(`"${h}" is not a valid header name`);
|
|
734
|
+
if (/[\n\r]/.test(v)) throw new Error(`header ${h} cannot contain newlines`);
|
|
735
|
+
}
|
|
736
|
+
const base = config.base_url?.trim();
|
|
737
|
+
if (base && !/^https?:\/\//.test(base)) throw new Error("base_url must be http(s)");
|
|
738
|
+
setSecret(
|
|
739
|
+
tenant, name,
|
|
740
|
+
API_PREFIX + JSON.stringify({ ...(base ? { base_url: base.replace(/\/+$/, "") } : {}), headers: config.headers }),
|
|
741
|
+
workspace, "api",
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
export function isApiValue(value: string): boolean {
|
|
746
|
+
return value.startsWith(API_PREFIX);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export function apiConfigOf(value: string): ApiConfig {
|
|
750
|
+
return JSON.parse(value.slice(API_PREFIX.length));
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Move an account's secrets from the install key onto its own key.
|
|
756
|
+
*
|
|
757
|
+
* Read with whichever key each record names, write them all back under the
|
|
758
|
+
* account's. Every value is decrypted BEFORE anything is written: a vault
|
|
759
|
+
* half-moved is an account that can authenticate nothing, so the whole file
|
|
760
|
+
* either converts or is left exactly as it was.
|
|
761
|
+
*/
|
|
762
|
+
export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable: string[] } {
|
|
763
|
+
assertSafeName(tenant, "tenant");
|
|
764
|
+
if (!tenantKey(tenant)) return { moved: 0, unreadable: [] };
|
|
765
|
+
|
|
766
|
+
let moved = 0;
|
|
767
|
+
const unreadable: string[] = [];
|
|
768
|
+
|
|
769
|
+
for (const { file } of secretFiles(tenant)) {
|
|
770
|
+
const all: SecretsFile = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
771
|
+
const plain = new Map<string, string>();
|
|
772
|
+
let pending = 0;
|
|
773
|
+
|
|
774
|
+
for (const [name, rec] of Object.entries(all)) {
|
|
775
|
+
if (rec.k === "tenant") continue; // already moved
|
|
776
|
+
const value = decrypt(tenant, rec);
|
|
777
|
+
if (value === null) {
|
|
778
|
+
unreadable.push(name);
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
plain.set(name, value);
|
|
782
|
+
pending += 1;
|
|
783
|
+
}
|
|
784
|
+
// Nothing to do, or something could not be read — leave the file alone.
|
|
785
|
+
// A partial conversion is worse than none, because the half that moved is
|
|
786
|
+
// no longer openable by the key the other half still needs.
|
|
787
|
+
if (pending === 0 || unreadable.length) continue;
|
|
788
|
+
|
|
789
|
+
const next: SecretsFile = { ...all };
|
|
790
|
+
for (const [name, value] of plain) {
|
|
791
|
+
next[name] = { ...encryptValue(tenant, value), updatedAt: all[name].updatedAt, ...(all[name].kind ? { kind: all[name].kind } : {}) };
|
|
792
|
+
}
|
|
793
|
+
const tmp = `${file}.rewrap`;
|
|
794
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
795
|
+
fs.renameSync(tmp, file);
|
|
796
|
+
moved += pending;
|
|
797
|
+
}
|
|
798
|
+
return { moved, unreadable };
|
|
799
|
+
}
|