@monster-orange/den 0.4.23 → 0.4.25

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 CHANGED
@@ -4,6 +4,57 @@ All notable changes to Den are documented here. Each release lists the
4
4
  config and manifest changes a distribution must make to move to it under
5
5
  **Migrations**.
6
6
 
7
+ ## [0.4.25] - 2026-11-10
8
+
9
+ ### Added
10
+
11
+ - **Agents can use Hub (HUB-1.5).** With `hub.url` and `hub.token` (a
12
+ secret ref in this install's own store) set, every agent Den launches
13
+ gets Hub's MCP server, so it can search Hub and publish docs and mocks
14
+ there. Hub knows the team from the install's token and the project from
15
+ the workspace's repos. The token stays in the agent's own 0600 MCP
16
+ file. Agents use Hub's tools without asking (`hub.allow_tools`, default
17
+ on; off, they ask like any tool). Settings › Agents › Hub and `doctor`
18
+ say whether Hub is connected, and to which team. Nothing changes
19
+ without `hub.url`. See docs/distributions.md, "Hub".
20
+
21
+ ### Fixed
22
+
23
+ - **Rotation wording.** Add override with no target says "Pick an
24
+ agent", not "Pick a agent". An install select's inherit option is short
25
+ ("inherit (auto)"), with where it comes from in the tag beside it, and
26
+ a select never cuts its words at 375 px.
27
+ - **doctor, an older session host.** A host from an earlier version, with
28
+ no service host behind it, is ℹ "older host (pid N)", not ⚠ "unknown":
29
+ it picks up this version the next time it exits, and nothing needs
30
+ doing.
31
+
32
+ ## [0.4.24] - 2026-11-10
33
+
34
+ ### Fixed
35
+
36
+ - **Visit opens in Safari (D27).** Visit, in the service panel and the
37
+ rail's ↗, is now a real link (`target=_blank`) instead of a button that
38
+ called `window.open`, which Safari's popup blocker stopped. The panel
39
+ also holds its 5 s repaint while a pointer is down in it, so a click
40
+ is never lost to a repaint between press and release.
41
+ - **Signing in from another machine (D29).** On an unclaimed Den with
42
+ `web.owner_github` set, "Sign in as @login" is the primary button. The
43
+ button that only works on the Den machine says so: "On <host> itself:
44
+ connect GitHub". With only `web.allowed_github_org`, the page says to
45
+ open it on that machine, or to set `web.owner_github`.
46
+ - **Rotation overrides are picked by name (D30).** Settings › Agents ›
47
+ Rotation's Add override form has searchable pickers (type to filter,
48
+ arrow keys, Enter) for the agent, persona, workspace or service, and a
49
+ choice for each value, never a typed id. The overrides table shows
50
+ names; an override for something that is gone is greyed, with Remove.
51
+ At 375 px the table stacks into cards, so no word breaks mid-letter.
52
+ - **doctor, where it runs.** In login mode (as installed), the server and
53
+ session host in the desktop session show ℹ, not ⚠. An older session
54
+ host still serving, with the service's own host standing by, is ℹ
55
+ "Nothing to do", not "unknown ✓". The version-manager node warning at
56
+ `service install` prints once, for the web job.
57
+
7
58
  ## [0.4.23] - 2026-11-10
8
59
 
9
60
  ### Added
@@ -911,6 +911,53 @@ later plugins.
911
911
  one line per network. Stacks that are down, and projects with no routes
912
912
  file there, are left alone. (0.3.0-dev.40.)
913
913
 
