@monster-orange/den 0.2.6 → 0.2.8

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 (37) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/bin/den +9 -0
  3. package/docs/distributions.md +205 -6
  4. package/package.json +1 -1
  5. package/service/install.sh +41 -2
  6. package/src/shared/agent-actor.ts +38 -3
  7. package/src/shared/config-error.ts +33 -0
  8. package/src/shared/den-config.ts +84 -5
  9. package/src/shared/environment-manager/catalog.ts +61 -3
  10. package/src/shared/environment-manager/default-workspace.ts +53 -14
  11. package/src/shared/environment-manager/git-remote.ts +49 -0
  12. package/src/shared/environment-manager/health.ts +51 -8
  13. package/src/shared/environment-manager/service-runner.ts +51 -5
  14. package/src/shared/environment-manager/workspace-manager.ts +69 -26
  15. package/src/shared/environment-manager/workspace-org.ts +279 -0
  16. package/src/shared/manifest/service-links.ts +20 -8
  17. package/src/shared/org-sources.ts +309 -0
  18. package/src/shared/types/index.ts +19 -2
  19. package/src/ui/cli/clone-plan.ts +374 -0
  20. package/src/ui/cli/flag-parser.ts +57 -3
  21. package/src/ui/cli/index.ts +346 -48
  22. package/src/ui/cli/org-command.ts +172 -0
  23. package/src/ui/cli/results-viewer.ts +5 -5
  24. package/src/ui/cli/table.ts +20 -0
  25. package/src/ui/cli/up-args.ts +15 -0
  26. package/src/ui/cli/validate-all.ts +171 -0
  27. package/src/ui/mcp/scope.ts +277 -0
  28. package/src/ui/mcp/server.ts +94 -9
  29. package/src/ui/mcp/tools.ts +101 -17
  30. package/src/ui/server/audit.ts +90 -3
  31. package/src/ui/server/daemon-reaper.ts +22 -4
  32. package/src/ui/server/github-service.ts +6 -1
  33. package/src/ui/server/index.ts +96 -5
  34. package/src/ui/server/public/panel.js +1 -1
  35. package/src/ui/server/readonly-routes.ts +12 -3
  36. package/src/ui/server/release-service.ts +40 -5
  37. package/src/ui/server/workspace-routes.ts +13 -6
