@monster-orange/den 0.4.4 → 0.4.6
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 +243 -0
- package/README.md +24 -9
- package/docs/distributions.md +320 -26
- package/docs/reference.md +8 -3
- package/package.json +4 -3
- package/skills/install-den/SKILL.md +114 -0
- package/src/shared/agent-setup.ts +9 -0
- package/src/shared/auth/sudo-vault.ts +115 -11
- package/src/shared/cli-command.ts +186 -0
- package/src/shared/confirm-code.ts +34 -0
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +7 -0
- package/src/shared/dev-metrics/emit.ts +59 -2
- 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/types.ts +11 -1
- package/src/shared/github-app.ts +94 -0
- package/src/shared/github-device.ts +286 -0
- package/src/shared/port-pick.ts +60 -0
- package/src/shared/secrets/onepassword.ts +27 -4
- package/src/shared/web-owner.ts +91 -0
- package/src/shared/web-session.ts +135 -1
- package/src/ui/cli/create-main.ts +1 -1
- package/src/ui/cli/create.ts +263 -30
- package/src/ui/cli/doctor-command.ts +67 -4
- package/src/ui/cli/doctor-onboard.ts +291 -0
- package/src/ui/cli/flag-parser.ts +5 -1
- package/src/ui/cli/index.ts +246 -4
- package/src/ui/cli/json-out.ts +49 -0
- package/src/ui/cli/mcp-connect.ts +179 -15
- package/src/ui/cli/repo-command.ts +490 -0
- package/src/ui/cli/repo-detect.ts +192 -0
- package/src/ui/cli/web-command.ts +513 -33
- package/src/ui/mcp/connect.ts +134 -0
- 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 +463 -27
- 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/error-page.js +452 -40
- package/src/ui/server/public/error.html +59 -0
- package/src/ui/server/public/i18n/es.js +74 -8
- package/src/ui/server/public/i18n/ja.js +74 -8
- package/src/ui/server/public/local-dev.html +16 -4
- package/src/ui/server/public/qr.js +317 -0
- package/src/ui/server/public/settings-page.js +10 -3
- package/src/ui/server/public/settings-registry.js +392 -1
- package/src/ui/server/public/shell.js +123 -10
- package/src/ui/server/public/topbar.js +7 -3
- package/src/ui/server/public/workspace-picker.js +1 -1
- package/src/ui/server/request-guard.ts +103 -36
- package/src/ui/server/route-classes.ts +102 -2
- package/src/ui/server/ui-assets.ts +2 -0
|
@@ -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,
|
|
@@ -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/constants.ts
CHANGED
|
@@ -112,3 +112,10 @@ export const SKIP_TEST_DIRS = [
|
|
|
112
112
|
|
|
113
113
|
/** Default den web server port. */
|
|
114
114
|
export const DEFAULT_PORT = 7777;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Ports a new distribution's web server never takes when `create` picks
|
|
118
|
+
* one because DEFAULT_PORT is in use: the edge's and other well-known Den
|
|
119
|
+
* instances' ports.
|
|
120
|
+
*/
|
|
121
|
+
export const RESERVED_DEN_PORTS: readonly number[] = [7778, 7791, 7799];
|
package/src/shared/den-config.ts
CHANGED
|
@@ -391,6 +391,13 @@ export interface UserConfig {
|
|
|
391
391
|
/** More runtime catalog entries (AG-0.5, agents/catalog.ts). */
|
|
392
392
|
runtime_catalog?: unknown;
|
|
393
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>;
|
|
394
401
|
}
|
|
395
402
|
|
|
396
403
|
const ALL_ENVIRONMENTS: Environment[] = [
|
|
@@ -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
|
|
@@ -25,8 +31,9 @@
|
|
|
25
31
|
|
|
26
32
|
import * as fs from "fs";
|
|
27
33
|
import * as path from "path";
|
|
28
|
-
import { execFileSync } from "child_process";
|
|
34
|
+
import { execFileSync, spawn } 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;
|
|
@@ -212,11 +241,38 @@ export function buildEnvelope(
|
|
|
212
241
|
return `{}\n${itemHeader}\n${payload}\n`;
|
|
213
242
|
}
|
|
214
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Send from a short-lived detached process instead of this one. The CLI
|
|
246
|
+
* records its run in the `exit` handler, where an async fetch never runs;
|
|
247
|
+
* a detached sender outlives the CLI and gives up after SHIP_TIMEOUT_MS.
|
|
248
|
+
* The long-lived server keeps the in-process fetch.
|
|
249
|
+
*/
|
|
250
|
+
let shipDetached = false;
|
|
251
|
+
export function shipFromDetachedSender(): void {
|
|
252
|
+
shipDetached = true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const SENDER = `fetch(process.env.DM_URL,{method:"POST",headers:{"Content-Type":"application/x-sentry-envelope","X-Sentry-Auth":process.env.DM_AUTH},body:process.env.DM_BODY,signal:AbortSignal.timeout(${SHIP_TIMEOUT_MS})}).catch(()=>{}).finally(()=>process.exit(0))`;
|
|
256
|
+
|
|
257
|
+
function sendDetached(url: string, auth: string, body: string): void {
|
|
258
|
+
try {
|
|
259
|
+
spawn(process.execPath, ["-e", SENDER], {
|
|
260
|
+
detached: true,
|
|
261
|
+
stdio: "ignore",
|
|
262
|
+
// Only what the sender needs: none of the caller's environment.
|
|
263
|
+
env: { DM_URL: url, DM_AUTH: auth, DM_BODY: body },
|
|
264
|
+
}).unref();
|
|
265
|
+
} catch {
|
|
266
|
+
/* fail-open: the JSONL line is already buffered */
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
215
270
|
function ship(
|
|
216
271
|
record: Record<string, string>,
|
|
217
272
|
epoch: number,
|
|
218
273
|
root: string,
|
|
219
274
|
): void {
|
|
275
|
+
if (!shareOptedIn(root)) return;
|
|
220
276
|
const dsn = resolveDsn(root);
|
|
221
277
|
if (!dsn) return;
|
|
222
278
|
const parsed = parseDsn(dsn);
|
|
@@ -236,6 +292,7 @@ function ship(
|
|
|
236
292
|
|
|
237
293
|
const url = `${parsed.scheme}://${parsed.host}/api/${parsed.projectId}/envelope/`;
|
|
238
294
|
const auth = `Sentry sentry_key=${parsed.key}, sentry_version=7, sentry_client=dev-metrics-${product().cli}/1.0`;
|
|
295
|
+
if (shipDetached) return sendDetached(url, auth, envelope);
|
|
239
296
|
// Fire-and-forget: never awaited, so the caller never blocks. Bounded by a 2s
|
|
240
297
|
// abort so a hung ingest can't leak a pending request in the long-lived server.
|
|
241
298
|
void fetch(url, {
|
|
@@ -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
|
+
}
|