914
+ ## Hub
915
+
916
+ With `hub.url` set, every agent Den launches gets Hub's tools (HUB-1.5):
917
+ it can search Hub and publish docs and mocks there. Without `hub.url`,
918
+ nothing changes and nothing about Hub shows.
919
+
920
+ ```yaml
921
+ # config/local/user.yml (or the org config; user.yml wins)
922
+ hub:
923
+ url: https://hub.example.com
924
+ token: { vault: Private, item: Acme Hub, field: token }
925
+ # or the name of a secrets.refs entry: token: hub_install
926
+ allow_tools: true # the default: agents use Hub's tools without asking
927
+ ```
928
+
929
+ - `hub.url` must be https. Plain http works only to this machine
930
+ (`localhost`, `*.localhost`).
931
+ - `hub.token` is this install's Hub token (`hubi_…`, one per install,
932
+ made in Hub), as a ref in this install's own secrets store
933
+ (`secrets.provider`). Den never assumes a vault, item or field for it.
934
+ - Each agent's MCP config gets a server named `hub`: Streamable HTTP at
935
+ `<hub.url>/mcp`, with `Authorization: Bearer <token>` and four plain
936
+ headers naming the caller: `X-Den-Agent` (its name), `X-Den-Session`
937
+ (a new opaque id per launch), `X-Den-Workspace` and `X-Den-Repos` (the
938
+ workspace's repos, `owner/repo`, comma-separated). Hub sets the team
939
+ from the token and the project from the repos.
940
+ - Its prompt gets one line: "You can search Hub and publish docs and
941
+ mocks there with the hub\_\* tools; your team and project are set from
942
+ this Den install."
943
+ - The token is only in the agent's own MCP file (mode 0600). It is never
944
+ in a command line, a record, a log, a page or an API answer.
945
+ - `hub.allow_tools` (default `true`): agents use Hub's tools without
946
+ asking (Den allows `mcp__hub` for every agent it launches). The token
947
+ limits writes to this install's team, and Hub keeps every version. Set
948
+ it to `false` and Hub's tools ask before they run, like any tool. An
949
+ explicit deny in the install's own Claude Code permissions still wins.
950
+ Settings › Agents › Hub has the checkbox (it saves to
951
+ `config/local/user.yml`).
952
+ - A Hub that is down, or a token that can't be read, never stops an
953
+ agent: it starts without Hub.
954
+
955
+ Settings › Agents › Hub shows the URL, connected or not, the team and
956
+ this install's label (from Hub's whoami tool), or why it can't connect.
957
+ `GET /api/hub` answers the same. `<cli> doctor` has a Hub row: ✓ when
958
+ connected, ⚠ with the reason when not. A Hub that is down never fails
959
+ doctor.
960
+
914
961
  ## doctor
915
962
 
