@monster-orange/den 0.2.7 → 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.
- package/CHANGELOG.md +108 -0
- package/docs/distributions.md +115 -2
- package/package.json +1 -1
- package/src/shared/agent-actor.ts +38 -3
- package/src/shared/config-error.ts +33 -0
- package/src/shared/den-config.ts +46 -4
- package/src/shared/environment-manager/catalog.ts +52 -1
- package/src/shared/environment-manager/default-workspace.ts +10 -2
- package/src/shared/environment-manager/git-remote.ts +49 -0
- package/src/shared/environment-manager/workspace-manager.ts +67 -25
- package/src/shared/environment-manager/workspace-org.ts +26 -11
- package/src/shared/manifest/service-links.ts +20 -8
- package/src/shared/org-sources.ts +309 -0
- package/src/shared/types/index.ts +12 -2
- package/src/ui/cli/clone-plan.ts +154 -23
- package/src/ui/cli/flag-parser.ts +57 -3
- package/src/ui/cli/index.ts +106 -17
- package/src/ui/cli/org-command.ts +12 -2
- package/src/ui/cli/up-args.ts +15 -0
- package/src/ui/mcp/scope.ts +277 -0
- package/src/ui/mcp/server.ts +70 -8
- package/src/ui/mcp/tools.ts +101 -17
- package/src/ui/server/audit.ts +90 -3
- package/src/ui/server/github-service.ts +6 -1
- package/src/ui/server/index.ts +65 -1
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/readonly-routes.ts +12 -3
- package/src/ui/server/release-service.ts +25 -3
- package/src/ui/server/workspace-routes.ts +10 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,114 @@ 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
|
+
|
|
7
115
|
## [0.2.7] - 2026-11-05
|
|
8
116
|
|
|
9
117
|
### Added
|
package/docs/distributions.md
CHANGED
|
@@ -473,8 +473,14 @@ To see what a setup would clone before it clones anything, run
|
|
|
473
473
|
`acme setup --plan`: each repo with its org and clone URL, and whether it
|
|
474
474
|
is cloned yet. On a fresh machine it plans the blueprint (every workspace
|
|
475
475
|
`acme workspace seed` would create); once seeded, the focused workspace.
|
|
476
|
-
|
|
477
|
-
|
|
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.
|
|
478
484
|
|
|
479
485
|
## Orgs
|
|
480
486
|
|
|
@@ -523,6 +529,73 @@ its workspace's org is a warning in both lists, never an error.
|
|
|
523
529
|
`org:` in the blueprint needs Den 0.2.7 or later: an older Den rejects the
|
|
524
530
|
unknown key. Pin 0.2.7 before you commit it.
|
|
525
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
|
+
|
|
526
599
|
## Releases
|
|
527
600
|
|
|
528
601
|
The Release page dispatches each repo's CD workflow on GitHub. A repo's
|
|
@@ -736,6 +809,46 @@ everyone's checkout is in the same place.
|
|
|
736
809
|
`--port <n>` (on `mcp` or `mcp connect`) points it at a web server on another
|
|
737
810
|
port.
|
|
738
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
|
+
|
|
739
852
|
### One call from a shell
|
|
740
853
|
|
|
741
854
|
```sh
|
package/package.json
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A distribution config that fails validation (ORG-1d's `orgs:` checks, for
|
|
3
|
+
* now). It is the user's to fix, not a crash: the CLI prints it and exits 1
|
|
4
|
+
* before doing anything, and the web server answers it as
|
|
5
|
+
* `{ ok: false, error, code: "config_invalid" }` with status 422.
|
|
6
|
+
*
|
|
7
|
+
* The message names config keys (`orgs.initech.repos`), never values a
|
|
8
|
+
* secret could hide in.
|
|
9
|
+
*/
|
|
10
|
+
export class ConfigError extends Error {
|
|
11
|
+
readonly code = "config_invalid";
|
|
12
|
+
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "ConfigError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isConfigError(e: unknown): e is ConfigError {
|
|
20
|
+
return e instanceof ConfigError;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The web server's status for a ConfigError: the request was fine, the config is not. */
|
|
24
|
+
export const CONFIG_ERROR_STATUS = 422;
|
|
25
|
+
|
|
26
|
+
/** The JSON body for a ConfigError (the `{ok, error, code}` frame R26 extends). */
|
|
27
|
+
export function configErrorBody(e: ConfigError): {
|
|
28
|
+
ok: false;
|
|
29
|
+
error: string;
|
|
30
|
+
code: "config_invalid";
|
|
31
|
+
} {
|
|
32
|
+
return { ok: false, error: e.message, code: e.code };
|
|
33
|
+
}
|
package/src/shared/den-config.ts
CHANGED
|
@@ -16,6 +16,8 @@ import type { Environment, ReleaseSourceConfig } from "./types/index.js";
|
|
|
16
16
|
import { getDistRoot } from "./paths.js";
|
|
17
17
|
import type { SecretsConfig } from "./secrets/index.js";
|
|
18
18
|
import type { OrgConfig } from "./org.js";
|
|
19
|
+
import { validateOrgSources, type WorkOrgsConfig } from "./org-sources.js";
|
|
20
|
+
import { ConfigError, isConfigError } from "./config-error.js";
|
|
19
21
|
import type { TestAccountsConfig } from "./auth/test-accounts.js";
|
|
20
22
|
|
|
21
23
|
import { product } from "./product.js";
|
|
@@ -194,11 +196,13 @@ export interface DenConfig {
|
|
|
194
196
|
/** GitHub org slug used to list org repos in the web UI (e.g. "Globex-Corp"). */
|
|
195
197
|
github_org?: string;
|
|
196
198
|
/**
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
199
|
+
* Work orgs, keyed by org id: a display name and an optional colour. An
|
|
200
|
+
* entry with neither `sources` nor `repos` is the GitHub owner of its own
|
|
201
|
+
* name (ORG-1a). ORG-1d: `sources: [{host, owner|group}]` binds the org to
|
|
202
|
+
* git-host owners, and `repos: [...]` lists repos outright. See
|
|
203
|
+
* src/shared/org-sources.ts.
|
|
200
204
|
*/
|
|
201
|
-
orgs?:
|
|
205
|
+
orgs?: WorkOrgsConfig;
|
|
202
206
|
/** Derived-dependency-graph tuning (aliases + edge overrides). */
|
|
203
207
|
graph?: GraphConfig;
|
|
204
208
|
/**
|
|
@@ -412,6 +416,41 @@ export function requireGithubOrg(config: DenConfig = readDenConfig()): string {
|
|
|
412
416
|
return org;
|
|
413
417
|
}
|
|
414
418
|
|
|
419
|
+
let checked: { key: string; error: ConfigError | null } | null = null;
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Throw the config's validation error (a ConfigError naming its keys), if it
|
|
423
|
+
* has one, without reading anything else. The CLI runs this before any
|
|
424
|
+
* command that touches orgs, the catalog, clones or releases, and the web
|
|
425
|
+
* server before every config-reading route, so a config that fails
|
|
426
|
+
* validation stops them all, not only those that happen to read `orgs:`.
|
|
427
|
+
* A missing file, or one that does not parse, is left to readDenConfig.
|
|
428
|
+
* Memoised on the file's mtime and size: the server checks per request.
|
|
429
|
+
*/
|
|
430
|
+
export function assertDenConfigValid(): void {
|
|
431
|
+
let key: string;
|
|
432
|
+
try {
|
|
433
|
+
const st = fs.statSync(DEN_CONFIG_PATH);
|
|
434
|
+
key = `${st.mtimeMs}:${st.size}`;
|
|
435
|
+
} catch {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (checked?.key !== key) {
|
|
439
|
+
let error: ConfigError | null = null;
|
|
440
|
+
try {
|
|
441
|
+
const parsed = yaml.parse(fs.readFileSync(DEN_CONFIG_PATH, "utf-8")) as {
|
|
442
|
+
orgs?: unknown;
|
|
443
|
+
} | null;
|
|
444
|
+
validateOrgSources(parsed?.orgs, product().configFile);
|
|
445
|
+
} catch (e) {
|
|
446
|
+
if (!isConfigError(e)) return;
|
|
447
|
+
error = e;
|
|
448
|
+
}
|
|
449
|
+
checked = { key, error };
|
|
450
|
+
}
|
|
451
|
+
if (checked.error) throw new ConfigError(checked.error.message);
|
|
452
|
+
}
|
|
453
|
+
|
|
415
454
|
/**
|
|
416
455
|
* Read the effective den config, merging the shared deployment config with
|
|
417
456
|
* per-engineer overrides from config/local/user.yml.
|
|
@@ -422,6 +461,9 @@ export function readDenConfig(): DenConfig {
|
|
|
422
461
|
try {
|
|
423
462
|
const raw = fs.readFileSync(DEN_CONFIG_PATH, "utf-8");
|
|
424
463
|
const parsed = yaml.parse(raw) as Partial<DenConfig> | null;
|
|
464
|
+
// ORG-1d keys (orgs.<id>.sources / .repos) are checked here, where a bad
|
|
465
|
+
// value throws naming its key; configs without them are not touched.
|
|
466
|
+
validateOrgSources(parsed?.orgs, product().configFile);
|
|
425
467
|
// `environments` may be malformed (scalar/object) in a hand-edited file —
|
|
426
468
|
// fall back to the default set rather than throwing on .filter below.
|
|
427
469
|
const envs = Array.isArray(parsed?.environments)
|
|
@@ -14,7 +14,13 @@ import * as path from "path";
|
|
|
14
14
|
import * as yaml from "yaml";
|
|
15
15
|
import type { ServiceCatalog, ServiceCatalogEntry } from "../types/index.js";
|
|
16
16
|
import { getDistRoot } from "../paths.js";
|
|
17
|
-
import { requireGithubOrg } from "../den-config.js";
|
|
17
|
+
import { readDenConfig, requireGithubOrg } from "../den-config.js";
|
|
18
|
+
import {
|
|
19
|
+
parseRemote,
|
|
20
|
+
repoSource,
|
|
21
|
+
type WorkOrgsConfig,
|
|
22
|
+
} from "../org-sources.js";
|
|
23
|
+
import { checkedRemote, isGitRemote } from "./git-remote.js";
|
|
18
24
|
|
|
19
25
|
/** Fallback GitHub org when `config/den.yml` doesn't set `github_org`. */
|
|
20
26
|
|
|
@@ -77,6 +83,51 @@ export function resolveRepoGhSlug(
|
|
|
77
83
|
return `${owner()}/${repoName}`;
|
|
78
84
|
}
|
|
79
85
|
|
|
86
|
+
/**
|
|
87
|
+
* What `repoName` clones from (ORG-1d): a GitHub slug or a git URL (see
|
|
88
|
+
* git-remote.ts). The repo's source decides: a GitHub source gives its slug
|
|
89
|
+
* (`gh`, else `<owner>/<repo>`). A repo with no source and a `remote:` gives
|
|
90
|
+
* that remote, through plain git. Anything else is the legacy GitHub slug
|
|
91
|
+
* (resolveRepoGhSlug), so a catalog without `remote:` clones as before.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveRepoCloneSource(
|
|
94
|
+
repoName: string,
|
|
95
|
+
catalog: ServiceCatalog = readCatalog(),
|
|
96
|
+
owner: () => string = () => requireGithubOrg(),
|
|
97
|
+
orgs: WorkOrgsConfig | undefined = readDenConfig().orgs,
|
|
98
|
+
): string {
|
|
99
|
+
const entry = catalog[repoName];
|
|
100
|
+
const gh = entry?.gh?.trim();
|
|
101
|
+
const remote = entry?.remote?.trim();
|
|
102
|
+
const src = repoSource(repoName, catalog, orgs ?? {});
|
|
103
|
+
if (src?.host === "github") {
|
|
104
|
+
if (gh) return gh;
|
|
105
|
+
const fromRemote = remote ? parseRemote(remote) : null;
|
|
106
|
+
return fromRemote?.host === "github"
|
|
107
|
+
? `${fromRemote.owner}/${repoName}`
|
|
108
|
+
: `${src.owner ?? ""}/${repoName}`;
|
|
109
|
+
}
|
|
110
|
+
if (remote) return checkedRemote(repoName, remote);
|
|
111
|
+
if (src)
|
|
112
|
+
throw new Error(
|
|
113
|
+
`${repoName}: its org's source is on ${src.host}, which Den clones only ` +
|
|
114
|
+
`from a remote: — set ${repoName}.remote in the service catalog`,
|
|
115
|
+
);
|
|
116
|
+
return resolveRepoGhSlug(repoName, catalog, owner);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether a clone source may be written back as the catalog's `gh` (the
|
|
121
|
+
* empty-slug repair): only a GitHub slug, and only for an entry with no
|
|
122
|
+
* `gh` and no `remote` of its own.
|
|
123
|
+
*/
|
|
124
|
+
export function needsGhRepair(
|
|
125
|
+
entry: ServiceCatalogEntry | undefined,
|
|
126
|
+
source: string,
|
|
127
|
+
): boolean {
|
|
128
|
+
return !isGitRemote(source) && !entry?.gh?.trim() && !entry?.remote?.trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
80
131
|
export function removeCatalogEntry(repoName: string): void {
|
|
81
132
|
const catalog = readCatalog();
|
|
82
133
|
delete catalog[repoName];
|
|
@@ -22,8 +22,10 @@ import {
|
|
|
22
22
|
import {
|
|
23
23
|
githubOrgFor,
|
|
24
24
|
readOrgContext,
|
|
25
|
+
resolveWorkspaceOrg,
|
|
25
26
|
type OrgContext,
|
|
26
27
|
} from "./workspace-org.js";
|
|
28
|
+
import { orgHasGithub } from "../org-sources.js";
|
|
27
29
|
import type {
|
|
28
30
|
ServiceCatalog,
|
|
29
31
|
WorkspaceConfig,
|
|
@@ -83,11 +85,17 @@ export function catalogFill(
|
|
|
83
85
|
ctx: OrgContext = readOrgContext(),
|
|
84
86
|
): Map<string, string> {
|
|
85
87
|
const fill = new Map<string, string>();
|
|
86
|
-
for (const ws of workspaces)
|
|
88
|
+
for (const ws of workspaces) {
|
|
89
|
+
// ORG-1d: an org with no GitHub source has no slug to give; its repos
|
|
90
|
+
// clone from their catalog `remote:`.
|
|
91
|
+
const org = resolveWorkspaceOrg(ws.name, ctx).org;
|
|
92
|
+
if (org && !orgHasGithub(org, ctx.config.orgs)) continue;
|
|
87
93
|
for (const repo of ws.repos) {
|
|
88
|
-
if (catalog[repo]?.gh || fill.has(repo))
|
|
94
|
+
if (catalog[repo]?.gh || catalog[repo]?.remote || fill.has(repo))
|
|
95
|
+
continue;
|
|
89
96
|
fill.set(repo, `${githubOrgFor(ws.name, ctx)}/${repo}`);
|
|
90
97
|
}
|
|
98
|
+
}
|
|
91
99
|
return fill;
|
|
92
100
|
}
|
|
93
101
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a managed clone is made from (ORG-1d): a GitHub slug (`owner/repo`,
|
|
3
|
+
* cloned from github.com with the user's gh as credential helper) or a git
|
|
4
|
+
* URL from the catalog's `remote:` (cloned through plain git and the user's
|
|
5
|
+
* own credential helper, never Den's or gh's: SEC-0).
|
|
6
|
+
*
|
|
7
|
+
* Clone commands run through a shell, so a remote must pass REMOTE_RE: a URL
|
|
8
|
+
* with a known transport, scp-style `user@host:path`, or an absolute path,
|
|
9
|
+
* and nothing a shell or git would read as a command (`ext::`, quotes, `$`,
|
|
10
|
+
* spaces, a leading `-`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** A GitHub slug, as the clone guards accept it. */
|
|
14
|
+
export const SAFE_SLUG_RE = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
|
|
15
|
+
|
|
16
|
+
const REMOTE_RE =
|
|
17
|
+
/^(?:(?:https?|ssh|git|file):\/\/[A-Za-z0-9._~@:%+/-]+|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:[A-Za-z0-9._~+/-]+|\/[A-Za-z0-9._~@+/-]+)$/;
|
|
18
|
+
|
|
19
|
+
/** True when `spec` is a git URL rather than a GitHub slug. */
|
|
20
|
+
export function isGitRemote(spec: string): boolean {
|
|
21
|
+
return !SAFE_SLUG_RE.test(spec);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** True when `remote` is a git URL Den will hand to git. */
|
|
25
|
+
export function isSafeRemote(remote: string): boolean {
|
|
26
|
+
return REMOTE_RE.test(remote) && !remote.includes("..");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `remote` for display: any user info (`https://user:token@host/…`) masked.
|
|
31
|
+
* Remotes can carry a token, so one is never printed as written.
|
|
32
|
+
*/
|
|
33
|
+
export function redactRemote(remote: string): string {
|
|
34
|
+
return remote.replace(/^([a-z+]+:\/\/)[^@/]+@/i, "$1***@");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The checked remote for `repo`, or a throw naming its catalog key (and not
|
|
39
|
+
* its value, which may hold a token).
|
|
40
|
+
*/
|
|
41
|
+
export function checkedRemote(repo: string, remote: string): string {
|
|
42
|
+
const r = remote.trim();
|
|
43
|
+
if (!isSafeRemote(r))
|
|
44
|
+
throw new Error(
|
|
45
|
+
`service catalog: ${repo}.remote is not a git URL Den can clone ` +
|
|
46
|
+
`(https://, ssh://, git://, file://, user@host:path or an absolute path)`,
|
|
47
|
+
);
|
|
48
|
+
return r;
|
|
49
|
+
}
|