@monster-orange/den 0.4.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.
- package/CHANGELOG.md +409 -0
- package/docs/distributions.md +204 -14
- package/package.json +3 -1
- package/src/shared/actor-token.ts +275 -0
- package/src/shared/agent-actor.ts +12 -2
- package/src/shared/agents/den-claude.ts +99 -1
- package/src/shared/agents/runtime.ts +7 -0
- package/src/shared/agents/runtimes.ts +9 -2
- package/src/shared/environment-manager/scope.ts +22 -0
- package/src/shared/environment-manager/service-runner.ts +94 -2
- package/src/shared/profile.ts +15 -4
- package/src/shared/sessions/client.ts +9 -2
- package/src/shared/sessions/env.ts +13 -3
- package/src/shared/sessions/host.ts +23 -0
- package/src/shared/web-secret.ts +181 -0
- package/src/shared/web-session.ts +491 -0
- package/src/ui/cli/agent-ops.ts +843 -0
- package/src/ui/cli/agents-command.ts +96 -614
- package/src/ui/cli/flag-parser.ts +21 -0
- package/src/ui/cli/index.ts +94 -10
- package/src/ui/cli/sessions-command.ts +32 -0
- package/src/ui/cli/target-workspace.ts +105 -0
- package/src/ui/cli/web-command.ts +536 -14
- package/src/ui/mcp/api.ts +7 -0
- package/src/ui/mcp/server.ts +74 -6
- package/src/ui/server/agent-routes.ts +323 -0
- package/src/ui/server/audit.ts +81 -27
- package/src/ui/server/http-errors.ts +21 -3
- package/src/ui/server/index.ts +814 -66
- package/src/ui/server/public/agents-page.js +648 -0
- package/src/ui/server/public/agents-section.js +299 -0
- package/src/ui/server/public/agents.html +439 -0
- package/src/ui/server/public/appearance.js +3 -0
- package/src/ui/server/public/error-page.js +103 -0
- package/src/ui/server/public/header.js +45 -11
- package/src/ui/server/public/i18n/es.js +90 -1
- package/src/ui/server/public/i18n/ja.js +86 -1
- package/src/ui/server/public/local-dev.html +53 -0
- package/src/ui/server/public/logs.html +3 -0
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/public/release.html +8 -0
- package/src/ui/server/public/settings-page.js +5 -0
- package/src/ui/server/public/settings-registry.js +33 -0
- package/src/ui/server/public/shell.js +248 -5
- package/src/ui/server/public/topbar.js +51 -2
- package/src/ui/server/request-guard.ts +670 -0
- package/src/ui/server/route-classes.ts +317 -0
- package/src/ui/server/session-routes.ts +445 -0
- package/src/ui/server/shutdown-guard.ts +91 -0
- package/src/ui/server/sudo-store.ts +29 -3
- package/src/ui/server/ui-assets.ts +10 -1
- package/src/ui/server/websocket.ts +326 -0
- package/src/ui/server/workspace-routes.ts +13 -7
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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) } : {}),
|