@monster-orange/den 0.4.0 → 0.4.2

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 (58) hide show
  1. package/CHANGELOG.md +457 -0
  2. package/README.md +88 -158
  3. package/docs/distributions.md +216 -16
  4. package/docs/reference.md +177 -0
  5. package/package.json +3 -1
  6. package/src/shared/actor-token.ts +275 -0
  7. package/src/shared/agent-actor.ts +12 -2
  8. package/src/shared/agents/den-claude.ts +99 -1
  9. package/src/shared/agents/runtime.ts +7 -0
  10. package/src/shared/agents/runtimes.ts +9 -2
  11. package/src/shared/den-config.ts +52 -3
  12. package/src/shared/environment-manager/scope.ts +22 -0
  13. package/src/shared/environment-manager/service-runner.ts +94 -2
  14. package/src/shared/profile.ts +15 -4
  15. package/src/shared/sessions/client.ts +16 -2
  16. package/src/shared/sessions/den-pty.ts +10 -2
  17. package/src/shared/sessions/env.ts +13 -3
  18. package/src/shared/sessions/host.ts +56 -8
  19. package/src/shared/sessions/types.ts +8 -0
  20. package/src/shared/web-secret.ts +181 -0
  21. package/src/shared/web-session.ts +491 -0
  22. package/src/ui/cli/agent-ops.ts +843 -0
  23. package/src/ui/cli/agents-command.ts +97 -615
  24. package/src/ui/cli/flag-parser.ts +21 -0
  25. package/src/ui/cli/index.ts +127 -11
  26. package/src/ui/cli/sessions-command.ts +49 -5
  27. package/src/ui/cli/target-workspace.ts +105 -0
  28. package/src/ui/cli/web-command.ts +542 -14
  29. package/src/ui/mcp/api.ts +7 -0
  30. package/src/ui/mcp/server.ts +74 -6
  31. package/src/ui/server/agent-routes.ts +324 -0
  32. package/src/ui/server/audit.ts +81 -27
  33. package/src/ui/server/http-errors.ts +21 -3
  34. package/src/ui/server/index.ts +821 -66
  35. package/src/ui/server/public/agents-page.js +654 -0
  36. package/src/ui/server/public/agents-section.js +299 -0
  37. package/src/ui/server/public/agents.html +439 -0
  38. package/src/ui/server/public/appearance.js +3 -0
  39. package/src/ui/server/public/error-page.js +103 -0
  40. package/src/ui/server/public/header.js +45 -11
  41. package/src/ui/server/public/i18n/es.js +90 -1
  42. package/src/ui/server/public/i18n/ja.js +86 -1
  43. package/src/ui/server/public/local-dev.html +53 -0
  44. package/src/ui/server/public/logs.html +3 -0
  45. package/src/ui/server/public/panel.js +1 -1
  46. package/src/ui/server/public/release.html +8 -0
  47. package/src/ui/server/public/settings-page.js +5 -0
  48. package/src/ui/server/public/settings-registry.js +33 -0
  49. package/src/ui/server/public/shell.js +248 -5
  50. package/src/ui/server/public/topbar.js +51 -2
  51. package/src/ui/server/request-guard.ts +670 -0
  52. package/src/ui/server/route-classes.ts +317 -0
  53. package/src/ui/server/session-routes.ts +445 -0
  54. package/src/ui/server/shutdown-guard.ts +91 -0
  55. package/src/ui/server/sudo-store.ts +29 -3
  56. package/src/ui/server/ui-assets.ts +10 -1
  57. package/src/ui/server/websocket.ts +326 -0
  58. package/src/ui/server/workspace-routes.ts +13 -7
@@ -12,6 +12,26 @@ import {
12
12
  } from "../../shared/secrets/index.js";
13
13
  import { getDistRoot, getEngineRoot, getStateDir } from "../../shared/paths.js";
14
14
  import { hasDenUiMarker } from "../../shared/ui-marker.js";
15
+ import {
16
+ cliAuthHeaders,
17
+ ensureWebSecret,
18
+ osUser,
19
+ personToken,
20
+ readWebSecret,
21
+ rotateWebSecret,
22
+ } from "../../shared/web-secret.js";
23
+ import {
24
+ ignoredConfigWarning,
25
+ localhostOrigin,
26
+ loginLink,
27
+ mintLoginCode,
28
+ parseOrigin,
29
+ readWebAuthConfig,
30
+ SESSION_TOUCH_MS,
31
+ sessionIdPrefix,
32
+ WebSessionStore,
33
+ } from "../../shared/web-session.js";
34
+ import { revokeAllActorTokens } from "../../shared/actor-token.js";
15
35
  import { engineBin, engineModuleFile } from "../../shared/engine.js";