package/CHANGELOG.md CHANGED
@@ -4,6 +4,231 @@ 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.2.8] - 2026-11-05
8
+
9
+ ### Added
10
+
11
+ - **A scoped MCP server for cooperating agents** (0.2.8-dev.0, AG-2a-core).
12
+ `<cli> mcp --workspace <ws> --actor agent:<id>` pins one workspace: a tool
13
+ that takes a workspace gets `<ws>`, and a call naming another is refused
14
+ before it reaches the web server and recorded in the activity trail.
15
+ Lists are filtered to `<ws>`: `workspaces` shows it alone, the log tools
16
+ and `manifest_validate` take only its services and repos, `workspace_sync`
17
+ syncs it, and the test tools need it to be the focused workspace. Every
18
+ web API call is stamped with the actor (`x-den-mcp-agent`,
19
+ `x-den-mcp-workspace`), so each exec, run and sync in the activity trail
20
+ and the server log names `agent:<id>` and its workspace. Both flags or
21
+ neither; a bad value is a usage error (exit 2). `--list` and `--call` take
22
+ them too. Without them the server is unchanged. **Limit:** it binds
23
+ cooperating agents only. The scope lives in the MCP process, and the web
24
+ server's HTTP API does not check it yet (WS-3a, 0.4); `mcp connect` still
25
+ registers the unscoped server.
26
+ - **`<cli> setup --plan -w <ws>` and `--all-active`** (0.2.8-dev.0). `-w` is
27
+ repeatable and plans those workspaces, active or not; `--all-active` plans
28
+ every active one; on a fresh machine `-w` narrows the blueprint's plan. A
29
+ closing hint names the repos only workspaces outside the plan hold. `-w`
30
+ and `--all-active` go with `--plan` only.
31
+ - **Orgs are Den's own; sources are bindings** (0.2.8-dev.1, ORG-1d). An
32
+ org is any key of `orgs:`. `orgs.<id>.sources: [{host, owner|group}]`
33
+ claims git-host owners for it (an org can span two GitHub owners), and
34
+ `orgs.<id>.repos: [...]` lists repos outright (one owner can be split
35
+ across orgs). Catalog entries take `remote: <git url>` for any host;
36
+ `gh: owner/repo` stays the GitHub shorthand. A repo's org is the org
37
+ listing it, else the one whose source claims its owner, else its GitHub
38
+ owner; inference, `/api/orgs` and `org list` use it, and a claimed owner
39
+ is not listed as its own org. A repo clones from its source (GitHub with
40
+ `gh` as credential helper), or with no source from its `remote:` through
41
+ plain git and the user's own credential helper (SEC-0); `setup --plan`
42
+ shows that URL. Discovery (`register`) and the Release page skip an org
43
+ with no GitHub source. A bad value names its key; a repo or owner claimed
44
+ by two orgs is an error naming both. See docs/distributions.md, Orgs.
45
+ - **`<cli> register <path> -w <ws>`** (0.2.8-dev.0) writes that workspace,
46
+ active or not, and leaves focus alone. It used to fail with "-w/--workspace
47
+ is not valid with register".
48
+
49
+ ### Changed
50
+
51
+ - **`setup --plan` and `up --plan` show a dev-managed repo's real branch**
52
+ (0.2.8-dev.0): the one its checkout has out (read from `.git/HEAD`, no git
53
+ process), or `(branch unknown)`, where they printed `main` for every
54
+ checkout.
55
+ - **`up --plan` exits 1 when nothing resolves** (0.2.8-dev.0): a name no
56
+ workspace has, or a workspace with nothing to start. It exited 0.
57
+ - **`register` with no terminal exits 2** (0.2.8-dev.0) with one usage line
58
+ that names `-w`: `register needs a terminal (stdin is not a TTY); without
59
+ one: <cli> register <path> [--name <repo>] [--gh <owner/repo>] [-w
60
+ <workspace>]`. It went on to prompt with nothing to answer.
61
+
62
+ ### Fixed
63
+
64
+ - **A config that fails validation stops every command** (0.2.8-dev.2).
65
+ A repo or owner claimed by two orgs (or any other bad ORG-1d value) is
66
+ checked before any command that touches orgs, the catalog, clones or
67
+ releases runs, and it exits 1 naming both keys. `sync` over existing
68
+ clones exited 0 and pulled, because it reads only workspaces.yml and the
69
+ catalog; `register <path> -w <ws>` wrote workspaces.yml and the catalog,
70
+ `workspace create` wrote workspaces.yml, and `workspace use` /
71
+ `add-service` exited 0. Now each stops before any git call or write. `web`, `mcp`, `validate`, `create`,
72
+ `config`, `local-dev` and `view-results` still run.
73
+ - **The web API answers a config that fails validation as JSON**
74
+ (0.2.8-dev.2). Every API route that reads the config answers 422
75
+ `{ ok: false, error: "<the message naming the keys>", code:
76
+ "config_invalid" }`, where `/api/orgs`, `/api/services` and others
77
+ answered a text/plain 500 "internal server error" and some routes turned
78
+ it into a 400 or an empty list. The error names config keys, never
79
+ values. `/api/server-info`, `/api/server-log`, `/api/changelog` and
80
+ `/api/web/restart` still answer, and pages still load.
81
+ - **A scoped MCP `manifest_validate` refused on another workspace's path
82
+ shows in that repo's activity** (0.2.8-dev.2). The refusal is recorded
83
+ under the catalog repo whose checkout holds the path (in any workspace),
84
+ as `logs_tail` refusals are since 0.2.8-dev.1. The entry is addressed by
85
+ the repo, not the path; its detail is the refusal message, which quotes
86
+ the path.
87
+ - **A scoped MCP refusal of a non-lifecycle tool shows on its service**
88
+ (0.2.8-dev.1). A refused `logs_tail`, `logs_search` or other non-lifecycle tool
89
+ was stored with an empty detail and no service, so the service panel's
90
+ activity (`GET /api/activity?address=<repo>/<svc>`) never showed it. The
91
+ entry's detail is now the refusal, and it carries `addresses`: the repo
92
+ or `repo/service` the call named, or every target of a `logs_search`
93
+ (one entry, listed under each).
94
+ - **`up --plan -w <ws>` with nothing to start is a usage error**
95
+ (0.2.8-dev.1): one line, exit 2, naming `up -a -w <ws>` (the whole
96
+ workspace) and `up <repo> -w <ws>`. It printed "No services specified and
97
+ no invocation target resolvable".
98
+ - **Test workers inherit no credentials** (0.2.8-dev.0). `tests/setup.ts`
99
+ strips credential-shaped variables (by name, family or token shape) from
100
+ the environment before any suite runs, so a failing assertion on a
101
+ spawn env can no longer print a developer's tokens.
102
+
103
+ ### Migrations
104
+
105
+ - None. A script that ran `up --plan` and ignored its exit code may now see
106
+ 1 when the plan is empty (2 for `up --plan -w <ws>` with no repo or
107
+ `-a`).
108
+ - ORG-1d (`orgs.<id>.sources`, `orgs.<id>.repos`, catalog `remote:`) is
109
+ optional: no change is needed, and a config without those keys behaves
110
+ exactly as on 0.2.7. A den.yml that uses them needs Den 0.2.8 or later.
111
+ - A client of the web API that reads a config error should check for
112
+ status 422 and `code: "config_invalid"` (0.2.8-dev.2); it was a
113
+ text/plain 500.
114
+
115
+ ## [0.2.7] - 2026-11-05
116
+
117
+ ### Added
118
+
119
+ - **A workspace belongs to an org** (0.2.7-dev.2, ORG-1a). Its org is
120
+ `org:` on the workspace in workspaces.yml, else `org:` on the blueprint
121
+ workspace (`defaults.workspaces.<n>.org`, the one new blueprint key), else
122
+ the one catalog owner of its repos (case-insensitive; `gh: ""` ignored),
123
+ else `github_org`, the default org. It is computed on read, never written
124
+ to workspaces.yml. A config with no `org:` resolves to `github_org` as
125
+ before, unless all of a workspace's repos have one other owner in the
126
+ catalog.
127
+ - **`<cli> org list`** (0.2.7-dev.2, ORG-1a): each org's label, its
128
+ workspaces on this machine, which is the default, and the org warnings.
129
+ `<cli> workspace list` gains an Org column; `--org <id>` filters it.
130
+ - **Org warnings, never errors** (0.2.7-dev.2, ORG-1a, C1): a workspace
131
+ whose repos span several owners, an `org:` naming an owner Den does not
132
+ know, a repo in another owner than its workspace's org, a workspaces.yml
133
+ `org:` that overrides the blueprint's. Shown by `org list` and
134
+ `workspace list`.
135
+ - **`<cli> setup --plan`** (0.2.7-dev.2, ORG-1a): each repo's org and clone
136
+ URL, and whether it is cloned, then stop. On a fresh machine it plans the
137
+ blueprint's workspaces; once seeded, the focused workspace. `<cli> up
138
+ --plan` prints the same lines for its repos. Nothing is cloned, seeded or
139
+ written.
140
+ - **`GET /api/orgs` entries gain `default` and `workspaces`**, and
141
+ `/api/<cli>/config` gains `default_org` (0.2.7-dev.2, ORG-1a). Every
142
+ existing field, entry and order is unchanged; `?repos=1` still maps each
143
+ repo to its owner string.
144
+ - **`<cli> register --org <id>`** (0.2.7-dev.2, ORG-1a) lists another org's
145
+ repos for a managed clone.
146
+
147
+ - **Bare `<cli> validate` checks every manifest** (0.2.7-dev.1, from a
148
+ consumer). In the distribution root, with no argument: each workspace repo
149
+ once per checkout (own or held, plus any override, then the merge), the
150
+ held manifest when there is no checkout, and held manifests no workspace
151
+ uses. One line per repo, details on failure, exit 1 if any fails, nothing
152
+ cloned. `--held` checks `manifests/` alone, independent of the machine. A
153
+ path argument works as before.
154
+
155
+ ### Changed
156
+
157
+ - **A repo is released from its own org** (0.2.7-dev.2, REL-1). With no
158
+ `release.org`, a repo with no `gh:` in `release.repos` released from
159
+ `github_org`, so a client org's repo targeted the wrong owner. Its owner
160
+ is now an explicit `release.org` (unchanged: it still wins for every
161
+ repo), else the repo's non-empty catalog owner, else `github_org`.
162
+ - **`register` lists the focused workspace's org** (0.2.7-dev.2, ORG-1a),
163
+ not `github_org` alone, and no longer falls back to the owner of Den's own
164
+ git remote (SEC-0): with no org it says how to set one.
165
+
166
+ ### Fixed
167
+
168
+ - **A second root's server leaves the first root's services alone**
169
+ (0.2.7-dev.3, dev.4). Every root of a product shares one daemons file.
170
+ A server's two startup sweeps (the stale-daemon prune and the health
171
+ reaper) dropped any record whose address did not resolve in its own
172
+ workspaces, so starting a scratch root's server silently forgot another
173
+ root's running dev server; an interactive Ctrl-C stopped every service in
174
+ the file, and deactivating a workspace released any record with the same
175
+ workspace name. Records are now stamped with the root that started them
176
+ and a server lists, stops, releases and sweeps only its own (other roots'
177
+ records stay in the file untouched). An older unstamped record is never
178
+ dropped just for not resolving; it still goes when its containers are
179
+ verifiably gone or two health checks fail. The reaper now logs what it
180
+ removes.
181
+ - **A service the edge has no route for is no longer healthy** (0.2.7-dev.2).
182
+ A health URL served through the edge answered the edge's own
183
+ `404 page not found` when the service had no route, and Den counted any
184
+ 404 as up. That exact 404 is now down with "no route (edge 404)", over
185
+ HTTP and HTTPS; a 404 from the service itself is still up.
186
+ - **A client workspace no longer clones from the default org** (0.2.7-dev.2,
187
+ ORG-1a). Seeding, `up` and the web sync gave a repo with no clone URL
188
+ `<github_org>/<repo>`; it is now `<org>/<repo>` with its workspace's org.
189
+ - **`<cli> setup` clones a catalog entry with `gh: ""`** from its
190
+ workspace's org (0.2.7-dev.2), as `up` does, instead of failing on an
191
+ empty slug.
192
+ - **A seed without a terminal no longer resets a workspace that has
193
+ entries** (SEED-0). `<cli> init` and `<cli> workspace seed` from a script
194
+ or `make` replaced every entry of the default (or blueprint) workspaces with
195
+ managed clones, your own dev-managed checkouts included, without asking.
196
+ Without a TTY they now refuse (exit 1, nothing written) and name
197
+ `--reset`; on a TTY they still ask, default No. `init --reset` and
198
+ `workspace seed --reset` reset as before.
199
+ - **A failed seed leaves the workspaces as they were** (0.2.7-dev.1, from a
200
+ consumer). Seeding wrote workspaces.yml, then filled the catalog, which
201
+ fails with no `github_org`: the workspace was reset and nothing could be
202
+ cloned. The catalog comes first now, and the org is needed only for a repo
203
+ with no clone URL in the catalog.
204
+ - **A seed refusal names the command that was run** (`init --reset` or
205
+ `workspace seed --reset`).
206
+ - **A `DEN_ROOT` with no den.yml is a one-line error** (exit 2), not a stack
207
+ trace from deep in module loading.
208
+ - **Piped output carries no ANSI from tables** (`status`, `services`, test
209
+ results). cli-table3 coloured borders and head cells itself, ignoring
210
+ NO_COLOR and a pipe.
211
+ - **`<cli> mcp --call` validates its arguments** against the tool's schema,
212
+ strictly: an unknown or misspelt key, or a bad value, exits 2 with the
213
+ problem and the keys the tool takes, and calls nothing.
214
+ - **The web UI service survives a removed nvm version.** Install prefers a
215
+ stable node of the same major version over a version manager's (or uses
216
+ `DEN_SERVICE_NODE`), and warns when it must keep one. `service status`
217
+ exits 1, naming it, when the installed node no longer exists.
218
+
219
+ - **`<cli> init` in the distribution root seeds even through a symlinked
220
+ path** (`/var` → `/private/var`, a symlinked home). It compared paths
221
+ without resolving links and ran the manifest scaffolder instead.
222
+
223
+ ### Migrations
224
+
225
+ - A script that relied on a non-interactive seed resetting a changed
226
+ workspace must pass `--reset`.
227
+ - Optional (ORG-1a): to put a blueprint workspace in an org other than the
228
+ one its catalog owners give, add `org: <id>` to it under
229
+ `defaults.workspaces`. Pin Den 0.2.7 or later first: an older Den rejects
230
+ the key. Nothing else needs changing.
231
+
7
232
  ## [0.2.6] - 2026-11-04
8
233
 
9
234
  ### Fixed
package/bin/den CHANGED
@@ -49,6 +49,15 @@ if [ "${1:-}" = "mcp" ] && ! case "${2:-}" in connect|disconnect|clients|config)
49
49
  built="dist/src/ui/cli/mcp-main.js"
50
50
  fi
51
51
 
52
+ # A DEN_ROOT that names no distribution is a usage error, not a crash: say
53
+ # so here, before any module resolves paths from it at import time.
54
+ # (`create` needs no distribution.)
55
+ if [ -n "${DEN_ROOT:-}" ] && [ "$entry" != "src/ui/cli/create-main.ts" ] && [ ! -f "$DEN_ROOT/den.yml" ]; then
56
+ echo "den: DEN_ROOT is set to \"$DEN_ROOT\" but that directory has no den.yml." >&2
57
+ echo " Point it at your distribution's checkout, or unset it." >&2
58
+ exit 2
59
+ fi
60
+
52
61
  # Escape hatch: force the compiled build (e.g. to test the prod artifact).
53
62
  if [ "${DEN_USE_DIST:-}" = "1" ] && [ -f "$DEN_ENGINE_ROOT/$built" ]; then
54
63
  exec node "$DEN_ENGINE_ROOT/$built" "$@"
@@ -104,6 +104,23 @@ naming the file each error is in, then checks the merged result:
104
104
  ERROR services.api.commands.local.down unknown key "down": not in the base manifest
105
105
  ```
