@monster-orange/den 0.4.3 → 0.4.5
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/CHANGELOG.md +348 -0
- package/README.md +12 -11
- package/docs/distributions.md +438 -91
- package/docs/reference.md +8 -3
- package/package.json +1 -1
- package/src/shared/agents/catalog-runtime.ts +178 -0
- package/src/shared/agents/catalog.ts +343 -0
- package/src/shared/agents/den-claude.ts +14 -18
- package/src/shared/agents/runtime-search.ts +142 -15
- package/src/shared/agents/runtime.ts +18 -1
- package/src/shared/agents/runtimes.ts +23 -1
- package/src/shared/auth/sudo-vault.ts +131 -11
- package/src/shared/cli-command.ts +186 -0
- package/src/shared/confirm-code.ts +34 -0
- package/src/shared/den-config.ts +64 -3
- package/src/shared/dev-metrics/emit.ts +31 -1
- package/src/shared/edge/config.ts +39 -0
- package/src/shared/edge/render.ts +92 -18
- package/src/shared/edge/runtime.ts +57 -1
- package/src/shared/edge/status.ts +134 -0
- package/src/shared/edge/types.ts +11 -1
- package/src/shared/environment-manager/health.ts +82 -8
- package/src/shared/environment-manager/workspaces.ts +54 -0
- package/src/shared/github-app.ts +94 -0
- package/src/shared/github-device.ts +286 -0
- package/src/shared/health/not-checked.ts +42 -0
- package/src/shared/health/rollup.ts +4 -0
- package/src/shared/process/spawn-streaming.ts +8 -3
- package/src/shared/secrets/onepassword.ts +27 -4
- package/src/shared/sessions/client.ts +5 -1
- package/src/shared/sessions/host.ts +29 -1
- package/src/shared/sessions/protocol.ts +6 -1
- package/src/shared/sessions/types.ts +6 -0
- package/src/shared/stacks/status.ts +24 -0
- package/src/shared/strategies/registry.ts +5 -0
- package/src/shared/types/index.ts +6 -0
- package/src/shared/web-owner.ts +91 -0
- package/src/shared/web-session.ts +135 -1
- package/src/ui/cli/agent-ops.ts +210 -19
- package/src/ui/cli/agents-command.ts +50 -21
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/flag-parser.ts +1 -1
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/cli/web-command.ts +512 -33
- package/src/ui/mcp/scope.ts +23 -3
- package/src/ui/mcp/server.ts +31 -7
- package/src/ui/mcp/tools.ts +327 -20
- package/src/ui/mcp/views.ts +450 -0
- package/src/ui/server/agent-routes.ts +118 -28
- package/src/ui/server/device-flows.ts +274 -0
- package/src/ui/server/edge-exposure.ts +16 -4
- package/src/ui/server/github-routes.ts +321 -0
- package/src/ui/server/http-errors.ts +10 -0
- package/src/ui/server/index.ts +540 -33
- package/src/ui/server/login-waits.ts +203 -0
- package/src/ui/server/pair-requests.ts +237 -0
- package/src/ui/server/pair-routes.ts +196 -0
- package/src/ui/server/public/agent-start.js +46 -7
- package/src/ui/server/public/agents-page.js +232 -97
- package/src/ui/server/public/agents-section.js +3 -5
- package/src/ui/server/public/agents.html +12 -28
- package/src/ui/server/public/error-page.js +452 -40
- package/src/ui/server/public/error.html +59 -0
- package/src/ui/server/public/graph-model.js +2 -0
- package/src/ui/server/public/header.js +2 -3
- package/src/ui/server/public/health-rollup.js +20 -8
- package/src/ui/server/public/i18n/es.js +89 -21
- package/src/ui/server/public/i18n/ja.js +88 -21
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +44 -21
- package/src/ui/server/public/qr.js +317 -0
- package/src/ui/server/public/services.html +21 -5
- package/src/ui/server/public/settings-page.js +10 -2
- package/src/ui/server/public/settings-registry.js +617 -1
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +151 -33
- package/src/ui/server/public/topbar.js +170 -206
- package/src/ui/server/public/workspace-picker.js +1 -1
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +103 -36
- package/src/ui/server/route-classes.ts +116 -4
- package/src/ui/server/session-routes.ts +9 -1
- package/src/ui/server/sudo-store.ts +170 -6
- package/src/ui/server/ui-assets.ts +2 -0
- package/src/ui/server/workspace-routes.ts +10 -3
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
* Claude Code's native installer puts `claude`.
|
|
6
6
|
*
|
|
7
7
|
* The search, first match wins:
|
|
8
|
+
* 0. workspace `<ws>.agents.runtimes.<id>.bin` in
|
|
9
|
+
* config/local/workspaces.yml (AG-0.5), for an agent of
|
|
10
|
+
* that workspace;
|
|
8
11
|
* 1. setting `agents.runtimes.<id>.bin` in config/local/user.yml
|
|
9
12
|
* (per machine, so never in the distribution's config):
|
|
10
|
-
* an absolute path to an executable file; a bad one
|
|
11
|
-
* refused with its reason, never skipped.
|
|
13
|
+
* an absolute path to an executable file; a bad one (at
|
|
14
|
+
* either scope) is refused with its reason, never skipped.
|
|
12
15
|
* 2. login-shell the login shell's PATH (`$SHELL -lc`), resolved once
|
|
13
16
|
* at server start, async, under a 3-second timeout; on
|
|
14
17
|
* any failure it is left out (the current PATH remains).
|
|
@@ -18,6 +21,11 @@
|
|
|
18
21
|
* /opt/homebrew/bin, /usr/local/bin and npm's global
|
|
19
22
|
* bin, derived from npm_config_prefix and node's own
|
|
20
23
|
* directory, never by running npm).
|
|
24
|
+
* 5. interactive-shell only when a runtime is still missing: the
|
|
25
|
+
* interactive login shell's PATH (`$SHELL -ilc`, where
|
|
26
|
+
* some people set PATH in .zshrc/.bashrc), asked once per
|
|
27
|
+
* process with stdin closed under the same 3-second
|
|
28
|
+
* timeout, and kept (ensureInteractivePath).
|
|
21
29
|
*
|
|
22
30
|
* Sessions and agents Den starts get the same directories on their PATH
|
|
23
31
|
* (sessionPath), so the runtime's own tools are found too.
|
|
@@ -32,7 +40,13 @@ import { spawn } from "child_process";
|
|
|
32
40
|
import * as fs from "fs";
|
|
33
41
|
import * as path from "path";
|
|
34
42
|
|
|
35
|
-
export type BinSource =
|
|
43
|
+
export type BinSource =
|
|
44
|
+
| "workspace"
|
|
45
|
+
| "setting"
|
|
46
|
+
| "login-shell"
|
|
47
|
+
| "well-known"
|
|
48
|
+
| "PATH"
|
|
49
|
+
| "interactive-shell";
|
|
36
50
|
|
|
37
51
|
/** The shell asked for the login PATH instead of $SHELL; "off" for none. */
|
|
38
52
|
export const LOGIN_SHELL_ENV = "DEN_LOGIN_SHELL";
|
|
@@ -52,7 +66,7 @@ export interface RuntimeFind {
|
|
|
52
66
|
|
|
53
67
|
export interface SearchDir {
|
|
54
68
|
dir: string;
|
|
55
|
-
source: Exclude<BinSource, "setting">;
|
|
69
|
+
source: Exclude<BinSource, "setting" | "workspace">;
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
export interface Located {
|
|
@@ -96,6 +110,8 @@ const END = "__DEN_PATH_END__";
|
|
|
96
110
|
export interface LoginPathOptions {
|
|
97
111
|
env?: NodeJS.ProcessEnv;
|
|
98
112
|
timeoutMs?: number;
|
|
113
|
+
/** The shell's flags: "-lc" (a login shell), "-ilc" (interactive too). */
|
|
114
|
+
flags?: "-lc" | "-ilc";
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
/**
|
|
@@ -127,10 +143,14 @@ export function resolveLoginPath(
|
|
|
127
143
|
};
|
|
128
144
|
let child: ReturnType<typeof spawn>;
|
|
129
145
|
try {
|
|
130
|
-
child = spawn(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
146
|
+
child = spawn(
|
|
147
|
+
shell,
|
|
148
|
+
[o.flags ?? "-lc", `printf '${BEGIN}%s${END}' "$PATH"`],
|
|
149
|
+
{
|
|
150
|
+
env,
|
|
151
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
152
|
+
},
|
|
153
|
+
);
|
|
134
154
|
} catch {
|
|
135
155
|
resolve(undefined);
|
|
136
156
|
return;
|
|
@@ -167,6 +187,38 @@ export async function initLoginPath(o: LoginPathOptions = {}): Promise<void> {
|
|
|
167
187
|
if (p) setLoginPath(p);
|
|
168
188
|
}
|
|
169
189
|
|
|
190
|
+
// ── the interactive shell's PATH (the fallback) ─────────────────────────────
|
|
191
|
+
|
|
192
|
+
let interactiveDirs: string[] | null = null;
|
|
193
|
+
let interactiveAsked: Promise<void> | null = null;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Ask the interactive login shell (`$SHELL -ilc`) for its PATH, once per
|
|
197
|
+
* process (stdin closed, the same timeout), and keep its directories as
|
|
198
|
+
* the last place a runtime is looked for. Only for a runtime still missing
|
|
199
|
+
* after the login PATH and the install directories; a second call waits
|
|
200
|
+
* on (or returns) the first. Never throws.
|
|
201
|
+
*/
|
|
202
|
+
export function ensureInteractivePath(
|
|
203
|
+
o: Omit<LoginPathOptions, "flags"> = {},
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
interactiveAsked ??= resolveLoginPath({ ...o, flags: "-ilc" }).then((p) => {
|
|
206
|
+
if (p) interactiveDirs = absoluteDirs(p);
|
|
207
|
+
});
|
|
208
|
+
return interactiveAsked;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Whether the interactive shell was asked already (the answer is kept). */
|
|
212
|
+
export function interactivePathAsked(): boolean {
|
|
213
|
+
return interactiveAsked !== null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Forget the interactive shell's PATH and that it was asked (tests). */
|
|
217
|
+
export function resetInteractivePath(): void {
|
|
218
|
+
interactiveDirs = null;
|
|
219
|
+
interactiveAsked = null;
|
|
220
|
+
}
|
|
221
|
+
|
|
170
222
|
// ── the directories ─────────────────────────────────────────────────────────
|
|
171
223
|
|
|
172
224
|
/** A PATH-style list's absolute entries, in order, once each. */
|
|
@@ -213,7 +265,10 @@ export function wellKnownDirs(
|
|
|
213
265
|
: [];
|
|
214
266
|
}
|
|
215
267
|
|
|
216
|
-
/**
|
|
268
|
+
/**
|
|
269
|
+
* Where a runtime is looked for, in order: login shell, PATH, well-known,
|
|
270
|
+
* then (once asked) the interactive shell's PATH.
|
|
271
|
+
*/
|
|
217
272
|
export function searchDirs(
|
|
218
273
|
find: RuntimeFind | undefined,
|
|
219
274
|
env: NodeJS.ProcessEnv,
|
|
@@ -226,6 +281,7 @@ export function searchDirs(
|
|
|
226
281
|
add(loginDirs ?? [], "login-shell");
|
|
227
282
|
add(absoluteDirs(env.PATH ?? ""), "PATH");
|
|
228
283
|
add(wellKnownDirs(find, env), "well-known");
|
|
284
|
+
add(interactiveDirs ?? [], "interactive-shell");
|
|
229
285
|
return out;
|
|
230
286
|
}
|
|
231
287
|
|
|
@@ -248,8 +304,8 @@ export const binSettingKey = (id: string) => `agents.runtimes.${id}.bin`;
|
|
|
248
304
|
export function checkBinSetting(
|
|
249
305
|
id: string,
|
|
250
306
|
value: unknown,
|
|
307
|
+
key: string = binSettingKey(id),
|
|
251
308
|
): { path: string } | { why: string } {
|
|
252
|
-
const key = binSettingKey(id);
|
|
253
309
|
if (
|
|
254
310
|
value &&
|
|
255
311
|
typeof value === "object" &&
|
|
@@ -268,10 +324,18 @@ export function checkBinSetting(
|
|
|
268
324
|
return { path: p };
|
|
269
325
|
}
|
|
270
326
|
|
|
327
|
+
/** A path someone set: where (global or the workspace's) and its key. */
|
|
328
|
+
export interface BinSetting {
|
|
329
|
+
value: unknown;
|
|
330
|
+
source: "setting" | "workspace";
|
|
331
|
+
/** The key it is under, for a refusal ("agents.runtimes.codex.bin"). */
|
|
332
|
+
key?: string;
|
|
333
|
+
}
|
|
334
|
+
|
|
271
335
|
/**
|
|
272
336
|
* Find runtime `id`'s CLI: the setting when there is one (refused with its
|
|
273
337
|
* reason when bad), else the first of `find.bins` in searchDirs. Runs
|
|
274
|
-
* nothing.
|
|
338
|
+
* nothing. `setting` is a value (global, as before) or a BinSetting.
|
|
275
339
|
*/
|
|
276
340
|
export function locateRuntime(
|
|
277
341
|
id: string,
|
|
@@ -279,11 +343,17 @@ export function locateRuntime(
|
|
|
279
343
|
env: NodeJS.ProcessEnv,
|
|
280
344
|
setting?: unknown,
|
|
281
345
|
): Located {
|
|
282
|
-
|
|
283
|
-
|
|
346
|
+
const set: BinSetting | null =
|
|
347
|
+
setting === undefined || setting === null
|
|
348
|
+
? null
|
|
349
|
+
: isBinSetting(setting)
|
|
350
|
+
? setting
|
|
351
|
+
: { value: setting, source: "setting" };
|
|
352
|
+
if (set && set.value !== undefined && set.value !== null) {
|
|
353
|
+
const c = checkBinSetting(id, set.value, set.key);
|
|
284
354
|
return "path" in c
|
|
285
|
-
? { available: true, path: c.path, source:
|
|
286
|
-
: { available: false, why: c.why, source:
|
|
355
|
+
? { available: true, path: c.path, source: set.source, searched: [] }
|
|
356
|
+
: { available: false, why: c.why, source: set.source, searched: [] };
|
|
287
357
|
}
|
|
288
358
|
const dirs = searchDirs(find, env);
|
|
289
359
|
for (const { dir, source } of dirs)
|
|
@@ -304,6 +374,17 @@ export function locateRuntime(
|
|
|
304
374
|
};
|
|
305
375
|
}
|
|
306
376
|
|
|
377
|
+
function isBinSetting(v: unknown): v is BinSetting {
|
|
378
|
+
return (
|
|
379
|
+
!!v &&
|
|
380
|
+
typeof v === "object" &&
|
|
381
|
+
"source" in v &&
|
|
382
|
+
"value" in v &&
|
|
383
|
+
((v as BinSetting).source === "setting" ||
|
|
384
|
+
(v as BinSetting).source === "workspace")
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
307
388
|
/**
|
|
308
389
|
* The PATH a session Den starts gets: the login shell's, the caller's,
|
|
309
390
|
* then each runtime's well-known directories that exist, and `extra` (a
|
|
@@ -410,6 +491,52 @@ export function runtimeVersion(
|
|
|
410
491
|
return p;
|
|
411
492
|
}
|
|
412
493
|
|
|
494
|
+
/**
|
|
495
|
+
* Run `<bin> <args>` (stdin closed, under a timeout, never cached) and
|
|
496
|
+
* return what it printed and whether it exited 0: to check a path someone
|
|
497
|
+
* sets is the runtime it claims to be. Never throws.
|
|
498
|
+
*/
|
|
499
|
+
export function versionOutput(
|
|
500
|
+
bin: string,
|
|
501
|
+
args: readonly string[],
|
|
502
|
+
env: NodeJS.ProcessEnv,
|
|
503
|
+
timeoutMs: number = VERSION_TIMEOUT_MS,
|
|
504
|
+
): Promise<{ ok: boolean; out: string }> {
|
|
505
|
+
return new Promise((resolve) => {
|
|
506
|
+
let out = "";
|
|
507
|
+
let done = false;
|
|
508
|
+
const finish = (ok: boolean) => {
|
|
509
|
+
if (done) return;
|
|
510
|
+
done = true;
|
|
511
|
+
clearTimeout(timer);
|
|
512
|
+
resolve({ ok, out });
|
|
513
|
+
};
|
|
514
|
+
let child: ReturnType<typeof spawn>;
|
|
515
|
+
try {
|
|
516
|
+
child = spawn(bin, [...args], { env, stdio: ["ignore", "pipe", "pipe"] });
|
|
517
|
+
} catch {
|
|
518
|
+
resolve({ ok: false, out: "" });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const timer = setTimeout(() => {
|
|
522
|
+
try {
|
|
523
|
+
child.kill("SIGKILL");
|
|
524
|
+
} catch {
|
|
525
|
+
/* gone */
|
|
526
|
+
}
|
|
527
|
+
finish(false);
|
|
528
|
+
}, timeoutMs);
|
|
529
|
+
timer.unref?.();
|
|
530
|
+
const take = (d: Buffer) => {
|
|
531
|
+
if (out.length < 4096) out += d.toString("utf-8");
|
|
532
|
+
};
|
|
533
|
+
child.stdout?.on("data", take);
|
|
534
|
+
child.stderr?.on("data", take);
|
|
535
|
+
child.on("error", () => finish(false));
|
|
536
|
+
child.on("close", (code) => finish(code === 0));
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
413
540
|
/** Forget every version asked (tests). */
|
|
414
541
|
export function clearVersionCache(): void {
|
|
415
542
|
versions.clear();
|
|
@@ -13,8 +13,10 @@ import {
|
|
|
13
13
|
getStrategy,
|
|
14
14
|
registerStrategy,
|
|
15
15
|
strategyIds,
|
|
16
|
+
unregisterStrategy,
|
|
16
17
|
type StrategyEntry,
|
|
17
18
|
} from "../strategies/registry.js";
|
|
19
|
+
import type { CatalogEntry } from "./catalog.js";
|
|
18
20
|
import type { Persona } from "./personas.js";
|
|
19
21
|
import type { BinSource, RuntimeFind } from "./runtime-search.js";
|
|
20
22
|
|
|
@@ -39,7 +41,8 @@ export interface RuntimeDetect {
|
|
|
39
41
|
export interface DetectOptions {
|
|
40
42
|
/**
|
|
41
43
|
* `agents.runtimes.<id>.bin` as configured in user.yml (undefined:
|
|
42
|
-
* read it there; null: none).
|
|
44
|
+
* read it there; null: none), or a BinSetting (runtime-search.ts) that
|
|
45
|
+
* says where it was set (the workspace's, or global).
|
|
43
46
|
*/
|
|
44
47
|
bin?: unknown;
|
|
45
48
|
}
|
|
@@ -107,6 +110,15 @@ export interface Runtime {
|
|
|
107
110
|
id: string;
|
|
108
111
|
/** Its name for people ("Claude Code"), in the Start agent form. */
|
|
109
112
|
label?: string;
|
|
113
|
+
/** Who makes it ("Anthropic"): a catalog runtime's vendor (AG-0.5). */
|
|
114
|
+
vendor?: string;
|
|
115
|
+
/** Its catalog entry (catalog.ts), when it is one. */
|
|
116
|
+
catalog?: CatalogEntry;
|
|
117
|
+
/**
|
|
118
|
+
* Whether it runs with Den's tools (its MCP config and guards). false:
|
|
119
|
+
* it runs as its own CLI, tagged "Den tools: coming" (AG-0.5).
|
|
120
|
+
*/
|
|
121
|
+
denTools?: boolean;
|
|
110
122
|
/**
|
|
111
123
|
* How its CLI is found and its version asked (AG-0.1): the search's
|
|
112
124
|
* binary names and install directories, per runtime, so a catalog can
|
|
@@ -143,6 +155,11 @@ export class LaunchError extends Error {
|
|
|
143
155
|
}
|
|
144
156
|
}
|
|
145
157
|
|
|
158
|
+
/** Remove a runtime (a configured catalog entry that is gone). */
|
|
159
|
+
export function unregisterRuntime(id: string): void {
|
|
160
|
+
unregisterStrategy(RUNTIME_KIND, id);
|
|
161
|
+
}
|
|
162
|
+
|
|
146
163
|
/** Register a runtime as strategy kind `runtime` (doctor lists it). */
|
|
147
164
|
export function registerRuntime(rt: Runtime): void {
|
|
148
165
|
const entry: StrategyEntry<typeof RUNTIME_KIND, Runtime> = {
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Den's built-in runtimes, registered as strategy kind `runtime`: import
|
|
3
|
-
* this module to have them. den-claude first: it is the default.
|
|
3
|
+
* this module to have them. den-claude first: it is the default. Then the
|
|
4
|
+
* rest of the runtime catalog (AG-0.5: codex, gemini, grok), then generic.
|
|
5
|
+
* Entries a distribution or user.yml adds are registered by
|
|
6
|
+
* syncConfiguredRuntimes, after the built-ins.
|
|
4
7
|
*/
|
|
8
|
+
import { BUILTIN_CATALOG, type CatalogEntry } from "./catalog.js";
|
|
9
|
+
import { catalogRuntime } from "./catalog-runtime.js";
|
|
5
10
|
import { denClaude } from "./den-claude.js";
|
|
6
11
|
import { generic } from "./generic.js";
|
|
7
12
|
import {
|
|
@@ -9,6 +14,7 @@ import {
|
|
|
9
14
|
getRuntime,
|
|
10
15
|
registerRuntime,
|
|
11
16
|
runtimeIds,
|
|
17
|
+
unregisterRuntime,
|
|
12
18
|
type Runtime,
|
|
13
19
|
} from "./runtime.js";
|
|
14
20
|
import { sessionPath, type RuntimeFind } from "./runtime-search.js";
|
|
@@ -16,8 +22,24 @@ import { ACTOR_TOKEN_ENV } from "../actor-token.js";
|
|
|
16
22
|
import { AGENT_ACTOR_ENV, buildSessionEnv } from "../sessions/env.js";
|
|
17
23
|
|
|
18
24
|
registerRuntime(denClaude);
|
|
25
|
+
for (const e of BUILTIN_CATALOG)
|
|
26
|
+
if (e.id !== denClaude.id) registerRuntime(catalogRuntime(e));
|
|
19
27
|
registerRuntime(generic);
|
|
20
28
|
|
|
29
|
+
let configured: string[] = [];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Register the catalog entries a distribution and user.yml add (already
|
|
33
|
+
* checked: catalog.ts validateCatalog), replacing the last set: an entry
|
|
34
|
+
* taken out of the config is no runtime any more.
|
|
35
|
+
*/
|
|
36
|
+
export function syncConfiguredRuntimes(entries: readonly CatalogEntry[]): void {
|
|
37
|
+
const ids = entries.map((e) => e.id);
|
|
38
|
+
for (const id of configured) if (!ids.includes(id)) unregisterRuntime(id);
|
|
39
|
+
for (const e of entries) registerRuntime(catalogRuntime(e));
|
|
40
|
+
configured = ids;
|
|
41
|
+
}
|
|
42
|
+
|
|
21
43
|
export {
|
|
22
44
|
allScrubEnv,
|
|
23
45
|
DEFAULT_RUNTIME_ID,
|
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
* Secrets-backed persistence for the local-dev sudo password.
|
|
3
3
|
*
|
|
4
4
|
* Goes through the configured secrets strategy (see src/shared/secrets),
|
|
5
|
-
* addressing the named ref `sudo`.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* addressing the named ref `sudo`. A distribution may set
|
|
6
|
+
* `secrets.refs.sudo` in its config; that ref always wins. Without one, the
|
|
7
|
+
* vault path is "not configured" until the person saves the password
|
|
8
|
+
* (LK-4): when a backend that keeps secrets (1Password, Doppler) is
|
|
9
|
+
* available, saving creates the item and writes the ref to their own
|
|
10
|
+
* user.yml, because the password is the person's, not the distribution's.
|
|
11
|
+
* Nothing is spawned until then.
|
|
9
12
|
*
|
|
10
13
|
* All read helpers fail *soft*: if the backend isn't installed, the user isn't
|
|
11
14
|
* signed in, the ref is not configured or the value is missing, they return
|
|
@@ -21,22 +24,64 @@ import {
|
|
|
21
24
|
secretsStatus,
|
|
22
25
|
SecretsNotConfiguredError,
|
|
23
26
|
type SecretRef,
|
|
27
|
+
type SecretsConfig,
|
|
24
28
|
type SecretsProviderName,
|
|
25
29
|
} from "../secrets/index.js";
|
|
26
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
readDenConfig,
|
|
32
|
+
readUserConfig,
|
|
33
|
+
writeUserConfig,
|
|
34
|
+
} from "../den-config.js";
|
|
35
|
+
import { envKey, product } from "../product.js";
|
|
27
36
|
|
|
28
37
|
/** Name of the sudo ref under `secrets.refs`. */
|
|
29
38
|
export const SUDO_REF_NAME = "sudo";
|
|
30
39
|
|
|
31
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* The configured sudo ref, or null when `secrets.refs.sudo` is not set. The
|
|
42
|
+
* distribution's own ref wins over one in user.yml (the merged config lets
|
|
43
|
+
* user.yml override refs in general; not this one).
|
|
44
|
+
*/
|
|
32
45
|
export function sudoRef(): SecretRef | null {
|
|
33
46
|
try {
|
|
34
|
-
|
|
47
|
+
const cfg = readSecretsConfig();
|
|
48
|
+
let team: SecretsConfig["refs"];
|
|
49
|
+
try {
|
|
50
|
+
team = readDenConfig().secrets?.refs;
|
|
51
|
+
} catch {
|
|
52
|
+
team = undefined;
|
|
53
|
+
}
|
|
54
|
+
const fromTeam = team?.[SUDO_REF_NAME]
|
|
55
|
+
? namedSecretRef(SUDO_REF_NAME, {
|
|
56
|
+
...cfg,
|
|
57
|
+
refs: { [SUDO_REF_NAME]: team[SUDO_REF_NAME] },
|
|
58
|
+
})
|
|
59
|
+
: null;
|
|
60
|
+
return fromTeam ?? namedSecretRef(SUDO_REF_NAME, cfg);
|
|
35
61
|
} catch {
|
|
36
62
|
return null;
|
|
37
63
|
}
|
|
38
64
|
}
|
|
39
65
|
|
|
66
|
+
/**
|
|
67
|
+
* LK-4: where a password saved with no `secrets.refs.sudo` goes, for a
|
|
68
|
+
* backend that keeps it: a new 1Password item in the person's default
|
|
69
|
+
* vault, or a Doppler key. Null for `env` (process memory only: nothing
|
|
70
|
+
* survives a restart) and `none`.
|
|
71
|
+
*/
|
|
72
|
+
export function newSudoRef(
|
|
73
|
+
backend: SecretsProviderName,
|
|
74
|
+
): Partial<SecretRef> | null {
|
|
75
|
+
switch (backend) {
|
|
76
|
+
case "1password":
|
|
77
|
+
return { item: `${product().title} admin password`, field: "password" };
|
|
78
|
+
case "doppler":
|
|
79
|
+
return { key: envKey("LOCAL_ADMIN_PWD") };
|
|
80
|
+
default:
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
40
85
|
/** True when the sudo ref is configured AND its backend is ready. */
|
|
41
86
|
export async function isSecretsBackendReady(): Promise<boolean> {
|
|
42
87
|
if (!sudoRef()) return false;
|
|
@@ -58,12 +103,64 @@ export async function hasSudoInVault(): Promise<boolean> {
|
|
|
58
103
|
return (await readSudoFromVault()) !== null;
|
|
59
104
|
}
|
|
60
105
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
106
|
+
/**
|
|
107
|
+
* A backend error, without the command line it ran: a CLI's failure message
|
|
108
|
+
* can quote its arguments, and a password can be one of them.
|
|
109
|
+
*/
|
|
110
|
+
function withoutSecret(err: unknown, password: string): Error {
|
|
111
|
+
const msg = String((err as Error)?.message ?? err)
|
|
112
|
+
.split("\n")
|
|
113
|
+
.filter((l) => !/^Command failed:/.test(l))
|
|
114
|
+
.join(" ")
|
|
115
|
+
.split(password)
|
|
116
|
+
.join("***")
|
|
117
|
+
.trim();
|
|
118
|
+
return new Error(msg || "the secrets backend refused it");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Save (or overwrite) the sudo password. With no `secrets.refs.sudo` and a
|
|
123
|
+
* backend that keeps secrets (LK-4), it creates the item and then writes
|
|
124
|
+
* the ref to user.yml (`ref: "created"`); a failed save writes nothing.
|
|
125
|
+
* Throws on backend errors, never with the password in the message.
|
|
126
|
+
*/
|
|
127
|
+
export async function saveSudoToVault(
|
|
128
|
+
password: string,
|
|
129
|
+
): Promise<{ ref: "configured" | "created" }> {
|
|
63
130
|
const ref = sudoRef();
|
|
64
|
-
if (
|
|
131
|
+
if (ref) {
|
|
132
|
+
try {
|
|
133
|
+
await getSecretsProvider().set(ref, password);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
throw withoutSecret(err, password);
|
|
136
|
+
}
|
|
137
|
+
return { ref: "configured" };
|
|
138
|
+
}
|
|
139
|
+
const st = secretsStatus(readSecretsConfig(), process.env);
|
|
140
|
+
// Only a backend someone chose (an unset provider is never used here).
|
|
141
|
+
const fresh =
|
|
142
|
+
!st.implicit && st.detect.available ? newSudoRef(st.backend) : null;
|
|
143
|
+
const made = fresh
|
|
144
|
+
? namedSecretRef(
|
|
145
|
+
SUDO_REF_NAME,
|
|
146
|
+
{ refs: { [SUDO_REF_NAME]: fresh } },
|
|
147
|
+
st.backend,
|
|
148
|
+
)
|
|
149
|
+
: null;
|
|
150
|
+
if (!fresh || !made)
|
|
65
151
|
throw new SecretsNotConfiguredError(notConfiguredMessage(SUDO_REF_NAME));
|
|
66
|
-
|
|
152
|
+
try {
|
|
153
|
+
await getSecretsProvider().set(made, password);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
throw withoutSecret(err, password);
|
|
156
|
+
}
|
|
157
|
+
const user = readUserConfig();
|
|
158
|
+
const secrets = user.secrets ?? {};
|
|
159
|
+
writeUserConfig({
|
|
160
|
+
...user,
|
|
161
|
+
secrets: { ...secrets, refs: { ...secrets.refs, [SUDO_REF_NAME]: fresh } },
|
|
162
|
+
});
|
|
163
|
+
return { ref: "created" };
|
|
67
164
|
}
|
|
68
165
|
|
|
69
166
|
/** What the UI's unlock form may offer: `/api/sudo-password/vault-status`. */
|
|
@@ -74,6 +171,12 @@ export interface SudoVaultStatus {
|
|
|
74
171
|
label: string;
|
|
75
172
|
/** `secrets.refs.sudo` is set for this backend. */
|
|
76
173
|
configured: boolean;
|
|
174
|
+
/**
|
|
175
|
+
* Saving works without editing YAML: the ref is configured, or (LK-4) it
|
|
176
|
+
* isn't and the backend keeps secrets, so saving creates the item and
|
|
177
|
+
* writes the ref to user.yml.
|
|
178
|
+
*/
|
|
179
|
+
canSave: boolean;
|
|
77
180
|
/**
|
|
78
181
|
* The backend can work here, whether or not a sudo ref is configured
|
|
79
182
|
* (`configured` says that). With no ref: detect() only (a PATH check,
|
|
@@ -106,6 +209,7 @@ export async function sudoVaultStatus(): Promise<SudoVaultStatus> {
|
|
|
106
209
|
backend: st.backend,
|
|
107
210
|
label: st.label,
|
|
108
211
|
configured: !!ref,
|
|
212
|
+
canSave: backendAvailable && (!!ref || !!newSudoRef(st.backend)),
|
|
109
213
|
backendAvailable,
|
|
110
214
|
...(reason ? { reason } : {}),
|
|
111
215
|
configFile: product().configFile,
|
|
@@ -125,6 +229,22 @@ export async function sudoVaultStatus(): Promise<SudoVaultStatus> {
|
|
|
125
229
|
return out(true, await hasSudoInVault());
|
|
126
230
|
}
|
|
127
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Where a saved password would be kept, for Settings' "saved in <label>"
|
|
234
|
+
* (LK-0): read from the config alone, nothing spawned (a page view never
|
|
235
|
+
* wakes the vault).
|
|
236
|
+
*/
|
|
237
|
+
export function sudoVaultWhere(): { configured: boolean; label: string } {
|
|
238
|
+
try {
|
|
239
|
+
const st = secretsStatus(readSecretsConfig(), process.env, {
|
|
240
|
+
detect: false,
|
|
241
|
+
});
|
|
242
|
+
return { configured: !!sudoRef(), label: st.label };
|
|
243
|
+
} catch {
|
|
244
|
+
return { configured: false, label: "" };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
128
248
|
/** Name of the active backend, for UI copy ("Save to Doppler"). */
|
|
129
249
|
export function secretsBackendLabel(): string {
|
|
130
250
|
return secretsStatus().label;
|