@monster-orange/den 0.4.25 → 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/src/shared/hub.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  * workspace's name, then the team's default). The token is never logged,
20
20
  * never in a page or an API answer: only in the agent's own 0600 MCP file.
21
21
  */
22
+ import * as crypto from "crypto";
22
23
  import { readDenConfig, readUserConfig, type DenConfig } from "./den-config.js";
23
24
  import {
24
25
  getSecretsProvider,
@@ -28,6 +29,12 @@ import {
28
29
  type SecretsConfig,
29
30
  type SecretsProvider,
30
31
  } from "./secrets/index.js";
32
+ import {
33
+ describeOpTokenSource,
34
+ parseOpTokenSource,
35
+ readOpToken,
36
+ type OpTokenSource,
37
+ } from "./secrets/op-token.js";
31
38
 
32
39
  /** The server's name in an agent's MCP config. */
33
40
  export const HUB_SERVER = "hub";
@@ -41,8 +48,16 @@ export interface HubConfig {
41
48
  url: string;
42
49
  /** The token's ref in this install's store, or null when none is set. */
43
50
  tokenRef: SecretRef | null;
51
+ /**
52
+ * HUB-1.7: or a token source, `file:<path>` (mode 600), `keychain:<service>`
53
+ * or `env:<VAR>`: where Connect keeps a token when the install's store
54
+ * can't, or a distribution's own choice.
55
+ */
56
+ tokenSource?: OpTokenSource;
44
57
  /** Where the ref came from, for messages (never the value). */
45
58
  tokenFrom: string;
59
+ /** HUB-1.7: Hub's MCP endpoint, as Hub gave it (default `<url>/mcp`). */
60
+ mcpUrl: string;
46
61
  /**
47
62
  * hub.allow_tools (default true): agents use Hub's tools without asking.
48
63
  * Off, they ask like any tool. An install's own deny rule still wins.
@@ -51,7 +66,12 @@ export interface HubConfig {
51
66
  }
52
67
 
53
68
  type RawHub =
54
- | { url?: unknown; token?: unknown; allow_tools?: unknown }
69
+ | {
70
+ url?: unknown;
71
+ token?: unknown;
72
+ allow_tools?: unknown;
73
+ mcp_url?: unknown;
74
+ }
55
75
  | undefined;
56
76
 
57
77
  function rawHub(den: unknown, user: unknown): RawHub {
@@ -94,7 +114,11 @@ export function readHubConfig(
94
114
  const t = h.token;
95
115
  let tokenRef: SecretRef | null = null;
96
116
  let tokenFrom = "hub.token";
97
- if (typeof t === "string" && t.trim()) {
117
+ let tokenSource: OpTokenSource | undefined;
118
+ if (typeof t === "string" && /^(keychain|file|env):/.test(t.trim())) {
119
+ tokenSource = parseOpTokenSource(t.trim(), "hub.token");
120
+ tokenFrom = `hub.token (${describeOpTokenSource(tokenSource)})`;
121
+ } else if (typeof t === "string" && t.trim()) {
98
122
  // The name of a secrets.refs entry.
99
123
  tokenFrom = `secrets.refs.${t.trim()}`;
100
124
  tokenRef = namedSecretRef(t.trim(), secrets());
@@ -110,12 +134,28 @@ export function readHubConfig(
110
134
  tokenRef = ref.item || ref.key || ref.field ? ref : null;
111
135
  } else if (t !== undefined && t !== null)
112
136
  throw new Error(
113
- "hub.token must be a secret ref ({ item, field } or { key }) or the name of a secrets.refs entry",
137
+ "hub.token must be a secret ref ({ item, field } or { key }), the name of a secrets.refs entry, or file:<path> / keychain:<service> / env:<VAR>",
114
138
  );
115
139
  const at = h.allow_tools;
116
140
  if (at !== undefined && at !== null && typeof at !== "boolean")
117
141
  throw new Error("hub.allow_tools must be true or false");
118
- return { url, tokenRef, tokenFrom, allowTools: at !== false };
142
+ let mcpUrl = `${url}/mcp`;
143
+ if (h.mcp_url !== undefined && h.mcp_url !== null && h.mcp_url !== "") {
144
+ if (typeof h.mcp_url !== "string")
145
+ throw new Error("hub.mcp_url must be a string");
146
+ const m = h.mcp_url.trim();
147
+ const badMcp = hubUrlProblem(m);
148
+ if (badMcp) throw new Error(badMcp.replace("hub.url", "hub.mcp_url"));
149
+ mcpUrl = m;
150
+ }
151
+ return {
152
+ url,
153
+ tokenRef,
154
+ ...(tokenSource ? { tokenSource } : {}),
155
+ tokenFrom,
156
+ mcpUrl,
157
+ allowTools: at !== false,
158
+ };
119
159
  }
120
160
 
121
161
  /**
@@ -141,6 +181,10 @@ export async function readHubToken(
141
181
  cfg: HubConfig,
142
182
  provider: () => SecretsProvider = getSecretsProvider,
143
183
  ): Promise<string | null> {
184
+ if (cfg.tokenSource) {
185
+ const r = await readOpToken(cfg.tokenSource).catch(() => null);
186
+ return r?.ok && r.token.trim() ? r.token.trim() : null;
187
+ }
144
188
  if (!cfg.tokenRef) return null;
145
189
  try {
146
190
  const v = await provider().tryGet(cfg.tokenRef);
@@ -152,7 +196,10 @@ export async function readHubToken(
152
196
 
153
197
  /** Who calls, as Hub reads it: plain names, never a secret. */
154
198
  export interface HubCaller {
199
+ /** The display name: Hub shows the latest one. */
155
200
  agent: string;
201
+ /** HUB-1.7: the agent's stable id: Hub keys attribution on it. */
202
+ agentId?: string;
156
203
  session: string;
157
204
  workspace: string;
158
205
  repos: string[];
@@ -168,6 +215,7 @@ export function hubHeaders(
168
215
  return {
169
216
  Authorization: `Bearer ${token}`,
170
217
  "X-Den-Agent": headerSafe(who.agent),
218
+ ...(who.agentId ? { "X-Den-Agent-Id": headerSafe(who.agentId) } : {}),
171
219
  "X-Den-Session": headerSafe(who.session),
172
220
  "X-Den-Workspace": headerSafe(who.workspace),
173
221
  "X-Den-Repos": headerSafe(who.repos.join(",")),
@@ -185,97 +233,252 @@ export function ownerRepo(
185
233
  return m ? `${m[1]}/${m[2]}` : null;
186
234
  }
187
235
 
236
+ /** HUB-1.7: who this install is, as Hub's GET /connect/whoami says. */
188
237
  export interface HubWhoami {
189
- team?: string;
190
- label?: string;
238
+ /** The install's label (Hub's `install`). */
239
+ install?: string;
240
+ role?: string;
241
+ /** The person who connected it. */
242
+ login?: string;
243
+ company?: { slug?: string; name?: string; url?: string };
244
+ /** Hub's MCP endpoint; opaque. */
245
+ mcpUrl?: string;
191
246
  }
192
247
 
248
+ /**
249
+ * Why a call to Hub failed: `rejected` (401: the token isn't accepted:
250
+ * connect again), `forbidden` (403: Hub's own words; ask an admin, never
251
+ * retry), `refused` (another 4xx with Hub's error code), `unreachable`.
252
+ * The message never carries the token.
253
+ */
254
+ export class HubCallError extends Error {
255
+ constructor(
256
+ readonly kind: "rejected" | "forbidden" | "refused" | "unreachable",
257
+ message: string,
258
+ readonly code?: string,
259
+ ) {
260
+ super(message);
261
+ }
262
+ }
263
+
264
+ export type HubState =
265
+ | "connected"
266
+ | "no_token"
267
+ | "rejected"
268
+ | "forbidden"
269
+ | "unreachable"
270
+ | "bad_config";
271
+
193
272
  export type HubStatus =
194
273
  | { configured: false }
195
274
  | {
196
275
  configured: true;
197
276
  url: string;
198
277
  connected: boolean;
278
+ /** HUB-1.7: what Settings shows and offers (Connect, Reconnect, …). */
279
+ state: HubState;
199
280
  /** hub.allow_tools: agents use Hub's tools without asking. */
200
281
  allowTools?: boolean;
282
+ /** The company's name (Hub's company.name). */
201
283
  team?: string;
284
+ /** Its page on Hub, for the deep link. */
285
+ teamUrl?: string;
286
+ /** This install's label on Hub. */
202
287
  label?: string;
288
+ role?: string;
289
+ login?: string;
203
290
  /** Why it isn't connected, in words (never the token). */
204
291
  problem?: string;
205
292
  };
206
293
 
207
- /** The first object in a tool result: structured content, else JSON text. */
208
- function resultObject(r: unknown): Record<string, unknown> | null {
209
- const o = r as {
210
- structuredContent?: unknown;
211
- content?: { type?: string; text?: string }[];
212
- };
213
- if (o?.structuredContent && typeof o.structuredContent === "object")
214
- return o.structuredContent as Record<string, unknown>;
215
- for (const c of o?.content ?? [])
216
- if (c?.type === "text" && c.text)
217
- try {
218
- const v = JSON.parse(c.text) as unknown;
219
- if (v && typeof v === "object") return v as Record<string, unknown>;
220
- } catch {
221
- /* not JSON */
222
- }
223
- return null;
294
+ type FetchFn = typeof fetch;
295
+ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
296
+
297
+ /** One JSON call to Hub, bounded; its answer, or a HubCallError. */
298
+ async function hubCall(
299
+ url: string,
300
+ init: RequestInit,
301
+ opts: { timeoutMs?: number; fetch?: FetchFn },
302
+ ): Promise<Record<string, unknown>> {
303
+ const timeout = opts.timeoutMs ?? 5000;
304
+ let res: Response;
305
+ try {
306
+ res = await (opts.fetch ?? fetch)(url, {
307
+ ...init,
308
+ redirect: "error",
309
+ signal: AbortSignal.timeout(timeout),
310
+ });
311
+ } catch (e) {
312
+ const name = (e as Error)?.name;
313
+ throw new HubCallError(
314
+ "unreachable",
315
+ name === "TimeoutError" || name === "AbortError"
316
+ ? `Hub did not answer in ${timeout} ms`
317
+ : `Hub could not be reached: ${(e as Error)?.message ?? e}`,
318
+ );
319
+ }
320
+ let body: Record<string, unknown> = {};
321
+ try {
322
+ const v = (await res.json()) as unknown;
323
+ if (v && typeof v === "object") body = v as Record<string, unknown>;
324
+ } catch {
325
+ /* not JSON */
326
+ }
327
+ if (res.ok) return body;
328
+ const said =
329
+ str(body.error_description) ?? str(body.message) ?? str(body.error);
330
+ if (res.status === 401)
331
+ throw new HubCallError(
332
+ "rejected",
333
+ "Hub doesn't accept this install's token (revoked, or replaced by a newer connect): connect again",
334
+ str(body.error),
335
+ );
336
+ if (res.status === 403)
337
+ throw new HubCallError(
338
+ "forbidden",
339
+ `${said ?? "Hub refused this install"}: ask an admin of the company on Hub`,
340
+ str(body.error),
341
+ );
342
+ if (res.status >= 400 && res.status < 500)
343
+ throw new HubCallError(
344
+ "refused",
345
+ `Hub refused it (${res.status}${said ? `: ${said}` : ""})`,
346
+ str(body.error),
347
+ );
348
+ throw new HubCallError(
349
+ "unreachable",
350
+ `Hub answered ${res.status}${said ? `: ${said}` : ""}`,
351
+ );
224
352
  }
225
353
 
226
- /**
227
- * Ask Hub who this install is: its whoami tool (the first tool whose name
228
- * ends in "whoami"), over Streamable HTTP with the token. Bounded by
229
- * `timeoutMs`; a failure is said in words, without the token.
230
- */
354
+ const bearer = (token: string) => ({ Authorization: `Bearer ${token}` });
355
+
356
+ /** Ask Hub who this install is: GET <url>/connect/whoami with the token. */
231
357
  export async function hubWhoami(
232
358
  url: string,
233
359
  token: string,
234
- opts: { timeoutMs?: number; fetch?: typeof fetch } = {},
360
+ opts: { timeoutMs?: number; fetch?: FetchFn } = {},
235
361
  ): Promise<HubWhoami> {
236
- // Loaded here: only Settings › Hub and doctor ask Hub anything.
237
- const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
238
- const { StreamableHTTPClientTransport } =
239
- await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
240
- const transport = new StreamableHTTPClientTransport(new URL(`${url}/mcp`), {
241
- requestInit: { headers: { Authorization: `Bearer ${token}` } },
242
- ...(opts.fetch ? { fetch: opts.fetch } : {}),
243
- });
244
- const client = new Client({ name: "den", version: "1" });
245
- const timeout = opts.timeoutMs ?? 5000;
246
- let timer: NodeJS.Timeout | undefined;
247
- const bounded = <T>(p: Promise<T>) =>
248
- Promise.race([
249
- p,
250
- new Promise<never>((_r, rej) => {
251
- timer = setTimeout(
252
- () => rej(new Error(`Hub did not answer in ${timeout} ms`)),
253
- timeout,
254
- );
362
+ const b = await hubCall(
363
+ `${url}/connect/whoami`,
364
+ {
365
+ method: "GET",
366
+ headers: { ...bearer(token), Accept: "application/json" },
367
+ },
368
+ opts,
369
+ );
370
+ const c = (b.company ?? {}) as Record<string, unknown>;
371
+ const company = {
372
+ ...(str(c.slug) ? { slug: str(c.slug)! } : {}),
373
+ ...(str(c.name) ? { name: str(c.name)! } : {}),
374
+ ...(str(c.url) ? { url: str(c.url)! } : {}),
375
+ };
376
+ return {
377
+ ...(str(b.install) ? { install: str(b.install)! } : {}),
378
+ ...(str(b.role) ? { role: str(b.role)! } : {}),
379
+ ...(str(b.login) ? { login: str(b.login)! } : {}),
380
+ ...(Object.keys(company).length ? { company } : {}),
381
+ ...(str(b.mcp_url) ? { mcpUrl: str(b.mcp_url)! } : {}),
382
+ };
383
+ }
384
+
385
+ /** What POST /connect/token gives: the token, and where Hub is. */
386
+ export interface HubGrant {
387
+ token: string;
388
+ install?: string;
389
+ role?: string;
390
+ company?: { slug?: string; name?: string; url?: string };
391
+ /** Hub's canonical origin: kept as hub.url. */
392
+ hubUrl?: string;
393
+ mcpUrl?: string;
394
+ }
395
+
396
+ /** Trade the one-time code for the install's token, server to server. */
397
+ export async function hubExchange(
398
+ url: string,
399
+ grant: { code: string; redirectUri: string; verifier: string },
400
+ opts: { timeoutMs?: number; fetch?: FetchFn } = {},
401
+ ): Promise<HubGrant> {
402
+ const b = await hubCall(
403
+ `${url}/connect/token`,
404
+ {
405
+ method: "POST",
406
+ headers: {
407
+ "Content-Type": "application/json",
408
+ Accept: "application/json",
409
+ },
410
+ body: JSON.stringify({
411
+ grant_type: "authorization_code",
412
+ code: grant.code,
413
+ redirect_uri: grant.redirectUri,
414
+ code_verifier: grant.verifier,
255
415
  }),
256
- ]).finally(() => clearTimeout(timer));
416
+ },
417
+ { timeoutMs: 15_000, ...opts },
418
+ );
419
+ const token = str(b.token);
420
+ if (!token || !/^\S{8,}$/.test(token))
421
+ throw new HubCallError("refused", "Hub's answer had no token");
422
+ const c = (b.company ?? {}) as Record<string, unknown>;
423
+ return {
424
+ token,
425
+ ...(str(b.install) ? { install: str(b.install)! } : {}),
426
+ ...(str(b.role) ? { role: str(b.role)! } : {}),
427
+ company: {
428
+ ...(str(c.slug) ? { slug: str(c.slug)! } : {}),
429
+ ...(str(c.name) ? { name: str(c.name)! } : {}),
430
+ ...(str(c.url) ? { url: str(c.url)! } : {}),
431
+ },
432
+ ...(str(b.hub_url) ? { hubUrl: str(b.hub_url)!.replace(/\/+$/, "") } : {}),
433
+ ...(str(b.mcp_url) ? { mcpUrl: str(b.mcp_url)! } : {}),
434
+ };
435
+ }
436
+
437
+ /**
438
+ * Revoke the install's token on Hub: POST <url>/connect/revoke with it.
439
+ * True when Hub revoked it now, false when it was already dead (401).
440
+ */
441
+ export async function hubRevoke(
442
+ url: string,
443
+ token: string,
444
+ opts: { timeoutMs?: number; fetch?: FetchFn } = {},
445
+ ): Promise<boolean> {
257
446
  try {
258
- await bounded(client.connect(transport));
259
- const tools = await bounded(client.listTools());
260
- const who = tools.tools.find((t) => /whoami$/.test(t.name));
261
- if (!who) return {};
262
- const r = resultObject(
263
- await bounded(client.callTool({ name: who.name, arguments: {} })),
447
+ await hubCall(
448
+ `${url}/connect/revoke`,
449
+ {
450
+ method: "POST",
451
+ headers: { ...bearer(token), Accept: "application/json" },
452
+ },
453
+ opts,
264
454
  );
265
- const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
266
- return {
267
- ...(str(r?.team ?? (r?.team_name as unknown))
268
- ? { team: str(r?.team ?? r?.team_name)! }
269
- : {}),
270
- ...(str(r?.label ?? r?.install ?? r?.install_label)
271
- ? { label: str(r?.label ?? r?.install ?? r?.install_label)! }
272
- : {}),
273
- };
274
- } finally {
275
- await client.close().catch(() => undefined);
455
+ return true;
456
+ } catch (e) {
457
+ if (e instanceof HubCallError && e.kind === "rejected") return false;
458
+ throw e;
276
459
  }
277
460
  }
278
461
 
462
+ /** base64url without padding. */
463
+ const b64url = (b: Buffer) =>
464
+ b
465
+ .toString("base64")
466
+ .replace(/\+/g, "-")
467
+ .replace(/\//g, "_")
468
+ .replace(/=+$/, "");
469
+
470
+ /** PKCE (S256): a fresh verifier and its challenge. */
471
+ export function newPkce(): { verifier: string; challenge: string } {
472
+ const verifier = b64url(crypto.randomBytes(32));
473
+ const challenge = b64url(
474
+ crypto.createHash("sha256").update(verifier).digest(),
475
+ );
476
+ return { verifier, challenge };
477
+ }
478
+
479
+ /** An opaque, single-use state value. */
480
+ export const newHubState = () => b64url(crypto.randomBytes(24));
481
+
279
482
  /** A message that is safe to show: the token, if it slipped in, cut out. */
280
483
  export function scrubToken(msg: string, token: string | null): string {
281
484
  return token ? msg.split(token).join("[token]") : msg;
@@ -297,28 +500,32 @@ export async function hubStatus(
297
500
  configured: true,
298
501
  url: "",
299
502
  connected: false,
503
+ state: "bad_config",
300
504
  problem: (e as Error).message,
301
505
  };
302
506
  }
303
507
  if (!cfg) return { configured: false };
304
- if (!cfg.tokenRef)
508
+ const base = {
509
+ configured: true as const,
510
+ url: cfg.url,
511
+ allowTools: cfg.allowTools,
512
+ };
513
+ if (!cfg.tokenRef && !cfg.tokenSource)
305
514
  return {
306
- configured: true,
307
- url: cfg.url,
308
- allowTools: cfg.allowTools,
515
+ ...base,
309
516
  connected: false,
517
+ state: "no_token",
310
518
  problem:
311
519
  cfg.tokenFrom === "hub.token"
312
- ? "no hub.token: set it to the install token's secret ref (Hub › Settings › Den installs makes one)"
520
+ ? "not connected yet: Connect signs you in to Hub and sets this install up"
313
521
  : `hub.token names ${cfg.tokenFrom}, which is not set for this install's secrets backend`,
314
522
  };
315
523
  const token = await (deps.token ?? ((c) => readHubToken(c)))(cfg);
316
524
  if (!token)
317
525
  return {
318
- configured: true,
319
- url: cfg.url,
320
- allowTools: cfg.allowTools,
526
+ ...base,
321
527
  connected: false,
528
+ state: "no_token",
322
529
  problem: `${cfg.tokenFrom} could not be read from this install's secrets store`,
323
530
  };
324
531
  try {
@@ -327,19 +534,34 @@ export async function hubStatus(
327
534
  token,
328
535
  );
329
536
  return {
330
- configured: true,
331
- url: cfg.url,
332
- allowTools: cfg.allowTools,
537
+ ...base,
333
538
  connected: true,
334
- ...who,
539
+ state: "connected",
540
+ ...(who.company?.name ? { team: who.company.name } : {}),
541
+ ...(who.company?.url ? { teamUrl: who.company.url } : {}),
542
+ ...(who.install ? { label: who.install } : {}),
543
+ ...(who.role ? { role: who.role } : {}),
544
+ ...(who.login ? { login: who.login } : {}),
335
545
  };
336
546
  } catch (e) {
547
+ const kind = e instanceof HubCallError ? e.kind : "unreachable";
337
548
  return {
338
- configured: true,
339
- url: cfg.url,
340
- allowTools: cfg.allowTools,
549
+ ...base,
341
550
  connected: false,
342
- problem: scrubToken(`Hub did not answer: ${(e as Error).message}`, token),
551
+ state:
552
+ kind === "rejected"
553
+ ? "rejected"
554
+ : kind === "forbidden"
555
+ ? "forbidden"
556
+ : "unreachable",
557
+ // A HubCallError's words stand on their own; anything else is
558
+ // something that kept Hub from answering.
559
+ problem: scrubToken(
560
+ e instanceof HubCallError
561
+ ? e.message
562
+ : `Hub did not answer: ${(e as Error)?.message ?? e}`,
563
+ token,
564
+ ),
343
565
  };
344
566
  }
345
567
  }