106
106
 
107
+ Run with no argument in the distribution root, `<cli> validate` checks every
108
+ manifest the distribution uses, and never clones:
109
+
110
+ - each repo of every workspace, once per checkout path, with the
111
+ workspaces that use it and where its base comes from (`own`, `held`,
112
+ `+ override`);
113
+ - a repo with no checkout on disk falls back to its held manifest, marked
114
+ `held only, no checkout`. With no held manifest either it is a warning, not
115
+ a failure;
116
+ - each held manifest in `manifests/` that no workspace uses.
117
+
118
+ It prints one line per repo, details only for a layer with errors or
119
+ warnings, and exits 1 if any fails. `<cli> validate --held` checks only
120
+ `manifests/`, as if nothing were checked out, so the result does not depend on
121
+ the machine (use it in a CI or upgrade gate). An override whose base is the
122
+ repo's own manifest is noted there, not checked.
123
+
107
124
  The web UI's Services cards show the layers each manifest was built from
108
125
  (`manifest: repo + override`), and `/api/services` returns them per service as
109
126
  `manifestLayers`, e.g. `["held", "override"]`. `DEN_MANIFESTS_DIR` points the
@@ -435,8 +452,8 @@ defaults:
435
452
  focus: true
436
453
  ```
437
454
 
438
- Each entry takes `repos`, `branch` and `autostart_off`, the same fields as
439
- `defaults.workspace`, plus `focus`. Seeding creates every named workspace
455
+ Each entry takes `repos`, `branch`, `autostart_off` and `org`, the same
456
+ fields as `defaults.workspace`, plus `focus`. Seeding creates every named workspace
440
457
  with its repos, activates them all, and focuses the one with `focus: true`
441
458
  (else the first). Set `defaults.workspace` or `defaults.workspaces`, not both:
442
459
  both at once is an error, as is more than one `focus: true`.
@@ -452,6 +469,141 @@ Once seeded, a repo that later joins `defaults.workspace.repos` is added to
452
469
  you removed from it or that another workspace holds. With
453
470
  `defaults.workspaces` nothing is topped up.
454
471
 
472
+ To see what a setup would clone before it clones anything, run
473
+ `acme setup --plan`: each repo with its org and clone URL, and whether it
474
+ is cloned yet. On a fresh machine it plans the blueprint (every workspace
475
+ `acme workspace seed` would create); once seeded, the focused workspace.
476
+ `-w <workspace>` (repeatable) plans those workspaces instead, active or not,
477
+ and `--all-active` every active one; on a fresh machine `-w` narrows the
478
+ blueprint's plan. Repos that only workspaces outside the plan hold are named
479
+ in a closing hint. A dev-managed repo shows the branch its checkout has out
480
+ (read from its `.git/HEAD`), not a configured one. `acme up --plan` lists
481
+ the same lines for the repos it would start, and exits 1 when nothing
482
+ resolves (a name no workspace has, an empty workspace). Both are read-only:
483
+ nothing is cloned, seeded or written.
484
+
485
+ ## Orgs
486
+
487
+ A distribution can hold work for more than one org: each workspace belongs
488
+ to one. An org's id is a key of `orgs:` or a GitHub owner, and today an org
489
+ is the GitHub owner of the same name. `github_org` is the default org. A
490
+ distribution that never mentions orgs has one org, the default, and every
491
+ workspace is in it.
492
+
493
+ ```yaml
494
+ github_org: acme
495
+ orgs:
496
+ acme: { label: Acme }
497
+ globex: { label: Globex, color: "#0af" }
498
+ defaults:
499
+ workspaces:
500
+ storefront:
501
+ repos: [shop-web, shop]
502
+ client:
503
+ org: globex # optional: the workspace's org
504
+ repos: [globex-web, globex-api]
505
+ ```
506
+
507
+ A workspace's org is the first of:
508
+
509
+ 1. `org:` on the workspace in `config/local/workspaces.yml` (this machine)
510
+ 2. `org:` on the blueprint workspace of the same name (committed)
511
+ 3. the one owner of its repos in the catalog (`gh: <owner>/<repo>`; owners
512
+ compare case-insensitively, and a `gh: ""` counts as unknown)
513
+ 4. `github_org`
514
+
515
+ It is worked out each time it is read and never written back. A repo with
516
+ no clone URL is cloned from `<org>/<repo>` with its workspace's org: seeding
517
+ the blueprint above gives `globex-web` `gh: globex/globex-web`, not
518
+ `acme/...`. `acme register` picks a managed clone from the focused
519
+ workspace's org, or from `--org <id>`.
520
+
521
+ `acme org list` shows each org's label, its workspaces and which is the
522
+ default. `acme workspace list` has an Org column, and `--org <id>` lists one
523
+ org's workspaces. A workspace whose repos belong to several owners, an
524
+ `org:` naming an owner Den does not know, or a repo in another owner than
525
+ its workspace's org is a warning in both lists, never an error.
526
+ `GET /api/orgs` adds `default` and `workspaces` to each entry, and
527
+ `/api/<cli>/config` adds `default_org`.
528
+
529
+ `org:` in the blueprint needs Den 0.2.7 or later: an older Den rejects the
530
+ unknown key. Pin 0.2.7 before you commit it.
531
+
532
+ ### Sources and repos (0.2.8)
533
+
534
+ An org is Den's own: its id is any key of `orgs:`, and it needs no git
535
+ host. Two optional keys bind it to where its repos live:
536
+
537
+ ```yaml
538
+ github_org: acme
539
+ orgs:
540
+ acme-group: # one org, two GitHub owners
541
+ label: Acme Group
542
+ sources:
543
+ - { host: github, owner: acme }
544
+ - { host: github, owner: acme-labs }
545
+ globex-billing: # one owner split across two orgs
546
+ repos: [globex-invoices]
547
+ globex:
548
+ sources: [{ host: github, owner: globex }]
549
+ initech: # no git host at all
550
+ sources: []
551
+ repos: [widget]
552
+ ```
553
+
554
+ - `orgs.<id>.sources` is a list of `{host, owner}` (or `{host, group}`). A
555
+ source claims that owner's repos for the org. `host: github` is built
556
+ in; any other host id (`gitlab`, …) claims repos whose `remote:` is on
557
+ `<host>.com` or a `<host>.` hostname, and clones them from that remote.
558
+ - `orgs.<id>.repos` lists repos that belong to the org whatever their
559
+ owner.
560
+ - An entry with neither key is what it was before 0.2.8: the GitHub owner
561
+ of its own name.
562
+
563
+ The catalog (`config/local/service-catalog.yml`) takes a `remote:` for any
564
+ host, beside `gh: owner/repo`, which stays as the GitHub shorthand:
565
+
566
+ ```yaml
567
+ widget: { remote: git@git.initech.example:tools/widget.git }
568
+ ```
569
+
570
+ A repo's org is the org whose `repos` lists it, else the org with a source
571
+ that claims its owner, else its GitHub owner as before. That org is what
572
+ `/api/orgs?repos=1` maps the repo to and what infers a workspace's org (step
573
+ 3 above). A claimed owner is not listed as an org of its own, and a
574
+ `github_org` that names one makes the claiming org the default. Service rows
575
+ (`/api/services`, `/api/health`) keep `org` as the repo's GitHub owner.
576
+
577
+ A repo clones from its source: a GitHub source gives
578
+ `https://github.com/<owner>/<repo>.git` with your `gh` as credential helper.
579
+ A repo with no source that claims it clones and pulls from its `remote:`
580
+ through plain git, with your own git credential helper; Den never hands it
581
+ `gh` or any credential of its own. A remote must be `https://`, `ssh://`,
582
+ `git://`, `file://`, `user@host:path` or an absolute path. `setup --plan`
583
+ shows it as the clone URL, with any user info masked.
584
+
585
+ An org with no GitHub source has no repo discovery (`register` lists
586
+ nothing for it) and its repos are left off the Release page.
587
+
588
+ These keys are checked when den.yml is read, and a bad value is an error
589
+ that names its key (`orgs.initech.sources[0].owner must be …`). A repo
590
+ listed by two orgs, or an owner claimed by two orgs' sources (or by a
591
+ source and an org with no `sources` of that name), is an error naming both
592
+ keys. Such an error stops every command that touches orgs, the catalog,
593
+ clones or releases (exit 1, before any git call or write), and every web
594
+ API route that reads the config answers it as 422 `{ "ok": false, "error":
595
+ "<message>", "code": "config_invalid" }`. Without the new keys nothing
596
+ changes: `/api/orgs` is byte for byte what 0.2.7 answered. They need Den
597
+ 0.2.8 or later.
598
+
599
+ ## Releases
600
+
601
+ The Release page dispatches each repo's CD workflow on GitHub. A repo's
602
+ owner there is its own `gh: owner/repo` in `release.repos`, else
603
+ `release.org` when it is set, else the repo's owner in the catalog, else
604
+ `github_org`. Leave `release.org` unset when the repos belong to different
605
+ orgs: each then releases from its own.
606
+
455
607
  ## The bin shim
