@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.
- package/CHANGELOG.md +457 -0
- package/README.md +88 -158
- package/docs/distributions.md +216 -16
- package/docs/reference.md +177 -0
- 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/den-config.ts +52 -3
- 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 +16 -2
- package/src/shared/sessions/den-pty.ts +10 -2
- package/src/shared/sessions/env.ts +13 -3
- package/src/shared/sessions/host.ts +56 -8
- package/src/shared/sessions/types.ts +8 -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 +97 -615
- package/src/ui/cli/flag-parser.ts +21 -0
- package/src/ui/cli/index.ts +127 -11
- package/src/ui/cli/sessions-command.ts +49 -5
- package/src/ui/cli/target-workspace.ts +105 -0
- package/src/ui/cli/web-command.ts +542 -14
- package/src/ui/mcp/api.ts +7 -0
- package/src/ui/mcp/server.ts +74 -6
- package/src/ui/server/agent-routes.ts +324 -0
- package/src/ui/server/audit.ts +81 -27
- package/src/ui/server/http-errors.ts +21 -3
- package/src/ui/server/index.ts +821 -66
- package/src/ui/server/public/agents-page.js +654 -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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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) } : {}),
|