@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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-2a: the commands a page prints for a person to run, and the names it
|
|
3
|
+
* gives the machines involved (the signed-out page, SEC-2 spec "exact
|
|
4
|
+
* copy"). One helper builds every printed command, so none is ever a bare
|
|
5
|
+
* `<cli>` that the server's user can't run:
|
|
6
|
+
*
|
|
7
|
+
* - `<cli>` when the first `<cli>` on PATH is this distribution's own
|
|
8
|
+
* (its bin/<cli> shim, or Den's bin/den in Den's own checkout), else the
|
|
9
|
+
* shim's absolute path;
|
|
10
|
+
* - each argument shell-quoted only when it needs it;
|
|
11
|
+
* - an ssh one-liner quotes the remote command once more, for the shell
|
|
12
|
+
* on the far side.
|
|
13
|
+
*
|
|
14
|
+
* Two host values (Vision): the label people read (`web.machine_name`, else
|
|
15
|
+
* the short os.hostname()) and the ssh target (the Host the browser used,
|
|
16
|
+
* minus the port). The browser's Host is never the label: it is often a
|
|
17
|
+
* service name, not a machine.
|
|
18
|
+
*
|
|
19
|
+
* Pure but for the filesystem probes, which are injectable for tests.
|
|
20
|
+
*/
|
|
21
|
+
import * as fs from "fs";
|
|
22
|
+
import * as net from "net";
|
|
23
|
+
import * as path from "path";
|
|
24
|
+
|
|
25
|
+
export interface CliResolveDeps {
|
|
26
|
+
/** The PATH to search (the server's own: its user's). */
|
|
27
|
+
pathEnv?: string;
|
|
28
|
+
isExecutable?: (file: string) => boolean;
|
|
29
|
+
realpath?: (file: string) => string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const defaultExecutable = (file: string): boolean => {
|
|
33
|
+
try {
|
|
34
|
+
fs.accessSync(file, fs.constants.X_OK);
|
|
35
|
+
return fs.statSync(file).isFile();
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const defaultRealpath = (file: string): string | null => {
|
|
41
|
+
try {
|
|
42
|
+
return fs.realpathSync(file);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* This distribution's command: its bin/<cli> shim, or (Den's own checkout,
|
|
50
|
+
* or a distribution without a shim) the engine's bin/den. Null when
|
|
51
|
+
* neither exists.
|
|
52
|
+
*/
|
|
53
|
+
export function distributionBin(
|
|
54
|
+
cli: string,
|
|
55
|
+
distRoot: string,
|
|
56
|
+
engineRoot: string,
|
|
57
|
+
isExecutable: (file: string) => boolean = defaultExecutable,
|
|
58
|
+
): string | null {
|
|
59
|
+
for (const f of [
|
|
60
|
+
path.join(distRoot, "bin", cli),
|
|
61
|
+
path.join(engineRoot, "bin", "den"),
|
|
62
|
+
])
|
|
63
|
+
if (isExecutable(f)) return f;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The first `name` on `pathEnv` that is executable, or null. */
|
|
68
|
+
export function findOnPath(
|
|
69
|
+
name: string,
|
|
70
|
+
pathEnv: string,
|
|
71
|
+
isExecutable: (file: string) => boolean = defaultExecutable,
|
|
72
|
+
): string | null {
|
|
73
|
+
for (const dir of pathEnv.split(path.delimiter)) {
|
|
74
|
+
if (!dir || !path.isAbsolute(dir)) continue;
|
|
75
|
+
const f = path.join(dir, name);
|
|
76
|
+
if (isExecutable(f)) return f;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What a printed command starts with: `cli` when the first `cli` on PATH
|
|
83
|
+
* resolves to this distribution's bin, else that bin's absolute path. With
|
|
84
|
+
* no bin found at all, `cli` (there is nothing better to print).
|
|
85
|
+
*/
|
|
86
|
+
export function cliPrefix(
|
|
87
|
+
cli: string,
|
|
88
|
+
distRoot: string,
|
|
89
|
+
engineRoot: string,
|
|
90
|
+
deps: CliResolveDeps = {},
|
|
91
|
+
): string {
|
|
92
|
+
const isExec = deps.isExecutable ?? defaultExecutable;
|
|
93
|
+
const real = deps.realpath ?? defaultRealpath;
|
|
94
|
+
const bin = distributionBin(cli, distRoot, engineRoot, isExec);
|
|
95
|
+
if (!bin) return cli;
|
|
96
|
+
const hit = findOnPath(cli, deps.pathEnv ?? process.env.PATH ?? "", isExec);
|
|
97
|
+
if (hit) {
|
|
98
|
+
const a = real(hit);
|
|
99
|
+
const b = real(bin);
|
|
100
|
+
if (a !== null && a === b) return cli;
|
|
101
|
+
}
|
|
102
|
+
return bin;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `s` as one word for a POSIX shell: as it is when safe, else single-quoted. */
|
|
106
|
+
export function shellQuote(s: string): string {
|
|
107
|
+
if (s !== "" && /^[A-Za-z0-9_@%+=:,./-]+$/.test(s)) return s;
|
|
108
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A command line: `prefix` (cliPrefix) and its arguments, each quoted as needed. */
|
|
112
|
+
export function cliCommand(prefix: string, ...args: string[]): string {
|
|
113
|
+
return [prefix, ...args].map(shellQuote).join(" ");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `ssh <target> '<remote>'`: the remote command quoted once more, for the far shell. */
|
|
117
|
+
export function sshCommand(target: string, remote: string): string {
|
|
118
|
+
return `ssh ${shellQuote(target)} ${shellQuote(remote)}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A Host header's hostname: lowercase, no port, no IPv6 brackets. "" when none. */
|
|
122
|
+
export function hostOfHeader(host: string | undefined): string {
|
|
123
|
+
const h = String(host ?? "")
|
|
124
|
+
.trim()
|
|
125
|
+
.toLowerCase();
|
|
126
|
+
if (!h) return "";
|
|
127
|
+
const v6 = /^\[([^\]]+)\](?::\d+)?$/.exec(h);
|
|
128
|
+
if (v6) return v6[1];
|
|
129
|
+
return h.replace(/:\d+$/, "").replace(/\.$/, "");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Whether `host` is an IP address literal (v4 or v6), not a name. */
|
|
133
|
+
export function isIpLiteral(host: string): boolean {
|
|
134
|
+
return net.isIP(host.replace(/^\[|\]$/g, "")) !== 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The ssh target: the Host the browser used, minus the port; else `fallback`. */
|
|
138
|
+
export function sshTarget(
|
|
139
|
+
hostHeader: string | undefined,
|
|
140
|
+
fallback: string,
|
|
141
|
+
): string {
|
|
142
|
+
const h = hostOfHeader(hostHeader);
|
|
143
|
+
return /^[a-z0-9._:-]+$/.test(h) && !h.startsWith("-") ? h : fallback;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** `Mac-Studio.localdomain` → `Mac-Studio`; an IP literal stays whole. */
|
|
147
|
+
export function shortHostname(hostname: string): string {
|
|
148
|
+
const h = hostname.trim();
|
|
149
|
+
if (!h || isIpLiteral(h)) return h;
|
|
150
|
+
return h.split(".")[0] || h;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The longest `web.machine_name` accepted. */
|
|
154
|
+
export const MACHINE_NAME_MAX = 64;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `web.machine_name` as stored: a trimmed one-line label of printable
|
|
158
|
+
* characters, at most MACHINE_NAME_MAX long; null when unset or blank.
|
|
159
|
+
* Throws naming the key for anything else.
|
|
160
|
+
*/
|
|
161
|
+
export function parseMachineName(v: unknown): string | null {
|
|
162
|
+
if (v === undefined || v === null) return null;
|
|
163
|
+
if (typeof v !== "string")
|
|
164
|
+
throw new Error(
|
|
165
|
+
`web.machine_name must be a name (text), got ${JSON.stringify(v)}`,
|
|
166
|
+
);
|
|
167
|
+
const s = v.trim();
|
|
168
|
+
if (!s) return null;
|
|
169
|
+
if (
|
|
170
|
+
s.length > MACHINE_NAME_MAX ||
|
|
171
|
+
[...s].some((c) => c.charCodeAt(0) < 0x20 || c.charCodeAt(0) === 0x7f)
|
|
172
|
+
)
|
|
173
|
+
throw new Error(
|
|
174
|
+
`web.machine_name must be one line of at most ${MACHINE_NAME_MAX} characters`,
|
|
175
|
+
);
|
|
176
|
+
return s;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The label people read: `web.machine_name`, else the short hostname. */
|
|
180
|
+
export function machineLabel(
|
|
181
|
+
machineName: string | null | undefined,
|
|
182
|
+
hostname: string,
|
|
183
|
+
): string {
|
|
184
|
+
const m = (machineName ?? "").trim();
|
|
185
|
+
return m || shortHostname(hostname) || "this machine";
|
|
186
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-2a: the short codes people read and type, in Crockford's base32.
|
|
3
|
+
* Pure helpers, shared by the CLI (`web login` makes the confirmation
|
|
4
|
+
* code) and the server (login-waits.ts checks it; pair-requests.ts makes
|
|
5
|
+
* pairing codes), so the CLI imports no server module.
|
|
6
|
+
*/
|
|
7
|
+
import { randomInt } from "crypto";
|
|
8
|
+
|
|
9
|
+
/** Crockford's base32: no I, L, O or U. */
|
|
10
|
+
export const PAIR_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
11
|
+
|
|
12
|
+
/** A `web login` confirmation code's length, without its dash. */
|
|
13
|
+
export const CONFIRM_CODE_LEN = 8;
|
|
14
|
+
|
|
15
|
+
/** A fresh confirmation code: `ABCD-EFGH` (Crockford base32, 40 bits). */
|
|
16
|
+
export function newConfirmCode(
|
|
17
|
+
rand: (n: number) => number = randomInt,
|
|
18
|
+
): string {
|
|
19
|
+
let s = "";
|
|
20
|
+
for (let i = 0; i < CONFIRM_CODE_LEN; i++) s += PAIR_ALPHABET[rand(32)];
|
|
21
|
+
return `${s.slice(0, 4)}-${s.slice(4)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A typed confirmation code: any case, dashes and spaces ignored, O as 0, I and L as 1. */
|
|
25
|
+
export function normalizeConfirmCode(input: string): string | null {
|
|
26
|
+
const s = String(input ?? "")
|
|
27
|
+
.toUpperCase()
|
|
28
|
+
.replace(/[\s-]/g, "")
|
|
29
|
+
.replace(/O/g, "0")
|
|
30
|
+
.replace(/[IL]/g, "1");
|
|
31
|
+
if (s.length !== CONFIRM_CODE_LEN) return null;
|
|
32
|
+
for (const c of s) if (!PAIR_ALPHABET.includes(c)) return null;
|
|
33
|
+
return s;
|
|
34
|
+
}
|
package/src/shared/den-config.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { SecretsConfig } from "./secrets/index.js";
|
|
|
18
18
|
import type { OrgConfig } from "./org.js";
|
|
19
19
|
import { validateOrgSources, type WorkOrgsConfig } from "./org-sources.js";
|
|
20
20
|
import { ConfigError, isConfigError } from "./config-error.js";
|
|
21
|
+
import { validateCatalog, type CatalogEntry } from "./agents/catalog.js";
|
|
21
22
|
import type { TestAccountsConfig } from "./auth/test-accounts.js";
|
|
22
23
|
|
|
23
24
|
import { product } from "./product.js";
|
|
@@ -385,7 +386,18 @@ export interface UserConfig {
|
|
|
385
386
|
* install directories). Per machine, so user.yml only: the distribution's
|
|
386
387
|
* config never sets a path. See shared/agents/runtime-search.ts.
|
|
387
388
|
*/
|
|
388
|
-
agents?: {
|
|
389
|
+
agents?: {
|
|
390
|
+
runtimes?: Record<string, { bin?: unknown } | null>;
|
|
391
|
+
/** More runtime catalog entries (AG-0.5, agents/catalog.ts). */
|
|
392
|
+
runtime_catalog?: unknown;
|
|
393
|
+
};
|
|
394
|
+
/**
|
|
395
|
+
* The web server's per-machine keys (src/shared/web-session.ts
|
|
396
|
+
* readWebAuthConfig): allow_plain_http_remote, edge_unattested,
|
|
397
|
+
* github_client_id, and (SEC-2a) machine_name, the name the signed-out
|
|
398
|
+
* page gives this machine.
|
|
399
|
+
*/
|
|
400
|
+
web?: Record<string, unknown>;
|
|
389
401
|
}
|
|
390
402
|
|
|
391
403
|
const ALL_ENVIRONMENTS: Environment[] = [
|
|
@@ -436,6 +448,40 @@ export function configuredRuntimeBin(
|
|
|
436
448
|
}
|
|
437
449
|
}
|
|
438
450
|
|
|
451
|
+
/**
|
|
452
|
+
* user.yml's `agents.runtime_catalog`, checked (agents/catalog.ts), after
|
|
453
|
+
* the built-in entries and the distribution's (`taken`). Throws naming
|
|
454
|
+
* the key when malformed.
|
|
455
|
+
*/
|
|
456
|
+
export function configuredRuntimeCatalog(
|
|
457
|
+
taken: readonly string[],
|
|
458
|
+
read: () => UserConfig = readUserConfig,
|
|
459
|
+
): CatalogEntry[] {
|
|
460
|
+
return validateCatalog(
|
|
461
|
+
read().agents?.runtime_catalog,
|
|
462
|
+
`agents.runtime_catalog (${path.basename(USER_CONFIG_PATH)})`,
|
|
463
|
+
taken,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Set (a path) or clear (null) `agents.runtimes.<id>.bin` in user.yml,
|
|
469
|
+
* keeping everything else there. The caller checks the path first.
|
|
470
|
+
*/
|
|
471
|
+
export function setUserRuntimeBin(id: string, bin: string | null): void {
|
|
472
|
+
const cfg = readUserConfig();
|
|
473
|
+
const agents = { ...(cfg.agents ?? {}) };
|
|
474
|
+
const runtimes = { ...(agents.runtimes ?? {}) };
|
|
475
|
+
if (bin === null) delete runtimes[id];
|
|
476
|
+
else runtimes[id] = { ...(runtimes[id] ?? {}), bin };
|
|
477
|
+
if (Object.keys(runtimes).length) agents.runtimes = runtimes;
|
|
478
|
+
else delete agents.runtimes;
|
|
479
|
+
const next: UserConfig = { ...cfg };
|
|
480
|
+
if (Object.keys(agents).length) next.agents = agents;
|
|
481
|
+
else delete next.agents;
|
|
482
|
+
writeUserConfig(next);
|
|
483
|
+
}
|
|
484
|
+
|
|
439
485
|
/** Read the per-engineer user config (config/local/user.yml). Returns {} if absent. */
|
|
440
486
|
export function readUserConfig(): UserConfig {
|
|
441
487
|
try {
|
|
@@ -790,6 +836,11 @@ export interface AgentsConfig {
|
|
|
790
836
|
* persona replaces one of the same id.
|
|
791
837
|
*/
|
|
792
838
|
persona_packs?: string[];
|
|
839
|
+
/**
|
|
840
|
+
* More runtime catalog entries (AG-0.5, agents/catalog.ts): a vendor CLI
|
|
841
|
+
* Den doesn't ship, with the same fields and checks as a built-in one.
|
|
842
|
+
*/
|
|
843
|
+
runtime_catalog?: unknown[];
|
|
793
844
|
/**
|
|
794
845
|
* Any other key is an agent id: `agents.<id>.grants` widens that agent's
|
|
795
846
|
* MCP scope to other workspaces (AG spec §5b). Config only: an agent
|
|
@@ -801,7 +852,11 @@ export interface AgentsConfig {
|
|
|
801
852
|
export const DEFAULT_AGENTS_MAX_CONCURRENT = 4;
|
|
802
853
|
|
|
803
854
|
/** The keys of `agents:` that are not an agent id. */
|
|
804
|
-
export const AGENTS_RESERVED_KEYS = [
|
|
855
|
+
export const AGENTS_RESERVED_KEYS = [
|
|
856
|
+
"max_concurrent",
|
|
857
|
+
"persona_packs",
|
|
858
|
+
"runtime_catalog",
|
|
859
|
+
];
|
|
805
860
|
|
|
806
861
|
/** One agent's own block, `agents.<id>`. */
|
|
807
862
|
export interface AgentGrantsConfig {
|
|
@@ -816,6 +871,8 @@ export interface ResolvedAgentsConfig {
|
|
|
816
871
|
personaPacks: string[];
|
|
817
872
|
/** `agents.<id>` blocks, by agent id. */
|
|
818
873
|
agents: Record<string, AgentGrantsConfig>;
|
|
874
|
+
/** The distribution's runtime catalog entries (AG-0.5), checked. */
|
|
875
|
+
runtimeCatalog: CatalogEntry[];
|
|
819
876
|
}
|
|
820
877
|
|
|
821
878
|
/** An agent id or workspace name: what WORKSPACE_NAME_RE accepts. */
|
|
@@ -838,6 +895,10 @@ export function readAgentsConfig(
|
|
|
838
895
|
maxConcurrent: DEFAULT_AGENTS_MAX_CONCURRENT,
|
|
839
896
|
personaPacks: [],
|
|
840
897
|
agents: {},
|
|
898
|
+
runtimeCatalog: validateCatalog(
|
|
899
|
+
raw.runtime_catalog,
|
|
900
|
+
"agents.runtime_catalog",
|
|
901
|
+
),
|
|
841
902
|
};
|
|
842
903
|
const max = raw.max_concurrent;
|
|
843
904
|
if (max !== undefined) {
|
|
@@ -866,7 +927,7 @@ export function readAgentsConfig(
|
|
|
866
927
|
const where = `agents.${id}`;
|
|
867
928
|
if (!AGENT_KEY_RE.test(id) || !isMapping(block))
|
|
868
929
|
throw new Error(
|
|
869
|
-
`${where}: unknown key (agents: takes max_concurrent, persona_packs, ` +
|
|
930
|
+
`${where}: unknown key (agents: takes max_concurrent, persona_packs, runtime_catalog, ` +
|
|
870
931
|
`or an agent id mapping to { grants: [...] })`,
|
|
871
932
|
);
|
|
872
933
|
for (const k of Object.keys(block))
|
|
@@ -11,7 +11,13 @@
|
|
|
11
11
|
* dev-metrics.emit.test.ts against golden envelopes captured via
|
|
12
12
|
* DEV_METRICS_ENVELOPE_OUT):
|
|
13
13
|
* 1. ALWAYS append one compact JSON line to <repoRoot>/.dev-metrics/events.jsonl.
|
|
14
|
-
* 2. THEN
|
|
14
|
+
* 2. THEN, only when the engineer opted in, best-effort ship to Sentry
|
|
15
|
+
* (Tier-1 -> log item, Tier-2 -> event item).
|
|
16
|
+
*
|
|
17
|
+
* Local only by default (den#2): nothing leaves the machine unless
|
|
18
|
+
* config/local/user.yml says `metrics: { share: true }` (the boolean, nothing
|
|
19
|
+
* else). A DSN, in the env or a synced dev-metrics.config, is where to send
|
|
20
|
+
* once opted in; on its own it never sends.
|
|
15
21
|
*
|
|
16
22
|
* Guarantees: never throws, never blocks the caller (the Sentry ship is
|
|
17
23
|
* fire-and-forget, bounded to ~2s). CI=true / DEV_METRICS_DISABLED=1 suppress
|
|
@@ -27,6 +33,7 @@ import * as fs from "fs";
|
|
|
27
33
|
import * as path from "path";
|
|
28
34
|
import { execFileSync } from "child_process";
|
|
29
35
|
import { createHash, randomUUID } from "crypto";
|
|
36
|
+
import * as yaml from "yaml";
|
|
30
37
|
import { getDistRoot } from "../paths.js";
|
|
31
38
|
|
|
32
39
|
import { product } from "../product.js";
|
|
@@ -116,6 +123,28 @@ function suppressShip(): boolean {
|
|
|
116
123
|
return process.env.CI === "true" || process.env.DEV_METRICS_DISABLED === "1";
|
|
117
124
|
}
|
|
118
125
|
|
|
126
|
+
/**
|
|
127
|
+
* The opt-in (den#2): `metrics.share` is exactly `true` in the engineer's
|
|
128
|
+
* config/local/user.yml under the repo root. Read on each ship, so turning
|
|
129
|
+
* it off takes effect at once; any error reads as "not opted in".
|
|
130
|
+
*/
|
|
131
|
+
export function shareOptedIn(root: string = repoRoot()): boolean {
|
|
132
|
+
try {
|
|
133
|
+
const raw = fs.readFileSync(
|
|
134
|
+
path.join(root, "config", "local", "user.yml"),
|
|
135
|
+
"utf-8",
|
|
136
|
+
);
|
|
137
|
+
const doc = yaml.parse(raw) as unknown;
|
|
138
|
+
if (!doc || typeof doc !== "object") return false;
|
|
139
|
+
const m = (doc as { metrics?: unknown }).metrics;
|
|
140
|
+
return (
|
|
141
|
+
!!m && typeof m === "object" && (m as { share?: unknown }).share === true
|
|
142
|
+
);
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
119
148
|
function resolveDsn(root: string): string {
|
|
120
149
|
const env = process.env.DEV_METRICS_SENTRY_DSN;
|
|
121
150
|
if (env) return env;
|
|
@@ -217,6 +246,7 @@ function ship(
|
|
|
217
246
|
epoch: number,
|
|
218
247
|
root: string,
|
|
219
248
|
): void {
|
|
249
|
+
if (!shareOptedIn(root)) return;
|
|
220
250
|
const dsn = resolveDsn(root);
|
|
221
251
|
if (!dsn) return;
|
|
222
252
|
const parsed = parseDsn(dsn);
|
|
@@ -29,10 +29,13 @@
|
|
|
29
29
|
* mounts: { <host path>: <container path> } # read-only
|
|
30
30
|
* networks: [<external network>]
|
|
31
31
|
* args: [--accesslog=true]
|
|
32
|
+
* proxy_protocol: # a TCP-passthrough front proxy that
|
|
33
|
+
* trusted_ips: [172.18.0.5] # sends the PROXY protocol (SEC-2a)
|
|
32
34
|
* web:
|
|
33
35
|
* port: 7777 # the web UI; routed as `<brand name>`
|
|
34
36
|
*/
|
|
35
37
|
import * as fs from "fs";
|
|
38
|
+
import * as net from "net";
|
|
36
39
|
import * as os from "os";
|
|
37
40
|
import * as path from "path";
|
|
38
41
|
import * as yaml from "yaml";
|
|
@@ -69,6 +72,7 @@ interface RawEdge {
|
|
|
69
72
|
mounts?: Record<string, string>;
|
|
70
73
|
networks?: string[];
|
|
71
74
|
args?: string[];
|
|
75
|
+
proxy_protocol?: { trusted_ips?: unknown } | null;
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
function readYaml(file: string): Record<string, unknown> {
|
|
@@ -367,5 +371,40 @@ function computeEdgeConfig(root: string): EdgeConfig {
|
|
|
367
371
|
]),
|
|
368
372
|
],
|
|
369
373
|
args: [...list(team.args, "args"), ...list(user.args, "args")],
|
|
374
|
+
proxyProtocolTrustedIps: proxyProtocolIps(
|
|
375
|
+
user.proxy_protocol ?? team.proxy_protocol,
|
|
376
|
+
),
|
|
370
377
|
};
|
|
371
378
|
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* `edge.proxy_protocol.trusted_ips`: a list of IPs or CIDRs (SEC-2a), or
|
|
382
|
+
* none. Throws naming the key on anything else.
|
|
383
|
+
*/
|
|
384
|
+
export function proxyProtocolIps(v: RawEdge["proxy_protocol"]): string[] {
|
|
385
|
+
if (v === undefined || v === null) return [];
|
|
386
|
+
if (typeof v !== "object" || Array.isArray(v))
|
|
387
|
+
throw new Error(
|
|
388
|
+
"edge.proxy_protocol must be a map: { trusted_ips: [...] }",
|
|
389
|
+
);
|
|
390
|
+
for (const k of Object.keys(v))
|
|
391
|
+
if (k !== "trusted_ips")
|
|
392
|
+
throw new Error(
|
|
393
|
+
`edge.proxy_protocol: unknown key "${k}" (known: trusted_ips)`,
|
|
394
|
+
);
|
|
395
|
+
const ips = list(v.trusted_ips, "proxy_protocol.trusted_ips");
|
|
396
|
+
for (const ip of ips) {
|
|
397
|
+
const [addr, bits, more] = ip.split("/");
|
|
398
|
+
const fam = net.isIP(addr);
|
|
399
|
+
const max = fam === 6 ? 128 : 32;
|
|
400
|
+
if (
|
|
401
|
+
!fam ||
|
|
402
|
+
more !== undefined ||
|
|
403
|
+
(bits !== undefined && (!/^\d{1,3}$/.test(bits) || Number(bits) > max))
|
|
404
|
+
)
|
|
405
|
+
throw new Error(
|
|
406
|
+
`edge.proxy_protocol.trusted_ips: "${ip}" is not an IP address or CIDR`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return ips;
|
|
410
|
+
}
|
|
@@ -220,6 +220,97 @@ export const EDGE_ORG_LABEL = "den.edge.org";
|
|
|
220
220
|
* `den.edge.org` (0.3.0-dev.40): edgeUp and `edge down` touch an existing
|
|
221
221
|
* edge only when its root is theirs.
|
|
222
222
|
*/
|
|
223
|
+
/**
|
|
224
|
+
* The addresses the edge's entrypoints believe X-Forwarded-* headers from
|
|
225
|
+
* (SEC-2a, in place of `forwardedHeaders.insecure`): loopback and the
|
|
226
|
+
* Docker networks the edge and its own proxies run on (Docker's
|
|
227
|
+
* 172.16.0.0/12, Docker Desktop's VM network). From anyone else Traefik
|
|
228
|
+
* drops the client's headers and writes its own; either way the right-most
|
|
229
|
+
* X-Forwarded-For entry is the peer it saw, which is all Den believes.
|
|
230
|
+
*/
|
|
231
|
+
export const EDGE_FORWARDED_TRUSTED_IPS: readonly string[] = [
|
|
232
|
+
"127.0.0.1/32",
|
|
233
|
+
"::1/128",
|
|
234
|
+
"172.16.0.0/12",
|
|
235
|
+
"192.168.65.0/24",
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
/** An `args` entry that sets what Den owns: the entrypoints' header or PROXY trust. */
|
|
239
|
+
const OWNED_ARG =
|
|
240
|
+
/^--entrypoints\.[^.=]+\.(forwardedheaders\.(insecure|trustedips)|proxyprotocol\.)/i;
|
|
241
|
+
|
|
242
|
+
/** The `edge.args` entries Den drops (SEC-2a): forwarded-header or PROXY trust. */
|
|
243
|
+
export function droppedEdgeArgs(cfg: Pick<EdgeConfig, "args">): string[] {
|
|
244
|
+
return cfg.args.filter((a) => OWNED_ARG.test(a));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The edge entrypoints' trust (SEC-2a): forwarded headers, and the PROXY protocol when set. */
|
|
248
|
+
export function edgeTrustArgs(cfg: EdgeConfig): string[] {
|
|
249
|
+
const eps = ["web", ...(edgeHttps(cfg) ? ["websecure"] : [])];
|
|
250
|
+
const pp = cfg.proxyProtocolTrustedIps ?? [];
|
|
251
|
+
return eps.flatMap((ep) => [
|
|
252
|
+
`--entrypoints.${ep}.forwardedHeaders.trustedIPs=${EDGE_FORWARDED_TRUSTED_IPS.join(",")}`,
|
|
253
|
+
...(pp.length
|
|
254
|
+
? [`--entrypoints.${ep}.proxyProtocol.trustedIPs=${pp.join(",")}`]
|
|
255
|
+
: []),
|
|
256
|
+
]);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The edge container's command (its Traefik static config). */
|
|
260
|
+
export function edgeCommand(cfg: EdgeConfig): string[] {
|
|
261
|
+
const https = edgeHttps(cfg);
|
|
262
|
+
const proxy = dockerProxyName(cfg.org);
|
|
263
|
+
const labels = cfg.dockerLabels;
|
|
264
|
+
return [
|
|
265
|
+
"--entrypoints.web.address=:80",
|
|
266
|
+
...(https ? ["--entrypoints.websecure.address=:443"] : []),
|
|
267
|
+
...edgeTrustArgs(cfg),
|
|
268
|
+
"--providers.file.directory=/etc/edge/dynamic",
|
|
269
|
+
"--providers.file.watch=true",
|
|
270
|
+
...(labels
|
|
271
|
+
? [
|
|
272
|
+
"--providers.docker=true",
|
|
273
|
+
`--providers.docker.endpoint=tcp://${proxy}:2375`,
|
|
274
|
+
"--providers.docker.exposedbydefault=false",
|
|
275
|
+
`--providers.docker.network=${labels.network}`,
|
|
276
|
+
]
|
|
277
|
+
: []),
|
|
278
|
+
"--api.dashboard=true",
|
|
279
|
+
"--ping=true",
|
|
280
|
+
"--log.level=WARN",
|
|
281
|
+
...cfg.args.filter((a) => !OWNED_ARG.test(a)),
|
|
282
|
+
];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* What changed between a running edge's command and the one `edge up`
|
|
287
|
+
* renders now, as words for its one-line notice; [] when nothing did (or
|
|
288
|
+
* the running one isn't known).
|
|
289
|
+
*/
|
|
290
|
+
export function edgeCommandDrift(
|
|
291
|
+
running: readonly string[] | null,
|
|
292
|
+
rendered: readonly string[],
|
|
293
|
+
): string[] {
|
|
294
|
+
if (!running) return [];
|
|
295
|
+
const a = new Set(running);
|
|
296
|
+
const b = new Set(rendered);
|
|
297
|
+
const changed = [
|
|
298
|
+
...rendered.filter((x) => !a.has(x)),
|
|
299
|
+
...running.filter((x) => !b.has(x)),
|
|
300
|
+
];
|
|
301
|
+
if (!changed.length) return [];
|
|
302
|
+
const what = new Set<string>();
|
|
303
|
+
for (const c of changed)
|
|
304
|
+
what.add(
|
|
305
|
+
/forwardedheaders/i.test(c)
|
|
306
|
+
? "forwarded-header trust"
|
|
307
|
+
: /proxyprotocol/i.test(c)
|
|
308
|
+
? "PROXY protocol"
|
|
309
|
+
: "its command",
|
|
310
|
+
);
|
|
311
|
+
return [...what];
|
|
312
|
+
}
|
|
313
|
+
|
|
223
314
|
export function renderEdgeCompose(
|
|
224
315
|
cfg: EdgeConfig,
|
|
225
316
|
ownerRoot: string = cfg.stateDir,
|
|
@@ -239,24 +330,7 @@ export function renderEdgeCompose(
|
|
|
239
330
|
restart: "unless-stopped",
|
|
240
331
|
labels: { [EDGE_ROOT_LABEL]: ownerRoot, [EDGE_ORG_LABEL]: cfg.org },
|
|
241
332
|
...(labels ? { depends_on: ["docker-proxy"] } : {}),
|
|
242
|
-
command:
|
|
243
|
-
"--entrypoints.web.address=:80",
|
|
244
|
-
...(https ? ["--entrypoints.websecure.address=:443"] : []),
|
|
245
|
-
"--providers.file.directory=/etc/edge/dynamic",
|
|
246
|
-
"--providers.file.watch=true",
|
|
247
|
-
...(labels
|
|
248
|
-
? [
|
|
249
|
-
"--providers.docker=true",
|
|
250
|
-
`--providers.docker.endpoint=tcp://${proxy}:2375`,
|
|
251
|
-
"--providers.docker.exposedbydefault=false",
|
|
252
|
-
`--providers.docker.network=${labels.network}`,
|
|
253
|
-
]
|
|
254
|
-
: []),
|
|
255
|
-
"--api.dashboard=true",
|
|
256
|
-
"--ping=true",
|
|
257
|
-
"--log.level=WARN",
|
|
258
|
-
...cfg.args,
|
|
259
|
-
],
|
|
333
|
+
command: edgeCommand(cfg),
|
|
260
334
|
...(cfg.ports
|
|
261
335
|
? {
|
|
262
336
|
ports: [
|
|
@@ -7,7 +7,10 @@ import * as net from "net";
|
|
|
7
7
|
import * as path from "path";
|
|
8
8
|
import { must, sh } from "./exec.js";
|
|
9
9
|
import {
|
|
10
|
+
droppedEdgeArgs,
|
|
10
11
|
EDGE_ROOT_LABEL,
|
|
12
|
+
edgeCommand,
|
|
13
|
+
edgeCommandDrift,
|
|
11
14
|
edgeNames,
|
|
12
15
|
renderEdgeCompose,
|
|
13
16
|
renderEdgeDynamic,
|
|
@@ -226,9 +229,58 @@ export function assertEdgeOwnedHere(
|
|
|
226
229
|
);
|
|
227
230
|
}
|
|
228
231
|
|
|
232
|
+
/** A container's command (`docker inspect .Config.Cmd`), or null. */
|
|
233
|
+
export function runningEdgeCommand(name: string): string[] | null {
|
|
234
|
+
const r = sh("docker", [
|
|
235
|
+
"inspect",
|
|
236
|
+
"--type",
|
|
237
|
+
"container",
|
|
238
|
+
"-f",
|
|
239
|
+
"{{json .Config.Cmd}}",
|
|
240
|
+
name,
|
|
241
|
+
]);
|
|
242
|
+
if (r.code !== 0) return null;
|
|
243
|
+
try {
|
|
244
|
+
const v: unknown = JSON.parse(r.out);
|
|
245
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string")
|
|
246
|
+
? (v as string[])
|
|
247
|
+
: null;
|
|
248
|
+
} catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* What `edge up` says before it writes compose.yml (SEC-2a): the `edge.args`
|
|
255
|
+
* it drops (Den owns the entrypoints' trust), and, when the running edge's
|
|
256
|
+
* command differs from the new one, that it recreates the edge once and
|
|
257
|
+
* why. Pure: `running` is the running container's command, or null.
|
|
258
|
+
*/
|
|
259
|
+
export function edgeUpNotices(
|
|
260
|
+
cfg: EdgeConfig,
|
|
261
|
+
running: readonly string[] | null,
|
|
262
|
+
): string[] {
|
|
263
|
+
const out: string[] = [];
|
|
264
|
+
const dropped = droppedEdgeArgs(cfg);
|
|
265
|
+
if (dropped.length)
|
|
266
|
+
out.push(
|
|
267
|
+
`edge.args: ignoring ${dropped.join(", ")}: Den sets the entrypoints' ` +
|
|
268
|
+
"forwarded-header trust itself (its own networks and loopback), and " +
|
|
269
|
+
"PROXY protocol trust from edge.proxy_protocol.trusted_ips. Remove it " +
|
|
270
|
+
"from the config to silence this.",
|
|
271
|
+
);
|
|
272
|
+
const drift = edgeCommandDrift(running, edgeCommand(cfg));
|
|
273
|
+
if (drift.length)
|
|
274
|
+
out.push(
|
|
275
|
+
`${edgeNames(cfg.org).container}'s settings changed (${drift.join(", ")}): ` +
|
|
276
|
+
"recreating it once. Routes drop only while it restarts.",
|
|
277
|
+
);
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
229
281
|
export async function edgeUp(
|
|
230
282
|
cfg: EdgeConfig,
|
|
231
|
-
opts: { takeOver?: boolean } = {},
|
|
283
|
+
opts: { takeOver?: boolean; log?: (line: string) => void } = {},
|
|
232
284
|
) {
|
|
233
285
|
// Before anything is written or started: an edge of this name that
|
|
234
286
|
// another state dir owns is never replaced.
|
|
@@ -261,6 +313,10 @@ export async function edgeUp(
|
|
|
261
313
|
const included = syncIncluded(cfg);
|
|
262
314
|
write(path.join(cfg.stateDir, "dynamic", GENERATED), renderEdgeDynamic(cfg));
|
|
263
315
|
const compose = path.join(cfg.stateDir, "compose.yml");
|
|
316
|
+
// SEC-2a: say what changed before compose recreates the edge for it.
|
|
317
|
+
const log = opts.log ?? ((l: string) => console.log(l));
|
|
318
|
+
for (const line of edgeUpNotices(cfg, runningEdgeCommand(n.container)))
|
|
319
|
+
log(line);
|
|
264
320
|
write(compose, renderEdgeCompose(cfg, realOrSelf(cfg.stateDir)));
|
|
265
321
|
must("docker", ["compose", "-f", compose, "up", "-d", "--remove-orphans"]);
|
|
266
322
|
return { certs, included };
|