456
608
 
457
609
  `bin/<cli>` is three lines of sh: it names the distribution root and runs
@@ -580,10 +732,17 @@ node_modules/@monster-orange/den/service/install.sh print # the plist only
580
732
  | `DEN_ROOT` | required in a distribution |
581
733
  | `DEN_SERVICE_LABEL` | `local.<distribution dir name>.web` |
582
734
  | `DEN_SERVICE_LOGS` | `~/Library/Logs/<distribution dir name>` |
583
-
584
- The service runs the engine's server with the `node` on your `PATH` at install
585
- time, from the distribution root; re-run `install` after changing node versions
586
- or upgrading Den. Keep the label stable: it is how launchd knows the service.
735
+ | `DEN_SERVICE_NODE` | see below |
736
+
737
+ The service runs the engine's server, from the distribution root, with the
738
+ `node` on your `PATH` at install time. A version manager's node (nvm, fnm,
739
+ volta, asdf, mise) lives under a versioned path that disappears when that
740
+ version is removed, so install prefers `/opt/homebrew/bin/node` or
741
+ `/usr/local/bin/node` when one has the same major version, and otherwise
742
+ keeps the version manager's and warns. `DEN_SERVICE_NODE` names the node
743
+ outright. `service status` exits 1 when the installed node no longer exists.
744
+ Re-run `install` after changing node versions or upgrading Den. Keep the
745
+ label stable: it is how launchd knows the service.
587
746
 
