@monster-orange/den 0.4.0 → 0.4.1
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 +409 -0
- package/docs/distributions.md +204 -14
- package/package.json +3 -1
- package/src/shared/actor-token.ts +275 -0
- package/src/shared/agent-actor.ts +12 -2
- package/src/shared/agents/den-claude.ts +99 -1
- package/src/shared/agents/runtime.ts +7 -0
- package/src/shared/agents/runtimes.ts +9 -2
- package/src/shared/environment-manager/scope.ts +22 -0
- package/src/shared/environment-manager/service-runner.ts +94 -2
- package/src/shared/profile.ts +15 -4
- package/src/shared/sessions/client.ts +9 -2
- package/src/shared/sessions/env.ts +13 -3
- package/src/shared/sessions/host.ts +23 -0
- package/src/shared/web-secret.ts +181 -0
- package/src/shared/web-session.ts +491 -0
- package/src/ui/cli/agent-ops.ts +843 -0
- package/src/ui/cli/agents-command.ts +96 -614
- package/src/ui/cli/flag-parser.ts +21 -0
- package/src/ui/cli/index.ts +94 -10
- package/src/ui/cli/sessions-command.ts +32 -0
- package/src/ui/cli/target-workspace.ts +105 -0
- package/src/ui/cli/web-command.ts +536 -14
- package/src/ui/mcp/api.ts +7 -0
- package/src/ui/mcp/server.ts +74 -6
- package/src/ui/server/agent-routes.ts +323 -0
- package/src/ui/server/audit.ts +81 -27
- package/src/ui/server/http-errors.ts +21 -3
- package/src/ui/server/index.ts +814 -66
- package/src/ui/server/public/agents-page.js +648 -0
- package/src/ui/server/public/agents-section.js +299 -0
- package/src/ui/server/public/agents.html +439 -0
- package/src/ui/server/public/appearance.js +3 -0
- package/src/ui/server/public/error-page.js +103 -0
- package/src/ui/server/public/header.js +45 -11
- package/src/ui/server/public/i18n/es.js +90 -1
- package/src/ui/server/public/i18n/ja.js +86 -1
- package/src/ui/server/public/local-dev.html +53 -0
- package/src/ui/server/public/logs.html +3 -0
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/public/release.html +8 -0
- package/src/ui/server/public/settings-page.js +5 -0
- package/src/ui/server/public/settings-registry.js +33 -0
- package/src/ui/server/public/shell.js +248 -5
- package/src/ui/server/public/topbar.js +51 -2
- package/src/ui/server/request-guard.ts +670 -0
- package/src/ui/server/route-classes.ts +317 -0
- package/src/ui/server/session-routes.ts +445 -0
- package/src/ui/server/shutdown-guard.ts +91 -0
- package/src/ui/server/sudo-store.ts +29 -3
- package/src/ui/server/ui-assets.ts +10 -1
- package/src/ui/server/websocket.ts +326 -0
- package/src/ui/server/workspace-routes.ts +13 -7
package/docs/distributions.md
CHANGED
|
@@ -563,16 +563,16 @@ product:
|
|
|
563
563
|
# workspaces_dir: acme-workspaces
|
|
564
564
|
```
|
|
565
565
|
|
|
566
|
-
| Field | Default | What it names
|
|
567
|
-
| ---------------- | -------------------- |
|
|
568
|
-
| `name` | `den` | The product; `[a-z][a-z0-9-]*`. Everything below derives from it
|
|
569
|
-
| `cli` | `<name>` | The command shown in help, hints and the web UI
|
|
570
|
-
| `env_prefix` | `<NAME>` (`-` → `_`) | Env vars: `<PREFIX>_ROOT`, `<PREFIX>_HOME`, `<PREFIX>_INVOKE_CWD`
|
|
571
|
-
| `config` | `config/<name>.yml` | The org config, relative to the distribution root
|
|
572
|
-
| `manifest` | `.<name>.yml` | The manifest file a repo carries; the `.json` twin is also read
|
|
573
|
-
| `state_dir` | `~/.<name>` | Per-user state: web server, daemons, logs, history
|
|
574
|
-
| `services_dir` | `.<name>-services` | Managed clones, relative to the distribution root
|
|
575
|
-
| `workspaces_dir` | `<name>-workspaces` | Workspace checkouts, relative to the distribution root
|
|
566
|
+
| Field | Default | What it names |
|
|
567
|
+
| ---------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
568
|
+
| `name` | `den` | The product; `[a-z][a-z0-9-]*`. Everything below derives from it |
|
|
569
|
+
| `cli` | `<name>` | The command shown in help, hints and the web UI |
|
|
570
|
+
| `env_prefix` | `<NAME>` (`-` → `_`) | Env vars: `<PREFIX>_ROOT`, `<PREFIX>_HOME`, `<PREFIX>_INVOKE_CWD` |
|
|
571
|
+
| `config` | `config/<name>.yml` | The org config, relative to the distribution root |
|
|
572
|
+
| `manifest` | `.<name>.yml` | The manifest file a repo carries; the `.json` twin is also read |
|
|
573
|
+
| `state_dir` | `~/.<name>` | Per-user state: web server, daemons, logs, history (a relative path is relative to den.yml's directory, not the cwd) |
|
|
574
|
+
| `services_dir` | `.<name>-services` | Managed clones, relative to the distribution root |
|
|
575
|
+
| `workspaces_dir` | `<name>-workspaces` | Workspace checkouts, relative to the distribution root |
|
|
576
576
|
|
|
577
577
|
Env vars, with `ACME` as the prefix:
|
|
578
578
|
|
|
@@ -1179,6 +1179,86 @@ Den's scripts run with the engine's tsx (`tsxCommand()`), never through `npx`
|
|
|
1179
1179
|
or a path relative to the current directory. In Den's own checkout both roots
|
|
1180
1180
|
are the same directory.
|
|
1181
1181
|
|
|
1182
|
+
## Signing in
|
|
1183
|
+
|
|
1184
|
+
On localhost nothing asks you to sign in: every page, the agents and the
|
|
1185
|
+
terminal work as they are, and the lock (R8) is the only prompt (for
|
|
1186
|
+
privileged actions). Signing in is for when the server is reached beyond
|
|
1187
|
+
this machine: through the edge hostname, a LAN address, or a bind that
|
|
1188
|
+
isn't loopback (`DEN_BIND_HOST`).
|
|
1189
|
+
|
|
1190
|
+
There is no password. Reading `web.secret` (32 random bytes, mode 0600 in
|
|
1191
|
+
the state dir, made at the first `web up`) proves you are this computer's
|
|
1192
|
+
user, and the CLI does that for you:
|
|
1193
|
+
|
|
1194
|
+
```bash
|
|
1195
|
+
./bin/acme web open # start if needed, and sign this browser in (a new tab)
|
|
1196
|
+
./bin/acme web login # print a one-time sign-in link, for another browser
|
|
1197
|
+
./bin/acme web sessions # the signed-in browsers: id, created, last seen, host, user agent
|
|
1198
|
+
./bin/acme web logout <id> # sign out one browser (an id prefix from `web sessions`)
|
|
1199
|
+
./bin/acme web logout # sign out every browser
|
|
1200
|
+
./bin/acme web logout --all # and rotate web.secret and revoke every agent's token
|
|
1201
|
+
```
|
|
1202
|
+
|
|
1203
|
+
- **The link** is `http://localhost:<port>/auth?c=<code>`, with `&next=<path>`
|
|
1204
|
+
for where to land (`web login --next /settings`). The code works once,
|
|
1205
|
+
within 60 seconds, and only this server's secret makes one. It is printed
|
|
1206
|
+
only on the one line you asked for (`web login`, or `web up` when it
|
|
1207
|
+
couldn't open the browser), never logged. Through the edge or a LAN name,
|
|
1208
|
+
the cookie belongs to that name: `web login --origin https://acme.acme.localhost`
|
|
1209
|
+
(the signed-out page there shows the exact command).
|
|
1210
|
+
- **The cookie** (`den_session_<port>`) is HttpOnly, SameSite=Strict and
|
|
1211
|
+
Path=/, and Secure when the request came over HTTPS through the edge. The
|
|
1212
|
+
session lives server-side (`web-sessions.json`, 0600, in the state dir)
|
|
1213
|
+
and never expires: it ends at sign-out, `web logout`, or `web logout
|
|
1214
|
+
--all`. The cookie asks the browser to keep it for 400 days, the most a
|
|
1215
|
+
browser allows. The 0.4.1-dev `web.session.idle` and `web.session.max`
|
|
1216
|
+
keys are ignored; the server logs a warning naming each one it finds.
|
|
1217
|
+
|
|
1218
|
+
`web sessions`' LAST SEEN is written at most once a minute (60 s), so it
|
|
1219
|
+
equals CREATED right after a sign-in.
|
|
1220
|
+
|
|
1221
|
+
- **What needs it.** On loopback, nothing: pages and read-only calls are
|
|
1222
|
+
open, and changes (an agent's start or resume and the terminal included)
|
|
1223
|
+
need only the page's own Origin and CSRF token, which the shell sends.
|
|
1224
|
+
The Host allowlist, the Origin check and the CSRF token are what stop
|
|
1225
|
+
another site or a rebinding DNS name. Through the edge, or from another
|
|
1226
|
+
machine, every page and call needs a signed-in browser (or a bearer). A
|
|
1227
|
+
page there shows "Sign in to use <name>" with the command to run; it
|
|
1228
|
+
never asks for a secret. `/api` answers 401 `signin_required`.
|
|
1229
|
+
- **localhost is the canonical name.** A cookie set on `localhost` isn't
|
|
1230
|
+
sent to `127.0.0.1`, so the links Den prints use `localhost`.
|
|
1231
|
+
- **Browsers.** Chromium, Firefox and Safari 16.4 or later: a sign-in link
|
|
1232
|
+
needs the browser to send `Sec-Fetch-Site` or `Origin`.
|
|
1233
|
+
- **Scripts and the CLI** are unaffected: the CLI and the MCP server send
|
|
1234
|
+
the person token (`web token --print` prints it for curl). After `web
|
|
1235
|
+
logout --all` the old token is refused; the CLI reads the new one.
|
|
1236
|
+
- **`web logout --all` is the "something leaked" button.** It signs out
|
|
1237
|
+
every browser, rotates `web.secret` (every person token is void) and
|
|
1238
|
+
revokes every agent's token: a running agent's next call gets 401
|
|
1239
|
+
`token_revoked` ("restart or resume the agent to get a new one"). Plain
|
|
1240
|
+
`web logout` (one browser, or every browser) leaves agent tokens alone.
|
|
1241
|
+
- **Another machine.** `DEN_BIND_HOST=0.0.0.0` (or any non-loopback
|
|
1242
|
+
address) starts only with `web.allow_plain_http_remote: true` in
|
|
1243
|
+
`config/local/user.yml`, since a session cookie would otherwise cross the
|
|
1244
|
+
network in clear text. Having the edge in front is not enough: the
|
|
1245
|
+
server's own port would still answer plain HTTP on every interface. Only
|
|
1246
|
+
`config/local/user.yml` is read (a choice for that machine); the key in
|
|
1247
|
+
the distribution config is ignored, and the server logs a warning naming
|
|
1248
|
+
the file. With it, the machine's own addresses and host name are accepted
|
|
1249
|
+
with the port, and every route there needs a session.
|
|
1250
|
+
- **The edge** reaches Den on loopback (macOS, through
|
|
1251
|
+
`host.docker.internal`), so it needs no other bind. On Linux, where the
|
|
1252
|
+
edge container reaches the host on the Docker bridge, the one other bind
|
|
1253
|
+
allowed without the key is the bridge address (`172.16.0.0/12`, for
|
|
1254
|
+
example `DEN_BIND_HOST=172.17.0.1`), and only with the edge configured.
|
|
1255
|
+
- **The edge hostname is believed only from the edge.** A request whose
|
|
1256
|
+
`Host` is the edge hostname is accepted only from a peer the edge could
|
|
1257
|
+
be (loopback, the Docker bridge range or Docker Desktop's VM network);
|
|
1258
|
+
from any other address it is
|
|
1259
|
+
421 `misdirected`, so a LAN client can't send the edge's name straight to
|
|
1260
|
+
the server's port.
|
|
1261
|
+
|
|
1182
1262
|
## Running the web UI as a service (macOS)
|
|
1183
1263
|
|
|
1184
1264
|
Den ships a launchd installer that runs the web UI at login and restarts it if
|
|
@@ -1554,6 +1634,80 @@ server scoped to its workspace.
|
|
|
1554
1634
|
exits non-zero, Den prints the exit code and its last lines (at most 10,
|
|
1555
1635
|
secret-shaped values redacted) and exits 1.
|
|
1556
1636
|
|
|
1637
|
+
### On the Workspaces page
|
|
1638
|
+
|
|
1639
|
+
The selected workspace's agents are listed under its repo table, one row
|
|
1640
|
+
per agent: its id and persona, runtime, state (`running`, `stopping`,
|
|
1641
|
+
`stopped`, `ended` or `exited (<code>)`), directory, and when its session
|
|
1642
|
+
started. The workspace header counts them ("2 agents").
|
|
1643
|
+
|
|
1644
|
+
- **Stop** sends its session TERM, as `agents stop` does; the row reads
|
|
1645
|
+
`stopping…` until the session has gone. **Resume** shows only when
|
|
1646
|
+
`agents resume` would run: the runtime declares resume and the agent is
|
|
1647
|
+
not running.
|
|
1648
|
+
- **+ Start agent** picks a persona (Den's pack, then
|
|
1649
|
+
`agents.persona_packs`) and a runtime (the persona's own unless you pick
|
|
1650
|
+
one; one that can't run here is listed, disabled), then Start.
|
|
1651
|
+
- The page and the CLI call the same operations and read the same agent
|
|
1652
|
+
records, so an agent started in one is listed in the other. A workspace
|
|
1653
|
+
lists its own agents only.
|
|
1654
|
+
- Start, Stop and Resume need the lock open (🔓, the same rule as a
|
|
1655
|
+
service's Start), are capped by `agents.max_concurrent`, and are refused
|
|
1656
|
+
for an agent (agents don't start agents from the page). Each one is in the
|
|
1657
|
+
server log's activity, with you as its actor. The agent's environment is
|
|
1658
|
+
the one below, built from the web server's environment.
|
|
1659
|
+
- **Open in Agents →** (the section header) opens the Agents tab for the
|
|
1660
|
+
workspace, and **Open** on a row with a session opens its terminal there.
|
|
1661
|
+
|
|
1662
|
+
The routes: `GET /api/workspaces/<ws>/agents`, `POST
|
|
1663
|
+
/api/workspaces/<ws>/agents` (`{ persona, runtime?, model?, id? }`), `POST
|
|
1664
|
+
/api/workspaces/<ws>/agents/<id>/stop` and `…/resume`, and `GET
|
|
1665
|
+
/api/personas` (personas and runtimes). Errors are the usual
|
|
1666
|
+
`{ ok: false, error, code, requestId }`: `locked` (423), `cap` (429),
|
|
1667
|
+
`conflict` (409, e.g. already running, or no resume), `not_found` (404),
|
|
1668
|
+
`exited_at_once` (502, with the session's last lines).
|
|
1669
|
+
|
|
1670
|
+
### The Agents tab
|
|
1671
|
+
|
|
1672
|
+
"Agents" in the rail (Develop, after Graph) is `/agents`: every session and
|
|
1673
|
+
agent of the workspaces in view, and their terminals in the browser.
|
|
1674
|
+
|
|
1675
|
+
- **The list** is grouped by workspace and follows the scope bar (or the
|
|
1676
|
+
page's `?ws=<ws>`, which "Open in Agents →" sets). Each row has its
|
|
1677
|
+
state, persona (a `sessions start` session is labelled "session"),
|
|
1678
|
+
runtime and directory, and "waiting" when it waits on you (once the
|
|
1679
|
+
session host can tell, AG-6a; not yet). An agent whose
|
|
1680
|
+
token `web logout --all` revoked says "token revoked": stop and resume
|
|
1681
|
+
it, or start it again.
|
|
1682
|
+
- **A pane** is the session's terminal: it shows the scrollback, then the
|
|
1683
|
+
live output, and your keys go to the session; its size follows the
|
|
1684
|
+
pane. Click more rows to tile up to four (a fifth closes the oldest).
|
|
1685
|
+
The URL holds the open panes (`/agents?open=<id>,<id>`), so a link, a
|
|
1686
|
+
reload or Back reopens them.
|
|
1687
|
+
- **Close** (×) detaches, as leaving the page does: the session keeps
|
|
1688
|
+
running, and opening it again replays what you missed. **Stop** (an
|
|
1689
|
+
agent only) is `agents stop`, behind the lock. **+ Start agent** (or
|
|
1690
|
+
`/agents?start=1`) starts one and opens its pane; under All it asks for
|
|
1691
|
+
the workspace.
|
|
1692
|
+
- ⌘K lists each as "Agents › <ws> / <name>", and "Start agent…".
|
|
1693
|
+
- Several browsers and `<cli> sessions attach` can attach to one session
|
|
1694
|
+
at once and each sees every byte; the terminal's size is the last
|
|
1695
|
+
one's to change it.
|
|
1696
|
+
- Terminals need the optional PTY module (`<cli> doctor`); without it the
|
|
1697
|
+
page lists what there is and says so.
|
|
1698
|
+
- **Who can open one.** The terminal is shell-grade: on localhost the
|
|
1699
|
+
page's own Origin and CSRF token, beyond loopback a signed-in browser
|
|
1700
|
+
(the list says "Sign in to open terminals" otherwise), and an agent's
|
|
1701
|
+
token only its own session's. Each attach and detach is in the server
|
|
1702
|
+
log's activity, with its actor.
|
|
1703
|
+
|
|
1704
|
+
The routes: `GET /api/sessions` (`{ sessions, workspaces, host, backend,
|
|
1705
|
+
io }`) and the WebSocket `/api/sessions/<id>/io` (subprotocol `den.io`, the
|
|
1706
|
+
CSRF token as `den.csrf.<token>`): binary frames are output and input, a
|
|
1707
|
+
text frame `{"type":"resize","cols":c,"rows":r}` resizes, and the server
|
|
1708
|
+
sends `{"type":"session",…}` first and `{"type":"exit","exitCode":n}` when
|
|
1709
|
+
the session ends.
|
|
1710
|
+
|
|
1557
1711
|
### An agent's environment
|
|
1558
1712
|
|
|
1559
1713
|
An agent's environment starts empty and gets, in order:
|
|
@@ -1564,7 +1718,7 @@ An agent's environment starts empty and gets, in order:
|
|
|
1564
1718
|
2. the variables its runtime names (`envPassthrough`; den-claude:
|
|
1565
1719
|
`CLAUDE_CONFIG_DIR`);
|
|
1566
1720
|
3. the runtime's launch env;
|
|
1567
|
-
4. `DEN_AGENT=agent:<id
|
|
1721
|
+
4. `DEN_AGENT=agent:<id>` and `DEN_ACTOR_TOKEN` (below).
|
|
1568
1722
|
|
|
1569
1723
|
Then every runtime's `scrubEnv` is removed (den-claude: `CLAUDECODE` and
|
|
1570
1724
|
`CLAUDE_CODE_*`), whatever put it there. Nothing else of yours reaches it:
|
|
@@ -1585,10 +1739,46 @@ Without the PTY module it says the probe is unavailable; that is not a
|
|
|
1585
1739
|
failure. Plain `doctor` never spawns anything; when agents are configured it
|
|
1586
1740
|
adds a note to run `<cli> doctor --probe`.
|
|
1587
1741
|
|
|
1742
|
+
### An agent's token
|
|
1743
|
+
|
|
1744
|
+
`agents start` (the CLI or the Workspaces page) mints a random `den_at_…`
|
|
1745
|
+
token for the agent and puts it in the session as `DEN_ACTOR_TOKEN`. Den
|
|
1746
|
+
keeps only its hash (`actor-tokens.json`, 0600, in the state dir) with the
|
|
1747
|
+
agent, its workspace, its grants and its session. Stop revokes it, and so
|
|
1748
|
+
does the session's end; resume mints a new one and the old one stops
|
|
1749
|
+
working. The `<cli>` and the MCP server Den starts for the agent send it, so
|
|
1750
|
+
the server audits their calls as the agent, from the token, never from a
|
|
1751
|
+
header (agent headers without the token are recorded as an unverified
|
|
1752
|
+
claim).
|
|
1753
|
+
|
|
1754
|
+
An agent's token reaches only its own session's routes, and never a
|
|
1755
|
+
shell-grade route, `/api/web/*`, sign-in or sign-out, or the lock: it can't
|
|
1756
|
+
unlock, use the sudo password or start another agent (403
|
|
1757
|
+
`agent_forbidden`). A person's credential never reaches a session:
|
|
1758
|
+
`DEN_PERSON_TOKEN`, a person token's value and a path to `web.secret` are
|
|
1759
|
+
always removed, and `sessions start --keep-env` of one is refused and logged.
|
|
1760
|
+
An agent runs as your OS user, so it could still read `web.secret` itself;
|
|
1761
|
+
the token keeps what Den does on its behalf attributable and bounded.
|
|
1762
|
+
**The boundary is the server's token rules** (above), not a deny list.
|
|
1763
|
+
|
|
1764
|
+
As a cooperative mitigation, den-claude passes Claude Code a settings file
|
|
1765
|
+
(`--settings`) whose `permissions.deny` lists `Read` of `<state
|
|
1766
|
+
dir>/web.secret` and `<state dir>/sessions/**`, and `Bash` rules in Claude
|
|
1767
|
+
Code's wildcard form for the path (`Bash(*<state dir>/web.secret*)`, and
|
|
1768
|
+
each of `cat`, `head`, `tail`, `less`, `more`, `xxd`, `od`, `strings`,
|
|
1769
|
+
`base64` and `cp` followed by it), spelled as an absolute path, under `~`
|
|
1770
|
+
and under `$HOME`, with a path that has spaces also backslash-escaped. A
|
|
1771
|
+
deny list can't name every way to read a file (another program, a script,
|
|
1772
|
+
a copy under another name), so it is a speed bump. **It binds cooperating
|
|
1773
|
+
agents only**: an agent that ignores its permission config, or reads the
|
|
1774
|
+
file some other way, can still read them. OS-user isolation (a separate
|
|
1775
|
+
user, a container, a sandbox) is not in 0.4.x. `web logout --all` revokes every agent's token
|
|
1776
|
+
(above, under Signing in).
|
|
1777
|
+
|
|
1588
1778
|
**What the scope does not do.** An agent's MCP scope binds cooperating
|
|
1589
1779
|
agents only. A runtime with a shell can still run `<cli>` itself, outside
|
|
1590
|
-
the scoped MCP server, and reach other workspaces
|
|
1591
|
-
that later. And the user's own Claude Code settings and hooks
|
|
1780
|
+
the scoped MCP server, and reach other workspaces (with its own token, so
|
|
1781
|
+
it is audited as the agent); WS-3a closes that later. And the user's own Claude Code settings and hooks
|
|
1592
1782
|
(`~/.claude/settings.json`, its hooks, a project's `.claude/`) load in a
|
|
1593
1783
|
den-claude agent, as in any `claude` you start.
|
|
1594
1784
|
|
|
@@ -1634,7 +1824,7 @@ are errors naming the file or the key.
|
|
|
1634
1824
|
|
|
1635
1825
|
- **`den-claude`** (the default) needs `claude` on PATH; doctor reads its
|
|
1636
1826
|
version from the install, without running it. It runs `claude --session-id
|
|
1637
|
-
<id> --mcp-config <file> --strict-mcp-config [--model <m>]
|
|
1827
|
+
<id> --mcp-config <file> --strict-mcp-config --settings <file> [--model <m>]
|
|
1638
1828
|
--append-system-prompt <role prompt>`, so Den's MCP server is the agent's
|
|
1639
1829
|
only one, and resumes with `claude --resume <id>`. Permission prompts stay
|
|
1640
1830
|
in the agent's terminal: it passes `--permission-mode default` unless the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@monster-orange/den",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -62,6 +62,8 @@
|
|
|
62
62
|
"@modelcontextprotocol/sdk": "1.32.1",
|
|
63
63
|
"@playwright/test": "^1.61.1",
|
|
64
64
|
"@slack/webhook": "^7.0.2",
|
|
65
|
+
"@xterm/addon-fit": "0.11.0",
|
|
66
|
+
"@xterm/xterm": "6.0.0",
|
|
65
67
|
"ajv": "^8.12.0",
|
|
66
68
|
"ajv-formats": "^2.1.1",
|
|
67
69
|
"auth0": "^4.3.1",
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEC-1c (SEC-1 spec §5): an agent's actor token.
|
|
3
|
+
*
|
|
4
|
+
* `agents start` (and resume) mints `den_at_<43 b64url>`: 32 random bytes,
|
|
5
|
+
* handed to the agent's session as DEN_ACTOR_TOKEN and to nothing else.
|
|
6
|
+
* The state dir keeps only its sha256 (`actor-tokens.json`, 0600, the dir
|
|
7
|
+
* 0700), with what it proves:
|
|
8
|
+
*
|
|
9
|
+
* { actor: "agent:<id>", workspace, grants, session: { name, id } }
|
|
10
|
+
*
|
|
11
|
+
* - One live token per agent: minting another (a resume, a restart)
|
|
12
|
+
* revokes the old one, so it stops working at once.
|
|
13
|
+
* - Revoked when the agent stops (`agents stop`, the page's Stop), and when
|
|
14
|
+
* its session ends: a token verifies only while the session it was minted
|
|
15
|
+
* for is listed as running in the session host's own record
|
|
16
|
+
* (`sessions/<id>.json`); an ended one's token is removed when presented.
|
|
17
|
+
* - It never derives from web.secret, so it proves nothing about the
|
|
18
|
+
* person. `web logout --all` (the "something leaked" button, SEC-1
|
|
19
|
+
* §6c.2) revokes every token as well as rotating the secret: each record
|
|
20
|
+
* is kept, marked `revokedAt`, so a running agent's next call is told
|
|
21
|
+
* `token_revoked` (restart or resume it) rather than a bare 401. Minting
|
|
22
|
+
* the agent's next token, or its session's end, drops the record.
|
|
23
|
+
*
|
|
24
|
+
* Nothing here prints or logs a token.
|
|
25
|
+
*/
|
|
26
|
+
import { createHash, randomBytes } from "crypto";
|
|
27
|
+
import * as fs from "fs";
|
|
28
|
+
import * as path from "path";
|
|
29
|
+
import { AGENT_ACTOR_RE, WORKSPACE_NAME_RE } from "./agent-actor.js";
|
|
30
|
+
import { sessionPaths } from "./sessions/paths.js";
|
|
31
|
+
|
|
32
|
+
export const ACTOR_TOKEN_PREFIX = "den_at_";
|
|
33
|
+
/** The agent's session carries its token here (AG spec §5a-env). */
|
|
34
|
+
export const ACTOR_TOKEN_ENV = "DEN_ACTOR_TOKEN";
|
|
35
|
+
/** A person's token in an env: never forwarded to any session. */
|
|
36
|
+
export const PERSON_TOKEN_ENV = "DEN_PERSON_TOKEN";
|
|
37
|
+
export const ACTOR_TOKENS_FILE = "actor-tokens.json";
|
|
38
|
+
|
|
39
|
+
const TOKEN_RE = /^den_at_[A-Za-z0-9_-]{43}$/;
|
|
40
|
+
/** web-secret.ts's PERSON_TOKEN_PREFIX and WEB_SECRET_FILE (it imports this module). */
|
|
41
|
+
const PERSON_PREFIX = "den_pt_";
|
|
42
|
+
const SECRET_FILE = "web.secret";
|
|
43
|
+
|
|
44
|
+
export interface ActorTokenRecord {
|
|
45
|
+
actor: `agent:${string}`;
|
|
46
|
+
workspace: string;
|
|
47
|
+
/** The agent's grants (agents.<id>.grants) when it was minted. */
|
|
48
|
+
grants: string[];
|
|
49
|
+
/** The session it was minted for: its name, and its id once started. */
|
|
50
|
+
session: { name: string; id?: string };
|
|
51
|
+
/** ISO 8601. */
|
|
52
|
+
createdAt: string;
|
|
53
|
+
/** ISO 8601: revoked by `web logout --all`. A revoked token never verifies. */
|
|
54
|
+
revokedAt?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface StoreFile {
|
|
58
|
+
v: 1;
|
|
59
|
+
/** sha256(token), hex → what it proves. */
|
|
60
|
+
tokens: Record<string, ActorTokenRecord>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function actorTokensPath(stateDir: string): string {
|
|
64
|
+
return path.join(stateDir, ACTOR_TOKENS_FILE);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The token's key in the store: sha256, hex. */
|
|
68
|
+
export function actorTokenKey(token: string): string {
|
|
69
|
+
return createHash("sha256").update(token).digest("hex");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Shaped like an actor token (says nothing about whether it is live). */
|
|
73
|
+
export function isActorTokenShape(v: string | undefined): v is string {
|
|
74
|
+
return typeof v === "string" && TOKEN_RE.test(v);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function load(stateDir: string): StoreFile {
|
|
78
|
+
try {
|
|
79
|
+
const raw = JSON.parse(
|
|
80
|
+
fs.readFileSync(actorTokensPath(stateDir), "utf-8"),
|
|
81
|
+
) as Partial<StoreFile>;
|
|
82
|
+
const tokens =
|
|
83
|
+
raw.tokens && typeof raw.tokens === "object" && !Array.isArray(raw.tokens)
|
|
84
|
+
? raw.tokens
|
|
85
|
+
: {};
|
|
86
|
+
return { v: 1, tokens };
|
|
87
|
+
} catch {
|
|
88
|
+
return { v: 1, tokens: {} };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function save(stateDir: string, data: StoreFile): void {
|
|
93
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
94
|
+
const file = actorTokensPath(stateDir);
|
|
95
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
96
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
97
|
+
fs.chmodSync(tmp, 0o600);
|
|
98
|
+
fs.renameSync(tmp, file);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A fresh token for agent `agentId`, its hash recorded, every older token
|
|
103
|
+
* of the same agent revoked. The token itself is returned once and never
|
|
104
|
+
* stored.
|
|
105
|
+
*/
|
|
106
|
+
export function mintActorToken(
|
|
107
|
+
stateDir: string,
|
|
108
|
+
o: {
|
|
109
|
+
agentId: string;
|
|
110
|
+
workspace: string;
|
|
111
|
+
grants?: readonly string[];
|
|
112
|
+
sessionName: string;
|
|
113
|
+
},
|
|
114
|
+
): { token: string; key: string } {
|
|
115
|
+
const actor = `agent:${o.agentId}` as const;
|
|
116
|
+
if (!AGENT_ACTOR_RE.test(actor))
|
|
117
|
+
throw new Error(`"${o.agentId}" is not an agent id`);
|
|
118
|
+
if (!WORKSPACE_NAME_RE.test(o.workspace))
|
|
119
|
+
throw new Error(`"${o.workspace}" is not a workspace name`);
|
|
120
|
+
const token = ACTOR_TOKEN_PREFIX + randomBytes(32).toString("base64url");
|
|
121
|
+
const key = actorTokenKey(token);
|
|
122
|
+
const data = load(stateDir);
|
|
123
|
+
for (const [k, r] of Object.entries(data.tokens))
|
|
124
|
+
if (r.actor === actor) delete data.tokens[k];
|
|
125
|
+
data.tokens[key] = {
|
|
126
|
+
actor,
|
|
127
|
+
workspace: o.workspace,
|
|
128
|
+
grants: (o.grants ?? []).filter((g) => WORKSPACE_NAME_RE.test(g)),
|
|
129
|
+
session: { name: o.sessionName },
|
|
130
|
+
createdAt: new Date().toISOString(),
|
|
131
|
+
};
|
|
132
|
+
save(stateDir, data);
|
|
133
|
+
return { token, key };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Bind a minted token to the session id the host gave its session. */
|
|
137
|
+
export function bindActorSession(
|
|
138
|
+
stateDir: string,
|
|
139
|
+
key: string,
|
|
140
|
+
sessionId: string,
|
|
141
|
+
): void {
|
|
142
|
+
const data = load(stateDir);
|
|
143
|
+
const rec = data.tokens[key];
|
|
144
|
+
if (!rec) return;
|
|
145
|
+
rec.session = { ...rec.session, id: sessionId };
|
|
146
|
+
save(stateDir, data);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Revoke one token (by its key). */
|
|
150
|
+
export function revokeActorToken(stateDir: string, key: string): boolean {
|
|
151
|
+
const data = load(stateDir);
|
|
152
|
+
if (!data.tokens[key]) return false;
|
|
153
|
+
delete data.tokens[key];
|
|
154
|
+
save(stateDir, data);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Revoke every token of agent `agentId`; how many there were. */
|
|
159
|
+
export function revokeAgentTokens(stateDir: string, agentId: string): number {
|
|
160
|
+
const data = load(stateDir);
|
|
161
|
+
const actor = `agent:${agentId}`;
|
|
162
|
+
let n = 0;
|
|
163
|
+
for (const [k, r] of Object.entries(data.tokens))
|
|
164
|
+
if (r.actor === actor) {
|
|
165
|
+
delete data.tokens[k];
|
|
166
|
+
n++;
|
|
167
|
+
}
|
|
168
|
+
if (n) save(stateDir, data);
|
|
169
|
+
return n;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Revoke every agent's token (`web logout --all`, SEC-1 §6c.2): each record
|
|
174
|
+
* is marked revoked, so its agent learns why. How many were live.
|
|
175
|
+
*/
|
|
176
|
+
export function revokeAllActorTokens(stateDir: string): number {
|
|
177
|
+
const data = load(stateDir);
|
|
178
|
+
const now = new Date().toISOString();
|
|
179
|
+
let n = 0;
|
|
180
|
+
for (const r of Object.values(data.tokens))
|
|
181
|
+
if (!r.revokedAt) {
|
|
182
|
+
r.revokedAt = now;
|
|
183
|
+
n++;
|
|
184
|
+
}
|
|
185
|
+
if (n) save(stateDir, data);
|
|
186
|
+
return n;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The agents whose token `web logout --all` revoked and whose record is
|
|
191
|
+
* still kept (their session still runs): the Agents tab marks them, so a
|
|
192
|
+
* person knows to restart or resume them (AG-4a-term). Ids only.
|
|
193
|
+
*/
|
|
194
|
+
export function revokedAgentIds(stateDir: string): Set<string> {
|
|
195
|
+
const out = new Set<string>();
|
|
196
|
+
for (const r of Object.values(load(stateDir).tokens))
|
|
197
|
+
if (r.revokedAt && AGENT_ACTOR_RE.test(r.actor))
|
|
198
|
+
out.add(r.actor.slice("agent:".length));
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** What a presented token was minted as, live or not; null when unknown. */
|
|
203
|
+
export function lookupActorToken(
|
|
204
|
+
stateDir: string,
|
|
205
|
+
token: string,
|
|
206
|
+
): ActorTokenRecord | null {
|
|
207
|
+
if (!isActorTokenShape(token)) return null;
|
|
208
|
+
return load(stateDir).tokens[actorTokenKey(token)] ?? null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Whether the session host lists session `id` as running (its own record). */
|
|
212
|
+
export function sessionRunning(stateDir: string, id: string): boolean {
|
|
213
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) return false;
|
|
214
|
+
try {
|
|
215
|
+
const rec = JSON.parse(
|
|
216
|
+
fs.readFileSync(
|
|
217
|
+
path.join(sessionPaths(stateDir).dir, `${id}.json`),
|
|
218
|
+
"utf-8",
|
|
219
|
+
),
|
|
220
|
+
) as { exited?: unknown };
|
|
221
|
+
return rec.exited !== true;
|
|
222
|
+
} catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* What a presented token proves: its record; `"revoked"` when `web logout
|
|
229
|
+
* --all` revoked it and its session still runs (the agent is told to
|
|
230
|
+
* restart or resume); or null: unknown, not yet bound to a session, or its
|
|
231
|
+
* session has ended (then its record is removed here).
|
|
232
|
+
*/
|
|
233
|
+
export function checkActorToken(
|
|
234
|
+
stateDir: string,
|
|
235
|
+
token: string,
|
|
236
|
+
): ActorTokenRecord | "revoked" | null {
|
|
237
|
+
const rec = lookupActorToken(stateDir, token);
|
|
238
|
+
if (!rec?.session.id) return null;
|
|
239
|
+
if (!sessionRunning(stateDir, rec.session.id)) {
|
|
240
|
+
try {
|
|
241
|
+
revokeActorToken(stateDir, actorTokenKey(token));
|
|
242
|
+
} catch {
|
|
243
|
+
/* a read-only state dir: still refused */
|
|
244
|
+
}
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
return rec.revokedAt ? "revoked" : rec;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The record a presented token proves, or null (checkActorToken, revoked or not). */
|
|
251
|
+
export function verifyActorToken(
|
|
252
|
+
stateDir: string,
|
|
253
|
+
token: string,
|
|
254
|
+
): ActorTokenRecord | null {
|
|
255
|
+
const r = checkActorToken(stateDir, token);
|
|
256
|
+
return r === "revoked" ? null : r;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A person's credential in an env (SEC-1 §5): DEN_PERSON_TOKEN, a value
|
|
261
|
+
* shaped like a person token, or a value that names a web.secret file.
|
|
262
|
+
* No session Den starts ever gets one, and `--keep-env` can't forward one.
|
|
263
|
+
*/
|
|
264
|
+
export function isPersonCredentialEnv(
|
|
265
|
+
name: string,
|
|
266
|
+
value: string | undefined,
|
|
267
|
+
): boolean {
|
|
268
|
+
if (name === PERSON_TOKEN_ENV) return true;
|
|
269
|
+
if (value === undefined) return false;
|
|
270
|
+
const v = value.trim();
|
|
271
|
+
if (v.includes(PERSON_PREFIX)) return true;
|
|
272
|
+
return v
|
|
273
|
+
.split(/[:;,\s]+/)
|
|
274
|
+
.some((part) => path.basename(part.replace(/[/\\]+$/, "")) === SECRET_FILE);
|
|
275
|
+
}
|
|
@@ -43,6 +43,11 @@ export interface AgentInfo {
|
|
|
43
43
|
|
|
44
44
|
export interface AgentActor extends AgentInfo {
|
|
45
45
|
kind: "agent";
|
|
46
|
+
/**
|
|
47
|
+
* SEC-1c: set when this came from headers the caller set, not from an
|
|
48
|
+
* agent's actor token. The audit keeps it as a claim, never as the actor.
|
|
49
|
+
*/
|
|
50
|
+
unverified?: true;
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
/** null: a person in the web UI (no agent headers). */
|
|
@@ -75,7 +80,11 @@ export function agentAuditHeaders(info: AgentInfo): Record<string, string> {
|
|
|
75
80
|
};
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* The actor an incoming request's headers claim, or null. SEC-1c: a claim,
|
|
85
|
+
* never the audit actor; the server's auditActorOf (request-guard.ts)
|
|
86
|
+
* takes the actor from the credential and keeps this as unverified.
|
|
87
|
+
*/
|
|
79
88
|
export function actorFromHeaders(
|
|
80
89
|
headers: IncomingHttpHeaders | Record<string, string | undefined>,
|
|
81
90
|
): Actor {
|
|
@@ -107,6 +116,7 @@ export function actorLabel(actor: AgentActor): string {
|
|
|
107
116
|
const ws = actor.workspace ? `workspace ${actor.workspace}, ` : "";
|
|
108
117
|
return (
|
|
109
118
|
`${who} ${actor.client} ${actor.clientVersion} ` +
|
|
110
|
-
`(${ws}mcp pid ${actor.mcpPid ?? "?"}, session ${actor.session || "?"})`
|
|
119
|
+
`(${ws}mcp pid ${actor.mcpPid ?? "?"}, session ${actor.session || "?"})` +
|
|
120
|
+
(actor.unverified ? " (unverified)" : "")
|
|
111
121
|
);
|
|
112
122
|
}
|