@monster-orange/den 0.4.26 → 0.4.27
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 +41 -0
- package/docs/distributions.md +74 -20
- package/package.json +1 -1
- package/src/shared/agents/runtime.ts +2 -2
- package/src/shared/hub.ts +304 -82
- package/src/shared/secrets/onepassword.ts +52 -15
- package/src/ui/cli/agent-ops.ts +17 -3
- package/src/ui/cli/doctor-command.ts +11 -2
- package/src/ui/server/agent-routes.ts +79 -1
- package/src/ui/server/hub-connect.ts +433 -0
- package/src/ui/server/public/i18n/es.js +2 -0
- package/src/ui/server/public/i18n/ja.js +2 -0
- package/src/ui/server/public/settings-registry.js +196 -33
- package/src/ui/server/public/settings.html +23 -0
- package/src/ui/server/public/shell.js +1 -0
- package/src/ui/server/public/topbar.js +5 -1
- package/src/ui/server/route-classes.ts +17 -0
package/src/ui/cli/agent-ops.ts
CHANGED
|
@@ -265,6 +265,8 @@ export interface AgentsDeps extends SessionsDeps {
|
|
|
265
265
|
url: string;
|
|
266
266
|
token: string;
|
|
267
267
|
allowTools?: boolean;
|
|
268
|
+
/** HUB-1.7: Hub's MCP endpoint (default <url>/mcp). */
|
|
269
|
+
mcpUrl?: string;
|
|
268
270
|
} | null>;
|
|
269
271
|
/** HUB-1.5: workspace `ws`'s repos as owner/repo (X-Den-Repos). */
|
|
270
272
|
workspaceRepos?: (ws: string) => string[];
|
|
@@ -288,6 +290,7 @@ export interface AgentsResolved extends Resolved {
|
|
|
288
290
|
url: string;
|
|
289
291
|
token: string;
|
|
290
292
|
allowTools?: boolean;
|
|
293
|
+
mcpUrl?: string;
|
|
291
294
|
} | null>;
|
|
292
295
|
workspaceRepos: (ws: string) => string[];
|
|
293
296
|
}
|
|
@@ -395,6 +398,7 @@ export async function agentHub(
|
|
|
395
398
|
d: AgentsResolved,
|
|
396
399
|
ws: string,
|
|
397
400
|
name: string,
|
|
401
|
+
agentId?: string,
|
|
398
402
|
): Promise<AgentMcp["hub"] | undefined> {
|
|
399
403
|
const h = await d.hub().catch(() => null);
|
|
400
404
|
if (!h) return undefined;
|
|
@@ -407,10 +411,11 @@ export async function agentHub(
|
|
|
407
411
|
}
|
|
408
412
|
return {
|
|
409
413
|
name: HUB_SERVER,
|
|
410
|
-
url: h.url
|
|
414
|
+
url: h.mcpUrl || `${h.url}/mcp`,
|
|
411
415
|
allowTools: h.allowTools !== false,
|
|
412
416
|
headers: hubHeaders(h.token, {
|
|
413
417
|
agent: name,
|
|
418
|
+
...(agentId ? { agentId } : {}),
|
|
414
419
|
session: crypto.randomUUID(),
|
|
415
420
|
workspace: ws,
|
|
416
421
|
repos,
|
|
@@ -480,6 +485,7 @@ async function defaultHub(): Promise<{
|
|
|
480
485
|
url: string;
|
|
481
486
|
token: string;
|
|
482
487
|
allowTools: boolean;
|
|
488
|
+
mcpUrl: string;
|
|
483
489
|
} | null> {
|
|
484
490
|
const { readHubConfig, readHubToken } = await import("../../shared/hub.js");
|
|
485
491
|
let cfg;
|
|
@@ -490,7 +496,9 @@ async function defaultHub(): Promise<{
|
|
|
490
496
|
}
|
|
491
497
|
if (!cfg) return null;
|
|
492
498
|
const token = await readHubToken(cfg);
|
|
493
|
-
return token
|
|
499
|
+
return token
|
|
500
|
+
? { url: cfg.url, token, allowTools: cfg.allowTools, mcpUrl: cfg.mcpUrl }
|
|
501
|
+
: null;
|
|
494
502
|
}
|
|
495
503
|
|
|
496
504
|
/**
|
|
@@ -1901,6 +1909,7 @@ export async function startAgent(
|
|
|
1901
1909
|
d,
|
|
1902
1910
|
ws,
|
|
1903
1911
|
given ?? prev?.displayName ?? persona.displayName ?? id,
|
|
1912
|
+
id,
|
|
1904
1913
|
);
|
|
1905
1914
|
const input = {
|
|
1906
1915
|
...base,
|
|
@@ -2443,7 +2452,12 @@ export async function resumeAgent(
|
|
|
2443
2452
|
// Resume in the cwd the agent had, wherever the workspace points now.
|
|
2444
2453
|
// D24 (and D25: a rename since) the name peers see it by.
|
|
2445
2454
|
const peerName = peerSessionName(d.cli, rec.workspace, rec);
|
|
2446
|
-
const hub = await agentHub(
|
|
2455
|
+
const hub = await agentHub(
|
|
2456
|
+
d,
|
|
2457
|
+
rec.workspace,
|
|
2458
|
+
rec.displayName ?? rec.id,
|
|
2459
|
+
rec.id,
|
|
2460
|
+
);
|
|
2447
2461
|
const at = {
|
|
2448
2462
|
...input,
|
|
2449
2463
|
...(hub ? { mcp: { ...input.mcp, hub } } : {}),
|
|
@@ -482,12 +482,21 @@ export function hubLines(
|
|
|
482
482
|
title: string,
|
|
483
483
|
): { lines: string[]; problem: boolean; check?: OnboardCheck } {
|
|
484
484
|
if (!st.configured) return { lines: [], problem: false };
|
|
485
|
-
const who = [
|
|
485
|
+
const who = [
|
|
486
|
+
st.team && `company ${st.team}`,
|
|
487
|
+
st.label && `install ${st.label}`,
|
|
488
|
+
st.role && `role ${st.role}`,
|
|
489
|
+
]
|
|
486
490
|
.filter(Boolean)
|
|
487
491
|
.join(", ");
|
|
492
|
+
// HUB-1.7: what to do about it is in Settings (Connect, Reconnect).
|
|
493
|
+
const fix =
|
|
494
|
+
st.state === "no_token" || st.state === "rejected"
|
|
495
|
+
? ` (Settings › Agents › Hub: ${st.state === "rejected" ? "Reconnect" : "Connect"})`
|
|
496
|
+
: "";
|
|
488
497
|
const detail = st.connected
|
|
489
498
|
? `${st.url}: connected${who ? ` (${who})` : ""}`
|
|
490
|
-
: `${st.url || "hub.url"}: not connected: ${st.problem ?? "no answer"}`;
|
|
499
|
+
: `${st.url || "hub.url"}: not connected: ${st.problem ?? "no answer"}${fix}`;
|
|
491
500
|
return {
|
|
492
501
|
lines: [
|
|
493
502
|
"",
|
|
@@ -164,6 +164,7 @@ import {
|
|
|
164
164
|
agentResumeRefusal,
|
|
165
165
|
} from "./agent-resume-grant.js";
|
|
166
166
|
import { readBody, sendJson } from "./http-helpers.js";
|
|
167
|
+
import { product } from "../../shared/product.js";
|
|
167
168
|
import type { Actor } from "../../shared/agent-actor.js";
|
|
168
169
|
|
|
169
170
|
export interface AgentRouteDeps {
|
|
@@ -175,6 +176,8 @@ export interface AgentRouteDeps {
|
|
|
175
176
|
personaWait?: { pollMs?: number; maxMs?: number };
|
|
176
177
|
/** HUB-1.5: GET /api/hub's answer (tests: a fake Hub). */
|
|
177
178
|
hubStatus?: () => Promise<import("../../shared/hub.js").HubStatus>;
|
|
179
|
+
/** HUB-1.7: Connect to Hub's state (tests: fake Hub calls and store). */
|
|
180
|
+
hubConnect?: import("./hub-connect.js").HubConnect;
|
|
178
181
|
}
|
|
179
182
|
|
|
180
183
|
const AGENTS_PATH =
|
|
@@ -434,9 +437,19 @@ export async function handleAgentRoutes(
|
|
|
434
437
|
if (method !== "GET" && method !== "HEAD")
|
|
435
438
|
return methodNotAllowed(res, "GET");
|
|
436
439
|
const { hubStatus } = await import("../../shared/hub.js");
|
|
437
|
-
|
|
440
|
+
const st = await (deps.hubStatus ?? (() => hubStatus()))();
|
|
441
|
+
// HUB-1.7: what the last Connect came to, once (words only).
|
|
442
|
+
const last = (deps.hubConnect ?? defaultConnect)?.lastResult();
|
|
443
|
+
sendJson(res, last ? { ...st, connect: last } : st);
|
|
438
444
|
return true;
|
|
439
445
|
}
|
|
446
|
+
// HUB-1.7: Connect to Hub, and its callback (see hub-connect.ts).
|
|
447
|
+
if (
|
|
448
|
+
pathname === "/api/hub/connect" ||
|
|
449
|
+
pathname === "/api/hub/disconnect" ||
|
|
450
|
+
pathname === "/hub/callback"
|
|
451
|
+
)
|
|
452
|
+
return hubConnectRoute(req, res, pathname, deps);
|
|
440
453
|
if (
|
|
441
454
|
pathname !== "/api/personas" &&
|
|
442
455
|
pathname !== "/api/agents/runtimes" &&
|
|
@@ -1381,3 +1394,68 @@ async function rotationRoute(
|
|
|
1381
1394
|
}
|
|
1382
1395
|
return true;
|
|
1383
1396
|
}
|
|
1397
|
+
|
|
1398
|
+
let defaultConnect: import("./hub-connect.js").HubConnect | null = null;
|
|
1399
|
+
|
|
1400
|
+
/** HUB-1.7: start a connect, finish it (Hub's redirect), or disconnect. */
|
|
1401
|
+
async function hubConnectRoute(
|
|
1402
|
+
req: http.IncomingMessage,
|
|
1403
|
+
res: http.ServerResponse,
|
|
1404
|
+
pathname: string,
|
|
1405
|
+
deps: AgentRouteDeps,
|
|
1406
|
+
): Promise<true> {
|
|
1407
|
+
const m = await import("./hub-connect.js");
|
|
1408
|
+
const hc = deps.hubConnect ?? (defaultConnect ??= new m.HubConnect());
|
|
1409
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1410
|
+
const method = req.method ?? "GET";
|
|
1411
|
+
if (pathname === "/hub/callback") {
|
|
1412
|
+
if (method !== "GET") return methodNotAllowed(res, "GET");
|
|
1413
|
+
const q = new URL(req.url ?? "/", "http://localhost").searchParams;
|
|
1414
|
+
const r = await hc.finish(q);
|
|
1415
|
+
res.writeHead(r.ok ? 200 : 400, {
|
|
1416
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1417
|
+
"Referrer-Policy": "no-referrer",
|
|
1418
|
+
"Cache-Control": "no-store",
|
|
1419
|
+
});
|
|
1420
|
+
res.end(
|
|
1421
|
+
m.callbackPage(product().title, {
|
|
1422
|
+
ok: r.ok,
|
|
1423
|
+
...(r.ok ? {} : { reason: r.reason }),
|
|
1424
|
+
message: r.ok
|
|
1425
|
+
? `Connected to Hub${r.team ? ` (${r.team})` : ""}. Going back to Settings…`
|
|
1426
|
+
: r.message,
|
|
1427
|
+
}),
|
|
1428
|
+
);
|
|
1429
|
+
return true;
|
|
1430
|
+
}
|
|
1431
|
+
if (method !== "POST") return methodNotAllowed(res, "POST");
|
|
1432
|
+
let body: Record<string, unknown> = {};
|
|
1433
|
+
try {
|
|
1434
|
+
const raw = await readBody(req);
|
|
1435
|
+
body = raw ? ((JSON.parse(raw) as Record<string, unknown>) ?? {}) : {};
|
|
1436
|
+
} catch {
|
|
1437
|
+
sendJson(res, { ok: false, error: "the body must be JSON" }, 400);
|
|
1438
|
+
return true;
|
|
1439
|
+
}
|
|
1440
|
+
if (pathname === "/api/hub/connect") {
|
|
1441
|
+
// The browser's Origin (request-guard checked it is this Den's).
|
|
1442
|
+
const origin = String(req.headers.origin ?? "");
|
|
1443
|
+
if (!origin) {
|
|
1444
|
+
sendJson(
|
|
1445
|
+
res,
|
|
1446
|
+
{
|
|
1447
|
+
ok: false,
|
|
1448
|
+
error: "Connect from Settings › Agents › Hub in a browser",
|
|
1449
|
+
},
|
|
1450
|
+
400,
|
|
1451
|
+
);
|
|
1452
|
+
return true;
|
|
1453
|
+
}
|
|
1454
|
+
const r = hc.start(origin, body.url);
|
|
1455
|
+
sendJson(res, r, r.ok ? 200 : 400);
|
|
1456
|
+
return true;
|
|
1457
|
+
}
|
|
1458
|
+
const r = await hc.disconnect(body.force === true);
|
|
1459
|
+
sendJson(res, r, r.ok ? 200 : 502);
|
|
1460
|
+
return true;
|
|
1461
|
+
}
|
|
@@ -0,0 +1,433 @@
|
|
|
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 { getStateDir } from "../../shared/paths.js";
|
|
44
|
+
import { envKey, product } from "../../shared/product.js";
|
|
45
|
+
import {
|
|
46
|
+
getSecretsProvider,
|
|
47
|
+
readSecretsConfig,
|
|
48
|
+
secretsStatus,
|
|
49
|
+
type SecretRef,
|
|
50
|
+
} from "../../shared/secrets/index.js";
|
|
51
|
+
|
|
52
|
+
/** How long a started connect waits for Hub to send the person back. */
|
|
53
|
+
export const CONNECT_TTL_MS = 10 * 60_000;
|
|
54
|
+
/** At most this many connects waiting at once (oldest dropped first). */
|
|
55
|
+
const MAX_PENDING = 16;
|
|
56
|
+
|
|
57
|
+
interface Pending {
|
|
58
|
+
verifier: string;
|
|
59
|
+
hubUrl: string;
|
|
60
|
+
redirectUri: string;
|
|
61
|
+
at: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What hub.token becomes after a connect: a store ref, or a file source. */
|
|
65
|
+
export type StoredToken =
|
|
66
|
+
| { kind: "ref"; ref: Partial<SecretRef>; where: string }
|
|
67
|
+
| { kind: "file"; source: string; where: string };
|
|
68
|
+
|
|
69
|
+
export interface HubConnectDeps {
|
|
70
|
+
now: () => number;
|
|
71
|
+
/** hub.* as configured now (a distribution may preset hub.url). */
|
|
72
|
+
config: () => HubConfig | null;
|
|
73
|
+
exchange: typeof hubExchange;
|
|
74
|
+
revoke: typeof hubRevoke;
|
|
75
|
+
/** Keep the token for this install; what hub.token should say. */
|
|
76
|
+
store: (token: string) => Promise<StoredToken>;
|
|
77
|
+
/** Remove a token Connect stored (best effort). */
|
|
78
|
+
forget: (cfg: HubConfig) => Promise<void>;
|
|
79
|
+
readToken: (cfg: HubConfig) => Promise<string | null>;
|
|
80
|
+
readUser: () => Record<string, unknown>;
|
|
81
|
+
writeUser: (u: Record<string, unknown>) => void;
|
|
82
|
+
/** This install's label on Hub (1–40 characters). */
|
|
83
|
+
installLabel: () => string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `<cli> on <machine>`: how Hub lists this install (Hub's label rule). */
|
|
87
|
+
export function defaultInstallLabel(
|
|
88
|
+
cli = product().cli,
|
|
89
|
+
host = os.hostname(),
|
|
90
|
+
): string {
|
|
91
|
+
const machine = host.split(".")[0] || "this machine";
|
|
92
|
+
// Hub's rule: 1–40 of [A-Za-z0-9 ._-], starting with a letter or digit.
|
|
93
|
+
const label = `${cli} on ${machine}`
|
|
94
|
+
.replace(/[^A-Za-z0-9 ._-]/g, "-")
|
|
95
|
+
.replace(/^[^A-Za-z0-9]+/, "")
|
|
96
|
+
.slice(0, 40)
|
|
97
|
+
.trimEnd();
|
|
98
|
+
return label || "den";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The file Den keeps an install's Hub token in, when its store can't. */
|
|
102
|
+
export function hubTokenFile(stateDir = getStateDir()): string {
|
|
103
|
+
return path.join(stateDir, "hub", "token");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Write the token to its 0600 file (folder 0700), replacing any old one. */
|
|
107
|
+
function writeTokenFile(file: string, token: string): void {
|
|
108
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
109
|
+
fs.chmodSync(path.dirname(file), 0o700);
|
|
110
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
111
|
+
fs.writeFileSync(tmp, `${token}\n`, { mode: 0o600 });
|
|
112
|
+
fs.chmodSync(tmp, 0o600);
|
|
113
|
+
fs.renameSync(tmp, file);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The install's store, when it keeps secrets: a 1Password item or a Doppler
|
|
118
|
+
* key, named from the distribution (never Den's own vault). Null for env
|
|
119
|
+
* (gone at restart) and none, and for a backend nobody chose.
|
|
120
|
+
*/
|
|
121
|
+
export function storeRefFor(backend: string): Partial<SecretRef> | null {
|
|
122
|
+
switch (backend) {
|
|
123
|
+
case "1password":
|
|
124
|
+
return { item: `${product().title} Hub token`, field: "token" };
|
|
125
|
+
case "doppler":
|
|
126
|
+
return { key: envKey("HUB_TOKEN") };
|
|
127
|
+
default:
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function defaultStore(token: string): Promise<StoredToken> {
|
|
133
|
+
const st = secretsStatus(readSecretsConfig(), process.env);
|
|
134
|
+
const ref =
|
|
135
|
+
!st.implicit && st.detect.available ? storeRefFor(st.backend) : null;
|
|
136
|
+
if (ref) {
|
|
137
|
+
const full: SecretRef = {
|
|
138
|
+
vault: ref.vault ?? "",
|
|
139
|
+
item: ref.item ?? "",
|
|
140
|
+
field: ref.field ?? ref.key ?? "",
|
|
141
|
+
...(ref.key ? { key: ref.key } : {}),
|
|
142
|
+
};
|
|
143
|
+
try {
|
|
144
|
+
await getSecretsProvider().set(full, token);
|
|
145
|
+
return {
|
|
146
|
+
kind: "ref",
|
|
147
|
+
ref,
|
|
148
|
+
where: ref.item
|
|
149
|
+
? `${st.label}: ${ref.item}`
|
|
150
|
+
: `${st.label}: ${ref.key}`,
|
|
151
|
+
};
|
|
152
|
+
} catch {
|
|
153
|
+
// The store refused (signed out, locked): keep it in the file instead.
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const file = hubTokenFile();
|
|
157
|
+
writeTokenFile(file, token);
|
|
158
|
+
return { kind: "file", source: `file:${file}`, where: file };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function defaultForget(cfg: HubConfig): Promise<void> {
|
|
162
|
+
if (cfg.tokenSource?.kind === "file") {
|
|
163
|
+
// Only the file Connect made: never a path a distribution chose.
|
|
164
|
+
if (path.resolve(cfg.tokenSource.path) === path.resolve(hubTokenFile()))
|
|
165
|
+
fs.rmSync(hubTokenFile(), { force: true });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (cfg.tokenRef) {
|
|
169
|
+
// Stores have no delete here: blank the value (the token is revoked).
|
|
170
|
+
const st = secretsStatus(readSecretsConfig(), process.env);
|
|
171
|
+
const ours = storeRefFor(st.backend);
|
|
172
|
+
const same =
|
|
173
|
+
!!ours &&
|
|
174
|
+
(ours.item
|
|
175
|
+
? cfg.tokenRef.item === ours.item
|
|
176
|
+
: cfg.tokenRef.key === ours.key);
|
|
177
|
+
if (same)
|
|
178
|
+
await getSecretsProvider()
|
|
179
|
+
.set(cfg.tokenRef, " ")
|
|
180
|
+
.catch(() => undefined);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function defaultHubConnectDeps(): HubConnectDeps {
|
|
185
|
+
return {
|
|
186
|
+
now: () => Date.now(),
|
|
187
|
+
config: () => {
|
|
188
|
+
try {
|
|
189
|
+
return readHubConfig();
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
exchange: hubExchange,
|
|
195
|
+
revoke: hubRevoke,
|
|
196
|
+
store: defaultStore,
|
|
197
|
+
forget: defaultForget,
|
|
198
|
+
readToken: (c) => readHubToken(c),
|
|
199
|
+
readUser: () => readUserConfig() as Record<string, unknown>,
|
|
200
|
+
writeUser: (u) => writeUserConfig(u as never),
|
|
201
|
+
installLabel: () => defaultInstallLabel(),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** An origin Hub will send the person back to: https, or loopback http. */
|
|
206
|
+
export function callbackOriginProblem(origin: string): string | null {
|
|
207
|
+
let u: URL;
|
|
208
|
+
try {
|
|
209
|
+
u = new URL(origin);
|
|
210
|
+
} catch {
|
|
211
|
+
return "this page's address isn't one Hub can send you back to";
|
|
212
|
+
}
|
|
213
|
+
const loop = u.hostname === "localhost" || u.hostname === "127.0.0.1";
|
|
214
|
+
if (u.protocol === "https:" || (u.protocol === "http:" && loop)) return null;
|
|
215
|
+
return `Hub can only send you back to https or to localhost; open ${product().title} over https (its edge address) or on this machine, then Connect`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type ConnectResult =
|
|
219
|
+
| { ok: true; team?: string; where: string }
|
|
220
|
+
| { ok: false; reason: "denied" | "expired" | "failed"; message: string };
|
|
221
|
+
|
|
222
|
+
export class HubConnect {
|
|
223
|
+
private readonly pending = new Map<string, Pending>();
|
|
224
|
+
constructor(readonly deps: HubConnectDeps = defaultHubConnectDeps()) {}
|
|
225
|
+
|
|
226
|
+
private sweep(): void {
|
|
227
|
+
const now = this.deps.now();
|
|
228
|
+
for (const [k, v] of this.pending)
|
|
229
|
+
if (now - v.at > CONNECT_TTL_MS) this.pending.delete(k);
|
|
230
|
+
while (this.pending.size >= MAX_PENDING) {
|
|
231
|
+
const oldest = this.pending.keys().next().value;
|
|
232
|
+
if (oldest === undefined) break;
|
|
233
|
+
this.pending.delete(oldest);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Step 1: where to send the browser. `origin` is this Den as the browser
|
|
239
|
+
* sees it (the request's checked Origin); `url` a Hub the person typed,
|
|
240
|
+
* else hub.url.
|
|
241
|
+
*/
|
|
242
|
+
start(
|
|
243
|
+
origin: string,
|
|
244
|
+
url?: unknown,
|
|
245
|
+
): { ok: true; location: string } | { ok: false; error: string } {
|
|
246
|
+
const typed = typeof url === "string" ? url.trim().replace(/\/+$/, "") : "";
|
|
247
|
+
const hubUrl = typed || this.deps.config()?.url || "";
|
|
248
|
+
if (!hubUrl) return { ok: false, error: "type Hub's address first" };
|
|
249
|
+
const bad = hubUrlProblem(hubUrl);
|
|
250
|
+
if (bad)
|
|
251
|
+
return { ok: false, error: bad.replace("hub.url", "Hub's address") };
|
|
252
|
+
const badOrigin = callbackOriginProblem(origin);
|
|
253
|
+
if (badOrigin) return { ok: false, error: badOrigin };
|
|
254
|
+
this.sweep();
|
|
255
|
+
const state = newHubState();
|
|
256
|
+
const { verifier, challenge } = newPkce();
|
|
257
|
+
const redirectUri = `${new URL(origin).origin}/hub/callback`;
|
|
258
|
+
this.pending.set(state, {
|
|
259
|
+
verifier,
|
|
260
|
+
hubUrl,
|
|
261
|
+
redirectUri,
|
|
262
|
+
at: this.deps.now(),
|
|
263
|
+
});
|
|
264
|
+
const q = new URLSearchParams({
|
|
265
|
+
install: this.deps.installLabel(),
|
|
266
|
+
redirect_uri: redirectUri,
|
|
267
|
+
state,
|
|
268
|
+
code_challenge: challenge,
|
|
269
|
+
code_challenge_method: "S256",
|
|
270
|
+
});
|
|
271
|
+
return { ok: true, location: `${hubUrl}/connect?${q}` };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Steps 2–3: Hub sent the person back. The state is used once, whatever
|
|
276
|
+
* happens. On success the token is stored and hub.* written to user.yml.
|
|
277
|
+
* The result names what happened in words (never the token or the code).
|
|
278
|
+
*/
|
|
279
|
+
async finish(q: URLSearchParams): Promise<ConnectResult> {
|
|
280
|
+
const r = await this.finishOnce(q);
|
|
281
|
+
this.last = {
|
|
282
|
+
ok: r.ok,
|
|
283
|
+
message: r.ok
|
|
284
|
+
? `Connected to Hub${r.team ? ` (${r.team})` : ""}.`
|
|
285
|
+
: r.message,
|
|
286
|
+
at: this.deps.now(),
|
|
287
|
+
};
|
|
288
|
+
return r;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* What the last Connect came to, in words, read once (Settings shows it
|
|
293
|
+
* after Hub's redirect; the callback page only flashes by). Never the
|
|
294
|
+
* token or the code.
|
|
295
|
+
*/
|
|
296
|
+
lastResult(): { ok: boolean; message: string } | null {
|
|
297
|
+
const l = this.last;
|
|
298
|
+
this.last = null;
|
|
299
|
+
if (!l || this.deps.now() - l.at > CONNECT_TTL_MS) return null;
|
|
300
|
+
return { ok: l.ok, message: l.message };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private last: { ok: boolean; message: string; at: number } | null = null;
|
|
304
|
+
|
|
305
|
+
private async finishOnce(q: URLSearchParams): Promise<ConnectResult> {
|
|
306
|
+
const state = q.get("state") ?? "";
|
|
307
|
+
const p = this.pending.get(state);
|
|
308
|
+
this.pending.delete(state);
|
|
309
|
+
if (!p || this.deps.now() - p.at > CONNECT_TTL_MS)
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
reason: "expired",
|
|
313
|
+
message: "That connect link is used or too old: press Connect again.",
|
|
314
|
+
};
|
|
315
|
+
if (q.get("error"))
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
reason: "denied",
|
|
319
|
+
message:
|
|
320
|
+
q.get("error") === "access_denied"
|
|
321
|
+
? "Not connected: the request was declined on Hub."
|
|
322
|
+
: "Not connected: Hub didn't finish the connect.",
|
|
323
|
+
};
|
|
324
|
+
const code = q.get("code") ?? "";
|
|
325
|
+
if (!code)
|
|
326
|
+
return { ok: false, reason: "failed", message: "Hub sent no code back." };
|
|
327
|
+
let grant: HubGrant;
|
|
328
|
+
try {
|
|
329
|
+
grant = await this.deps.exchange(p.hubUrl, {
|
|
330
|
+
code,
|
|
331
|
+
redirectUri: p.redirectUri,
|
|
332
|
+
verifier: p.verifier,
|
|
333
|
+
});
|
|
334
|
+
} catch (e) {
|
|
335
|
+
const m =
|
|
336
|
+
e instanceof HubCallError &&
|
|
337
|
+
e.kind === "refused" &&
|
|
338
|
+
e.code === "invalid_grant"
|
|
339
|
+
? "Hub didn't accept the code (used, expired or for another address): press Connect again."
|
|
340
|
+
: `Hub didn't give a token: ${(e as Error).message}`;
|
|
341
|
+
return { ok: false, reason: "failed", message: m };
|
|
342
|
+
}
|
|
343
|
+
const hubUrl =
|
|
344
|
+
grant.hubUrl && !hubUrlProblem(grant.hubUrl) ? grant.hubUrl : p.hubUrl;
|
|
345
|
+
let stored: StoredToken;
|
|
346
|
+
try {
|
|
347
|
+
stored = await this.deps.store(grant.token);
|
|
348
|
+
} catch (e) {
|
|
349
|
+
// Don't leave a live token nobody can find: revoke it.
|
|
350
|
+
await this.deps.revoke(hubUrl, grant.token).catch(() => undefined);
|
|
351
|
+
return {
|
|
352
|
+
ok: false,
|
|
353
|
+
reason: "failed",
|
|
354
|
+
message: `Connected, but the token couldn't be kept here (${(e as Error).message}); it was revoked. Press Connect again.`,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const user = this.deps.readUser();
|
|
358
|
+
const hub = {
|
|
359
|
+
...((user.hub as Record<string, unknown> | undefined) ?? {}),
|
|
360
|
+
};
|
|
361
|
+
hub.url = hubUrl;
|
|
362
|
+
hub.token = stored.kind === "ref" ? stored.ref : stored.source;
|
|
363
|
+
if (grant.mcpUrl && !hubUrlProblem(grant.mcpUrl))
|
|
364
|
+
hub.mcp_url = grant.mcpUrl;
|
|
365
|
+
else delete hub.mcp_url;
|
|
366
|
+
this.deps.writeUser({ ...user, hub });
|
|
367
|
+
return {
|
|
368
|
+
ok: true,
|
|
369
|
+
...(grant.company?.name ? { team: grant.company.name } : {}),
|
|
370
|
+
where: stored.where,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Step 4: revoke on Hub, then remove it here and clear hub.token (hub.url
|
|
376
|
+
* stays, so Connect is one click). Hub unreachable: nothing is removed,
|
|
377
|
+
* so it can be tried again; `force` removes it here anyway and says Hub
|
|
378
|
+
* still has it.
|
|
379
|
+
*/
|
|
380
|
+
async disconnect(
|
|
381
|
+
force = false,
|
|
382
|
+
): Promise<
|
|
383
|
+
| { ok: true; revoked: boolean; message: string }
|
|
384
|
+
| { ok: false; error: string }
|
|
385
|
+
> {
|
|
386
|
+
const cfg = this.deps.config();
|
|
387
|
+
if (!cfg || (!cfg.tokenRef && !cfg.tokenSource))
|
|
388
|
+
return { ok: true, revoked: false, message: "Not connected." };
|
|
389
|
+
const token = await this.deps.readToken(cfg);
|
|
390
|
+
let revoked = false;
|
|
391
|
+
let note = "";
|
|
392
|
+
if (token) {
|
|
393
|
+
try {
|
|
394
|
+
revoked = await this.deps.revoke(cfg.url, token);
|
|
395
|
+
if (!revoked) note = " Hub had already revoked it.";
|
|
396
|
+
} catch (e) {
|
|
397
|
+
if (!force)
|
|
398
|
+
return {
|
|
399
|
+
ok: false,
|
|
400
|
+
error: `Hub couldn't revoke the token (${(e as Error).message}). Nothing was removed. Try again, or remove it here anyway.`,
|
|
401
|
+
};
|
|
402
|
+
note =
|
|
403
|
+
" Hub couldn't be reached, so the token still works there until an admin revokes this install.";
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
await this.deps.forget(cfg).catch(() => undefined);
|
|
407
|
+
const user = this.deps.readUser();
|
|
408
|
+
const hub = {
|
|
409
|
+
...((user.hub as Record<string, unknown> | undefined) ?? {}),
|
|
410
|
+
};
|
|
411
|
+
delete hub.token;
|
|
412
|
+
delete hub.mcp_url;
|
|
413
|
+
if (Object.keys(hub).length) user.hub = hub;
|
|
414
|
+
else delete user.hub;
|
|
415
|
+
this.deps.writeUser(user);
|
|
416
|
+
return {
|
|
417
|
+
ok: true,
|
|
418
|
+
revoked,
|
|
419
|
+
message: `Disconnected.${revoked ? " Hub revoked this install's token." : ""}${note}`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The page /hub/callback answers: it moves on to Settings by script. */
|
|
425
|
+
export function callbackPage(
|
|
426
|
+
title: string,
|
|
427
|
+
result: { ok: boolean; message: string; reason?: string },
|
|
428
|
+
): string {
|
|
429
|
+
const next = `/settings?hub=${result.ok ? "connected" : (result.reason ?? "failed")}#agents.hub`;
|
|
430
|
+
const esc = (s: string) =>
|
|
431
|
+
s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
432
|
+
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>`;
|
|
433
|
+
}
|
|
@@ -197,6 +197,8 @@
|
|
|
197
197
|
"lock.note":
|
|
198
198
|
"Den la usa solo para sudo. Los agentes no pueden introducirla.",
|
|
199
199
|
"lock.saved": "Contraseña de administrador guardada para esta sesión",
|
|
200
|
+
"lock.savedTo":
|
|
201
|
+
"Contraseña de administrador guardada para esta sesión y en {s}",
|
|
200
202
|
"lock.saveFailed":
|
|
201
203
|
"Guardada para esta sesión, pero no se pudo guardar en {s}: {err}",
|
|
202
204
|
"lock.wrong": "Contraseña incorrecta; inténtalo de nuevo",
|
|
@@ -186,6 +186,8 @@
|
|
|
186
186
|
"lock.note":
|
|
187
187
|
"Den はこれを sudo にだけ使います。エージェントは入力できません。",
|
|
188
188
|
"lock.saved": "このセッションの間、管理者パスワードを保持します",
|
|
189
|
+
"lock.savedTo":
|
|
190
|
+
"このセッションの間、管理者パスワードを保持し、{s} に保存しました",
|
|
189
191
|
"lock.saveFailed":
|
|
190
192
|
"このセッションの間は保持しますが、{s} への保存に失敗しました: {err}",
|
|
191
193
|
"lock.wrong": "パスワードが違います。もう一度お試しください",
|