@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 +51 -0
- package/docs/distributions.md +47 -0
- package/package.json +1 -1
- package/service/install.sh +5 -3
- package/src/shared/agents/den-claude.ts +13 -2
- package/src/shared/agents/runtime.ts +24 -2
- package/src/shared/hub.ts +345 -0
- package/src/ui/cli/agent-ops.ts +171 -3
- package/src/ui/cli/doctor-command.ts +62 -1
- package/src/ui/cli/doctor-where.ts +60 -13
- package/src/ui/server/agent-routes.ts +24 -1
- package/src/ui/server/index.ts +13 -0
- package/src/ui/server/public/combobox.js +150 -0
- package/src/ui/server/public/error-page.js +26 -14
- package/src/ui/server/public/i18n/es.js +4 -2
- package/src/ui/server/public/i18n/ja.js +4 -2
- package/src/ui/server/public/settings-registry.js +218 -25
- package/src/ui/server/public/settings.html +149 -1
- package/src/ui/server/public/shell.js +21 -14
- package/src/ui/server/public/svc-panel.js +54 -15
- package/src/ui/server/route-classes.ts +4 -0
- package/src/ui/server/ui-assets.ts +2 -0
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
|
package/docs/distributions.md
CHANGED
|
@@ -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
package/service/install.sh
CHANGED
|
@@ -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
|
|
114
|
-
echo " when that version is removed
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
+
}
|