@monster-orange/den 0.4.26 → 0.4.28
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 +88 -0
- package/docs/distributions.md +145 -20
- package/package.json +1 -1
- package/src/shared/agents/claude-helpers.ts +623 -0
- package/src/shared/agents/den-claude.ts +54 -0
- package/src/shared/agents/helpers.ts +175 -0
- package/src/shared/agents/runtime.ts +15 -2
- package/src/shared/den-config.ts +42 -1
- package/src/shared/hub.ts +314 -82
- package/src/shared/secrets/onepassword.ts +52 -15
- package/src/ui/cli/agent-ops.ts +161 -3
- package/src/ui/cli/agents-command.ts +127 -1
- package/src/ui/cli/doctor-command.ts +11 -2
- package/src/ui/mcp/api.ts +8 -2
- package/src/ui/mcp/server.ts +13 -1
- package/src/ui/mcp/tools.ts +50 -0
- package/src/ui/server/agent-routes.ts +133 -1
- package/src/ui/server/hub-connect.ts +499 -0
- package/src/ui/server/index.ts +7 -1
- package/src/ui/server/public/agent-helpers.js +311 -0
- package/src/ui/server/public/agents-page.js +76 -9
- package/src/ui/server/public/i18n/es.js +41 -0
- package/src/ui/server/public/i18n/ja.js +39 -0
- package/src/ui/server/public/settings-registry.js +232 -33
- package/src/ui/server/public/settings.html +23 -0
- package/src/ui/server/public/shell.js +201 -0
- package/src/ui/server/public/topbar.js +5 -1
- package/src/ui/server/public/transcript-view.js +202 -0
- package/src/ui/server/request-guard.ts +3 -1
- package/src/ui/server/route-classes.ts +33 -0
- package/src/ui/server/session-routes.ts +71 -1
- package/src/ui/server/ui-assets.ts +3 -0
|
@@ -113,6 +113,8 @@ import {
|
|
|
113
113
|
createPersona,
|
|
114
114
|
deletePersona,
|
|
115
115
|
listAgents,
|
|
116
|
+
agentHelpers,
|
|
117
|
+
agentHelperTranscript,
|
|
116
118
|
listPersonas,
|
|
117
119
|
personaDetail,
|
|
118
120
|
registries,
|
|
@@ -164,6 +166,7 @@ import {
|
|
|
164
166
|
agentResumeRefusal,
|
|
165
167
|
} from "./agent-resume-grant.js";
|
|
166
168
|
import { readBody, sendJson } from "./http-helpers.js";
|
|
169
|
+
import { product } from "../../shared/product.js";
|
|
167
170
|
import type { Actor } from "../../shared/agent-actor.js";
|
|
168
171
|
|
|
169
172
|
export interface AgentRouteDeps {
|
|
@@ -175,11 +178,16 @@ export interface AgentRouteDeps {
|
|
|
175
178
|
personaWait?: { pollMs?: number; maxMs?: number };
|
|
176
179
|
/** HUB-1.5: GET /api/hub's answer (tests: a fake Hub). */
|
|
177
180
|
hubStatus?: () => Promise<import("../../shared/hub.js").HubStatus>;
|
|
181
|
+
/** HUB-1.7: Connect to Hub's state (tests: fake Hub calls and store). */
|
|
182
|
+
hubConnect?: import("./hub-connect.js").HubConnect;
|
|
178
183
|
}
|
|
179
184
|
|
|
180
185
|
const AGENTS_PATH =
|
|
181
186
|
/^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume|fresh|relaunch|remove|name|paths|persona|keep|handoff))?$/;
|
|
182
187
|
|
|
188
|
+
/** D32: an agent's helpers, and one helper's transcript (read-only). */
|
|
189
|
+
const HELPERS_PATH =
|
|
190
|
+
/^\/api\/workspaces\/([^/]+)\/agents\/([^/]+)\/helpers(?:\/([^/]+))?$/;
|
|
183
191
|
const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
|
|
184
192
|
const PERSONA_PATH = /^\/api\/personas\/([^/]+)$/;
|
|
185
193
|
const PLAIN_PATH = /^\/api\/workspaces\/([^/]+)\/sessions$/;
|
|
@@ -428,15 +436,33 @@ export async function handleAgentRoutes(
|
|
|
428
436
|
pathname === "/api/agents/resume-interrupted";
|
|
429
437
|
if (pathname === "/api/agents/rotation")
|
|
430
438
|
return rotationRoute(req, res, webDeps(deps.agents));
|
|
439
|
+
const hp = HELPERS_PATH.exec(pathname);
|
|
440
|
+
if (hp) return helpersRoute(req, res, hp, webDeps(deps.agents));
|
|
431
441
|
// HUB-1.5: Settings › Hub. Never the token: hubStatus doesn't return it.
|
|
432
442
|
if (pathname === "/api/hub") {
|
|
433
443
|
const method = req.method ?? "GET";
|
|
434
444
|
if (method !== "GET" && method !== "HEAD")
|
|
435
445
|
return methodNotAllowed(res, "GET");
|
|
436
446
|
const { hubStatus } = await import("../../shared/hub.js");
|
|
437
|
-
|
|
447
|
+
const st = await (deps.hubStatus ?? (() => hubStatus()))();
|
|
448
|
+
// HUB-1.7: what the last Connect came to, once (words only). The
|
|
449
|
+
// rail's look (?rail=1) leaves it for Settings, which shows it.
|
|
450
|
+
const rail =
|
|
451
|
+
new URL(req.url ?? "/", "http://localhost").searchParams.get("rail") ===
|
|
452
|
+
"1";
|
|
453
|
+
const last = rail
|
|
454
|
+
? null
|
|
455
|
+
: (deps.hubConnect ?? defaultConnect)?.lastResult();
|
|
456
|
+
sendJson(res, last ? { ...st, connect: last } : st);
|
|
438
457
|
return true;
|
|
439
458
|
}
|
|
459
|
+
// HUB-1.7: Connect to Hub, and its callback (see hub-connect.ts).
|
|
460
|
+
if (
|
|
461
|
+
pathname === "/api/hub/connect" ||
|
|
462
|
+
pathname === "/api/hub/disconnect" ||
|
|
463
|
+
pathname === "/hub/callback"
|
|
464
|
+
)
|
|
465
|
+
return hubConnectRoute(req, res, pathname, deps);
|
|
440
466
|
if (
|
|
441
467
|
pathname !== "/api/personas" &&
|
|
442
468
|
pathname !== "/api/agents/runtimes" &&
|
|
@@ -1381,3 +1407,109 @@ async function rotationRoute(
|
|
|
1381
1407
|
}
|
|
1382
1408
|
return true;
|
|
1383
1409
|
}
|
|
1410
|
+
|
|
1411
|
+
let defaultConnect: import("./hub-connect.js").HubConnect | null = null;
|
|
1412
|
+
|
|
1413
|
+
/** HUB-1.7: start a connect, finish it (Hub's redirect), or disconnect. */
|
|
1414
|
+
async function hubConnectRoute(
|
|
1415
|
+
req: http.IncomingMessage,
|
|
1416
|
+
res: http.ServerResponse,
|
|
1417
|
+
pathname: string,
|
|
1418
|
+
deps: AgentRouteDeps,
|
|
1419
|
+
): Promise<true> {
|
|
1420
|
+
const m = await import("./hub-connect.js");
|
|
1421
|
+
const hc = deps.hubConnect ?? (defaultConnect ??= new m.HubConnect());
|
|
1422
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1423
|
+
const method = req.method ?? "GET";
|
|
1424
|
+
if (pathname === "/hub/callback") {
|
|
1425
|
+
if (method !== "GET") return methodNotAllowed(res, "GET");
|
|
1426
|
+
const q = new URL(req.url ?? "/", "http://localhost").searchParams;
|
|
1427
|
+
const r = await hc.finish(q);
|
|
1428
|
+
res.writeHead(r.ok ? 200 : 400, {
|
|
1429
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1430
|
+
"Referrer-Policy": "no-referrer",
|
|
1431
|
+
"Cache-Control": "no-store",
|
|
1432
|
+
});
|
|
1433
|
+
res.end(
|
|
1434
|
+
m.callbackPage(product().title, {
|
|
1435
|
+
ok: r.ok,
|
|
1436
|
+
...(r.ok ? {} : { reason: r.reason }),
|
|
1437
|
+
message: r.ok
|
|
1438
|
+
? `Connected to Hub${r.team ? ` (${r.team})` : ""}. Going back to Settings…`
|
|
1439
|
+
: r.message,
|
|
1440
|
+
}),
|
|
1441
|
+
);
|
|
1442
|
+
return true;
|
|
1443
|
+
}
|
|
1444
|
+
if (method !== "POST") return methodNotAllowed(res, "POST");
|
|
1445
|
+
let body: Record<string, unknown> = {};
|
|
1446
|
+
try {
|
|
1447
|
+
const raw = await readBody(req);
|
|
1448
|
+
body = raw ? ((JSON.parse(raw) as Record<string, unknown>) ?? {}) : {};
|
|
1449
|
+
} catch {
|
|
1450
|
+
sendJson(res, { ok: false, error: "the body must be JSON" }, 400);
|
|
1451
|
+
return true;
|
|
1452
|
+
}
|
|
1453
|
+
if (pathname === "/api/hub/connect") {
|
|
1454
|
+
// The browser's Origin (request-guard checked it is this Den's).
|
|
1455
|
+
const origin = String(req.headers.origin ?? "");
|
|
1456
|
+
if (!origin) {
|
|
1457
|
+
sendJson(
|
|
1458
|
+
res,
|
|
1459
|
+
{
|
|
1460
|
+
ok: false,
|
|
1461
|
+
error: "Connect from Settings › Agents › Hub in a browser",
|
|
1462
|
+
},
|
|
1463
|
+
400,
|
|
1464
|
+
);
|
|
1465
|
+
return true;
|
|
1466
|
+
}
|
|
1467
|
+
const r = hc.start(origin, body.url);
|
|
1468
|
+
sendJson(res, r, r.ok ? 200 : 400);
|
|
1469
|
+
return true;
|
|
1470
|
+
}
|
|
1471
|
+
const r = await hc.disconnect(body.force === true);
|
|
1472
|
+
sendJson(res, r, r.ok ? 200 : 502);
|
|
1473
|
+
return true;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* D32: GET .../agents/:id/helpers: the helpers agent :id started in its
|
|
1478
|
+
* own session; .../helpers/:helper: one helper's transcript (?limit=).
|
|
1479
|
+
* Read-only: nothing here stops or steers a helper.
|
|
1480
|
+
*/
|
|
1481
|
+
async function helpersRoute(
|
|
1482
|
+
req: http.IncomingMessage,
|
|
1483
|
+
res: http.ServerResponse,
|
|
1484
|
+
hp: RegExpExecArray,
|
|
1485
|
+
d: AgentsResolved,
|
|
1486
|
+
): Promise<true> {
|
|
1487
|
+
const method = req.method ?? "GET";
|
|
1488
|
+
if (method !== "GET" && method !== "HEAD")
|
|
1489
|
+
return methodNotAllowed(res, "GET");
|
|
1490
|
+
const ws = safeDecode(hp[1]);
|
|
1491
|
+
const id = safeDecode(hp[2]);
|
|
1492
|
+
const helper = hp[3] === undefined ? undefined : safeDecode(hp[3]);
|
|
1493
|
+
try {
|
|
1494
|
+
if (!d.workspaceExists(ws))
|
|
1495
|
+
throw new AgentError(`workspace "${ws}" does not exist`, { status: 404 });
|
|
1496
|
+
if (helper === undefined) {
|
|
1497
|
+
const r = await agentHelpers(d, id, ws);
|
|
1498
|
+
sendJson(res, {
|
|
1499
|
+
agent: r.rec.id,
|
|
1500
|
+
workspace: ws,
|
|
1501
|
+
supported: r.supported,
|
|
1502
|
+
live: r.live,
|
|
1503
|
+
helpers: r.helpers,
|
|
1504
|
+
note: r.note,
|
|
1505
|
+
});
|
|
1506
|
+
return true;
|
|
1507
|
+
}
|
|
1508
|
+
const q = new URL(req.url ?? "/", "http://localhost").searchParams;
|
|
1509
|
+
const limit = Number(q.get("limit") ?? "") || undefined;
|
|
1510
|
+
sendJson(res, await agentHelperTranscript(d, id, helper, ws, limit));
|
|
1511
|
+
} catch (e) {
|
|
1512
|
+
sendError(res, e, ws);
|
|
1513
|
+
}
|
|
1514
|
+
return true;
|
|
1515
|
+
}
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HUB-1.7: Connect to Hub. A person connects this install from Settings ›
|
|
3
|
+
* Agents › Hub; nobody copies a token or edits config.
|
|
4
|
+
*
|
|
5
|
+
* 1. POST /api/hub/connect { url? } (signed in, CSRF): a PKCE verifier
|
|
6
|
+
* and a single-use state are made and kept here (10 min); the answer
|
|
7
|
+
* is Hub's /connect URL, and the page goes there.
|
|
8
|
+
* 2. Hub signs the person in; they pick a company and approve. Hub sends
|
|
9
|
+
* the browser to <this Den>/hub/callback?code&state (or error=…).
|
|
10
|
+
* 3. GET /hub/callback (sign-in free: the session cookie is SameSite
|
|
11
|
+
* Strict, so a redirect from Hub doesn't carry it; the single-use state
|
|
12
|
+
* and the verifier only this server holds are what protect it) trades
|
|
13
|
+
* the code for the install's token server to server (POST
|
|
14
|
+
* /connect/token), stores the token and points hub.token at it, then
|
|
15
|
+
* answers a page that moves on to Settings by script, a same-site
|
|
16
|
+
* navigation that carries the cookie again.
|
|
17
|
+
* 4. POST /api/hub/disconnect revokes the token on Hub, then removes it
|
|
18
|
+
* here and clears hub.token.
|
|
19
|
+
*
|
|
20
|
+
* Where the token goes (SEC-0: the install's, never Den's): the install's
|
|
21
|
+
* own secrets store when it keeps secrets (1Password, Doppler: a new item
|
|
22
|
+
* or key, like the saved admin password), else a file Den keeps for this
|
|
23
|
+
* install, <state dir>/hub/token (mode 600, its folder 700). Either way the
|
|
24
|
+
* value travels on stdin or into the file, never in argv, and never to the
|
|
25
|
+
* browser, a log or an API answer.
|
|
26
|
+
*/
|
|
27
|
+
import * as fs from "fs";
|
|
28
|
+
import * as os from "os";
|
|
29
|
+
import * as path from "path";
|
|
30
|
+
import {
|
|
31
|
+
hubExchange,
|
|
32
|
+
HubCallError,
|
|
33
|
+
hubRevoke,
|
|
34
|
+
hubUrlProblem,
|
|
35
|
+
newHubState,
|
|
36
|
+
newPkce,
|
|
37
|
+
readHubConfig,
|
|
38
|
+
readHubToken,
|
|
39
|
+
type HubConfig,
|
|
40
|
+
type HubGrant,
|
|
41
|
+
} from "../../shared/hub.js";
|
|
42
|
+
import { readUserConfig, writeUserConfig } from "../../shared/den-config.js";
|
|
43
|
+
import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
|
|
44
|
+
import { webRouteHosts } from "../../shared/edge/render.js";
|
|
45
|
+
import { getStateDir } from "../../shared/paths.js";
|
|
46
|
+
import { envKey, product } from "../../shared/product.js";
|
|
47
|
+
import {
|
|
48
|
+
getSecretsProvider,
|
|
49
|
+
readSecretsConfig,
|
|
50
|
+
secretsStatus,
|
|
51
|
+
type SecretRef,
|
|
52
|
+
} from "../../shared/secrets/index.js";
|
|
53
|
+
|
|
54
|
+
/** How long a started connect waits for Hub to send the person back. */
|
|
55
|
+
export const CONNECT_TTL_MS = 10 * 60_000;
|
|
56
|
+
/** At most this many connects waiting at once (oldest dropped first). */
|
|
57
|
+
const MAX_PENDING = 16;
|
|
58
|
+
|
|
59
|
+
interface Pending {
|
|
60
|
+
verifier: string;
|
|
61
|
+
hubUrl: string;
|
|
62
|
+
redirectUri: string;
|
|
63
|
+
at: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What hub.token becomes after a connect: a store ref, or a file source. */
|
|
67
|
+
export type StoredToken =
|
|
68
|
+
| { kind: "ref"; ref: Partial<SecretRef>; where: string }
|
|
69
|
+
| { kind: "file"; source: string; where: string };
|
|
70
|
+
|
|
71
|
+
export interface HubConnectDeps {
|
|
72
|
+
now: () => number;
|
|
73
|
+
/** hub.* as configured now (a distribution may preset hub.url). */
|
|
74
|
+
config: () => HubConfig | null;
|
|
75
|
+
exchange: typeof hubExchange;
|
|
76
|
+
revoke: typeof hubRevoke;
|
|
77
|
+
/** Keep the token for this install; what hub.token should say. */
|
|
78
|
+
store: (token: string) => Promise<StoredToken>;
|
|
79
|
+
/** Remove a token Connect stored (best effort). */
|
|
80
|
+
forget: (cfg: HubConfig) => Promise<void>;
|
|
81
|
+
readToken: (cfg: HubConfig) => Promise<string | null>;
|
|
82
|
+
readUser: () => Record<string, unknown>;
|
|
83
|
+
writeUser: (u: Record<string, unknown>) => void;
|
|
84
|
+
/** This install's label on Hub (1–40 characters). */
|
|
85
|
+
installLabel: () => string;
|
|
86
|
+
/**
|
|
87
|
+
* This Den's https edge address, when it has one (Connect's den_url:
|
|
88
|
+
* where Hub links back to it when the browser came over loopback).
|
|
89
|
+
*/
|
|
90
|
+
denUrl?: () => string | undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A `<name>.localhost` host: this machine (RFC 6761). */
|
|
94
|
+
export const isDotLocalhost = (host: string) =>
|
|
95
|
+
host.toLowerCase().endsWith(".localhost");
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `u` as an origin Hub takes for den_url, else null: plain (no user, path,
|
|
99
|
+
* query or hash), and https or http to a `<name>.localhost`.
|
|
100
|
+
*/
|
|
101
|
+
export function denUrlOrigin(u: string): string | null {
|
|
102
|
+
let x: URL;
|
|
103
|
+
try {
|
|
104
|
+
x = new URL(u);
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (
|
|
109
|
+
!(
|
|
110
|
+
x.protocol === "https:" ||
|
|
111
|
+
(x.protocol === "http:" && isDotLocalhost(x.hostname))
|
|
112
|
+
) ||
|
|
113
|
+
x.username ||
|
|
114
|
+
x.password ||
|
|
115
|
+
(x.pathname !== "/" && x.pathname !== "") ||
|
|
116
|
+
x.search ||
|
|
117
|
+
x.hash
|
|
118
|
+
)
|
|
119
|
+
return null;
|
|
120
|
+
return x.origin;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Of edge addresses, the den_url to send: https first, else a .localhost one. */
|
|
124
|
+
export function pickDenUrl(urls: readonly string[]): string | undefined {
|
|
125
|
+
const all = urls.map((u) => denUrlOrigin(u)).filter((o): o is string => !!o);
|
|
126
|
+
return all.find((o) => o.startsWith("https:")) ?? all[0];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** This server's edge address for den_url (pickDenUrl of its edge routes). */
|
|
130
|
+
export function edgeDenUrl(
|
|
131
|
+
port = Number(process.env.PORT) || 0,
|
|
132
|
+
): string | undefined {
|
|
133
|
+
try {
|
|
134
|
+
const ports = new Set([readWebPort(), ...(port ? [port] : [])]);
|
|
135
|
+
return pickDenUrl(webRouteHosts(readEdgeConfig(), ports).map((r) => r.url));
|
|
136
|
+
} catch {
|
|
137
|
+
/* no edge config: none */
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `<cli> on <machine>`: how Hub lists this install (Hub's label rule). */
|
|
143
|
+
export function defaultInstallLabel(
|
|
144
|
+
cli = product().cli,
|
|
145
|
+
host = os.hostname(),
|
|
146
|
+
): string {
|
|
147
|
+
const machine = host.split(".")[0] || "this machine";
|
|
148
|
+
// Hub's rule: 1–40 of [A-Za-z0-9 ._-], starting with a letter or digit.
|
|
149
|
+
const label = `${cli} on ${machine}`
|
|
150
|
+
.replace(/[^A-Za-z0-9 ._-]/g, "-")
|
|
151
|
+
.replace(/^[^A-Za-z0-9]+/, "")
|
|
152
|
+
.slice(0, 40)
|
|
153
|
+
.trimEnd();
|
|
154
|
+
return label || "den";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The file Den keeps an install's Hub token in, when its store can't. */
|
|
158
|
+
export function hubTokenFile(stateDir = getStateDir()): string {
|
|
159
|
+
return path.join(stateDir, "hub", "token");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Write the token to its 0600 file (folder 0700), replacing any old one. */
|
|
163
|
+
function writeTokenFile(file: string, token: string): void {
|
|
164
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
165
|
+
fs.chmodSync(path.dirname(file), 0o700);
|
|
166
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
167
|
+
fs.writeFileSync(tmp, `${token}\n`, { mode: 0o600 });
|
|
168
|
+
fs.chmodSync(tmp, 0o600);
|
|
169
|
+
fs.renameSync(tmp, file);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The install's store, when it keeps secrets: a 1Password item or a Doppler
|
|
174
|
+
* key, named from the distribution (never Den's own vault). Null for env
|
|
175
|
+
* (gone at restart) and none, and for a backend nobody chose.
|
|
176
|
+
*/
|
|
177
|
+
export function storeRefFor(backend: string): Partial<SecretRef> | null {
|
|
178
|
+
switch (backend) {
|
|
179
|
+
case "1password":
|
|
180
|
+
return { item: `${product().title} Hub token`, field: "token" };
|
|
181
|
+
case "doppler":
|
|
182
|
+
return { key: envKey("HUB_TOKEN") };
|
|
183
|
+
default:
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function defaultStore(token: string): Promise<StoredToken> {
|
|
189
|
+
const st = secretsStatus(readSecretsConfig(), process.env);
|
|
190
|
+
const ref =
|
|
191
|
+
!st.implicit && st.detect.available ? storeRefFor(st.backend) : null;
|
|
192
|
+
if (ref) {
|
|
193
|
+
const full: SecretRef = {
|
|
194
|
+
vault: ref.vault ?? "",
|
|
195
|
+
item: ref.item ?? "",
|
|
196
|
+
field: ref.field ?? ref.key ?? "",
|
|
197
|
+
...(ref.key ? { key: ref.key } : {}),
|
|
198
|
+
};
|
|
199
|
+
try {
|
|
200
|
+
await getSecretsProvider().set(full, token);
|
|
201
|
+
return {
|
|
202
|
+
kind: "ref",
|
|
203
|
+
ref,
|
|
204
|
+
where: ref.item
|
|
205
|
+
? `${st.label}: ${ref.item}`
|
|
206
|
+
: `${st.label}: ${ref.key}`,
|
|
207
|
+
};
|
|
208
|
+
} catch {
|
|
209
|
+
// The store refused (signed out, locked): keep it in the file instead.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const file = hubTokenFile();
|
|
213
|
+
writeTokenFile(file, token);
|
|
214
|
+
return { kind: "file", source: `file:${file}`, where: file };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function defaultForget(cfg: HubConfig): Promise<void> {
|
|
218
|
+
if (cfg.tokenSource?.kind === "file") {
|
|
219
|
+
// Only the file Connect made: never a path a distribution chose.
|
|
220
|
+
if (path.resolve(cfg.tokenSource.path) === path.resolve(hubTokenFile()))
|
|
221
|
+
fs.rmSync(hubTokenFile(), { force: true });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (cfg.tokenRef) {
|
|
225
|
+
// Stores have no delete here: blank the value (the token is revoked).
|
|
226
|
+
const st = secretsStatus(readSecretsConfig(), process.env);
|
|
227
|
+
const ours = storeRefFor(st.backend);
|
|
228
|
+
const same =
|
|
229
|
+
!!ours &&
|
|
230
|
+
(ours.item
|
|
231
|
+
? cfg.tokenRef.item === ours.item
|
|
232
|
+
: cfg.tokenRef.key === ours.key);
|
|
233
|
+
if (same)
|
|
234
|
+
await getSecretsProvider()
|
|
235
|
+
.set(cfg.tokenRef, " ")
|
|
236
|
+
.catch(() => undefined);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function defaultHubConnectDeps(): HubConnectDeps {
|
|
241
|
+
return {
|
|
242
|
+
now: () => Date.now(),
|
|
243
|
+
config: () => {
|
|
244
|
+
try {
|
|
245
|
+
return readHubConfig();
|
|
246
|
+
} catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
exchange: hubExchange,
|
|
251
|
+
revoke: hubRevoke,
|
|
252
|
+
store: defaultStore,
|
|
253
|
+
forget: defaultForget,
|
|
254
|
+
readToken: (c) => readHubToken(c),
|
|
255
|
+
readUser: () => readUserConfig() as Record<string, unknown>,
|
|
256
|
+
writeUser: (u) => writeUserConfig(u as never),
|
|
257
|
+
installLabel: () => defaultInstallLabel(),
|
|
258
|
+
denUrl: () => edgeDenUrl(),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** An origin Hub will send the person back to: https, or loopback http (incl. *.localhost). */
|
|
263
|
+
export function callbackOriginProblem(origin: string): string | null {
|
|
264
|
+
let u: URL;
|
|
265
|
+
try {
|
|
266
|
+
u = new URL(origin);
|
|
267
|
+
} catch {
|
|
268
|
+
return "this page's address isn't one Hub can send you back to";
|
|
269
|
+
}
|
|
270
|
+
// Loopback: localhost, 127.0.0.1, and any <name>.localhost (RFC 6761:
|
|
271
|
+
// it never leaves this machine, and browsers count it a secure context),
|
|
272
|
+
// which is how an edge on this machine is often reached over http.
|
|
273
|
+
const h = u.hostname.toLowerCase();
|
|
274
|
+
const loop = h === "localhost" || h === "127.0.0.1" || isDotLocalhost(h);
|
|
275
|
+
if (u.protocol === "https:" || (u.protocol === "http:" && loop)) return null;
|
|
276
|
+
return `Hub can only send you back to https or to this machine (localhost or a .localhost name); open ${product().title} over https (its edge address) or on this machine, then Connect`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export type ConnectResult =
|
|
280
|
+
| { ok: true; team?: string; where: string }
|
|
281
|
+
| { ok: false; reason: "denied" | "expired" | "failed"; message: string };
|
|
282
|
+
|
|
283
|
+
export class HubConnect {
|
|
284
|
+
private readonly pending = new Map<string, Pending>();
|
|
285
|
+
constructor(readonly deps: HubConnectDeps = defaultHubConnectDeps()) {}
|
|
286
|
+
|
|
287
|
+
private sweep(): void {
|
|
288
|
+
const now = this.deps.now();
|
|
289
|
+
for (const [k, v] of this.pending)
|
|
290
|
+
if (now - v.at > CONNECT_TTL_MS) this.pending.delete(k);
|
|
291
|
+
while (this.pending.size >= MAX_PENDING) {
|
|
292
|
+
const oldest = this.pending.keys().next().value;
|
|
293
|
+
if (oldest === undefined) break;
|
|
294
|
+
this.pending.delete(oldest);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Step 1: where to send the browser. `origin` is this Den as the browser
|
|
300
|
+
* sees it (the request's checked Origin); `url` a Hub the person typed,
|
|
301
|
+
* else hub.url.
|
|
302
|
+
*/
|
|
303
|
+
start(
|
|
304
|
+
origin: string,
|
|
305
|
+
url?: unknown,
|
|
306
|
+
): { ok: true; location: string } | { ok: false; error: string } {
|
|
307
|
+
const typed = typeof url === "string" ? url.trim().replace(/\/+$/, "") : "";
|
|
308
|
+
const hubUrl = typed || this.deps.config()?.url || "";
|
|
309
|
+
if (!hubUrl) return { ok: false, error: "type Hub's address first" };
|
|
310
|
+
const bad = hubUrlProblem(hubUrl);
|
|
311
|
+
if (bad)
|
|
312
|
+
return { ok: false, error: bad.replace("hub.url", "Hub's address") };
|
|
313
|
+
const badOrigin = callbackOriginProblem(origin);
|
|
314
|
+
if (badOrigin) return { ok: false, error: badOrigin };
|
|
315
|
+
this.sweep();
|
|
316
|
+
const state = newHubState();
|
|
317
|
+
const { verifier, challenge } = newPkce();
|
|
318
|
+
const redirectUri = `${new URL(origin).origin}/hub/callback`;
|
|
319
|
+
this.pending.set(state, {
|
|
320
|
+
verifier,
|
|
321
|
+
hubUrl,
|
|
322
|
+
redirectUri,
|
|
323
|
+
at: this.deps.now(),
|
|
324
|
+
});
|
|
325
|
+
const q = new URLSearchParams({
|
|
326
|
+
install: this.deps.installLabel(),
|
|
327
|
+
redirect_uri: redirectUri,
|
|
328
|
+
state,
|
|
329
|
+
code_challenge: challenge,
|
|
330
|
+
code_challenge_method: "S256",
|
|
331
|
+
});
|
|
332
|
+
// Hub takes only a plain https or <name>.localhost address (anything
|
|
333
|
+
// else is a 400), and uses it only when redirect_uri is loopback. A
|
|
334
|
+
// reconnect sends it again.
|
|
335
|
+
const denUrl = denUrlOrigin(this.deps.denUrl?.() ?? "");
|
|
336
|
+
if (denUrl) q.set("den_url", denUrl);
|
|
337
|
+
return { ok: true, location: `${hubUrl}/connect?${q}` };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Steps 2–3: Hub sent the person back. The state is used once, whatever
|
|
342
|
+
* happens. On success the token is stored and hub.* written to user.yml.
|
|
343
|
+
* The result names what happened in words (never the token or the code).
|
|
344
|
+
*/
|
|
345
|
+
async finish(q: URLSearchParams): Promise<ConnectResult> {
|
|
346
|
+
const r = await this.finishOnce(q);
|
|
347
|
+
this.last = {
|
|
348
|
+
ok: r.ok,
|
|
349
|
+
message: r.ok
|
|
350
|
+
? `Connected to Hub${r.team ? ` (${r.team})` : ""}.`
|
|
351
|
+
: r.message,
|
|
352
|
+
at: this.deps.now(),
|
|
353
|
+
};
|
|
354
|
+
return r;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* What the last Connect came to, in words, read once (Settings shows it
|
|
359
|
+
* after Hub's redirect; the callback page only flashes by). Never the
|
|
360
|
+
* token or the code.
|
|
361
|
+
*/
|
|
362
|
+
lastResult(): { ok: boolean; message: string } | null {
|
|
363
|
+
const l = this.last;
|
|
364
|
+
this.last = null;
|
|
365
|
+
if (!l || this.deps.now() - l.at > CONNECT_TTL_MS) return null;
|
|
366
|
+
return { ok: l.ok, message: l.message };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
private last: { ok: boolean; message: string; at: number } | null = null;
|
|
370
|
+
|
|
371
|
+
private async finishOnce(q: URLSearchParams): Promise<ConnectResult> {
|
|
372
|
+
const state = q.get("state") ?? "";
|
|
373
|
+
const p = this.pending.get(state);
|
|
374
|
+
this.pending.delete(state);
|
|
375
|
+
if (!p || this.deps.now() - p.at > CONNECT_TTL_MS)
|
|
376
|
+
return {
|
|
377
|
+
ok: false,
|
|
378
|
+
reason: "expired",
|
|
379
|
+
message: "That connect link is used or too old: press Connect again.",
|
|
380
|
+
};
|
|
381
|
+
if (q.get("error"))
|
|
382
|
+
return {
|
|
383
|
+
ok: false,
|
|
384
|
+
reason: "denied",
|
|
385
|
+
message:
|
|
386
|
+
q.get("error") === "access_denied"
|
|
387
|
+
? "Not connected: the request was declined on Hub."
|
|
388
|
+
: "Not connected: Hub didn't finish the connect.",
|
|
389
|
+
};
|
|
390
|
+
const code = q.get("code") ?? "";
|
|
391
|
+
if (!code)
|
|
392
|
+
return { ok: false, reason: "failed", message: "Hub sent no code back." };
|
|
393
|
+
let grant: HubGrant;
|
|
394
|
+
try {
|
|
395
|
+
grant = await this.deps.exchange(p.hubUrl, {
|
|
396
|
+
code,
|
|
397
|
+
redirectUri: p.redirectUri,
|
|
398
|
+
verifier: p.verifier,
|
|
399
|
+
});
|
|
400
|
+
} catch (e) {
|
|
401
|
+
const m =
|
|
402
|
+
e instanceof HubCallError &&
|
|
403
|
+
e.kind === "refused" &&
|
|
404
|
+
e.code === "invalid_grant"
|
|
405
|
+
? "Hub didn't accept the code (used, expired or for another address): press Connect again."
|
|
406
|
+
: `Hub didn't give a token: ${(e as Error).message}`;
|
|
407
|
+
return { ok: false, reason: "failed", message: m };
|
|
408
|
+
}
|
|
409
|
+
const hubUrl =
|
|
410
|
+
grant.hubUrl && !hubUrlProblem(grant.hubUrl) ? grant.hubUrl : p.hubUrl;
|
|
411
|
+
let stored: StoredToken;
|
|
412
|
+
try {
|
|
413
|
+
stored = await this.deps.store(grant.token);
|
|
414
|
+
} catch (e) {
|
|
415
|
+
// Don't leave a live token nobody can find: revoke it.
|
|
416
|
+
await this.deps.revoke(hubUrl, grant.token).catch(() => undefined);
|
|
417
|
+
return {
|
|
418
|
+
ok: false,
|
|
419
|
+
reason: "failed",
|
|
420
|
+
message: `Connected, but the token couldn't be kept here (${(e as Error).message}); it was revoked. Press Connect again.`,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
const user = this.deps.readUser();
|
|
424
|
+
const hub = {
|
|
425
|
+
...((user.hub as Record<string, unknown> | undefined) ?? {}),
|
|
426
|
+
};
|
|
427
|
+
hub.url = hubUrl;
|
|
428
|
+
hub.token = stored.kind === "ref" ? stored.ref : stored.source;
|
|
429
|
+
if (grant.mcpUrl && !hubUrlProblem(grant.mcpUrl))
|
|
430
|
+
hub.mcp_url = grant.mcpUrl;
|
|
431
|
+
else delete hub.mcp_url;
|
|
432
|
+
this.deps.writeUser({ ...user, hub });
|
|
433
|
+
return {
|
|
434
|
+
ok: true,
|
|
435
|
+
...(grant.company?.name ? { team: grant.company.name } : {}),
|
|
436
|
+
where: stored.where,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Step 4: revoke on Hub, then remove it here and clear hub.token (hub.url
|
|
442
|
+
* stays, so Connect is one click). Hub unreachable: nothing is removed,
|
|
443
|
+
* so it can be tried again; `force` removes it here anyway and says Hub
|
|
444
|
+
* still has it.
|
|
445
|
+
*/
|
|
446
|
+
async disconnect(
|
|
447
|
+
force = false,
|
|
448
|
+
): Promise<
|
|
449
|
+
| { ok: true; revoked: boolean; message: string }
|
|
450
|
+
| { ok: false; error: string }
|
|
451
|
+
> {
|
|
452
|
+
const cfg = this.deps.config();
|
|
453
|
+
if (!cfg || (!cfg.tokenRef && !cfg.tokenSource))
|
|
454
|
+
return { ok: true, revoked: false, message: "Not connected." };
|
|
455
|
+
const token = await this.deps.readToken(cfg);
|
|
456
|
+
let revoked = false;
|
|
457
|
+
let note = "";
|
|
458
|
+
if (token) {
|
|
459
|
+
try {
|
|
460
|
+
revoked = await this.deps.revoke(cfg.url, token);
|
|
461
|
+
if (!revoked) note = " Hub had already revoked it.";
|
|
462
|
+
} catch (e) {
|
|
463
|
+
if (!force)
|
|
464
|
+
return {
|
|
465
|
+
ok: false,
|
|
466
|
+
error: `Hub couldn't revoke the token (${(e as Error).message}). Nothing was removed. Try again, or remove it here anyway.`,
|
|
467
|
+
};
|
|
468
|
+
note =
|
|
469
|
+
" Hub couldn't be reached, so the token still works there until an admin revokes this install.";
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
await this.deps.forget(cfg).catch(() => undefined);
|
|
473
|
+
const user = this.deps.readUser();
|
|
474
|
+
const hub = {
|
|
475
|
+
...((user.hub as Record<string, unknown> | undefined) ?? {}),
|
|
476
|
+
};
|
|
477
|
+
delete hub.token;
|
|
478
|
+
delete hub.mcp_url;
|
|
479
|
+
if (Object.keys(hub).length) user.hub = hub;
|
|
480
|
+
else delete user.hub;
|
|
481
|
+
this.deps.writeUser(user);
|
|
482
|
+
return {
|
|
483
|
+
ok: true,
|
|
484
|
+
revoked,
|
|
485
|
+
message: `Disconnected.${revoked ? " Hub revoked this install's token." : ""}${note}`,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The page /hub/callback answers: it moves on to Settings by script. */
|
|
491
|
+
export function callbackPage(
|
|
492
|
+
title: string,
|
|
493
|
+
result: { ok: boolean; message: string; reason?: string },
|
|
494
|
+
): string {
|
|
495
|
+
const next = `/settings?hub=${result.ok ? "connected" : (result.reason ?? "failed")}#agents.hub`;
|
|
496
|
+
const esc = (s: string) =>
|
|
497
|
+
s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
498
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>${esc(title)}: Hub</title></head><body style="font:15px system-ui,sans-serif;margin:2rem;max-width:40rem"><p data-hub-result="${result.ok ? "connected" : (result.reason ?? "failed")}">${esc(result.message)}</p><p><a href="${next}">Back to Settings</a></p><script>location.replace(${JSON.stringify(next)})</script></body></html>`;
|
|
499
|
+
}
|
package/src/ui/server/index.ts
CHANGED
|
@@ -895,6 +895,7 @@ import {
|
|
|
895
895
|
handleSessionUpgrade,
|
|
896
896
|
isSessionIoPath,
|
|
897
897
|
sendSessionHttp,
|
|
898
|
+
SESSION_HELPERS_PATH,
|
|
898
899
|
} from "./session-routes.js";
|
|
899
900
|
import { refuseUpgrade } from "./websocket.js";
|
|
900
901
|
import {
|
|
@@ -2124,7 +2125,12 @@ async function handleRequest(
|
|
|
2124
2125
|
// AG-4a-term's session routes (`/api/sessions/<id>/io`): shell-grade, and
|
|
2125
2126
|
// an agent's token reaches its own session's only (request-guard). The io
|
|
2126
2127
|
// is a WebSocket (the server's upgrade handler): a plain GET of it is 426.
|
|
2127
|
-
|
|
2128
|
+
// D32's /api/sessions/helpers (a count, read-grade) is not a session's:
|
|
2129
|
+
// the list's handler below answers it.
|
|
2130
|
+
if (
|
|
2131
|
+
pathname.startsWith("/api/sessions/") &&
|
|
2132
|
+
pathname !== SESSION_HELPERS_PATH
|
|
2133
|
+
) {
|
|
2128
2134
|
await sendSessionHttp(req, res, pathname);
|
|
2129
2135
|
return;
|
|
2130
2136
|
}
|