588
747
  ## MCP server
589
748
 
@@ -650,6 +809,46 @@ everyone's checkout is in the same place.
650
809
  `--port <n>` (on `mcp` or `mcp connect`) points it at a web server on another
651
810
  port.
652
811
 
812
+ ### Scoped to one workspace
813
+
814
+ ```sh
815
+ ./bin/acme mcp --workspace smb --actor agent:planner
816
+ ```
817
+
818
+ `--workspace <ws>` and `--actor agent:<id>` (both or neither) scope the server
819
+ to one workspace for one agent:
820
+
821
+ - **Pinned.** A tool that takes a `workspace` gets `<ws>` when none is given.
822
+ A call naming another workspace is refused (`Refused: <tool>: this MCP
823
+ server is scoped to workspace "<ws>" (agent:<id>); "<other>" is another
824
+ workspace`) before anything reaches the web server. A refused call is
825
+ recorded in the activity trail, read tools included.
826
+ - **Filtered.** `workspaces` lists `<ws>` only. `logs_search` and `logs_tail`
827
+ take only `<ws>`'s services, `manifest_validate` takes only an absolute path
828
+ inside one of its repos (or the content), and `workspace_sync` syncs `<ws>`.
829
+ Tests run against the focused workspace, so `tests_available` and
830
+ `run_tests` are refused unless `<ws>` is the focused one.
831
+ - **Not workspace data**, served as unscoped: `environments`,
832
+ `security_latest`, `release_status` and `edge_status` (the org's and the
833
+ distribution's, not a workspace's).
834
+ - **Stamped.** Every web API call carries `x-den-mcp-agent: agent:<id>` and
835
+ `x-den-mcp-workspace: <ws>` beside the usual audit headers. The activity
836
+ entry gains `agent` and `workspace`, and the log line reads `· by
837
+ agent:<id> <client> <version> (workspace <ws>, mcp pid <pid>, session
838
+ <id>)` for every exec, run and sync.
839
+ - `<ws>` must exist in this distribution; until it does, every tool says so.
840
+ The id is letters, digits, dots, dashes and underscores (at most 64). A bad
841
+ or half-given scope is a usage error, exit 2. `--list` and `--call` take
842
+ the scope too.
843
+
844
+ **It binds cooperating agents only.** The scope lives in the MCP server's
845
+ process. The web server's HTTP API does not check it yet, so an agent that
846
+ calls the API itself (curl, a browser) is not held to its workspace. Enforcing
847
+ the scope in the HTTP server is WS-3a, planned for 0.4. `mcp connect` still
848
+ registers the unscoped server; to hand one agent a scoped server, add it to
849
+ that agent's config yourself, for example in a project's `.mcp.json`:
850
+ `"args": ["mcp", "--workspace", "smb", "--actor", "agent:planner"]`.
851
+
653
852
  ### One call from a shell
