@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/README.md +12 -11
  3. package/docs/distributions.md +438 -91
  4. package/docs/reference.md +8 -3
  5. package/package.json +1 -1
  6. package/src/shared/agents/catalog-runtime.ts +178 -0
  7. package/src/shared/agents/catalog.ts +343 -0
  8. package/src/shared/agents/den-claude.ts +14 -18
  9. package/src/shared/agents/runtime-search.ts +142 -15
  10. package/src/shared/agents/runtime.ts +18 -1
  11. package/src/shared/agents/runtimes.ts +23 -1
  12. package/src/shared/auth/sudo-vault.ts +131 -11
  13. package/src/shared/cli-command.ts +186 -0
  14. package/src/shared/confirm-code.ts +34 -0
  15. package/src/shared/den-config.ts +64 -3
  16. package/src/shared/dev-metrics/emit.ts +31 -1
  17. package/src/shared/edge/config.ts +39 -0
  18. package/src/shared/edge/render.ts +92 -18
  19. package/src/shared/edge/runtime.ts +57 -1
  20. package/src/shared/edge/status.ts +134 -0
  21. package/src/shared/edge/types.ts +11 -1
  22. package/src/shared/environment-manager/health.ts +82 -8
  23. package/src/shared/environment-manager/workspaces.ts +54 -0
  24. package/src/shared/github-app.ts +94 -0
  25. package/src/shared/github-device.ts +286 -0
  26. package/src/shared/health/not-checked.ts +42 -0
  27. package/src/shared/health/rollup.ts +4 -0
  28. package/src/shared/process/spawn-streaming.ts +8 -3
  29. package/src/shared/secrets/onepassword.ts +27 -4
  30. package/src/shared/sessions/client.ts +5 -1
  31. package/src/shared/sessions/host.ts +29 -1
  32. package/src/shared/sessions/protocol.ts +6 -1
  33. package/src/shared/sessions/types.ts +6 -0
  34. package/src/shared/stacks/status.ts +24 -0
  35. package/src/shared/strategies/registry.ts +5 -0
  36. package/src/shared/types/index.ts +6 -0
  37. package/src/shared/web-owner.ts +91 -0
  38. package/src/shared/web-session.ts +135 -1
  39. package/src/ui/cli/agent-ops.ts +210 -19
  40. package/src/ui/cli/agents-command.ts +50 -21
  41. package/src/ui/cli/edge-command.ts +16 -3
  42. package/src/ui/cli/flag-parser.ts +1 -1
  43. package/src/ui/cli/health-display.ts +4 -1
  44. package/src/ui/cli/web-command.ts +512 -33
  45. package/src/ui/mcp/scope.ts +23 -3
  46. package/src/ui/mcp/server.ts +31 -7
  47. package/src/ui/mcp/tools.ts +327 -20
  48. package/src/ui/mcp/views.ts +450 -0
  49. package/src/ui/server/agent-routes.ts +118 -28
  50. package/src/ui/server/device-flows.ts +274 -0
  51. package/src/ui/server/edge-exposure.ts +16 -4
  52. package/src/ui/server/github-routes.ts +321 -0
  53. package/src/ui/server/http-errors.ts +10 -0
  54. package/src/ui/server/index.ts +540 -33
  55. package/src/ui/server/login-waits.ts +203 -0
  56. package/src/ui/server/pair-requests.ts +237 -0
  57. package/src/ui/server/pair-routes.ts +196 -0
  58. package/src/ui/server/public/agent-start.js +46 -7
  59. package/src/ui/server/public/agents-page.js +232 -97
  60. package/src/ui/server/public/agents-section.js +3 -5
  61. package/src/ui/server/public/agents.html +12 -28
  62. package/src/ui/server/public/error-page.js +452 -40
  63. package/src/ui/server/public/error.html +59 -0
  64. package/src/ui/server/public/graph-model.js +2 -0
  65. package/src/ui/server/public/header.js +2 -3
  66. package/src/ui/server/public/health-rollup.js +20 -8
  67. package/src/ui/server/public/i18n/es.js +89 -21
  68. package/src/ui/server/public/i18n/ja.js +88 -21
  69. package/src/ui/server/public/index.html +17 -4
  70. package/src/ui/server/public/local-dev.html +44 -21
  71. package/src/ui/server/public/qr.js +317 -0
  72. package/src/ui/server/public/services.html +21 -5
  73. package/src/ui/server/public/settings-page.js +10 -2
  74. package/src/ui/server/public/settings-registry.js +617 -1
  75. package/src/ui/server/public/settings.html +90 -1
  76. package/src/ui/server/public/shell.js +151 -33
  77. package/src/ui/server/public/topbar.js +170 -206
  78. package/src/ui/server/public/workspace-picker.js +1 -1
  79. package/src/ui/server/readonly-routes.ts +29 -5
  80. package/src/ui/server/request-guard.ts +103 -36
  81. package/src/ui/server/route-classes.ts +116 -4
  82. package/src/ui/server/session-routes.ts +9 -1
  83. package/src/ui/server/sudo-store.ts +170 -6
  84. package/src/ui/server/ui-assets.ts +2 -0
  85. package/src/ui/server/workspace-routes.ts +10 -3
@@ -17,6 +17,7 @@ import {
17
17
  type ContainerHealthTarget,
18
18
  } from "../../shared/stacks/compose-health.js";
19
19
  import { healthCheckFields, type HealthCheckType } from "./health-row.js";
20
+ import { notCheckedReason } from "../../shared/health/not-checked.js";
20
21
  import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
21
22
  import * as http from "http";
22
23
  import * as net from "net";
@@ -162,6 +163,7 @@ import {
162
163
  saveSudoToVault,
163
164
  sudoRef,
164
165
  sudoVaultStatus,
166
+ sudoVaultWhere,
165
167
  SUDO_REF_NAME,
166
168
  } from "../../shared/auth/sudo-vault.js";
