@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +409 -0
  2. package/docs/distributions.md +204 -14
  3. package/package.json +3 -1
  4. package/src/shared/actor-token.ts +275 -0
  5. package/src/shared/agent-actor.ts +12 -2
  6. package/src/shared/agents/den-claude.ts +99 -1
  7. package/src/shared/agents/runtime.ts +7 -0
  8. package/src/shared/agents/runtimes.ts +9 -2
  9. package/src/shared/environment-manager/scope.ts +22 -0
  10. package/src/shared/environment-manager/service-runner.ts +94 -2
  11. package/src/shared/profile.ts +15 -4
  12. package/src/shared/sessions/client.ts +9 -2
  13. package/src/shared/sessions/env.ts +13 -3
  14. package/src/shared/sessions/host.ts +23 -0
  15. package/src/shared/web-secret.ts +181 -0
  16. package/src/shared/web-session.ts +491 -0
  17. package/src/ui/cli/agent-ops.ts +843 -0
  18. package/src/ui/cli/agents-command.ts +96 -614
  19. package/src/ui/cli/flag-parser.ts +21 -0
  20. package/src/ui/cli/index.ts +94 -10
  21. package/src/ui/cli/sessions-command.ts +32 -0
  22. package/src/ui/cli/target-workspace.ts +105 -0
  23. package/src/ui/cli/web-command.ts +536 -14
  24. package/src/ui/mcp/api.ts +7 -0
  25. package/src/ui/mcp/server.ts +74 -6
  26. package/src/ui/server/agent-routes.ts +323 -0
  27. package/src/ui/server/audit.ts +81 -27
  28. package/src/ui/server/http-errors.ts +21 -3
  29. package/src/ui/server/index.ts +814 -66
  30. package/src/ui/server/public/agents-page.js +648 -0
  31. package/src/ui/server/public/agents-section.js +299 -0
  32. package/src/ui/server/public/agents.html +439 -0
  33. package/src/ui/server/public/appearance.js +3 -0
  34. package/src/ui/server/public/error-page.js +103 -0
  35. package/src/ui/server/public/header.js +45 -11
  36. package/src/ui/server/public/i18n/es.js +90 -1
  37. package/src/ui/server/public/i18n/ja.js +86 -1
  38. package/src/ui/server/public/local-dev.html +53 -0
  39. package/src/ui/server/public/logs.html +3 -0
  40. package/src/ui/server/public/panel.js +1 -1
  41. package/src/ui/server/public/release.html +8 -0
  42. package/src/ui/server/public/settings-page.js +5 -0
  43. package/src/ui/server/public/settings-registry.js +33 -0
  44. package/src/ui/server/public/shell.js +248 -5
  45. package/src/ui/server/public/topbar.js +51 -2
  46. package/src/ui/server/request-guard.ts +670 -0
  47. package/src/ui/server/route-classes.ts +317 -0
  48. package/src/ui/server/session-routes.ts +445 -0
  49. package/src/ui/server/shutdown-guard.ts +91 -0
  50. package/src/ui/server/sudo-store.ts +29 -3
  51. package/src/ui/server/ui-assets.ts +10 -1
  52. package/src/ui/server/websocket.ts +326 -0
  53. package/src/ui/server/workspace-routes.ts +13 -7
@@ -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; WS-3a and SEC-1 close
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.0",
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
- /** The actor an incoming request names, or null for a person in the UI. */
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
  }