916
963
  `<cli> doctor` lists every strategy kind in the registry (secrets, tls,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.23",
3
+ "version": "0.4.25",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -110,8 +110,9 @@ pick_node() {
110
110
  done
111
111
  # Warn when installing; `print` writes the plist and nothing else.
112
112
  if [ "${1:-}" = install ]; then
113
- echo "⚠ the service will run $node, a version manager's node: it stops starting" >&2
114
- echo " when that version is removed. Re-run install after switching, or set DEN_SERVICE_NODE." >&2
113
+ echo "⚠ the web job will run $node, a version manager's node: it stops starting" >&2
114
+ echo " when that version is removed (the session host runs Den's own copy of node)." >&2
115
+ echo " Re-run install after switching, or set DEN_SERVICE_NODE." >&2
115
116
  fi
116
117
  echo "$node"
117
118
  }
@@ -165,7 +166,8 @@ host_exe() { # $1 node
165
166
  }
166
167
 
167
168
  write_plist() { # $1 = output file, $2 = web|host, $3 = mode
168
- NODE="$(pick_node "$MODE")"
169
+ # Warned once, for the web job: the host job runs Den's node copy.
170
+ NODE="$(pick_node "$([ "$2" = web ] && echo "$MODE")")"
169
171
  TSX="$(find_tsx)" || { echo "✗ tsx not installed: run npm ci in $DIST" >&2; exit 1; }
170
172
  local exe="$NODE" label="$LABEL" log="web.log"
171
173
  if [ "$2" = host ]; then
@@ -50,6 +50,7 @@ import * as fs from "fs";
50
50
  import * as path from "path";
51
51
  import { MCP_ACTIONS, configuredRuntimeBin } from "../den-config.js";
52
52
  import { rolePrompt } from "./personas.js";
53
+ import { HUB_PROMPT_LINE } from "../hub.js";
53
54
  import { GUARDRAILS_DIR, guardrailHooks, hookCommands } from "./guardrails.js";
54
55
  import { DEFAULT_ROTATE } from "./rotation.js";
55
56
  import { idsInNames } from "./handback.js";
@@ -374,7 +375,17 @@ function common(input: LaunchInput): {
374
375
  )
375
376
  : null;
376
377
  const den = input.denToolsAllowed ? denToolRules(input.mcp.name) : null;
377
- const allow = [...allowRules(input.allow ?? []), ...(den?.allow ?? [])];
378
+ // HUB-1.5: Hub's tools without asking, when hub.allow_tools is on (the
379
+ // default). Claude Code checks deny before allow: an install's deny wins.
380
+ const hubAllow =
381
+ input.mcp.hub?.allowTools === true
382
+ ? [`mcp__${input.mcp.hub.name}`, `mcp__${input.mcp.hub.name}__*`]
383
+ : [];
384
+ const allow = [
385
+ ...allowRules(input.allow ?? []),
386
+ ...(den?.allow ?? []),
387
+ ...hubAllow,
388
+ ];
378
389
  const ask = den?.ask ?? [];
379
390
  const settings =
380
391
  input.stateDir || hooks || allow.length ? settingsPath(input) : null;
@@ -393,7 +404,7 @@ function common(input: LaunchInput): {
393
404
  input.agentId,
394
405
  input.workspace.name,
395
406
  input.mcp.name,
396
- ),
407
+ ) + (input.mcp.hub ? `\n\n${HUB_PROMPT_LINE}` : ""),
397
408
  ],