167
169
  import {
@@ -209,8 +211,13 @@ import {
209
211
  } from "../../shared/actor-token.js";
210
212
  import {
211
213
  checkLoginCode,
214
+ deviceName,
215
+ loginCodeNonce,
212
216
  ignoredConfigWarning,
213
217
  localhostOrigin,
218
+ loginLink,
219
+ LOGIN_CODE_TTL_MS,
220
+ mintLoginCode,
214
221
  readWebAuthConfig,
215
222
  safeNext,
216
223
  SESSION_COOKIE_MAX_AGE_S,
@@ -220,6 +227,32 @@ import {
220
227
  type WebAuthConfig,
221
228
  } from "../../shared/web-session.js";
222
229
  import { serveUiAsset } from "./ui-assets.js";
230
+ import {
231
+ expiringTokenWarning,
232
+ githubClientId,
233
+ githubEndpoints,
234
+ noClientIdMessage,
235
+ } from "../../shared/github-app.js";
236
+ import { githubDeviceClient } from "../../shared/github-device.js";
237
+ import { readWebOwner } from "../../shared/web-owner.js";
238
+ import { DeviceFlows } from "./device-flows.js";
239
+ import {
240
+ decideFlow,
241
+ handleGithubRoutes,
242
+ isGithubRoute,
243
+ } from "./github-routes.js";
244
+ import { PairRequests } from "./pair-requests.js";
245
+ import { LoginWaits } from "./login-waits.js";
246
+ import { handlePairRoutes, isPairRoute } from "./pair-routes.js";
247
+ import {
248
+ cliCommand,
249
+ cliPrefix,
250
+ isIpLiteral,
251
+ machineLabel,
252
+ parseMachineName,
253
+ sshCommand,
254
+ sshTarget,
255
+ } from "../../shared/cli-command.js";
223
256
  import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
224
257
  import { graphPageHtml } from "./graph-page.js";
225
258
  import { mergeShellPrefs, shellPrefs, storedShell } from "./shell-prefs.js";
@@ -275,6 +308,10 @@ interface HealthResult {
275
308
  pending?: boolean;
276
309
  status?: number;
277
310
  error?: string;
311
+ /** A 3xx answer's target (MCP-2.4): ok null, not down. */
312
+ redirect?: string;
313
+ /** With ok null and nothing checked: why not (MCP-2.4). */
314
+ why?: string;
278
315
  /** Where the app opens for this env (open-in-new-tab), or null. */
279
316
  openUrl?: string | null;
280
317
  /** Extra named links for this env. */
@@ -556,7 +593,7 @@ setHtmlFill((html) => {
556
593
  out = out.replace(
557
594
  '<div id="den-shell"',
558
595
  () =>
559
- `<div id="den-shell" data-edge-exposed="${escAttr(EDGE_EXPOSED_MESSAGE)}"`,
596
+ `<div id="den-shell" data-edge-exposed="${escAttr(edgeExposedMessage(WEB_AUTH.edgeUnattested))}"`,
560
597
  );
561
598
  // MD-2: every page says when the engine on disk is not the one running.
562
599
  const engine = engineCheck();
@@ -716,12 +753,14 @@ const CONFIG_FREE_PAGES = new Set(["/settings", "/settings.html", "/logs"]);
716
753
  import {
717
754
  setSudoPassword,
718
755
  clearSudoPassword,
756
+ clearSudoShims,
719
757
  closeLockForThisRequest,
720
758
  inRequestLockScope,
721
759
  getSudoEnv,
722
760
  prewarmSudo,
723
761
  hasSudoPassword,
724
762
  hasPasswordlessSudo,
763
+ watchSudo,
725
764
  } from "./sudo-store.js";
726
765
  import {
727
766
  guardStdio,
@@ -730,6 +769,9 @@ import {
730
769
  requestShutdown,
731
770
  } from "./shutdown-guard.js";
732
771
 
772
+ /** This server saved the admin password to the vault or loaded it from there. */
773
+ let sudoInVault = false;
774
+
733
775
  // ── HTTP helpers ─────────────────────────────────────────────────────────────
734
776
  import { sweepLogs } from "../../shared/process/log-rotation.js";
735
777
  import {
@@ -788,7 +830,7 @@ import {
788
830
  webRouteHosts,
789
831
  } from "../../shared/edge/render.js";
790
832
  import {
791
- EDGE_EXPOSED_MESSAGE,
833
+ edgeExposedMessage,
792
834
  edgeExposure,
793
835
  edgeNetworkSubnets,
794
836
  } from "./edge-exposure.js";
@@ -1077,13 +1119,154 @@ const GUARD: GuardContext = {
1077
1119
  peerIsLoopback: (req) => isLoopbackAddress(req.socket?.remoteAddress),
1078
1120
  // The edge hostname only from the edge's own peers (loopback, Docker).
1079
1121
  peerIsEdge: (req) => isEdgePeer(req.socket?.remoteAddress),
1080
- // 0.4.3: the edge from this machine counts as local (edgeClientIsLocal).
1122
+ // This machine's addresses and bridges, for edgeClient (SEC-2a).
1081
1123
  localNet: currentLocalNet,
1082
1124
  // A deliberate exposure (DEN_BIND_HOST beyond loopback, other than the
1083
1125
  // Linux edge's Docker bridge): the edge needs a sign-in too.
1084
1126
  exposed: !isLoopbackBind(BIND_HOST) && !isDockerBridgeAddress(BIND_HOST),
1127
+ // SEC-2a: an edge client Den can't place signs in (web.edge_unattested).
1128
+ edgeUnattested: WEB_AUTH.edgeUnattested,
1085
1129
  };
1086
1130
 
1131
+ // ── SEC-2a: Sign in with GitHub, the owner, pairing ──────────────────────────
1132
+ const GITHUB_CLIENT_ID = githubClientId(WEB_AUTH.githubClientId);
1133
+ const GITHUB = githubEndpoints();
1134
+ const DEVICE_FLOWS = new DeviceFlows({
1135
+ client: githubDeviceClient(GITHUB),
1136
+ clientId: () => GITHUB_CLIENT_ID,
1137
+ decide: decideFlow(getStateDir, (l) => serverLog(l, "lifecycle")),
1138
+ // A test's fake GitHub may answer interval 0; the real one never does.
1139
+ minIntervalMs: GITHUB.fake ? 50 : 1000,
1140
+ onExpiringToken: noteExpiringToken,
1141
+ });
1142
+
1143
+ /**
1144
+ * The GitHub App has "Expire user authorization tokens" on (a token answer
1145
+ * carried expires_in or a refresh_token). Sign-in still completes; Den
1146
+ * warns once, naming the fix, and Settings › Access (GET /api/web/owner)
1147
+ * says so as a boolean. The token itself is never logged or kept.
1148
+ */
1149
+ let GITHUB_TOKENS_EXPIRE = false;
1150
+ function noteExpiringToken(): void {
1151
+ if (GITHUB_TOKENS_EXPIRE) return;
1152
+ GITHUB_TOKENS_EXPIRE = true;
1153
+ // The in-app log, and stderr (web.log), as the edge's exposure warning.
1154
+ const line = ` ⚠ ${expiringTokenWarning()}`;
1155
+ pushLog("warn", line);
1156
+ if (!outputLost()) process.stderr.write(line + "\n");
1157
+ }
1158
+
1159
+ // ── SEC-2a: approve this computer with a code (pair-requests.ts) ──────────
1160
+ const PAIRS = new PairRequests();
1161
+
1162
+ /**
1163
+ * What every printed command starts with (cli-command.ts): `<cli>` when the
1164
+ * first one on this server's PATH is this distribution's, else its
1165
+ * absolute path. PATH doesn't change while the server runs: once.
1166
+ */
1167
+ let CLI_PREFIX: string | null = null;
1168
+ function printedCli(): string {
1169
+ CLI_PREFIX ??= cliPrefix(product().cli, getDistRoot(), getEngineRoot());
1170
+ return CLI_PREFIX;
1171
+ }
1172
+
1173
+ /**
1174
+ * The name people read for this machine: `web.machine_name` (user.yml,
1175
+ * read now, so a change in Settings shows at once), else the short
1176
+ * hostname. Never the browser's Host.
1177
+ */
1178
+ function machineName(): string {
1179
+ let set: string | undefined;
1180
+ try {
1181
+ set = readWebAuthConfig(getDistRoot(), product().configFile).machineName;
1182
+ } catch {
1183
+ set = WEB_AUTH.machineName;
1184
+ }
1185
+ return machineLabel(set, os.hostname());
1186
+ }
1187
+
1188
+ /** `web.machine_name` as user.yml holds it, or null (unset or unreadable). */
1189
+ function machineNameSetting(user: {
1190
+ web?: Record<string, unknown>;
1191
+ }): string | null {
1192
+ try {
1193
+ return parseMachineName(user.web?.machine_name);
1194
+ } catch {
1195
+ return null;
1196
+ }
1197
+ }
1198
+
1199
+ /**
1200
+ * A path as the request log writes it: a sign-in poll's id is its
1201
+ * credential (SEC-2a: the code request's, a GitHub flow's), so it shows
1202
+ * as `:id`.
1203
+ */
1204
+ function loggedPath(pathname: string): string {
1205
+ return pathname.replace(
1206
+ /^(\/api\/(?:signin\/code|github\/signin|web\/owner\/link))\/[^/]+$/,
1207
+ "$1/:id",
1208
+ );
1209
+ }
1210
+
1211
+ /** The ssh target for this request: the Host it used, minus the port. */
1212
+ function sshTargetOf(req: http.IncomingMessage): string {
1213
+ return sshTarget(req.headers.host, os.hostname());
1214
+ }
1215
+
1216
+ /**
1217
+ * A browser's live WebSockets (a session's terminal), by its session's
1218
+ * key: revoking a device closes them at once (SEC-2a).
1219
+ */
1220
+ const SESSION_SOCKETS = new Map<string, Set<net.Socket>>();
1221
+ function trackSessionSocket(key: string, socket: net.Socket): void {
1222
+ const set = SESSION_SOCKETS.get(key) ?? new Set<net.Socket>();
1223
+ set.add(socket);
1224
+ SESSION_SOCKETS.set(key, set);
1225
+ socket.once("close", () => {
1226
+ set.delete(socket);
1227
+ if (set.size === 0) SESSION_SOCKETS.delete(key);
1228
+ });
1229
+ }
1230
+ function closeSessionSockets(keys: Iterable<string>): void {
1231
+ for (const k of keys) {
1232
+ for (const sock of SESSION_SOCKETS.get(k) ?? []) sock.destroy();
1233
+ SESSION_SOCKETS.delete(k);
1234
+ }
1235
+ }
1236
+
1237
+ /** A new session for this request and its Set-Cookie line (GitHub or a link). */
1238
+ function startSession(
1239
+ req: http.IncomingMessage,
1240
+ via: "github" | "link" | "code",
1241
+ login?: string,
1242
+ ): string {
1243
+ const g = checkRequest(req, GUARD, product().cli, product().title);
1244
+ const named = g.ok ? g.via : null;
1245
+ const { id } = webSessions.create({
1246
+ userAgent: req.headers["user-agent"],
1247
+ host: req.headers.host,
1248
+ via,
1249
+ ...(g.ok && g.client ? { client: g.client } : {}),
1250
+ ...(login ? { login } : {}),
1251
+ });
1252
+ return sessionSetCookie(id, requestIsHttps(req, named));
1253
+ }
1254
+
1255
+ /** The edge hostnames that route here, and their URLs (Pair a device). */
1256
+ function denEdgeRoutes(): { host: string; url: string }[] {
1257
+ try {
1258
+ return webRouteHosts(readEdgeConfig(), new Set([PORT, readWebPortSafe()]));
1259
+ } catch {
1260
+ return [];
1261
+ }
1262
+ }
1263
+
1264
+ /** What the signed-out page says about Sign in with GitHub. */
1265
+ function githubSigninState(): "ready" | "no_client_id" | "no_owner" {
1266
+ if (!GITHUB_CLIENT_ID) return "no_client_id";
1267
+ return readWebOwner(getStateDir()) ? "ready" : "no_owner";
1268
+ }
1269
+
1087
1270
  /**
1088
1271
  * 0.4.3: why the edge is reachable beyond this machine (edge-exposure.ts),
1089
1272
  * once at start; null until known, or when Den can't tell.
@@ -1107,7 +1290,7 @@ function checkEdgeExposure(): void {
1107
1290
  EDGE_EXPOSED = why;
1108
1291
  // The in-app log, and stderr, which is web.log even when `web up`
1109
1292
  // starts this --silent (stdout is not written then).
1110
- const line = ` ⚠ ${EDGE_EXPOSED_MESSAGE} (${why})`;
1293
+ const line = ` ⚠ ${edgeExposedMessage(WEB_AUTH.edgeUnattested)} (${why})`;
1111
1294
  pushLog("warn", line);
1112
1295
  if (!outputLost()) process.stderr.write(line + "\n");
1113
1296
  })
@@ -1155,6 +1338,8 @@ function sendSignedOut(
1155
1338
  search: string;
1156
1339
  scope: "terminal" | "agents" | "den";
1157
1340
  expired?: boolean;
1341
+ /** SEC-2a: a waited-on link: ask for the code `web login` printed. */
1342
+ confirm?: boolean;
1158
1343
  requestId: string;
1159
1344
  message: string;
1160
1345
  },
@@ -1165,9 +1350,11 @@ function sendSignedOut(
1165
1350
  ? "edge"
1166
1351
  : "other";
1167
1352
  const origin = `${requestIsHttps(req, via === "edge" ? "edge" : null) ? "https" : "http"}://${host}`;
1168
- const cmd =
1169
- `${product().cli} web login` +
1170
- (toLocal || host === `localhost:${PORT}` ? "" : ` --origin ${origin}`);
1353
+ const bin = printedCli();
1354
+ const loginArgs =
1355
+ toLocal || host === `localhost:${PORT}` ? [] : ["--origin", origin];
1356
+ const cmd = cliCommand(bin, "web", "login", ...loginArgs);
1357
+ const ssh = sshTargetOf(req);
1171
1358
  res.setHeader("Cache-Control", "no-store");
1172
1359
  sendErrorPage(req, res, {
1173
1360
  status: 401,
@@ -1179,7 +1366,24 @@ function sendSignedOut(
1179
1366
  scope: opts.scope,
1180
1367
  name: product().title,
1181
1368
  cmd,
1369
+ // SEC-2a: which machine runs the commands, and how to reach it.
1370
+ machine: machineName(),
1371
+ ssh: {
1372
+ target: ssh,
1373
+ // Open Terminal (ssh://) only for a name, never an IP literal.
1374
+ name: !isIpLiteral(ssh),
1375
+ login: sshCommand(ssh, cmd),
1376
+ },
1377
+ // SEC-2a: Sign in with GitHub, and why it can't be offered yet.
1378
+ github: {
1379
+ state: githubSigninState(),
1380
+ owner: cliCommand(bin, "web", "owner", "link"),
1381
+ ...(GITHUB_CLIENT_ID
1382
+ ? {}
1383
+ : { message: noClientIdMessage(product().cli) }),
1384
+ },
1182
1385
  ...(opts.expired ? { expired: true } : {}),
1386
+ ...(opts.confirm ? { confirm: true } : {}),
1183
1387
  ...(toLocal && !opts.expired
1184
1388
  ? {
1185
1389
  localhost: `${localhostOrigin(PORT)}${opts.pathname}${opts.search}`,
@@ -1254,19 +1458,34 @@ function sendGuardRefusal(
1254
1458
  * 303 to `next` when it is a same-origin path, else `/`. Anything else gets
1255
1459
  * the signed-out page, which never echoes the code.
1256
1460
  */
1257
- function handleAuth(
1461
+ async function handleAuth(
1258
1462
  req: http.IncomingMessage,
1259
1463
  res: http.ServerResponse,
1260
1464
  url: URL,
1261
1465
  requestId: string,
1262
- via: "loopback" | "edge" | "lan" | null,
1263
- ): void {
1466
+ ): Promise<void> {
1264
1467
  const secret = webSecret.current();
1468
+ const c = url.searchParams.get("c") ?? "";
1469
+ const nonce = loginCodeNonce(c);
1470
+ // SEC-2a: a link `web login` waits on asks for its code first. Opened,
1471
+ // its code is checked as of the moment it was opened (then 5 minutes).
1472
+ const wait = nonce ? LOGIN_WAITS.forNonce(nonce) : null;
1265
1473
  const check = secret
1266
- ? checkLoginCode(url.searchParams.get("c") ?? "", secret)
1474
+ ? checkLoginCode(c, secret, wait?.openedAt ?? Date.now())
1267
1475
  : null;
1268
- if (!check?.ok || !webSessions.consumeCode(check.nonce, check.expiresAt)) {
1476
+ const post = req.method === "POST";
1477
+ const gone = () => {
1269
1478
  res.setHeader("Referrer-Policy", "no-referrer");
1479
+ if (post)
1480
+ return sendJson(
1481
+ res,
1482
+ {
1483
+ ok: false,
1484
+ code: "expired",
1485
+ error: `This link no longer works. On ${machineName()}, run web login again for a new one.`,
1486
+ },
1487
+ 410,
1488
+ );
1270
1489
  return sendSignedOut(req, res, {
1271
1490
  pathname: "/auth",
1272
1491
  search: "",
@@ -1275,20 +1494,90 @@ function handleAuth(
1275
1494
  requestId,
1276
1495
  message: `That sign-in link expired or was used. Run \`${product().cli} web login\` for a new one.`,
1277
1496
  });
1497
+ };
1498
+ if (!check?.ok) return gone();
1499
+ if (wait && nonce) {
1500
+ if (!post) {
1501
+ if (wait.state !== "waiting" && wait.state !== "opened") return gone();
1502
+ LOGIN_WAITS.open(nonce);
1503
+ res.setHeader("Referrer-Policy", "no-referrer");
1504
+ return sendSignedOut(req, res, {
1505
+ pathname: "/auth",
1506
+ search: "",
1507
+ scope: "den",
1508
+ confirm: true,
1509
+ requestId,
1510
+ message: "Enter the code `web login` printed.",
1511
+ });
1512
+ }
1513
+ let body: { code?: unknown } = {};
1514
+ try {
1515
+ body = (JSON.parse((await readBody(req)) || "{}") ?? {}) as typeof body;
1516
+ } catch {
1517
+ return sendJson(
1518
+ res,
1519
+ { ok: false, error: "the body must be JSON: { code }" },
1520
+ 400,
1521
+ );
1522
+ }
1523
+ const r = LOGIN_WAITS.confirm(nonce, String(body.code ?? ""));
1524
+ res.setHeader("Referrer-Policy", "no-referrer");
1525
+ res.setHeader("Cache-Control", "no-store");
1526
+ if (!r.ok) {
1527
+ if (r.error === "expired") return gone();
1528
+ return sendJson(
1529
+ res,
1530
+ r.error === "wrong"
1531
+ ? {
1532
+ ok: false,
1533
+ code: "wrong_code",
1534
+ left: r.left,
1535
+ error:
1536
+ "That code isn't right. Check what web login printed and try again.",
1537
+ }
1538
+ : r.error === "rate_limited"
1539
+ ? {
1540
+ ok: false,
1541
+ code: "rate_limited",
1542
+ error: "Too many wrong codes. Wait a minute and try again.",
1543
+ }
1544
+ : {
1545
+ ok: false,
1546
+ code: "refused",
1547
+ error: `Too many wrong codes: this link no longer works. On ${machineName()}, run web login again.`,
1548
+ },
1549
+ r.error === "wrong" ? 401 : r.error === "rate_limited" ? 429 : 410,
1550
+ );
1551
+ }
1552
+ if (!webSessions.consumeCode(check.nonce, check.expiresAt)) return gone();
1553
+ const g = checkRequest(req, GUARD, product().cli, product().title);
1554
+ LOGIN_WAITS.used(nonce, {
1555
+ device: deviceName(req.headers["user-agent"]),
1556
+ ...(g.ok && g.client ? { client: g.client } : {}),
1557
+ });
1558
+ res.setHeader("Set-Cookie", startSession(req, "link"));
1559
+ return sendJson(res, {
1560
+ ok: true,
1561
+ next: safeNext(url.searchParams.get("next")),
1562
+ });
1278
1563
  }
1279
- const { id } = webSessions.create({
1280
- userAgent: req.headers["user-agent"],
1281
- host: req.headers.host,
1282
- });
1564
+ // A link nobody waits on (`web login --no-wait`, or minted with no
1565
+ // server running): it signs in when opened, once, as before.
1566
+ if (post || !webSessions.consumeCode(check.nonce, check.expiresAt))
1567
+ return gone();
1283
1568
  res.writeHead(303, {
1284
1569
  Location: safeNext(url.searchParams.get("next")),
1285
- "Set-Cookie": sessionSetCookie(id, requestIsHttps(req, via)),
1570
+ "Set-Cookie": startSession(req, "link"),
1286
1571
  "Cache-Control": "no-store",
1287
1572
  "Referrer-Policy": "no-referrer",
1288
1573
  });
1289
1574
  res.end();
1290
1575
  }
1291
1576
 
1577
+ /** `web login`'s wait (login-waits.ts), by the CLI's id. */
1578
+ const LOGIN_WAIT_PATH = /^\/api\/web\/logins\/([A-Za-z0-9_-]{16,64})$/;
1579
+ const LOGIN_WAITS = new LoginWaits();
1580
+
1292
1581
  /**
1293
1582
  * A service's declared command `cmd` for `env`, own properties only
1294
1583
  * (SEC-1 §6b.2): `constructor` or `__proto__` is no command, never
@@ -1339,7 +1628,7 @@ async function handleRequest(
1339
1628
  res.on("finish", () => {
1340
1629
  const ms = dim(`${Date.now() - start}ms`);
1341
1630
  serverLog(
1342
- ` ${logTimestamp()} ${method} ${pathname} → ${logStatus(res.statusCode)} ${ms}` +
1631
+ ` ${logTimestamp()} ${method} ${loggedPath(pathname)} → ${logStatus(res.statusCode)} ${ms}` +
1343
1632
  (res.statusCode >= 400 ? dim(` req ${requestId}`) : ""),
1344
1633
  "request",
1345
1634
  );
@@ -1368,8 +1657,51 @@ async function handleRequest(
1368
1657
 
1369
1658
  // ── Sign-in (SEC-1b, §2) ───────────────────────────────────────────────────
1370
1659
  // Before config validation: signing in never depends on the config.
1371
- if (pathname === "/auth" && (req.method === "GET" || req.method === "HEAD"))
1372
- return handleAuth(req, res, url, requestId, guard.ok ? guard.via : null);
1660
+ if (
1661
+ pathname === "/auth" &&
1662
+ (req.method === "GET" || req.method === "HEAD" || req.method === "POST")
1663
+ )
1664
+ return handleAuth(req, res, url, requestId);
1665
+ // SEC-2a: `web login` registers its link's wait, then polls it
1666
+ // (shell-grade: the CLI's bearer).
1667
+ if (pathname === "/api/web/logins" && req.method === "POST") {
1668
+ let body: { nonce?: unknown; code?: unknown; expiresAt?: unknown } = {};
1669
+ try {
1670
+ body = (JSON.parse((await readBody(req)) || "{}") ?? {}) as typeof body;
1671
+ } catch {
1672
+ return send400(res, "the body must be JSON: { nonce, code, expiresAt }");
1673
+ }
1674
+ const exp = Number(body.expiresAt);
1675
+ const id =
1676
+ Number.isFinite(exp) &&
1677
+ exp > Date.now() &&
1678
+ exp - Date.now() <= LOGIN_CODE_TTL_MS + 5_000
1679
+ ? LOGIN_WAITS.register(
1680
+ String(body.nonce ?? ""),
1681
+ String(body.code ?? ""),
1682
+ exp,
1683
+ )
1684
+ : null;
1685
+ if (!id) return send400(res, "not a login link's nonce, code and expiry");
1686
+ res.setHeader("Cache-Control", "no-store");
1687
+ return sendJson(res, { ok: true, id });
1688
+ }
1689
+ {
1690
+ const w = LOGIN_WAIT_PATH.exec(pathname);
1691
+ if (w && (req.method === "GET" || req.method === "HEAD")) {
1692
+ res.setHeader("Cache-Control", "no-store");
1693
+ const v = LOGIN_WAITS.get(w[1]);
1694
+ if (!v) return sendJson(res, { ok: false, state: "expired" }, 404);
1695
+ return sendJson(res, {
1696
+ ok: true,
1697
+ state: v.state,
1698
+ ...(v.device ? { device: v.device } : {}),
1699
+ ...(v.client ? { client: v.client } : {}),
1700
+ ...(v.usedAt ? { usedAt: new Date(v.usedAt).toISOString() } : {}),
1701
+ expiresAt: new Date(v.expiresAt).toISOString(),
1702
+ });
1703
+ }
1704
+ }
1373
1705
  if (
1374
1706
  pathname === "/signin" &&
1375
1707
  (req.method === "GET" || req.method === "HEAD")
@@ -1399,6 +1731,65 @@ async function handleRequest(
1399
1731
  // is a WebSocket (the server's upgrade handler): a plain GET of it is 426.
1400
1732
  if (pathname.startsWith("/api/sessions/"))
1401
1733
  return sendSessionHttp(req, res, pathname);
1734
+ // SEC-2a: Sign in with GitHub, the owner's link, Pair a device. Before
1735
+ // config validation, like the sign-in above.
1736
+ if (
1737
+ isGithubRoute(pathname) &&
1738
+ (await handleGithubRoutes(req, res, pathname, {
1739
+ flows: DEVICE_FLOWS,
1740
+ clientId: () => GITHUB_CLIENT_ID,
1741
+ tokensExpire: () => GITHUB_TOKENS_EXPIRE,
1742
+ cli: product().cli,
1743
+ sendJson,
1744
+ readBody,
1745
+ signIn: startSession,
1746
+ edgeRoutes: denEdgeRoutes,
1747
+ mintLink: (origin) => {
1748
+ const secret = webSecret.current();
1749
+ if (!secret) return null;
1750
+ const now = Date.now();
1751
+ return {
1752
+ url: loginLink(origin, mintLoginCode(secret, now)),
1753
+ expiresAt: now + LOGIN_CODE_TTL_MS,
1754
+ };
1755
+ },
1756
+ log: (l) => serverLog(l, "lifecycle"),
1757
+ stateDir: getStateDir,
1758
+ }))
1759
+ )
1760
+ return;
1761
+
1762
+ // SEC-2a: approve this computer with a code. Before config validation.
1763
+ if (
1764
+ isPairRoute(pathname) &&
1765
+ (await handlePairRoutes(req, res, pathname, {
1766
+ pairs: PAIRS,
1767
+ sendJson,
1768
+ readBody,
1769
+ signIn: (r) => startSession(r, "code"),
1770
+ client: (r) => {
1771
+ const g = checkRequest(r, GUARD, product().cli, product().title);
1772
+ return g.ok ? g.client : undefined;
1773
+ },
1774
+ commands: (r, code) => {
1775
+ const approve = cliCommand(printedCli(), "web", "approve", code);
1776
+ return { approve, ssh: sshCommand(sshTargetOf(r), approve) };
1777
+ },
1778
+ audit: (r, verb, detail, refused) =>
1779
+ recordAction(
1780
+ auditActorOf(r),
1781
+ {
1782
+ verb,
1783
+ detail,
1784
+ ...(refused
1785
+ ? { outcome: "refused" as const, reason: refused }
1786
+ : {}),
1787
+ },
1788
+ principalOf(r),
1789
+ ),
1790
+ }))
1791
+ )
1792
+ return;
1402
1793
 
1403
1794
  // A mutating request may change what /api/services would list (exec and
1404
1795
  // sync stream over GET, for EventSource).
@@ -1843,6 +2234,8 @@ async function handleRequest(
1843
2234
  compose: ComposeHealthTarget | null;
1844
2235
  /** A `type: container` check: answered from the shared listing. */
1845
2236
  container: ContainerHealthTarget | null;
2237
+ /** The repo has no manifest (the row is kept, unchecked). */
2238
+ noManifest?: boolean;
1846
2239
  meta: Pick<HealthResult, "runs" | "target" | "focused" | "workspaces">;
1847
2240
  }> = [];
1848
2241
  const focusName = getFocusedWorkspaceName();
@@ -1885,6 +2278,7 @@ async function handleRequest(
1885
2278
  org: orgOf(resolved.repoName, catalog),
1886
2279
  compose: null,
1887
2280
  container: null,
2281
+ noManifest: true,
1888
2282
  meta: {
1889
2283
  runs: "none",
1890
2284
  target: null,
@@ -2012,6 +2406,7 @@ async function handleRequest(
2012
2406
  expect,
2013
2407
  compose,
2014
2408
  container,
2409
+ noManifest,
2015
2410
  }) => {
2016
2411
  if (checkType !== "none") {
2017
2412
  const started = Date.now();
@@ -2078,6 +2473,13 @@ async function handleRequest(
2078
2473
  isEntrypoint,
2079
2474
  ...meta,
2080
2475
  ok: null,
2476
+ why: notCheckedReason({
2477
+ env,
2478
+ runs: meta.runs,
2479
+ target: meta.target,
2480
+ manifest: !noManifest,
2481
+ conventional,
2482
+ }),
2081
2483
  };
2082
2484
  };
2083
2485
  // Stream each result the moment it lands. The checks run concurrently but
@@ -2244,9 +2646,10 @@ async function handleRequest(
2244
2646
  Connection: "keep-alive",
2245
2647
  "X-Accel-Buffering": "no",
2246
2648
  });
2247
- const emit = (obj: unknown): void => {
2649
+ // LK-0: the command's sudo without the password is a needs-sudo event.
2650
+ const emit = watchSudo((obj: unknown): void => {
2248
2651
  res.write(`data: ${JSON.stringify(obj)}\n\n`);
2249
- };
2652
+ }, `${repoName}/${svcName} ${cmdName}`);
2250
2653
  // Every fail here is before anything ran: a refusal or a lookup miss.
2251
2654
  // Recorded, so failed attempts show in the activity trail (F2).
2252
2655
  const fail = (msg: string): void => {
@@ -3362,22 +3765,41 @@ async function handleRequest(
3362
3765
  actor: g.principal.actor,
3363
3766
  signedIn: false,
3364
3767
  });
3365
- if (g?.session)
3768
+ // SEC-2a: who this browser is (the rail's identity chip), and, once
3769
+ // after a sign-in, that it just signed in (the shell's toast).
3770
+ if (g?.session) {
3771
+ const rec = webSessions.list().find((x) => x.key === g.session!.key);
3772
+ const github = rec?.via === "github";
3366
3773
  return sendJson(res, {
3367
3774
  csrf: g.session.csrf,
3368
3775
  actor: "person-browser",
3369
3776
  signedIn: true,
3777
+ identity: {
3778
+ kind: github ? "github" : "device",
3779
+ via: rec?.via ?? "link",
3780
+ ...(github && rec?.login ? { login: rec.login } : {}),
3781
+ },
3782
+ ...(webSessions.takeWelcome(g.session.key)
3783
+ ? {
3784
+ justSignedIn: true,
3785
+ product: product().title,
3786
+ machine: machineName(),
3787
+ }
3788
+ : {}),
3370
3789
  });
3790
+ }
3371
3791
  return sendJson(res, {
3372
3792
  csrf: g?.beyond ? null : GUARD.csrfToken,
3373
3793
  actor: null,
3374
3794
  signedIn: false,
3795
+ ...(g && !g.beyond ? { identity: { kind: "local" } } : {}),
3375
3796
  });
3376
3797
  }
3377
3798
  // Sign out: this browser's session ends and its cookie is cleared.
3378
3799
  if (pathname === "/api/session/logout" && req.method === "POST") {
3379
3800
  const g = guard.ok ? guard : null;
3380
3801
  const ended = g?.session ? webSessions.end(g.session.key) : false;
3802
+ if (g?.session) closeSessionSockets([g.session.key]);
3381
3803
  res.setHeader(
3382
3804
  "Set-Cookie",
3383
3805
  clearSessionCookie(requestIsHttps(req, g?.via ?? null)),
@@ -3394,6 +3816,11 @@ async function handleRequest(
3394
3816
  lastSeenAt: new Date(x.lastSeenAt).toISOString(),
3395
3817
  userAgent: x.userAgent,
3396
3818
  host: x.host,
3819
+ // SEC-2a: a linked device's name, how it signed in, its address.
3820
+ name: deviceName(x.userAgent),
3821
+ via: x.via ?? "link",
3822
+ ...(x.login ? { login: x.login } : {}),
3823
+ ...(x.client ? { client: x.client } : {}),
3397
3824
  ...(x.key === mine ? { current: true } : {}),
3398
3825
  })),
3399
3826
  });
@@ -3414,11 +3841,14 @@ async function handleRequest(
3414
3841
  if (body.id !== undefined && typeof body.id !== "string")
3415
3842
  return send400(res, "id must be a session id prefix");
3416
3843
  try {
3417
- const ended =
3844
+ const endedKeys =
3418
3845
  typeof body.id === "string"
3419
- ? [sessionIdPrefix(webSessions.endByPrefix(body.id).key)]
3420
- : webSessions.list().map((x) => sessionIdPrefix(x.key));
3846
+ ? [webSessions.endByPrefix(body.id).key]
3847
+ : webSessions.list().map((x) => x.key);
3421
3848
  if (typeof body.id !== "string") webSessions.endAll();
3849
+ // SEC-2a: a revoked device's open terminals close now, too.
3850
+ closeSessionSockets(endedKeys);
3851
+ const ended = endedKeys.map(sessionIdPrefix);
3422
3852
  const rotated = body.rotate === true;
3423
3853
  let agentTokens = 0;
3424
3854
  if (rotated) {
@@ -3864,10 +4294,15 @@ async function handleRequest(
3864
4294
  }
3865
4295
 
3866
4296
  // ── API: sudo password ────────────────────────────────────────────────────
4297
+ // LK-0: the admin password is for sudo, and nothing else: no route asks
4298
+ // for it as an access check. A run that is about to run sudo without it
4299
+ // streams a needs-sudo event (watchSudo); the page asks, then runs again.
3867
4300
  // POST /api/sudo-password body: { password: string, saveToVault?: boolean }
3868
- // GET /api/sudo-password/status → { set: boolean }
4301
+ // POST /api/sudo-password/forget → { ok, vaultCleared, vaultError? }
4302
+ // GET /api/sudo-password/status → { set, passwordless,
4303
+ // saved: "vault" | "session" | null, vault: { configured, label } }
3869
4304
  // GET /api/sudo-password/vault-status → { backend, label, configured,
3870
- // backendAvailable, reason?, hasStored, opAvailable (deprecated) }
4305
+ // canSave, backendAvailable, reason?, hasStored, opAvailable (deprecated) }
3871
4306
  // POST /api/sudo-password/load-from-vault → { ok, reason?, message? }
3872
4307
  if (pathname === "/api/sudo-password") {
3873
4308
  if (req.method !== "POST") {
@@ -3897,25 +4332,75 @@ async function handleRequest(
3897
4332
  setSudoPassword(password);
3898
4333
  let savedToVault = false;
3899
4334
  let vaultError: string | undefined;
4335
+ let refCreated = false;
3900
4336
  if (saveToVault) {
4337
+ // LK-4: with no secrets.refs.sudo, this creates the item and writes
4338
+ // the ref to user.yml. The error never carries the password.
3901
4339
  try {
3902
- await saveSudoToVault(password);
4340
+ refCreated = (await saveSudoToVault(password)).ref === "created";
3903
4341
  savedToVault = true;
4342
+ sudoInVault = true;
3904
4343
  } catch (err) {
3905
4344
  vaultError = (err as Error).message;
3906
4345
  }
3907
4346
  }
3908
- return sendJson(res, { ok: true, savedToVault, vaultError });
4347
+ return sendJson(res, {
4348
+ ok: true,
4349
+ savedToVault,
4350
+ ...(refCreated ? { refCreated } : {}),
4351
+ vaultError,
4352
+ });
3909
4353
  } catch (e) {
3910
4354
  return send400(res, `Invalid request: ${(e as Error).message}`);
3911
4355
  }
3912
4356
  }
3913
4357
 
3914
4358
  if (pathname === "/api/sudo-password/status") {
3915
- // passwordless: NOPASSWD sudoer (or warm cache) — no admin login needed.
4359
+ // passwordless: NOPASSWD sudoer (or warm cache) — sudo needs none.
4360
+ // saved (Settings, LK-0): "vault" once this server saved it there or
4361
+ // loaded it from there, "session" when it is held in memory only.
4362
+ const set = hasSudoPassword();
3916
4363
  return sendJson(res, {
3917
- set: hasSudoPassword(),
4364
+ set,
3918
4365
  passwordless: hasPasswordlessSudo(),
4366
+ saved: sudoInVault ? "vault" : set ? "session" : null,
4367
+ vault: sudoVaultWhere(),
4368
+ });
4369
+ }
4370
+
4371
+ // Forget (Settings, LK-0): the password this server holds goes, and the
4372
+ // vault's copy when this server knows there is one (written empty, which
4373
+ // every read takes as none). sudo's own cache is dropped too.
4374
+ if (pathname === "/api/sudo-password/forget") {
4375
+ if (req.method !== "POST") {
4376
+ res.writeHead(405);
4377
+ res.end();
4378
+ return;
4379
+ }
4380
+ clearSudoPassword();
4381
+ try {
4382
+ execSync("sudo -k", {
4383
+ stdio: ["ignore", "ignore", "ignore"],
4384
+ timeout: CLI_SPAWN_TIMEOUT_MS,
4385
+ });
4386
+ } catch {
4387
+ /* best-effort */
4388
+ }
4389
+ let vaultCleared = false;
4390
+ let vaultError: string | undefined;
4391
+ if (sudoInVault) {
4392
+ try {
4393
+ await saveSudoToVault("");
4394
+ vaultCleared = true;
4395
+ sudoInVault = false;
4396
+ } catch (err) {
4397
+ vaultError = (err as Error).message;
4398
+ }
4399
+ }
4400
+ return sendJson(res, {
4401
+ ok: true,
4402
+ vaultCleared,
4403
+ ...(vaultError ? { vaultError } : {}),
3919
4404
  });
3920
4405
  }
3921
4406
 
@@ -3960,6 +4445,7 @@ async function handleRequest(
3960
4445
  return sendJson(res, { ok: false, reason: "stale" });
3961
4446
  }
3962
4447
  setSudoPassword(password);
4448
+ sudoInVault = true;
3963
4449
  return sendJson(res, { ok: true });
3964
4450
  }
3965
4451
 
@@ -4025,6 +4511,10 @@ async function handleRequest(
4025
4511
  // The UI's language (R19), and the ones it has catalogs for.
4026
4512
  language: uiLanguage(user.language),
4027
4513
  ui_languages: UI_LANGUAGES,
4514
+ // SEC-2a: the name the signed-out page gives this machine, and
4515
+ // what it is when unset (the short hostname).
4516
+ machine_name: machineNameSetting(user),
4517
+ machine_name_default: machineLabel(null, os.hostname()),
4028
4518
  });
4029
4519
  }
4030
4520
  if (req.method === "POST") {
@@ -4037,8 +4527,23 @@ async function handleRequest(
4037
4527
  appearance?: unknown;
4038
4528
  shell?: unknown;
4039
4529
  language?: unknown;
4530
+ machine_name?: unknown;
4040
4531
  };
4041
4532
  const user = readUserConfig();
4533
+ // SEC-2a: web.machine_name (user.yml); null or "" clears it.
4534
+ if (body.machine_name !== undefined) {
4535
+ let name: string | null;
4536
+ try {
4537
+ name = parseMachineName(body.machine_name);
4538
+ } catch (e) {
4539
+ return send400(res, (e as Error).message);
4540
+ }
4541
+ const web = { ...(user.web ?? {}) };
4542
+ if (name) web.machine_name = name;
4543
+ else delete web.machine_name;
4544
+ if (Object.keys(web).length) user.web = web;
4545
+ else delete user.web;
4546
+ }
4042
4547
  // null clears (0.3.0-dev.32, a consumer's nit): a block or a key
4043
4548
  // set to null is removed from user.yml, and a block left empty
4044
4549
  // goes too, so setting a pref and clearing it again leaves the
@@ -4133,6 +4638,7 @@ async function handleRequest(
4133
4638
  appearance: appearancePrefs(user.appearance),
4134
4639
  shell: shellPrefs(user.shell),
4135
4640
  language: uiLanguage(user.language),
4641
+ machine_name: machineNameSetting(user),
4136
4642
  });
4137
4643
  } catch (e) {
4138
4644
  return send400(res, `Invalid request: ${(e as Error).message}`);
@@ -4487,6 +4993,7 @@ server.on(
4487
4993
  setPrincipal(req, guard.principal);
4488
4994
  if (!isSessionIoPath(pathname))
4489
4995
  return refuse(404, "No WebSocket route here", "not_found");
4996
+ if (guard.session) trackSessionSocket(guard.session.key, socket);
4490
4997
  handleSessionUpgrade(req, socket, head, pathname, requestId).catch(
4491
4998
  (err: unknown) => {
4492
4999
  serverLog(
@@ -4747,7 +5254,7 @@ async function stopDaemons(): Promise<void> {
4747
5254
  }
4748
5255
 
4749
5256
  async function handleShutdown(signal: string): Promise<void> {
4750
- clearSudoPassword();
5257
+ clearSudoShims();
4751
5258
  // The registry lives in the state dir, which is resolved from the
4752
5259
  // distribution root on every call: if that can no longer be found (den.yml
4753
5260
  // moved or removed while the server ran), still exit. Never product() here: