@monster-orange/den 0.3.0 → 0.4.1

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