398
409
  files: {
399
410
  [file]: mcpConfigJson(input.mcp),
@@ -60,6 +60,19 @@ export interface AgentMcp {
60
60
  env: Record<string, string>;
61
61
  /** The workspace it is pinned to. */
62
62
  scope: string;
63
+ /**
64
+ * HUB-1.5: Den Hub's MCP server (Streamable HTTP), when hub.url is set:
65
+ * its base URL and the headers (the bearer token among them). The
66
+ * headers go only into the agent's own 0600 MCP file, never into argv,
67
+ * a record, a log or a page.
68
+ */
69
+ hub?: {
70
+ name: string;
71
+ url: string;
72
+ headers: Record<string, string>;
73
+ /** hub.allow_tools: its tools run without asking (default on). */
74
+ allowTools?: boolean;
75
+ };
63
76
  }
64
77
 
65
78
  export interface LaunchInput {
@@ -322,8 +335,8 @@ export function whichBin(
322
335
  }
323
336
 
324
337
  /**
325
- * A standard MCP config (`mcpServers`, the shape most MCP clients read)
326
- * with Den's server as the only entry.
338
+ * A standard MCP config (`mcpServers`, the shape most MCP clients read):
339
+ * Den's server, and Den Hub's when it is set (HUB-1.5).
327
340
  */
328
341
  export function mcpConfigJson(mcp: AgentMcp): string {
329
342
  const [command, ...args] = mcp.command;
@@ -336,6 +349,15 @@ export function mcpConfigJson(mcp: AgentMcp): string {
336
349
  args,
337
350
  ...(Object.keys(mcp.env).length ? { env: mcp.env } : {}),
338
351
  },
352
+ ...(mcp.hub && mcp.hub.name !== mcp.name
353
+ ? {
354
+ [mcp.hub.name]: {
355
+ type: "http",
356
+ url: `${mcp.hub.url}/mcp`,
357
+ headers: mcp.hub.headers,
358
+ },
359
+ }
360
+ : {}),
339
361
  },
340
362
  },
341
363
  null,
@@ -0,0 +1,345 @@
1
+ /**
2
+ * HUB-1.5: Den Hub for every agent Den launches.
3
+ *
4
+ * hub:
5
+ * url: https://hub.example.com # the Hub's base URL
6
+ * token: { item: …, field: … } # a secret ref (or the name of a
7
+ * # secrets.refs entry), resolved
8
+ * # from this install's own store
9
+ *
10
+ * Read from the org config, then config/local/user.yml (user wins). SEC-0:
11
+ * the token is the install's, from the install's secrets backend; Den
12
+ * never assumes a vault, item or field of its own. With no hub.url,
13
+ * nothing changes and nothing is shown.
14
+ *
15
+ * Every agent gets Hub's MCP server (Streamable HTTP at `<url>/mcp`, with
16
+ * `Authorization: Bearer <token>`) and plain headers naming who calls:
17
+ * X-Den-Agent, X-Den-Session, X-Den-Workspace, X-Den-Repos. Hub picks the
18
+ * team (from the token) and the project (from the repos, then the
19
+ * workspace's name, then the team's default). The token is never logged,
20
+ * never in a page or an API answer: only in the agent's own 0600 MCP file.
21
+ */
22
+ import { readDenConfig, readUserConfig, type DenConfig } from "./den-config.js";
23
+ import {
24
+ getSecretsProvider,
25
+ namedSecretRef,
26
+ readSecretsConfig,
27
+ type SecretRef,
28
+ type SecretsConfig,
29
+ type SecretsProvider,
30
+ } from "./secrets/index.js";
31
+
32
+ /** The server's name in an agent's MCP config. */
33
+ export const HUB_SERVER = "hub";
34
+
35
+ /** The line every agent's prompt gets when Hub is set (generic). */
36
+ export const HUB_PROMPT_LINE =
37
+ "You can search Hub and publish docs and mocks there with the hub_* tools; your team and project are set from this Den install.";
38
+
39
+ export interface HubConfig {
40
+ /** The base URL, no trailing slash. */
41
+ url: string;
42
+ /** The token's ref in this install's store, or null when none is set. */
43
+ tokenRef: SecretRef | null;
44
+ /** Where the ref came from, for messages (never the value). */
45
+ tokenFrom: string;
46
+ /**
47
+ * hub.allow_tools (default true): agents use Hub's tools without asking.
48
+ * Off, they ask like any tool. An install's own deny rule still wins.
49
+ */
50
+ allowTools: boolean;
51
+ }
52
+
53
+ type RawHub =
54
+ | { url?: unknown; token?: unknown; allow_tools?: unknown }
55
+ | undefined;
56
+
57
+ function rawHub(den: unknown, user: unknown): RawHub {
58
+ const d = (den as { hub?: RawHub } | null)?.hub ?? {};
59
+ const u = (user as { hub?: RawHub } | null)?.hub ?? {};
60
+ return { ...d, ...u };
61
+ }
62
+
63
+ /** A URL Den will send a token to: https, or http to this machine. */
64
+ export function hubUrlProblem(url: string): string | null {
65
+ let u: URL;
66
+ try {
67
+ u = new URL(url);
68
+ } catch {
69
+ return `hub.url is not a URL: ${url}`;
70
+ }
71
+ const local =
72
+ ["localhost", "127.0.0.1", "[::1]"].includes(u.hostname) ||
73
+ u.hostname.endsWith(".localhost");
74
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && local))
75
+ return `hub.url must be https (http only to this machine): ${url}`;
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * hub.* (org config, then user.yml), or null without hub.url. A bad value
81
+ * throws naming its key.
82
+ */
83
+ export function readHubConfig(
84
+ den: DenConfig | Record<string, unknown> = readDenConfig(),
85
+ user: Record<string, unknown> = readUserConfig() as Record<string, unknown>,
86
+ secrets: () => SecretsConfig = readSecretsConfig,
87
+ ): HubConfig | null {
88
+ const h = rawHub(den, user);
89
+ if (h?.url === undefined || h.url === null || h.url === "") return null;
90
+ if (typeof h.url !== "string") throw new Error("hub.url must be a string");
91
+ const url = h.url.trim().replace(/\/+$/, "");
92
+ const bad = hubUrlProblem(url);
93
+ if (bad) throw new Error(bad);
94
+ const t = h.token;
95
+ let tokenRef: SecretRef | null = null;
96
+ let tokenFrom = "hub.token";
97
+ if (typeof t === "string" && t.trim()) {
98
+ // The name of a secrets.refs entry.
99
+ tokenFrom = `secrets.refs.${t.trim()}`;
100
+ tokenRef = namedSecretRef(t.trim(), secrets());
101
+ } else if (t && typeof t === "object") {
102
+ const r = t as Record<string, unknown>;
103
+ const s = (v: unknown) => (typeof v === "string" ? v.trim() : "");
104
+ const ref: SecretRef = {
105
+ vault: s(r.vault),
106
+ item: s(r.item),
107
+ field: s(r.field) || s(r.key),
108
+ };
109
+ if (s(r.key)) ref.key = s(r.key);
110
+ tokenRef = ref.item || ref.key || ref.field ? ref : null;
111
+ } else if (t !== undefined && t !== null)
112
+ throw new Error(
113
+ "hub.token must be a secret ref ({ item, field } or { key }) or the name of a secrets.refs entry",
114
+ );
115
+ const at = h.allow_tools;
116
+ if (at !== undefined && at !== null && typeof at !== "boolean")
117
+ throw new Error("hub.allow_tools must be true or false");
118
+ return { url, tokenRef, tokenFrom, allowTools: at !== false };
119
+ }
120
+
121
+ /**
122
+ * Apply a POSTed `hub_allow_tools` to user.yml's hub block (in place): a
123
+ * boolean sets hub.allow_tools, null clears it (back to the default, on).
124
+ */
125
+ export function patchHubAllowTools(
126
+ user: Record<string, unknown>,
127
+ v: unknown,
128
+ ): { ok: true } | { ok: false; error: string } {
129
+ if (v !== null && typeof v !== "boolean")
130
+ return { ok: false, error: "hub_allow_tools must be true, false or null" };
131
+ const hub = { ...((user.hub as Record<string, unknown> | undefined) ?? {}) };
132
+ if (v === null) delete hub.allow_tools;
133
+ else hub.allow_tools = v;
134
+ if (Object.keys(hub).length) user.hub = hub;
135
+ else delete user.hub;
136
+ return { ok: true };
137
+ }
138
+
139
+ /** The token from this install's store, or null (none set, or unreadable). */
140
+ export async function readHubToken(
141
+ cfg: HubConfig,
142
+ provider: () => SecretsProvider = getSecretsProvider,
143
+ ): Promise<string | null> {
144
+ if (!cfg.tokenRef) return null;
145
+ try {
146
+ const v = await provider().tryGet(cfg.tokenRef);
147
+ return v && v.trim() ? v.trim() : null;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ /** Who calls, as Hub reads it: plain names, never a secret. */
154
+ export interface HubCaller {
155
+ agent: string;
156
+ session: string;
157
+ workspace: string;
158
+ repos: string[];
159
+ }
160
+
161
+ /** A header value: one line, printable, bounded. */
162
+ const headerSafe = (v: string) => v.replace(/[^\x20-\x7e]/g, "?").slice(0, 512);
163
+
164
+ export function hubHeaders(
165
+ token: string,
166
+ who: HubCaller,
167
+ ): Record<string, string> {
168
+ return {
169
+ Authorization: `Bearer ${token}`,
170
+ "X-Den-Agent": headerSafe(who.agent),
171
+ "X-Den-Session": headerSafe(who.session),
172
+ "X-Den-Workspace": headerSafe(who.workspace),
173
+ "X-Den-Repos": headerSafe(who.repos.join(",")),
174
+ };
175
+ }
176
+
177
+ /** owner/repo from a catalog entry: its gh slug, else its git remote. */
178
+ export function ownerRepo(
179
+ entry: { gh?: string; remote?: string } | undefined,
180
+ ): string | null {
181
+ if (entry?.gh && /^[^/\s]+\/[^/\s]+$/.test(entry.gh)) return entry.gh;
182
+ const r = entry?.remote?.trim();
183
+ if (!r) return null;
184
+ const m = /[:/]([^/:\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(r);
185
+ return m ? `${m[1]}/${m[2]}` : null;
186
+ }
187
+
188
+ export interface HubWhoami {
189
+ team?: string;
190
+ label?: string;
191
+ }
192
+
193
+ export type HubStatus =
194
+ | { configured: false }
195
+ | {
196
+ configured: true;
197
+ url: string;
198
+ connected: boolean;
199
+ /** hub.allow_tools: agents use Hub's tools without asking. */
200
+ allowTools?: boolean;
201
+ team?: string;
202
+ label?: string;
203
+ /** Why it isn't connected, in words (never the token). */
204
+ problem?: string;
205
+ };
206
+
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;
224
+ }
225
+
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
+ */
231
+ export async function hubWhoami(
232
+ url: string,
233
+ token: string,
234
+ opts: { timeoutMs?: number; fetch?: typeof fetch } = {},
235
+ ): 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
+ );
255
+ }),
256
+ ]).finally(() => clearTimeout(timer));
257
+ 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: {} })),
264
+ );
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);
276
+ }
277
+ }
278
+
279
+ /** A message that is safe to show: the token, if it slipped in, cut out. */
280
+ export function scrubToken(msg: string, token: string | null): string {
281
+ return token ? msg.split(token).join("[token]") : msg;
282
+ }
283
+
284
+ /** Settings › Hub and doctor: set, connected, and to whom. */
285
+ export async function hubStatus(
286
+ deps: {
287
+ config?: () => HubConfig | null;
288
+ token?: (c: HubConfig) => Promise<string | null>;
289
+ whoami?: (url: string, token: string) => Promise<HubWhoami>;
290
+ } = {},
291
+ ): Promise<HubStatus> {
292
+ let cfg: HubConfig | null;
293
+ try {
294
+ cfg = (deps.config ?? (() => readHubConfig()))();
295
+ } catch (e) {
296
+ return {
297
+ configured: true,
298
+ url: "",
299
+ connected: false,
300
+ problem: (e as Error).message,
301
+ };
302
+ }
303
+ if (!cfg) return { configured: false };
304
+ if (!cfg.tokenRef)
305
+ return {
306
+ configured: true,
307
+ url: cfg.url,
308
+ allowTools: cfg.allowTools,
309
+ connected: false,
310
+ problem:
311
+ cfg.tokenFrom === "hub.token"
312
+ ? "no hub.token: set it to the install token's secret ref (Hub › Settings › Den installs makes one)"
313
+ : `hub.token names ${cfg.tokenFrom}, which is not set for this install's secrets backend`,
314
+ };
315
+ const token = await (deps.token ?? ((c) => readHubToken(c)))(cfg);
316
+ if (!token)
317
+ return {
318
+ configured: true,
319
+ url: cfg.url,
320
+ allowTools: cfg.allowTools,
321
+ connected: false,
322
+ problem: `${cfg.tokenFrom} could not be read from this install's secrets store`,
323
+ };
324
+ try {
325
+ const who = await (deps.whoami ?? ((u, t) => hubWhoami(u, t)))(
326
+ cfg.url,
327
+ token,
328
+ );
329
+ return {
330
+ configured: true,
331
+ url: cfg.url,
332
+ allowTools: cfg.allowTools,
333
+ connected: true,
334
+ ...who,
335
+ };
336
+ } catch (e) {
337
+ return {
338
+ configured: true,
339
+ url: cfg.url,
340
+ allowTools: cfg.allowTools,
341
+ connected: false,
342
+ problem: scrubToken(`Hub did not answer: ${(e as Error).message}`, token),
343
+ };
344
+ }
345
+ }