@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,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as fs from "fs";
|
|
7
7
|
import * as path from "path";
|
|
8
|
+
import * as tls from "tls";
|
|
8
9
|
import { certNames, edgeDomains, edgeNames, routeUrl } from "./render.js";
|
|
9
10
|
import {
|
|
10
11
|
includedRouters,
|
|
@@ -28,7 +29,18 @@ export interface EdgeStatus {
|
|
|
28
29
|
routes: { name: string; url: string; upstream: string }[];
|
|
29
30
|
/** Names on the issued cert, or the planned ones when none is issued yet. */
|
|
30
31
|
certNames: string[];
|
|
32
|
+
/**
|
|
33
|
+
* Whether Den minted the edge's cert itself (mkcert: its names stamp is
|
|
34
|
+
* there). False under `tls: files` (the distribution supplies the cert)
|
|
35
|
+
* and `tls: none`, whether or not HTTPS serves: see `https`.
|
|
36
|
+
*/
|
|
37
|
+
certIssuedByDen: boolean;
|
|
38
|
+
/** @deprecated certIssuedByDen (MCP-2.5); kept for one release. */
|
|
31
39
|
certIssued: boolean;
|
|
40
|
+
/** MCP-2.5: the TLS strategy in words, and whether HTTPS is serving. */
|
|
41
|
+
https: EdgeHttps;
|
|
42
|
+
/** Fields kept for one release under an old name, and what replaces them. */
|
|
43
|
+
deprecated: { certIssued: string };
|
|
32
44
|
/** Whether the CA is trusted on this machine (null: can't tell, or none). */
|
|
33
45
|
caTrusted: boolean | null;
|
|
34
46
|
/**
|
|
@@ -59,6 +71,28 @@ export interface EdgeStatus {
|
|
|
59
71
|
stackErrors: IncludedRouters["errors"];
|
|
60
72
|
}
|
|
61
73
|
|
|
74
|
+
export interface EdgeHttps {
|
|
75
|
+
strategy: EdgeConfig["tls"]["strategy"];
|
|
76
|
+
/**
|
|
77
|
+
* Who supplies the cert: "den" (mkcert, minted here), "distribution"
|
|
78
|
+
* (`tls: files`, or the legacy `issue_cert: false`), or "none" (http only).
|
|
79
|
+
*/
|
|
80
|
+
certs: "den" | "distribution" | "none";
|
|
81
|
+
/**
|
|
82
|
+
* Whether the edge answers a TLS handshake on its https port: true, false,
|
|
83
|
+
* or null when it was not probed (`edge status` from the CLI, or a proxy
|
|
84
|
+
* in front owns the port).
|
|
85
|
+
*/
|
|
86
|
+
serving: boolean | null;
|
|
87
|
+
/** How `serving` was decided, in words. */
|
|
88
|
+
detail: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const CERT_ISSUED_DEPRECATION =
|
|
92
|
+
"certIssued is renamed certIssuedByDen (0.4.4) and goes in the release " +
|
|
93
|
+
"after: it says whether Den minted the cert itself, so it is false under " +
|
|
94
|
+
"tls strategy files while HTTPS serves. Read https.serving for that.";
|
|
95
|
+
|
|
62
96
|
export interface EdgeStatusDeps {
|
|
63
97
|
containerState: (name: string) => string;
|
|
64
98
|
caTrusted: (cfg: EdgeConfig) => boolean | null;
|
|
@@ -113,7 +147,23 @@ export function edgeStatus(
|
|
|
113
147
|
domains: edgeDomains(cfg),
|
|
114
148
|
routes: edgeRoutes(cfg),
|
|
115
149
|
certNames: issued ?? (cfg.tls.strategy === "none" ? [] : certNames(cfg)),
|
|
150
|
+
certIssuedByDen: issued !== null,
|
|
116
151
|
certIssued: issued !== null,
|
|
152
|
+
https: {
|
|
153
|
+
strategy: cfg.tls.strategy,
|
|
154
|
+
certs:
|
|
155
|
+
cfg.tls.strategy === "mkcert"
|
|
156
|
+
? "den"
|
|
157
|
+
: cfg.tls.strategy === "files"
|
|
158
|
+
? "distribution"
|
|
159
|
+
: "none",
|
|
160
|
+
serving: cfg.tls.strategy === "none" ? false : null,
|
|
161
|
+
detail:
|
|
162
|
+
cfg.tls.strategy === "none"
|
|
163
|
+
? "tls: none: the edge serves http only"
|
|
164
|
+
: "not probed",
|
|
165
|
+
},
|
|
166
|
+
deprecated: { certIssued: CERT_ISSUED_DEPRECATION },
|
|
117
167
|
caTrusted: cfg.tls.strategy === "none" ? null : deps.caTrusted(cfg),
|
|
118
168
|
tls: {
|
|
119
169
|
strategy: cfg.tls.strategy,
|
|
@@ -132,3 +182,87 @@ export function edgeStatus(
|
|
|
132
182
|
stackErrors: stk.errors,
|
|
133
183
|
};
|
|
134
184
|
}
|
|
185
|
+
|
|
186
|
+
/** One TLS handshake: whether something on host:port speaks TLS. */
|
|
187
|
+
export type TlsHandshake = (o: {
|
|
188
|
+
host: string;
|
|
189
|
+
port: number;
|
|
190
|
+
servername: string;
|
|
191
|
+
timeoutMs: number;
|
|
192
|
+
}) => Promise<{ ok: boolean; error?: string }>;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Connect, complete a TLS handshake, close. Trust is not checked here (that
|
|
196
|
+
* is `caTrusted`): only whether HTTPS is being served at all.
|
|
197
|
+
*/
|
|
198
|
+
export const tlsHandshake: TlsHandshake = ({
|
|
199
|
+
host,
|
|
200
|
+
port,
|
|
201
|
+
servername,
|
|
202
|
+
timeoutMs,
|
|
203
|
+
}) =>
|
|
204
|
+
new Promise((resolve) => {
|
|
205
|
+
let settled = false;
|
|
206
|
+
const done = (v: { ok: boolean; error?: string }) => {
|
|
207
|
+
if (settled) return;
|
|
208
|
+
settled = true;
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
socket.destroy();
|
|
211
|
+
resolve(v);
|
|
212
|
+
};
|
|
213
|
+
const socket = tls.connect({
|
|
214
|
+
host,
|
|
215
|
+
port,
|
|
216
|
+
servername,
|
|
217
|
+
rejectUnauthorized: false,
|
|
218
|
+
});
|
|
219
|
+
const timer = setTimeout(
|
|
220
|
+
() => done({ ok: false, error: `no TLS answer in ${timeoutMs}ms` }),
|
|
221
|
+
timeoutMs,
|
|
222
|
+
);
|
|
223
|
+
socket.once("secureConnect", () => done({ ok: true }));
|
|
224
|
+
socket.once("error", (e: NodeJS.ErrnoException) =>
|
|
225
|
+
done({ ok: false, error: e.code ?? e.message }),
|
|
226
|
+
);
|
|
227
|
+
socket.once("close", () =>
|
|
228
|
+
done({ ok: false, error: "closed before a TLS handshake" }),
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* `st` with `https.serving` probed (MCP-2.5): a TLS handshake on this
|
|
234
|
+
* machine's published https port, named as one of the edge's hosts. Not
|
|
235
|
+
* probed when a proxy in front owns the port (no published ports), and
|
|
236
|
+
* false without a handshake when the edge container is not running.
|
|
237
|
+
*/
|
|
238
|
+
export async function withHttpsServing(
|
|
239
|
+
st: EdgeStatus,
|
|
240
|
+
cfg: EdgeConfig,
|
|
241
|
+
handshake: TlsHandshake = tlsHandshake,
|
|
242
|
+
): Promise<EdgeStatus> {
|
|
243
|
+
if (cfg.tls.strategy === "none") return st;
|
|
244
|
+
const set = (serving: boolean | null, detail: string): EdgeStatus => ({
|
|
245
|
+
...st,
|
|
246
|
+
https: { ...st.https, serving, detail },
|
|
247
|
+
});
|
|
248
|
+
if (!st.ports)
|
|
249
|
+
return set(
|
|
250
|
+
null,
|
|
251
|
+
"not probed: a proxy in front of the edge owns its ports (edge.publish: false)",
|
|
252
|
+
);
|
|
253
|
+
if (!st.up) return set(false, `the edge container is ${st.state}`);
|
|
254
|
+
const port = st.ports.https;
|
|
255
|
+
const servername = `${Object.keys(cfg.routes)[0] ?? "edge"}.${cfg.domain}`;
|
|
256
|
+
const r = await handshake({
|
|
257
|
+
host: "127.0.0.1",
|
|
258
|
+
port,
|
|
259
|
+
servername,
|
|
260
|
+
timeoutMs: 1500,
|
|
261
|
+
});
|
|
262
|
+
return r.ok
|
|
263
|
+
? set(true, `TLS handshake answered on 127.0.0.1:${port} (${servername})`)
|
|
264
|
+
: set(
|
|
265
|
+
false,
|
|
266
|
+
`no TLS on 127.0.0.1:${port} (${servername}): ${r.error ?? "failed"}`,
|
|
267
|
+
);
|
|
268
|
+
}
|
package/src/shared/edge/types.ts
CHANGED
|
@@ -73,6 +73,16 @@ export interface EdgeConfig {
|
|
|
73
73
|
mounts: Record<string, string>;
|
|
74
74
|
/** Extra external Docker networks the edge joins. */
|
|
75
75
|
networks: string[];
|
|
76
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* Extra Traefik static-config arguments. Den owns the entrypoints'
|
|
78
|
+
* forwarded-header and PROXY-protocol trust (SEC-2a): an argument that
|
|
79
|
+
* sets either is dropped, with a warning at `edge up`.
|
|
80
|
+
*/
|
|
77
81
|
args: string[];
|
|
82
|
+
/**
|
|
83
|
+
* `edge.proxy_protocol.trusted_ips` (SEC-2a): the front proxies (IPs or
|
|
84
|
+
* CIDRs) whose PROXY protocol header the entrypoints believe, so a TCP
|
|
85
|
+
* passthrough in front can pass on the real client. Empty: none.
|
|
86
|
+
*/
|
|
87
|
+
proxyProtocolTrustedIps?: string[];
|
|
78
88
|
}
|
|
@@ -63,6 +63,14 @@ export interface PingResult {
|
|
|
63
63
|
pending?: boolean;
|
|
64
64
|
status?: number;
|
|
65
65
|
error?: string;
|
|
66
|
+
/**
|
|
67
|
+
* MCP-2.4: the check answered with a redirect (3xx, `status`): where it
|
|
68
|
+
* points, as a path for the same origin, else origin + path (the query
|
|
69
|
+
* and fragment are dropped: they can carry tokens). A redirect is not
|
|
70
|
+
* down and not up (ok null): the service answered, and the check was
|
|
71
|
+
* sent elsewhere.
|
|
72
|
+
*/
|
|
73
|
+
redirect?: string;
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
export interface ServiceHealthResult {
|
|
@@ -255,6 +263,41 @@ function statusOk(status: number, body404?: string): boolean {
|
|
|
255
263
|
return status < 300 || (status >= 400 && status < 500);
|
|
256
264
|
}
|
|
257
265
|
|
|
266
|
+
/** A 3xx: the check was sent elsewhere (redirect: "manual"). */
|
|
267
|
+
export function isRedirect(status: number | undefined): boolean {
|
|
268
|
+
return status !== undefined && status >= 300 && status < 400;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Where a redirect points, for a health row: a path when it stays on the
|
|
273
|
+
* checked URL's origin, else origin + path. Never the query or fragment
|
|
274
|
+
* (a login redirect's `?next=` or `state=` can carry a token).
|
|
275
|
+
*/
|
|
276
|
+
export function redirectTarget(
|
|
277
|
+
location: string | null | undefined,
|
|
278
|
+
from: string,
|
|
279
|
+
): string {
|
|
280
|
+
if (!location) return "(no Location header)";
|
|
281
|
+
try {
|
|
282
|
+
const to = new URL(location, from);
|
|
283
|
+
const base = new URL(from);
|
|
284
|
+
return to.origin === base.origin
|
|
285
|
+
? to.pathname
|
|
286
|
+
: `${to.origin}${to.pathname}`;
|
|
287
|
+
} catch {
|
|
288
|
+
return "(unreadable Location header)";
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The PingResult for a 3xx: not down, not up, and where it points. */
|
|
293
|
+
function redirectResult(
|
|
294
|
+
status: number,
|
|
295
|
+
location: string | null | undefined,
|
|
296
|
+
from: string,
|
|
297
|
+
): PingResult {
|
|
298
|
+
return { ok: null, status, redirect: redirectTarget(location, from) };
|
|
299
|
+
}
|
|
300
|
+
|
|
258
301
|
/** The PingResult for `status`, naming an edge 404 as "no route". */
|
|
259
302
|
function statusResult(status: number, body404?: string): PingResult {
|
|
260
303
|
const ok = statusOk(status, body404);
|
|
@@ -309,6 +352,12 @@ async function pingHttps(parsed: URL, timeoutMs: number): Promise<PingResult> {
|
|
|
309
352
|
},
|
|
310
353
|
(res) => {
|
|
311
354
|
const status = res.statusCode ?? 0;
|
|
355
|
+
if (isRedirect(status)) {
|
|
356
|
+
res.resume(); // discard the body
|
|
357
|
+
const loc = res.headers.location;
|
|
358
|
+
done(redirectResult(status, loc, parsed.toString()));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
312
361
|
if (status !== 404) {
|
|
313
362
|
res.resume(); // discard the body
|
|
314
363
|
done({ ok: statusOk(status), status });
|
|
@@ -374,14 +423,21 @@ async function pingOnce(
|
|
|
374
423
|
});
|
|
375
424
|
clearTimeout(timer);
|
|
376
425
|
// 2xx = healthy. 4xx = healthy (service is up; auth/route 4xx still proves it's responding).
|
|
377
|
-
// 3xx =
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
426
|
+
// 3xx = a redirect (MCP-2.4): ok null, with its status and target. With
|
|
427
|
+
// redirect:"manual" a redirect means "go verify somewhere else", so it
|
|
428
|
+
// is never green: that guards against hostname-to-AWS-ALB resolution
|
|
429
|
+
// (e.g. VPN / /etc/hosts) returning 301 on port 80 for services that
|
|
430
|
+
// actually run on a different port (the old `< 500` check showed those
|
|
431
|
+
// green). It is not down either: something answered, and a login
|
|
432
|
+
// redirect (302 → /login) is how a healthy app answers a bare GET.
|
|
381
433
|
// 5xx = unhealthy (service errored).
|
|
382
434
|
// A 404 may be the edge's own "no route" (see EDGE_NO_ROUTE_BODY): read
|
|
383
435
|
// a little of it. Otherwise discard the body: only the status matters,
|
|
384
436
|
// and unconsumed bodies pin sockets in the long-lived web server.
|
|
437
|
+
if (isRedirect(res.status)) {
|
|
438
|
+
void res.body?.cancel().catch(() => {});
|
|
439
|
+
return redirectResult(res.status, res.headers?.get("location"), url);
|
|
440
|
+
}
|
|
385
441
|
if (res.status === 404) {
|
|
386
442
|
let body = "";
|
|
387
443
|
try {
|
|
@@ -421,12 +477,17 @@ export async function pingHttp(
|
|
|
421
477
|
// never a localhost fallback (it is not on this machine).
|
|
422
478
|
if (expect !== undefined) {
|
|
423
479
|
const r = await pingOnce(url, timeoutMs);
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
480
|
+
if (r.status === undefined) return r;
|
|
481
|
+
if (expectMatches(r.status, expect)) return { ok: true, status: r.status };
|
|
482
|
+
// A redirect the manifest does not list as up is still a redirect, not
|
|
483
|
+
// down (MCP-2.4): a production app answering 302 → /login is serving.
|
|
484
|
+
if (r.redirect !== undefined) return r;
|
|
485
|
+
return { ok: false, status: r.status };
|
|
427
486
|
}
|
|
428
487
|
const result = await pingOnce(url, timeoutMs);
|
|
429
|
-
|
|
488
|
+
// A redirect answered: no localhost fallback (that is for a name that
|
|
489
|
+
// could not be reached at all).
|
|
490
|
+
if (result.ok === false) {
|
|
430
491
|
// Fall back to localhost for Docker-style hostnames regardless of how the
|
|
431
492
|
// first ping failed. Timeouts on the primary are legitimate candidates for
|
|
432
493
|
// retry: on macOS, VPN/`/etc/hosts` can resolve a Docker hostname to an
|
|
@@ -689,8 +750,17 @@ export function containerTarget(
|
|
|
689
750
|
: { project: h.project, service: h.service };
|
|
690
751
|
}
|
|
691
752
|
|
|
753
|
+
/** A health row whose check answered with a redirect (see checkService). */
|
|
754
|
+
export function isRedirectDetail(detail: string | undefined): boolean {
|
|
755
|
+
return /^redirect \d{3} → /.test(detail ?? "");
|
|
756
|
+
}
|
|
757
|
+
|
|
692
758
|
/** A ping result as a ServiceHealthResult health state. */
|
|
693
759
|
export function healthState(r: PingResult): ServiceHealthResult["health"] {
|
|
760
|
+
// A redirect is a verdict of its own (MCP-2.4): not down, and not a check
|
|
761
|
+
// still waiting to answer. "unknown", like an unchecked service, so a
|
|
762
|
+
// caller waiting for health proceeds instead of waiting it out.
|
|
763
|
+
if (r.ok === null && r.redirect !== undefined) return "unknown";
|
|
694
764
|
return r.ok === true ? "ok" : r.ok === false ? "error" : "pending";
|
|
695
765
|
}
|
|
696
766
|
|
|
@@ -814,6 +884,9 @@ export async function checkServiceHealth(
|
|
|
814
884
|
...placed,
|
|
815
885
|
health: healthState(result),
|
|
816
886
|
healthDetail:
|
|
887
|
+
(result.redirect !== undefined
|
|
888
|
+
? `redirect ${result.status} → ${result.redirect}`
|
|
889
|
+
: undefined) ??
|
|
817
890
|
result.error ??
|
|
818
891
|
(!result.ok && result.status !== undefined
|
|
819
892
|
? `http ${result.status}`
|
|
@@ -899,6 +972,7 @@ export function rollupRepoHealth(
|
|
|
899
972
|
service: r.subsystem,
|
|
900
973
|
ok: r.health === "ok" ? true : r.health === "error" ? false : null,
|
|
901
974
|
isEntrypoint: !!entrypoint && r.subsystem === entrypoint,
|
|
975
|
+
redirect: isRedirectDetail(r.healthDetail),
|
|
902
976
|
})),
|
|
903
977
|
);
|
|
904
978
|
const lead =
|
|
@@ -877,3 +877,57 @@ export function generateVSCodeWorkspace(
|
|
|
877
877
|
);
|
|
878
878
|
return outPath;
|
|
879
879
|
}
|
|
880
|
+
|
|
881
|
+
/** The key of workspace `ws`'s runtime path, as a refusal names it. */
|
|
882
|
+
export const workspaceBinKey = (ws: string, id: string) =>
|
|
883
|
+
`workspaces.${ws}.agents.runtimes.${id}.bin`;
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* `<ws>.agents.runtimes.<id>.bin` in config/local/workspaces.yml (AG-0.5),
|
|
887
|
+
* as written (checked where it is used); undefined when unset or when the
|
|
888
|
+
* file can't be read. A block that is no mapping is `{ why }`.
|
|
889
|
+
*/
|
|
890
|
+
export function workspaceRuntimeBin(
|
|
891
|
+
ws: string,
|
|
892
|
+
id: string,
|
|
893
|
+
read: () => WorkspacesFile = readWorkspacesFile,
|
|
894
|
+
): unknown {
|
|
895
|
+
try {
|
|
896
|
+
const rts = read().workspaces[ws]?.agents?.runtimes;
|
|
897
|
+
if (!rts || typeof rts !== "object") return undefined;
|
|
898
|
+
const block = (rts as Record<string, unknown>)[id];
|
|
899
|
+
if (block === undefined || block === null) return undefined;
|
|
900
|
+
if (typeof block !== "object" || Array.isArray(block))
|
|
901
|
+
return {
|
|
902
|
+
why: `${workspaceBinKey(ws, id).replace(/\.bin$/, "")} must be a mapping ({ bin: <absolute path> })`,
|
|
903
|
+
};
|
|
904
|
+
return (block as { bin?: unknown }).bin;
|
|
905
|
+
} catch {
|
|
906
|
+
return undefined;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Set (a path) or clear (null) workspace `ws`'s
|
|
912
|
+
* `agents.runtimes.<id>.bin`. The caller checks the path first.
|
|
913
|
+
*/
|
|
914
|
+
export function setWorkspaceRuntimeBin(
|
|
915
|
+
ws: string,
|
|
916
|
+
id: string,
|
|
917
|
+
bin: string | null,
|
|
918
|
+
): void {
|
|
919
|
+
const file = readWorkspacesFile();
|
|
920
|
+
const cfg = file.workspaces[ws];
|
|
921
|
+
if (!cfg) throw new Error(`workspace "${ws}" does not exist`);
|
|
922
|
+
const agents = { ...(cfg.agents ?? {}) };
|
|
923
|
+
const runtimes = { ...(agents.runtimes ?? {}) };
|
|
924
|
+
if (bin === null) delete runtimes[id];
|
|
925
|
+
else runtimes[id] = { ...(runtimes[id] ?? {}), bin };
|
|
926
|
+
if (Object.keys(runtimes).length) agents.runtimes = runtimes;
|
|
927
|
+
else delete agents.runtimes;
|
|
928
|
+
const next = { ...cfg };
|
|
929
|
+
if (Object.keys(agents).length) next.agents = agents;
|
|
930
|
+
else delete next.agents;
|
|
931
|
+
file.workspaces[ws] = next;
|
|
932
|
+
writeWorkspacesFile(file);
|
|
933
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-2a: the MO "Den" GitHub App, as Den uses it (SEC-2 spec, "The MO Den
|
|
3
|
+
* GitHub App"). Den uses only its public client id, through the device flow,
|
|
4
|
+
* acting as the person: no client secret and no private key, ever. With the
|
|
5
|
+
* app's "Expire user authorization tokens" off, no refresh (which would need
|
|
6
|
+
* the secret) is ever needed, so Den never asks for `offline_access`.
|
|
7
|
+
*
|
|
8
|
+
* `web.github_client_id` (config/den.yml, or config/local/user.yml) replaces
|
|
9
|
+
* the default below; it is the one place the MO app's id lives.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The "Monster Orange Den" GitHub App's client id (owner Monster-Orange,
|
|
14
|
+
* slug monster-orange-den). Public by design: it ships in Den. A
|
|
15
|
+
* distribution sets its own app with `web.github_client_id`; an empty
|
|
16
|
+
* value there turns Sign in with GitHub off.
|
|
17
|
+
*/
|
|
18
|
+
export const MO_DEN_GITHUB_CLIENT_ID = "Iv23liKL12gnN6WvRV5b";
|
|
19
|
+
|
|
20
|
+
/** What a person reads when the app issues expiring tokens (never the token). */
|
|
21
|
+
export function expiringTokenWarning(): string {
|
|
22
|
+
return (
|
|
23
|
+
"The GitHub App issues expiring user tokens (its answer carried expires_in " +
|
|
24
|
+
"or a refresh_token). Sign-in still works: Den uses the token once and never " +
|
|
25
|
+
"keeps it. To fix it, switch off \u201cExpire user authorization tokens\u201d in " +
|
|
26
|
+
"the app's settings (GitHub \u203a Settings \u203a Developer settings \u203a GitHub Apps)."
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Where a person enters the device code. */
|
|
31
|
+
export const GITHUB_DEVICE_PAGE = "https://github.com/login/device";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Tests only: a fake GitHub on loopback (`http://localhost:<port>`), for the
|
|
35
|
+
* device-flow endpoints and `GET /user` alike. Ignored unless it is plain
|
|
36
|
+
* http on localhost or 127.0.0.1, so it can never point Den at another host.
|
|
37
|
+
*/
|
|
38
|
+
export const GITHUB_TEST_BASE_ENV = "DEN_TEST_GITHUB_URL";
|
|
39
|
+
|
|
40
|
+
export interface GithubEndpoints {
|
|
41
|
+
deviceCode: string;
|
|
42
|
+
accessToken: string;
|
|
43
|
+
user: string;
|
|
44
|
+
/** A test's fake GitHub is in use (GITHUB_TEST_BASE_ENV). */
|
|
45
|
+
fake: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A loopback http base URL, without a trailing slash, or null. */
|
|
49
|
+
function loopbackBase(raw: string | undefined): string | null {
|
|
50
|
+
if (!raw) return null;
|
|
51
|
+
try {
|
|
52
|
+
const u = new URL(raw);
|
|
53
|
+
if (u.protocol !== "http:") return null;
|
|
54
|
+
if (u.hostname !== "localhost" && u.hostname !== "127.0.0.1") return null;
|
|
55
|
+
return u.origin;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** GitHub's device-flow and user endpoints (or a test's fake GitHub). */
|
|
62
|
+
export function githubEndpoints(
|
|
63
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
64
|
+
): GithubEndpoints {
|
|
65
|
+
const fake = loopbackBase(env[GITHUB_TEST_BASE_ENV]);
|
|
66
|
+
if (fake)
|
|
67
|
+
return {
|
|
68
|
+
deviceCode: `${fake}/login/device/code`,
|
|
69
|
+
accessToken: `${fake}/login/oauth/access_token`,
|
|
70
|
+
user: `${fake}/user`,
|
|
71
|
+
fake: true,
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
deviceCode: "https://github.com/login/device/code",
|
|
75
|
+
accessToken: "https://github.com/login/oauth/access_token",
|
|
76
|
+
user: "https://api.github.com/user",
|
|
77
|
+
fake: false,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The client id in use: `web.github_client_id`, else the MO app's. */
|
|
82
|
+
export function githubClientId(configured: string | undefined): string {
|
|
83
|
+
return (configured ?? MO_DEN_GITHUB_CLIENT_ID).trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What a GitHub button says while no client id is configured. */
|
|
87
|
+
export function noClientIdMessage(cli: string): string {
|
|
88
|
+
return (
|
|
89
|
+
"Sign in with GitHub needs a GitHub App client id, and none is configured. " +
|
|
90
|
+
"Set web.github_client_id in config/local/user.yml (or the distribution's " +
|
|
91
|
+
`config), then restart the server (\`${cli} web restart\`). The app's setup ` +
|
|
92
|
+
"is in docs/distributions.md, “Signing in with GitHub”."
|
|
93
|
+
);
|
|
94
|
+
}
|