654
853
 
655
854
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  #
16
16
  # DEN_SERVICE_LABEL launchd label (default local.<dist dir name>.web)
17
17
  # DEN_SERVICE_LOGS log directory (default ~/Library/Logs/<dist dir name>)
18
+ # DEN_SERVICE_NODE the node the agent runs (default: see pick_node)
18
19
  # DEN_SERVICE_READY_CHECK=external install does not print "running": the
19
20
  # caller (`<cli> service install`) checks the server
20
21
  # answers first.
@@ -47,8 +48,36 @@ find_tsx() {
47
48
  done
48
49
  }
49
50
 
51
+ # A version manager's node (nvm, fnm, volta, asdf, mise) lives under a
52
+ # versioned path that disappears when that version is removed, and the agent
53
+ # then fails to start with no clear reason. Prefer a stable install of the
54
+ # same major version; otherwise keep it and say so.
55
+ STABLE_NODES="${DEN_SERVICE_STABLE_NODES:-/opt/homebrew/bin/node /usr/local/bin/node}"
56
+ node_major() { "$1" -p "process.versions.node.split('.')[0]" 2>/dev/null; }
57
+ pick_node() {
58
+ if [ -n "${DEN_SERVICE_NODE:-}" ]; then
59
+ [ -x "$DEN_SERVICE_NODE" ] || { echo "✗ DEN_SERVICE_NODE ($DEN_SERVICE_NODE) is not executable" >&2; exit 1; }
60
+ echo "$DEN_SERVICE_NODE"; return
61
+ fi
62
+ local node; node="$(command -v node)" || { echo "✗ node is not on PATH" >&2; exit 1; }
63
+ case "$node" in
64
+ */.nvm/*|*/fnm/*|*/.fnm/*|*/.volta/*|*/.asdf/*|*/mise/*) ;;
65
+ *) echo "$node"; return ;;
66
+ esac
67
+ local want c; want="$(node_major "$node")"
68
+ for c in $STABLE_NODES; do
69
+ if [ -x "$c" ] && [ "$(node_major "$c")" = "$want" ]; then echo "$c"; return; fi
70
+ done
71
+ # Warn when installing; `print` writes the plist and nothing else.
72
+ if [ "${1:-}" = install ]; then
73
+ echo "⚠ the service will run $node, a version manager's node: it stops starting" >&2
74
+ echo " when that version is removed. Re-run install after switching, or set DEN_SERVICE_NODE." >&2
75
+ fi
76
+ echo "$node"
77
+ }
78
+
50
79
  write_plist() { # $1 = output file
51
- NODE="$(command -v node)" || { echo "✗ node is not on PATH" >&2; exit 1; }
80
+ NODE="$(pick_node "$MODE")"
52
81
  TSX="$(find_tsx)" || { echo "✗ tsx not installed: run npm ci in $DIST" >&2; exit 1; }
53
82
  DOCKER_DIR="$(dirname "$(command -v docker || echo /usr/local/bin/docker)")"
54
83
  SVC_PATH="$(dirname "$NODE"):$DOCKER_DIR:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
@@ -79,7 +108,8 @@ else:
79
108
  PY
80
109
  }
81
110
 
82
- case "${1:-install}" in
111
+ MODE="${1:-install}"
112
+ case "$MODE" in
83
113
  install)
84
114
  mkdir -p "$LOGS" "$(dirname "$PLIST")"
85
115
  write_plist "$PLIST.new"
@@ -115,6 +145,15 @@ case "${1:-install}" in
115
145
  write_plist -
116
146
  ;;
117
147
  status)
148
+ # The node the agent was installed with may be gone (a removed nvm
149
+ # version): launchd then restarts it forever with no useful message.
150
+ if [ -f "$PLIST" ] && command -v plutil >/dev/null 2>&1; then
151
+ svc_node="$(plutil -extract ProgramArguments.0 raw -o - "$PLIST" 2>/dev/null || true)"
152
+ if [ -n "$svc_node" ] && [ ! -x "$svc_node" ]; then
153
+ echo "✗ $LABEL runs $svc_node, which no longer exists: re-run service install" >&2
154
+ exit 1
155
+ fi
156
+ fi
118
157
  # launchctl's own "Could not find service" is noise when it isn't loaded.
119
158
  if out="$(launchctl print "$DOMAIN/$LABEL" 2>/dev/null)"; then
120
159
  printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs|spawn type)'
@@ -12,8 +12,20 @@ export const AUDIT_HEADERS = {
12
12
  clientVersion: "x-den-mcp-client-version",
13
13
  pid: "x-den-mcp-pid",
14
14
  session: "x-den-mcp-session",
15
+ /** A scoped server's actor id (`agent:<id>`, AG-2a-core). */
16
+ agent: "x-den-mcp-agent",
17
+ /** The workspace a scoped server is pinned to (AG-2a-core). */
18
+ workspace: "x-den-mcp-workspace",
15
19
  } as const;
