@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.
@@ -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 ? { url: cfg.url, token, allowTools: cfg.allowTools } : null;
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(d, rec.workspace, rec.displayName ?? rec.id);
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 = [st.team && `team ${st.team}`, st.label && `install ${st.label}`]
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
- sendJson(res, await (deps.hubStatus ?? (() => hubStatus()))());
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": "パスワードが違います。もう一度お試しください",