16
36
  import { readWebPort } from "../../shared/edge/config.js";
17
37
  import {
@@ -22,8 +42,10 @@ import {
22
42
  } from "./server-identity.js";
23
43
 
24
44
  import { product } from "../../shared/product.js";
45
+ import { redactSecrets } from "../../shared/redact.js";
25
46
  interface WebCommandOptions {
26
- action: "up" | "down" | "restart";
47
+ /** `open` is `up` that always opens a fresh sign-in tab (SEC-1b). */
48
+ action: "up" | "down" | "restart" | "open";
27
49
  port: number;
28
50
  openBrowser: boolean;
29
51
  silent: boolean;
@@ -33,6 +55,8 @@ interface WebCommandOptions {
33
55
  interface StartedServer {
34
56
  pid: number | undefined;
35
57
  logPath: string;
58
+ /** Where this start's output begins in the log. */
59
+ logOffset?: number;
36
60
  }
37
61
 
38
62
  interface WebServerState {
@@ -113,6 +137,7 @@ export function parseWebCommandArgs(
113
137
  const arg = args[i];
114
138
  if (
115
139
  arg === "up" ||
140
+ arg === "open" ||
116
141
  arg === "down" ||
117
142
  arg === "restart" ||
118
143
  arg === "start" || // legacy alias for up
@@ -159,6 +184,7 @@ export function parseWebCommandArgs(
159
184
  throw new Error(`Unknown web option: ${arg}`);
160
185
  }
161
186
 
187
+ if (options.action === "open") options.openBrowser = true;
162
188
  if (options.setupBrowserAccess && options.action !== "up") {
163
189
  throw new Error(
164
190
  `--setup-browser-access is only valid with ${product().cli} web up`,
@@ -310,10 +336,50 @@ export async function isDenWebRunning(port: number): Promise<boolean> {
310
336
  return (await isDenWebApiRunning(port)) && (await isDenWebUiReady(port));
311
337
  }
312
338
 
313
- async function waitForDenWeb(port: number): Promise<boolean> {
339
+ function pidAlive(pid: number): boolean {
340
+ try {
341
+ process.kill(pid, 0);
342
+ return true;
343
+ } catch (e) {
344
+ return (e as NodeJS.ErrnoException).code === "EPERM";
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Why a server that just started refused to (its `✗ <reason>` line: the
350
+ * bind refusal, a wrong web.session value), read from this start's part of
351
+ * its log, secret-shaped values redacted. Null when it said none.
352
+ */
353
+ export function startupRefusal(logPath: string, offset = 0): string | null {
354
+ let text: string;
355
+ try {
356
+ const fd = fs.openSync(logPath, "r");
357
+ try {
358
+ const size = fs.fstatSync(fd).size;
359
+ const from = Math.max(0, Math.min(offset, size), size - 64 * 1024);
360
+ const buf = Buffer.alloc(size - from);
361
+ fs.readSync(fd, buf, 0, buf.length, from);
362
+ text = buf.toString("utf-8");
363
+ } finally {
364
+ fs.closeSync(fd);
365
+ }
366
+ } catch {
367
+ return null;
368
+ }
369
+ const lines = text
370
+ .split(/\r?\n/)
371
+ .map((l) => /^\s*✗\s+(.+?)\s*$/.exec(l)?.[1])
372
+ .filter((l): l is string => !!l);
373
+ const last = lines.at(-1);
374
+ return last ? redactSecrets(last).slice(0, 600) : null;
375
+ }
376
+
377
+ async function waitForDenWeb(port: number, pid?: number): Promise<boolean> {
314
378
  const deadline = Date.now() + WEB_START_TIMEOUT_MS;
315
379
  while (Date.now() < deadline) {
316
380
  if (await isDenWebRunning(port)) return true;
381
+ // A server that refused to start has exited: no point waiting it out.
382
+ if (pid !== undefined && !pidAlive(pid)) return isDenWebRunning(port);
317
383
  await new Promise((resolve) => setTimeout(resolve, WEB_START_POLL_MS));
318
384
  }
319
385
  return false;
@@ -393,6 +459,7 @@ function startDenWebServer(options: WebCommandOptions): StartedServer {
393
459
  `\n--- ${product().cli} web start ${new Date().toISOString()} ---\n`,
394
460
  );
395
461
 
462
+ const logOffset = fs.statSync(logPath).size;
396
463
  const logFd = fs.openSync(logPath, "a");
397
464
  try {
398
465
  const child = spawn(
@@ -406,7 +473,7 @@ function startDenWebServer(options: WebCommandOptions): StartedServer {
406
473
  },
407
474
  );
408
475
  child.unref();
409
- const started = { pid: child.pid, logPath };
476
+ const started = { pid: child.pid, logPath, logOffset };
410
477
  writeWebServerState({
411
478
  ...started,
412
479
  port: options.port,
@@ -867,15 +934,26 @@ function openUrl(url: string): BrowserOpenResult {
867
934
  return { status: "open-failed", error };
868
935
  }
869
936
 
870
- function openOrFocusDenWebUrl(url: string, port: number): BrowserOpenResult {
871
- const focusResult = focusExistingBrowserTab(port);
872
- if (focusResult.status === "focused") return focusResult;
873
-
874
- if (focusResult.status === "error" && process.platform === "darwin") {
875
- return focusResult;
937
+ /**
938
+ * Focus a tab already on this server, else open `url()` (SEC-1b: the
939
+ * sign-in link, minted only when it is opened). `focus: false` (`web
940
+ * open`) always opens.
941
+ */
942
+ function openOrFocusDenWebUrl(
943
+ url: () => string,
944
+ port: number,
945
+ focus = true,
946
+ ): BrowserOpenResult {
947
+ if (focus) {
948
+ const focusResult = focusExistingBrowserTab(port);
949
+ if (focusResult.status === "focused") return focusResult;
950
+
951
+ if (focusResult.status === "error" && process.platform === "darwin") {
952
+ return focusResult;
953
+ }
876
954
  }
877
955
 
878
- return openUrl(url);
956
+ return openUrl(url());
879
957
  }
880
958
 
881
959
  function buildBrowserAccessProbeScript(appName: string): string {
@@ -987,12 +1065,28 @@ function setupBrowserAccess(): void {
987
1065
  console.log(chalk.gray(` ${product().cli} web --setup-browser-access\n`));
988
1066
  }
989
1067
 
1068
+ /** `web` subcommands that answer their own --help (cmdWeb dispatches them first). */
1069
+ export const WEB_SUBCOMMANDS_WITH_HELP = [
1070
+ "token",
1071
+ "login",
1072
+ "sessions",
1073
+ "logout",
1074
+ ] as const;
1075
+
990
1076
  export function displayWebHelp(): void {
991
1077
  console.log(chalk.bold("\nUsage:"));
992
1078
  console.log(
993
1079
  ` ${product().cli} web [up|down|restart] [--port <port>] [--no-open] [--verbose]`,
994
1080
  );
995
1081
  console.log(` ${product().cli} web --setup-browser-access`);
1082
+ console.log(` ${product().cli} web token --print`);
1083
+ console.log(
1084
+ ` ${product().cli} web login [--next <path>] [--origin <url>] [--port <port>]`,
1085
+ );
1086
+ console.log(` ${product().cli} web sessions [--json] [--port <port>]`);
1087
+ console.log(
1088
+ ` ${product().cli} web logout [<id prefix>] [--all] [--port <port>]`,
1089
+ );
996
1090
  console.log();
997
1091
  console.log(chalk.bold("Actions:"));
998
1092
  console.log(
@@ -1002,6 +1096,19 @@ export function displayWebHelp(): void {
1002
1096
  ` down Stop the ${product().title} web daemon only`,
1003
1097
  );
1004
1098
  console.log(` restart Stop then start ${product().title} web`);
1099
+ console.log(
1100
+ " open Start if needed, and sign this browser in (a new tab)",
1101
+ );
1102
+ console.log(
1103
+ " login Print a one-time sign-in link (60 s), for another browser",
1104
+ );
1105
+ console.log(" sessions List the signed-in browsers");
1106
+ console.log(
1107
+ " logout [<id>] Sign out one browser, or all; --all also rotates web.secret and revokes agent tokens",
1108
+ );
1109
+ console.log(
1110
+ " token --print Print your CLI token, for curl (Authorization: Bearer …)",
1111
+ );
1005
1112
  console.log(" (start/stop remain accepted as legacy aliases for up/down)");
1006
1113
  console.log();
1007
1114
  console.log(chalk.bold("Options:"));
@@ -1021,7 +1128,62 @@ export function displayWebHelp(): void {
1021
1128
  console.log();
1022
1129
  }
1023
1130
 
1131
+ /**
1132
+ * `<cli> web token --print` (SEC-1a): the person token, for curl. The token
1133
+ * alone on stdout; a one-line note on stderr that it grants the user's
1134
+ * access. Creates web.secret on first need.
1135
+ */
1136
+ export function cmdWebToken(
1137
+ args: string[],
1138
+ io: {
1139
+ out: (s: string) => void;
1140
+ err: (s: string) => void;
1141
+ stateDir?: string;
1142
+ } = {
1143
+ out: (s) => process.stdout.write(s),
1144
+ err: (s) => process.stderr.write(s),
1145
+ },
1146
+ ): number {
1147
+ const usage =
1148
+ `Usage: ${product().cli} web token --print\n` +
1149
+ " Prints the token that authorizes your CLI's calls to the web server.\n";
1150
+ if (isHelp(args)) {
1151
+ io.out(usage);
1152
+ return 0;
1153
+ }
1154
+ const rest = args.filter((a) => a !== "--print");
1155
+ if (!args.includes("--print") || rest.length > 0) {
1156
+ io.err(usage);
1157
+ return 2;
1158
+ }
1159
+ const secret = ensureWebSecret(io.stateDir ?? getStateDir());
1160
+ io.err(
1161
+ `${product().cli}: this token grants your access (person:${osUser()}) to ` +
1162
+ `${product().title} web; keep it out of logs and shared shells.\n`,
1163
+ );
1164
+ io.out(`${personToken(secret)}\n`);
1165
+ return 0;
1166
+ }
1167
+
1024
1168
  export async function cmdWeb(args: string[]): Promise<void> {
1169
+ // A subcommand with its own help answers its own --help (it is
1170
+ // dispatched first); the rest (up, down, restart, open) share this one.
1171
+ if (args[0] === "token") {
1172
+ process.exitCode = cmdWebToken(args.slice(1));
1173
+ return;
1174
+ }
1175
+ if (args[0] === "login") {
1176
+ process.exitCode = cmdWebLogin(args.slice(1));
1177
+ return;
1178
+ }
1179
+ if (args[0] === "sessions") {
1180
+ process.exitCode = await cmdWebSessions(args.slice(1));
1181
+ return;
1182
+ }
1183
+ if (args[0] === "logout") {
1184
+ process.exitCode = await cmdWebLogout(args.slice(1));
1185
+ return;
1186
+ }
1025
1187
  if (args.includes("--help") || args.includes("-h")) {
1026
1188
  displayWebHelp();
1027
1189
  return;
@@ -1079,6 +1241,15 @@ export async function cmdWeb(args: string[]): Promise<void> {
1079
1241
  );
1080
1242
  }
1081
1243
 
1244
+ // SEC-1b: a wrong web.allow_plain_http_remote would stop the server at
1245
+ // start; say so here, naming the key, instead of "did not become ready".
1246
+ // A key read and ignored (the distribution's web.allow_plain_http_remote:
1247
+ // only user.yml counts) is said here too, not only in the server's log.
1248
+ const ignored = ignoredConfigWarning(
1249
+ readWebAuthConfig(getDistRoot(), product().configFile),
1250
+ );
1251
+ if (ignored) console.log(chalk.yellow(`\n ⚠ ${ignored}`));
1252
+
1082
1253
  const apiRunning = await isDenWebApiRunning(options.port);
1083
1254
  let running = apiRunning && (await isDenWebUiReady(options.port));
1084
1255
  let started: StartedServer | null = null;
@@ -1102,10 +1273,14 @@ export async function cmdWeb(args: string[]): Promise<void> {
1102
1273
  console.log(chalk.cyan(`\n Starting ${product().cli} web on ${url}...`));
1103
1274
  try {
1104
1275
  started = startDenWebServer(options);
1105
- running = await waitForDenWeb(options.port);
1276
+ running = await waitForDenWeb(options.port, started.pid);
1106
1277
  if (!running) {
1278
+ // The server's own reason, when it gave one, then where to look.
1279
+ const why = startupRefusal(started.logPath, started.logOffset);
1107
1280
  throw new Error(
1108
- `${product().title} web did not become ready. See ${started.logPath}`,
1281
+ why
1282
+ ? `${product().title} web refused to start: ${why}\n See ${started.logPath}`
1283
+ : `${product().title} web did not become ready. See ${started.logPath}`,
1109
1284
  );
1110
1285
  }
1111
1286
  console.log(
@@ -1130,7 +1305,16 @@ export async function cmdWeb(args: string[]): Promise<void> {
1130
1305
  return;
1131
1306
  }
1132
1307
 
1133
- const browser = openOrFocusDenWebUrl(url, options.port);
1308
+ // SEC-1b: what opens is a sign-in link (localhost, one-time, 60 s). It
1309
+ // is printed only when the browser couldn't be opened: the one line the
1310
+ // person then needs.
1311
+ let link: string | null = null;
1312
+ const signIn = () => (link ??= freshLoginLink(options.port));
1313
+ const browser = openOrFocusDenWebUrl(
1314
+ signIn,
1315
+ options.port,
1316
+ options.action !== "open",
1317
+ );
1134
1318
  if (browser.status === "focused") {
1135
1319
  console.log(chalk.green(` OK Focused existing ${browser.app} tab`));
1136
1320
  console.log(chalk.gray(` ${url}\n`));
@@ -1162,5 +1346,349 @@ export async function cmdWeb(args: string[]): Promise<void> {
1162
1346
  } else {
1163
1347
  console.log(chalk.yellow("\n Could not open a browser automatically."));
1164
1348
  }
1165
- console.log(chalk.gray(` Visit: ${url}\n`));
1349
+ console.log(` Sign in (once, within 60 s): ${signIn()}\n`);
1350
+ }
1351
+
1352
+ // ── SEC-1b: sign-in (`web login`, `web sessions`, `web logout`) ─────────────
1353
+
1354
+ /** A fresh one-time sign-in link for `origin` (localhost by default). */
1355
+ export function freshLoginLink(
1356
+ port: number,
1357
+ next = "/",
1358
+ origin: string = localhostOrigin(port),
1359
+ stateDir: string = getStateDir(),
1360
+ ): string {
1361
+ return loginLink(origin, mintLoginCode(ensureWebSecret(stateDir)), next);
1362
+ }
1363
+
1364
+ interface CliIo {
1365
+ out: (s: string) => void;
1366
+ err: (s: string) => void;
1367
+ stateDir?: string;
1368
+ }
1369
+ const stdIo: CliIo = {
1370
+ out: (s) => process.stdout.write(s),
1371
+ err: (s) => process.stderr.write(s),
1372
+ };
1373
+
1374
+ /** `--help` or `-h` among a subcommand's args. */
1375
+ function isHelp(args: readonly string[]): boolean {
1376
+ return args.includes("--help") || args.includes("-h");
1377
+ }
1378
+
1379
+ /** `--port <p>` / `--port=<p>` / `-p <p>`, and the rest of `args`. */
1380
+ function takePort(args: string[]): { port: number; rest: string[] } {
1381
+ let port = parsePort(process.env.PORT);
1382
+ const rest: string[] = [];
1383
+ for (let i = 0; i < args.length; i++) {
1384
+ const a = args[i];
1385
+ if (a === "--port" || a === "-p") {
1386
+ port = parsePort(args[++i]);
1387
+ } else if (a.startsWith("--port=")) {
1388
+ port = parsePort(a.slice("--port=".length));
1389
+ } else rest.push(a);
1390
+ }
1391
+ return { port, rest };
1392
+ }
1393
+
1394
+ /**
1395
+ * `<cli> web login [--next <path>] [--origin <url>]`: a fresh sign-in link
1396
+ * on stdout, alone (the only place a login code is ever printed); a note on
1397
+ * stderr. `--origin` for the edge hostname or a LAN name, where the cookie
1398
+ * must be set (the signed-out page there says which).
1399
+ */
1400
+ export function cmdWebLogin(args: string[], io: CliIo = stdIo): number {
1401
+ const usage = `Usage: ${product().cli} web login [--next <path>] [--origin <url>] [--port <port>]\n`;
1402
+ if (isHelp(args)) {
1403
+ io.out(
1404
+ usage +
1405
+ " Prints a one-time sign-in link (it works once, within 60 s) for one browser.\n" +
1406
+ " On localhost nothing asks for a sign-in; it is needed when this server is\n" +
1407
+ " reached beyond this machine (the edge, or a LAN address: pass its --origin).\n" +
1408
+ " --next <path> where the browser lands after signing in (same origin only)\n" +
1409
+ " --origin <url> the address the browser uses, http(s)://host[:port]\n",
1410
+ );
1411
+ return 0;
1412
+ }
1413
+ let next = "/";
1414
+ let origin: string | null = null;
1415
+ let port: number;
1416
+ let rest: string[];
1417
+ try {
1418
+ ({ port, rest } = takePort(args));
1419
+ } catch (e) {
1420
+ io.err(`${(e as Error).message}\n`);
1421
+ return 2;
1422
+ }
1423
+ for (let i = 0; i < rest.length; i++) {
1424
+ const a = rest[i];
1425
+ const val = (flag: string) =>
1426
+ a === flag
1427
+ ? rest[++i]
1428
+ : a.startsWith(`${flag}=`)
1429
+ ? a.slice(flag.length + 1)
1430
+ : undefined;
1431
+ const n = val("--next");
1432
+ const o = val("--origin");
1433
+ if (n !== undefined) next = n;
1434
+ else if (o !== undefined) {
1435
+ origin = parseOrigin(o);
1436
+ if (!origin) {
1437
+ io.err(
1438
+ `--origin must be http(s)://host[:port] with no path, got "${o}"\n`,
1439
+ );
1440
+ return 2;
1441
+ }
1442
+ } else {
1443
+ io.err(usage);
1444
+ return 2;
1445
+ }
1446
+ }
1447
+ const link = freshLoginLink(
1448
+ port,
1449
+ next,
1450
+ origin ?? localhostOrigin(port),
1451
+ io.stateDir ?? getStateDir(),
1452
+ );
1453
+ io.err(
1454
+ `${product().cli}: open this link within 60 seconds; it signs in one browser, once.\n`,
1455
+ );
1456
+ io.out(`${link}\n`);
1457
+ return 0;
1458
+ }
1459
+
1460
+ interface SessionRow {
1461
+ id: string;
1462
+ createdAt: string;
1463
+ lastSeenAt: string;
1464
+ userAgent: string;
1465
+ host: string;
1466
+ }
1467
+
1468
+ /**
1469
+ * A call to the running server with the person token, or null when no
1470
+ * server answers on the port (the caller then edits the state files).
1471
+ */
1472
+ function serverCall(
1473
+ stateDir: string,
1474
+ port: number,
1475
+ method: string,
1476
+ p: string,
1477
+ body?: unknown,
1478
+ ): Promise<{ status: number; json: unknown } | null> {
1479
+ return new Promise((resolve) => {
1480
+ const payload = body === undefined ? undefined : JSON.stringify(body);
1481
+ const req = http.request(
1482
+ {
1483
+ host: "127.0.0.1",
1484
+ port,
1485
+ path: p,
1486
+ method,
1487
+ timeout: 10_000,
1488
+ headers: {
1489
+ ...cliAuthHeaders(stateDir),
1490
+ ...(payload !== undefined
1491
+ ? {
1492
+ "Content-Type": "application/json",
1493
+ "Content-Length": String(Buffer.byteLength(payload)),
1494
+ }
1495
+ : {}),
1496
+ },
1497
+ },
1498
+ (res) => {
1499
+ let out = "";
1500
+ res.setEncoding("utf-8");
1501
+ res.on("data", (d: string) => (out += d));
1502
+ res.on("end", () => {
1503
+ let json: unknown = null;
1504
+ try {
1505
+ json = JSON.parse(out);
1506
+ } catch {
1507
+ /* not JSON */
1508
+ }
1509
+ resolve({ status: res.statusCode ?? 0, json });
1510
+ });
1511
+ },
1512
+ );
1513
+ req.on("timeout", () => req.destroy());
1514
+ req.on("error", () => resolve(null));
1515
+ req.end(payload);
1516
+ });
1517
+ }
1518
+
1519
+ function offlineStore(stateDir: string): WebSessionStore {
1520
+ return new WebSessionStore({
1521
+ stateDir: () => stateDir,
1522
+ secret: () => readWebSecret(stateDir),
1523
+ });
1524
+ }
1525
+
1526
+ function apiError(r: { status: number; json: unknown }): string {
1527
+ const e = (r.json as { error?: unknown } | null)?.error;
1528
+ return typeof e === "string" ? e : `the server answered ${r.status}`;
1529
+ }
1530
+
1531
+ /** `<cli> web sessions [--json]`: the signed-in browsers. */
1532
+ export async function cmdWebSessions(
1533
+ args: string[],
1534
+ io: CliIo = stdIo,
1535
+ ): Promise<number> {
1536
+ let port: number;
1537
+ let rest: string[];
1538
+ try {
1539
+ ({ port, rest } = takePort(args));
1540
+ } catch (e) {
1541
+ io.err(`${(e as Error).message}\n`);
1542
+ return 2;
1543
+ }
1544
+ const usage = `Usage: ${product().cli} web sessions [--json] [--port <port>]\n`;
1545
+ if (isHelp(rest)) {
1546
+ io.out(
1547
+ usage +
1548
+ " The browsers signed in to this server: ID (for `web logout <id>`), CREATED,\n" +
1549
+ ` LAST SEEN, HOST and USER AGENT. LAST SEEN is written at most once a\n` +
1550
+ ` minute (${SESSION_TOUCH_MS / 1000} s) per session, so it trails a browser's last request by up to\n` +
1551
+ " that long (right after sign-in it equals CREATED). A session never\n" +
1552
+ " expires: it ends at sign-out or `web logout`.\n",
1553
+ );
1554
+ return 0;
1555
+ }
1556
+ const asJson = rest.includes("--json");
1557
+ if (rest.some((a) => a !== "--json")) {
1558
+ io.err(usage);
1559
+ return 2;
1560
+ }
1561
+ const stateDir = io.stateDir ?? getStateDir();
1562
+ let rows: SessionRow[];
1563
+ const r = await serverCall(stateDir, port, "GET", "/api/web/sessions");
1564
+ if (r) {
1565
+ if (r.status !== 200) {
1566
+ io.err(`${product().cli}: ${apiError(r)}\n`);
1567
+ return 1;
1568
+ }
1569
+ rows = (r.json as { sessions: SessionRow[] }).sessions;
1570
+ } else {
1571
+ rows = offlineStore(stateDir)
1572
+ .list()
1573
+ .map((x) => ({
1574
+ id: sessionIdPrefix(x.key),
1575
+ createdAt: new Date(x.createdAt).toISOString(),
1576
+ lastSeenAt: new Date(x.lastSeenAt).toISOString(),
1577
+ userAgent: x.userAgent,
1578
+ host: x.host,
1579
+ }));
1580
+ }
1581
+ if (asJson) {
1582
+ io.out(`${JSON.stringify(rows, null, 2)}\n`);
1583
+ return 0;
1584
+ }
1585
+ if (rows.length === 0) {
1586
+ io.out("No signed-in browsers.\n");
1587
+ return 0;
1588
+ }
1589
+ const when = (iso: string) => iso.replace("T", " ").replace(/\.\d+Z$/, "Z");
1590
+ const table = [
1591
+ ["ID", "CREATED", "LAST SEEN", "HOST", "USER AGENT"],
1592
+ ...rows.map((x) => [
1593
+ x.id,
1594
+ when(x.createdAt),
1595
+ when(x.lastSeenAt),
1596
+ x.host,
1597
+ x.userAgent,
1598
+ ]),
1599
+ ];
1600
+ const widths = table[0].map((_, c) =>
1601
+ Math.max(...table.map((row) => row[c].length)),
1602
+ );
1603
+ for (const row of table)
1604
+ io.out(
1605
+ `${row
1606
+ .map((cell, c) =>
1607
+ c === row.length - 1 ? cell : cell.padEnd(widths[c]),
1608
+ )
1609
+ .join(" ")
1610
+ .trimEnd()}\n`,
1611
+ );
1612
+ return 0;
1613
+ }
1614
+
1615
+ /**
1616
+ * `<cli> web logout [<id prefix>] [--all]`: sign out one browser, or every
1617
+ * browser; `--all` also rotates web.secret, which ends every session and
1618
+ * every person token (the CLI re-reads it, so it keeps working), and
1619
+ * revokes every agent's actor token (SEC-1 §6c.2).
1620
+ */
1621
+ export async function cmdWebLogout(
1622
+ args: string[],
1623
+ io: CliIo = stdIo,
1624
+ ): Promise<number> {
1625
+ let port: number;
1626
+ let rest: string[];
1627
+ try {
1628
+ ({ port, rest } = takePort(args));
1629
+ } catch (e) {
1630
+ io.err(`${(e as Error).message}\n`);
1631
+ return 2;
1632
+ }
1633
+ const usage = `Usage: ${product().cli} web logout [<id prefix>] [--all] [--port <port>]\n`;
1634
+ if (isHelp(rest)) {
1635
+ io.out(
1636
+ usage +
1637
+ " <id prefix> sign out one browser (an ID from `web sessions`)\n" +
1638
+ " (none) sign out every browser; agents keep their tokens\n" +
1639
+ " --all sign out every browser, rotate web.secret (every person token\n" +
1640
+ " is void; this CLI re-reads it) and revoke every agent's token:\n" +
1641
+ " a running agent must be restarted or resumed for a new one\n",
1642
+ );
1643
+ return 0;
1644
+ }
1645
+ const all = rest.includes("--all");
1646
+ const ids = rest.filter((a) => a !== "--all");
1647
+ if (
1648
+ ids.length > 1 ||
1649
+ ids.some((a) => a.startsWith("-")) ||
1650
+ (all && ids.length)
1651
+ ) {
1652
+ io.err(usage);
1653
+ return 2;
1654
+ }
1655
+ const id = ids[0];
1656
+ const stateDir = io.stateDir ?? getStateDir();
1657
+ const r = await serverCall(stateDir, port, "POST", "/api/web/logout", {
1658
+ ...(id ? { id } : {}),
1659
+ ...(all ? { rotate: true } : {}),
1660
+ });
1661
+ let ended: number;
1662
+ let agentTokens = 0;
1663
+ if (r) {
1664
+ if (r.status !== 200) {
1665
+ io.err(`${product().cli}: ${apiError(r)}\n`);
1666
+ return 1;
1667
+ }
1668
+ const j = r.json as { ended?: unknown[]; agentTokens?: unknown };
1669
+ ended = (j.ended ?? []).length;
1670
+ if (typeof j.agentTokens === "number") agentTokens = j.agentTokens;
1671
+ } else {
1672
+ // No server: the state files themselves (the same OS user's).
1673
+ const store = offlineStore(stateDir);
1674
+ try {
1675
+ ended = id ? (store.endByPrefix(id), 1) : store.endAll();
1676
+ } catch (e) {
1677
+ io.err(`${product().cli}: ${(e as Error).message}\n`);
1678
+ return 1;
1679
+ }
1680
+ if (all) {
1681
+ if (readWebSecret(stateDir)) rotateWebSecret(stateDir);
1682
+ agentTokens = revokeAllActorTokens(stateDir);
1683
+ }
1684
+ }
1685
+ io.out(
1686
+ `Signed out ${ended} browser${ended === 1 ? "" : "s"}.` +
1687
+ (all
1688
+ ? ` web.secret rotated: every old session and person token is void; this CLI keeps working.` +
1689
+ ` Revoked ${agentTokens} agent token${agentTokens === 1 ? "" : "s"}: a running agent must be restarted or resumed to get a new one.`
1690
+ : "") +
1691
+ "\n",
1692
+ );
1693
+ return 0;
1166
1694
  }
package/src/ui/mcp/api.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * every tool says whose server it is instead of serving its data.
11
11
  */
12
12
  import { product } from "../../shared/product.js";
13
+ import { cliAuthHeaders } from "../../shared/web-secret.js";
13
14
  import {
14
15
  foreignServerMessage,
15
16
  identifyServer,
@@ -233,6 +234,11 @@ export function createDenApi(opts: {
233
234
  headers: () => Record<string, string>;
234
235
  /** Check whose server it is before any request (the MCP server does). */
235
236
  identity?: IdentityCheck;
237
+ /**
238
+ * The credential every call carries (SEC-1a): the person token from
239
+ * web.secret by default (cliAuthHeaders, the one CLI-side helper).
240
+ */
241
+ auth?: () => Record<string, string>;
236
242
  }): DenApi {
237
243
  const { baseUrl, cli } = opts;
238
244
  const { hostname } = new URL(baseUrl);
@@ -284,6 +290,7 @@ export function createDenApi(opts: {
284
290
  method,
285
291
  headers: {
286
292
  ...opts.headers(),
293
+ ...(opts.auth ?? cliAuthHeaders)(),
287
294
  ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
288
295
  },
289
296
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),