16
20
 
21
+ /**
22
+ * `agent:<id>`: what `<cli> mcp --actor` takes. The id is letters, digits,
23
+ * dots, dashes and underscores, 1-64 long, starting with a letter or digit.
24
+ */
25
+ export const AGENT_ACTOR_RE = /^agent:[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
26
+ /** A workspace name, as the config accepts it (1-64 long here). */
27
+ export const WORKSPACE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
28
+
17
29
  export interface AgentInfo {
18
30
  /** The MCP client's self-reported name (clientInfo.name). */
19
31
  client: string;
@@ -23,6 +35,10 @@ export interface AgentInfo {
23
35
  mcpPid: number | null;
24
36
  /** The MCP server's session id (one per `<cli> mcp` process). */
25
37
  session: string;
38
+ /** A scoped server's actor (`agent:<id>`); absent when unscoped. */
39
+ agent?: string;
40
+ /** The workspace a scoped server is pinned to; absent when unscoped. */
41
+ workspace?: string;
26
42
  }
27
43
 
28
44
  export interface AgentActor extends AgentInfo {
@@ -49,6 +65,13 @@ export function agentAuditHeaders(info: AgentInfo): Record<string, string> {
49
65
  [AUDIT_HEADERS.clientVersion]: clean(info.clientVersion) || "unknown",
50
66
  [AUDIT_HEADERS.pid]: info.mcpPid === null ? "" : String(info.mcpPid),
51
67
  [AUDIT_HEADERS.session]: clean(info.session).replace(/[^A-Za-z0-9-]/g, ""),
68
+ // Only a scoped server sends these: an unscoped one's headers are as before.
69
+ ...(info.agent && AGENT_ACTOR_RE.test(info.agent)
70
+ ? { [AUDIT_HEADERS.agent]: info.agent }
71
+ : {}),
72
+ ...(info.workspace && WORKSPACE_NAME_RE.test(info.workspace)
73
+ ? { [AUDIT_HEADERS.workspace]: info.workspace }
74
+ : {}),
52
75
  };
53
76
  }
54
77
 
@@ -59,19 +82,31 @@ export function actorFromHeaders(
59
82
  const h = headers as Record<string, string | string[] | undefined>;
60
83
  if (clean(h[AUDIT_HEADERS.actor]) !== "agent") return null;
61
84
  const pid = clean(h[AUDIT_HEADERS.pid], 10);
85
+ // Kept only when well-formed: a malformed value is dropped, never cleaned
86
+ // into something that looks like another agent's id.
87
+ const agent = clean(h[AUDIT_HEADERS.agent], 80);
88
+ const workspace = clean(h[AUDIT_HEADERS.workspace], 80);
62
89
  return {
63
90
  kind: "agent",
64
91
  client: clean(h[AUDIT_HEADERS.client]) || "unknown",
65
92
  clientVersion: clean(h[AUDIT_HEADERS.clientVersion], 32) || "unknown",
66
93
  mcpPid: /^\d+$/.test(pid) ? Number(pid) : null,
67
94
  session: clean(h[AUDIT_HEADERS.session]).replace(/[^A-Za-z0-9-]/g, ""),
95
+ ...(AGENT_ACTOR_RE.test(agent) ? { agent } : {}),
96
+ ...(WORKSPACE_NAME_RE.test(workspace) ? { workspace } : {}),
68
97
  };
69
98
  }
70
99
 
71
- /** "agent <client> <version> (mcp pid <pid>, session <id>)". */
100
+ /**
101
+ * "agent <client> <version> (mcp pid <pid>, session <id>)"; a scoped
102
+ * server's actor leads: "agent:<id> <client> <version> (workspace <ws>,
103
+ * mcp pid <pid>, session <id>)".
104
+ */
72
105
  export function actorLabel(actor: AgentActor): string {
106
+ const who = actor.agent ?? "agent";
107
+ const ws = actor.workspace ? `workspace ${actor.workspace}, ` : "";
73
108
  return (
74
- `agent ${actor.client} ${actor.clientVersion} ` +
75
- `(mcp pid ${actor.mcpPid ?? "?"}, session ${actor.session || "?"})`
109
+ `${who} ${actor.client} ${actor.clientVersion} ` +
110
+ `(${ws}mcp pid ${actor.mcpPid ?? "?"}, session ${actor.session || "?"})`
76
111
  );
77
112
  }