Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75fb3e10e3 | ||
|
|
8614ba87cc | ||
|
|
28213ac27d | ||
|
|
eac92d9565 | ||
|
|
fb20a61b4e | ||
|
|
4281b0064c | ||
|
|
51006710ac | ||
|
|
8d170d7a6e | ||
|
|
120c616ea5 | ||
|
|
a2d94b2c2a | ||
|
|
29e85196b2 | ||
|
|
f325657c7a | ||
|
|
29404f9964 | ||
|
|
13a98bfd08 | ||
|
|
3e25a829d7 | ||
|
|
a0f6afc508 | ||
|
|
366a4c6d5d | ||
|
|
eab6e1a3a8 | ||
|
|
8645cb4560 | ||
|
|
a0ce2ffb84 | ||
|
|
1508fd175f | ||
|
|
e1635308be | ||
|
|
5fc296e708 | ||
|
|
2958acb81e | ||
|
|
c1913fb07a | ||
|
|
88222f3f3f |
@@ -16,17 +16,6 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
|
||||
| Rocko | General development, investigation, testing, and review | T3 session (Codex, gpt-6.1-sol, thread d99326cc, lead decision 122; Claude thread b84bb264 retired 2026-10-12); launcher Claude Code, Sonnet model | `agents/rocko/launch.sh` |
|
||||
| Filbert | General development, investigation, testing, and review | T3 session (Claude, Opus 5.5, thread 048f3c63, non-sandbox reviews only, lead decisions 130 and 134; Codex threads f712d8af and 50342f6a retired 2026-10-13, Claude thread 9cb9731e retired 2026-10-12); launcher Pi, `openai-codex/gpt-6-astra:low`, not to be used under R26 until it changes (lead decision 69) | `agents/filbert/launch.sh` |
|
||||
| Researcher | Source-grounded technical research and evidence | T3 session (Claude, Sonnet 5.5, thread 9bac0794, rows 97 and 98, lead decision 124); Haiku rows (71, 80, 84, 88, later 96) run as subagents Sage dispatches with model haiku, effort high and a worktree (lead decision 129); Haiku thread c98aaf4a retired 2026-10-13 unused; launcher Pi, configured Mosaic model | `agents/researcher/launch.sh` |
|
||||
| Launchpad | Builder, MVP T1 row 192 (Q175 SSO phase 1) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Morgana | Builder, MVP T1 row 193 (human users and RBAC) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Honker | Builder, MVP T1 row 194 (agent lifecycle) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Gander | Builder, the human boundary across uids (P19 333, #1694) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
|
||||
| Herb | Builder, a second gate lane (#1695) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
|
||||
| Bushroot | Builder, retire the control board writer on 7331 (#1696) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1008 | none, T3 only |
|
||||
| Megavolt | Builder, MVP T1 item 6 (agent credentials and git identity, #1697) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Quackerjack | Builder, federation R5 and R2 (#414) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1023 | none, T3 only |
|
||||
| Gizmoduck | Federation design (#414) and builder of FED R1 | T3 session (Claude, Opus 5.5, thread 9312a896) | none, T3 only |
|
||||
| Webby | Reviewer: Case 2 designs, rows 192 and 194 | T3 session (Claude, Opus 5.5, thread 03086006), started by Mos (mos-0981) | none, T3 only |
|
||||
| Duckworth | Reviewer: federation designs, row 193 | T3 session (Claude, Opus 5.5, thread ffceafec), started by Mos (mos-0977) | none, T3 only |
|
||||
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
|
||||
|
||||
Each script supports `--check` and `--fresh`. Normal launches resume the agent's
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Bushroot
|
||||
|
||||
Builder seat, Claude Haiku 5.5, started by Sage under mos-1008 item 2 (a low-level model under Q177) for the row
|
||||
that retires the control board writer on 7331 (#1696).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `bushroot/*`
|
||||
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Duckworth
|
||||
|
||||
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0977, thread ffceafec). Reviews federation designs and MVP T1 row 193.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `duckworth/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Gander
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Sage under mos-0977 item 5 (more Codex Sol rows) for the human
|
||||
boundary row (P19 333, #1694, mos-0989).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `gander/*`
|
||||
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -1,9 +0,0 @@
|
||||
# Gizmoduck
|
||||
|
||||
Design and builder seat, Claude Opus 5.5, a T3 thread (9312a896). Author of the federation design
|
||||
(`agents/gizmoduck/work/federation/DESIGN.md` on `gizmoduck/federation-design`, #414). Builds FED R1, the
|
||||
access.json loader and validator, under Mos's order mos-1023 item 1.
|
||||
|
||||
No launcher in this directory. The seat works in its own worktree and has no credentials of its own; until
|
||||
the credentials row settles per-agent identity, it pushes only its own `gizmoduck/*` branches through the
|
||||
repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +0,0 @@
|
||||
# Herb
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Sage under mos-0977 item 5 (more Codex Sol rows) for a second gate lane
|
||||
design row (#1695, mos-0977 item 6).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `herb/*`
|
||||
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Honker
|
||||
|
||||
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 194 (agent lifecycle, #1692) under mos-0974 and mos-0977.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `honker/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -1,579 +0,0 @@
|
||||
# Row 194 build notes
|
||||
|
||||
Design r4 approved: DESIGN.md sha256 824ef884 at 7d0788236 (Webby webby-0014, review at e1e64ff01 on
|
||||
webby/reviews; Mos mos-1042; Sage sage-1387 "build now"). Build base: refactor aa1bd209e merged into
|
||||
honker/row194 at fa9eaa4c3.
|
||||
|
||||
## Build notes from the approval (each gets a test and a mutant)
|
||||
|
||||
- W-N1 (webby-0014 N1): launch refuses `instance-firing` while I has a `fire.stopped` with no `fire.done`.
|
||||
Test case in T9c. Mutant M59: the check is dropped.
|
||||
- W-N2 (webby-0014 N2): the broker's hire-time check requires roleVersion 2, so a definition with no
|
||||
action lists refuses. Test case in T8b. Mutant M60: the version check is dropped.
|
||||
- Grace (sage-1387): 5 s, one constant shared with row 191.
|
||||
|
||||
## Chain state (2026-10-20T19:45Z)
|
||||
|
||||
Rows 190, 191 and 193 have no product commits yet (rocko/row191 and darkwing/row190 sit at da230ca48, the
|
||||
brief commit). Slices that don't need row 191 build first. S6 waits for row 191's candidate.
|
||||
|
||||
## Slices
|
||||
|
||||
| Slice | Scope | Tests | State |
|
||||
|---|---|---|---|
|
||||
| S1 | business: vocabulary (role.hire, role.fire hard waits, HIRE_ALLOWED), role.mjs `hireable`, business.mjs keep/noHire/retired, instance-settings delete and veto ops, chief-of-staff.json `hireable: []` | T6 (business), T7, T8c, T17, D2.2 writer | done, see below |
|
||||
| S2 | bus: reloadBusiness and runtime callback, instance.launch/end/regenerate requests, instances.set delete, veto.set, instance.hire/fire, keep checks, D4 hook, tester checks | T8-T10, T9c, T12-T14, T18 | S2a (D3), S2b (veto, hook, keep, T13), S2c (requests, terminate, delete) and S2d (hire, fire, W-N1) and S2e (PRD 1.7 routing, sage-1471) done |
|
||||
| S3 | cli: launcher lifecyclePass, host ops, `business instance` verbs | T1-T6, T11 | S3a (launcher pass) and S3b (verbs, veto, end to end, T2a, T4, T6, T11) done |
|
||||
| S4 | harness: launch tool schema, hire and fire tools | T8 (D3 item 5) | done, see S4 notes |
|
||||
| S5 | webui: Agents section and routes | T16 | done, see S5 notes. The "still running after fire" flag waits for S6 |
|
||||
| S6 | row 191 chain: halted checks, fire SIGKILL through row 191's stop | T9a, T9b, T15 | waits on row 191 |
|
||||
| S7 | brief item 7 (rev 2547, sage-1586): a created agent's credential entry, the restart route | T19 (new) | waits on row 199 landing, see item 7 plan |
|
||||
| S8 | brief item 8 (rev 2556, sage-1588): fire and delete revoke the agent's credentials before the file changes | T20 (new) | waits on row 199 landing, see item 8 plan |
|
||||
|
||||
## Changed assertions (D3 item 4, N4)
|
||||
|
||||
Listed here as the build changes them.
|
||||
|
||||
- `packages/business/tests/instance-settings.test.mjs:53, :67, :72`: the row 165 read and add views
|
||||
gain `kept: false` (D1, the read gains `kept` per instance). No other field changed.
|
||||
- `packages/business/tests/instance-settings.test.mjs:70` (`added.restart` true to false) and `:91`
|
||||
(`redefined.restart` true to false): the writer no longer asks for a restart. The bus decides,
|
||||
after its reload (D3 items 2-3). The two test names say so.
|
||||
- `packages/cli/tests/instances.test.mjs`: `setup` boots the bus from the file through `bootConfig`, as
|
||||
the unit does, instead of a one-role stub. The add test (T1) now expects the "no restart" line and
|
||||
binds `sec-1` on the running bus. The `RESTART_NEEDED` text pins moved, unchanged, to a new test that
|
||||
boots the old stub (`setup(t, { stub: true })`). Its coder has no credential references and the
|
||||
file's has, so the bus doesn't reload, prints `RESTART_NEEDED` and doesn't bind `sec-1` (D3 item 3).
|
||||
- `packages/webui/tests/instances.test.mjs:25`: the fake writer's default answer is `restart: false`,
|
||||
the usual answer now. The real-broker test boots through `bootConfig` and expects `restart: false`
|
||||
(`:71`) and the log line `done restart=false` (`:74`). The answer-shape rows at `:140-145` keep
|
||||
`restart: true`. Each row tests another field, or `restart: 'yes'`, so the value is not what they
|
||||
check.
|
||||
- `packages/bus/tests/instances-verb.test.mjs` T12 (S2c): the human delete now answers `{request}`
|
||||
where S2b refused `invalid-request`. The stub writer answers `instances.check` with the revision,
|
||||
and the calls list gains that one check, actor `jason`. Nothing else in the test changed.
|
||||
- `packages/bus/tests/tester-writes.test.mjs:19` (row 175 pin): TESTER_VERBS gains `instance.launch`,
|
||||
`instance.end` and `instance.request` (D4.1). TESTER_ORGANIZATION_VERBS is unchanged.
|
||||
- `packages/bus/tests/instances-verb.test.mjs:78` keeps `restart: true`, now for a D3 item 3 reason:
|
||||
its boot copy is a stub without the file's credential references. A comment there says so.
|
||||
`reload.test.mjs` covers the reload from a file boot.
|
||||
- `packages/bus/tests/hard-waits.test.mjs:38` (S2d): the every-hard-wait loop gives `role.hire` and
|
||||
`role.fire` a target and `arguments`, which their raise now requires (D2.1). The class and route it
|
||||
checks are unchanged.
|
||||
- S2e (PRD 1.7 REQ-COS-3) takes back the S2d entry above. `role.hire` and `role.fire` left the hard
|
||||
waits, so `packages/bus/tests/hard-waits.test.mjs` and `packages/business/tests/hard-waits.test.mjs`
|
||||
drop them from WAITS, and the bus file's every-hard-wait loop is back to its S2c form. The business
|
||||
file gains a test that both are routed and not in GATED_ONLY.
|
||||
- `packages/bus/tests/reload.test.mjs` (S2e): its hard-wait example used `role.hire`, now routed. It
|
||||
uses `spend`. The check is unchanged.
|
||||
- `packages/business/tests/chief-of-staff.test.mjs` (S2e): the chief's pinned authority and the test
|
||||
name gain `role.hire` and `role.fire`. `packages/business/tests/role.test.mjs` gains the check that
|
||||
no other shipped role holds either.
|
||||
- `packages/harness/tests/tools.test.mjs` and `packages/harness/tests/bundle.test.mjs` (S2e): the
|
||||
chief's pinned tool lists gain `hire` and `fire`. Every held action needs a typed tool
|
||||
(`untypedActions`), so the tools come forward from S4.
|
||||
- `packages/bus/tests/chief-of-staff.test.mjs` (S2e): the S2d decision-form tests are rewritten to the
|
||||
reason form (below). The S2d raise tests with `arguments` are gone with the key.
|
||||
- `packages/bus/tests/lifecycle-verbs.test.mjs` V5 retry (S3a): `lifecycleDelete` on a retry now
|
||||
answers `{transaction, retry: true}` with the failed delete's transaction, where S2c answered
|
||||
`{transaction: null, retry: true}`. The launcher needs it to name `.retired/I-<transaction>`. The
|
||||
test checks the value and that the retry still neither checks nor writes the file.
|
||||
- `packages/harness/tests/tools.test.mjs:48` (S4): the launch tool's `instance` was an enum of the
|
||||
start-time listing. It is now `{type: "string", description, pattern: ID_PATTERN.source}`, and the
|
||||
listing moves to the tool description, which the test matches. `:107` (the pm test's chief check)
|
||||
asserts there is no enum and the description names `chief.launch.instances`.
|
||||
- `packages/business/tests/chief-of-staff.test.mjs` (S4): the contract test gains a match on the new
|
||||
sentence that a hire is listed for `launch` at once but runs only after the human sets its route.
|
||||
|
||||
- `packages/webui/tests/browser.test.mjs:21-22` (S5): the page's script list pins the scripts in
|
||||
order. It now has `/agents.js` between `/chat.js` and `/bus.js`, and the comment names row 194.
|
||||
|
||||
## S1 notes
|
||||
|
||||
- Tests: `tests/lifecycle-settings.test.mjs` (new) holds T6's business part (V5 order, last listed,
|
||||
clean delete, retired refusals for add and hire), T7 (V6) and the writer's hire and fire (D2.2).
|
||||
T8c is in `role.test.mjs`, T17 in `business.test.mjs`.
|
||||
- `deleteRefusal`'s entry half (`entry.tracker`, `entry.credentials`) follows V5's wording "holds
|
||||
credentials or a tracker bot". In a file that validates it is the same as the definition check: a
|
||||
definition with no credential refuses an entry tracker or credentials at load. So no test reaches it
|
||||
alone, and no mutant is listed for it.
|
||||
- END_PROTECTED is `assistant` only. The chief's own instance refuses delete and fire as
|
||||
`organization-instance-referenced` (launch.by), not as protected.
|
||||
- The writer refuses an `assistant` hire `organization-definition-refused` through `definitionFor`, as
|
||||
row 165's add does.
|
||||
- PRD draft: 69544f342 (sha256 c1f52d98…), sage-1395: adding to the fixed list is a PRD change.
|
||||
|
||||
## S2a notes (D3 live reload)
|
||||
|
||||
- `Broker.reloadBusiness(id, doc)` runs the boot check (`checkBusiness`, moved out of the
|
||||
constructor unchanged) on a clone, then refuses a changed `human` and swaps the document in.
|
||||
- The runtime reloads after every committed write, through the chain `bootConfig` uses
|
||||
(`loadBusiness`, `resolveInstance` with `organization.system`, `busBusiness`). Then `limits.refresh`
|
||||
runs. `instances.set` answers `restart: !live`. Any other verb name now throws `invalid-request`
|
||||
where it used to fall through to `plugins.set`.
|
||||
- D3 item 3 check: a role in the boot copy must keep its credential references and tracker bot. A role
|
||||
not in the boot copy must have neither. The tracker sync block must match. Otherwise no reload and
|
||||
`restart: true`. The first version compared one fingerprint over all roles, so every create failed
|
||||
it. T14's create test caught that.
|
||||
- Consumers checked against item 3 (`runtime.mjs` startBroker):
|
||||
- The Credentials store takes the role and sync references and the project roots it protects at
|
||||
start. The tasks adapter takes the bots at start. All of these are compared.
|
||||
- Limits read `settings.current` at each ask. The human proof reads `human` from the boot copy,
|
||||
and `reloadBusiness` refuses a changed human, so the two agree.
|
||||
- Web grants: the broker reads `roles.<I>.web` from its own document (`broker.mjs:1545`), so a
|
||||
reload carries the grant. A hireable definition has no `web` tool.
|
||||
- Usage loads its `usage-source` plug-ins from `b.plugins` at start. That was already so before
|
||||
this row (`plugins.set` never restarted them). A reload doesn't change it, and it isn't compared.
|
||||
- Instance-running: `instances.set` op change with a `definition` on a declared instance with a
|
||||
claim holder (or thread holders for a thread instance) refuses `instance-running` before the
|
||||
writer. A vars or launch change still passes.
|
||||
- Tests: `packages/bus/tests/reload.test.mjs` (T14, 6 tests).
|
||||
|
||||
## S2b notes (veto.set, the D4 hook, keep checks)
|
||||
|
||||
- `LIFECYCLE_HUMAN_VERBS` (exported from `broker.mjs`) has one entry per verb and per `instances.set`
|
||||
op: `instance.launch`, `instance.end`, `instance.regenerate`, `veto.set`, `instances.set add`,
|
||||
`instances.set change`, `instances.set delete`. Choice: `#mayHuman(s, verb, target)` refuses
|
||||
`human-required` for an entry missing from the list. So the list and the calls can't drift apart,
|
||||
and T12 shows the hook got the exact entry: each listed human call passes. The tradeoff is that a
|
||||
new human verb has to join the list before it works, which is what row 193 needs anyway.
|
||||
- `bindHuman` already refuses any human but the owner, so the hook's owner check can't fail on any
|
||||
path today. It is the body row 193 replaces.
|
||||
- M46 (`veto.set` skips `#human`) is equivalent while the generic check runs first: no session
|
||||
without `s.human` gets past `human-required`, and only `bindHuman` sets `s.human`, always with
|
||||
`via: 'cli'` and `outsideAgent: true`. T18 shows the refusal. `#human` stays as the design asks.
|
||||
- `instances.set` now checks `op` in the broker (`add`, `change`, `delete`, else `invalid-request`).
|
||||
The tester's delete refuses `tester-refused` (B6). Until S2c records the delete request, a human
|
||||
delete refuses `invalid-request` after the hook. Without that, the writer's `set` op `delete` would
|
||||
commit with no running or starting check and no workspace rename.
|
||||
- Keep: the `role.revoke` reason branch refuses `instance-kept` for any caller, not only the chief.
|
||||
Only the chief holds `role.end` today, so this is the same set. A caller with `role.end` added
|
||||
later is held to the forbid too. The `organization.set` check refuses when any key of
|
||||
`args.instances` is kept, before the writer and before the grant is spent. The human's `limits.set`
|
||||
isn't checked: the human owns the forbid.
|
||||
- T13 needed no code. `organization.set` and `limits.set` take only `ORGANIZATION_KEYS`, and
|
||||
`limits` and `models` write only `launch.limits` and `launch.models`.
|
||||
- Tests: `chief-of-staff.test.mjs` T10 (reason end, decision end, model write), T13 and T18's
|
||||
veto.set and instances.set paths on a stub writer. T18's file-bytes check and its hire and fire
|
||||
cases come with S2d. `instances-verb.test.mjs` T12 (sessions, tester, hook). `reload.test.mjs`
|
||||
gains veto.set.
|
||||
|
||||
## S2c notes (lifecycle requests, terminate, delete)
|
||||
|
||||
- A request is an `action.allowed` event, action `lifecycle.launch`, `.regenerate`, `.delete` or
|
||||
`.end`, body `{action, request, via, by, ...}`. The human's also gets `human.input {kind: 'admin'}`.
|
||||
The tester's gets none. Steps are `lifecycle.taken`, `lifecycle.done {request, run?, transaction?,
|
||||
refusal?}` and `lifecycle.cancelled {request, by}`, with no role or run in the session. No new
|
||||
table or event kind, so no migration.
|
||||
- Host ops for the launcher pass (S3), each in LAUNCH_OPS: `lifecycleRequests`, `lifecycleTake`,
|
||||
`lifecycleDelete`, `lifecycleDone`. Choice: `lifecycleDelete` commits through the bus's own
|
||||
organization callback, so the D3 reload runs after it as after any other write. The tradeoff is
|
||||
one more host op instead of the launcher calling the writer.
|
||||
- The take re-checks: request-expired (60 s, `LIFECYCLE_REQUEST_MS`), then launch-revoked and
|
||||
instance-retired for launch and regenerate only, then tester-off for a tester request. A refused
|
||||
take writes `lifecycle.done` with the refusal, so the request closes. `lifecycle.end` never
|
||||
expires: the runs it names still need the signal.
|
||||
- instance-starting counts any taken request of I, and any open one still under 60 s, delete
|
||||
included. An open request past 60 s can only close request-expired, so it doesn't count. The end
|
||||
request doesn't count.
|
||||
- Delete order at the verb (V5): keys and identifier, the 64-hex revision, the retired-name retry
|
||||
gate, then `instances.check` (the writer's plan and validation, no write). A named organization
|
||||
refusal fails at once. A BusinessError (the last-listed refusal) is held until instance-running
|
||||
and instance-starting have passed, then answers invalid-request. Then revision-conflict, then the
|
||||
request with `{revision}`. `lifecycleDelete` checks running and starting again after the take
|
||||
(other requests only), then calls `instances.set` op delete with actor = the request's `by`.
|
||||
- Retry: a retired name that isn't in roles deletes again only when its last delete closed
|
||||
`workspace-rename-failed`. The request carries `retry: true`, and `lifecycleDelete` answers
|
||||
`{transaction: null, retry: true}` with no check and no write. The launcher (S3) retries the rename.
|
||||
- `lifecycleDone` carries `transaction`, so the delete's done names the commit the rename used.
|
||||
- Terminate (V3): every claim holder of I, or every thread holder for a thread instance, is revoked
|
||||
with the reason (default "ended by the human" or "ended by the tester"). The answer names the held
|
||||
runs plus the bound runs of I not yet ended, cancels open requests of I and lists taken ones. With
|
||||
none of these it refuses not-held. The tester ends only its own threads and cancels only its own
|
||||
requests.
|
||||
- The tester's business scope needs no check: its cap is bound to one business (M19), so another
|
||||
business's instance answers unknown-role. `instance.request` needs no tester-file check: a tester
|
||||
reads only requests it made.
|
||||
- The tester switch is read through a `testerOn` option. The runtime reads `bus.tester` from the
|
||||
live file through `settings.current`, so a change applies with no restart. Anything but `true`,
|
||||
or a read error, is off.
|
||||
- Refactor: the organization error mapping moved into `#organizationFail(e, verb)`, shared by the
|
||||
organization verbs and `lifecycleDelete`. Same mapping as before.
|
||||
- Deferred: the halted checks (S6, row 191) and W-N1 (S2d with fire).
|
||||
- Tests: `packages/bus/tests/lifecycle-verbs.test.mjs` (12 tests: T12 lifecycle part, T12a, V2-V5,
|
||||
the take and the runtime delete end to end). Probe mutants on the expiry, starting (verb and
|
||||
take), the take's expiry and tester checks, the retry gate, the tester's own-request read and
|
||||
not-held all died. They are not the M-list run.
|
||||
|
||||
## S2d notes (the chief's hire and fire, W-N1)
|
||||
|
||||
- Still hard waits (PRD 1.6). Under PRD 1.7 they move to ROUTED in S2e, with the reason form.
|
||||
- The raise: `decision.raise` takes `arguments`, only for `role.hire` (`{name, definition, vars?}`)
|
||||
or `role.fire` (`{name, reason}`), and requires it for those. `target` must equal
|
||||
`arguments.name`. The raise event records it, and the human's decision view shows it under
|
||||
`authorization.arguments`. The shape checks are the call's (`#hireFireArgs`): identifiers, not
|
||||
`human` or `tester`, vars only `model` and `limits.*`, reason 1-500 characters.
|
||||
- The call: `instance.hire {name, definition, vars?, decision}` and `instance.fire {name, reason,
|
||||
decision}`, from a chief session only. Order: tester-refused, read-only and agent-required
|
||||
(`#agent`), keys, `role-not-chief` (launch.by), not-configured, decision-mismatch for a decision of
|
||||
another business, `#checkAuthority`, the call's arguments against the raise's (canonical JSON,
|
||||
decision-mismatch), decision-consumed, instance-kept. The writer gets the chief as actor
|
||||
(`chief-of-staff.chief`) and the context `{instance, run, decision}`, which changes.jsonl records.
|
||||
- Hire: `#hireable` loads the definition and the chief's own role file through the new `roleFile`
|
||||
option (the runtime passes `loadRole(rolesDir, name)`). Version 1, any action off HIRE_ALLOWED
|
||||
(`unhireableAction`), not on the chief's `hireable` list, on `noHire` or no file all refuse
|
||||
definition-not-hireable before the writer. Then the writer's hire, then the consume with
|
||||
`{transaction}`. Without `roleFile` a hire refuses not-configured.
|
||||
- Fire, before step A: instance-kept, unknown-role, organization-definition-refused (END_PROTECTED),
|
||||
organization-instance-referenced (launch.by), and invalid-request for the last listed name.
|
||||
Step A, one transaction: revoke the claims with the decision and reason, cancel open requests,
|
||||
list taken ones, write `lifecycle.end {via: 'chief', by, runs, reason, kill: true}` when runs exist,
|
||||
and `fire.stopped {cites, request}`. Step B: the writer's fire (already delisted is done). Step C,
|
||||
one transaction: the consume with `{reason, transaction}` and `fire.done {cites, transaction}`.
|
||||
- The fire's own events cite the decision under `cites`, not `decision`, so `#spent` counts only the
|
||||
consume. A failure at B or C leaves the decision unspent, so the repeat runs.
|
||||
- Choice: a repeat after a failed B or C asks for the kill again for any run of I still bound. A
|
||||
revoked run stays bound until the launcher reports it ended, and a second signal to a dead
|
||||
process does nothing. The tradeoff is a duplicate `lifecycle.end` against a run that may outlive
|
||||
the first one.
|
||||
- W-N1 `#firing`: a `fire.stopped` with no `fire.done` for the same `cites`. It refuses
|
||||
instance-firing at the human and tester verbs (`#launchable`), at the take of a launch or
|
||||
regenerate (`#takeRefusal`), and at the chief's `role.launch` (`#authorityFor`).
|
||||
- `#organizationFail` passes the writer's own definition-not-hireable and instance-kept through by
|
||||
name for hire and fire. They come from the file as it stands, after the broker's checks passed.
|
||||
- `lifecycleView` shows `kill: true` when the request carries it.
|
||||
- Deferred: the kill escalation in the launcher (S3, S6).
|
||||
- Tests: `packages/bus/tests/chief-of-staff.test.mjs`, 16 new (T8 x5, T8a, T8b x2, T9 x2, T9c x2,
|
||||
T10, T18, the runtime end to end, W-N1 at the take). 39 in the file pass. Probe mutants on 21
|
||||
lines (the arguments check, each W-N1 site, M60's version check, keep, the other-business check,
|
||||
last-listed, kill, the chief's list, noHire, role-not-chief, the pre-check consume, both consumes'
|
||||
events, the raise's argument rules, the revoke, the cancel, the pass-through, the view's kill) all
|
||||
died. They are not the M-list run.
|
||||
|
||||
## S2e notes (PRD 1.7 REQ-COS-3 routing, sage-1471, mos-1089)
|
||||
|
||||
- Role-policy change, for Webby to review on its own: `roles/chief-of-staff.json` withinRole gains
|
||||
`role.hire` and `role.fire`. `packages/business/src/vocabulary.mjs` and `packages/bus/src/broker.mjs`
|
||||
move both from the hard waits to ROUTED. `roles/chief-of-staff.md` and `roles/pm.md` drop them from
|
||||
the hard-wait list, which `workflow-roles.test.mjs:113` pins to GATED_ONLY. No other shipped role
|
||||
gains either action, and `role.test.mjs` pins that.
|
||||
- The call takes the reason form, as `role.end` does: `instance.hire {name, definition, vars?, reason}`
|
||||
and `instance.fire {name, reason}`, with no decision. The reason is 1-500 characters for both.
|
||||
- Choice: the decision form is gone, not kept beside the reason form. A within-role raise closes at
|
||||
once, so a decision there adds an event and no check. The `decision.raise` `arguments` key from S2d
|
||||
goes with it. Tradeoff: if a later PRD gates hire again, the raise path comes back as new code.
|
||||
- Choice: hire takes a reason too. The PRD logs every hire and fire with its reason.
|
||||
- Order: tester-refused, read-only and agent-required, keys, `role-not-chief` (launch.by),
|
||||
not-configured, `#checkAuthority` with `{target: name}`, instance-kept. `role-not-chief` comes before
|
||||
the authority check, so a non-chief role refuses `role-not-chief` even when its file grants the
|
||||
actions. A chief whose file lacks the grant refuses `decision-required`.
|
||||
- The writer gets only its own keys. The reason goes in the change context `{instance, run, reason}`,
|
||||
which changes.jsonl records. The hire's consume detail is `{reason, transaction}`.
|
||||
- W-N1 `#firing` now goes by order, since the reason form has no decision id: a `fire.stopped` of the
|
||||
subject with no later `fire.done` (`seq` after it) for the same subject. `fire.stopped` carries
|
||||
`{action, request}` and `fire.done` `{action, transaction}`.
|
||||
- Harness: the typed `hire` and `fire` tools (verbs `instance.hire` and `instance.fire`) come forward
|
||||
from S4, because a held action with no typed tool fails `untypedActions`. Each tool follows its own
|
||||
grant. `roles/chief-of-staff.md` already names them. The rest of the S4 contract text and the runner
|
||||
end to end for hire and fire stay in S4.
|
||||
- Going live is a later re-pin step with Mos (sage-1471). Landing the change doesn't put it on the live
|
||||
stack.
|
||||
- Correction to S2d (found in S2e): S2d's broker imported `business/src/role.mjs`, which is not in
|
||||
`seat/src/sandbox.mjs` RUNNER_FILES. The runner imports broker.mjs, so a session died with
|
||||
ERR_MODULE_NOT_FOUND: cli `launcher.test.mjs` failed 15 of 29 and seat `sandbox.test.mjs` 4 of 10 at
|
||||
089b92ca3. A bisect showed S2c passes 29 of 29. I had not run those two files for S2d. Fix:
|
||||
`unhireableAction` moves into vocabulary.mjs, which is mounted, and role.mjs re-exports it.
|
||||
RUNNER_FILES is unchanged, so the sandbox boundary does not widen. Rule from here (sage-1497): a
|
||||
change to broker.mjs imports, or those of any RUNNER_FILES file, runs these four singly: seat
|
||||
`sandbox.test.mjs`, cli `launcher.test.mjs`, cli `thread-launch.test.mjs` and cli
|
||||
`git-socket.test.mjs`.
|
||||
- Mutant (sage-1497): broker.mjs imports `unhireableAction` from role.mjs again. Killed: sandbox.test
|
||||
fails 4 of 10 (role.mjs outside RUNNER_FILES), launcher.test 15 of 29, thread-launch 7 of 11. git-socket
|
||||
passes 24 of 24 under it, so it does not catch this one and stays on the list for its own sandbox runs.
|
||||
- Tests: `packages/bus/tests/chief-of-staff.test.mjs` S2d block rewritten to the reason form (39 pass),
|
||||
with new tests for the reason bounds, `role-not-chief` with the grant, `decision-required` for a chief
|
||||
without each grant, W-N1 by order, and the runtime end to end with the reason in the receipt context.
|
||||
`packages/harness/tests/tools.test.mjs` gains the hire and fire tool shape test, and that no coder,
|
||||
reviewer or pm gets either tool.
|
||||
- Probe (not the M-list run): 16 mutants, all killed. ROUTED drops of each action in the broker and in
|
||||
vocabulary, the role file drops of each, `#firing` without the order and without the subject, the
|
||||
reason unchecked, the writer given the reason, the context without it, no `#checkAuthority`, no
|
||||
`role-not-chief`, the consume without the reason, the hire tool's reason optional, and the fire tool
|
||||
gated on the hire grant. The last one survived at first and a one-grant check in tools.test killed it.
|
||||
|
||||
## S3a notes (the launcher's lifecycle pass)
|
||||
|
||||
- `packages/cli/src/launcher.mjs` `lifecyclePass` runs on the tick after the thread and land passes.
|
||||
It lists the open requests (host op `lifecycleRequests`) and carries each under `serialize`: the
|
||||
take, the act, then `lifecycleDone`. A take that answers no closes nothing more here. A refusal
|
||||
goes in the launch log as `refused` with the request id. A done that throws leaves the request
|
||||
taken with no done, which S3b's CLI reports as `outcome-unknown`. The log line names a run that
|
||||
did start.
|
||||
- Launch: `check()` and `start()` gain `human`. The human's request may name any declared instance,
|
||||
as V2 asks. The listing limits the chief. launch.by still takes the `pm` branch (N8). A tester
|
||||
request launches with `launchedBy: "tester"`.
|
||||
- End: SIGTERM to each run the verb named and to any other run of the instance in the registry,
|
||||
each after the start-time check, with a `lifecycle-end` entry. The exit handler writes the one
|
||||
`end` record. A thread instance's end signals only the runs the verb named, so the tester's end
|
||||
leaves the human's threads alone. No SIGKILL escalation here: that is row 191's stop (S6).
|
||||
- Regenerate: the end's signals, then a wait until the old run leaves the registry, then a launch.
|
||||
The wait runs under `serialize`, so no other start runs meanwhile. Past the request's mark
|
||||
(`at` + 60 s, `lifecycleRequestMs` for tests) it closes `regenerate-timeout` and starts nothing. A
|
||||
closing host closes it `host-closing`. Tradeoff: one stuck regenerate holds other starts for up to
|
||||
60 s. The other choice, a second pass that comes back for it, needed state across ticks for the
|
||||
same result.
|
||||
- Delete (V5 steps 1-3): `instance-running` against the registry first, then `lifecycleDelete` (the
|
||||
writer's commit), then the rename of `workspaces/<b>/I/` to `workspaces/<b>/.retired/I-<tx>/`,
|
||||
both 0700. The file goes first, so a failed rename can't leave a launchable name on the old
|
||||
workspace. A failed rename closes `workspace-rename-failed` with the transaction. A later delete of
|
||||
the name is the retry, and the broker now answers the failed transaction, so the rename target is
|
||||
the same (changed assertion above). No workspace means nothing to rename.
|
||||
- `--no-launch` (row 163): the ticker now runs on a no-launch host, but only the lifecycle pass, and
|
||||
`act` closes each request `launches-disabled` before any signal, start or rename. The thread and
|
||||
land passes stay off, so `launcher.test.mjs`'s no-launch tests pass unchanged.
|
||||
- Tests: new `packages/cli/tests/lifecycle.test.mjs`, 9 tests on the in-process host of
|
||||
thread-launch.test.mjs, with ticks called by the test: T2 (unlisted and launch.by launches, a
|
||||
request past 60 s, a check refusal with its code in the log), T3 (`--no-launch`, `capacity-full`
|
||||
with the limit in the log, `instance-running`), T4 (end at the verb, the signal, one end record,
|
||||
the cancelled request never runs), T5 (a new run on the same workspace, `regenerate-timeout`
|
||||
with a SIGSTOPped runner, `regenerate-thread` at the verb), V5 (a failed rename then the retry,
|
||||
modes 0700, no workspace).
|
||||
- Singles at S3a: seat sandbox 10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24,
|
||||
bus lifecycle-verbs 12/12, cli instances 6/6, cli lifecycle 9/9, bus process 8/8. broker.mjs
|
||||
imports did not change, and the four must-run files ran anyway.
|
||||
|
||||
## S3b notes (the CLI verbs)
|
||||
|
||||
- `packages/cli/src/instances.mjs`: `business instance launch|regenerate BUSINESS I [--wait S]
|
||||
[--tester]`, `terminate BUSINESS I [--reason TEXT] [--tester]`, `delete BUSINESS I --revision REV
|
||||
[--wait S]` and `business veto BUSINESS keep|unkeep INSTANCE|nohire|hire DEFINITION --revision
|
||||
REV`. `--wait` takes 0 to 600 s and defaults to 30. Every usage error exits 4 before any bus call.
|
||||
add and set refuse `--wait` and `--reason`.
|
||||
- Launch, regenerate and delete print the request id on stderr, then poll `instance.request` every
|
||||
200 ms. Closed with a run or a transaction prints it and exits 0. A refusal prints its reason, the
|
||||
request id and any transaction. A cancel prints who cancelled it. Still open at `--wait` says so and
|
||||
names the 60 s expiry. Taken with no done is `outcome-unknown` and points at the launch log.
|
||||
Terminate doesn't wait: it prints the revoked runs, the cancelled requests and any taken request
|
||||
past cancelling.
|
||||
- Exit codes: every lifecycle outcome that isn't done (refusal, cancel, still open, outcome unknown)
|
||||
exits 3, as the design's `--wait` timeout does. Tradeoff: errors.mjs has 1 for an unknown outcome
|
||||
elsewhere. One code lets a script treat "the request did not do it" as one case, and the message
|
||||
says which. A refusal at the verb keeps the transport's code (2), as add and set do.
|
||||
- The bus `instances` read gains `running` per instance (`#running`, from the claims), so the CLI
|
||||
and S5 can show it without a second verb. An answer with no instances map passes through as
|
||||
before. `bus instances-verb.test.mjs`'s stub writer answers `{ok: true}` and caught the first
|
||||
version, which threw. The CLI view marks `kept` and `running r1, r2` and adds `retired:` and
|
||||
`the chief may not hire:` lines.
|
||||
- `scripts/mosaic` sends `business veto` to packages/cli with instances and instance.
|
||||
- Tests: cli `instances.test.mjs` +8: the V6 veto ops and kept-delete refusal, the `running` read,
|
||||
launch and regenerate printing the run, a closed refusal with its id (exit 3), `--wait 0` still
|
||||
open and taken `outcome-unknown`, request-cancelled, terminate output and `not-held`, delete to
|
||||
its transaction and `workspace-rename-failed` naming it, usage errors (exit 4, no call), and the
|
||||
scripts/mosaic veto route. The existing route test gains the veto assertions. cli
|
||||
`lifecycle.test.mjs` +5: the CLI end to end through the pass (launch, regenerate, `capacity-full`
|
||||
with the request id, terminate, delete), T2a (`launch-revoked` at the take with no process, a
|
||||
done that fails once leaves the request taken and the next pass starts nothing), T4 (a spawned
|
||||
run the bus never bound is signalled, a mismatched start time is not), T6 (run evidence bytes
|
||||
unchanged by a delete, the launch log only grows, a create and a launch of the retired name
|
||||
refuse), T11 (two roots with business `acme`: a launch, a terminate and a delete in one leave the
|
||||
other's files byte-identical). bus `chief-of-staff.test.mjs` +1: T11's bus half, a chief on a bus
|
||||
with two businesses hires and fires only in its own.
|
||||
- Not covered here: T6's `instance-starting` and the V5 refusal order are bus tests from S2c.
|
||||
`check()` refusing a retired name is shown through the verb and the take, since `check()` reads a
|
||||
file that no longer lists the name.
|
||||
- Singles at S3b: cli instances 14/14, cli lifecycle 14/14, bus lifecycle-verbs 12/12, bus
|
||||
instances-verb 3/3 (after the fix), bus chief-of-staff 40/40, webui instances 5/5, seat sandbox
|
||||
10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24, usage 2/2, project 4/4, app
|
||||
30/30, host 42/42, tester-threads 5/5, tester-reads 6/6, stack-copy 12/12, queue dispatch 4/4,
|
||||
runs task-cli 9/9, webui pinned-copy 10/10, service 31/31, views 3/3. The broker change adds no
|
||||
import. The four must-run files ran while the `instances` guard was being written, and the guard
|
||||
touches only the read. No test pins a hash of scripts/mosaic.
|
||||
|
||||
## S4 notes (the launch tool takes a hire, D3 item 5)
|
||||
|
||||
- `packages/harness/src/tools.mjs`: the launch tool's `instance` is a string with the ID_PATTERN
|
||||
pattern, not an enum fixed when the bundle was built. The start-time listing stays in the
|
||||
description. The host checks the listing at each launch (`instance-not-listed`), so the chief
|
||||
launches its own hire in the same session. The hire and fire tools came forward in S2e.
|
||||
- The runner does not enforce a tool's schema pattern. A bad name reaches the host and refuses
|
||||
`instance-not-listed`, so the end-to-end test asserts that. M15 (the enum kept) is killed by the
|
||||
tools.test shape assertion, not by the end-to-end test.
|
||||
- `packages/cli/tests/lifecycle.test.mjs` gains the T8 end to end: the chief's session hires
|
||||
`sec-2` (definition chief-information-security-officer, the shipped definition `hireable` accepts:
|
||||
message.send and record.write, no credential) with `TOOL hire`, a launch refuses `no-route`, the
|
||||
human sets the route with `instances.set` op change, the same session launches it (launchedBy
|
||||
chief), `NOT A NAME` and `reviewer` refuse `instance-not-listed`, then `TOOL fire` ends its run,
|
||||
leaves it `launched: false` and a later launch refuses `instance-not-listed`.
|
||||
- Route gap, raised with Sage (honker-0027): harness, model and thinking are agent-layer vars
|
||||
(`business/src/vars.mjs:142-147`) and hire vars allow only model and limits.* (N3). So a hire runs
|
||||
only after the human sets harness and thinking. D3 item 5 says the same session launches it, and
|
||||
it does, after that human step. Letting hire vars carry the route would widen the chief's
|
||||
authority, a policy change that is not mine to make. As built the human step stays, and
|
||||
`roles/chief-of-staff.md` says so. Sage ruled (b) in sage-1521: the human route step stays, and
|
||||
the manifest notes say that launch refuses `no-route` until the human sets the route.
|
||||
- sage-1521 also keeps exit 3 for every lifecycle outcome but done (outcome-unknown included, the
|
||||
S3b tradeoff line above stands for Webby) and the S3a choices (a `--no-launch` host runs only the
|
||||
lifecycle pass, `lifecycleDelete` answers the transaction on a retry). `roles/chief-of-staff.md`
|
||||
is role policy: the manifest names it and the review request flags it as a policy change.
|
||||
- `roles/chief-of-staff.md` (role policy, named in the manifest): one paragraph on the hire's route
|
||||
step and on a fired instance staying in the file off the list.
|
||||
- Singles, each run alone: business chief-of-staff 10/10, spec-review-contracts 3/3, role 19/19,
|
||||
resolve 10/10, harness bundle 22/22, tools 23/23, runner 34/34, pi-session 8/8, seat sandbox
|
||||
10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24, lifecycle 15/15, webui reads
|
||||
11/11. No test pins a hash of tools.mjs or roles/chief-of-staff.md. tools.mjs gains an import from
|
||||
vocabulary.mjs, already in RUNNER_FILES, and the four must-run files passed after it.
|
||||
|
||||
## Rebase onto row 192 (2026-10-20T22:10Z)
|
||||
|
||||
Row 192 (4429a104f, Q175 phase 1 SSO) changed webui `serve.mjs`, `cli.mjs`, `app.js` and
|
||||
`index.html`, the files S5 changes. Before S5 the branch was rebased onto origin/refactor
|
||||
f41729178 as `honker/row194-b2`. `honker/row194` stays at f70b4da50 (no force-push). The only file
|
||||
both sides changed was `PRD-1.7-DRAFT.md`, whose blob was the same on both (9bfc6f76), so its five
|
||||
draft commits dropped as already upstream. The tree check: the rebased tip differs from f70b4da50
|
||||
by exactly the base's changes. Every test file the branch changes, the four must-run files and
|
||||
webui sso, service and reads were run again singly on the new base: 23 files, all pass.
|
||||
|
||||
## S5 notes (the Console's Agents section, D7)
|
||||
|
||||
- `packages/webui/src/public/agents.js` (new, `window.MosaicAgents.section`): Settings draws it after
|
||||
the role instances, from the same instances read. The table has instance, definition, launched, kept
|
||||
and running runs, and each row has Launch, a reason box and Terminate. A human also gets Regenerate,
|
||||
Keep or Unkeep, and Delete. The protected assistant has no Delete. Each definition has a No-hire or
|
||||
Allow-hiring button, and a Create form adds an instance of an accepted definition. Retired names show
|
||||
as one line. D7's "retired" column is that line, since a retired name has no instance row.
|
||||
- Regenerate and Delete take a second click. The confirm says what happens: for Delete, "Its evidence
|
||||
and its workspace under .retired stay." Cancel asks nothing.
|
||||
- Choice: every answer that names a request is followed at `/api/agent/request` every 2 s, up to 90
|
||||
reads, until the request closes. D7 names launch and regenerate. Delete and terminate also answer a
|
||||
request (S2c), so the page follows them too, and the receipt shows the close or its refusal reason.
|
||||
A failed read is shown and the poll goes on. The request is never sent again. Past 90 reads the page
|
||||
says the request is still open and asks for a fresh read. A sign-out stops the poll.
|
||||
- With `send` false the section shows "Agent actions are unavailable here. Use mosaic business instance
|
||||
from the instance CLI." and every control is disabled. The tester sees Launch and Terminate only.
|
||||
- `serve.mjs`: `/api/instances` takes op `delete` with the revision and the instance alone and answers
|
||||
the request. `/api/agent/launch`, `/regenerate`, `/terminate` and `/api/veto` each have
|
||||
`instanceSend`'s boundary and one call to `agentWrite`, with only the route's keys. A reason is never
|
||||
logged. `GET /api/agent/request?id=` takes one request id and adds the reason for a closed refusal or
|
||||
a cancel. Every refusal code the S2/S3 verbs answer has a reason line (`AGENT_REASONS`), and a code
|
||||
missing there shows as itself. No route has a `--send` exception.
|
||||
- `cli.mjs`: `agentWrite` reaches only `instance.launch`, `instance.regenerate`, `instance.end`,
|
||||
`veto.set` and `instance.request` (`agentCaller` throws for any other). The tester listener gets
|
||||
launch, end and request only (D4.1).
|
||||
- D4.1 at the webui: the tester listener lets launch and terminate through and refuses regenerate and
|
||||
veto with no call. A tester delete on `/api/instances` is refused in `instanceSend` with no call
|
||||
(`tester-refused`). The bus refuses it too (broker tester-refused), and this keeps the webui from
|
||||
asking. The agents test covers it, and with the guard removed that test fails (control run).
|
||||
- Deferred to S6: the "still running after fire" flag needs the `lifecycle.end.alive` event, which
|
||||
comes with row 191's stop.
|
||||
- Tests: webui `agents.test.mjs` (new, 8: actions and logs, boundary refusals, read-only, refusal and
|
||||
unknown outcomes, the request read, delete, tester routes, agents.js served) and
|
||||
`agents-browser.test.mjs` (new, 1: read-only, launch followed to close, confirms, terminate reason,
|
||||
refusal, keep and unknown outcome).
|
||||
- Singles, each run alone, after the last code change: webui agents 8/8, agents-browser 1/1, browser
|
||||
1/1, instances 5/5, instances-browser 1/1, serve 4/4, service 31/31, sso 28/28, sso-browser 2/2,
|
||||
routes 5/5, routes-browser 1/1, tester-listener 10/10, tester-browser 2/2, tester-cli 2/2,
|
||||
settings-write 4/4, send 13/13, send-browser 2/2, shell 5/5, cli launcher 29/29, thread-launch
|
||||
11/11, git-socket 24/24, stack-copy 12/12, cli stack-release 174/174, seat sandbox 10/10. No test
|
||||
pins a hash of a file S5 changes. shell.test reads bus.js strings and its POST count, which S5 leaves
|
||||
alone because agents.js holds the section's writes.
|
||||
|
||||
## Item 7 plan (brief rev 2547, sage-1586, from row 199 N2, duckworth-0068)
|
||||
|
||||
Choice: create states that the agent needs a restart before `issue`. It doesn't register the entry with
|
||||
the running bus.
|
||||
|
||||
- Today create and hire refuse a definition that declares credentials (`organization-credentials-out-of-
|
||||
scope`, `instance-settings.mjs:75-77`). So no created agent has a credential entry, and item 7 has nothing
|
||||
to act on until row 199 lands. Row 199's D13 (origin/megavolt/row199-on-190
|
||||
`agents/megavolt/work/queue-199/DESIGN.md:295-307`) has row 194 lift that refusal for the human's create
|
||||
only. The agent is created with `store` references and no files. Hire stays uncredentialed.
|
||||
- The runtime already treats credential references as fixed at start (`runtime.mjs:115-134`). A file whose
|
||||
references differ from the boot copy isn't reloaded, and `instances.set` answers `restart: true`
|
||||
(`runtime.mjs:182`). The CLI prints RESTART_NEEDED (`packages/cli/src/instances.mjs:59, 235`), and the
|
||||
Console's instances section prints RESTART_TEXT (`packages/webui/src/public/bus.js:691, 733`). So after the
|
||||
lift, a human create of a credentialed definition answers `restart: true` with no new runtime code.
|
||||
`credentials.issue` then refuses `credential-unknown`, because the broker's copy of the business doesn't
|
||||
hold the agent (`broker.mjs:1443` on row 199) and Credentials has no reference for it
|
||||
(`credentials.mjs:148-150, 157`).
|
||||
- Why not register: registering means a new path in row 199's Credentials that adds references to a running
|
||||
store, plus a change to the fixed-at-start rule that row 194's D3 review accepted. The restart route changes
|
||||
neither. It costs one bus restart per credentialed create, which the human already makes for any credential
|
||||
change.
|
||||
- Build, after row 199 lands and this branch moves onto it (new branch name, no force):
|
||||
1. `instance-settings.mjs`: a human `add` of a credentialed definition writes `store` references with the
|
||||
date fields row 199 requires and no files. Hire keeps the refusal. The human's create is
|
||||
`instances.set` op `add` (agents.js posts it to /api/instances), so it already answers `restart`. The
|
||||
runtime comment at `runtime.mjs:115-116` ("Create and hire add neither") changes to say a human create
|
||||
can.
|
||||
2. `agents.js` createAgent: the receipt carries the answer's restart text, as bus.js does. Today it says
|
||||
"Created X." and drops `restart`.
|
||||
3. T19, bus test: a human create of a credentialed definition answers `restart: true`. Issue refuses
|
||||
`credential-unknown` before a restart. After a bus restart over the same directories, issue works and the
|
||||
status reads `valid`. Hire of the same definition still refuses. A browser assertion covers the restart
|
||||
text in the Agents receipt.
|
||||
4. Mutants: M61 the create refusal stays for the human (T19 fails at create). M62 `fixedRole` ignores
|
||||
`credentials`, so the reload goes live and answers `restart: false` (T19 fails). M63 the Agents receipt
|
||||
drops the restart text.
|
||||
- D13's revoke on fire and delete: Sage put it in row 194 as brief item 8 (sage-1588). See the item 8 plan.
|
||||
|
||||
## Item 8 plan (brief rev 2556, 722136772, sage-1588, row 199 D13)
|
||||
|
||||
`fire` and `delete` call `credentials.revoke(business, agent, {reason, actor})` before they change the
|
||||
business file. A failed revoke stops the verb with `credential-revoke-failed` and leaves the file unchanged.
|
||||
|
||||
- Order: the verb's own checks, then the revoke, then the write. A verb its checks refuse revokes nothing.
|
||||
Fire's checks are `instance-settings.mjs:254-265` (unknown, kept, end-protected, launch.by, last name).
|
||||
Delete's are `planDelete` (`instance-settings.mjs:189-193`). A delete writes in the launcher's pass,
|
||||
through `set` op `delete` after the take (comment at `instance-settings.mjs:187-188`), so its revoke goes
|
||||
there, after `planDelete` and before the commit. The write runs under the same revision the
|
||||
checks read, so a revision race refuses as today, after the revoke. That leaves an agent revoked with its
|
||||
file unchanged, which is the safe side: Jason runs `issue` again if they still want it.
|
||||
- Where: the business package doesn't import the bus. The runtime passes the instance writer a revoke
|
||||
callback, and fire and delete call it between the checks and the write. With no Credentials store (no
|
||||
row 199 config) the callback is absent and nothing changes.
|
||||
- A fire of a name that isn't in `launch.instances` writes nothing (`instance-settings.mjs:262`) and calls
|
||||
no revoke.
|
||||
- A revoke for an agent the running bus has no entry for (sage-1592): `credential-unknown` counts as
|
||||
nothing to revoke only when the agent has no token file under the store, `secrets/agents/<business>/
|
||||
<agent>/`. If a file is there while the bus doesn't know the entry (a file issued before a restart that
|
||||
dropped the entry), the verb stops with `credential-revoke-failed`, so no agent is fired with its token
|
||||
still on disk. Every other refusal stops the verb too.
|
||||
- Row 199 as of 86963d281 (origin/megavolt/row199-on-190) doesn't cover that case. `revoke(instance,
|
||||
service)` calls `#known` first (`credentials.mjs:197-198`), and `#known` refuses `credential-unknown`
|
||||
from the boot-time entries alone (`credentials.mjs:148-150`). It never looks at the disk. The store path
|
||||
is `<storeRoot>/<business>/<agent>/<service>` (`credentials.mjs:71-76`), with storeRoot
|
||||
`<configDir>/secrets/agents` (`credentials.mjs:40`). So the extra check stays: on `credential-unknown`,
|
||||
look for any entry in the agent's store directory. Recheck at landing. If the landed revoke deletes or
|
||||
refuses in that case, cite the line and drop the check.
|
||||
- Also to recheck at landing: D13's call reads `credentials.revoke(business, agent, {reason, actor})`, but
|
||||
the class method is per service, `revoke(instance, service)`, and `remove(instance)` revokes every
|
||||
service and then clears the agent's directory (`credentials.mjs:211-218`). The broker verb
|
||||
`credentials.revoke` takes `agent, service, reason` (`broker.mjs:1437`). The build uses whatever the
|
||||
landed code offers for every service of one agent, and records which.
|
||||
- Sage's pick (sage-1593): fire calls `revoke(instance, service)` for each service in the agent's entry
|
||||
and keeps the directory, so the status still reads revoked and Jason deletes the Gitea or Vikunja token
|
||||
(199 A5). Delete calls `remove(instance)`. Both count as the revoke for the order, the refusal on
|
||||
failure, T20 and M64-M67. If the landed code differs from 86963d281, follow it and note the difference
|
||||
here.
|
||||
- What that does to the sage-1592 check, as of 86963d281: `remove` takes its services from the boot-time
|
||||
entries, so for an unknown agent it revokes nothing and doesn't refuse. It then lists the agent's store
|
||||
directory and unlinks every token and tmp file, refusing `credential-file` for any other name
|
||||
(`credentials.mjs:211-224`). So delete already leaves no token on disk, and the extra check applies to
|
||||
fire only (sage-1592: cite the line and drop it). T20's delete case with a file and no entry expects the
|
||||
file gone and the delete done. M67 runs against fire only. Recheck all of this at landing.
|
||||
- The reason: fire carries the chief's reason from the change context. Delete carries the human's request
|
||||
reason. The actor is the verb's actor.
|
||||
- T20, bus tests, each for fire and for delete:
|
||||
- With a credentialed agent: the revoke runs, the status reads revoked, and the file changes.
|
||||
- With a revoke that fails (a stub store that throws): the verb refuses `credential-revoke-failed`, the
|
||||
file's revision is unchanged, and the agent is still listed.
|
||||
- The revoke runs before the write. The stub records the file revision it saw, and that revision is the
|
||||
old one.
|
||||
- With no entry in the running bus but a token file in the agent's store directory: the verb refuses
|
||||
`credential-revoke-failed` and the file's revision is unchanged. With no entry and no file: the verb
|
||||
goes on and the file changes (sage-1592).
|
||||
- Mutants: M64 drop the revoke call (the success case fails, the status isn't revoked). M65 move the revoke
|
||||
after the write (the order check fails). M66 ignore the revoke's failure (the failing case writes the
|
||||
file). M67 treat any `credential-unknown` as nothing to revoke (the token-on-disk case writes the file).
|
||||
M64-M66 run against fire and delete, M67 against fire only (see sage-1593 above): M64a-b to M66a-b, M67.
|
||||
|
||||
@@ -1,707 +0,0 @@
|
||||
# Row 194 design: agent lifecycle from the Console and the CLI, with a per-agent veto
|
||||
|
||||
Brief: `docs/plans/2026-10-20_agent-lifecycle.md` (blob a907109409ac). Gitea #1692. MVP T1 item 5 (mos-0974,
|
||||
mos-0977, DECISIONS Q176). Owner Honker, reviewer Webby, gate owner Sage. Base: refactor at 53bb59462.
|
||||
Branch `honker/row194`.
|
||||
|
||||
Related designs read: row 190 `agents/darkwing/work/queue-190/DESIGN.md` (r2, after mos-0986), row 191
|
||||
`agents/rocko/work/queue-191/DESIGN-r2.md`. Row 193 is still in design. All line numbers below are at
|
||||
53bb59462.
|
||||
|
||||
## Summary
|
||||
|
||||
- One verb family on the CLI, `mosaic business instance <verb> BUSINESS INSTANCE`, and one Console section.
|
||||
Create and delete change the business file. Launch, terminate and regenerate act on runs.
|
||||
- Every human verb goes through the bus with the human proof. Launch, regenerate and delete are requests the
|
||||
launcher takes once and carries out under `serialize`. Launches go through the one `start()`, so
|
||||
`--no-launch`, the limits, `launch.revoke` and row 191's switch all apply.
|
||||
- The chief hires (adds and lists an instance) and fires (ends its runs and delists it). It never deletes.
|
||||
`role.hire` and `role.fire` are new hard waits, so Jason resolves each one (Mos, mos-1017). The routed
|
||||
form waits on a PRD 1.7 draft in this packet.
|
||||
- A hire allowlist in code: the chief can hire only a definition whose every action is on `HIRE_ALLOWED`
|
||||
(message, record, review request, assigned-task updates, and two actions that matter after row 199).
|
||||
Launch, end, organization, naming, hire, verdict, merge and every later action stay out.
|
||||
- The per-agent forbid is two lists in the business file that only the human writes: `launch.keep`
|
||||
(instances the chief can't fire, replace or end) and `launch.noHire` (definitions the chief can't hire).
|
||||
- The broker reloads a business from its file after each committed write. Create, delete, hire, fire and
|
||||
veto changes then count without a bus restart. Hand edits still need one, except `bus.tester`.
|
||||
- No bus schema change and no migration. Named for review: two new vocabulary actions (hard waits), the
|
||||
role-file `hireable` key, `"hireable": []` in `roles/chief-of-staff.json`, and three business-file keys.
|
||||
|
||||
## What exists today (checked at 53bb59462)
|
||||
|
||||
| # | Fact | Where |
|
||||
|---|---|---|
|
||||
| E1 | Instances are keys of the business file's `roles`. `launch.instances` lists those the chief may launch. The list must be non-empty | `business.mjs:121-132` |
|
||||
| E2 | The human adds an instance and changes its definition, vars and listing. No delete. Credentialed definitions are refused | `instance-settings.mjs:1-5, 22-31, 75-156` |
|
||||
| E3 | The broker copies the businesses once at start and refuses a role it doesn't know | `broker.mjs:208, 446` |
|
||||
| E4 | The chief launches over `launch.sock`. `fromSession` checks identity, then `check()` (which reloads the file), then `authorizeLaunch`, then `start()` | `launcher.mjs:232-327, 624-668` |
|
||||
| E5 | The chief's `launch` tool takes an enum of the instances listed when its bundle was built | `harness/src/tools.mjs:116-119` |
|
||||
| E6 | The chief ends a run with `role.revoke` plus a reason (`role.end`). Ending then launching again is already a replace | `broker.mjs:1601-1628`, `tools.mjs:167` |
|
||||
| E7 | `mosaic stop RUN` is a same-uid SIGTERM. It has no bus call and no human proof | `cli.mjs:447-454`, `seat/src/session.mjs:249-270` |
|
||||
| E8 | The Console stops only the assistant (`assistant.stop`) and writes instances through `instances.set` | `serve.mjs:282-289, 315-363, 535` |
|
||||
| E9 | The launcher already starts runs for the human on its 2 s tick: threads with pending messages, and merge holders | `launcher.mjs:133, 487-597` |
|
||||
| E10 | `launch.revoke` and `launch.restore` are human-only. A revoked business refuses `role.launch` | `broker.mjs:770-774, 2087-2095` |
|
||||
| E11 | Only the assistant is END_PROTECTED. Coder, reviewer, pm and cto all declare gitea and vikunja credentials | `vocabulary.mjs:135`, `roles/*.json` |
|
||||
|
||||
The chief's veto today comes from the human-only membership in E2. The chief can only launch what the human
|
||||
listed. It has no way to create an instance, and the human has no way to stop the chief ending a given
|
||||
instance's runs.
|
||||
|
||||
## D1. The verbs
|
||||
|
||||
All of them are human verbs. Each is a broker verb behind `#human(s)` and then the D4 hook. Each logs its
|
||||
actor. The CLI usage joins `INSTANCES_USAGE` (`cli/src/instances.mjs:9`).
|
||||
|
||||
| Verb | CLI | Bus verb | Effect |
|
||||
|---|---|---|---|
|
||||
| V1 create | `business instance add B I --definition DEF [--vars FILE] [--launch] --revision REV` | `instances.set` op `add` | as today, with no restart (D3) |
|
||||
| V2 launch | `business instance launch B I [--wait SECONDS]` | `instance.launch` | one run, through the launcher |
|
||||
| V3 terminate | `business instance terminate B I [--reason TEXT]` | `instance.end` | ends every run of I and cancels I's open requests |
|
||||
| V4 regenerate | `business instance regenerate B I [--wait SECONDS]` | `instance.regenerate` | V3, then V2 once the run has exited |
|
||||
| V5 delete | `business instance delete B I --revision REV [--wait SECONDS]` | `instances.set` op `delete` | a request. The launcher removes I from the business file and renames its workspace |
|
||||
| V6 veto | `business veto B (keep\|unkeep) I` and `business veto B (nohire\|hire) DEF`, each `--revision REV` | `veto.set` | edits `launch.keep` and `launch.noHire` |
|
||||
|
||||
`mosaic business instances B` (read) gains, per instance: `running` (run ids from the bus claims), `kept`
|
||||
and `retired`. The top level gains `noHire`. `mosaic stop RUN` stays as it is. It is the same-uid operator
|
||||
tool, and V3 is the human verb.
|
||||
|
||||
### D1.1 Requests (V2, V4, V5)
|
||||
|
||||
The broker never starts a process or touches a workspace. Launch, regenerate and delete are requests the
|
||||
launcher carries out, as E9 already does for the human's threads. A request has three states, open, taken
|
||||
and closed, all recorded as `action.allowed` events with reserved action names:
|
||||
|
||||
| Event | Written by | Body |
|
||||
|---|---|---|
|
||||
| `lifecycle.launch`, `lifecycle.regenerate`, `lifecycle.delete` | the broker, at the verb | `{ request, via }`, subject I, actor the human or `tester`. A `human.input` event goes with it, as `launch.revoke` does |
|
||||
| `lifecycle.taken` | host op `lifecycleTake` | `{ request }` |
|
||||
| `lifecycle.done` | host op `lifecycleDone` | `{ request, run? , refusal? }` |
|
||||
| `lifecycle.cancelled` | V3, for each open request of I | `{ request, by }` |
|
||||
|
||||
1. The launcher's new `lifecyclePass` runs on the same tick as `threadPass` and `landPass`. It lists open
|
||||
requests with host op `lifecycleRequests`.
|
||||
2. For each one, under `serialize`, it calls `lifecycleTake(request)`. That op runs in one store
|
||||
transaction. It re-checks, in order: the request is still open (not taken, cancelled or closed), it is
|
||||
less than 60 s old (else it closes as `request-expired`), the business isn't revoked (`launch_state`,
|
||||
`launch-revoked`), I isn't retired, row 191's switch is off (`processing-halted`) for launch and
|
||||
regenerate, and for a tester request `bus.tester` is true in the file (D4.1). Any failure closes the
|
||||
request with that code. Otherwise it writes `lifecycle.taken` and answers go.
|
||||
3. Only after go does the pass act: `check(I, { human: true })` then `start()` for a launch, the V5 steps
|
||||
for a delete.
|
||||
4. The pass then calls `lifecycleDone`. A taken request never runs again, whatever happens after the take.
|
||||
If `lifecycleDone` throws, the request stays taken with no done event. The CLI reports that as
|
||||
`outcome-unknown` with the request id, and the launcher log names the run.
|
||||
5. The CLI polls a read verb, `instance.request`, until the request closes or `--wait` runs out (default
|
||||
30 s). A timeout exits 3 with the request id. An untaken request can still be taken within its 60 s.
|
||||
|
||||
So the human's `launch.revoke` stops every untaken request, not only new ones (B4), and a bus restart
|
||||
can't start a request older than 60 s.
|
||||
|
||||
### V2 launch
|
||||
|
||||
1. At the verb the broker checks that I exists, isn't a thread instance (`not-thread-launch`: threads start
|
||||
on a message), isn't retired, and that the business isn't revoked and, with row 191, isn't halted. These
|
||||
repeat at the take (D1.1 item 2).
|
||||
2. After the take, `check(I, { human: true })` and `start({ instance: I, launchedBy })`, where
|
||||
`launchedBy` is the business human or `tester`. `human: true` lets the human launch an instance that
|
||||
isn't in `launch.instances`. The listing limits the chief, not the human. `launch.by` still goes through
|
||||
the `pm: true` branch (`launcher.mjs:235`), as `--pm` does today. Every other step of `check()` and
|
||||
`start()` is unchanged.
|
||||
|
||||
### V3 terminate
|
||||
|
||||
1. The broker revokes every claim I holds (`#claimEnd(s, h, 'revoke', null, reason, actor)`). A thread
|
||||
instance has all its thread claims revoked, which is exactly `assistant.stop`.
|
||||
2. In the same transaction it writes `lifecycle.cancelled` for every open (untaken) request of I, so no
|
||||
launch the human asked for before the terminate starts after it (B5).
|
||||
3. It writes `lifecycle.end` with the runs. The launcher pass sends SIGTERM to each run's runner after
|
||||
checking its start time, as the idle stop does (`launcher.mjs:526`). It also signals any run of I in the
|
||||
launcher's registry that has spawned but not yet bound, which holds no claim yet. The existing exit
|
||||
handler records the end, so the launch journal and `session.ended` are unchanged.
|
||||
4. Nothing held, nothing open and nothing in the registry answers `not-held`.
|
||||
5. Terminate is allowed while row 191's switch is on. It starts nothing.
|
||||
|
||||
A taken launch request is past cancelling. Its run reaches the registry under `serialize`, and the next
|
||||
terminate ends it. The CLI's terminate output says so when a taken request exists.
|
||||
|
||||
There is no SIGKILL escalation in this row. A run that ignores SIGTERM keeps running. `mosaic stop` and the
|
||||
limits are the operator's tools for it (N6).
|
||||
|
||||
### V4 regenerate
|
||||
|
||||
A fresh session with the same identity and role. It is V3 then V2 under one request. The pass takes the
|
||||
request, waits for the old run's exit handler, then runs `check()` and `start()`. The workspace stays, as
|
||||
it does across every launch today. The session is new because every non-thread launch gets a new run dir
|
||||
(`seat/src/session.mjs:107`, newRun). If the old run hasn't exited by the request's 60 s mark, the request
|
||||
closes as `regenerate-timeout`, nothing starts, and the old run is still running (N6).
|
||||
|
||||
A thread instance (the assistant) refuses `regenerate-thread`. Its context lives in the thread's session
|
||||
directory, and clearing that is T1 item 7 (clear context). This row doesn't take it.
|
||||
|
||||
### V5 delete
|
||||
|
||||
`instances.set` gains op `delete`. At the verb the broker and the writer refuse, in this order:
|
||||
|
||||
| Refusal | When |
|
||||
|---|---|
|
||||
| `tester-refused` | the caller is the tester (B6) |
|
||||
| `organization-definition-refused` | I is END_PROTECTED |
|
||||
| `organization-instance-referenced` | I is `launch.by` or an arbiter. The message names the reference |
|
||||
| `organization-instance-kept` | I is in `launch.keep`. The human runs `veto unkeep` first. Delete never clears a veto silently |
|
||||
| `organization-credentials-out-of-scope` | I holds credentials or a tracker bot. That stays a hand edit until the credentials row (row 199) |
|
||||
| `instance-running` | the broker sees a claim for I. The human terminates first |
|
||||
| `instance-starting` | an open or taken request for I has no done event |
|
||||
| BusinessError | I is the last name in `launch.instances` (E1). The message says so |
|
||||
|
||||
The checks pass with no write, and the broker records a `lifecycle.delete` request. After the take, the
|
||||
launcher pass, under `serialize` so no `start()` of I runs alongside it:
|
||||
|
||||
1. Re-checks the registry for I (`instance-running`) and re-runs the writer's checks against the file as it
|
||||
stands. The `--revision` the human gave must still match, else `revision-conflict`.
|
||||
2. Commits the file through the organization writer. It removes `roles.I` and the references that exist
|
||||
only for I: its `launch.instances` entry, its `launch.limits.tokens.instances` entry and its
|
||||
per-instance model entry. It adds I to a new top-level list, `retired`. The build lists every place
|
||||
`validateBusinessDocument` reads a role instance name and puts each one in one of the two groups (refuse
|
||||
or remove). A reference the table doesn't name stops the build and goes back to Webby.
|
||||
3. Renames `workspaces/<b>/I/` to `workspaces/<b>/.retired/I-<transaction>/`, mode 0700. No workspace is
|
||||
no rename.
|
||||
4. Calls `lifecycleDone` with the transaction.
|
||||
|
||||
The file goes first. Once it is committed, `check()` refuses I as unknown, so no launch can open the old
|
||||
workspace even if step 3 fails. A failed rename closes the request with `workspace-rename-failed`, the
|
||||
transaction and the path left behind. A second `delete` of a retired name skips steps 1 and 2 and retries
|
||||
step 3 only. The file and evidence are already right, so this is the whole recovery.
|
||||
|
||||
What stays:
|
||||
|
||||
- Run evidence (`launches/<b>/<run>/`, `launches/<b>.jsonl`) and bus rows are untouched. They are
|
||||
write-once.
|
||||
- The workspace, under `.retired`. This row deletes no file. A prune with a receipt is a later act.
|
||||
- `retired` stops a later create or hire from reusing the name. Decisions bind to the role instance and
|
||||
outlive restarts (`broker.mjs:783-788`). A new `coder` would otherwise inherit an approval raised by the
|
||||
old one. The tradeoff is a name that can't come back without a hand edit. I chose that over voiding
|
||||
decisions, which would touch a second store.
|
||||
|
||||
### V6 veto
|
||||
|
||||
`veto.set` args: `{ revision, op: "keep" | "unkeep" | "nohire" | "hire", name }`. It goes through the
|
||||
organization writer, so it is logged with actor and diff in `changes.jsonl`. `keep` needs a declared,
|
||||
non-END_PROTECTED instance. `nohire` needs a name that matches the id pattern. A no-op answers
|
||||
`organization-no-change`. It is human-only and isn't in `TESTER_ORGANIZATION_VERBS`.
|
||||
|
||||
## D2. Chief hire and fire
|
||||
|
||||
### D2.1 Authority (Mos, mos-1015 and mos-1017, through Sage, sage-1362 and sage-1365)
|
||||
|
||||
This row adds `role.hire` and `role.fire` to `ACTIONS` (`vocabulary.mjs:11`). Under PRD REQ-WAIT-3 a new
|
||||
action is a hard wait until a PRD version says it isn't (`vocabulary.mjs:52-57`), so both ship as hard
|
||||
waits. Jason resolves each one. There is no PRD change and no grant in a role file: `role.mjs:42` refuses
|
||||
a withinRole that lists a hard wait, and REQ-WAIT-2 forbids it.
|
||||
|
||||
The flow in this row:
|
||||
|
||||
1. The chief raises a `role.hire` or `role.fire` decision whose body carries the arguments (business,
|
||||
name, definition, vars, or name and reason). A hard wait goes to the human only. No arbiter resolves it.
|
||||
2. Jason resolves it in the Console or the CLI.
|
||||
3. The chief calls `instance.hire` or `instance.fire` citing the decision. The broker checks it with
|
||||
`#consumeAuthority(s, 'role.hire', { decision, target })`, as the `role.revoke` decision branch does,
|
||||
and the arguments must equal the decision body field by field (N1, webby-0010). For hire that is
|
||||
`name`, `definition` and `vars` (vars compared with `canonicalJson`, absent equal to absent only). For
|
||||
fire it is `name` and `reason`. A key in the call that the body lacks, or a body key the call lacks,
|
||||
refuses `decision-mismatch`. Every check below runs again at this call, so a veto set after the resolve
|
||||
still refuses.
|
||||
|
||||
The routed form, where the chief hires and fires without a decision each time, needs a PRD version that
|
||||
moves both actions to `ROUTED`. My draft is `agents/honker/work/queue-194/PRD-1.7-DRAFT.md`. It quotes
|
||||
TARGET.md line 66 and carries Mos's bounds. Jason approves it as a `prd.approve` decision in the Console,
|
||||
and Sage opens that decision. When it lands, a later commit adds the two actions to both `ROUTED` lists
|
||||
(`vocabulary.mjs`, `broker.mjs:43-69`) and to `roles/chief-of-staff.json` withinRole. The broker then
|
||||
takes the `reason` form instead of a decision. The verbs and every check below stay the same. This row
|
||||
builds and tests the hard-wait form only. It writes the `hireable` key and its allowlist now (D2.3), because
|
||||
both forms use them.
|
||||
|
||||
### D2.2 What the chief may do
|
||||
|
||||
| Act | Bus verb | Rule |
|
||||
|---|---|---|
|
||||
| launch | `launch` (host) | as today: listed instances only |
|
||||
| end a run | `role.revoke` + reason | as today, and refused `instance-kept` when the target is kept (D2.4) |
|
||||
| hire | `instance.hire { name, definition, vars?, decision }` | adds and lists an instance. Refusals below |
|
||||
| fire | `instance.fire { name, reason, decision }` | ends I's runs, cancels I's open requests, and removes I from `launch.instances`. Refusals below |
|
||||
|
||||
Only the business's chief, the instance named by `launch.by`, may call either verb. Any other caller
|
||||
refuses `role-not-chief`, even with a resolved decision, as `#classification` already limits
|
||||
`role.launch`. The broker reads `hireable` from that chief's own role file. Both act only in the chief's
|
||||
own business. Neither verb takes a business argument. The broker uses the
|
||||
cap's business, and a decision raised in another business refuses `decision-mismatch`. Row 191's switch
|
||||
refuses both while halted (D6). Every hire and fire writes `changes.jsonl` through the organization writer
|
||||
with actor the chief's instance and run, and the decision id.
|
||||
|
||||
Hire refuses, in order:
|
||||
|
||||
1. `definition-not-hireable` when the definition holds an action outside `HIRE_ALLOWED` (D2.3), whatever
|
||||
any list says.
|
||||
2. `definition-not-hireable` when the definition isn't in the chief role file's `hireable` list, or is in
|
||||
the business's `launch.noHire`.
|
||||
3. `organization-definition-refused` for END_PROTECTED, `organization-credentials-out-of-scope` for a
|
||||
credentialed definition (until the credentials row, row 199), `invalid-request` for version 1.
|
||||
4. `organization-instance-retired` when the name is retired.
|
||||
5. When the name exists: a fired instance (in `roles`, not listed) is listed again only if `definition`
|
||||
equals its current one and `vars` is absent. Any other definition or vars refuses
|
||||
`organization-instance-exists`, and so does a listed name. A re-hire never moves an instance's
|
||||
decisions to a different role (B3).
|
||||
|
||||
Hire's `vars` may hold only the keys the chief can already set on an instance through `organization.set`
|
||||
(the model key, `broker.mjs:1266-1269`) and narrowing `limits.*` keys. Anything else, such as `harness`
|
||||
or a route, refuses `invalid-request`. The human sets those (N3).
|
||||
|
||||
Fire refuses `instance-kept` for a kept instance, `organization-definition-refused` for END_PROTECTED,
|
||||
`organization-instance-referenced` for `launch.by`, and a BusinessError for the last listed instance. A
|
||||
fired instance stays in `roles`. The chief never deletes. Delete stays a human act because it retires the
|
||||
name.
|
||||
|
||||
Fire ends the fired instance's processes, not only its claims (F4, Megavolt through sage-1374). Every run
|
||||
gets the shared model token at start: `ENV_ALLOW` (`packages/seat/src/session.mjs:83-94`) passes
|
||||
`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN` and the other provider variables into the sandbox. A revoke
|
||||
can't take that token back, so a fired agent whose process lives on still holds it.
|
||||
|
||||
The delist is a business file commit through the organization writer, and the revoke and events are store
|
||||
rows. No transaction covers both (webby-0011 B2), so fire runs in a fixed order, store side first:
|
||||
|
||||
A. One store transaction: steps 1 to 3 below, plus `fire.stopped` with the decision id. The decision isn't
|
||||
consumed yet.
|
||||
B. The delist through the organization writer (`launch.instances` loses I, logged in `changes.jsonl`).
|
||||
C. One store write consuming the decision and recording `fire.done`.
|
||||
|
||||
If B or C fails, fire answers the step's code and the decision stays unconsumed. A second fire citing the
|
||||
same decision runs again. A finds nothing to revoke, cancel or stop, and that isn't a refusal for fire
|
||||
(unlike V3's `not-held`). B finds I already delisted and counts as done. C consumes. So a fire is safe to
|
||||
repeat until it's consumed. The other order would leave a delisted agent with a live process and its model
|
||||
token when the store step fails, which is the case F4 exists to stop. A halt between A and B doesn't stop
|
||||
B or C, because neither starts anything. A second fire while halted refuses (D6), and the halt's own stop
|
||||
covers the runs.
|
||||
|
||||
Steps 1 to 3 are V3's, with one change:
|
||||
|
||||
1. It revokes every claim the instance holds.
|
||||
2. It writes `lifecycle.cancelled` for each of the instance's open requests.
|
||||
3. It writes `lifecycle.end` with the runs and `kill: true`. The launcher pass stops each runner, spawned
|
||||
but unbound registry runs included, with escalation (sage-1380 item 1):
|
||||
- SIGTERM after the identity check.
|
||||
- After the grace, SIGKILL to any runner still alive, after the same identity check again, and
|
||||
`lifecycle.end.killed` with the run and pid.
|
||||
- A runner still alive after SIGKILL and the observe wait gets `lifecycle.end.alive`. The Console's
|
||||
Agents section shows it to the human.
|
||||
A runner whose identity no longer matches is never signalled. Every check, the recheck after the
|
||||
grace included, uses the identity (start ticks plus boot id), never a bare pid, so a reused pid never
|
||||
counts as the fired run (webby-0011 N2).
|
||||
|
||||
Fire's answer lists the runs it is stopping. The delist stops any new chief launch (`launch` takes listed
|
||||
instances only). A human launch request already taken is past cancelling, as in V3. The human made it, the
|
||||
fire answer names it, and the human's terminate ends its run.
|
||||
|
||||
The stop is row 191's per-run stop, not a second one, so the two can't fight (sage-1380 item 1). Row 191's
|
||||
design r2 (`agents/rocko/work/queue-191/DESIGN-r2.md`, "Stop policy and completion") sends SIGTERM, waits
|
||||
5 s, sends SIGKILL to verified groups and namespaces, and observes for 2 s. Its identity check is start
|
||||
ticks plus boot id, which replaces the start-time check at `launcher.mjs:526`. Fire calls that routine for
|
||||
one instance's runs and uses its constants, so the grace is row 191's 5 s, not the 10 s in r3. The order:
|
||||
|
||||
- Fire is refused while halted (D6), so a halt sweep never meets a fire that started after it.
|
||||
- A halt that comes during a fire's stop finds those runs already stopping. Each run has one stop receipt
|
||||
(row 191's), and the first stop owns it. The halt sweep joins it instead of sending a second SIGTERM or
|
||||
restarting the deadline, and counts the run in its remaining or quiescent totals.
|
||||
- The record says which stop owned it: reason `fire` with the decision, or row 191's `kill-switch` with
|
||||
its generation.
|
||||
|
||||
V3 terminate is unchanged. It still sends SIGTERM only (N6). Only fire escalates, because only a fired
|
||||
agent must lose the token at once. If row 191 hasn't landed when this row builds, the build chains on its
|
||||
candidate (Q177), as D6 already says.
|
||||
|
||||
### D2.3 The hire allowlist (B2, sage-1365 item 4, webby-0010 B1)
|
||||
|
||||
r2 and r3 had a deny list, `HIRE_FLOOR`. Webby's B1 showed it missed routed actions (`git.merge.protected`,
|
||||
which `roles/merge-gate.json` holds with no credentials, `decision.resolve.technical`, `policy.change`,
|
||||
`prd.approve`), and that any action added later would be hireable by default. That isn't fail-closed. r4
|
||||
replaces it with an allowlist.
|
||||
|
||||
A definition is hireable only if every action in its withinRole and crossRole is on `HIRE_ALLOWED`:
|
||||
|
||||
| Allowed action | Held today by |
|
||||
|---|---|
|
||||
| `message.send`, `record.write` | every v2 role file |
|
||||
| `review.request`, `task.update.assigned` | coder, cto, pm, reviewer |
|
||||
| `task.scope.change`, `git.push.working` | coder, cto, pm (credentialed, so they matter only after row 199) |
|
||||
|
||||
Everything else in `ACTIONS` fails, including the actions of the old floor (`role.launch`, `role.end`,
|
||||
`role.revoke`, `organization.change`, `instance.name`, `review.verdict`, `role.hire`, `role.fire`) and those
|
||||
B1 named. An action added to `ACTIONS` later fails until a reviewed commit adds it here.
|
||||
`git.push.remote` (row 199, routed to coder by PRD 1.7) isn't on the list. A coder holding it stays
|
||||
unhireable until a reviewed commit adds it, after the credentials bound is lifted.
|
||||
|
||||
Against today's role files: chief-of-staff, cto, merge-gate, pm, reviewer and security-reviewer fail.
|
||||
Assistant, researcher, coder and the five C-suite files pass the allowlist. Assistant is END_PROTECTED and
|
||||
coder is credentialed, so D2.2 item 3 refuses them. The C-suite files hold only `message.send` and
|
||||
`record.write`, so hiring one gives no authority beyond its name. `hireable` still has to list it.
|
||||
|
||||
The list is a constant in `business/src/vocabulary.mjs`, `HIRE_ALLOWED`. It is checked twice:
|
||||
|
||||
- At load. `role.mjs` validates the chief role file's `hireable` list: distinct definition names, each
|
||||
present in `rolesDir`, version 2, and every action on `HIRE_ALLOWED`. A list naming one that isn't refuses
|
||||
the role file (`hireable names <def>, which holds <action>`). The check reads only the named file's authority lists,
|
||||
never its `hireable`, so a list can't recurse. `hireable` is an optional key on any role file. This is a
|
||||
role schema change, named for review.
|
||||
- At hire, in the broker, against the definition as loaded then. A role file edited after load is caught
|
||||
here.
|
||||
|
||||
`hireable` in `roles/chief-of-staff.json` stays empty in this row (`[]`, Q4, sage-1374). Naming the first
|
||||
role kind is a separate policy commit in `roles/`, made after Jason resolves the `prd.approve` decision, as
|
||||
AGENTS.md invariant 7 requires. With an empty list the chief can hire nothing, even with Jason's resolve,
|
||||
until that commit. The row's tests use scratch role files whose `hireable` names a kind.
|
||||
|
||||
### D2.4 The per-agent forbid and the veto (sage-1365 item 3)
|
||||
|
||||
TARGET.md line 66: "Chief of staff agent has hire/fire authority for agents as needed; unless user forbids
|
||||
per agent". The forbid is two lists in the business file that only the human writes:
|
||||
|
||||
- `launch.keep`: instance names. The chief can't fire, replace or end a kept instance. The broker refuses
|
||||
`instance-kept` for every chief act aimed at it except launch:
|
||||
- `instance.fire`, even with a resolved `role.fire` decision. Mos's limit is that Jason's veto applies
|
||||
to every hire and fire, so the human unkeeps before a fire.
|
||||
- a hire of that name.
|
||||
- `role.revoke` in the reason branch (`broker.mjs:1607-1613`). End then launch is a replace (E6).
|
||||
- an `organization.set` write of that instance's model key (B7). A model change is a replace at the next
|
||||
launch.
|
||||
|
||||
The `role.revoke` decision branch (`broker.mjs:1614-1615`) still ends a kept run. It ends a run, it
|
||||
doesn't fire or replace, and it cites a decision the human resolved (Webby's Q2 ruling). The human's V3
|
||||
and the limits also still end a kept run.
|
||||
- `launch.noHire`: definition names the chief can't hire into this business (D2.2 item 2).
|
||||
|
||||
Why the chief can't change the forbid:
|
||||
|
||||
- Only `veto.set` writes the lists. It is human-only (`#human(s)` then the D4 hook) and refuses the tester.
|
||||
- `organization.set`, the chief's one write verb, reaches only `ORGANIZATION_KEYS` (`broker.mjs:145`),
|
||||
which exclude `launch` and `retired`.
|
||||
- `instances.set` is human or tester only (`broker.mjs:1248-1298`).
|
||||
- Hire and fire carry no field that writes either list.
|
||||
|
||||
T18 tests each of those paths from a chief session and checks the business file bytes are unchanged.
|
||||
|
||||
END_PROTECTED instances can't be kept (N7). That is intended: Q169 lets the chief end the assistant's
|
||||
runs, and the assistant's own stop is `assistant.stop`. Default, per "unless user forbids": nothing is
|
||||
kept and nothing is on `noHire`.
|
||||
|
||||
### D2.5 Where it lives and how the broker enforces it
|
||||
|
||||
The forbid lives in the business file, under `launch`, beside the membership it limits:
|
||||
`launch.keep: [instance]`, `launch.noHire: [definition]`, and top-level `retired: [instance]`. `hireable`
|
||||
lives in the chief's role file, because it bounds the role, not one business.
|
||||
|
||||
`checkLaunch` (`business.mjs:118`) gains the two keys. Each list is distinct. `keep` names declared,
|
||||
non-protected instances. `noHire` entries need only the id pattern (N2). A stale deny entry is harmless,
|
||||
and a reviewed commit that removes a role file must not stop every business that lists it. `retired`
|
||||
names none of `roles`. This is the business-file schema change for review. Old files without the keys load
|
||||
unchanged. An old bus loader rejects a file that has them (`rejectUnknownKeys`), so a rollback past this
|
||||
row needs the keys removed first. The activation plan has to say so (R4).
|
||||
|
||||
The broker enforces from its live copy of the business, which D3 keeps current. The checks sit in the
|
||||
`role.revoke` reason branch, in `organization.set`'s model write and in the two chief verbs, before any
|
||||
write.
|
||||
|
||||
## D3. Live changes
|
||||
|
||||
Today the broker's `#businesses` is fixed at start (E3), and `instances.set` answers `restart: true` for
|
||||
add and redefine.
|
||||
|
||||
1. `Broker.reloadBusiness(id, document)` swaps in one business's validated document. It is synchronous, so
|
||||
no request sees half of it. It refuses an unknown id and a document whose `human` changed (that is row
|
||||
193's ground).
|
||||
2. The runtime's organization callback (`runtime.mjs:121-133`) calls it in one place, after every verb
|
||||
that commits a write, whatever the verb (N1). Today that is `limits.set`, `instances.set`, `routes.set`
|
||||
and `plugins.set`, and after this row `veto.set`, `instance.hire`, `instance.fire` and the launcher's
|
||||
delete through the same writer. It loads from the file through the loader `bootConfig` uses
|
||||
(`cli/src/config.mjs:88-105`), so the broker holds exactly what a restart would give it. Then
|
||||
`limits.refresh(id)` runs, as `plugins.set` does now.
|
||||
3. Other startup consumers of `businesses` in `startBroker` (`runtime.mjs:75-81`): credential references,
|
||||
usage, the web services and the tasks adapter's bots. A create or hire adds no credentials, no tracker
|
||||
bot and no web grant, because credentialed definitions are refused. The build checks each consumer
|
||||
against that claim. Any change that would need one of them still answers `restart: true` and doesn't
|
||||
reload.
|
||||
4. A definition change on a running instance refuses `instance-running`. A live cap must not change
|
||||
authority under its run. This is new: today the change waits for the restart. Existing tests that pin
|
||||
`restart: true` change with it (N4): `business/tests/instance-settings.test.mjs`,
|
||||
`bus/tests/instances-verb.test.mjs`, `cli/tests/instances.test.mjs:76-94` (the `RESTART_NEEDED` text and
|
||||
the restart line) and `webui/tests/instances.test.mjs:25, 71, 74, 140-145`. The build names each
|
||||
changed assertion in its manifest.
|
||||
5. The chief's `launch` tool (E5) takes `instance` as a string with the id pattern instead of the start-time
|
||||
enum. Its description says the host refuses an unlisted instance. `check()` already refuses one
|
||||
(`launcher.mjs:235`). Without this, the chief can't launch its own hire until its session restarts.
|
||||
`tools.mjs` may carry hash pins in tests. The build greps for them (R5).
|
||||
6. Hand edits of the business file still need a restart. Nothing watches the file. The one exception is
|
||||
`bus.tester` (D4.1), read from the file at each tester call.
|
||||
|
||||
After this, `restart` is false for create, delete, hire, fire and veto changes, and true only under item 3.
|
||||
|
||||
## D4. RBAC until row 193
|
||||
|
||||
Every human verb above calls `#human(s)` and then one hook, `#mayHuman(s, verb, target)`. Until row 193
|
||||
lands, the hook passes for the business's one `human`, which is the owner. The verbs are listed once, in
|
||||
an exported `LIFECYCLE_HUMAN_VERBS` (`instance.launch`, `instance.end`, `instance.regenerate`, `veto.set`,
|
||||
and `instances.set` ops `add`, `change`, `delete`), so row 193 maps each verb to the human roles that may
|
||||
use it. Row 193 replaces the hook body and nothing else.
|
||||
|
||||
### D4.1 The tester (Sage, sage-1355)
|
||||
|
||||
What the tester may do after this row:
|
||||
|
||||
| Verb | Tester |
|
||||
|---|---|
|
||||
| `instance.launch`, `instance.end` | yes, new |
|
||||
| `instances.set` ops `add`, `change` | yes, as today (row 175, `broker.mjs:103`, `serve.mjs:537`) |
|
||||
| `instances.set` op `delete` | no, `tester-refused` in the broker (B6) |
|
||||
| `instance.regenerate`, `veto.set` | no, `tester-refused` |
|
||||
|
||||
The tester's cap is bound to one business, and only when that business sets `bus.tester` to true
|
||||
(`broker.mjs:542-546`). That stays Jason's switch, and no bus verb writes it. Two checks run on each tester
|
||||
lifecycle call, before any effect, and again at the take (D1.1):
|
||||
|
||||
1. The request's business is the cap's business. Any other refuses `tester-refused`.
|
||||
2. `bus.tester` is exactly true in the business file as it stands, read through `settings.current(id)` as
|
||||
`harnessOn` does (`runtime.mjs:136-142`), not from the broker's copy (B8). An unreadable file refuses
|
||||
`tester-off`. So a hand edit turning testing off counts at the next tester call, without a restart.
|
||||
|
||||
The request and done events record actor `tester`. The tester's Q173 tests of the Agents section then cover
|
||||
launch and terminate on a scratch business, and the rest through fixtures.
|
||||
|
||||
## D5. One stack instance
|
||||
|
||||
Row 190 carries the stack instance as the config `MOSAIC_CONFIG` names. These verbs take no instance name.
|
||||
The CLI reaches the broker socket under that config's `dataRoot`. The broker acts on its own businesses,
|
||||
launcher and data root. A business id that exists in two stack instances is two separate businesses. A test
|
||||
runs two scratch roots with the same business id and shows a launch, a terminate and a delete in one leave
|
||||
the other byte-identical (T11).
|
||||
|
||||
## D6. The kill switch
|
||||
|
||||
- Launch and regenerate end in `start()`, which row 191 gates (its D3 inventory, `launcher.mjs:329-457`).
|
||||
The new pass adds no second start path.
|
||||
- The broker refuses `instance.launch` and `instance.regenerate` with `processing-halted` while the switch
|
||||
is on (V2 step 1), and the take checks again (D1.1), so no request is waiting when a human resumes.
|
||||
- `instance.hire` and `instance.fire` are agent work verbs, so row 191's default refuses them while halted.
|
||||
The build adds them to row 191's work table, not its read list. A hire Jason resolved before a halt
|
||||
still refuses while halted, and the chief calls it again after the resume.
|
||||
- Terminate, create, delete and veto start nothing and stay allowed.
|
||||
- If row 191 hasn't landed when this row builds, the build chains on its candidate (Q177). The two halted
|
||||
checks are written against its reader and tested with its fixture.
|
||||
|
||||
## D7. The Console
|
||||
|
||||
A new Settings section, "Agents", in its own `agents.js` file. Settings itself isn't rewritten. It lists
|
||||
each instance with definition, listed, kept, retired and running runs, and flags a run with a
|
||||
`lifecycle.end.alive` event as "still running after fire" (D2.2). It has these actions:
|
||||
|
||||
- Create (definition select, from `definitions`), Delete (with a confirm naming what stays: the evidence
|
||||
and the workspace under `.retired`).
|
||||
- Launch, Terminate (with an optional reason), Regenerate (with a confirm).
|
||||
- Keep and Unkeep per instance, and a No-hire toggle per definition.
|
||||
|
||||
The routes are `POST /api/instances` (op `delete` joins `add` and `change`), `POST /api/agent/launch`,
|
||||
`/api/agent/terminate`, `/api/agent/regenerate` and `POST /api/veto`. Each uses `instanceSend`'s boundary:
|
||||
signed-in user, exact Origin, same-origin Fetch Metadata, JSON within 16 KiB, strict keys, one bus call
|
||||
through the human transport, and `outcome-unknown` on an unexpected answer. Launch and regenerate show the
|
||||
request, then poll `GET /api/agent/request?id=` until it closes.
|
||||
|
||||
`agents.js` joins the static file list (`serve.mjs:28`) and `index.html` (N5).
|
||||
|
||||
When `/api/config` says `send` is false, every action button shows as unavailable with the text "Agent
|
||||
actions are unavailable here. Use mosaic business instance from the instance CLI." This is the live state
|
||||
until Q175 phase 2. Every write test uses a fixture server and fixture writers, as rows 165 and 191 do. No
|
||||
route gets a `--send` exception.
|
||||
|
||||
## D8. Tests
|
||||
|
||||
Every test uses scratch roots under `TMPDIR` and the existing fixtures (`business/tests/helpers.mjs`,
|
||||
`cli/tests/helpers.mjs`, `webui/tests/tester-fixture.mjs`). None touches `~/.mosaic-dev`,
|
||||
`~/.config/mosaic-dev`, a live unit or a slot file.
|
||||
|
||||
| Test | Shows | File |
|
||||
|---|---|---|
|
||||
| T1 | V1 create on a running bus: a launch of the new instance binds with no restart, and the answer says `restart: false` | cli `instances.test.mjs` |
|
||||
| T2 | V2: a human launch of an unlisted instance runs. `launch.by` goes through the `pm` branch (N8). A thread instance, a retired one and a revoked business refuse at the verb. A request older than 60 s never launches. A refused `check()` closes the request with its code | cli `lifecycle.test.mjs` (new) |
|
||||
| T2a | Take: a `launch.revoke` between the request and the pass closes it `launch-revoked` with no process. A `lifecycleDone` that throws once leaves the request taken, and the next tick starts nothing. A taken request is never taken twice | same |
|
||||
| T3 | V2 with `--no-launch` and with each limit refusal: no process, request closed with the code | same |
|
||||
| T4 | V3: claims revoked, runner SIGTERMed after the start-time check, one end record. A mismatched start time sends no signal. An open request is cancelled and never starts. A spawned, unbound run in the registry is signalled | same |
|
||||
| T5 | V4: the new run starts only after the old exit, with a new run dir and the same workspace. A run that ignores SIGTERM closes the request `regenerate-timeout` with nothing started. The assistant refuses `regenerate-thread` | same |
|
||||
| T6 | V5: each refusal in the V5 table, in order, including `instance-starting` with an open request. A clean delete leaves the evidence bytes unchanged, commits the file before the rename, adds `retired`, and a create or hire of the same name refuses. A failed rename closes `workspace-rename-failed`, `check()` refuses the name, and a second delete renames only | business `instance-settings.test.mjs` and cli `lifecycle.test.mjs` |
|
||||
| T7 | V6: each op, the no-op refusal, an unknown `noHire` name accepted, and a `changes.jsonl` entry with actor `human` | business |
|
||||
| T8 | Chief hire (hard-wait form): no decision refuses. A decision raised by the chief goes to the human only, no arbiter. After the resolve, the hire adds and lists the instance, logs actor and decision, and the same chief session launches it (D3 item 5). Arguments that differ from the decision body refuse. A non-chief caller with a resolved decision refuses `role-not-chief`. Outside `hireable`, in `noHire`, credentialed, END_PROTECTED, retired, and extra vars each refuse | bus `chief-of-staff.test.mjs` |
|
||||
| T8a | Re-hire (B3): a fired name with the same definition and no vars is listed again. Another definition or vars refuses `organization-instance-exists` | same |
|
||||
| T8b | Hire allowlist (D2.3): one case per action in `ACTIONS` that is neither on `HIRE_ALLOWED` nor a hard wait (`role.mjs` already refuses those), looped from the lists, not typed by hand. A fixture definition, clean when the chief's role file loads and listed in `hireable`, is rewritten to hold that action before the hire. The hire refuses `definition-not-hireable`. A definition holding one allowed and one refused action refuses. One holding only allowed actions hires. A pin test fixes `HIRE_ALLOWED` to the six actions | same |
|
||||
| T8c | Allowlist at load: a role file whose `hireable` names a definition holding each refused action refuses at `loadRole`, one case per action, looped the same way. The mixed case refuses. A clean list loads. A missing, version 1 or duplicate name refuses | business `role.test.mjs` |
|
||||
| T9 | Chief fire: runs end, open requests cancelled, delisted, still in `roles`. Kept (even with a resolved decision), END_PROTECTED, `launch.by` and last-listed refuse | bus `chief-of-staff.test.mjs` |
|
||||
| T9a | Fire ends processes (F4, sage-1380 item 1): with a bound run and a spawned but unbound registry run of I, both fixture runners holding a stand-in model variable, fire answers both runs. After the launcher pass, neither runner pid is alive and I holds no claim. A fixture runner that ignores SIGTERM gets SIGKILL after the grace, is gone, and has `lifecycle.end.killed` with its run and pid. A stand-in whose kill is stubbed to fail gets `lifecycle.end.alive`. A runner whose identity changed before the SIGTERM, or between SIGTERM and SIGKILL, gets no signal. V3 terminate of a SIGTERM-ignoring runner sends no SIGKILL | cli `launcher.test.mjs` |
|
||||
| T9b | Fire and row 191's halt: a halt during a fire's stop sends no second SIGTERM, keeps the fire's deadline, writes one stop receipt with reason `fire` and the decision, and counts the run in its totals. A fire while halted refuses (D6) | cli `launcher.test.mjs`, on row 191's fixture |
|
||||
| T9c | Fire order (webby-0011 B2): the organization writer stubbed to fail after A commits. Fire answers the writer's code, I's claims are revoked and its runs stopped, I is still listed, and the decision is unconsumed. A second fire citing the same decision delists I and consumes it. A third fire refuses `decision-consumed`. With C stubbed to fail, the second fire finds I delisted and only consumes | bus `chief-of-staff.test.mjs` |
|
||||
| T10 | Keep: chief `role.revoke` with a reason, fire, hire of that name and an `organization.set` model write aimed at a kept instance each refuse `instance-kept`. A `role.revoke` citing a human-resolved decision ends the kept run (Q2). The human's V3 works | same |
|
||||
| T11 | Two scratch stack instances with the same business id: V2, V3 and V5 in A leave B's root and config byte-identical. A chief of business A can't hire into business B on the same bus | cli `lifecycle.test.mjs`, bus |
|
||||
| T12 | Agent and reader sessions refuse every human verb before any effect. The tester gets exactly the D4.1 table, including `tester-refused` for `instances.set` op `delete` (B6). The D4 hook is called with the verb | bus `instances-verb.test.mjs` |
|
||||
| T12a | Tester launch and terminate work on the cap's business with `bus.tester` true, record actor `tester`, and refuse `tester-refused` for a second business on the same bus. A file edit setting `bus.tester` to false, with no restart, refuses the next call and the take `tester-off`. An unreadable file refuses `tester-off` | bus `instances-verb.test.mjs` |
|
||||
| T13 | `organization.set` with `launch`, `retired`, `keep` or `noHire` keys refuses | bus |
|
||||
| T14 | Live reload: after each committed verb, `plugins.set` and `routes.set` included, the broker answers from the new document. A definition change on a running instance refuses `instance-running` | bus |
|
||||
| T15 | Kill switch (with row 191's fixture): launch and regenerate refuse `processing-halted` at the verb and at the take, hire and fire refuse, terminate works | bus and cli |
|
||||
| T16 | Console: each route's boundary refusals, a fixture write per action, `outcome-unknown` on a bad shape, unavailable buttons when `send` is false, request polling, and `agents.js` served from the static list | webui `agents.test.mjs` (new) |
|
||||
| T17 | Business file: the new keys validate, each bad entry refuses, and a file without them loads byte for byte as before | business `business.test.mjs` |
|
||||
| T18 | The forbid can't be changed by the chief (D2.4): from a chief session, `veto.set`, `organization.set` with each list key, `instances.set`, and hire and fire with extra fields all refuse, and the business file bytes are unchanged | bus `chief-of-staff.test.mjs` |
|
||||
|
||||
### Mutants
|
||||
|
||||
Each one must fail at least one test. They run against the candidate, and the file is restored and its
|
||||
sha256 checked after each one.
|
||||
|
||||
| # | Change | Killer |
|
||||
|---|---|---|
|
||||
| M1 | `instance.launch` skips `#human` | T12 |
|
||||
| M2 | the pass calls `check()` without `human: true` | T2 |
|
||||
| M3 | the take accepts a request older than 60 s | T2 |
|
||||
| M4 | the take skips the `launch_state` check | T2a |
|
||||
| M5 | terminate signals without the start-time check | T4 |
|
||||
| M6 | regenerate starts before the old exit | T5 |
|
||||
| M7 | delete skips the running check | T6 |
|
||||
| M8 | delete skips `retired` | T6 |
|
||||
| M9 | delete clears `keep` instead of refusing | T6 |
|
||||
| M10 | delete removes the workspace instead of renaming it | T6 |
|
||||
| M11 | the `keep` check is removed from the `role.revoke` reason branch | T10 |
|
||||
| M12 | `noHire` is not read by hire | T8 |
|
||||
| M13 | fire deletes the instance from `roles` | T9 |
|
||||
| M14 | `reloadBusiness` isn't called after `instances.set` | T1, T14 |
|
||||
| M15 | the tool enum is kept | T8 |
|
||||
| M16 | the halted check is dropped from `instance.launch` | T15 |
|
||||
| M17 | the Console route skips the Origin check | T16 |
|
||||
| M18 | `veto.set` joins `TESTER_ORGANIZATION_VERBS` | T12 |
|
||||
| M19 | the tester's business scope check is dropped | T12a |
|
||||
| M20 | the tester's `bus.tester` file read is dropped | T12a |
|
||||
| M21 | hire skips the role file's `hireable` list | T8 |
|
||||
| M22 | the take doesn't write `lifecycle.taken`, so a request can be taken twice | T2a |
|
||||
| M23 | terminate doesn't cancel open requests | T4 |
|
||||
| M24 | delete skips the `instance-starting` check | T6 |
|
||||
| M25 | delete renames before it commits the file | T6 |
|
||||
| M26 | the tester's `instances.set` op `delete` refusal is dropped | T12 |
|
||||
| M27 | the `keep` check is dropped from `organization.set`'s model write | T10 |
|
||||
| M28 | the `keep` check is added to the `role.revoke` decision branch | T10 |
|
||||
| M29 | a re-hire accepts a different definition | T8a |
|
||||
| M30 to M36 | the broker's hire check accepts one action outside `HIRE_ALLOWED`, one mutant per action: `role.launch`, `role.end`, `organization.change`, `instance.name`, `review.verdict`, `git.merge.protected`, `decision.resolve.technical` | T8b |
|
||||
| M37 to M43 | the same seven, accepted by the load check in `role.mjs` only | T8c |
|
||||
| M44 | fire skips `instance-kept` when a decision is cited | T9 |
|
||||
| M45 | `instance.hire` skips the `launch.by` caller check | T8 |
|
||||
| M46 | `veto.set` skips `#human` | T18 |
|
||||
| M47 | the broker's hire check needs some action on `HIRE_ALLOWED` instead of every action | T8b |
|
||||
| M48 | the same in `role.mjs` only | T8c |
|
||||
| M49 | fire revokes the claims but writes no `lifecycle.end`, so no runner is signalled | T9a |
|
||||
| M50 | fire's signal skips spawned but unbound registry runs | T9a |
|
||||
| M51 | fire's stop drops the SIGKILL escalation | T9a |
|
||||
| M52 | SIGKILL is sent without the second identity check | T9a |
|
||||
| M53 | a killed runner gets no `lifecycle.end.killed` | T9a |
|
||||
| M54 | V3 terminate escalates to SIGKILL as fire does | T9a |
|
||||
| M55 | the halt sweep sends its own SIGTERM and restarts the deadline on a run fire is stopping | T9b |
|
||||
| M56 | fire delists before the store step | T9c |
|
||||
| M57 | fire consumes the decision in step A | T9c |
|
||||
| M58 | a second fire with nothing to revoke refuses `not-held` | T9c |
|
||||
|
||||
## Scope and boundaries
|
||||
|
||||
- In the row: the verbs and passes above in `business` (vocabulary, role validation, instance-settings,
|
||||
business validation), `bus` (broker verbs, reload, runtime callback), `cli` (instances command, launcher
|
||||
pass, host ops), `harness` (the launch tool's schema, and the hire and fire tools), `webui` (routes and
|
||||
the Agents section), the tests, the package READMEs and the PRD draft in this packet. `docs/TOOLS.md`
|
||||
text goes to Sage.
|
||||
- Named for review:
|
||||
- Vocabulary: `role.hire` and `role.fire` join `ACTIONS` as hard waits, under REQ-WAIT-3. The commit
|
||||
message names them as hard waits, as REQ-WAIT-3 requires.
|
||||
- Role schema: the optional `hireable` key, validated in `role.mjs` (D2.3).
|
||||
- Role file: `roles/chief-of-staff.json` gains `"hireable": []`. No authority change.
|
||||
- Business file schema: `launch.keep`, `launch.noHire`, `retired` (D2.5).
|
||||
- Not in the row: the routed form (after PRD 1.7), filling `hireable`, credentialed hires and deletes (row
|
||||
199), clear context for the assistant (T1 item 7), human roles (row 193), SIGKILL escalation, and any
|
||||
live activation or Console start.
|
||||
|
||||
## Risks
|
||||
|
||||
- R1. Case 1's working roles (coder, reviewer, pm, cto) all declare credentials (E11), and pm, cto and
|
||||
reviewer fail the hire allowlist anyway. Neither the human verb nor the chief can create or delete credentialed
|
||||
roles until the credentials row (row 199). That is the largest gap against Case 1, and it isn't this
|
||||
row's to close.
|
||||
- R2. The human proof is path-bound and same-uid (P19 333). Any same-uid process that runs the tree's
|
||||
`human-cli.mjs` can launch and delete. Row 190b's separate uid and row 193 narrow it. This row adds no
|
||||
check of its own.
|
||||
- R3. The 60 s request window means a slow `check()` (checkouts) can still start a run up to that point
|
||||
after the CLI gave up. The CLI says so on timeout.
|
||||
- R4. Rollback past this row needs `keep`, `noHire` and `retired` removed from each business file, and
|
||||
`hireable` from the role file, or the old loaders refuse them. The activation plan states this.
|
||||
- R5. `tools.mjs`, `serve.mjs`, `broker.mjs`, `vocabulary.mjs` and `role.mjs` may carry sha256 pins in
|
||||
other packages' tests. The build greps the tests for every changed path plus a 64-hex value before
|
||||
in-review, without a `head`.
|
||||
- R6. With `hireable` empty, the chief can hire nothing until the policy commit in `roles/` that follows
|
||||
the `prd.approve` resolve names a kind (Q4). Case 1's "as needed" waits on that commit and on PRD 1.7.
|
||||
- R7. Fire's SIGKILL closes the SIGTERM-ignoring case (sage-1380). A runner still alive after SIGKILL
|
||||
(an unkillable process, or a stop whose identity check fails) keeps the shared model token until the
|
||||
human acts on `lifecycle.end.alive`. Only a per-agent model token (row 199 Q-M4, a later proxy row)
|
||||
would let a fire revoke the token itself.
|
||||
|
||||
## Questions and rulings
|
||||
|
||||
- Q1, ruled by Mos (mos-1015, mos-1017, through sage-1365): `role.hire` and `role.fire` ship as hard
|
||||
waits Jason resolves. The routed form needs PRD 1.7, drafted in `PRD-1.7-DRAFT.md`. D2.1.
|
||||
- Q2, ruled by Webby (webby-0003): keep blocks the chief's `role.revoke` reason branch, not its decision
|
||||
branch. D2.4.
|
||||
- Q3, ruled by Sage (sage-1355): launch and terminate are tester verbs scoped to the tester's own
|
||||
business, behind `bus.tester`. D4.1.
|
||||
- Q4, ruled by Sage (sage-1374): `hireable` stays empty in this row. Naming the first role kind is a
|
||||
separate policy commit in `roles/` after Jason resolves the `prd.approve` decision. Tests use scratch role
|
||||
files that name a kind. D2.3.
|
||||
|
||||
## r2 changes against webby-0003
|
||||
|
||||
| Finding | Where |
|
||||
|---|---|
|
||||
| B1 | D2.1: hard waits under REQ-WAIT-3, no role grant. The routed form is PRD 1.7 (packet draft) |
|
||||
| B2 | D2.3: the floor from sage-1365 item 4, checked at load and at hire, T8b, T8c, M30 to M43 (an allowlist since r4) |
|
||||
| B3 | D2.2 hire item 5: a re-hire keeps the definition, T8a, M29 |
|
||||
| B4 | D1.1: one `lifecycleTake` transaction re-checks and writes `lifecycle.taken`, T2a, M4, M22 |
|
||||
| B5 | V3 cancels open requests and signals unbound runs. V5 refuses `instance-starting`, runs under `serialize`, commits the file before the rename, and states the recovery. T4, T6, M23 to M25 |
|
||||
| B6 | V5 table and D4.1: the tester's delete refuses, T12, M26 |
|
||||
| B7 | D2.4: keep covers `organization.set`'s model write, T10, M27 |
|
||||
| B8 | D4.1: `bus.tester` is read from the file at each call and at the take, T12a, M20 |
|
||||
| Q2 | D2.4, T10, M28 |
|
||||
| N1 | D3 item 2: one reload after every committed write |
|
||||
| N2 | D2.5: `noHire` takes any id-pattern name |
|
||||
| N3 | D2.2: hire's vars limited to the model key and narrowing `limits.*` |
|
||||
| N4 | D3 item 4 names the tests that change |
|
||||
| N5 | D7 and T16: `agents.js` joins `serve.mjs:28` and `index.html` |
|
||||
| N6 | V3 and V4: no SIGKILL, `regenerate-timeout` |
|
||||
| N7 | D2.4: intended, Q169 |
|
||||
| N8 | V2 item 2 and T2 |
|
||||
|
||||
## r3 changes against sage-1374, sage-1377 and Megavolt's F4
|
||||
|
||||
| Item | Where |
|
||||
|---|---|
|
||||
| Q4 ruled: `hireable` stays empty, the first kind is a later policy commit in `roles/` | D2.3, R6, Questions |
|
||||
| `instance.name` joins the floor (Webby B2, mos-1023 item 6) | D2.3 table, T8b, T8c, M47, M48 |
|
||||
| F4: fire ends the fired agent's processes, since `ENV_ALLOW` gives every run the shared model token | D2.2, D7, T9a, M49 to M51, R7 |
|
||||
| `git.push.remote` routed to coder only (mos-1028 item 3, row 199 r3) | `PRD-1.7-DRAFT.md`, REQ-LAND-2 and the REQ-WAIT-3 append |
|
||||
|
||||
## r4 changes against webby-0010, sage-1380 and megavolt-0008
|
||||
|
||||
| Item | Where |
|
||||
|---|---|
|
||||
| webby-0010 B1: the deny-list floor becomes the allowlist `HIRE_ALLOWED`, so merge-gate, `decision.resolve.technical`, `policy.change`, `prd.approve` and any later action are refused | D2.3, Summary, R1, T8b, T8c, M30 to M43, M47, M48, PRD REQ-COS-3 bound |
|
||||
| webby-0010 N1: field-by-field compare of the call against the decision body, extra or missing keys refuse | D2.1 item 3 |
|
||||
| webby-0010 N2: the model write is in REQ-COS-3's text | `PRD-1.7-DRAFT.md` |
|
||||
| sage-1380 item 1: fire escalates to SIGKILL through row 191's per-run stop, and the order against a halt is fixed | D2.2, T9a, T9b, M51 to M55, R7 |
|
||||
| sage-1380 item 2 and megavolt-0008: REQ-LAND-2 binds one commit oid, and Megavolt's three text changes against row 199 r4 (e1aa6216c) | `PRD-1.7-DRAFT.md` |
|
||||
| webby-0011 B1: same as webby-0010 B1, M47 and M48 now allowlist mutants | D2.3 |
|
||||
| webby-0011 B2: fire runs store side first, then the delist, then the consume, safe to repeat | D2.2 order A to C, T9c, M56 to M58 |
|
||||
| webby-0011 N1: the remote-owner host check at each push | sent to Megavolt for row 199 |
|
||||
| webby-0011 N2: identity, not bare pid, at every check | D2.2 step 3 |
|
||||
| Q4: Webby's view matches Sage's ruling (empty) | no change |
|
||||
@@ -1,158 +0,0 @@
|
||||
# PRD 1.7 draft: the chief of staff's hire and fire authority, the coder's push to its fork, and agent
|
||||
# host access through the credential store
|
||||
|
||||
Draft by Honker for row 194 (#1692), under Mos's ruling mos-1017 (through Sage, sage-1365). Not approved.
|
||||
It changes nothing until Jason approves it as a `prd.approve` decision in the Console. Sage opens that
|
||||
decision and sends Mos its id. Base: `docs/prd/mosaic-stack.md` version 1.6 at 53bb59462.
|
||||
|
||||
## What it changes
|
||||
|
||||
Row 194 adds `role.hire` and `role.fire` to the vocabulary, and row 199 adds `git.push.remote`. Under
|
||||
REQ-WAIT-3 all three are hard waits, so today Jason resolves every hire, every fire and every push to a
|
||||
remote. This version routes hire and fire for the chief of staff within fixed bounds, so the chief hires
|
||||
and fires as needed and the user's per-agent forbid still holds. It routes `git.push.remote` for the coder
|
||||
only (Mos, mos-1028 item 3). It adds agent host access with sudo through the credential store for
|
||||
agents with a grant (Q183, Q184, Vikunja #424, due 2026-10-27). One `prd.approve` decision covers it all
|
||||
(Vikunja #420).
|
||||
|
||||
When this version lands, a reviewed commit:
|
||||
|
||||
1. adds `role.hire`, `role.fire` and `git.push.remote` to `ROUTED` in
|
||||
`packages/business/src/vocabulary.mjs` and in `packages/bus/src/broker.mjs`.
|
||||
2. adds `role.hire` and `role.fire` to `roles/chief-of-staff.json` withinRole, and `git.push.remote` to
|
||||
`roles/coder.json` withinRole. No other role file gets `git.push.remote`.
|
||||
3. switches the broker's check on `instance.hire` and `instance.fire` from a cited decision to the
|
||||
`reason` form, as `role.end` works for REQ-ROLE-2, and makes `publish`'s `decision` optional for a
|
||||
within-role class (row 199 D9 step 1).
|
||||
4. states, for a `git.push.remote` with no decision, where the source commit comes from: the latest work
|
||||
commit the bus recorded for the coder's task. The check still matches it against the work ref tip and
|
||||
the push sends it by oid (row 199, duckworth-0022 N9).
|
||||
5. adds the host credential grant check and the host access it allows (REQ-CRED-3, Vikunja #424).
|
||||
|
||||
Every other check stays as rows 194 and 199 build it. Filling `hireable` stays a separate reviewed role
|
||||
file commit in `roles/`.
|
||||
|
||||
## Text for the PRD
|
||||
|
||||
Status line, appended to the current paragraph:
|
||||
|
||||
> Version 1.7 routes the chief of staff's hire and fire actions and the coder's push to its own fork,
|
||||
> and adds agent host access through the credential store, approved by Jason in decision
|
||||
> <prd.approve decision id>.
|
||||
|
||||
Source, a new bullet above "Version 1.6 source":
|
||||
|
||||
> - Version 1.7 source, TARGET.md line 66: "Chief of staff agent has hire/fire authority for agents as
|
||||
> needed; unless user forbids per agent". And Jason through Mos (mos-1067), DECISIONS Q183, in part: "I will
|
||||
> still want to allow infra agent(s) on my .53 instance to access other servers, etc. However, not all
|
||||
> agents will need to have that access. I envision a credential store within Mosaic Stack that works
|
||||
> as some sort of variable for the agent to call. The system will enforce access to the credentials to
|
||||
> keep the agents sandboxed with proper security. I need to be able to trust my agents with assisting
|
||||
> me. This will require sudo access to several systems. If we can utilize the credential store in
|
||||
> Mosaic Stack and allow agents to perform their jobs with NOPASSWD sudo on various machines, I am a
|
||||
> happy man." DECISIONS Q184, on whether that is in place by 10-27: "Q5: by 10.27"
|
||||
|
||||
Under "Added in version 1.2", after REQ-COS-2:
|
||||
|
||||
> Added in version 1.7:
|
||||
>
|
||||
> - **REQ-COS-3.** The chief of staff hires and fires role instances without a decision each time. Hiring
|
||||
> adds a role instance to the organization file and lists it for launch. Firing ends the instance's
|
||||
> runs and removes it from the launch list. It stays in the organization file. Only the user deletes
|
||||
> an instance. These actions are `role.hire` and `role.fire`. They leave the hard waits and route
|
||||
> under this requirement, within these bounds:
|
||||
> - The chief acts only in its own organization.
|
||||
> - The chief hires only role definitions that need no credentials, until a later version adds
|
||||
> credentialed roles.
|
||||
> - The chief hires only the role kinds its role file lists. Changing that list is a reviewed commit.
|
||||
> - The chief hires only a role whose every action is on a fixed list of plain work actions: sending
|
||||
> messages, writing records, asking for review, updating its assigned tasks, and the scope and
|
||||
> working-push actions of an assigned task. A role holding any other action, such as launch, end,
|
||||
> organization, naming, hire, review verdict or merge authority, or any action added later, is never
|
||||
> hired. So the chief never hires a chief, never picks its own reviewers or merge gate, and the author
|
||||
> is never the reviewer. This holds whatever the role file's list says. Adding to that list is a PRD
|
||||
> change.
|
||||
> - The chief may set a hired instance's model and narrow its limits, and nothing else about it.
|
||||
> - The user can forbid per agent. The user marks an instance so the chief can't fire it, end its runs
|
||||
> or change its model, and marks role kinds the chief can't hire. Only the user changes these marks, through the
|
||||
> WebUI or the CLI.
|
||||
> - The kill switch stops every hire and fire while it is on.
|
||||
> - Every hire and fire is logged with the chief's instance, its run and the reason.
|
||||
|
||||
Under "Landing", after REQ-LAND-1:
|
||||
|
||||
> Added in version 1.7:
|
||||
>
|
||||
> - **REQ-LAND-2.** A coder pushes its own work to its own fork. The action is `git.push.remote`.
|
||||
> - What it pushes: one commit, by its oid, never a ref name. That commit is the latest work commit the
|
||||
> bus recorded for the coder's task, and the coder made that push. A decision that authorizes a push
|
||||
> names that commit, and it doesn't authorize any other.
|
||||
> - Where it lands: only the branch `refs/heads/<its Gitea user>/<branch>`. The host builds that
|
||||
> name, the agent never does. A push never forces.
|
||||
> - Which remote: the agent's own fork of the project's upstream. The business file names the upstream,
|
||||
> and the host never contacts it. For each push the host builds the fork's address over https from
|
||||
> the upstream's host, port and repository name and the agent's own Gitea user. The history check,
|
||||
> the push and the credential match all use that address. So the agent's own user owns the receiving
|
||||
> repository on every push, and the agent never names it.
|
||||
> - The host refuses an upstream on a host or port other than the agent's Gitea, and an upstream the
|
||||
> agent's own user owns. It refuses a fork that is missing, empty or doesn't share the project's
|
||||
> history. Gitea's push-to-create stays off, so a push never creates a repository.
|
||||
> - Who may: the action routes under REQ-DEC-2 and is granted within-role to the coder role only. No
|
||||
> other role file grants it.
|
||||
> - Before version 1.7 the action was a hard wait under REQ-WAIT-3, so each push needed a decision
|
||||
> the human resolved, and that decision named the commit.
|
||||
>
|
||||
> Every push is logged with the remote, the ref, the commit and, when one was given, the decision.
|
||||
|
||||
Under "Credentials", after REQ-CRED-2:
|
||||
|
||||
> Added in version 1.7:
|
||||
>
|
||||
> - **REQ-CRED-3.** An agent with a grant can run commands with sudo on another host. The agent asks
|
||||
> for the host credential by name. The stack checks that a grant exists for that agent and that host
|
||||
> before the credential is used. The agent never sees the key or any other secret value (REQ-CRED-1,
|
||||
> REQ-VAR-2). An agent without a grant for that host is refused. Not every agent gets a grant. Who
|
||||
> grants a host credential, and how, is set by the #424 design, which Jason approves.
|
||||
|
||||
REQ-WAIT-3, appended:
|
||||
|
||||
> `role.hire` and `role.fire` were added as hard waits and route under REQ-COS-3 since version 1.7.
|
||||
> `git.push.remote` was added as a hard wait and routes under REQ-LAND-2 since version 1.7.
|
||||
|
||||
## Notes for the reviewer and for Mos
|
||||
|
||||
- REQ-COS-1 (1.2) already says the chief "hires and terminates role instances". REQ-COS-3 gives that
|
||||
sentence its actions and bounds. It doesn't widen REQ-COS-1.
|
||||
- The forbid now names what r3 called "replace": ending a kept instance's run with a reason and changing
|
||||
its model (webby-0010 N2). A user-resolved decision still ends a kept run (row 194 D2.4).
|
||||
- The fixed list is `HIRE_ALLOWED` in row 194 D2.3, an allowlist since r4 (webby-0010 B1). r3's text
|
||||
named the refused authorities instead, which let merge-gate and any later action through.
|
||||
- The kill switch is row 191's. If row 191 hasn't landed when this version is approved, the bound still
|
||||
holds through row 194's chain on its candidate.
|
||||
- REQ-LAND-2 follows row 199's design r6 (4d3007ec9 on megavolt/row199, approved by Duckworth in
|
||||
duckworth-0027: D9 to D11, D9a, D9c, Q-M1 and Q-M2, and the oid binding from mos-1034 item 5) and
|
||||
Megavolt's wording in megavolt-0005, megavolt-0008 and megavolt-0014, through Sage (sage-1377,
|
||||
sage-1380, sage-1387). The fork bullet answers duckworth B5 and B6 and webby-0011 N1. Commit-email
|
||||
checks, the refusal codes and the URL parse rules (D9c) are host checks with tests in row 199, not
|
||||
bounds, so they stay out of the text (megavolt-0008).
|
||||
- Once the routing lands and the decision is optional for the within-role class, the commit is the task's
|
||||
latest recorded push at check time. It is still checked against the work ref tip and pushed by oid, so
|
||||
the binding holds without a decision (megavolt-0008). Step 4 of "When this version lands" puts that in
|
||||
the routing commit (duckworth-0022 N9, sage-1387).
|
||||
- REQ-LAND-2 and REQ-COS-3 meet at the coder. Coder declares Gitea credentials, so REQ-COS-3's
|
||||
credentials bound keeps the chief from hiring one until a later version lifts it. `git.push.remote` isn't
|
||||
on REQ-COS-3's fixed list either, so a coder holding it stays unhireable until a PRD version adds it
|
||||
(row 194 D2.3).
|
||||
- Adding to the fixed action list is a PRD change (mos-1035 item 3, sage-1395). The role file's
|
||||
`hireable` list stays a reviewed commit in `roles/`.
|
||||
- The credentials bound names no row number, so the text doesn't go stale when row 199 lands. A later
|
||||
version lifts it.
|
||||
- REQ-CRED-3 states only what Jason said in Q183 and Q184 (sage-1422). It names the outcome and the
|
||||
check, not how the stack reaches the host. Proxying the ssh and a signing socket are both designs, and
|
||||
the #424 design picks one (duckworth-0038 B1).
|
||||
- Open point: Q183 doesn't say who grants host access or how, so the text leaves it open and defines no
|
||||
mechanism. The #424 design settles it, Jason approves that design, and Duckworth reviews it because it
|
||||
gives agents sudo on hosts (Q184, Mos's reading, duckworth-0038 B2). The build is row 210 (#1707,
|
||||
owner Gizmoduck), a new row on top of row 199 (megavolt-0019, sage-1430).
|
||||
- This draft follows the PRD's own style: plain words, no semicolons in new sentences, and the status
|
||||
paragraph never edited in place except to append.
|
||||
@@ -1,327 +0,0 @@
|
||||
# PRD 1.8 draft: trusted actions within role guardrails, the manual mode, and grants the chief of staff
|
||||
# requests and the user approves
|
||||
|
||||
Draft by Honker for Vikunja #434, under Mos's assignment mos-1117 (through Sage, sage-1525). Not approved.
|
||||
It changes nothing until Jason approves it as a `prd.approve` decision. Sage opens that decision and sends
|
||||
Mos its id. Base: `docs/prd/mosaic-stack.md` version 1.6 at 008746b6e, plus the version 1.7 text Jason
|
||||
approved (PRD-1.7-DRAFT.md r2, 9ba31b5b3, decision d52febb5, mos-1089). Where 1.7 is not yet written into
|
||||
the PRD file, this draft's anchors name 1.7's sections.
|
||||
|
||||
Revision 2 makes Mos's edits a to f (mos-1124, through Sage, sage-1534) and fixes Duckworth's B1 and B2
|
||||
with notes N1 to N6 (duckworth-0057) on r1 at 3c30f1036. Revision 3 fixes Duckworth's C1 and takes
|
||||
notes N1 and N2 (duckworth-0058) on r2 at 60f5ca8fa, with Gizmoduck's enrollment wording
|
||||
(gizmoduck-0067). Revision 4 makes Mos's rulings on duckworth-0057 (mos-1129, through Sage, sage-1538)
|
||||
on r3 at d16ccdb91: only an Approve creates a grant, one decision for a grant that makes an account, and
|
||||
the arbiter route for `host.sudo`. Revision 5 fixes two stale notes from Duckworth's r4 check
|
||||
(duckworth-0061 N1), with no change to the requirement text. Revision 6 makes Mos's wording for those
|
||||
notes and adds two items for Jason at approval (mos-1138, through Sage, sage-1548), again with no change
|
||||
to the requirement text.
|
||||
|
||||
For Jason at approval: "The main agent or chief of staff" in Q10 is read as one role under two names,
|
||||
the chief of staff. So only the chief raises a grant request, and your assistant passes your own request
|
||||
to the chief. That reading is Mos's (mos-1124). To change it, say so when you approve, and REQ-GRANT-1
|
||||
changes before the text goes into the PRD.
|
||||
|
||||
Also for Jason at approval:
|
||||
|
||||
- Only an Approve creates a grant. A grant written into the business file by hand makes nothing. An agent
|
||||
that runs as the same OS user as the stack can still write the stack's own store. That is the B7
|
||||
residual (Q182 on dragon-lin, #423 on .53 and .54), and this version doesn't close it.
|
||||
- Who decides a root command on another host changes. Once `host.sudo` is routed, a raise for it that
|
||||
cites the spec and isn't within-role goes to the arbiter for its domain, as version 1.7 set for
|
||||
`git.push.remote`, not to you. The grant and its allowlist still limit the command. In manual mode the
|
||||
decision comes to you (REQ-TRUST-2).
|
||||
- Revoke: Q188 says "you, Mos or the tester". "Mos" is read as your assistant (REQ-ROLE-4), limited to
|
||||
grants in its own organization, and the tester may revoke only on a dev instance. That reading is Mos's
|
||||
(mos-1124 d, mos-1132). To change it, say so when you approve, and REQ-GRANT-4 changes before the text
|
||||
goes into the PRD.
|
||||
- The broker enforces only that the chief of staff raised a grant request. The rest of the chain of
|
||||
command is the messaging lists and the role contracts, not a broker check (REQ-GRANT-1).
|
||||
|
||||
## What it changes
|
||||
|
||||
Today an agent acts without a raise only on actions its role file grants within-role, and the role
|
||||
files grant few. Every other use of a tool or command waits on a decision. Row 210 builds `host.sudo` as a
|
||||
hard wait, so Jason would decide each command run on another machine. Grants themselves are hand work:
|
||||
Jason makes every token and key from a runbook (REQ-CRED-1).
|
||||
|
||||
Jason's ruling Q188 turns this around. Roles set what each agent may do, and by default agents do it at
|
||||
will within their sandbox and guardrails. A per-use raise happens only in a manual mode. No agent creates
|
||||
a grant. The chief of staff brings a grant request to the user's inbox, the user clicks Approve or Deny, and
|
||||
protected stack code does every step of making the grant. The user's own tokens are entered once in the
|
||||
WebUI and never reach an agent.
|
||||
|
||||
This version adds:
|
||||
|
||||
1. Trusted actions within role guardrails as the default mode (REQ-TRUST-1).
|
||||
2. Manual mode, a list of actions that each need a decision per use: who sets it, where it lives and what
|
||||
it changes (REQ-TRUST-2).
|
||||
3. Grant requests, raised only by the chief of staff through the chain of command (REQ-GRANT-1).
|
||||
4. Approve and the protected grant code, with three grant methods: machine key, Gitea and GitHub
|
||||
(REQ-GRANT-2).
|
||||
5. The user's tokens, entered in the WebUI and used only by the grant code (REQ-GRANT-3).
|
||||
6. Row 210's REQ-CRED-4, generalized from `host.sudo` to every role-listed action that uses a grant
|
||||
(REQ-CRED-4).
|
||||
7. Instant revoke (REQ-GRANT-4).
|
||||
|
||||
The hard waits stay with the user (Mos, mos-1117): spending money, deleting data, new external accounts,
|
||||
messages outside the fleet and the rest of REQ-WAIT-1. Approving a grant is the user resolving one of
|
||||
those hard waits, `credential.mint`. A grant that makes an account, such as a seat's own Gitea user
|
||||
(Q190), is the same one decision: its method says it makes an account, and the inbox shows the account
|
||||
name before Approve. So no hard wait moves to an agent.
|
||||
|
||||
When this version lands, reviewed commits:
|
||||
|
||||
1. add the manual-mode list to the organization file schema, written only on the human's path
|
||||
(REQ-TRUST-2), and make the broker classify a listed action as needing a decision for each use, before
|
||||
any other routing.
|
||||
2. add the grant request: a decision the chief of staff raises with action `credential.mint`, naming the
|
||||
agent, the method and target, the exact rights and, when the method makes an account, the account
|
||||
name (REQ-GRANT-1). The broker refuses a `credential.mint` raise from any other role. An
|
||||
`account.create` raise isn't a grant request and routes as it does in version 1.7, such as a PM's
|
||||
sign-up for a service.
|
||||
3. add the grant code that runs on a resolved Approve, one method at a time, and the grant record it
|
||||
writes (REQ-GRANT-2, Vikunja #435 and #436).
|
||||
4. add the user's token store, written from the WebUI (or the CLI until the Console knows the signed-in
|
||||
human) and read only by the grant code (REQ-GRANT-3).
|
||||
5. move `host.sudo` into both `ROUTED` lists, as row 199 does for `git.push.remote`, and route it under
|
||||
REQ-CRED-4, with the arbiter route version 1.7 set for `git.push.remote`. Which definitions list it
|
||||
within-role is a separate reviewed role commit in `roles/`.
|
||||
6. update the role contracts that list the hard waits: `roles/chief-of-staff.md:75` raises grant
|
||||
requests with the REQ-GRANT-1 fields, and `roles/pm.md:55` asks the chief of staff for a grant instead
|
||||
of raising `credential.mint` itself (line numbers on origin/refactor). Other roles that list the hard
|
||||
waits get the same line as the PM. A role's messaging list carries the chain of command.
|
||||
|
||||
## Text for the PRD
|
||||
|
||||
Status line, appended to the current paragraph:
|
||||
|
||||
> Version 1.8 records Jason's ruling Q188 of 2026-10-20: trusted actions within role guardrails by
|
||||
> default, a manual mode for per-use raises, and grants the chief of staff requests and the user
|
||||
> approves, approved by Jason in decision <prd.approve decision id>.
|
||||
|
||||
Source, two new bullets above "Version 1.7 source":
|
||||
|
||||
> - Version 1.8 source, Jason's ruling Q188 (DECISIONS, 2026-10-20), his answers to Q8 and Q10, in part.
|
||||
> Q8: "I would rather grant the grant right to an agent that runs with strict guardrails. That agent
|
||||
> presents the final authorization for the user. Upon approval, the grant is created programmatically.
|
||||
> I see this as: User configures their PATs, API tokens, authorizations within the WebUI. The agent
|
||||
> presents the request for grant with X rights for another agent. The user reviews, then clicks
|
||||
> Approve/Deny. Click of Approve fires off protected mechanical methods to handle the grants on Github,
|
||||
> Gitea, .... services as needed. The tooling would need to exist and must be guarded." And "Disallowing
|
||||
> the agents to create grants autonomously is a necessary evil for better security. Requiring the user
|
||||
> to manually perform the steps is a no-fly." Q10: "Agents should have tools and command access based on
|
||||
> roles. Those roles dictate the allowed tools and commands. The use of a tool or command should not be
|
||||
> raised to the user unless the system is configured with a manual mode that requires user
|
||||
> interaction. By default I want to have the agents capable of running commands and tools at will
|
||||
> within their sandbox/guardrails. This allows autonomous actions by agents within their lanes. Perhaps
|
||||
> a "Trusted actions within approved guardrails" methodology." And "The main agent or chief of staff
|
||||
> would be responsible for surfacing the request to the user's inbox for approval. Not all agents are
|
||||
> capable or authorized to request directly. It must follow the chain of command properly." He agreed
|
||||
> with Mos's Q10 points 3 to 6: protected stack code does every step on Approve, his tokens are entered
|
||||
> in the WebUI and agents never see them, a command on the approved list runs without a click and any
|
||||
> other is refused, and he, Mos or the tester can revoke a grant at once.
|
||||
> - Version 1.8 source, Jason's ruling Q190 (DECISIONS, 2026-10-20), his answer "Q12: O1": each agent seat
|
||||
> gets its own account on his Gitea, created through the Q188 approval path once the Gitea grants exist.
|
||||
|
||||
Under "Decisions", after REQ-DEC-6, a new section:
|
||||
|
||||
> ### Trusted actions
|
||||
>
|
||||
> Added in version 1.8 (Q188).
|
||||
>
|
||||
> - **REQ-TRUST-1.** Agents act within their role guardrails without asking. The default mode is
|
||||
> "trusted actions within approved guardrails". An action a role file grants within-role is done at
|
||||
> will, inside the agent's sandbox, its tool limit and its grants, and is logged. It raises nothing to
|
||||
> the user. An action the file doesn't grant within-role still routes by REQ-DEC-2, and a hard wait
|
||||
> still goes to the user (REQ-WAIT-1). Only a reviewed role change adds an action to a role file, and
|
||||
> REQ-WAIT-2 still keeps every hard wait out of every role file.
|
||||
> - **REQ-TRUST-2.** The user can put chosen actions in manual mode. The organization file holds the list
|
||||
> of actions in manual mode, empty by default. While an action is on the list, every use of it by any
|
||||
> role becomes a decision for that use, raised to the user, and the action waits until the user
|
||||
> resolves it. A decision denied, or not resolved, refuses the action. Each decision is used once. For a
|
||||
> command on another host, the decision names the host, the command and its input. Actions not on the
|
||||
> list are unchanged. Only the user changes the list, through the WebUI or the CLI. The chief of
|
||||
> staff's organization writes (REQ-ROLE-3) can't change it, and no agent can. Every change is logged
|
||||
> with who made it. A change applies to the next use, and a run already in progress keeps no
|
||||
> exemption.
|
||||
|
||||
Under "Credentials", after REQ-CRED-3 (version 1.7):
|
||||
|
||||
> Added in version 1.8 (Q188):
|
||||
>
|
||||
> - **REQ-CRED-4.** An action a role definition lists within-role, and that uses a grant, runs without a
|
||||
> decision for each use only when all of these hold:
|
||||
> - the definition declares the credential kind the action uses;
|
||||
> - a grant names that agent, and that host or service;
|
||||
> - for a command, the command matches the grant's allowlist. A grant lists exact argv prefixes. A
|
||||
> wildcard grant is refused;
|
||||
> - the agent's key or token for that grant is issued and not revoked.
|
||||
>
|
||||
> A command off the allowlist is refused, and adding one takes a new grant request (REQ-GRANT-1). An
|
||||
> action whose definition doesn't list it within-role still needs a decision for each use. Only a
|
||||
> reviewed role change adds an action to a definition. `host.sudo` routes under this requirement.
|
||||
> - **REQ-GRANT-1.** No agent creates or writes a grant. A grant request follows the chain of command,
|
||||
> and only the chief of staff raises it to the user's inbox. Any other role that needs a grant for
|
||||
> itself or for an agent it directs asks the chief of staff, through its chain: a worker role through its
|
||||
> PM, the PM and the CTO directly. The assistant passes the user's own request to the chief of staff
|
||||
> (REQ-ASSIST-1). The request names the agent, the grant method and its target, the exact rights, and a
|
||||
> rotate-by or expiry date. The chief raises one only for an agent whose reviewed role definition
|
||||
> allows that kind of credential, and the broker refuses any other. A grant can't name a wildcard, a
|
||||
> shell or an empty command. The request is a decision with the action `credential.mint`, so it goes
|
||||
> to the user as a hard wait (REQ-WAIT-1). A grant whose method makes an account, such as a seat's own
|
||||
> Gitea user (Q190), is the same one decision: the method in the grant says it makes an account, the
|
||||
> request names the account, and the inbox shows the account name before Approve. The user's one
|
||||
> resolution covers the account and its credential, so the hard-waits list doesn't change. An
|
||||
> `account.create` raise isn't a grant request and routes as version 1.7 has it. The broker checks
|
||||
> only that the chief of staff raised the request. The chain of command before that is in the
|
||||
> messaging lists and the role contracts, not a broker check.
|
||||
> - **REQ-GRANT-2.** The user approves or denies each grant request in the inbox. Approve is the only thing
|
||||
> that creates a grant, and the code uses a grant only when it has a matching Approve record. No agent
|
||||
> creates a grant. A grant written into the business file by hand makes nothing. Row 210's hand-written
|
||||
> grants stay a test path until Vikunja #436a moves `host.run` to grants made by Approve. On Approve,
|
||||
> protected stack code does every step, not an agent and not the user: it records the grant, makes the
|
||||
> key or token, installs it on the machine or creates it on the service, and logs each step. On Deny
|
||||
> nothing is made, and the denial is logged. The grant methods are:
|
||||
> - machine key: a key for one agent on one enrolled host, with the command allowlist of REQ-CRED-4. A
|
||||
> host's identity comes from the host side when the user approves its enrollment, never from a first
|
||||
> connection. A host user with sudo receives a grant only after the user approved that user by name;
|
||||
> - Gitea: a token for one agent, and for an agent seat its own Gitea user (Q190), with an expiry. The
|
||||
> all scope and the admin scope are never granted;
|
||||
> - GitHub: a grant for one agent.
|
||||
>
|
||||
> The Gitea and GitHub rights units come in a later design that Jason approves.
|
||||
>
|
||||
> The approval binds the exact rights the user saw and the target or its enrolled identity. A change to
|
||||
> the agent, its definition, the target, the target's identity or the rights after the raise voids it. An
|
||||
> approval is used once. A grant the code couldn't finish is undone at once on the stack's side, so it
|
||||
> is never used. What it left on the host or service stays visible as pending until it is removed, as
|
||||
> for a revoke (REQ-GRANT-4). A new grant needs a new approval, never an automatic retry. The grant
|
||||
> code runs only on a request the user resolved. A request resolved by an arbiter, the tester or any
|
||||
> agent makes nothing. A `credential.mint` decision raised before version 1.8, or without the fields of
|
||||
> REQ-GRANT-1, makes nothing when it is approved. Until the Console
|
||||
> knows the signed-in human (Vikunja #412), Approve is `mosaic decide` in the CLI (REQ-DEC-3).
|
||||
> - **REQ-GRANT-3.** The user enters their own personal access tokens and API tokens in the WebUI, or the
|
||||
> CLI. Only the grant code reads them, to make grants. No agent ever sees a token value or a key's
|
||||
> private half. Values appear in no event, log, message, prompt or agent file, and no agent can read
|
||||
> them back (REQ-VAR-2). Until the Console knows the signed-in human, they are entered through the
|
||||
> CLI.
|
||||
> - **REQ-GRANT-4.** A grant can be revoked at once by the user, by the assistant for a grant in its own
|
||||
> organization, or by the tester on a dev instance (Q173). A revoke needs no decision and no new trust.
|
||||
> It takes effect at once on the stack's side, without waiting on the network, so the next use is
|
||||
> refused. Removing the key or token at the host or service follows. Until that removal is done it stays
|
||||
> visible as pending. A revoke while the grant is being made stops it, and nothing it made stays usable.
|
||||
> Every revoke is logged with who made it.
|
||||
|
||||
REQ-CRED-1, appended:
|
||||
|
||||
> Since version 1.8, a token or key for a grant is made by the grant code on the user's Approve
|
||||
> (REQ-GRANT-2), not by hand. Tokens that exist stay as they are.
|
||||
|
||||
REQ-WAIT-3, appended:
|
||||
|
||||
> `host.sudo` was added as a hard wait and routes under REQ-CRED-4 since version 1.8.
|
||||
|
||||
Under "Risks and open questions", a new bullet:
|
||||
|
||||
> - Trusted mode lets an agent run its allowed commands at will. On another host, an allowed command that
|
||||
> writes a file as root or starts a shell can plant a key, and the seat sandbox doesn't confine the
|
||||
> network, so root can outlive the grant and a revoke. The allowlist narrows this. A seat sandbox with
|
||||
> its own network namespace closes it. Until then the host's own audit log is the check after the fact
|
||||
> (row 210 design section 6).
|
||||
|
||||
## Notes for the reviewer and for Mos
|
||||
|
||||
- REQ-TRUST-1 restates what REQ-DEC-2 already does for within-role actions ("routine and within-role
|
||||
decisions are logged") and names it as the default mode. It grants nothing new by itself. What widens
|
||||
is the role files, one reviewed commit at a time.
|
||||
- The hard waits stay with the user (mos-1117). REQ-GRANT-1 makes the grant request a `credential.mint`
|
||||
decision, already a hard wait under REQ-WAIT-1, so Approve is the user resolving it. REQ-WAIT-2 stands:
|
||||
no role file grants `credential.mint`. The grant code acts on the user's resolution, not on a role
|
||||
grant. A grant that makes an account, such as a seat's Gitea user under Q190, is the same one
|
||||
`credential.mint` decision. So 1.8 doesn't change the hard-waits list (mos-1124 a, mos-1129 N4).
|
||||
- Manual mode: Sage asked who sets it, where it lives and what it changes. Mos accepted these choices
|
||||
(mos-1124 b), and Jason can change them: a list of actions in the organization file, empty by
|
||||
default, changed only by the user, outside the chief's organization writes. A list rather than one
|
||||
switch follows Gizmoduck's #436 draft section 7 (gizmoduck/machine-grant-design f6b6a04b6), so Jason
|
||||
can make host commands manual without making every action manual. Listing every within-role action
|
||||
gives the whole-organization manual mode. Row 210's per-call `host.run` decision, bound to host, argv
|
||||
and stdin and spent once, is this mode's decision for a host command (Q188, Mos's reading).
|
||||
- Gizmoduck's draft proposes REQ-CRED-5 (section 8) for the machine grant. Its content is in REQ-GRANT-1
|
||||
to REQ-GRANT-4 here, generalized to all three methods, so 1.8 has no REQ-CRED-5. Their bounds in
|
||||
gizmoduck-0062 items 4a to 4g are in the text: the chief raises only for an allowed definition, the
|
||||
approval binds the rights shown, it is used once with no automatic retry, no value is ever seen, revoke
|
||||
is at once on the stack's side with the host or service removal visible until done, every grant has a
|
||||
rotate-by or expiry date, and the host identity comes from the host side. Duckworth has reviewed their
|
||||
design r1 to r4 (duckworth-0056 and duckworth-0060), so a later change there may need a change here.
|
||||
- B1 (duckworth-0057, mos-1129): a grant the code couldn't finish is undone at once on the stack's side.
|
||||
What it left on the host or service stays visible as pending until it is removed, as for a revoke.
|
||||
- B2 (duckworth-0057, mos-1129): Mos chose Duckworth's second option. Only an Approve creates a grant,
|
||||
and the code uses a grant only when it has a matching Approve record. A grant written by hand makes
|
||||
nothing. Jason's Q188 gives the reason: "Disallowing the agents to create grants autonomously is a
|
||||
necessary evil for better security" and "Upon approval, the grant is created programmatically." Row
|
||||
210's hand-written grants stay a test path until #436a moves `host.run` to grants made by Approve. r2
|
||||
and r3 had a hand path in the business file, and r4 takes it out, with r3's load check on hand grants
|
||||
(duckworth-0058 N1) and the note that a revoke never rewrites the user's file (duckworth-0058 N2).
|
||||
The same-uid write to the stack's own store stays the B7 residual, and the top of this draft names it
|
||||
for Jason.
|
||||
- C1 (duckworth-0058, sage-1538): `account.create` is the hard wait for any new external account, not
|
||||
only a grant (REQ-WAIT-1, `vocabulary.mjs:43-46`, `roles/pm.md:55`). It stays as version 1.7 has it.
|
||||
Only `credential.mint` is chief-only, and a grant that makes an account is a `credential.mint`
|
||||
decision (N4), so a PM's sign-up for a service routes as before.
|
||||
- Gizmoduck's machine-grant r2 (eb4c756a1, gizmoduck-0067): enrollment is a request the user approves
|
||||
and the stack runs (mos-1120), and a host user with sudo is enrolled by name, the user's Approve
|
||||
covering that user (mos-1125). REQ-GRANT-2's machine key says both. Their design r3 and r4 (1c5705d6d
|
||||
section 6) give revoke to the user, the assistant in its own organization and the tester on a dev
|
||||
instance, as REQ-GRANT-4 does (mos-1124 d).
|
||||
- N1 (duckworth-0057, mos-1129): the approval binds the target or its enrolled identity.
|
||||
- N2 (duckworth-0057, mos-1129): a revoke during creation stops it, and nothing it made stays usable.
|
||||
- N3 (duckworth-0057, mos-1129): landing step 6 lists the role contract updates (`roles/pm.md:55`,
|
||||
`roles/chief-of-staff.md:75`). A `credential.mint` decision raised before 1.8, or one without the
|
||||
grant fields, makes nothing when it is approved.
|
||||
- N4 (duckworth-0057, mos-1129): a decision carries one action (`a.action`, broker.mjs:1678 on
|
||||
origin/refactor). A grant that makes an account is one `credential.mint` decision: the method in the
|
||||
grant says it makes an account, and the inbox shows the account name before Approve. The user clicks
|
||||
once, and there is no separate `account.create` action for it. Gizmoduck's machine-grant r2 reads it
|
||||
the same way.
|
||||
- N5 (duckworth-0057, mos-1129): `host.sudo` keeps the arbiter route version 1.7 set for
|
||||
`git.push.remote`. A raise for it that cites the spec and isn't within-role goes to the arbiter for
|
||||
its domain (broker.mjs:1677-1678 on origin/refactor), and the grant and its allowlist still limit the
|
||||
command. In manual mode the decision goes to the user. The top of this draft names this for Jason as a
|
||||
change in who decides a root command on another host.
|
||||
- N6 (duckworth-0057, mos-1129): the broker enforces only that the chief of staff raised a grant request.
|
||||
The rest of the chain of command is the messaging lists and the role contracts (step 6 of "When this
|
||||
version lands"). REQ-GRANT-1 says so.
|
||||
- Approving a grant doesn't by itself let a command run without a click. That needs REQ-CRED-4 and
|
||||
`host.sudo` in the routed lists, step 5 of "When this version lands" (gizmoduck-0062 item 4i).
|
||||
- Which roles may request: Q188 says "Not all agents are capable or authorized to request directly. It
|
||||
must follow the chain of command properly." The text makes the chief of staff the only role that raises,
|
||||
the PM and the CTO ask it directly, and worker roles ask through their PM. Jason's words also name "the
|
||||
main agent", but REQ-ASSIST-1 keeps the assistant out of directing agents, so it passes the user's
|
||||
request to the chief rather than raising one. Mos accepted this (mos-1124 c) and reads "the main agent
|
||||
or chief of staff" as one role under two names. That reading is Mos's, so the top of this draft puts it
|
||||
to Jason at approval.
|
||||
- REQ-CRED-4 keeps row 210's conditions (origin/gizmoduck/row210-on-199-r2,
|
||||
agents/gizmoduck/work/queue-210/DESIGN.md:376-392): the definition declares the kind, the grant names the
|
||||
agent, an exact argv-prefix allowlist with wildcards refused, the key issued, and only a reviewed role
|
||||
change adds the action. Row 210 put the grant in a hand-edited business file, which stays a test path until
|
||||
#436a. Under 1.8 the grant is what Approve records, so "the business file grants that agent that host"
|
||||
becomes "a grant names that agent and that host".
|
||||
- Revoke: Q188 says "you, Mos or the tester". Mos is Jason's assistant (REQ-ROLE-4), so the text says the
|
||||
assistant, limited to grants in its own organization, and the tester only on a dev instance (Q173).
|
||||
Mos accepted this with those limits (mos-1124 d).
|
||||
- Approve in the Console needs the Console to know the signed-in human (Q175 phase 2, #412). Until then
|
||||
Approve is `mosaic decide`, which REQ-DEC-3 already allows (mos-1124 e). This draft doesn't take
|
||||
REQ-WEB-2, which stays with the Console decide buttons amendment (Vikunja 19/266). That amendment, or a
|
||||
later version, moves Approve into the Console.
|
||||
- Roles already use a scoped Gitea token (`<read|write>:<category>`, all and admin never granted,
|
||||
`packages/business/src/vocabulary.mjs:105-106` on origin/refactor). It is a likely Gitea unit, not a settled
|
||||
one. Only the machine key is designed so far. Gitea and GitHub are a later #436 draft, and minting a Gitea
|
||||
token needs an admin credential the user enters, which is a design of its own (Gizmoduck's draft section
|
||||
11). GitHub's rights unit isn't settled. So the text leaves both units to a later design Jason approves
|
||||
(mos-1124 f), and keeps only the bounds: an expiry, and never the all or admin scope. The PRD names outcomes
|
||||
and checks, not the grant code's steps, as REQ-CRED-3 does.
|
||||
- Jason expects the inbox Approve and the Gitea and GitHub grants by 10-27 (Q188 point 6). That is a
|
||||
delivery date, so it is in the source and not in the requirement text.
|
||||
- This draft follows the PRD's own style: plain words, no semicolons in new sentences, and the status
|
||||
paragraph never edited in place except to append.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Launchpad
|
||||
|
||||
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 192 (Q175 SSO phase 1, #1690) under mos-0974 and mos-0977.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `launchpad/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Megavolt
|
||||
|
||||
Builder seat, Claude Opus 5.5, started by Sage under mos-0974 and mos-0977 item 5 for MVP T1 item 6, the
|
||||
credential store per agent with its own git identity (#1697).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `megavolt/*`
|
||||
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Morgana
|
||||
|
||||
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 193 (human users and RBAC, #1691) under mos-0974 and mos-0977.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `morgana/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -1,10 +0,0 @@
|
||||
# Quackerjack
|
||||
|
||||
Builder seat, Claude Haiku 5.5 at high effort, started by Sage under mos-1023 item 1 and Q177 for federation
|
||||
rows R5 (`mosaic access list, grant, revoke`) and R2 (the `federation` config key), per Gizmoduck's design
|
||||
section 7 (#414).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own
|
||||
`quackerjack/*` branches through the repository credential helper (mos-0981), never `refactor`, `main` or
|
||||
another seat's branch.
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/bash
|
||||
n=0
|
||||
for p in /proc/[0-9]*; do
|
||||
c=$(tr '\0' ' ' < $p/cmdline 2>/dev/null) || continue
|
||||
case "$c" in *shell-snapshots*|*chrome-devtools-mcp*|*sage-busy-now*) continue;; esac
|
||||
if echo "$c" | grep -qE 'node --test|gate\.sh|mutant|scripts/test-[a-z-]+\.sh|docker compose'; then echo "BUSY ${p#/proc/}: ${c:0:160}"; n=$((n+1)); fi
|
||||
done
|
||||
[ $n -eq 0 ] && echo CLEAR
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -o pipefail
|
||||
# Landing gate template (2026-10-14): sed the G= and OUT= lines and this comment.
|
||||
# Sequential suites, each teed to its own log; stops at the first failure.
|
||||
G=/mnt/storage/scratch/SET-ME-wt
|
||||
OUT=/mnt/storage/scratch/SET-ME-out
|
||||
cd "$G" || exit 9
|
||||
mkdir -p "$OUT" || exit 9 # a missing OUT failed the row 142 gate at step 1 (2026-10-15)
|
||||
# test-task.sh live cases run `docker compose` from $G, so without a name the project is "wt"
|
||||
# and leaves wt_default behind (row 142 gate, 2026-10-15). Name it per gate dir and remove it on exit.
|
||||
export COMPOSE_PROJECT_NAME="$(basename "$(dirname "$G")")"
|
||||
trap 'docker network rm "${COMPOSE_PROJECT_NAME}_default" >/dev/null 2>&1 && echo "removed network ${COMPOSE_PROJECT_NAME}_default"' EXIT
|
||||
# A gate tree without the node_modules link fails cli tests with ENOENT (2026-10-13).
|
||||
[ -d "$G/node_modules" ] || { echo "STOP no node_modules in $G"; exit 8; }
|
||||
echo "base $(git rev-parse --short HEAD) dirty $(git status --short | grep -vc node_modules) DOCKER_HOST=${DOCKER_HOST:-unset}"
|
||||
run() { local name="$1" dir="$2" cmd="$3"
|
||||
(cd "$G/$dir" && bash -c "$cmd") > "$OUT/$name.log" 2>&1; local rc=$?
|
||||
echo "$name rc=$rc $(grep -E '^ℹ (pass|fail) [0-9]+' "$OUT/$name.log" | tr '\n' ' ') | $(tail -n1 "$OUT/$name.log")"
|
||||
[ $rc -eq 0 ] || { echo "STOP $name"; exit $rc; }; }
|
||||
# No pkg-queue step: scripts/test-queue.sh runs packages/queue/tests/ itself (same 156 tests; CI debate item 2a, Vikunja #274).
|
||||
for d in packages/*/; do p=$(basename "$d"); [ -d "$d/tests" ] || continue; [ "$p" = queue ] && continue
|
||||
run "pkg-$p" "packages/$p" 'node --test tests/*.test.mjs'; done
|
||||
for s in scripts/test-*.sh; do n=$(basename "$s" .sh); run "script-${n#test-}" . "bash $s"; done
|
||||
echo GATE_PASSED
|
||||
@@ -1,36 +0,0 @@
|
||||
# Row 189 gate timing (for row 197, Herb)
|
||||
|
||||
Sage, 2026-10-20T19:25:04Z. Gate on 0c177e9ef (base 0217b71c8), one lane. The slot was mine. Seats may have run single test files at the same time, which the slot rules allow, and I did not record that.
|
||||
Start 2026-10-20T19:09:37Z, end 2026-10-20T19:20:36Z. Total wall time: 659 s.
|
||||
|
||||
Wall time per suite is the gap between one suite's log mtime and the one before it (the first suite starts at start.txt), so it includes node start-up. node's duration_ms is listed where the suite prints it. Scripts print no duration.
|
||||
|
||||
| Suite | Wall s | duration_ms s | Ended (UTC) |
|
||||
|---|---|---|---|
|
||||
| pkg-bus | 3 | 3.8 | 19:09:40 |
|
||||
| pkg-business | 3 | 2.2 | 19:09:43 |
|
||||
| pkg-cli | 388 | 388.5 | 19:16:11 |
|
||||
| pkg-control-board | 1 | 0.8 | 19:16:12 |
|
||||
| pkg-conversation | 35 | 35.4 | 19:16:47 |
|
||||
| pkg-discord | 3 | 2.6 | 19:16:50 |
|
||||
| pkg-harness | 12 | 11.6 | 19:17:02 |
|
||||
| pkg-ledger | 13 | 13.5 | 19:17:15 |
|
||||
| pkg-mosaic | 3 | 2.3 | 19:17:18 |
|
||||
| pkg-plugins | 0 | 0.3 | 19:17:18 |
|
||||
| pkg-project | 1 | 1.2 | 19:17:19 |
|
||||
| pkg-runs | 1 | 0.8 | 19:17:20 |
|
||||
| pkg-seat | 6 | 6.2 | 19:17:26 |
|
||||
| pkg-tasks | 1 | 1.2 | 19:17:27 |
|
||||
| pkg-webui | 58 | 57.9 | 19:18:25 |
|
||||
| script-auth | 1 | - | 19:18:26 |
|
||||
| script-conductor | 5 | - | 19:18:31 |
|
||||
| script-config | 2 | - | 19:18:33 |
|
||||
| script-discord | 8 | - | 19:18:41 |
|
||||
| script-extension-package | 1 | - | 19:18:42 |
|
||||
| script-foundation | 40 | - | 19:19:22 |
|
||||
| script-land-work | 11 | - | 19:19:33 |
|
||||
| script-queue | 21 | - | 19:19:54 |
|
||||
| script-release | 11 | - | 19:20:05 |
|
||||
| script-task | 31 | - | 19:20:36 |
|
||||
|
||||
Source: /mnt/storage/scratch/sage-g189/out/*.log mtimes and gate.log. The cli suite is about 59% of the run, and webui plus conversation add about 14%.
|
||||
@@ -1,42 +1,8 @@
|
||||
# Q175 design: the Console verifies SSO itself (Vikunja #412)
|
||||
|
||||
Author Sage, 2026-10-20, r3. Reviewer Filbert, and the review comes before any code. Line numbers are at
|
||||
refactor 4ca8d6e94 unless a line says otherwise. This replaces Part B (row 178 proxy proof, #385 comments 345
|
||||
and 353). #385 stays open until the Console stops trusting `X-authentik-username`.
|
||||
|
||||
## Changes from r2 (Filbert's r2 review, CHANGES)
|
||||
|
||||
- C1: the `console` key goes into the shared loader, scripts/mosaic-config.mjs, which today refuses any
|
||||
unknown top-level key (:113). Section 4 names the blast radius. Jason step 4 moves inside the phase 2
|
||||
window, after the pin and before the Console starts. The phase 2 rollback removes `console` first. A
|
||||
test-config.sh case covers it (test 25). Mos confirms the sequence before phase 1 (section 5).
|
||||
- C2: under `--sso` the page treats a 401 `sign-in required` as signed out and links to
|
||||
`/auth/login?return=<path>` (section 5 phase 1, test 24).
|
||||
- Notes: tests 7 and 15 are built so that only the record rule refuses; test 9a is an alg test; Q-B's two
|
||||
classes are defined, and test 23 adds a 200 HTML page; the phase 2 scratch Console uses a scratch config,
|
||||
a dummy slot, no `--send` and no `--tester-port`.
|
||||
- r3 fix (Filbert r3 approve): D1, the loader checks the slot reference's shape only, and the file checks
|
||||
run in the Console's `--sso` start; D2, the slot resolves from the config dir, and the scratch Console has
|
||||
a scratch dataRoot and an unreachable issuer (section 5).
|
||||
- Q-E: settled as Filbert ruled. The `subject-unknown` line keeps iss and sub, JSON-escaped and capped
|
||||
(section 1, tests 10 and 19).
|
||||
|
||||
## Changes from r1 (Filbert's r1 review, CHANGES)
|
||||
|
||||
- B1: the provider config moves to `~/.config/mosaic-dev/config.json` (section 4). Mos agreed (mos-0973). The
|
||||
Console prints provider ids, issuers and subject counts at start. The rehearse-strip follow-up is dropped.
|
||||
- B2: one return-path rule, `new URL(ret, publicOrigin).origin === publicOrigin` (section 1 step 7), with
|
||||
tests for `/\evil.example` and `/%09/evil.example`.
|
||||
- B3: refusals log a fixed code and the provider id, never the error or its cause (section 1).
|
||||
- B4: login records are capped at 32 (section 1 step 1), with a test.
|
||||
- B5: test 9 splits into a forged payload under a real kid and a kid missing from the JWKS, and a try/catch
|
||||
mutant joins the list. Test 2 no longer claims the signature mutant (section 9).
|
||||
- B6: the phase 2 forged-header check runs on a scratch Console, or Mos or Jason runs it on live (section 5).
|
||||
- Rulings: Q-B start degraded (section 4), Q-C only the public Host on 7332 (section 3), Q-D keep 1 h idle
|
||||
but polls don't count as activity (section 3).
|
||||
- Section 6: `startServer` throws on `tester && sso`, and `human` joins `tester` as a refused subject label.
|
||||
- The missing tests and nits N1-N5 are in sections 1, 3, 7 and 9.
|
||||
- Section 8: the human-cli statement stays as written (mos-0972). The recommendation is now Mos's ruling.
|
||||
Author Sage, 2026-10-20. Reviewer Filbert, and the review comes before any code. Line numbers are at refactor
|
||||
4ca8d6e94. This replaces Part B (row 178 proxy proof, #385 comments 345 and 353). #385 stays open until the
|
||||
Console stops trusting `X-authentik-username`.
|
||||
|
||||
## The ruling this implements
|
||||
|
||||
@@ -56,9 +22,7 @@ trust with verification" and confirmed five points (mos-0970):
|
||||
## What exists today
|
||||
|
||||
The live Console runs from copies/490e2771d3d0 with `--public-host mosaic.woltje.com --auth-header
|
||||
X-authentik-username --allow-user jason.woltje --send --tester-port 7333`. Row 189 (#1687, P19 #413) adds
|
||||
`stack-release.sh activate --no-send`, and Mos's ruling mos-0972 takes `--send` off the live Console until
|
||||
phase 2.
|
||||
X-authentik-username --allow-user jason.woltje --send --tester-port 7333`.
|
||||
|
||||
- `previewOptions` (packages/webui/src/serve.mjs:205) builds `{ host, header, user }`.
|
||||
- A request is `named` when its Host equals the public name (:493). A named request passes sign-in when it
|
||||
@@ -81,73 +45,41 @@ New module `packages/webui/src/sso.mjs`, used only by the human listener (7332).
|
||||
|
||||
| Route | Method | What it does |
|
||||
|---|---|---|
|
||||
| `/auth/login` | GET | One provider configured: start its flow. Several: a plain page listing them, each a link to `/auth/login?provider=ID`. A provider whose discovery hasn't succeeded gets 503 (section 4). |
|
||||
| `/auth/login` | GET | One provider configured: start its flow. Several: a plain page listing them, each a link to `/auth/login?provider=ID`. |
|
||||
| `/auth/callback` | GET | Finish the flow, create the session, 303 to the saved return path. |
|
||||
| `/auth/logout` | POST | Same-origin checks, delete the session, 303 to the provider's `end_session_endpoint`. |
|
||||
| `/auth/signed-out` | GET | Static "signed out" page with a sign-in link. |
|
||||
|
||||
Every URL the Console hands a provider is built from the configured `publicOrigin`: `redirect_uri` is
|
||||
`publicOrigin + '/auth/callback'` and `post_logout_redirect_uri` is `publicOrigin + '/auth/signed-out'`. No
|
||||
code path reads the request's Host to build them (N2).
|
||||
|
||||
The flow per provider:
|
||||
|
||||
1. Start. Generate a state, a nonce and a PKCE verifier (`generateRandomState`, `generateRandomNonce`,
|
||||
`generateRandomCodeVerifier`), and a challenge with `calculatePKCECodeChallenge` (S256 only). Put them, the
|
||||
provider id, the return path and an expiry 10 minutes out in a server-side login record keyed by a random
|
||||
id. The store holds at most 32 login records; a new one evicts the oldest (B4). Set `__Host-mosaic_login` to
|
||||
that id. 303 to `authorization_endpoint` with `response_type=code`, `scope=openid profile email`,
|
||||
`code_challenge_method=S256`, `redirect_uri`, `state` and `nonce`.
|
||||
provider id and the return path in a server-side login record keyed by a random id. Set
|
||||
`__Host-mosaic_login` to that id. 303 to `authorization_endpoint` with `response_type=code`,
|
||||
`scope=openid profile email`, `code_challenge_method=S256`, `redirect_uri`, `state` and `nonce`.
|
||||
2. Callback. Read exactly one `__Host-mosaic_login` cookie, then take and delete its record (single use, so a
|
||||
replayed callback finds nothing). Expire the cookie in the response. The provider comes from the record,
|
||||
never from the query, which stops a mix-up between providers. Two tabs signing in at once each set the
|
||||
cookie, so the later one wins and the earlier tab's callback finds no record and gets the 403 page with a
|
||||
sign-in link (N5). That costs one retry and keeps one cookie per browser.
|
||||
never from the query, which stops a mix-up between providers.
|
||||
3. `validateAuthResponse(as, client, url, record.state)` checks state, refuses an error response, and checks
|
||||
the `iss` parameter when the provider advertises RFC 9207.
|
||||
4. `authorizationCodeGrantRequest(as, client, clientAuth, params, redirectUri, record.verifier)` returns a
|
||||
`Response`. Keep that object. `processAuthorizationCodeResponse(as, client, response, { expectedNonce:
|
||||
record.nonce, requireIdToken: true })` checks iss, aud (and azp when aud has several values), exp, iat and
|
||||
nonce, and checks `alg` against `client.id_token_signed_response_alg`. That field gets the provider's whole
|
||||
`algs` array, which the library's `checkSigningAlgorithm` accepts, so a provider listing RS256 and ES256
|
||||
takes either and nothing else (N1). Test 16 pins this.
|
||||
4. `authorizationCodeGrantRequest(as, client, clientAuth, params, redirectUri, record.verifier)`, then
|
||||
`processAuthorizationCodeResponse(as, client, response, { expectedNonce: record.nonce, requireIdToken: true })`.
|
||||
That checks iss, aud (and azp when aud has several values), exp, iat and nonce, and checks `alg` against
|
||||
`client.id_token_signed_response_alg`.
|
||||
5. Signature. oauth4webapi does not verify the signature of an ID token that came straight from the token
|
||||
endpoint over TLS (OIDC Core 3.1.3.7 allows that, and the library docs for
|
||||
`validateApplicationLevelSignature` say so). Point 2 asks for the JWKS check, so the callback always calls
|
||||
`validateApplicationLevelSignature(as, response)` with the same `Response` object from step 4. It fetches
|
||||
the JWKS from the discovered `jwks_uri`, picks the key by `kty`, `kid`, `alg` and `use`, and refuses HS*
|
||||
outright. On a `kid` it doesn't have, it refetches the JWKS once if the cache is at least 60 s old, which
|
||||
covers key rotation (test 18). The call is not wrapped in a catch that continues; its throw ends the
|
||||
callback.
|
||||
6. Our own checks on top of the library: `header.alg` is in the provider's `algs`, the token's `iss` equals
|
||||
the configured issuer as a raw string compare, with no URL normalising (N3; the library's discovery check
|
||||
compares normalised hrefs, so this one is stricter), and `(iss, sub)` is in the provider's `subjects` list.
|
||||
7. Create the session (section 3), then 303 to the return path. The rule (B2): resolve
|
||||
`u = new URL(ret, publicOrigin)`, and use `u.pathname + u.search` only when
|
||||
`u.origin === publicOrigin`; otherwise use `/`. A missing or unparsable `ret` gives `/`.
|
||||
`validateApplicationLevelSignature(as, response)` after step 4. It fetches the JWKS from the discovered
|
||||
`jwks_uri`, picks the key by `kid` and `alg`, and refuses HS* outright.
|
||||
6. Our own checks on top of the library: `header.alg` is in the provider's configured `algs`, the token's
|
||||
`iss` equals the configured issuer exactly, and `(iss, sub)` is in the provider's `subjects` list. Anything
|
||||
else is a 403 that logs `refused sign-in iss=... sub=...` (never a token, code or cookie).
|
||||
7. Create the session (section 3) and 303 to the return path. The return path must start with one `/` and
|
||||
is otherwise replaced with `/`.
|
||||
|
||||
Every refusal in steps 2-6 ends at a 403 page saying sign-in failed, with a link back to `/auth/login`. No
|
||||
step falls back to a header.
|
||||
|
||||
Logging (B3). A refusal logs one line: a fixed code from a closed list (`login-record-missing`,
|
||||
`state-mismatch`, `auth-error-response`, `grant-failed`, `id-token-invalid`, `signature-invalid`,
|
||||
`alg-refused`, `issuer-mismatch`, `subject-unknown`, `discovery-failed`, `jwks-failed`) and the provider id.
|
||||
It never logs the exception, its message or its `cause`. oauth4webapi's errors carry token bodies and claims
|
||||
in `cause`, so even a message can leak. The one extra field is on `subject-unknown`: it logs `iss` and `sub`,
|
||||
because Jason step 3 reads his `sub` from that line (Q-E, Filbert's ruling). Authentik's default subject mode
|
||||
is a hashed user id that its admin view doesn't show, so the line is the practical source. Neither value is a
|
||||
credential. Three conditions hold:
|
||||
|
||||
- Only a token that passed steps 3-6 up to the subject check reaches this line. Every earlier refusal logs
|
||||
its own code and the provider id only.
|
||||
- Both values are written with `JSON.stringify` and each is cut to 255 characters first. Any account at the
|
||||
provider can get a signed token, so a `sub` may hold a newline, and an unescaped one would let a sign-in
|
||||
forge a log line.
|
||||
- Test 10 uses a `sub` holding `\n` and a fake `refused sign-in` suffix and asserts one escaped line.
|
||||
|
||||
Test 19 asserts that no log line holds a token, code, state, nonce, verifier, client secret or session id.
|
||||
It exempts only the `iss` and `sub` fields of the `subject-unknown` line.
|
||||
|
||||
### 2. Library: oauth4webapi 3.8.8, pinned exactly
|
||||
|
||||
Mos's steer (mos-0971) was to prefer oauth4webapi over hand-rolled verification on `node:crypto` unless I
|
||||
@@ -171,78 +103,34 @@ entry. Root is right because `scripts/stack-copy.sh:23-31` copies only the root
|
||||
webui's first runtime dependency. The root dependency rule (exact pins, never unversioned) applies to it.
|
||||
Upgrades are reviewed commits.
|
||||
|
||||
### 3. Sessions, cookies, Host and logout
|
||||
### 3. Sessions, cookies and logout
|
||||
|
||||
- Session id: 32 random bytes, base64url. The store keys on its sha256 and holds no raw id, so a heap dump
|
||||
doesn't hold usable ids. Test 20 checks that the store's keys and values never contain the cookie value.
|
||||
- Cookie, exactly: `__Host-mosaic_session=<id>; Path=/; Secure; HttpOnly; SameSite=Lax`, no Domain, no
|
||||
Max-Age (a browser-session cookie). The `__Host-` prefix makes the browser refuse it unless it was set over
|
||||
https on this exact host. Test 21 asserts the whole Set-Cookie value, and a mutant drops `HttpOnly`.
|
||||
- Login cookie `__Host-mosaic_login`, the same attributes plus `Max-Age=600`. The login record also expires
|
||||
at 10 minutes server-side.
|
||||
- Session id: 32 random bytes, base64url. The store keys on its sha256, so a heap dump doesn't hold usable ids.
|
||||
- Cookie `__Host-mosaic_session=<id>; Secure; HttpOnly; SameSite=Lax; Path=/`, no Domain, no Max-Age (a
|
||||
browser-session cookie). The `__Host-` prefix makes the browser refuse it unless it was set over https on
|
||||
this exact host.
|
||||
- Login cookie `__Host-mosaic_login`, same attributes plus `Max-Age=600`. The login record also expires at
|
||||
10 minutes server-side.
|
||||
- Lax, not Strict, because the callback is a top-level navigation from the provider's site, and a Strict
|
||||
cookie set there would be missing on the redirect to `/`. Lax still keeps the session off cross-site POSTs.
|
||||
The write routes keep their exact `Origin` and `Sec-Fetch-Site: same-origin` checks (:171, :250, :297, :340,
|
||||
:387, :425), so cross-site writes are refused twice.
|
||||
- Lifetime: 8 h absolute, 1 h idle, both server-side (Q-D, Filbert's ruling). The store is in memory with a
|
||||
cap of 64 sessions (oldest goes first), so a Console restart signs everyone out. That's acceptable for one
|
||||
human and keeps sessions off disk.
|
||||
- Activity (Q-D). Only a page load (a GET outside `/api/`) or a write (a POST) refreshes `seen`. The page's
|
||||
background reads don't: `refresh` re-reads the board every 10 s (packages/webui/src/public/app.js:346) and
|
||||
the 5 s timer at :301 redraws the age label. Without this rule an open tab would never go idle. Test 13
|
||||
covers it.
|
||||
- Lifetime: 8 h absolute, 1 h idle, both server-side. The store is in memory with a cap of 64 sessions
|
||||
(oldest goes first), so a Console restart signs everyone out. That's acceptable for one human and keeps
|
||||
sessions off disk.
|
||||
- The session holds `{ provider, iss, sub, name, idToken, created, seen }`. `name` is the subject entry's
|
||||
label. It goes where `preview.user` goes today (send logs, settings, seat routes), so the downstream writer
|
||||
shape stays as it is. `idToken` is kept only as the logout hint and never leaves the server.
|
||||
- Host (Q-C, Filbert's ruling). Under `--sso`, 7332 serves only requests whose Host is the public host.
|
||||
Any other Host, loopback included, gets 403 `public Host required`, except `GET /healthz`. The loopback read
|
||||
path at :509-512 is gone under `--sso`. Today's readers of 127.0.0.1:7332 and where they go:
|
||||
- `scripts/webui-service.sh status` (:406), the stack-release gate (scripts/stack-release.sh:484) and the
|
||||
rehearse gate (:1369) read only `/healthz`, which stays open on loopback.
|
||||
- The webui README (:83) describes the loopback path. It changes in phase 1.
|
||||
- Agent reads of the board go to the tester listener on 7333, which already serves reads. I found no other
|
||||
code that reads 7332 on loopback. A person opening http://127.0.0.1:7332 in a local browser gets the 403
|
||||
and uses the public URL instead.
|
||||
Test 22 covers a loopback Host on `/`, `/api/board` and a write, and `/healthz` passing.
|
||||
- Every request on 7332 except `/healthz` and `/auth/*` needs a valid session. Without one, `/api/*` gets
|
||||
401 JSON and other GETs get a 303 to `/auth/login?return=<path>`.
|
||||
- Logout: `POST /auth/logout` with the same-origin checks. It deletes the session, expires the cookie, and
|
||||
303s to `end_session_endpoint?id_token_hint=...&post_logout_redirect_uri=<publicOrigin>/auth/signed-out&client_id=...`.
|
||||
303s to `end_session_endpoint?id_token_hint=...&post_logout_redirect_uri=https://mosaic.woltje.com/auth/signed-out&client_id=...`.
|
||||
If the provider has no `end_session_endpoint`, it goes straight to `/auth/signed-out`.
|
||||
- Limit: signing out at the provider does not end a Console session until idle or absolute expiry. Back-channel
|
||||
logout is a follow-up, not phase 1.
|
||||
|
||||
### 4. Provider config: shape and location
|
||||
|
||||
Location (B1, Filbert's ruling, Mos agreed in mos-0973): a `console.sso` key in
|
||||
`~/.config/mosaic-dev/config.json`. The reason is who can write it. The business file is writable by agents
|
||||
through `organizationWriter` (the tester verbs and the chief's `organization.set`), so a provider entry there
|
||||
could be changed by the agents the Console is meant to keep out. config.json is user-authored and never
|
||||
auto-written: `scripts/bootstrap.sh` creates it and nothing overwrites it. Adding the key is a Jason step
|
||||
(section 10).
|
||||
|
||||
The loader (C1). Every tool reads config.json through one loader, scripts/mosaic-config.mjs. Its `validate`
|
||||
refuses any top-level key other than `configVersion`, `environment`, `dataRoot` and `execution` with
|
||||
`unsupported configuration key` (:113). The business, seat, cli, discord, bootstrap, land-work, stack-copy and
|
||||
conductor-apply code all use it. So phase 1 changes the shared loader, not a Console-only reader:
|
||||
|
||||
- mosaic-config.mjs accepts an optional `console` key, and the strict `console.sso` shape validator (the
|
||||
config checks below, except the slot file checks) runs inside the loader. The fail-closed canon holds for
|
||||
every reader. The loader checks shape only and never touches a slot file: `clientSecret` must be
|
||||
`{ slot: <name> }` with the name matching `[a-z0-9-]{1,64}`. The file checks and the read run only in the
|
||||
Console's `--sso` start (D1), so a missing slot or a 0640 slot stops the Console, not the stack.
|
||||
- The blast radius is the whole stack. A typo in `console.sso` makes every tool refuse the config, not just
|
||||
the Console. That's the price of one loader, and the reason step 4 writes the key inside a window with a
|
||||
backup first.
|
||||
- The other direction breaks too. A copy older than phase 1 refuses a config that has the key. If the key
|
||||
went in before the phase 2 pin, the live copy (490e2771d3d0 or later) would refuse its own config on the
|
||||
next read, and the bus, seats and host would stop. After phase 2, a rollback to a pre-phase-1 copy fails
|
||||
the same way until the key is removed. This is the same trap as the plug-in kind before a rollback.
|
||||
- Files without the key still load, on old and new copies.
|
||||
|
||||
Test 25 in scripts/test-config.sh: a config with a valid `console.sso` loads, a malformed one fails with
|
||||
the bad key path named, and a config without `console` loads.
|
||||
|
||||
No secret value goes in this file. The client secret is a slot reference.
|
||||
|
||||
```json
|
||||
@@ -263,54 +151,38 @@ No secret value goes in this file. The client secret is a slot reference.
|
||||
}
|
||||
```
|
||||
|
||||
At start, `--sso` prints one line per provider: its id, its issuer and the number of subjects. No client id
|
||||
value beyond that, no secret, no `sub`.
|
||||
Location: a new optional `console` key in the business file (`~/.config/mosaic-dev/businesses/<id>.json`). The
|
||||
Console already serves one business (`--business`), the identities map to that business's human, and Q173
|
||||
lets agents edit the test business with a backup first. Adding the key means adding `console` to `TOP_KEYS`
|
||||
(packages/business/src/business.mjs:21) with a strict validator. It's additive, and existing files without
|
||||
it still load. The alternative is `~/.config/mosaic-dev/config.json`, which is user-authored and never
|
||||
auto-written. That fits a deployment setting well, but it puts per-business identity in system config.
|
||||
**Filbert, rule on this one.**
|
||||
|
||||
The slot is `<configDir>/secrets/mosaic-stack/<slot>`, where `configDir` is the directory of the config file
|
||||
the loader read (`MOSAIC_CONFIG`, default `~/.config/mosaic-dev`), so a scratch config gets a scratch slot
|
||||
(D2). The Console's `--sso` start reads it. The Console refuses to start if
|
||||
the file is a symlink, isn't a regular file, isn't owned by the Console's uid, has any group or other mode
|
||||
bits (0640 refuses as well as 0644), or resolves under the repo or a stack copy. The value is never logged or
|
||||
put in a refusal message.
|
||||
The slot is `~/.config/mosaic-dev/secrets/mosaic-stack/<slot>`, read at start. The Console refuses to start if
|
||||
the file isn't a regular file, isn't owned by the Console's uid, has any group or other mode bits, or
|
||||
resolves under the repo or a stack copy. The value is never logged or put in a refusal message.
|
||||
|
||||
`stack-release.sh rehearse` starts its Console with `serve --port N --business B` and no `--sso`, so a
|
||||
rehearsed Console never reads `console.sso` and never uses the live redirect URI. No rehearse change is
|
||||
needed; r1's follow-up is dropped.
|
||||
`stack-release.sh rehearse` copies a backup onto a scratch root. A rehearsed Console must not use the live
|
||||
redirect URI, so rehearse strips `console.sso` from the scratch business file and the rehearsed Console runs
|
||||
without SSO (tester listener only). That's a row 163 follow-up, named in the build brief.
|
||||
|
||||
Config checks, run at start without the network. Any failure refuses the start, and the message names the
|
||||
provider and the check:
|
||||
Startup checks for each provider, all fail-closed (the Console doesn't start, and the message names the
|
||||
provider and the check):
|
||||
|
||||
- `publicOrigin` is an https origin with no path, query or fragment.
|
||||
- Provider ids are unique and `[a-z0-9-]{1,32}`.
|
||||
- `issuer` is https with no query or fragment.
|
||||
- `algs` is non-empty and holds only asymmetric algs from the library's list. `HS256`, any `HS*` and `none`
|
||||
refuse.
|
||||
- `subjects` is non-empty, each `sub` is 1..255 visible characters, and no `name` is `tester` or `human`.
|
||||
- `clientSecret` is `{ slot: <name> }` with the name matching `[a-z0-9-]{1,64}` (shape only, in the loader).
|
||||
- In the Console's `--sso` start only, the slot file passes the file checks above.
|
||||
|
||||
Discovery checks, run per provider when its discovery succeeds:
|
||||
|
||||
- Discovery returns `issuer` exactly.
|
||||
- `id_token_signing_alg_values_supported` shares at least one alg with `algs`. mosaic-preview advertises HS256
|
||||
only, so it fails here.
|
||||
- `issuer` is https with no query or fragment. Discovery returns that issuer exactly.
|
||||
- `id_token_signing_alg_values_supported` shares at least one alg with `algs`, and `algs` holds only
|
||||
asymmetric algs from the library's list. mosaic-preview advertises HS256 only, so the Console refuses it at
|
||||
start.
|
||||
- `code_challenge_methods_supported` includes `S256`.
|
||||
- `jwks_uri` returns at least one key usable for one of `algs`. The empty mosaic-preview JWKS fails here too.
|
||||
- `subjects` is non-empty, and each `sub` is 1..255 visible characters.
|
||||
- `client.id_token_signed_response_alg` is set to `algs[0]`, so the library enforces it per token.
|
||||
- Provider ids are unique and `[a-z0-9-]{1,32}`.
|
||||
|
||||
Unreachable provider (Q-B, Filbert's ruling). Config errors still refuse the start. Each fetch of the
|
||||
discovery document or the JWKS lands in one of two classes:
|
||||
|
||||
- Unreachable: a network error, a timeout, a status other than 200, or a body that doesn't parse as JSON. An
|
||||
Authentik maintenance page served as 200 HTML is unreachable, not refused, so it can't block a restart.
|
||||
- Refused: a JSON document that fails the issuer, alg, S256 or JWKS check.
|
||||
|
||||
An unreachable provider doesn't stop the Console. It starts, that provider's `/auth/login` gets 503
|
||||
`sign-in unavailable`, and a retry runs with backoff (5 s, doubling, capped at 5 minutes). `/healthz` stays
|
||||
200 and adds `sso: { <id>: "ready" | "unreachable" | "refused" }`, so the gates still pass and an operator
|
||||
can see the state. A refused provider at start refuses the start, because it's a configuration problem. On a
|
||||
later retry it stays disabled with the `discovery-failed` code until a restart. Sessions already made keep
|
||||
working while a provider is unreachable, since a session check needs no network. Test 23 covers the degraded
|
||||
start, a 200 HTML page, and the recovery.
|
||||
Starting while the provider is unreachable refuses the start. A Console with no working sign-in serves
|
||||
nothing, so refusing loudly costs nothing, and the unit restarts it. The tradeoff is that an IdP outage
|
||||
keeps the Console down until the IdP is back. With every route behind a session, it would be unusable anyway.
|
||||
|
||||
Several providers: Pocket ID, WorkOS and other compliant OPs use the same entry shape. The login page lists
|
||||
them. Each entry has its own issuer, client, algs and subjects, and a session records which provider made it.
|
||||
@@ -318,48 +190,13 @@ them. Each entry has its own issuer, client, algs and subjects, and a session re
|
||||
### 5. Retiring `--auth-header`, `--allow-user` and the proof header
|
||||
|
||||
1. **Phase 1 (code row).** Add `--sso` to `cli.mjs serve` and `webui-service.sh`. It reads `console.sso` from
|
||||
config.json. `--sso` cannot be combined with `--auth-header`, `--allow-user` or `--proof-*`. Under
|
||||
the business file. `--sso` cannot be combined with `--auth-header`, `--allow-user` or `--proof-*`. Under
|
||||
`--sso`, `named` means "has a session", and `writer` comes from the session. `--send` needs `--sso` or the
|
||||
old preview set. Phase 1 also carries the loader change and its test-config.sh case (section 4, test 25).
|
||||
Nothing live changes until the re-pin.
|
||||
|
||||
The page (C2). Today app.js signs out only on `res.type === 'opaqueredirect'` (packages/webui/src/public/
|
||||
app.js:111 and :305), which is the outpost's redirect. Under `--sso` the Console answers an `/api/*`
|
||||
request without a session with 401 `sign-in required`, so the page would show an error on every 10 s
|
||||
refresh and never offer a way back. Phase 1 makes both fetch paths treat a 401 whose body has
|
||||
`error: 'sign-in required'` as signed out, and the signed-out notice links to
|
||||
`/auth/login?return=<current path and query>`. Other 401s stay errors. The opaqueredirect branch stays
|
||||
until phase 3, because the preview flags still run behind the outpost until then. Test 24 covers it.
|
||||
|
||||
Before phase 1 starts, Mos confirms the config.json sequence in phase 2 (C1).
|
||||
old preview set. Nothing live changes until the re-pin.
|
||||
2. **Phase 2 (Jason-gated window).** Re-pin to the phase 1 commit with `--sso --send --tester-port 7333`
|
||||
instead of the preview flags, after Jason steps 1 to 3. Backup first, STATE after (Q173). The order
|
||||
inside the window:
|
||||
1. Back up the store, the pointer, the unit and config.json.
|
||||
2. Pin the phase 1 copy with the Console stopped. The copy's loader accepts a config with or without
|
||||
`console`, so the bus and seats keep reading the old config.
|
||||
3. Straggler check (Mos's C1 condition, mos-0977): list every running process, unit and timer that
|
||||
still runs from the old copy and reads config.json. Each one is restarted on the new copy or shown
|
||||
not to re-read config. The old loader refuses the key, so a straggler would fail on its next read.
|
||||
Step 4 waits until the list is clean.
|
||||
4. Jason step 4: the `console.sso` key goes into config.json, written by Jason or by an agent on his go
|
||||
for that one key. The new loader reads it back (`scripts/mosaic-config.mjs` validate) before anything
|
||||
starts.
|
||||
5. Start the Console with `--sso --send --tester-port 7333`.
|
||||
Acceptance: Jason signs in through the Console flow, sends one message and signs out. The forged-header
|
||||
check (a request with `X-authentik-username` and no cookie gets 401) runs either on a scratch Console or
|
||||
on live by Mos or Jason (B6). No agent sends the auth header to the live 7332 (section 8). The scratch
|
||||
Console runs from the same copy as `serve --sso --port <scratch port>`, with `MOSAIC_CONFIG` pointing at a
|
||||
scratch config in a scratch dir. That config has a scratch `dataRoot`, and any business it serves comes
|
||||
from the scratch config dir; neither points at live. Its dummy client secret sits in the scratch slot
|
||||
(`<scratch configDir>/secrets/mosaic-stack/<slot>`). Its issuer is an unreachable loopback host, not
|
||||
Authentik, so the scratch Console sends nothing outbound (D2, mos-0971). It comes up in the degraded
|
||||
state (Q-B), and the 401 check needs no sign-in. It has no `--send` and no `--tester-port`.
|
||||
|
||||
Rollback for phase 2: remove the `console` key from config.json first (restore the step 1 backup), then
|
||||
roll the pointer back. An older copy's loader refuses a config holding the key (section 4), so the other
|
||||
order stops the bus and seats. The window plan lists that order, and the STATE check after a rollback
|
||||
reads config.json for the key's absence.
|
||||
instead of the preview flags, after the Jason steps below. Backup first, STATE after (Q173). Acceptance:
|
||||
Jason signs in through the Console flow, sends one message, signs out, and a forged-header request with no
|
||||
cookie gets 401.
|
||||
3. **Phase 3 (code row).** Remove `previewOptions`, `proofOptions` and their tests. `cli.mjs` and
|
||||
`webui-service.sh` refuse the old flags with `--auth-header was retired by Q175; use --sso`. Close #385
|
||||
with the phase 2 STATE and the phase 3 commit.
|
||||
@@ -374,15 +211,13 @@ The tester listener exists so agents can test as the tester and never as Jason (
|
||||
- It never mounts `/auth/*` (404), never reads a cookie, and refuses any request carrying `Authorization` or a
|
||||
`__Host-mosaic_session` / `__Host-mosaic_login` cookie (403 `tester listener takes no sign-in`). The same
|
||||
goes for `X-authentik-jwt` and the old auth and proof header names.
|
||||
- `startServer` throws when its options hold both `tester` and `sso`, so the SSO module can't be mounted on
|
||||
the tester listener by a wiring mistake. A unit test calls it with both.
|
||||
- Its writer stays the fixed `{ tester: true, user: 'tester' }`, and it allows only the row 175 writes, as
|
||||
today. No session or header can change it.
|
||||
- Its bus calls stay on `TESTER_CLI`. The bus checks the tester proof and refuses human verbs from it, and the
|
||||
broker refuses a business whose human is `tester` and any role named `tester`. So nothing that reaches 7333
|
||||
can become the human.
|
||||
- 7332 keeps refusing `x-mosaic-tester` for every Host (:485).
|
||||
- `subjects` validation refuses a `name` of `tester` or `human`.
|
||||
- `subjects` validation refuses an entry named `tester`.
|
||||
|
||||
### 7. Point 5, the proxy-forwarded token
|
||||
|
||||
@@ -390,9 +225,7 @@ Not in phase 1. Point 5 makes it optional and the code flow covers the ruling. I
|
||||
|
||||
- Verify with jose (MIT, exact pin), against the JWKS from the configured issuer's own discovery. Never use
|
||||
the `X-authentik-meta-jwks` header, because it arrives with the request and the forger controls it.
|
||||
- Use the same alg allow list, iss, exp and subject list. `aud` is the proxy provider's client id, and the
|
||||
config check refuses it if it equals any code-flow `clientId`, so an ID token issued to the Console can't be
|
||||
replayed as a proxy token or the other way round (N4).
|
||||
- Use the same alg allow list, iss, aud (the proxy provider's client id), exp and subject list.
|
||||
- The mosaic-preview provider signs with HS256 and has an empty JWKS, so its `X-authentik-jwt` fails every
|
||||
check until Jason gives it an RSA or EC key (Jason step 1).
|
||||
|
||||
@@ -404,11 +237,9 @@ Today, with `--send` live, any same-uid process on dragon-lin can connect to 127
|
||||
strips client copies of the header, so a remote forger needs local code execution first. Users on other uids
|
||||
can reach the loopback port too (P19 333).
|
||||
|
||||
Mos ruled (mos-0972) that the Console runs without `--send` until phase 2. Reads stay up, the header path
|
||||
stops carrying writes, and Jason sends piece 1 with `mosaic talk pm` instead. Row 189 (`stack-release.sh
|
||||
activate --no-send`, #1687) makes that change through the pointer so the unit and stack-active.json stay in
|
||||
step; the restart follows its landing. Until phase 2, no agent sends a request with the auth header to 7332.
|
||||
The tests use fixture servers.
|
||||
Recommendation: Mos restarts the Console without `--send` until phase 2. Reads stay up, the header path stops
|
||||
carrying writes, and only Jason's sends through the Console stop. That's a Mos call (Console start belongs to
|
||||
Jason or Mos). Until then, no agent sends a request with the auth header to 7332. The tests use fixture servers.
|
||||
|
||||
To be clear about the limit: SSO protects Console ingress. It doesn't protect the bus's human boundary. The
|
||||
human proof is path-bound (#225), so a same-uid process can already run the tree's `human-cli.mjs` and act
|
||||
@@ -418,11 +249,11 @@ as the human without touching the Console. Q175 doesn't change that. P19 333 own
|
||||
|
||||
All tests run against a fake OP in `packages/webui/tests/helpers/fake-oidc.mjs`. It's a loopback https
|
||||
server with a self-signed cert. Each test passes the library a `customFetch` that trusts only that cert, so
|
||||
production code needs no insecure-request flag. The fake OP has discovery, a JWKS, and authorization, token
|
||||
and end-session endpoints, with keys made by `node:crypto` (RSA 2048, P-256, and an HMAC secret for the
|
||||
forgeries). It can serve two issuers at once and rotate its keys. No test reaches a real provider. Each
|
||||
refusal test asserts the status, that no `__Host-mosaic_session` was set, and that the bus send stub was
|
||||
never called. Each test also passes once unmutated, as the control.
|
||||
production code needs no insecure-request flag. The fake OP has discovery, a
|
||||
JWKS, and authorization, token and end-session endpoints, with keys made by `node:crypto` (RSA 2048, P-256, and an
|
||||
HMAC secret for the forgeries). No test reaches a real provider. Each refusal test asserts the status, that no
|
||||
`__Host-mosaic_session` was set, and that the bus send stub was never called. Each test also passes once
|
||||
unmutated, as the control.
|
||||
|
||||
Refusals Mos named:
|
||||
|
||||
@@ -430,87 +261,35 @@ Refusals Mos named:
|
||||
Fetch Metadata, and no cookie: 401 on `/api/bus/message`, `/api/settings` and `/api/config`, and a 303 to
|
||||
login on `/`.
|
||||
2. HS256 ID token. The fake OP signs the ID token with HS256, using the client secret as the key (the classic
|
||||
confusion) and `kid` set to a real RSA kid: refused at the callback. The library's alg check refuses it
|
||||
before any signature check, so this test proves the alg rule, not the signature check. Test 9 covers that.
|
||||
3. Wrong aud. A correctly signed RS256 token with `aud` set to another client id: refused. Also an `aud`
|
||||
array holding our client id and another, with no `azp`: refused.
|
||||
4. Expired token. A correctly signed token with `exp` 10 minutes ago (beyond the library's 30 s clock
|
||||
tolerance): refused.
|
||||
confusion) and `kid` set to a real RSA kid: refused at the callback.
|
||||
3. Wrong aud. A correctly signed RS256 token with `aud` set to another client id: refused.
|
||||
4. Expired token. A correctly signed token with `exp` 10 minutes ago (beyond the library's clock tolerance):
|
||||
refused.
|
||||
|
||||
More refusals:
|
||||
|
||||
5. Wrong `iss`, both a different issuer and a trailing-slash variant.
|
||||
6. Nonce mismatch or missing nonce.
|
||||
7. State mismatch, a missing login cookie, a replayed callback (record already taken), an expired login record,
|
||||
and two login cookies. For the replay, the fake OP is set to accept the same code twice, so the second
|
||||
callback reaches the record lookup and only the take-and-delete rule refuses it.
|
||||
and two login cookies.
|
||||
8. A provider that demands no PKCE. The token request must carry `code_verifier`, and the fake OP refuses a
|
||||
wrong one.
|
||||
9. The signature check (B5), three cases: (a) `alg: none`; (b) an RS256 token whose header names a real kid in
|
||||
the JWKS, with a changed payload and the original signature; (c) an RS256 token signed by a key whose kid
|
||||
isn't in the JWKS, even after the refetch. (a) is refused by the alg check in step 4, so it's an alg test
|
||||
like test 2. Only (b) and (c) reach step 5, and (b) is the case only the signature check catches.
|
||||
10. A valid token for a `sub` not in `subjects`: 403 and one `subject-unknown` log line with the provider id,
|
||||
iss and sub only.
|
||||
11. Start refusals: `algs` holding `HS256` or `none`, HS-only discovery (the mosaic-preview shape), an empty
|
||||
JWKS, no S256, an issuer mismatch, a subject named `tester` or `human`, `--sso` together with
|
||||
`--auth-header`, and slot files that are a symlink, mode 0644, mode 0640, or under the repo. The owner
|
||||
check has no test: making a file owned by another uid needs root, which the suite doesn't have. The
|
||||
review reads that branch instead.
|
||||
9. `alg: none`, and an RS256 token signed by a key not in the JWKS.
|
||||
10. A valid token for a `sub` not in `subjects`: 403 and one log line with iss and sub only.
|
||||
11. Startup refusals: HS-only discovery (the mosaic-preview shape), an empty JWKS, no S256, an issuer mismatch,
|
||||
a slot with mode 0644, and `--sso` together with `--auth-header`.
|
||||
12. Tester port: a valid session cookie (taken from a 7332 sign-in), `Authorization: Bearer <valid id token>`,
|
||||
and `X-authentik-jwt` each get 403. `/auth/login` on 7333 gets 404. `startServer` with `tester` and `sso`
|
||||
throws.
|
||||
and `X-authentik-jwt` each get 403. `/auth/login` on 7333 gets 404.
|
||||
13. Session: idle expiry, absolute expiry, a logout followed by reuse of the old cookie, and logout without
|
||||
same-origin (refused, session kept). Background `/api/` GETs every 10 s for over an hour don't keep the
|
||||
session alive; a page load does.
|
||||
14. Open redirect: `return=//evil.example`, `return=https://evil.example`, `return=/\evil.example` and
|
||||
`return=/%09/evil.example` all land on `/`, and `return=/board?x=1` lands there.
|
||||
15. Provider mix-up. Two providers, A and B. A login started for A whose callback carries B's `iss` and a
|
||||
code from B is refused, and a `provider=` query on the callback is ignored. B's code is issued against
|
||||
A-record's PKCE challenge, so the verifier matches and only the record rule (the callback belongs to A)
|
||||
refuses it.
|
||||
16. Several algs: a provider with `algs: ["RS256", "ES256"]` takes a token signed with either, and refuses
|
||||
PS256 even when the JWKS holds a usable PS256 key.
|
||||
17. Login record cap: 33 started logins leave 32 records, and the first login's callback finds no record.
|
||||
18. JWKS rotation: the fake OP rotates to a new key with a new kid. A sign-in after the rotation succeeds
|
||||
through the one refetch, and a token under the removed kid is refused.
|
||||
19. Logs: across every test in this file, captured log lines hold none of the tokens, codes, states, nonces,
|
||||
verifiers, the client secret or a session id that the test produced.
|
||||
20. The session store holds no raw session id (keys are sha256 values, and no value holds the cookie).
|
||||
21. Set-Cookie: the exact session and login cookie strings, including the expiry of the login cookie at
|
||||
callback.
|
||||
22. Host: under `--sso`, a loopback Host on `/`, `/api/board` and a write each get 403; `/healthz` on
|
||||
loopback gets 200.
|
||||
23. Degraded start: the fake OP is down at start; the Console starts, `/auth/login` is 503, `/healthz` reports
|
||||
`unreachable`; the OP comes up and the next retry makes sign-in work. A discovery URL that answers 200
|
||||
with an HTML page also counts as `unreachable` and the Console starts. A provider that answers with
|
||||
HS-only discovery at start refuses the start.
|
||||
24. Signed-out page (C2). A page test in the board-held.test.mjs style stubs fetch to answer 401
|
||||
`{ error: 'sign-in required' }`: the page shows the signed-out notice, its link is
|
||||
`/auth/login?return=<current path and query>`, and the refresh stops. A 401 with another error stays an
|
||||
error. The existing opaqueredirect case still signs out.
|
||||
25. Loader (C1), in scripts/test-config.sh: a config with a valid `console.sso` loads; a malformed one (an HS
|
||||
alg, a missing `slot` field, a slot name outside the pattern, an unknown key under `console`) fails and
|
||||
names the bad key path, and no case reads a slot file (the file cases stay in test 11); a config without
|
||||
`console` loads.
|
||||
same-origin (refused, session kept).
|
||||
14. Open redirect: `return=//evil.example` and `return=https://evil.example` both land on `/`.
|
||||
|
||||
Positive paths: an RS256 and an ES256 provider each complete sign-in and one send, and two providers configured
|
||||
together each work with their own subjects.
|
||||
|
||||
Mutants for the gate, each killed by the named test:
|
||||
|
||||
- drop the `validateApplicationLevelSignature` call (9b);
|
||||
- wrap that call in a try/catch that continues (9b);
|
||||
- drop the `subjects` check (10);
|
||||
- accept HS* in `algs` (11);
|
||||
- take the provider from the query (15);
|
||||
- let the tester listener read cookies (12);
|
||||
- skip the login record delete (7);
|
||||
- drop `HttpOnly` from the session cookie (21);
|
||||
- refresh `seen` on every request (13);
|
||||
- build `redirect_uri` from the request Host (a test with a different Host on `/auth/login` checks the
|
||||
`redirect_uri` sent to the fake OP);
|
||||
- serve the loopback read path under `--sso` (22).
|
||||
Mutants for the gate: drop the `validateApplicationLevelSignature` call (test 2 or 9 must fail), drop the
|
||||
`subjects` check, accept HS* in `algs`, take the provider from the query, let the tester listener read
|
||||
cookies, and skip the login record delete.
|
||||
|
||||
### 10. Jason-gated steps
|
||||
|
||||
@@ -521,21 +300,16 @@ Mutants for the gate, each killed by the named test:
|
||||
post-logout URI `https://mosaic.woltje.com/auth/signed-out`, keep RS256, and give the client id. Put the
|
||||
client secret into slot `console-sso-authentik` (or say an agent may create the slot file from a value he
|
||||
puts in place, per the Q173 secrets rule). Agents never see the value.
|
||||
3. His `sub` for that provider, or a go to read it from the Console's one `subject-unknown` log line on his
|
||||
first sign-in attempt.
|
||||
4. The `console.sso` entry in `~/.config/mosaic-dev/config.json`, inside the phase 2 window, after the pin
|
||||
and before the Console starts (section 5). He writes it, or he gives an agent the go to write that one
|
||||
key from the values in steps 2 and 3, with a backup of the file first. config.json is user-authored, so
|
||||
this isn't covered by Q173. Writing it earlier stops every tool on the live copy, whose loader refuses
|
||||
the key.
|
||||
5. Any Traefik or outpost change. None is needed while the outpost stays in front. Dropping the outpost
|
||||
3. His `sub` for that provider, or a go to read it from the Console's one refusal log line on his first
|
||||
sign-in attempt.
|
||||
4. Any Traefik or outpost change. None is needed while the outpost stays in front. Dropping the outpost
|
||||
later, so the Console is the only gate, changes who can reach the Console from outside and is his call.
|
||||
6. The phase 2 re-pin window go.
|
||||
5. The phase 2 re-pin window go.
|
||||
|
||||
## Questions for review
|
||||
|
||||
Q-A to Q-E are settled (Filbert r1 and r2, Mos mos-0973). One open point, for Mos:
|
||||
|
||||
- The config.json sequence (C1): the key goes in only inside the phase 2 window, after the pin and before
|
||||
the Console starts, with a backup first, and a rollback removes it before the pointer moves (section 5).
|
||||
Mos confirms this before phase 1 starts.
|
||||
- Q-A: the config location, business file `console` key or config.json (section 4).
|
||||
- Q-B: refuse to start when the IdP is unreachable, or start and refuse sign-ins (section 4).
|
||||
- Q-C: should the loopback-Host read path on 7332 also go behind the session, as proposed? Public traffic
|
||||
arrives from 127.0.0.1 too, and only Host keeps it off that path today.
|
||||
- Q-D: 8 h absolute and 1 h idle, or shorter.
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
# Row 189: `stack-release.sh activate --no-send`
|
||||
|
||||
Gitea #1687. Vikunja P19 #413 (Q175 interim, #412). Brief: docs/plans/2026-10-20_activate-no-send.md.
|
||||
Rulings: mos-0972 (the Console runs without `--send` until Q175 phase 2), mos-0973 (go; Sage builds,
|
||||
Dewey reviews; gate is the cli suite plus every scripts/test-*.sh). Base: def2e7932 (refactor, the
|
||||
brief commit on 9ae8c73ac).
|
||||
|
||||
## What it does
|
||||
|
||||
`activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE` in a new `send_for_target`, which
|
||||
`console_for_target` calls after `proof_for_target`. `install_stack` renders the unit from
|
||||
`RUN_CONSOLE` and `write_pointer` writes the pointer from it, so both lose the flag and `flags_drift`
|
||||
stays quiet afterwards. Only `activate` takes the flag; the parser sends every other op to usage
|
||||
(exit 4).
|
||||
|
||||
`check_target` lets a `--no-send` activate name the pointer's copy only when the pointer's Console
|
||||
flags hold `--send`. That run is a live-copy re-pin, the same path row 187 uses for the proof pair:
|
||||
the plan prints the live-copy note, the run stops both units, backs up the stopped store and starts
|
||||
them again. A plain same-copy activate, or a same-copy `--no-send` once `--send` is gone, still
|
||||
refuses `already-active`.
|
||||
|
||||
`--plan` prints one `send:` line after the `proof:` line, for activate and rollback:
|
||||
|
||||
- `send: dropped; --no-send was given`
|
||||
- `send: off; --no-send was given and the pointer holds no --send`
|
||||
- `send: carried` (the pointer's `--send` goes on unchanged)
|
||||
- `send: off`
|
||||
|
||||
The steps log gets `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
|
||||
--send, so there is nothing to drop`.
|
||||
|
||||
Rollback and pm-on carry the pointer's flags as they are, so once `--no-send` has run, nothing adds
|
||||
`--send` back. There is no re-enable flag (mos-0973). Q175 phase 2 changes the Console flags in its
|
||||
own row.
|
||||
|
||||
docs/TOOLS.md: `[--no-send]` in the usage block and one paragraph after the proof paragraph.
|
||||
|
||||
## Tests (packages/cli/tests/stack-release.test.mjs, T55)
|
||||
|
||||
1. Seeded preview pointer with `--send --tester-port 7333`: a same-copy `activate B acme --no-send`
|
||||
passes. The pointer and the unit ExecStart lack `--send` and keep the other flags in order, status
|
||||
says the unit flags match, the steps log has the drop line, the log line has from = to = B and the
|
||||
backup sum equals the stopped store's, and the units stop and start in order.
|
||||
2. Plain same-copy activate, with `--tester-port` and with `--no-proof`, refuses `already-active`. So does
|
||||
a same-copy `--no-send` once `--send` is gone, and on a pointer that never held it.
|
||||
3. The four `send:` lines, the live-copy note on the same-copy plan only, and the Console line right
|
||||
after `send:` without `--send`. The run on a pointer with no `--send` logs the nothing-to-drop line
|
||||
and no drop line.
|
||||
4. After `--no-send` on C1, a rollback to B leaves `--send` out of the pointer and the unit, the steps
|
||||
log never names `--send`, and status matches. pm-on after `activate --pm-after-harness --no-send`
|
||||
keeps it off.
|
||||
5. `--no-send` on rollback (with and without `--plan`), pm-on, status, adopt and rehearse is a usage
|
||||
error with the usage text, nothing written, started or logged. The usage text shows `[--no-send]`.
|
||||
6. A pointer holding both the proof pair and `--send`: the plans for `--no-proof`, `--no-send` and both
|
||||
name what each drops and carries. `--no-send` keeps the pair, `--no-send` with a new pair replaces it,
|
||||
`--no-proof` keeps `--send`, and both together leave neither. Status matches after each.
|
||||
|
||||
`planTail` gains `'send: off'` after `'proof: off'`, because every plan now prints the line. T36 takes
|
||||
`out.slice(-n)` around `planTail`, so its four slice counts grow by one.
|
||||
|
||||
## Mutants
|
||||
|
||||
mutants.sh in this packet, run on the candidate. Each mutates scripts/stack-release.sh and runs T55
|
||||
plus T1 and T2.
|
||||
|
||||
- M1: the same-copy exception ignores `--no-send`.
|
||||
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
|
||||
- M3: with `--no-send`, the pointer is written from `P_CONSOLE`, so it keeps `--send` while the unit
|
||||
drops it.
|
||||
- M4: rollback re-adds `--send`.
|
||||
- M5: the parser accepts `--no-send` for rollback.
|
||||
|
||||
Results: mutants-run.log, all five killed (M1 5 failures, M2 2, M3 7, M4 2, M5 2).
|
||||
|
||||
## Deviations from the brief
|
||||
|
||||
- The brief's line numbers were taken before row 163 landed. The candidate corrects them to the
|
||||
9ae8c73ac lines (check_target :249-256, flags_drift :646-657, console_for_target :664-693, adopt
|
||||
:828).
|
||||
- Test 5 also covers rehearse and `rollback --plan`, which the brief did not list.
|
||||
|
||||
## Outbound calls
|
||||
|
||||
None. The change edits an array before the existing unit render and pointer write. Every test uses
|
||||
the stand-in systemctl, units, stores and data root the file already builds; no test touches
|
||||
`~/.mosaic-dev`, the live units or a slot file.
|
||||
@@ -1,7 +0,0 @@
|
||||
70dbc89c0282fa063514317262d8926d16b8b8aaab4a1e47379303918495fc2c agents/sage/work/queue-189/BUILD.md
|
||||
4bd216cce2b40b39998725daa8989f6e3acb3ccd81a7017f6b09026af3965584 agents/sage/work/queue-189/mutants-run.log
|
||||
ddbdddf03abf4aa91aa62f52e468be7af3c6b2f5d7bec2ee99b11e315471f18c agents/sage/work/queue-189/mutants.sh
|
||||
5d7b2da84d994f4f93d1212e349b4e4c19c5c00780e6cfcde91edb3ca08c014e docs/plans/2026-10-20_activate-no-send.md
|
||||
38e410c4cb731390f96747646eb7f6caa7a587fca9024ccbe901aab5ac19c7cf docs/TOOLS.md
|
||||
fe430969cc669515e348fced1a22cbe839a12023f7ac67cfba905b7afd29872f packages/cli/tests/stack-release.test.mjs
|
||||
16f77305e558f4fb953accc6d09f1f7b408aba363851d8ca79216ffce41bed9f scripts/stack-release.sh
|
||||
@@ -1,5 +0,0 @@
|
||||
M1 KILLED fail=5
|
||||
M2 KILLED fail=2
|
||||
M3 KILLED fail=7
|
||||
M4 KILLED fail=2
|
||||
M5 KILLED fail=2
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Row 189 mutants M1-M5: each mutates scripts/stack-release.sh in the sage-b189 worktree, runs T55 and
|
||||
# the plan-tail tests, and restores the file from a checked backup.
|
||||
set -uo pipefail
|
||||
WT=/mnt/storage/scratch/sage-b189
|
||||
OUT=/mnt/storage/scratch/sage-b189-mut/out
|
||||
S=$WT/scripts/stack-release.sh
|
||||
mkdir -p "$OUT" || exit 1
|
||||
cp -p "$S" "$OUT/stack-release.sh.orig" || { echo "backup failed"; exit 1; }
|
||||
ORIG=$(sha256sum "$S" | cut -d' ' -f1)
|
||||
[ "$(sha256sum "$OUT/stack-release.sh.orig" | cut -d' ' -f1)" = "$ORIG" ] || { echo "backup differs"; exit 1; }
|
||||
restore() { cp -p "$OUT/stack-release.sh.orig" "$S"; [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ] || { echo "RESTORE FAILED"; exit 1; }; }
|
||||
trap restore EXIT
|
||||
|
||||
mutate() {
|
||||
case "$1" in
|
||||
M1) sed -i '/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/d' "$S" ;;
|
||||
M2) sed -i 's/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/{ [ "$NO_SEND" = 1 ]; } ||/' "$S" ;;
|
||||
M3) sed -i 's/^ write_pointer "\$TO" "\$RUN_PM" "\$RUN_PENDING" "\$P_CLAUDE" "\$T_SCHEMA" "\${RUN_CONSOLE\[@\]}"$/ if [ "$NO_SEND" = 1 ]; then write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${P_CONSOLE[@]}"; else write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${RUN_CONSOLE[@]}"; fi/' "$S" ;;
|
||||
M4) sed -i 's/^ SEND_PLAN="send: off"$/ SEND_PLAN="send: off"; [ "$OP" != rollback ] || RUN_CONSOLE+=(--send)/' "$S" ;;
|
||||
M5) sed -i 's/--no-send) \[ "\$OP" = activate \] || usage;/--no-send) { [ "$OP" = activate ] || [ "$OP" = rollback ]; } || usage;/' "$S" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
for m in M1 M2 M3 M4 M5; do
|
||||
restore
|
||||
mutate "$m"
|
||||
if [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ]; then echo "$m NOT-APPLIED"; continue; fi
|
||||
bash -n "$S" || { echo "$m SYNTAX"; continue; }
|
||||
diff "$OUT/stack-release.sh.orig" "$S" > "$OUT/$m.diff"
|
||||
(cd "$WT" && TMPDIR=/mnt/storage/scratch/t189 timeout 900 node --test --test-reporter=tap --test-name-pattern='T55|T1 |T2 ' packages/cli/tests/stack-release.test.mjs) > "$OUT/$m.log" 2>&1
|
||||
fails=$(awk '/^# fail /{print $3}' "$OUT/$m.log")
|
||||
if [ "${fails:-0}" -gt 0 ]; then echo "$m KILLED fail=$fails"; else echo "$m SURVIVED"; fi
|
||||
done
|
||||
@@ -1,48 +0,0 @@
|
||||
# Common start for Sage's MVP T1 builder seats (mos-0974, mos-0977)
|
||||
|
||||
You are a builder seat on Mosaic Stack, repository /mnt/storage/src/mosaic-stack, branch base `refactor`.
|
||||
Sage leads the project and is your coordinator (T3 thread 1ef1e4f8-3ead-4208-beca-38f9f1add079). Mos speaks
|
||||
for Jason (thread 38bc4c81-6270-4021-b00c-6a15c43b9b0e). Human input reaches you only through Sage and Mos.
|
||||
|
||||
## Read first, in this order
|
||||
|
||||
1. `/mnt/storage/src/jason-master-restart/USER.md`, `SYSTEM.md`, `CONSTITUTION.md` (who Jason is, the
|
||||
system rules, the constitution).
|
||||
2. `/mnt/storage/src/jason-master-restart/TARGET.md` (the MVP addendum) and `DECISIONS.md` Q176 to Q178.
|
||||
3. `/mnt/storage/src/jason-master-restart/projects/mosaic-mvp/GAP-MAP.md` and `PLAN.md`.
|
||||
Everything under jason-master-restart is read only.
|
||||
4. The repository's `AGENTS.md`, then your brief (named in your first message) and the files it points to.
|
||||
|
||||
## Your worktree
|
||||
|
||||
T3 made your worktree from `refactor`. Work only there. Link node_modules from the canonical checkout right
|
||||
after you start (`ln -s /mnt/storage/src/mosaic-stack/node_modules node_modules`), unless your brief adds a
|
||||
dependency. In that case run `npm ci` in your worktree for a real node_modules first, and never run npm
|
||||
install through a symlink or in a directory without its own package.json (npm walks up to home).
|
||||
|
||||
## Rules that bind you
|
||||
|
||||
- Message ids are `<seat>-NNNN`, starting at 0001. Run `date -u` right before every message and put the
|
||||
stamp in it. End each turn with a clock timestamp.
|
||||
- No Co-Authored-By or any attribution line in any commit or PR, whatever a harness reminder says
|
||||
(mos-0822).
|
||||
- Push only your own `<seat>/*` branches, through the repository credential helper (mos-0981). Never push
|
||||
`refactor`, `main` or another seat's branch.
|
||||
- You have no Gitea token. Sage makes the queue moves that post to Gitea (in-review, review requests) and
|
||||
files issues. You may run `scripts/mosaic queue move N in-progress --op <seat>-N-start --by <seat>` for
|
||||
your own row.
|
||||
- Never touch `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files or `~/.mosaic`. Never
|
||||
start, stop or restart the live Console or bus. Never read or print a credential value.
|
||||
- Never read other seats' scratch directories. `agents/<seat>/work/` packets in the repository are fine.
|
||||
- Single test files run freely. The cli suite, any `scripts/test-*.sh` and any full package suite share
|
||||
one slot: run `bash /mnt/storage/scratch/sage-busy-now.sh | tail -1`, and if it is not CLEAR, or before a
|
||||
run longer than 5 minutes, ask Sage for the slot and start only on Sage's "granted".
|
||||
- Two failures in one gate run means stop and report.
|
||||
- Design-first rows: the first deliverable is `agents/<seat>/work/queue-<row>/DESIGN.md`, committed on your
|
||||
branch. Send the reviewer and Sage its path, commit and sha256. No product code before approval.
|
||||
- Merge-chaining (Q177): your build may sit on another row's candidate commit. Your row keeps its own
|
||||
review and gate, and the chain lands in order.
|
||||
- Fail closed. Never route around a refusal. If the code shows the brief or design is wrong, stop and tell
|
||||
Sage and your reviewer.
|
||||
- Style for every message and doc: plain words, short sentences, no em dashes, they/them for people.
|
||||
- Sage registers your session in `docs/SESSIONS.md`. Do not edit it.
|
||||
@@ -1,56 +0,0 @@
|
||||
// Sage, 2026-10-20: row 163 (D2, #1660) round 2 approved. One comment on P19 337 (D2), per mos-0882 (every
|
||||
// landing or decision gets a Vikunja comment). Sage's own key (bot user 13); the sync key reads back. Comments only:
|
||||
// no task edits, labels, assignees or bucket moves. Checks the task's title before posting. Prints no secret.
|
||||
// Usage: node comment-337-r163-approved.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
|
||||
const POSTS = [
|
||||
{ id: 337, title: /^D2: stack rehearsal on a scratch root/, body: [
|
||||
"Row 163 (Gitea #1660) landed and pushed. Landing commit 8ecc7a239 (Sage; Filbert approve 29447), queue revs 2235-2236 (landing note and done) in 9ae8c73ac, range 4ca8d6e94..9ae8c73ac on refactor. Patch-id b8d5f8a37c24 matches candidate c6f4f4c12596. #1660 is closed with comment 29450.",
|
||||
"Sage gate passed at 18:16Z on eaa0f2666: cli 472/472 and every package and scripts/test-*.sh green. The pushed commit is a cherry-pick of eaa0f2666 onto 7f57419b3, which adds only the Q175 design doc, and no test reads that file. Mos dropped the Part B wait when Q175 replaced Part B (mos-0971).",
|
||||
"Not live. The live stack still runs copies/490e2771d3d0. Rehearse and bus start --no-launch go live at the next re-pin, where the Q173 backup and STATE report apply.",
|
||||
] },
|
||||
{ id: 412, title: /^Console SSO: zero-trust OIDC verification/, body: [
|
||||
"Design r1 is committed: agents/sage/work/q175-sso/DESIGN.md in 7f57419b3 (blob 91e5b1b5e737), pushed in 9ae8c73ac. The Console becomes an OIDC relying party on oauth4webapi 3.8.8, pinned exactly. It always calls validateApplicationLevelSignature, because the library skips the signature check on a token-endpoint ID token. Identity is an (iss, sub) pair, and only asymmetric algs are accepted.",
|
||||
"Filbert's r1 review asks for changes in the doc before any code (B1-B6). B1 moves the provider config to config.json, because agents have write paths into the business file. B2 closes an open-redirect hole. B3 keeps the library's error causes out of logs. B4 caps login records. B5 fixes the signature mutant test. B6 removes a live forged-header probe from the phase 2 acceptance. Their rulings: Q-B start degraded when the IdP is unreachable, Q-C yes (7332 accepts only the public Host under --sso), Q-D keep idle expiry but don't refresh it on poll GETs. r2 follows.",
|
||||
"Interim (mos-0972): the Console runs without --send until phase 2. stack-release.sh has no way to drop --send that keeps stack-active.json and the unit in step, so a small tool row comes first, through review.",
|
||||
] },
|
||||
];
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
for (const P of POSTS) {
|
||||
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
|
||||
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
|
||||
}
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
for (const P of POSTS) {
|
||||
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
|
||||
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
|
||||
rec.comments.push(e);
|
||||
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
|
||||
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
|
||||
const found = list(back.json).find((x) => x.id === c.json.id);
|
||||
Object.assign(e, { readBack: back.status, found: Boolean(found) });
|
||||
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
|
||||
save();
|
||||
}
|
||||
save();
|
||||
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
|
||||
if (rec.problems.length) process.exit(2);
|
||||
@@ -1,56 +0,0 @@
|
||||
// Sage, 2026-10-20: files the activate --no-send row (mos-0972, mos-0973) as one P19 task, owner Sage, no due date.
|
||||
// Sage's own key (bot user 13); the sync key reads back. No labels, assignees, due dates or bucket moves. Refuses if
|
||||
// the title exists. Prints no secret.
|
||||
// Usage: node file-no-send-row.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const SIG = "Created by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (sig, xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(sig)}</p>`;
|
||||
const T = {
|
||||
title: "stack-release.sh activate --no-send: drop the Console's --send and keep the pointer in step (Q175 interim, mos-0972)",
|
||||
description: ps(SIG, [
|
||||
"Mos ruled (mos-0972) that the Console runs without --send until Q175 phase 2 (P19 #412), because with --send live any same-uid process can forge X-authentik-username on 127.0.0.1:7332 and send as Jason.",
|
||||
"stack-release.sh has no path that drops --send and keeps ~/.mosaic-dev/state/stack-active.json and the unit in step: console_for_target (:658-687) carries the pointer's flags, check_target (:247-249) allows a same-copy activate only for the proof pair, and adopt refuses once a pointer exists. A hand reinstall makes flags_drift refuse every later activate, pm-on and rollback.",
|
||||
"The row adds activate --no-send, modeled on row 187's --no-proof. It drops --send from the pointer and the unit. A same-copy activate is allowed when the pointer holds --send. Rollback and pm-on carry the pointer as they do now. There is no re-enable flag. Brief: docs/plans/2026-10-20_activate-no-send.md. Owner Sage, reviewer Dewey, gate the cli suite plus every scripts/test-*.sh (mos-0973 go).",
|
||||
"After landing: a same-copy activate on 490e2771d3d0 with --no-send under Q173, with the plan output sent to Mos first, a backup first and a STATE report after. Then row 66 lands.",
|
||||
]),
|
||||
};
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
async function existing() {
|
||||
const out = [];
|
||||
for (let page = 1; page < 20; page++) {
|
||||
const r = await api("GET", `/api/v2/projects/${PROJECT}/tasks?per_page=50&page=${page}`, undefined, sync);
|
||||
if (r.status !== 200) throw new Error(`list tasks: ${r.status}`);
|
||||
out.push(...list(r.json));
|
||||
if (list(r.json).length < 50) return out;
|
||||
}
|
||||
throw new Error("more than 19 pages");
|
||||
}
|
||||
const rec = { at: new Date().toISOString(), project: PROJECT, created: null, problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
if ((await existing()).some((t) => t.title === T.title)) throw new Error("title exists; refusing");
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
const r = await api("PUT", `/api/v1/projects/${PROJECT}/tasks`, { title: T.title, description: T.description }, key);
|
||||
rec.created = { status: r.status, id: r.json?.id ?? null };
|
||||
if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) rec.problems.push(`create: ${r.status} ${r.json?.message ?? ""}`);
|
||||
else {
|
||||
const back = await api("GET", `/api/v2/tasks/${r.json.id}`, undefined, sync);
|
||||
Object.assign(rec.created, { readBack: back.status, done: back.json?.done, inverse: `mark done: POST /api/v1/tasks/${r.json.id} with the whole task, done true (sage key); delete is Jason's or ops-01's` });
|
||||
if (back.status !== 200 || back.json?.title !== T.title || back.json?.done) rec.problems.push("create read-back differs");
|
||||
}
|
||||
save();
|
||||
console.log(JSON.stringify(rec));
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"at": "2026-10-20T18:21:15.323Z",
|
||||
"project": 19,
|
||||
"created": {
|
||||
"status": 201,
|
||||
"id": 413,
|
||||
"readBack": 200,
|
||||
"done": false,
|
||||
"inverse": "mark done: POST /api/v1/tasks/413 with the whole task, done true (sage key); delete is Jason's or ops-01's"
|
||||
},
|
||||
"problems": []
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"at": "2026-10-20T18:19:36.544Z",
|
||||
"comments": [
|
||||
{
|
||||
"task": 337,
|
||||
"status": 201,
|
||||
"id": 372,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
},
|
||||
{
|
||||
"task": 412,
|
||||
"status": 201,
|
||||
"id": 373,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
}
|
||||
],
|
||||
"problems": []
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
# Re-pin 490e2771d to 4429a104f (row 192, Q175 phase 1), with npm ci in the new copy
|
||||
|
||||
Sage, 2026-10-20, draft 1, for mos-1099 item 3 under DECISIONS Q173. Mos checks this file at its commit before
|
||||
P0. Any change to this file or to NEW after the check means Mos checks again (mos-0849/0850).
|
||||
|
||||
This is a smaller window than `repin-rows-177-185.md` (draft 2, which Mos checked in mos-0929). Where a step is
|
||||
the same, this file names that file's step and says what differs. The 7332 matrix, the snapshot, the W9 list and
|
||||
the rollback compare are reused unchanged.
|
||||
|
||||
## Who, and what nobody does
|
||||
|
||||
- Sage operates, as the tester. Darkwing holds the gate lane for row 190 round 3 tonight, so I take the
|
||||
operator seat. Mos verifies each step's output before the next one and runs `rehearse` (P3), which reads the
|
||||
live config and a live backup (`docs/TOOLS.md` `rehearse`: Jason or Mos runs it).
|
||||
- Jason has no step. Q173 covers a re-pin of this test system with a backup first and a STATE report after.
|
||||
Nothing is pruned.
|
||||
- Nobody acts as Jason: no `--by jason`, no human shell or tmux pane, every bus read and write carries
|
||||
`--tester`, and no request carries `jason.woltje`. `bus.tester` stays as it is; if it isn't `true`, stop at P1.
|
||||
- No pi (mos-0865 D2, P19 #383). No Save in the human Console, no `mosaic name`.
|
||||
- Outside reach doesn't change. The Console keeps its current flags. `--sso` stays off: row 192's sign-in code is
|
||||
dormant without the flag, and `cli.mjs:57` refuses `--sso` with the preview flags the pointer carries anyway.
|
||||
The proof pair stays unset. The tunnel still forwards 7332 only (P4 and W10 check it).
|
||||
|
||||
## Names
|
||||
|
||||
- OLD, live: `490e2771d3d057ab76991e2c7859cd251857e64c`, activation `20261020T192956Z-490e2771d3d0`, pointer
|
||||
`pm: true`, schema 3i, Console `--port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username
|
||||
--allow-user jason.woltje --tester-port 7333` (no `--send`, read from `stack-release.sh status` between 21:36Z and 21:39Z).
|
||||
- NEW: `4429a104f6d65ccf165f6c83b5d1dce3147dfde0`, row 192's landing, 106 commits past OLD counting records.
|
||||
The commits after it (5b8def2f1 plans, 176a6bf4b sessions, 305857c8f queue) are records and don't move NEW. If
|
||||
another code row lands before W0 (row 190 is the likely one), NEW stays here unless Mos rules otherwise.
|
||||
- REPO: the canonical checkout. `activate` runs REPO's `webui-service.sh`, so the four service scripts are held
|
||||
at NEW for the window (P5).
|
||||
- `P=/mnt/storage/scratch/sage-pin-de352dfaf` (`business-check.mjs`, `console-check.sh`, checked against
|
||||
`agents/sage/work/window-plan/repin-de352dfaf/SCRIPTS.sha256`).
|
||||
- `WIN=agents/sage/work/window-repin-4429a104f/`, where every transcript goes.
|
||||
- `SNAP=$WIN/snapshot.mjs`, a copy of `agents/darkwing/work/window-rows-177-185/snapshot.mjs`, sha256
|
||||
`639fd28a2674aaa03ce824e3394b4052f21cdef7d6fa49bae61a9927ed341e67` (mos-0899). I check the sha before S1, S2
|
||||
and any D3.
|
||||
|
||||
## What NEW carries over OLD
|
||||
|
||||
The path-limited log over `packages`, `scripts`, `roles`, `contracts`, `adapters`, `skills` and the package files:
|
||||
|
||||
| Row | Commit | Live effect |
|
||||
|---|---|---|
|
||||
| 186 | 269134d8d | Tool only: the activate plan's backup line says what the run will do. |
|
||||
| 187 | 9a71e4e80 | Tool: activate sets, carries and drops the proof pair. The pointer has no pair, so nothing changes. |
|
||||
| 188 | 8fc0ee216 | Tool: proof file checks on a carried pair. No pair, no effect. |
|
||||
| 163 | 8ecc7a239 | Tool: `rehearse`, which P3 uses, and `bus start --no-launch`. CLI `cli.mjs` and `launcher.mjs` gain the `--no-launch` path; the default start is unchanged. |
|
||||
| 189 | ace27dc5e | Tool: `activate --no-send`. Live already runs without `--send`; the pointer carries that. |
|
||||
| 192 | 4429a104f | Console `--sso` (off here), `scripts/mosaic-config.mjs` load becomes async, `webui-service.sh` renders `--sso` only when given, and a new dependency, oauth4webapi 3.8.8. `cli.mjs` imports `sso.mjs` at the top, so the Console won't start from a copy without that module. |
|
||||
|
||||
No schema step: `SCHEMA_VERSION` (`packages/bus/src/store.mjs:17`) is 3i at OLD and NEW, and no migration was
|
||||
added. `--plan` must say `migration: none`. No roles, skills, contracts, adapters or plug-in kinds changed. So: no
|
||||
harness set, no skill carry, no `pm-on`. One `activate --pm`.
|
||||
|
||||
## npm ci in the new copy
|
||||
|
||||
mos-1099 asks for npm ci in the new copy. `stack-copy.sh copy` already does it: it extracts the archive to a temp
|
||||
dir, refuses without `package.json` and `package-lock.json` (:55), runs `npm ci --no-audit --no-fund` there and
|
||||
writes no copy if it fails (:56). W0 checks the result: `node_modules/oauth4webapi/package.json` in the copy reads
|
||||
3.8.8, and `node --input-type=module -e "await import('oauth4webapi')"` from the copy exits 0. The live copy is
|
||||
163M; `/home` has 30G free (94% used), enough for one more.
|
||||
|
||||
## Backups
|
||||
|
||||
| Before | Backup | Taken by |
|
||||
|---|---|---|
|
||||
| W5 | the stopped store, `$DATA/backups/stack/<W5 activation id>/` with SHA256SUMS, DATA_ROOT, SCHEMA 3i, STAMP | `stack-release.sh` (row 185: every activate) |
|
||||
| W5 | the rollback set (units, wrapper, pointer) | `stack-release.sh` |
|
||||
| W9 | the business file, `cp -p` to `~/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-<stamp>` with its sha256, as evidence nothing wrote it | Sage, in W0b |
|
||||
|
||||
## Before the window (nothing stopped)
|
||||
|
||||
| # | Who | Step |
|
||||
|---|-----|------|
|
||||
| P0 | Mos | The `$P` sha check, as in the 177-185 plan P0. |
|
||||
| P1 | Sage | The business file, read only, the same values as the 177-185 plan P1. I report its sha256 now rather than assume the 06:15Z value. `business-check.mjs` for OLD now and for NEW after W0: LOAD_OK both. |
|
||||
| W0 | Sage | `cd $REPO && scripts/stack-copy.sh copy $NEW`, in a gap between gate-lane runs, since npm ci is heavy. Then the two npm checks above, and `diff <(git -C $REPO show $NEW:package-lock.json) $COPIES/$NEW/package-lock.json` empty. W0 writes only under `copies/` and changes nothing live, so it moves before P2 and P3, which need the copy. |
|
||||
| P2 | Sage | `console-check.sh $NEW` against a stub Console of NEW on a scratch port, once without and once with `--tester-port` on a second scratch port. CONSOLE_PASS both. |
|
||||
| P3 | Mos | `scripts/stack-release.sh rehearse $NEW mosaic-stack --backup 20261020T192956Z-490e2771d3d0 --port <scratch>`. It must pass every gate check but the PM's, with the pointer's Console flags. It stops no unit and writes no pointer. Transcript in `$WIN/p3/`. |
|
||||
| P4 | Sage | The tunnel read, as in the 177-185 plan P4. |
|
||||
| P5 | Sage | `git -C $REPO diff --quiet $NEW HEAD -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh` and an empty `git status --porcelain` on the same four. Row 190 round 3 touches `webui-service.sh`; if it lands before W5, P5 fails and the window waits for Mos's ruling. |
|
||||
| P6 | Sage | The 7332 matrix (a)-(g) on OLD, as in the 177-185 plan, saved as `$WIN/matrix-old.txt`. |
|
||||
|
||||
## The window
|
||||
|
||||
| # | Who | Step |
|
||||
|---|-----|------|
|
||||
| W0b | Sage | The business file copy. |
|
||||
| W1 | Sage | Idle check (`mosaic agents --business mosaic-stack --tester`, `mosaic launches list`), the start stamp from `date -u`, then S1 `node $SNAP --store "$DATA/bus/bus.sqlite" --held`. |
|
||||
| W2 | Sage | `stack-release.sh status mosaic-stack`: pointer on OLD, `pm: true`, 3i, last line ok, unit flags match. |
|
||||
| W3 | Sage | `stack-release.sh activate $NEW mosaic-stack --pm --plan`. Must show from OLD to NEW, `migration: none`, the backup line, and the Console line with OLD's flags exactly: no `--send`, no `--sso`, no proof pair, `--tester-port 7333`. Any other difference stops here. |
|
||||
| W4 | Sage | The unit reads, as in the 177-185 plan W4. |
|
||||
| W5 | Sage, on Mos's go | `stack-release.sh activate $NEW mosaic-stack --pm`. Exit 0, `result ok`, every gate check including `pm_launched`. Record the activation id, the backup path and STAMP, and the backup SHA256SUMS sha256. Then `status`. |
|
||||
| W9 | Sage, after Mos verifies W5 | The 177-185 plan's W9 list unchanged, the chief turn and the one `assistant` thread turn included (mos-0929 Q3), plus two lines: the webui journal since the W5 stamp (with `UTC`) has one `Proxy: proxy proof off` and no `Sign-in:` line, and `ls -l /proc/<Console pid>/cwd` names the NEW copy. |
|
||||
| W10 | Sage | The browser check on 127.0.0.1:7333 and the matrix again as `$WIN/matrix-new.txt`, identical to `matrix-old.txt`. Then P4 again. |
|
||||
| W11 | Sage | STATE report in `$WIN/STATE.md`, posted on P19 386 and P19 412 and on Gitea #1690. |
|
||||
|
||||
## Stop conditions and rollback
|
||||
|
||||
The same as the 177-185 plan. Two failures in one window: stop and report (mos-0823).
|
||||
|
||||
Rollback: `stack-release.sh rollback mosaic-stack --plan`, then the real run. The previous distinct copy in the
|
||||
log is 490e2771d, both are 3i, so no `--restore` and no data loss. OLD takes the same Console flags. The R1 and
|
||||
R1post compares are as in the 177-185 plan. A restore past W5 follows that plan's D3 steps, with Mos deciding.
|
||||
|
||||
Keep every copy. Prune nothing.
|
||||
|
||||
## Questions for the plan check
|
||||
|
||||
- Q1: NEW is row 192 alone, not bundled with row 190. Row 190 (several instances) changes `stack-release.sh`
|
||||
and `webui-service.sh` and deserves its own window. I recommend running this one first.
|
||||
- Q2: Sage operates instead of Darkwing, who is on the row 190 gate. OK?
|
||||
- Q3: W0 before P2 and P3. It writes a copy only. OK?
|
||||
|
||||
## What Sage ran for this plan
|
||||
|
||||
Read only, 2026-10-20 between 21:36Z and 21:39Z (clock read 21:39:50Z at the commit): `stack-release.sh status mosaic-stack` (pointer, last five log
|
||||
lines, unit flags match, 106 commits of drift), `git rev-list --count`, `SCHEMA_VERSION` at both ends, the
|
||||
path-limited log, `git diff --stat` on the service scripts, CLI and config loader, `stack-copy.sh:23-56`,
|
||||
`webui/src/cli.mjs:15-120`, `docs/TOOLS.md` on `rehearse`, `du -sh` of the live copy and `df -h /home`. Nothing
|
||||
live was changed.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Webby
|
||||
|
||||
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0981, thread 03086006). Reviews Case 2 designs and MVP T1 rows 192 and 194.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `webby/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
+2
-8
@@ -1357,11 +1357,5 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-20T15:26:33Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 for Filbert's r1 changes (F1-F3, N1) | in-review r2, Filbert reviewer: candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5, manifest c6f4f4c12596; tests R14 R15 + R4/R9/R11 cases, read-only Registry #write() returns; gate passed, K34-K41 killed; Mos checked manifest (mos-0967); revs 2230 (Filbert) and 2231-2233 committed 92cc6011c c539995cf; request comment 29446; P19 337 comment 367; packet 7af166574; lands after B7
|
||||
2026-10-20T15:40:36Z | Filbert (Opus 5.5, 048f3c63) | row 163 r2 review (D2 rehearse, candidate 2c090eefd, manifest c6f4f4c12596) | approve, comment 29447 rev 2234: 249/249 singles pass; my M1-M6 and N1 mutants all killed (R14, R9, R11, R4, R15, session test)
|
||||
2026-10-20T15:42:00Z | Sage (Opus 5.5, 1ef1e4f8) | row 163 (#1660) r2 verdict intake | approved by Filbert (comment 29447, rev 2234 committed cd2b377bb); P19 337 comment 367 -> 368; candidate 2c090eefd manifest c6f4f4c12596 waits to land after B7, fresh gate if the base moves
|
||||
2026-10-20T18:19:06Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r1 (#412, DESIGN.md blob 91e5b1b5e737 at 7f57419b3), static read incl. oauth4webapi 3.8.8 | changes: B1 config in config.json not business file, B2 return-path backslash, B3 log redaction of library error causes, B4 login-record cap, B5 test 9 not 2 kills signature mutant, B6 phase 2 live forged-header probe; rulings Q-B start degraded, Q-C yes, Q-D 8h abs + idle ignores polls; step 5 reading confirmed; verdict sent to Sage, no Gitea/queue record
|
||||
2026-10-20T18:34:53Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r2 (DESIGN.md blob 3eb718b5e131 at 1a268249e), static | changes: C1 config.json console key fails old loaders (mosaic-config.mjs:113 rejects unknown keys; key add inside phase 2 window, rollback removes it, loader test), C2 app.js signs out only on opaqueredirect so 401 never shows sign-in; notes on tests 15/7/9a, Q-B 200-HTML classification, scratch Console flags; Q-E keep iss/sub escaped and capped; verdict sent to Sage, no Gitea/queue record
|
||||
2026-10-20T18:40:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r3 (DESIGN.md blob a152fd58e18e at 817e8f51b), static | approve on condition D1 (slot file checks stay out of shared loader, shape-only there) as a line edit; D2 notes: slot root from configDir, scratch dataRoot, name discovery GETs for Mos; C1/C2 and notes verified; Mos C1 sequence confirm still open; no Gitea/queue record
|
||||
2026-10-20T18:41:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design D1/D2 check (DESIGN.md blob eba861f5d2bb at d55d86bb0), static git diff 817e8f51b..d55d86bb0 | ack: D1 and D2 match; design approved; Mos C1 sequence confirm still open; no Gitea/queue record
|
||||
2026-10-20T18:57:10Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r2 (#1688, DESIGN.md sha256 5493c9df3ec0) | CHANGES to Darkwing, cc Sage: B1 claim check on MOSAIC_CONFIG-only selection, B2 shared prefix drop-in dirs; N1-N7 notes; static read, no slot, no Gitea or queue record
|
||||
2026-10-20T19:00:04Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r3 (#1688, DESIGN.md sha256 98236a1aff98) | APPROVE to Darkwing, cc Sage: B1 and B2 closed with M13 and M14, N7 settled by Sage's 48ebee0ff; static read, no slot; manifest round gets a queue review record (sage-1337)
|
||||
2026-10-20T20:40:15Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 1 review, candidate 8862feccc (static) | CHANGES: B1 base drift, row 189 ace27dc5e conflicts in stack-release.sh, rebase + full gate rerun; N1-N7 notes; Gitea 29634, queue rev 2362 (uncommitted, left for Sage)
|
||||
2026-10-20T21:29:20Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 2 review, candidate 537a02392 (static) | CHANGES: B1 base drift, row 192 4429a104f conflicts in mosaic-config.mjs and webui-service.sh (async load); round 2 content fine; Gitea 29685, queue rev 2447 (uncommitted, left for Sage)
|
||||
2026-10-20T18:48:55Z | Gosalyn (Opus 5.5, 7604438a) | T2 Case 2 design (#416), agents/gosalyn/work/case2/DESIGN.md on gosalyn/case2-design, static read at 5d019d876 | design r1 committed, review requested from Dewey, rows C2-01..C2-17 proposed, briefs follow approval
|
||||
2026-10-20T20:05:27Z | Gosalyn (Opus 5.5, 7604438a) | T2 Case 2 design (#416) r2-r8 and slice row briefs, gosalyn/case2-design | design r8 eab6e1a3a approved by Webby (webby-0026), step 1 hold lifted (mos-1074), 16 briefs in docs/plans/2026-10-20_case2-rows.md sent to Sage for queue add, webby-0026 N1 folded into the C2-13 brief
|
||||
|
||||
+1
-9
@@ -368,7 +368,7 @@ reports checkout or copy identity and refactor drift, and writes nothing.
|
||||
```
|
||||
scripts/stack-release.sh adopt BUSINESS
|
||||
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
|
||||
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
|
||||
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
|
||||
scripts/stack-release.sh status BUSINESS
|
||||
@@ -480,14 +480,6 @@ copies. It checks the file with `stat` and `realpath` and never reads the value.
|
||||
or `off`. On the live copy it also notes that the run re-pins the pointer's copy
|
||||
and only the Console flags change.
|
||||
|
||||
`activate ... --no-send` (row 189, mos-0972) drops `--send` from the pointer's
|
||||
Console flags and the unit, so the Console serves reads and no human sends. It
|
||||
may name the live copy while the pointer holds `--send`; once `--send` is gone a
|
||||
same-copy `--no-send` refuses `already-active`. Later activates, `pm-on` and
|
||||
rollbacks carry the pointer, so send stays off. No flag puts `--send` back.
|
||||
`--plan` prints one `send:` line: `dropped`, `off` with or without the
|
||||
`--no-send` note, or `carried`.
|
||||
|
||||
#### A target past c216f47db: the PM after the harness kind
|
||||
|
||||
From c216f47db on, the bus launches the PM only with a `harness` kind in the
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# Brief: activate --no-send drops the Console's --send
|
||||
|
||||
Sage, 2026-10-20, for Mos's ruling mos-0972 (Q175 interim, Vikunja #412). Owner Sage, reviewer Dewey, gate owner
|
||||
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit. The candidate corrects them;
|
||||
the first draft took them before row 163.
|
||||
|
||||
This row builds the tool only. The live restart without `--send` is a separate step afterwards, a same-copy
|
||||
activate under Q173 with a backup first and a STATE report after. No test here touches `~/.mosaic-dev`, the
|
||||
live units or a slot file. Every test uses the stand-in `systemctl`, units, stores and data root that
|
||||
`packages/cli/tests/stack-release.test.mjs` already builds.
|
||||
|
||||
## Why
|
||||
|
||||
Mos ruled that the Console runs without `--send` until Q175 phase 2, because with `--send` live any same-uid
|
||||
process can forge `X-authentik-username` on 127.0.0.1:7332 and send as Jason (design section 8). On the pinned
|
||||
stack, the Console flags live in the pointer (`~/.mosaic-dev/state/stack-active.json`):
|
||||
|
||||
- `console_for_target` (scripts/stack-release.sh:664-693) carries the pointer's flags unchanged. Only
|
||||
`--tester-port` and the row 187 proof pair can change.
|
||||
- `check_target` (:249-256) lets an activate name the pointer's copy only for a proof-pair change.
|
||||
- `adopt` refuses once a pointer exists (:828).
|
||||
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:646-657) refuse
|
||||
`unit-flags-drift` on every later activate, pm-on and rollback.
|
||||
|
||||
So the tool can't drop `--send` today. This row adds that one path.
|
||||
|
||||
## The change
|
||||
|
||||
1. `activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE`, so `install_stack` leaves it out of the
|
||||
Console unit and `write_pointer` leaves it out of the pointer. Only `activate` takes it. It combines with
|
||||
`--tester-port`, the proof pair and `--no-proof`.
|
||||
2. An activate with `--no-send` may name the pointer's copy when the pointer's Console flags hold `--send`. It
|
||||
re-pins the live copy with only the Console flags changed, as row 187 does for the pair, including the
|
||||
live-copy plan note and the backup of the stopped store. Without `--send` in the pointer, a same-copy
|
||||
`--no-send` still refuses `already-active`.
|
||||
3. The steps log records `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
|
||||
--send, so there is nothing to drop`.
|
||||
4. `--plan` prints one `send:` line for an activate or rollback: `send: dropped; --no-send was given`,
|
||||
`send: off; --no-send was given and the pointer holds no --send`, `send: carried` when the pointer's
|
||||
`--send` goes on unchanged, or `send: off`.
|
||||
5. `rollback` and `pm-on` don't change: they carry the pointer's flags. So once `--no-send` has run, a rollback
|
||||
to an earlier copy keeps send off. There is no flag to turn send back on. Q175 phase 2 changes the Console
|
||||
flags in its own row.
|
||||
6. `docs/TOOLS.md` "Pinned stack update" gets `--no-send` in the usage block and one sentence.
|
||||
|
||||
## Tests (packages/cli/tests/stack-release.test.mjs)
|
||||
|
||||
1. Seeded preview pointer with `--send` and `--tester-port 7333`: a same-copy `activate B acme --no-send`
|
||||
passes. The pointer and the unit ExecStart lack `--send` and keep the preview flags and
|
||||
`--tester-port 7333` in order. Status says `unit flags: match the pointer`. The steps log has the drop
|
||||
line. The log line has from = to = B, and the stopped store was backed up.
|
||||
2. A plain same-copy activate still refuses `already-active`. So does a same-copy `--no-send` once `--send` is
|
||||
gone.
|
||||
3. `--plan` prints the four `send:` lines in their cases, with the live-copy note on the same-copy run.
|
||||
4. After `--no-send`, a rollback to the earlier copy and `pm-on` both leave `--send` out of the pointer and
|
||||
the unit.
|
||||
5. `--no-send` on `rollback`, `pm-on`, `status` or `adopt` is a usage error (exit 4).
|
||||
6. `--no-send` with the proof pair, and with `--no-proof`, on a pointer holding both: each drops what it
|
||||
names and keeps the rest.
|
||||
|
||||
## Mutants (each must fail at least one test)
|
||||
|
||||
- M1: the same-copy exception ignores `--no-send`.
|
||||
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
|
||||
- M3: `--no-send` drops `--send` from the pointer but not from the unit (it writes the pointer from
|
||||
`P_CONSOLE`).
|
||||
- M4: rollback re-adds `--send`.
|
||||
- M5: the arg parser accepts `--no-send` for rollback.
|
||||
|
||||
## Gate
|
||||
|
||||
Dewey approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and the
|
||||
mutants are killed.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Brief: a credential store per agent, own git identity, pushes to a remote
|
||||
|
||||
Sage, 2026-10-20, for mos-0974 (MVP T1 item 6) and the MVP plan
|
||||
(`jason-master-restart/projects/mosaic-mvp/PLAN.md` item 6, GAP-MAP G11 and G13; TARGET.md: "credentials store
|
||||
available for all agents; secrets protected with RLS equivalent" and "Agent uses own credentials for git and
|
||||
other actions"). Gitea #1697. Owner Megavolt (Opus 5.5, a new builder seat), reviewer Duckworth, gate owner
|
||||
Sage. Base: refactor at the brief commit. Design first. The first deliverable is
|
||||
`agents/megavolt/work/queue-ROW/DESIGN.md`.
|
||||
|
||||
This row draws a security boundary. Sage reviews the design's authority model, and Mos rules on it before any
|
||||
code is written.
|
||||
|
||||
## What exists today (GAP-MAP, measured at 9ae8c73ac)
|
||||
|
||||
- G11. Credential references exist per instance for Gitea and Vikunja only
|
||||
(`packages/business/src/credentials.mjs`, `packages/bus/src/credentials.mjs`, `vocabulary.mjs:103`). One
|
||||
Claude env file is shared by every seat.
|
||||
- G13. Agents use their own Vikunja token (`packages/tasks/src/adapter.mjs`). Git goes through the host
|
||||
socket (`packages/cli/src/git-socket.mjs`). Gitea tokens are loaded but never used.
|
||||
- The development repository's helper (`scripts/git-credential-mosaic.mjs`, wired in `.git/config` for
|
||||
git.mosaicstack.dev) pushes as one identity. Until this row lands, seats push only their own `<seat>/*`
|
||||
branches through it (mos-0981).
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. The store. Where each agent's credentials live, per instance, under the row 190 config canon. File modes,
|
||||
ownership and the read path. Which process may read which entry, and how the bus enforces that (the "RLS
|
||||
equivalent"). A process asking for another agent's entry is refused and logged.
|
||||
2. Kinds. Gitea (git over HTTPS, and the API), Vikunja, and the model provider. Say which kinds 10-27 needs
|
||||
and which wait. The model provider entry must not copy jwoltje's `claude-session.env`. If it needs a new
|
||||
provider account or subscription, flag it for Jason and don't pick one.
|
||||
3. Git identity and push. Each agent commits with its own name and email and pushes with its own token to a
|
||||
remote it is granted. Which refs an agent may push (its own branches, never the default branch or another
|
||||
agent's), and where that's enforced: the Gitea side (branch protection, token scopes) and our side. How
|
||||
this fits the host git socket, or replaces it.
|
||||
4. Lifecycle. Issue, rotate (`rotateBy`, `expires`), revoke and delete, and what row 194 (agent lifecycle) calls
|
||||
when an agent is hired or fired. A fired agent's credentials stop working at once.
|
||||
5. Who does what. Creating a Gitea user or token, or any account outside the fleet, is Jason's (Q173
|
||||
exclusions). The design lists each such step as a command or UI action for Jason. Agents never mint
|
||||
accounts.
|
||||
6. Migration. Today's seats (jarvis helper, seat tokens) keep working until each moves. The mos-0981 push
|
||||
rule ends only when Mos says so.
|
||||
7. What this row closes in G11 and G13, and what stays open.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No credential value is read, printed, logged or committed. Tests use fake tokens in scratch roots and a
|
||||
fake Gitea.
|
||||
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, `~/.mosaic` or the live
|
||||
`.git/config` of the canonical checkout.
|
||||
- No request to git.mosaicstack.dev, tasks.woltje.com or any provider from a test.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, Mos rules on the authority model, then Duckworth approves the manifest. The bus,
|
||||
business, cli and tasks suites and every `scripts/test-*.sh` are green on Sage's gate, and the design's
|
||||
mutants are killed.
|
||||
@@ -1,47 +0,0 @@
|
||||
# Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto
|
||||
|
||||
Sage, 2026-10-20, for mos-0974 (MVP, DECISIONS Q176, Vikunja #414 "User and main agent can create and spawn
|
||||
agents" and "User maintains ability to terminate, regenerate, or create agents via WebUI or CLI ad-hoc", #415
|
||||
Case 1 "Chief of staff agent has hire/fire authority for agents as needed; unless user forbids per agent" and
|
||||
"User maintains ability to restructure organization at will"). Gitea #1692. T1 item 5. Owner Honker, reviewer Webby (mos-0981), gate
|
||||
owner Sage. Base: refactor at the brief commit.
|
||||
|
||||
Design first. The first deliverable is `agents/honker/work/queue-194/DESIGN.md`. Webby reviews it before any
|
||||
code.
|
||||
|
||||
## What exists today (GAP-MAP.md G8, G9, C1.5, C1.6, measured at 9ae8c73ac)
|
||||
|
||||
- The chief launches only listed instances (`roles/chief-of-staff.json`, harness `tools.mjs` launch,
|
||||
`broker.mjs:1968`). No user verb launches an arbitrary agent.
|
||||
- Terminate exists (`packages/cli/src/cli.mjs:447`, `instances.mjs:9-11`, webui `serve.mjs:288, 535`). There
|
||||
is no regenerate, no delete and no Console launch.
|
||||
- Instance changes need a bus restart (`instance-settings.mjs:35,75`, `organization.mjs:83`). Add and change
|
||||
only, no delete.
|
||||
- Human-only membership acts as a veto today, but there is no per-agent veto list.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. The verbs: create (from a role and a name), launch, terminate, regenerate (a fresh session with the same
|
||||
identity and role), and delete (with what happens to its sessions, workspace and run evidence, which
|
||||
stays write-once). Each one is a human verb on the CLI and a Console action.
|
||||
2. Chief hire/fire. What the chief may do on its own, and the per-agent veto: the human marks an agent so
|
||||
the chief can't fire or replace it, and marks roles the chief can't hire. Where the veto lives and how
|
||||
the broker enforces it.
|
||||
3. Live changes. Which changes take effect without a bus restart, and how a running bus picks them up
|
||||
safely.
|
||||
4. RBAC. Human roles come from row 193 (Morgana). Until it lands, these verbs are owner-only. The design
|
||||
states the hook that row 193 fills.
|
||||
5. Per instance. Row 190 runs one instance per OS user. Lifecycle verbs act on their own instance only.
|
||||
6. The kill switch (row 191, Rocko) refuses every launch path. These new launch paths check it too.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file.
|
||||
- Role authority is declared in `roles/` and changes only by reviewed commits (AGENTS.md invariant 7). This
|
||||
row adds verbs, not new role authority. Any role file change is called out for review.
|
||||
- The build may chain on rows 190, 191 and 193 candidates (Q177).
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves DESIGN.md, then the manifest. The bus, cli, business and webui suites and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
|
||||
@@ -1,120 +0,0 @@
|
||||
# Federation build rows R1, R2 and R5 (#414)
|
||||
|
||||
Sage, 2026-10-20, for Mos's orders mos-1023 item 1, mos-1028 item 2 and mos-1034 item 1. The design is
|
||||
Gizmoduck's `agents/gizmoduck/work/federation/DESIGN.md` at a1daa3c07 on `gizmoduck/federation-design` (r4.9,
|
||||
sha256 a31cbebd070f9e3a5ca4ccb158b1747c6f5a83c3469855705b0026a581fb5c6a). Duckworth approved r4.5
|
||||
(duckworth-0012), r4.6, r4.7 (duckworth-0020) and r4.8 (duckworth-0021, 8a22d0897 on `duckworth/reviews`),
|
||||
and acked r4.9 (duckworth-0024). r4.7 applies Jason's Q181 hosts, r4.8 adds N22's R8 checks and r4.9 names who
|
||||
runs them. None of them changes R1, R2 or R5. Section 7's table
|
||||
sets each row's scope and test gate. Vikunja P19 #414. Seats push only their own `<seat>/*` branches
|
||||
(mos-0981).
|
||||
|
||||
The other R rows are filed when their parents have candidates: R3 after R1 and row 196, R4 after R3, R5 and
|
||||
row 193's viewer level (193a, mos-1019), R6 after #412 phase 1, R1, R2 and R3, R7 after R1-R6 and row 190.
|
||||
R8 is a live window, not a code row. Its brief is the last section here.
|
||||
|
||||
## Brief: FED R1, the access.json loader and validator
|
||||
|
||||
Owner Gizmoduck, reviewer Duckworth, gate owner Sage. Base: refactor at this brief's commit. No parent.
|
||||
|
||||
`packages/business/src/access.mjs`: the access.json loader and validator from DESIGN.md section 3.5, and the
|
||||
pure `covers`. Providers, peers and businesses come in as arguments. One read plus the file checks. The
|
||||
exported interface is what R5 and row 193a build against, so the manifest names it, and any change to it in
|
||||
review goes to Sage before it lands. Section 7 names `covers` but not the loader and validator exports or
|
||||
the context shape. Morgana's 193a (508ee4d61 on `morgana/row193a`) assumes `checkAccess(doc, {providers,
|
||||
peers, businesses})`, with `businesses` mapping an id to `{human, instances}`, throwing a `BusinessError` that
|
||||
names the key path. Use those names, or tell Morgana and Quackerjack yours before the manifest goes to
|
||||
Duckworth. Row 193a later widens the level check to `viewer` on R1's candidate (mos-1019).
|
||||
|
||||
Tests, in `packages/business/tests/`: every rule in 3.5 refuses with the key path named. That covers a
|
||||
symlink, a non-regular file, another owner, group or other write, expiry, `*`, a grant disjoint from the given
|
||||
subjects, `human` and `tester` ids, any level other than `collaborator`, and a project entry that isn't a
|
||||
UUID. For `covers`: `*` and listed ids match, while an unlisted project, instance or business doesn't, an
|
||||
expired grant doesn't, and a level filter rejects another level. One mutant per refusal, each killed.
|
||||
|
||||
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. Tests
|
||||
use scratch roots. Single test files run freely, and suites need Sage's slot.
|
||||
|
||||
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, and the mutants are killed.
|
||||
|
||||
## Brief: FED R5, mosaic access list, grant and revoke
|
||||
|
||||
Owner Quackerjack, reviewer Duckworth, gate owner Sage. Chained on R1 (Q177). The build starts now against
|
||||
R1's exported interface as the R1 manifest names it, and R5 lands after R1 and rebases onto R1's landing.
|
||||
|
||||
`mosaic access list`, `grant` and `revoke`: human proof, atomic write, revision bump, a receipt line. The help
|
||||
text states the `instances` limit and the provider-removal order (DESIGN.md section 7, row R5). R4 later makes
|
||||
these verbs call `access.reload`, so R5 leaves no other reload path.
|
||||
|
||||
Context (sage-1419). `ssoSubjects` is row 192's `console.sso.providers[].subjects` flattened to `{provider,
|
||||
sub}` pairs; the design's "`console.sso.subjects`" in 3.5 is shorthand for it. `providers` is the list of
|
||||
`providers[].id`, read through `loadSystem` and row 192's `checkConsole`. `peers` is `[]` until R2 lands, so a grant
|
||||
naming a peer home refuses. R5 rebases onto row 192's landing as well as R1's.
|
||||
|
||||
Tests, in `packages/cli/tests/`: tester and agent callers are refused, a concurrent write refuses on a
|
||||
revision conflict, the receipt line is appended, and a bad grant is refused before anything is written. One
|
||||
mutant per refusal, each killed.
|
||||
|
||||
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. The
|
||||
human proof in tests uses the scratch stand-in pattern (a scratch bus verifies its own human-cli path).
|
||||
Single test files run freely, and suites need Sage's slot.
|
||||
|
||||
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutants are killed.
|
||||
|
||||
## Brief: FED R2, the federation key in the system config
|
||||
|
||||
Owner Quackerjack, reviewer Webby, gate owner Sage. Chained on row 192 (#412 phase 1). The build starts when
|
||||
row 192 has a candidate, on that candidate.
|
||||
|
||||
A `federation` key in `scripts/mosaic-config.mjs` with the shape in DESIGN.md section 3. A config with no
|
||||
`federation` key still loads as it does today. This changes the config schema, so the manifest lists every
|
||||
reader of the key.
|
||||
|
||||
Tests, in `scripts/test-config.sh`: a valid key loads, an unknown sub-key and a bad origin refuse, and a
|
||||
config without the key loads. One mutant per refusal, each killed.
|
||||
|
||||
Boundaries: `~/.config/mosaic-dev/config.json` is user-authored and is not touched. Tests use scratch config
|
||||
dirs only.
|
||||
|
||||
Gate: Webby approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and
|
||||
the mutants are killed.
|
||||
|
||||
## Brief: FED R8, the hub window on 10.1.1.54
|
||||
|
||||
A live window, not a code row (DESIGN.md section 7, row R8). The brief follows r4.11, b93f098f0 on
|
||||
`gizmoduck/federation-design` (sha256 1bcee8df0a3a8628a6646535b756c936ca6d9e7bcfa7e1dd2bf1d3f01715c8e6),
|
||||
which Duckworth acked with r4.10 (duckworth-0034). Mos closed design rounds there (mos-1072). Run by Jason and
|
||||
Mos. Nothing here authorizes a step on any host; each step waits for the window's go.
|
||||
|
||||
What changed since r4.9 (DECISIONS Q182, Q183, mos-1055, mos-1063). R8 is a recorded acceptance. It starts
|
||||
with Jason's acceptance of the factory paths to .54 on record, quoted in section 8. The privilege and host
|
||||
checks still run, and each result goes into the R8 record with the name of whoever ran it. They report and
|
||||
don't refuse. The four functional checks named at the end of section 7's R8 row still stop R8 when they fail
|
||||
(mos-1066 item 1).
|
||||
|
||||
Order (mos-1042 item 2). R8 runs after the Case 2 install on .54 (#416, due by 2026-10-27, Jason's latest-by
|
||||
date in Q176, mos-1069), after Bean and Chet are live in Case 2, and after both Hermes gateways on 10.1.1.54
|
||||
have stopped. Case 2 go-live doesn't wait on R8. The risk, stated for 10-27: federation between the two
|
||||
instances depends on Melanie's switch-over date.
|
||||
|
||||
Entry checks, all before anything starts:
|
||||
- R7, #412 phase 2 and Jason's #419 answer, as section 7 lists.
|
||||
- The four hub user checks on 10.1.1.54 (N22, mos-1035 item 1): `sudo -l -U mosaic` grants nothing, `id
|
||||
mosaic` shows only its own group, `~mosaic/.ssh/authorized_keys` is absent or empty, and no file under
|
||||
`/etc/sudoers.d` names `mosaic`. An agent or Jason runs them as localadmin (mos-1055), and the output goes
|
||||
into the R8 record with who ran them.
|
||||
- The factory privilege checks, the key fingerprint comparison and the section 8 privilege path table, each
|
||||
as section 7's R8 row states them.
|
||||
|
||||
Build notes:
|
||||
- N1 (Duckworth, duckworth-0034, mos-1072). A check that fails on a path Jason didn't name in Q182 or Q183 is
|
||||
marked "not named in Q182 or Q183" in the record, not filed under the acceptance. Examples: a factory mount
|
||||
of hub data, another agent on .54, a provider admin token. Mos takes each one to Jason.
|
||||
- N2 (Duckworth, doc-only). The Q183 and Q4 quotes in section 8 are excerpts. When the R8 record quotes them,
|
||||
it marks each omission with "…" and checks the full text at DECISIONS.md:1535-1541 in jason-master-restart
|
||||
(read only).
|
||||
|
||||
Done when every entry check has its output recorded, Jason signs in to the hub and sends one message, Melanie
|
||||
revokes, and his next request is refused.
|
||||
@@ -1,81 +0,0 @@
|
||||
# Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426)
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-1078 (Vikunja P19 #426) and its go mos-1083 item 1. Owner Rocko,
|
||||
reviewer Dewey, gate owner Sage. Base: refactor at this brief's commit. Starts after row 191.
|
||||
|
||||
## Why
|
||||
|
||||
Morgana force-pushed a seat branch on 2026-10-20 (morgana-0016). Mos took it to Jason. Force-push is a
|
||||
Q173 exclusion, and canon says never force-push, but nothing mechanical stops one. Mos: "Done means a
|
||||
force-push from a seat worktree is refused, with a test that shows it."
|
||||
|
||||
Earlier the same day, a busy check chained as `| tail -1 &&` passed while the slot was BUSY. The script it
|
||||
called prints BUSY or CLEAR, but it exits 0 either way.
|
||||
|
||||
The guard has two layers:
|
||||
- (a) Gitea protection with force-push off on seat branch patterns and on refactor, next and main. ops-01
|
||||
sets it up, through Mos (mos-1082). That part isn't this row.
|
||||
- (b) The client hook below. It can be bypassed with `--no-verify`, so (a) is the real guard and (b) is the
|
||||
early refusal.
|
||||
|
||||
## What ships
|
||||
|
||||
1. `scripts/git-hooks/pre-push` (new). For each ref line git passes on stdin:
|
||||
- It refuses when the remote sha isn't all zeros and isn't an ancestor of the local sha (`git
|
||||
merge-base --is-ancestor`). The refusal names the ref and both shas, and the hook exits 1.
|
||||
- A new branch passes. A delete (local sha all zeros) is refused.
|
||||
- Any failure of the check refuses too, for example an unknown remote object. The hook fetches nothing.
|
||||
If the remote tip isn't present locally, it refuses and says to fetch first.
|
||||
2. Install, following `scripts/queue-commit.sh --install-hook`. Either add a `--install-push-hook` there or
|
||||
ship a small `scripts/install-git-hooks.sh`, whichever fits the existing code better. Say which in BUILD.md.
|
||||
- The install copies the hook into the common `.git/hooks`, which every seat worktree shares.
|
||||
- It refuses when `core.hooksPath` is set (the husky trap, 2026-10-09). It refuses when an installed
|
||||
pre-push differs from the repository copy, unless `--force-install` is given.
|
||||
- It never edits the installed copy in place.
|
||||
3. `scripts/slot-busy.sh` (new), the shared busy check.
|
||||
- It prints one BUSY line per matching process, or CLEAR.
|
||||
- It exits 0 on CLEAR, 1 on BUSY and 2 on its own error.
|
||||
- It matches `node --test`, `gate.sh`, `mutant`, `scripts/test-*.sh` and `docker compose` command lines.
|
||||
It skips its own process and shell snapshots.
|
||||
- It reads `/proc/<pid>/cmdline` and the cwd link only, never file contents under another seat's
|
||||
directory.
|
||||
- Usage in `docs/TOOLS.md`: `bash scripts/slot-busy.sh > LOG && gate`, with `set -o pipefail` in any
|
||||
script that pipes it. Never `| tail -1 &&`.
|
||||
|
||||
## Tests
|
||||
|
||||
Add them to `scripts/test-land-work.sh` or a new `scripts/test-git-guard.sh` (say which). Run against a scratch
|
||||
bare remote and a scratch clone with a worktree, never against origin:
|
||||
- A rewritten branch pushed with `--force` from a worktree is refused, and the remote ref is unchanged.
|
||||
- A `+refspec` push is refused.
|
||||
- A fast-forward push passes.
|
||||
- A new branch passes.
|
||||
- A delete is refused.
|
||||
- A remote tip that's missing locally is refused.
|
||||
- The install refuses with `core.hooksPath` set, and refuses a differing installed hook.
|
||||
- `slot-busy.sh` exits 1 with a fake `node --test` process running (a sleeping stand-in with that argv),
|
||||
and 0 without one.
|
||||
- `slot-busy.sh | tail -1 && false` doesn't apply here. The test runs `bash scripts/slot-busy.sh >/dev/null
|
||||
&& echo ran`, and asserts it doesn't print `ran` while BUSY.
|
||||
|
||||
Mutants, each killed:
|
||||
- drop the ancestor check
|
||||
- treat an unknown remote object as passing
|
||||
- let a delete pass
|
||||
- install over a set `core.hooksPath`
|
||||
- `slot-busy.sh` exits 0 on BUSY
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Install the hook in the canonical checkout only after landing, as its own step with Sage. Tests use
|
||||
scratch repositories.
|
||||
- No push to origin from a test. No change to Gitea config. That's ops-01's.
|
||||
- Don't touch `~/.mosaic`, `~/.mosaic-dev` or `~/.config/mosaic-dev`.
|
||||
|
||||
## Gate
|
||||
|
||||
Dewey approves the manifest. The new or changed `scripts/test-*.sh` file and every other `scripts/test-*.sh`
|
||||
are green on Sage's gate, and the mutants are killed. Done when the hook is installed in the canonical
|
||||
checkout and a scripted force-push from a seat worktree to a scratch remote is refused there. ops-01's
|
||||
server proof (a refused force-push, plus a fast-forward push and a new branch that go through) is recorded
|
||||
in Vikunja #426 by Mos.
|
||||
@@ -1,70 +0,0 @@
|
||||
# Brief: agent host access with sudo through the credential store (#424)
|
||||
|
||||
Sage, 2026-10-20, for Jason's rulings Q183 and Q184 relayed by Mos (mos-1056 item 3, mos-1067). Gitea #1707, Vikunja P19
|
||||
#424, due 2026-10-27 (Q184: "Q5: by 10.27"). Owner Gizmoduck (design and build), reviewer Duckworth, gate owner
|
||||
Sage. Chained on row 199's candidate (Q177): row 199 design r6 (4d3007ec9 on `megavolt/row199`, approved in
|
||||
duckworth-0027) is not reopened. This row lands after 199 and rebases onto its landing.
|
||||
|
||||
## What Jason asked for
|
||||
|
||||
Q183, through Mos: "I envision a credential store within Mosaic Stack that works as some sort of variable for
|
||||
the agent to call. The system will enforce access to the credentials to keep the agents sandboxed with proper
|
||||
security... If we can utilize the credential store in Mosaic Stack and allow agents to perform their jobs with
|
||||
NOPASSWD sudo on various machines, I am a happy man." Q183 also says not every agent needs that access.
|
||||
|
||||
Mos's requirements (mos-1067 item 1):
|
||||
- An agent asks for a host credential by name.
|
||||
- The stack checks a grant for that agent and that host, and does the ssh and sudo for it.
|
||||
- The agent never sees the key or any secret value.
|
||||
- Only agents with a grant can do it.
|
||||
|
||||
The PRD text is REQ-CRED-3, drafted by Honker for PRD 1.7 (sage-1422). If it misses 1.7 it goes into 1.8, and
|
||||
this row lands only after the version that holds it is approved.
|
||||
|
||||
## What row 199 gives and what it doesn't (megavolt-0019)
|
||||
|
||||
It builds on 199 as 199 stands: the store layout under `<configDir>/secrets/agents/<business>/<agent>/`, the
|
||||
lifecycle verbs and their `changes.jsonl` receipts, `useFor(session, service, fn)` with the D5 allowlist test,
|
||||
`assertClean` on every result, and the grant shape of `publish` (a list on the role entry, checked by the
|
||||
broker, plus a hard-wait action that isn't routed).
|
||||
|
||||
This row's own changes:
|
||||
- A host kind in the closed kind set (vocabulary.mjs `SERVICES`, credentials.mjs, the broker's service check,
|
||||
the CLI date flag, the remove regex), with a naming rule for one credential per host or per agent.
|
||||
- A per-kind value check. An OpenSSH private key fails today's `TOKEN` pattern.
|
||||
- Handing the key to ssh without the agent seeing it: an ssh-agent on a private socket, or a 0600 temp file
|
||||
removed on every path, the way publish's one-shot helper works.
|
||||
- Pinned host keys per host, and no trust on first use.
|
||||
- The command boundary: what an agent may run under sudo, and how output returns without echoing the secret.
|
||||
This is the main design work.
|
||||
- The executor: a ticket and a host op running in the bus child, the same split row 199 uses for publish
|
||||
(Megavolt's deviation in 199's BUILD.md).
|
||||
|
||||
## Steps
|
||||
|
||||
1. DESIGN.md first, in `agents/gizmoduck/work/queue-ROW/`. It answers each item above, names the grant shape
|
||||
(`roles.<agent>.hosts` and a `host.sudo` action, or a reasoned alternative), says who grants (Jason, through
|
||||
a reviewed role change, unless REQ-CRED-3 says otherwise), and lists tests and mutants. Duckworth approves it
|
||||
before code. Sage reads it too, because it gives agents sudo on hosts.
|
||||
2. Build on 199's candidate. Single test files run freely. Suites and mutant batches need Sage's slot.
|
||||
3. The live part is not this row. Putting a key in the store and granting a host is a separate window with
|
||||
Jason or Mos, after landing.
|
||||
|
||||
## Hosts
|
||||
|
||||
The first targets are `localadmin` with NOPASSWD sudo on 10.1.1.53 and 10.1.1.54 (#423 step 1, applied by
|
||||
ops-01 at 20:05:47Z), and the servers agents already reach as localadmin (Q172). ops-01 owns the host side.
|
||||
Tests use scratch sshd fixtures on 127.0.0.1 or fakes, never a real host.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files, the live units or `~/.mosaic`. No key is
|
||||
read, copied or printed. No ssh to any real host from a test or a seat.
|
||||
- No widening of row 199's kinds or regex inside row 199. Both rows touch them, so this row rebases after 199
|
||||
lands.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, then the manifest. The bus, business and cli suites and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the mutants are killed (one per refusal, plus one that leaks
|
||||
the key into the agent's output and one that skips the host key pin).
|
||||
@@ -1,66 +0,0 @@
|
||||
# Brief: the Case 1 install on 10.1.1.53 (row 195)
|
||||
|
||||
Sage, 2026-10-20, third version, for Darkwing's rewrite request and sage-1439. Gitea #1693.
|
||||
|
||||
Why it changed:
|
||||
- The first two versions put Melanie's hub on its own Linux user, then on its own host, Jason's choice under
|
||||
#419.
|
||||
- Q181 settled the hosts: Case 1 (Jason's software factory) runs on 10.1.1.53 and Case 2 (Melanie's hub) on
|
||||
10.1.1.54.
|
||||
- Q182 and Q183 made the hub's privilege checks a recorded acceptance. They report and don't refuse
|
||||
(federation R8, mos-1055, mos-1063, mos-1066).
|
||||
- The Case 2 install on .54 is Gosalyn's C2-13 plus its window (#416, due by 2026-10-27). It is not this row.
|
||||
|
||||
This row now installs Case 1 on .53. On the 10-27 MVP path (mos-0974, T1 item 1).
|
||||
|
||||
Owner Darkwing (Opus 5.5), reviewer Duckworth, gate owner Sage. Chained on row 190's candidate (the config
|
||||
canon) and on C2-13's candidate (the install script), Q177. It reuses C2-13's `case2-install.sh`,
|
||||
`case2-precheck.sh` and `case2-unit.sh` by input, not by copy. A gap in them goes to Gosalyn as a C2-13
|
||||
change, or into this row's manifest as a named, reviewed change to those files. Design first:
|
||||
`agents/darkwing/work/queue-195/DESIGN.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
Jason's software factory runs on 10.1.1.53 as `mosaic` under `/srv/mosaic`, in the same unit form as Case 2
|
||||
(design 9, Q181). It uses row 190's config canon and the model account the factory uses today (Q185,
|
||||
mos-1081 item 1). No new provider account.
|
||||
|
||||
## Provision
|
||||
|
||||
- ops-01 runs the root steps over ssh as `localadmin`: `useradd mosaic`, `/srv/mosaic` and the unit through
|
||||
`case2-unit.sh` from a root-owned checkout. #423 step 1 (localadmin with NOPASSWD sudo on .53 and .54) was
|
||||
applied at 20:05:47Z (mos-1066, mos-1077), and nothing else in #423 gates this row.
|
||||
- #423 step 4 (jarvis sudo) waits until Case 1 is live on .53.
|
||||
- The host list comes from Q181. Agents may run read-only checks on .53 as `localadmin`.
|
||||
|
||||
## The design must state
|
||||
|
||||
1. The install as input values to C2-13's scripts: user, home, business file, roles, the human id, the
|
||||
token slot path. Which commands run as `mosaic` through `sudo -u mosaic -H`, and which as root through
|
||||
ops-01.
|
||||
2. Where Case 1 differs from Case 2: the factory roles in place of the library business, no library sync,
|
||||
and any precheck item that doesn't apply, each with a reason.
|
||||
3. Model access through the existing factory account (Q185). Placing the credential on .53 is a step under
|
||||
the Q173 secrets rule, done in the window. It is never copied by the script, and its value is never
|
||||
printed.
|
||||
4. Re-pin, rollback and backup on .53, and who runs them.
|
||||
5. The acceptance checks, as code Duckworth can read. Each check names a path and gives it one of three
|
||||
marks: closed, accepted under Q182 or Q183, or "not named in Q182 or Q183". An accepted path doesn't
|
||||
fail the row. An unnamed one is reported to Mos, who takes it to Jason (the R8 N1 rule). The functional
|
||||
checks fail the row when they fail: the unit runs as `mosaic`, the Console answers on its port on the
|
||||
internal network only, and one tester message gets a reply.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Infra and k3s changes, exposure outside the internal network, and spend stay Jason's (Q173 exclusions).
|
||||
- No change to dragon-lin's `~/.mosaic-dev`, `~/.config/mosaic-dev`, its live units or `~/.mosaic`.
|
||||
- No credential value is printed, logged or committed.
|
||||
- Tests use scratch roots and stand-ins. Nothing in a test reaches .53.
|
||||
- The run on .53 is a window with Jason's or Mos's go, a backup first where state exists, and a STATE report
|
||||
after.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, then the manifest. Any repository change is green on Sage's gate (cli suite and
|
||||
every `scripts/test-*.sh`), with the design's mutants killed. The row is done when the window's acceptance
|
||||
run passes on .53, Duckworth reads its output, and Mos confirms.
|
||||
@@ -1,62 +0,0 @@
|
||||
# Brief: the human boundary across uids and humans (P19 333)
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-0989 (P19 333, MOSAICSTA-173, is now on the 10-27 path: federation R3 and
|
||||
row 193 need it). Gitea #1694. Owner Gander (Codex gpt-6.1-sol, a new builder seat under mos-0977 item 5),
|
||||
reviewer Duckworth, gate owner Sage. Latest-by: DESIGN.md approved by 2026-10-22, landed by 2026-10-24, and in
|
||||
any case before federation R3 lands. R3 and row 193 may chain on this row's candidate (Q177). Base: refactor at
|
||||
the brief commit. Design first. The first deliverable is `agents/gander/work/queue-ROW/DESIGN.md`.
|
||||
|
||||
## The problem, as filed and as it now matters
|
||||
|
||||
P19 333 as filed (row 87, mos-0657): the Console listens on a loopback TCP port, and any host process that
|
||||
reaches it, under any uid, acts as Jason. Jason accepted that as a documented limit for a one-user host
|
||||
(mos-0787, row 161). Two things change that:
|
||||
|
||||
- Row 190b (#1693, mos-0986) puts Melanie's hub under its own Linux user. Factory processes then sit on another
|
||||
uid, on the same host, next to the hub's ports and socket. The other-uid reach is now the Case 2 threat.
|
||||
- Federation r2 (gizmoduck-0004, 60b9b112a) binds a bus user only from a user-cli whose proof passes the human
|
||||
walk, whose direct parent is the bus's own Console, and which holds a live grant. That rule rests on the
|
||||
path-bound human proof (#225): today a same-uid process that runs the tree's `human-cli.mjs` acts as the
|
||||
human, without touching the Console. The Q175 design (section 8) names that limit and leaves it here.
|
||||
|
||||
## What already settles part of it
|
||||
|
||||
- Q175 phase 2 (#412) settles the Console-ingress part on the main port. Under `--sso`, 7332 answers any
|
||||
non-public Host with 403 except `GET /healthz`, and every other request needs a verified OIDC session. A
|
||||
process on another uid that reaches the port gets only `/healthz`. Phase 1 (row 192) builds it; it is live
|
||||
only after the phase 2 window.
|
||||
- Row 189 (`--no-send`) is interim only. Until phase 2 the Console takes no human writes, so the reach is
|
||||
read-only. It settles nothing.
|
||||
- Row 190b separates files and units by uid. Whether the hub's `broker.sock` refuses the factory uid depends on
|
||||
its directory and socket modes, which this row checks and tests rather than assumes.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. Inventory every listener and entry point per instance: the Console port, the tester listener (7333 today),
|
||||
`broker.sock`, the human CLI wrapper, the Discord connector and anything row 190 adds. For each: who can
|
||||
reach it today from the same uid and from another uid, with and without `--sso`.
|
||||
2. The tester listener. It serves reads and the row 175 tester writes to anything that reaches it, on any uid.
|
||||
Close the other-uid reach (for example a unix socket with 0600 modes, or a peer-uid check on loopback), or
|
||||
bring Mos a concrete reason it must stay open. bus.tester stays Jason's switch.
|
||||
3. The bus human boundary. How the bus tells the human's own verified call from a same-uid process that runs
|
||||
`human-cli.mjs`. State what federation R3's direct-parent rule needs from the bus (peer credentials on
|
||||
`broker.sock`, the parent check, how it can't be forged by a child of an agent), and what stays possible for
|
||||
a same-uid process after this row. Say it plainly where a residual limit remains.
|
||||
4. Fit with row 193 (Morgana, human users and roles; Mos's B1 rule: a principal comes only from something the
|
||||
bus verifies) and with federation R3. One mechanism, not three.
|
||||
5. Migration: the live single-user Console and bus keep working until each instance re-pins.
|
||||
6. Which parts of P19 333 this row closes, which parts Q175 phase 2 closes, and what P19 333 says when it is
|
||||
closed.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Who can reach the Console from outside stays Jason's. This row changes what a host process can do once it
|
||||
reaches a local port or socket.
|
||||
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file. No test creates a real
|
||||
second uid; use stand-ins as rows 190 and 190b do, and name any part only a Q173 window can prove.
|
||||
- No request to the live 127.0.0.1:7332 or 7333, mosaic.woltje.com or auth.diversecanvas.com.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, then the manifest. The bus, webui and cli suites and every `scripts/test-*.sh`
|
||||
are green on Sage's gate, and the design's mutants are killed.
|
||||
@@ -1,70 +0,0 @@
|
||||
# Row 193 build rows: 193a, 193b1, 193b2, 193c and 193d (#1691)
|
||||
|
||||
Sage, 2026-10-20, for Mos's go mos-1034 item 4. Row 193's design is Morgana's
|
||||
`agents/morgana/work/queue-193/DESIGN.md` at e2d161e2e on `morgana/row193` (r5, sha256
|
||||
dd7fcb419ed8510e9d8d74b569183386d0942af8b68eecc0bbb5e182e677b523), approved by Duckworth (duckworth-0018).
|
||||
Section 8 lists the tests and mutants, and section 9 the split. The brief behind them is
|
||||
`docs/plans/2026-10-20_human-users.md`. Vikunja P19 #414/#415, Gitea #1691.
|
||||
|
||||
Owner Morgana for every row, reviewer Duckworth, gate owner Sage. Each row chains on its parent's candidate
|
||||
(Q177) and rebases onto the parent's landing. Seats push only their own `<seat>/*` branches (mos-0981).
|
||||
Single test files run freely, and suites and mutant batches need Sage's slot.
|
||||
|
||||
Boundaries for every row: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, any
|
||||
live access.json or `~/.mosaic`. No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
|
||||
No user thread goes live before 193b1's message filter has landed (mos-1021 B2, FED R8).
|
||||
|
||||
The owner-visible records are fixed by Mos (mos-1023 item 2) and named in one user sentence, which FED R6
|
||||
reuses word for word: "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks
|
||||
and project files, but not your messages."
|
||||
|
||||
## Brief: 193a, the access level check widened to viewer
|
||||
|
||||
Chained on FED R1. Started early on `morgana/row193a` (508ee4d61) against R1's interface as the FED brief
|
||||
names it, with Sage's go (sage-1373). R1's level check accepts `viewer` and still refuses any other new
|
||||
level. Tests in `packages/business/tests/` (section 8, Business), with the four mutants in Morgana's
|
||||
BUILD.md. FED R4 chains on this row.
|
||||
|
||||
Duckworth's N1 on R1 (duckworth-0033, sage-1417): with viewer added, `covers` called with no level would let a
|
||||
viewer grant cover a collaborator ask. This row makes `level` required in `covers`, with a test and a mutant that
|
||||
makes it optional again. R3 and R4 call `covers` with a level.
|
||||
|
||||
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, and the mutants are killed.
|
||||
|
||||
## Brief: 193b1, viewer verbs, board refusals and the message filter
|
||||
|
||||
Chained on FED R3, FED R4 and row 196's verifier. The bus gets the viewer verb list, per-grant level
|
||||
checks, board reads refused to users, and the section 5.7 `trail` and `replies` filter with `#readable`
|
||||
owner-only. No schema change. The governance pin test names each item in the user sentence above. Bus tests
|
||||
and mutants 1, 3, 6, 10, 11 and 12 from section 8.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutants are killed.
|
||||
|
||||
## Brief: 193b2, thread shares (schema 3j)
|
||||
|
||||
Chained on 193b1. `thread_shares`, the share verbs, the share rules of `#readable` and `#threadRead`, and the
|
||||
section 5.5 share filters. Schema 3i to 3j. The table is append-only, and row 181's restore guard and
|
||||
`COUNTED` include it. Bus tests for shares and the migration, and mutants 2, 4, 7, 8 and 9 from section 8.
|
||||
Going live needs a `--migrate` window with Jason. Sage takes it to Mos when this row is near.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus suite, the cli suite and every `scripts/test-*.sh` are green on
|
||||
Sage's gate, and the mutants are killed.
|
||||
|
||||
## Brief: 193c, mosaic access user verbs and --level viewer
|
||||
|
||||
Chained on FED R5. `mosaic access user add/remove` and `grant --level viewer`, only where R5 lacks them. They
|
||||
refuse tester and agent callers, write a receipt line and call `access.reload` (section 8, CLI). If R5
|
||||
already has all of them, this row records that and closes with no code.
|
||||
|
||||
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutants are killed.
|
||||
|
||||
## Brief: 193d, the Console's shared list, share controls and viewer read-only
|
||||
|
||||
Chained on FED R6, row 192 and 193b2 (for share controls). A viewer has no composer or share control, and
|
||||
with each UI check removed the bus still refuses (section 8, Webui).
|
||||
|
||||
Gate: Duckworth approves the manifest. The webui suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, and the mutants are killed.
|
||||
@@ -1,56 +0,0 @@
|
||||
# Brief: many humans per business, human roles, private and shared threads
|
||||
|
||||
Sage, 2026-10-20, for mos-0974 and mos-0977 (MVP, DECISIONS Q176, Vikunja #414 "RBAC enforcement" and #415
|
||||
Case 1 "Additional users can be configured for access" and "Shared and private agent sessions available per
|
||||
user; RBAC"). Gitea #1691. T1 item 3. Owner Morgana, reviewer Duckworth, gate owner Sage. Base: refactor at the brief
|
||||
commit.
|
||||
|
||||
Design first. The first deliverable is `agents/morgana/work/queue-193/DESIGN.md`. Duckworth reviews it before
|
||||
any code.
|
||||
|
||||
## What exists today (GAP-MAP.md, measured at 9ae8c73ac)
|
||||
|
||||
- One human per business: `packages/business/src/business.mjs:160`, and the broker's human check
|
||||
(`packages/bus/src/broker.mjs:532-538`).
|
||||
- Threads have an owner field only (`packages/bus/schema.sql:247`, `broker.mjs:353-360`). Nothing is private
|
||||
or shared.
|
||||
- RBAC covers agents only (`role.mjs`, `vocabulary.mjs`, `broker.mjs:769-799`, harness `gate.mjs`). Humans
|
||||
have no roles.
|
||||
- The human proof is path-bound (#225, P19 333): any same-uid process that runs the tree's `human-cli.mjs` acts
|
||||
as the human. That limit is real and the design must say how several humans change it.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. The human model. How a business lists several humans, how each is identified, and how a Console SSO
|
||||
session (#412, row 192: each session names a provider subject mapped to a name) and a CLI call each
|
||||
become exactly one of them. Starting rule (Mos, mos-0985, from Duckworth's federation review B1 at
|
||||
b402197ff on origin duckworth/reviews): the bus takes a human principal only from something it verifies,
|
||||
such as the Console's OIDC session under #412. A process's own claim of who it is never counts.
|
||||
2. Human roles. A small fixed set to start (for example owner, member, viewer), what each may do on the
|
||||
bus and in the Console, and who may add or remove humans and change roles. Restructuring stays with
|
||||
the owner (Case 1).
|
||||
3. Threads. Private threads (visible to their owner and the agents in them) and shared threads (visible to
|
||||
named humans or all members). Schema change, migration of existing rows (all become the current human's),
|
||||
and every read path that must filter.
|
||||
4. One user and grant store. Gizmoduck's federation design (thread 9312a896, design 6925f78f0 on
|
||||
gizmoduck/federation-design) proposes `access.json` for grants. Mos's reading (mos-0977): one model for
|
||||
local and federated humans, because two stores drift. Lead decision (Sage, 2026-10-20, to gizmoduck-0002):
|
||||
row 193 takes Gizmoduck's human-authored `<configDir>/access.json` (federation DESIGN.md section 3.5) as
|
||||
the one user and grant store, and extends it with local humans, roles and shared threads. Federation adds
|
||||
only a `home` field. The design confirms this or brings a concrete reason against it to Sage and
|
||||
Gizmoduck before the build. Federation's R1 (the access.json loader) then comes before this row's build.
|
||||
5. The bus human boundary. With several humans, the human verbs must know which human calls. State what a
|
||||
same-uid process can still do (P19 333) and whether row 190's one-instance-per-OS-user direction narrows
|
||||
it.
|
||||
6. Migration and rollback. Existing businesses keep working with their one human as owner.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Changing who can reach the Console from outside is Jason's. This row changes who is allowed once inside.
|
||||
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file.
|
||||
- The build may chain on rows 190 and 192 candidates (Q177). Each row keeps its own review and gate.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, then the manifest. The bus, business, cli and webui suites and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
|
||||
@@ -1,65 +0,0 @@
|
||||
# Brief: one kill switch for the bus, every run, Discord and new launches
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-0974 (MVP, DECISIONS Q176, Vikunja #414 "Kill switch is available to
|
||||
stop all processing immediately"). Gitea #1689. T1 item 4. Owner Rocko, reviewer Dewey, gate owner Sage. Base: refactor
|
||||
at the brief commit.
|
||||
|
||||
Design first. The first deliverable is `agents/rocko/work/queue-191/DESIGN.md`, which Dewey reviews before
|
||||
any code. Then the build follows the design, with tests and mutants.
|
||||
|
||||
## The goal
|
||||
|
||||
One human command and one Console button stop all processing at once and keep it stopped until the human
|
||||
clears it:
|
||||
|
||||
- The bus takes no new work. Agent verbs that start or continue work refuse with a fixed code while the
|
||||
switch is on. Reads and the human's own verbs, including the clear, still work.
|
||||
- Every running managed session and task run is stopped (SIGTERM, then SIGKILL after a bounded wait), and
|
||||
each stop is recorded as an event.
|
||||
- Discord stops (its existing STOP file and `stop` verb) and can't restart while the switch is on.
|
||||
- New launches refuse: the launcher, chief hire, `run-task.sh`, and any other path that starts an agent.
|
||||
- The switch survives a bus restart and a host reboot (it lives in the data root, not in memory).
|
||||
- Turning it on and clearing it are human-only verbs, through the bus human proof, and each leaves one
|
||||
event with who and when.
|
||||
|
||||
## What exists today (GAP-MAP.md G10, measured at 9ae8c73ac)
|
||||
|
||||
These are pieces, with no single switch over them. The design inventories every path that starts or
|
||||
continues work and says how each one checks the switch:
|
||||
|
||||
- `launch.revoke` and `launch.restore` in `packages/bus/src/broker.mjs` (around :2087), human-only.
|
||||
- The `mosaic launches off|on` verbs in `packages/cli/src/cli.mjs` (around :456-533) and
|
||||
`packages/cli/src/launcher.mjs` (around :769).
|
||||
- The Discord STOP file (`packages/discord/src/cli.mjs:18-27`).
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. Where the switch state lives, its format, and how each process reads it (the broker checks on every
|
||||
work verb; the launcher and Discord check before start). The race: a launch already past its check when
|
||||
the switch goes on must still be stopped.
|
||||
2. The CLI verb names (for example `mosaic halt` and `mosaic resume`) and the Console button. The button
|
||||
is a human write, so it goes through the same writer and same-origin checks as other Console writes, and
|
||||
it works under the Q175 SSO session (#412) once phase 2 lands. Until then, test it with fixture servers
|
||||
only.
|
||||
3. Per instance. Row 190 adds several instances per host. The switch is per instance, so it stops one
|
||||
instance and never another. The design names how it finds the instance's data root. This row may build
|
||||
on the row 190 candidate (Q177 merge-chaining).
|
||||
4. What a stopped run's record says, so run evidence stays write-once and honest.
|
||||
|
||||
## Tests
|
||||
|
||||
Each part of the goal gets a test: work verbs refuse, a running stand-in session and run are stopped,
|
||||
Discord refuses to start, every launch path refuses, the switch survives a restart, an agent can't set or
|
||||
clear it, and the clear lets work resume. Mutants: drop the check from one launch path, let an agent clear
|
||||
the switch, keep the switch in memory only, skip the SIGKILL after the wait. Each one must fail a test.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No test touches `~/.mosaic-dev`, the live units, a real Discord bot or a slot file. Use the stand-in
|
||||
units, scratch data roots and fixture servers the suites already build.
|
||||
- Rocko has no git credentials. Sage commits the candidate and runs the queue moves Rocko can't.
|
||||
|
||||
## Gate
|
||||
|
||||
Dewey approves DESIGN.md, then the manifest. The cli suite, the bus, discord and webui package suites and
|
||||
every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
|
||||
@@ -1,70 +0,0 @@
|
||||
# Brief: several Mosaic Stack instances on one host
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-0974 (MVP, DECISIONS Q176-Q178, Vikunja #414). Gitea #1688. T1 item 1. Owner
|
||||
Darkwing, reviewer Filbert, gate owner Sage. Base: refactor at the brief commit. This row blocks both MVP
|
||||
cases: Case 1 (Jason's software factory) and Case 2 (Melanie's creative writing hub) run as two instances
|
||||
on one homelab host, beside the current mosaic-stack test instance.
|
||||
|
||||
Design first. The first deliverable is `agents/darkwing/work/queue-190/DESIGN.md`. Filbert reviews it
|
||||
before any code, as Q175 did. Then the build follows the design, with tests and mutants.
|
||||
|
||||
## The goal
|
||||
|
||||
Two or more instances run side by side on dragon-lin. Each one has its own config file, data root (store,
|
||||
broker.sock, host.lock, state, runs, sessions, workspaces), businesses, bus unit, Console unit and port,
|
||||
tester port, Discord unit, Claude session env file, stack-active pointer and activation log. No instance's
|
||||
commands, units or tools address another instance's state by default or by mistake. On one uid this is
|
||||
addressing only, not a security boundary: a same-uid process can still read another instance's files.
|
||||
Isolation against that is row 190b (#1693, mos-0986), one Linux user per instance. The current instance keeps working unchanged: its
|
||||
config path, data root, unit names and ports stay as they are, and no re-pin is needed for it.
|
||||
|
||||
## Known single-instance points (GAP-MAP.md, measured at 9ae8c73ac)
|
||||
|
||||
The design starts with a full inventory. Grep every script and package, not only these:
|
||||
|
||||
- `packages/cli/systemd/mosaic-bus.service.in`: `mosaic-bus@<business>` sets no `MOSAIC_CONFIG` and reads the
|
||||
fixed `EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env`.
|
||||
- `packages/webui/systemd/mosaic-webui.service.in` and `scripts/webui-service.sh`: one `mosaic-webui.service`.
|
||||
- `packages/discord/systemd/[email protected]` and `scripts/discord-service.sh`.
|
||||
- `scripts/stack-copy.sh:8` hardcodes `BUSINESS=mosaic-stack`, and `:95` reads `mosaic-webui.service`.
|
||||
- `scripts/stack-release.sh`: unit names, the Console port and the tester port default, `COPIES` (:28), the
|
||||
pointer under `$DATA/state` (:1474), and `rehearse`.
|
||||
- `scripts/mosaic-config.mjs`, `scripts/bootstrap.sh`, `scripts/reset.sh`, `scripts/land-work.sh`,
|
||||
`packages/webui/src/cli.mjs`, `packages/business/src/{business,vars}.mjs`, and
|
||||
`packages/conversation/src/guard.mjs` each name the default config path or data root.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. Instance identity. How a name maps to its config file and how every tool picks the instance (an
|
||||
`--instance NAME` flag, `MOSAIC_CONFIG`, or both), and what happens with neither (the current default,
|
||||
unchanged). Name rules, and refusal of a name that collides.
|
||||
2. Config canon. AGENTS.md invariant 2 says `~/.config/mosaic-dev/config.json` is the sole system config,
|
||||
created only by `scripts/bootstrap.sh`. Several instances mean several config files. The design proposes
|
||||
the exact wording change and how bootstrap creates an instance config without overwriting one. Mos rules
|
||||
on the canon change before the build.
|
||||
3. Unit names and templates. The bus, Console and Discord units per instance, how `MOSAIC_CONFIG` and the
|
||||
Claude env file reach each unit, and how the current unit names stay valid for the default instance.
|
||||
4. Ports. Each instance's Console and tester ports, with a refusal when two instances claim the same port.
|
||||
5. Stack copies and the pointer. Whether copies are shared between instances (read-only trees, so sharing
|
||||
may be fine) and how the pointer, activation log, backups and `flags_drift` stay per instance.
|
||||
`stack-release.sh` and `stack-copy.sh` take the instance.
|
||||
6. Secrets. The per-instance Claude session env file and the secrets slot root, resolved from the instance's
|
||||
config dir (the Q175 design resolves its slot the same way: `<configDir>/secrets/mosaic-stack/<slot>`).
|
||||
7. Isolation proof. What the tests show: two instances started side by side on stand-in units and scratch
|
||||
roots, each reads only its own config and data root, a command aimed at one never touches the other, and
|
||||
the default instance's paths and unit names are byte-identical to today.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file. Use the stand-in
|
||||
systemctl, units and scratch roots that `packages/cli/tests/stack-release.test.mjs` already builds.
|
||||
- Standing up a real second instance on dragon-lin is a later window under Q173, with a backup first and a
|
||||
STATE report after. Exposing a new Console through the tunnel or Traefik changes who can reach it from
|
||||
outside, so it waits for Jason. SSO (Q175, #412) comes first for any instance a second user can reach.
|
||||
- Merge-chaining (Q177): rows that need instances (human users, kill switch, lifecycle) may build on this
|
||||
row's candidate. This row keeps its own review and gate.
|
||||
|
||||
## Gate
|
||||
|
||||
Filbert approves DESIGN.md, then the manifest. The cli suite and every `scripts/test-*.sh` are green on
|
||||
Sage's gate, and the design's mutants are killed.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Brief: retire the control board writer on 7331
|
||||
|
||||
Sage, 2026-10-20, for Mos's ruling mos-1008 item 2. Gitea #1696. MVP 10-27 work. Owner Bushroot (Haiku 5.5, a
|
||||
low-level model under Q177), reviewer Webby, gate owner Sage. Base: refactor at the brief commit.
|
||||
|
||||
## Why
|
||||
|
||||
`packages/control-board/src/serve.mjs` is a loopback HTTP service on 7331 with no authentication. It reads
|
||||
transcripts and writes through `/api/reply`. Any host process that reaches the port can do both. That fails
|
||||
Q175 ("zero trust with verification"). Gander found it in the row 196 inventory (gander-0004). Nothing listens
|
||||
on 7331 now (`ss -ltnp`, 2026-10-20T19:05:09Z), so retiring it costs the least.
|
||||
|
||||
## Steps, in order
|
||||
|
||||
1. Inventory first. List every reference to the control board and to 7331: scripts, units, `scripts/mosaic`
|
||||
and other CLI dispatch, docs (AGENTS.md, READMEs, TOOLS.md), package.json scripts, tests, and the Console
|
||||
(`packages/webui`). For each, say whether it starts the writer, reads its code, or only mentions it.
|
||||
Commit the list as `agents/bushroot/work/queue-ROW/INVENTORY.md` and send it to Sage and Webby.
|
||||
2. Stop if something depends on it. If a reference shows a live dependency (the Console or another package
|
||||
imports code that only the control board has, or a unit or script still starts it), stop. Sage takes it to
|
||||
Mos before anything is removed.
|
||||
3. Otherwise retire the writer. Either remove the `serve` entry point, or make it refuse to start with a
|
||||
message that points to the Console. Keep any read-only code another package imports, and say which.
|
||||
Update the docs that tell people to start it. The AGENTS.md text about the control-board reply convention
|
||||
is a separate matter. Change it only if it tells someone to start 7331.
|
||||
|
||||
## Mos's call on the Console dependency (mos-1019)
|
||||
|
||||
The inventory (004f133bd) found that the Console proxies `/api/board`, `/api/seen`, `/api/reply` and the
|
||||
conversation routes to `DEFAULT_BOARD` on 7331 (`packages/webui/src/serve.mjs:12`), so the board view returns
|
||||
502 today. Mos took Sage's recommendation: retire the 7331 listener and its README start commands, and have
|
||||
the Console call the board's read code in-process, behind the Console's own auth (Q175 SSO on 7332).
|
||||
`/api/reply` stays only under `--send`. The DESIGN.md for that change comes before code, and Webby approves
|
||||
it.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Nothing in the stack can start a writer on 7331. A test proves it: the entry point refuses (or is gone), no
|
||||
script or unit starts it, and a grep check finds no starter. Each change gets a mutant that brings the writer
|
||||
back and the test kills it. Mos added three items (mos-1019):
|
||||
|
||||
- The board view answers from the Console with nothing listening on 7331.
|
||||
- Every board route sits behind the Console's auth.
|
||||
- A test proves `/api/reply` refuses without `--send`.
|
||||
|
||||
Test fixtures that bind port 0 may stay if they only test the code in-process.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files or `~/.mosaic`.
|
||||
- No request to any live port.
|
||||
- Single test files run freely. Suites need Sage's slot.
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves INVENTORY.md, then DESIGN.md, then the manifest. The control-board and webui package suites, the cli suite and
|
||||
every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Brief: a second gate lane, two full gates at once on dragon-lin
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-0977 item 6 ("put the design for a second gate lane on the list now")
|
||||
and the MVP push (mos-0974). Gitea #1695. Owner Herb (Codex gpt-6.1-sol, a new builder seat), reviewer
|
||||
Webby, gate owner Sage. Base: refactor at the brief commit. Design first. The first deliverable is
|
||||
`agents/herb/work/queue-ROW/DESIGN.md`.
|
||||
|
||||
## Why
|
||||
|
||||
Every full run shares one slot today. A full gate (cli suite plus every `scripts/test-*.sh`) takes about 13
|
||||
minutes or more, reviewers' runs and mutants take the same slot, and with eight or more seats building for
|
||||
10-27 the slot is the bottleneck. The slot exists because concurrent runs broke each other. The busy check is
|
||||
`agents/sage/work/gate-lane/busy-now.sh` (a copy of Sage's), and the gate is
|
||||
`agents/sage/work/gate-lane/gate-template.sh` (a copy of Sage's template). The host has 16 cores and 62 GB.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. Every shared resource two concurrent runs can collide on, found from the code and tests, not guessed:
|
||||
Docker compose project names and networks (`scripts/test-task.sh` and others), fixed TCP ports in tests,
|
||||
unix socket paths and their 107-byte limit under TMPDIR, the sandbox's private `/tmp`, shared caches,
|
||||
scratch and worktree paths, the queue lock, and any test that reads host-wide state (process lists,
|
||||
`pgrep`, `/proc`). For each: how a lane gets its own copy, or why it is safe to share.
|
||||
2. Timing. Which tests have time limits that a doubled CPU load could break, measured, and what changes (a
|
||||
bound, or a lane rule that keeps those suites apart).
|
||||
3. The lane mechanism: lane ids, a lock per lane that a run takes and releases even on a crash, the busy
|
||||
check that reports per lane, and how a seat asks for a lane. Sage still grants it.
|
||||
4. The gate template takes a lane id and isolates everything from point 1 by it.
|
||||
5. Proof: two full gates on different candidates at once, three times, with zero cross-failures, plus the
|
||||
wall time of each against a single-lane run. Any flake found is named with its cause.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev` or slot files. The proof runs use
|
||||
scratch roots only, and it needs Sage's grant of the whole slot while it runs.
|
||||
- Test code changes that only isolate a resource are in scope. A change to what a test checks is not, and
|
||||
goes to its owner.
|
||||
- Docker networks and containers a run makes are removed by the run (trap), and nothing else is touched.
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves DESIGN.md, then the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, both singly and in the two-lane proof.
|
||||
@@ -1,47 +0,0 @@
|
||||
# Brief: Q175 phase 1, the Console verifies SSO itself (--sso)
|
||||
|
||||
Sage, 2026-10-20, for Q175 (Jason's ruling), mos-0971/0973/0977 and Vikunja #412. Gitea #1690. MVP T1 item 2. Owner
|
||||
Launchpad, reviewer Webby (mos-0981), gate owner Sage. Base: refactor at the brief commit.
|
||||
|
||||
The design is approved: `agents/sage/work/q175-sso/DESIGN.md` at d55d86bb0 (blob eba861f5d2bb), Filbert r3
|
||||
approve plus the D1/D2 ack, Mos C1 confirmation in mos-0977, whose straggler-check condition is in the phase 2 window at b416d2ede. Build phase 1 exactly as the design says. If
|
||||
the code shows the design is wrong somewhere, stop and tell Sage, Webby and Filbert before building around it.
|
||||
|
||||
## Scope (phase 1 only, design section 5 item 1)
|
||||
|
||||
- oauth4webapi 3.8.8, pinned exactly (design section 2). No other new dependency.
|
||||
- The OIDC relying party in the Console: `/auth/login`, `/auth/callback`, `/auth/logout`, `/auth/signed-out`,
|
||||
the callback steps 1-7, our own checks on top of the library, and sessions, cookies and Host rules
|
||||
(design sections 1 and 3).
|
||||
- The shared loader, `scripts/mosaic-config.mjs`, accepts an optional `console` key and checks only its
|
||||
shape. The slot file checks and the read run in the Console's `--sso` start, and the slot resolves from
|
||||
the config dir (section 4, D1, D2).
|
||||
- The degraded start for an unreachable provider, and the unreachable/refused classes (Q-B, section 4).
|
||||
- `--sso` for `cli.mjs serve` and `webui-service.sh`, with its refusals against the old flags.
|
||||
- The tester listener rules (section 6).
|
||||
- The page: both fetch paths in `packages/webui/src/public/app.js` (:111 and :305) treat a 401
|
||||
`sign-in required` as signed out, and the notice links to `/auth/login?return=<path>` (C2).
|
||||
- Logging as the design says, including the Q-E `subject-unknown` line.
|
||||
|
||||
Out of scope: the phase 2 window, the live config.json key, phase 3 removals, the proxy-forwarded token
|
||||
(section 7), and any change to the live Console.
|
||||
|
||||
## Tests and mutants
|
||||
|
||||
Design section 9: tests 1-25, the positive paths, and every listed mutant, each killed by its named test.
|
||||
All tests run against the fake OP in `packages/webui/tests/helpers/fake-oidc.mjs`. No test reaches a real
|
||||
provider or Authentik, and no test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a
|
||||
slot file. Test 25 goes in `scripts/test-config.sh`.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No request of any kind to auth.diversecanvas.com, mosaic.woltje.com or the live 127.0.0.1:7332 (mos-0971,
|
||||
design section 8).
|
||||
- No credential value is read, printed or logged.
|
||||
- `npm install` runs only inside the worktree that has its own package.json, never in a directory that would
|
||||
walk up to home.
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves the manifest. Filbert approved the design and answers design questions. The webui and cli suites, `scripts/test-config.sh` and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
|
||||
@@ -1,39 +0,0 @@
|
||||
# Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite)
|
||||
|
||||
Sage, 2026-10-20, for Launchpad's finding F3 on row 192 (launchpad-0001) and Q175 (#412). Gitea #1698. Owner
|
||||
Launchpad, reviewer Webby, gate owner Sage. Chained on row 192's candidate (Q177): it lands after row 192 and
|
||||
before the phase 2 window. Base: row 192's candidate.
|
||||
|
||||
## Why
|
||||
|
||||
The phase 2 window re-pins the Console to the phase 1 commit with `--sso --send --tester-port 7333`
|
||||
(`agents/sage/work/q175-sso/DESIGN.md`, section 5). `scripts/stack-release.sh` doesn't know `--sso` today.
|
||||
Its `proof_checks` (`:751`) requires `--public-host`, `--auth-header` and `--allow-user` whenever a proof pair is
|
||||
carried, and the pointer's Console flags still hold the forward-auth pair (`--auth-header X-authentik-username
|
||||
--allow-user jason.woltje`) that `--sso` refuses to start with. So the window can't run through the script, and
|
||||
a hand-edited pointer is not an option.
|
||||
|
||||
## What the row does
|
||||
|
||||
1. `activate ID BUSINESS --sso` writes Console flags with `--sso` and without `--auth-header`, `--allow-user`
|
||||
and any proof pair, and `--plan` names each flag it drops and carries, in the style of row 189's `send:`
|
||||
line. The design names the full flag set the phase 2 Console starts with and checks it against row 192's
|
||||
refusals.
|
||||
2. Before anything stops, the script refuses a target copy whose Console doesn't support `--sso`, and refuses
|
||||
when the config's `console` key or its slot file fails the checks row 192's `--sso` start runs. It reads
|
||||
neither value and only uses the same stat and shape checks.
|
||||
3. Rollback from an `--sso` pointer to a pre-phase-1 copy follows the design's phase 2 rollback order (remove
|
||||
the `console` key first). The script refuses a rollback that would start a Console on a config it can't
|
||||
read, and says which step comes first.
|
||||
4. `docs/TOOLS.md` gets the flag.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No change to the live pointer, units, `~/.mosaic-dev` or `~/.config/mosaic-dev`. Tests use scratch roots
|
||||
and seeded pointers, as rows 187 and 189 do.
|
||||
- No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and the
|
||||
mutants are killed (one per refusal, and one that keeps `--auth-header` in the written flags).
|
||||
+1
-43
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
|
||||
<!-- mosaic-queue:begin -->
|
||||
|
||||
Generated from `docs/plans/queue.json` revision 2455 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 2236 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -225,48 +225,6 @@ Generated from `docs/plans/queue.json` revision 2455 by `queue render`. Do not e
|
||||
| 186 | stack-release --plan: the backup line says what the run will do (P19 404, row 185 N1/N2) | darkwing; reviewers filbert | #1684 | done | — | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the three brief mutants killed (sage) | `docs/plans/2026-10-20_plan-backup-line.md` § Brief: the activate plan's backup line says what the run will do | Landed 269134d8d (author Darkwing) on 3c5da3a91. Patch-id c97b9f6e25cd matches the r1 candidate (manifest 449e9121, Filbert approve 29419). Sage gate on b367f5730+patch 09:00:20Z-09:08:59Z: node 2681/0, scripts 422/0, queue 156, network removed; 3c5da3a91 adds only a SESSIONS line. Not live: the stack runs copy 490e2771d3d0. Follow-ups on P19 404: Filbert M6 gap, store-unclean reading. |
|
||||
| 187 | stack-release activate carries the row 178 proof pair (mos-0928 Part B A0) | darkwing; reviewers filbert | #1685 | done | 186 done | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-proof-pair.md` § Brief: activate carries the row 178 proof pair | Landed 9a71e4e80 (author Darkwing) on d17328a55; patch-id 6b57c47bc661 matches candidate 52a5ecc1d143 and Filbert's r1 reading. Sage gate passed 10:21:55Z: cli 446/446 and every package and scripts/test-*.sh green. Filbert approve 29424, rev 2204; follow-ups N1-N4 filed on P19. |
|
||||
| 188 | stack-release tests: a carried proof pair and a proof file inside the repository (P19 409, 410; gates Part B) | darkwing; reviewers filbert | #1686 | done | 187 settled | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_proof-test-gaps.md` § Brief: tests for the two proof-file paths row 187 left open | Landed 8fc0ee216 (author Darkwing) on 23a609c5c; patch-id c9a56b167c07 matches candidate 86aa549bad75 and Filbert's r1 reading; stack-release.sh blob 2586a2a37cd2 unchanged. Sage gate passed 10:58Z: cli 453/453 and every package and scripts/test-*.sh green. Filbert approve 29433, rev 2212; N1 (REPO logical path) to P19 after Part B (mos-0959). |
|
||||
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | done | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | Landed ace27dc5e on refactor (cherry-pick of candidate d6116771f onto a4a42dbac). Gate GATE_PASSED on 0c177e9ef (base 0217b71c8), 2026-10-20T19:09:37Z-19:20:36Z, 25 suites rc=0. patch-id ce89378718f6 for both 0c177e9ef and ace27dc5e; range-diff differs only in the message. Landed manifest hashes to 67af69bd (Dewey approve 29485). Base drift 0217b71c8..a4a42dbac: 7 doc/README files, none on manifest paths, no test reads them. Brief re-pinned to blob 29a9e6d2d. |
|
||||
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-review, round 2, request posted | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
|
||||
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | in-progress | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | — |
|
||||
| 192 | Q175 phase 1: the Console verifies SSO itself (--sso), OIDC RP with oauth4webapi 3.8.8, no live change (MVP T1.2, #412) | launchpad; reviewers webby | #1690 | done | — | Webby approves the manifest; webui and cli suites, scripts/test-config.sh and every scripts/test-*.sh green on Sage's gate; design section 9 mutants killed (sage) | `docs/plans/2026-10-20_sso-phase1.md` § Brief: Q175 phase 1, the Console verifies SSO itself (--sso) | Landed 4429a104f on refactor (squash of launchpad/row192 53bb59462..37582e820, round 2 manifest 6f8cee14...7787). patch-id 4a4869026 matches the reviewed diff on all 18 paths. Gate on 088087ac9: package suites 2758/0, scripts 429/0, mutants 22 of 23 killed (#23 equivalent, sage-1407). npm ci in canonical, oauth4webapi 3.8.8. Live re-pin is a window with Mos (mos-1099 item 3). |
|
||||
| 193 | many humans per business, human roles, private and shared threads, one access.json store (MVP T1.3, Vikunja #414/#415) | morgana; reviewers duckworth | #1691 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, business, cli and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users.md` § Brief: many humans per business, human roles, private and shared threads | — |
|
||||
| 194 | agent lifecycle from the Console and the CLI (create, launch, terminate, regenerate, delete) with a per-agent hire/fire veto (MVP T1.5, Vikunja #414/#415) | honker; reviewers webby | #1692 | in-progress | — | Webby approves DESIGN.md then the manifest; bus, cli, business and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto | — |
|
||||
| 195 | row 195: the Case 1 install on 10.1.1.53 as mosaic under /srv/mosaic, reusing C2-13's scripts by input under row 190's canon (Q181, Q182/Q183, Q185; ops-01 root steps under #423 step 1) | darkwing; reviewers duckworth | #1693 | briefed | 190 done | Duckworth approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Brief: the Case 1 install on 10.1.1.53 (row 195) | sage-1439: rewritten to Case 1 on .53 (f24f2cd40). Chains on C2-13's candidate too once C2-13 is queued. Darkwing starts DESIGN.md after row 190's mutants. |
|
||||
| 196 | the human boundary across uids and humans: Console, tester listener, broker.sock and the path-bound human proof (P19 333, before federation R3, mos-0989) | gander; reviewers duckworth | #1694 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-boundary.md` § Brief: the human boundary across uids and humans (P19 333) | — |
|
||||
| 197 | a second gate lane: two full gates at once on dragon-lin, design first (mos-0977 item 6) | herb; reviewers webby | #1695 | in-review, round 1, request posted | — | Webby approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate, singly and in the two-lane proof (sage) | `docs/plans/2026-10-20_second-gate-lane.md` § Brief: a second gate lane, two full gates at once on dragon-lin | Order with row 198 (Sage, mos-1040 N1): 197 lands first. After B1 the shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. Row 198 rebases onto 197's landing before its manifest review. |
|
||||
| 198 | retire the control board writer on 7331 (unauthenticated loopback writer, Q175; mos-1008) | bushroot; reviewers webby | #1696 | in-progress | 192 done, 197 done | Webby approves INVENTORY.md then the manifest; control-board, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_retire-control-board.md` § Brief: retire the control board writer on 7331 | Order with row 197 (Sage, mos-1040 N1): 197 lands first. Row 198 rebases onto 197's landing before its manifest review; shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. |
|
||||
| 199 | MVP T1 item 6: a credential store per agent, own git identity, pushes to a remote (G11, G13; mos-0974) | megavolt; reviewers duckworth | #1697 | in-progress | — | Duckworth approves DESIGN.md; Mos rules on the authority model; Duckworth approves the manifest; bus, business, cli and tasks suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: a credential store per agent, own git identity, pushes to a remote | — |
|
||||
| 200 | stack-release.sh activate --sso for the Q175 phase 2 window (row 192 F3; chained on 192) | launchpad; reviewers webby | #1698 | in-review, round 2, request posted | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite) | — |
|
||||
| 201 | FED R1: access.json loader and validator, and covers (#414) | gizmoduck; reviewers duckworth | #1699 | in-review, round 1, request posted | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R1, the access.json loader and validator | — |
|
||||
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | in-review, round 1, request posted | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
|
||||
| 203 | FED R2: the federation key in the system config (#414) | quackerjack; reviewers webby | #1701 | briefed | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R2, the federation key in the system config | Chained on row 192's candidate (Q177): starts on 192's candidate, lands after 192 (Sage) |
|
||||
| 204 | 193a: access level check widened to viewer (#1691) | morgana; reviewers duckworth | #1702 | in-review, round 1, request posted | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193a, the access level check widened to viewer | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
|
||||
| 205 | 193b1: viewer verbs, board refusals and the message filter (#1691; also after FED R3 and R4 when filed) | morgana; reviewers duckworth | #1703 | queued | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1, viewer verbs, board refusals and the message filter | — |
|
||||
| 206 | 193b2: thread shares, schema 3j (#1691; live needs a --migrate window with Jason) | morgana; reviewers duckworth | #1704 | queued | 205 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3j) | — |
|
||||
| 207 | 193c: mosaic access user verbs and --level viewer (#1691) | morgana; reviewers duckworth | #1705 | queued | 202 done | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193c, mosaic access user verbs and --level viewer | — |
|
||||
| 208 | 193d: Console shared list, share controls and viewer read-only (#1691; also after FED R6 when filed) | morgana; reviewers duckworth | #1706 | queued | 192 done, 206 done | Duckworth approves the manifest; webui suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193d, the Console's shared list, share controls and viewer read-only | — |
|
||||
| 209 | FED R8: the hub window on 10.1.1.54, a recorded acceptance (#414, r4.11 b93f098f0; live window run by Jason and Mos, after the Case 2 install #416, Bean and Chet live and both Hermes gateways stopped) | sage; reviewers duckworth | — | queued | — | Every entry check's output is in the R8 record with who ran it; Jason signs in to the hub and sends one message, Melanie revokes, his next request is refused; Duckworth checks the record (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R8, the hub window on 10.1.1.54 | Stays queued until its entry gates near: Case 2 install on .54 (#416, by 2026-10-27), Bean and Chet live, both Hermes gateways on .54 stopped, R7, #412 phase 2, #419 answer. Filed per mos-1073 after duckworth-0034 (Sage) |
|
||||
| 210 | Agent host access with sudo through the credential store: grant per agent and host, the stack does ssh and sudo, the agent never sees the key (#1707, Vikunja #424, Q183/Q184, due 10-27; chained on row 199's candidate) | gizmoduck; reviewers duckworth | #1707 | in-progress | — | Duckworth approves DESIGN.md, then the manifest; bus, business, cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_host-credentials.md` § Brief: agent host access with sudo through the credential store (#424) | Design runs before 199 lands (Q177 chain on 199's candidate). after cleared so Gizmoduck can claim; lands only after 199 and rebases onto it. |
|
||||
| 211 | Force-push guard: scripts/git-hooks/pre-push refuses non-fast-forward and delete pushes from every worktree, and scripts/slot-busy.sh exits 1 on BUSY (#1709, Vikunja #426, mos-1078/1083) | rocko; reviewers dewey | #1709 | briefed | — | Dewey approves the manifest; the new or changed scripts/test-*.sh and every other scripts/test-*.sh green on Sage's gate; the mutants killed; hook installed in the canonical checkout with a scripted refused force-push (sage) | `docs/plans/2026-10-20_force-push-guard.md` § Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426) | — |
|
||||
| 212 | Case 2 C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | gosalyn; reviewers webby | #1713 | in-progress | — | Webby approves the manifest. The library tests and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | — |
|
||||
| 213 | Case 2 C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | morgana; reviewers webby | #1714 | in-progress | — | Webby approves the manifest. The business, bus and harness suites, `scripts/test-config.sh` and every `scripts/test-*.sh` are green on Sage's gate, and the `models` mutant is killed. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | — |
|
||||
| 214 | Case 2 C2-11a editor wikilink node, after measuring whether [[ round-trips | rocko; reviewers filbert | #1715 | briefed | — | Filbert approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11a editor wikilink node, after measuring whether [[ round-trips | — |
|
||||
| 215 | Case 2 C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | herb; reviewers filbert | #1716 | briefed | — | Filbert approves the manifest. The business, seat, cli, project, bus and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | — |
|
||||
| 216 | Case 2 C2-05a lookup index and queries | researcher; reviewers filbert | #1717 | briefed | — | Filbert approves the manifest. The library suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05a lookup index and queries | — |
|
||||
| 217 | Case 2 C2-06 the cited-reply guard, the page store and the three notices | launchpad; reviewers webby | #1718 | briefed | — | Webby approves the manifest. The library, bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-06 the cited-reply guard, the page store and the three notices | — |
|
||||
| 218 | Case 2 C2-12 the setup sheet for Melanie and the survey run sheet | sage; reviewers webby | #1719 | briefed | — | Doc only. Webby approves it against C2-01's real flags. Before landing, grep `packages/*/tests` and `scripts/test-*.sh` to confirm no test reads either file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12 the setup sheet for Melanie and the survey run sheet | — |
|
||||
| 219 | Case 2 C2-09a one thread per creative instance | rocko; reviewers dewey | #1720 | briefed | — | Dewey approves the manifest. The bus, cli and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. Watch for hash pins on `bus.js` and `broker.mjs` (`packages/bus/tests/channel-proof.test.mjs` pins a webui file). (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09a one thread per creative instance | — |
|
||||
| 220 | Case 2 C2-10a no raw session view (M7) and no raw Discord engine (M8) | researcher; reviewers filbert | #1721 | briefed | — | Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10a no raw session view (M7) and no raw Discord engine (M8) | — |
|
||||
| 221 | Case 2 C2-20 Bean and Chet persona import, with a count-only dry run | gosalyn; reviewers webby | #1722 | briefed | — | Webby approves the manifest. The test file runs green on its own and the cli suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20 Bean and Chet persona import, with a count-only dry run | — |
|
||||
| 222 | Case 2 C2-03a fetch-only sync | megavolt; reviewers dewey | #1723 | briefed | — | Dewey approves the manifest. The library and bus suites are green on Sage's gate, along with every `scripts/test-*.sh`, and the mutant is killed. A search of the test run's output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03a fetch-only sync | — |
|
||||
| 223 | Case 2 C2-07 runner retry on a guard refusal, and the runner's notices | researcher; reviewers filbert | #1724 | briefed | — | Filbert approves the manifest. The harness suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-07 runner retry on a guard refusal, and the runner's notices | — |
|
||||
| 224 | Case 2 C2-05b broker lookup verbs and typed tools | rocko; reviewers dewey | #1725 | briefed | — | Dewey approves the manifest. The bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05b broker lookup verbs and typed tools | — |
|
||||
| 225 | Case 2 C2-10b Console chat: cited items and the status line | researcher; reviewers dewey | #1726 | briefed | — | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. Grep for hash pins on `bus.js` and `serve.mjs` before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10b Console chat: cited items and the status line | — |
|
||||
| 226 | Case 2 C2-13 install script and precheck for the host | gosalyn; reviewers webby | #1727 | briefed | — | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. The run on the host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13b install script and library precheck | — |
|
||||
| 227 | Case 2 C2-14 end-to-end acceptance on a scratch root | launchpad; reviewers dewey | #1728 | briefed | — | Dewey runs the script and approves. It is green on Sage's gate with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-14 end-to-end acceptance on a scratch root | — |
|
||||
| 228 | Row A: ZAI_API_KEY passes through ENV_ALLOW to sessions, README line, pass-through test (XS, Haiku high) | researcher; reviewers webby | #1729 | in-review, round 1, request posted | — | Webby approves the manifest. The mutant is killed. The seat suite and the cli suite are green on Sage's gate with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row A, ZAI_API_KEY in the session env allowlist | — |
|
||||
| 229 | Row B: per-instance pi agent dir for Chet's Codex login, design only, starts after 10-27 (#428) | gosalyn; reviewers webby | #1730 | queued | — | A live check, with Jason's or Mos's go, that two clients on one ChatGPT login don't revoke each other's refresh tokens. No build row starts before it passes. Held until after 10-27. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row B, a per-instance pi agent dir for Chet's Codex login (#428) | — |
|
||||
| 230 | Case 2 C2-13a unit render and host precheck (case2-unit.sh, case2-precheck.sh) | gosalyn; reviewers webby | #1732 | briefed | — | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every scripts/test-*.sh. A run on a host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13a unit render and host precheck | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+1
-8250
File diff suppressed because it is too large
Load Diff
Generated
+1
-11
@@ -9,8 +9,7 @@
|
||||
"version": "0.0.3",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
"oauth4webapi": "3.8.8"
|
||||
"@earendil-works/pi-coding-agent": "0.85.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
@@ -1934,15 +1933,6 @@
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
"version": "3.8.8",
|
||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz",
|
||||
"integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "6.40.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz",
|
||||
|
||||
+1
-2
@@ -5,7 +5,6 @@
|
||||
"description": "Minimal Mosaic Stack container proof of concept: one Pi agent, four local contract files, one real model request returning MOSAIC_HELLO_OK.",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
"oauth4webapi": "3.8.8"
|
||||
"@earendil-works/pi-coding-agent": "0.85.1"
|
||||
}
|
||||
}
|
||||
|
||||
+45
-494
@@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path';
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, unhireableAction } from '../../business/src/vocabulary.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -29,9 +29,6 @@ export const ACTIONS = Object.freeze([
|
||||
'organization.change',
|
||||
// Row 141 (#1635): routed under PRD 1.5 REQ-MSG-2.
|
||||
'instance.name',
|
||||
// Row 194 (#1692): routed under PRD 1.7 REQ-COS-3, below.
|
||||
'role.hire',
|
||||
'role.fire',
|
||||
'credential.mint',
|
||||
'spend',
|
||||
'deploy',
|
||||
@@ -67,9 +64,6 @@ const ROUTED = new Set([
|
||||
'policy.change',
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
|
||||
'role.hire',
|
||||
'role.fire',
|
||||
]);
|
||||
// REQ-WAIT-1's hard waits: always gated, and a raise for one goes to the human, cited or not.
|
||||
export const HARD_WAITS = Object.freeze(ACTIONS.filter((action) => !ROUTED.has(action)));
|
||||
@@ -103,42 +97,10 @@ export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task
|
||||
// Row 177 (#1675, Q173): the tester's own threads, owner `tester`, and a stop of their runs.
|
||||
export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies',
|
||||
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials',
|
||||
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
|
||||
// Row 194 (#1692, D4.1): launch and terminate, and the read of the requests they make.
|
||||
'instance.launch', 'instance.end', 'instance.request']);
|
||||
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop']);
|
||||
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
|
||||
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
|
||||
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
|
||||
// Row 194 (#1692, D4): the human lifecycle verbs, one entry per verb and instances.set op. Each calls
|
||||
// #mayHuman after #human, and the hook refuses a verb missing here. Row 193 maps each entry to the
|
||||
// human roles that may use it.
|
||||
export const LIFECYCLE_HUMAN_VERBS = Object.freeze(['instance.launch', 'instance.end', 'instance.regenerate', 'veto.set',
|
||||
'instances.set add', 'instances.set change', 'instances.set delete']);
|
||||
// Row 194 (D1.1): the requests the launcher carries out, and the events that move each one. All are
|
||||
// action.allowed events, which event.emit refuses for any action but routine.
|
||||
const LIFECYCLE_REQUESTS = ['lifecycle.launch', 'lifecycle.regenerate', 'lifecycle.delete', 'lifecycle.end'];
|
||||
const LIFECYCLE_STEPS = ['lifecycle.taken', 'lifecycle.done', 'lifecycle.cancelled'];
|
||||
// A launch, regenerate or delete request older than this never runs. A terminate's signal has no
|
||||
// limit: the launcher checks each run's start time before it signals.
|
||||
export const LIFECYCLE_REQUEST_MS = 60_000;
|
||||
const LIFECYCLE_CODE = /^[a-z-]{1,64}$/;
|
||||
// Row 194 (#1692, D2.1, D2.2, PRD 1.7 REQ-COS-3): the chief's hire and fire arguments, allowed then
|
||||
// required. Each carries the reason the log records.
|
||||
const HIRE_FIRE_ARGS = Object.freeze({
|
||||
'role.hire': [['name', 'definition', 'vars', 'reason'], ['name', 'definition', 'reason']],
|
||||
'role.fire': [['name', 'reason'], ['name', 'reason']],
|
||||
});
|
||||
// N3: hire's vars hold only the model key and limits.* keys. The writer checks the values.
|
||||
const HIRE_VAR = (key) => key === 'model' || key.startsWith('limits.');
|
||||
// What instance.request and the host ops answer about a request.
|
||||
const lifecycleView = (r) => ({
|
||||
request: r.request, action: r.action, instance: r.instance, by: r.by, at: r.at, state: r.state,
|
||||
...('revision' in r ? { revision: r.revision, retry: r.retry === true } : {}),
|
||||
...('runs' in r ? { runs: r.runs } : {}),
|
||||
...(r.kill === true ? { kill: true } : {}),
|
||||
done: r.done ? { run: r.done.run ?? null, transaction: r.done.transaction ?? null, refusal: r.done.refusal ?? null } : null,
|
||||
cancelled: r.cancelled ? { by: r.cancelled.by } : null,
|
||||
});
|
||||
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
|
||||
const POLL_KINDS = new Set([
|
||||
'task.changed.external',
|
||||
@@ -183,7 +145,6 @@ const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test
|
||||
export const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances', 'reset', 'assistants'];
|
||||
// Row 165 (#1662): the keys of the human-only instances.set (business instance-settings.mjs).
|
||||
const INSTANCE_KEYS = ['revision', 'instance', 'op', 'definition', 'vars', 'launch'];
|
||||
const VETO_KEYS = ['revision', 'op', 'name'];
|
||||
// Row 123 (#1617): role.end never reaches the caller's own run. END_PROTECTED (vocabulary.mjs, row
|
||||
// 155): only the chief of staff, or a decision the human resolved through the CLI, ends or revokes
|
||||
// an instance of one (#protectedEnd, Q169). A raise to revoke one is a hard wait.
|
||||
@@ -219,36 +180,6 @@ export const THREAD_PAGE_MAX = 200;
|
||||
// A project or thread name: what the user calls it, one line.
|
||||
const name = (x) => text(x, 200) && !/[\r\n]/.test(x);
|
||||
|
||||
// The checks a business document passes at boot and at every reload.
|
||||
function checkBusiness(name, b) {
|
||||
// Row 177: a human named `tester` would share the tester's threads.
|
||||
if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters))
|
||||
fail('invalid-business');
|
||||
for (const role of Object.keys(b.roles)) {
|
||||
if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business');
|
||||
const a = b.roles[role].authority;
|
||||
if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole))
|
||||
fail('invalid-authority');
|
||||
const all = [...a.withinRole, ...a.crossRole];
|
||||
if (new Set(all).size !== all.length || all.some((v) => !ACTIONS.includes(v) || GATED.has(v)))
|
||||
fail('invalid-authority');
|
||||
}
|
||||
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
|
||||
fail('invalid-arbiter');
|
||||
// Row 120 (#1614): a role definition's optional messaging lists. A malformed one refuses at boot.
|
||||
for (const d of Object.values(b.definitions ?? {})) {
|
||||
const m = d?.messaging;
|
||||
if (m === undefined || m === null) continue;
|
||||
if (
|
||||
!object(m) ||
|
||||
!['sendTo', 'receiveFrom'].every(
|
||||
(k) => Array.isArray(m[k]) && m[k].every((r) => id(r) && r !== 'tester'),
|
||||
)
|
||||
)
|
||||
fail('invalid-messaging');
|
||||
}
|
||||
}
|
||||
|
||||
export class Broker {
|
||||
#store;
|
||||
#businesses;
|
||||
@@ -268,13 +199,11 @@ export class Broker {
|
||||
#usage;
|
||||
#limits;
|
||||
#harnessOn;
|
||||
#testerOn;
|
||||
#roleFile;
|
||||
// Row 125 (#1619, REQ-LIMIT-4): a run a limit stops, by business:run, until its next receive or state.
|
||||
#stops = new Map();
|
||||
#clock = 0;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, testerOn = null, roleFile = null }) {
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
this.#businesses = structuredClone(businesses);
|
||||
this.#store = store;
|
||||
@@ -289,15 +218,6 @@ export class Broker {
|
||||
// module-shaped name passes and the launcher's own harness-off check is the only one.
|
||||
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
||||
this.#harnessOn = harnessOn ?? (() => true);
|
||||
// Row 194 (#1692, D4.1, B8): testerOn(business) is true when the organization file as it stands
|
||||
// sets bus.tester to exactly true; the bus runtime reads it live, so a hand edit counts at the next
|
||||
// tester lifecycle call and at the take. Without it the broker's own copy decides.
|
||||
if (testerOn !== null && typeof testerOn !== 'function') throw new TypeError('testerOn must be a function');
|
||||
this.#testerOn = testerOn ?? ((business) => this.#business(business).vars?.['bus.tester'] === true);
|
||||
// Row 194 (#1692, D2.3): roleFile(name) loads role definition `name` from the roles directory as it
|
||||
// stands, for the hire checks. Without it a hire refuses not-configured.
|
||||
if (roleFile !== null && typeof roleFile !== 'function') throw new TypeError('roleFile must be a function');
|
||||
this.#roleFile = roleFile;
|
||||
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
|
||||
this.#requirements = loadRequirements();
|
||||
for (const table of [
|
||||
@@ -320,17 +240,34 @@ export class Broker {
|
||||
this.#clock = Math.max(this.#clock, ms);
|
||||
}
|
||||
}
|
||||
for (const [name, b] of Object.entries(this.#businesses)) checkBusiness(name, b);
|
||||
}
|
||||
// Row 194 (#1692, D3): swap in one business's document, loaded from the file as a restart would
|
||||
// load it. It is synchronous, so no request sees half of it. A new human is row 193's ground.
|
||||
reloadBusiness(name, document) {
|
||||
if (!Object.hasOwn(this.#businesses, name)) fail('unknown-business');
|
||||
if (!object(document)) fail('invalid-business');
|
||||
const next = structuredClone(document);
|
||||
checkBusiness(name, next);
|
||||
if (next.human !== this.#businesses[name].human) fail('invalid-business');
|
||||
this.#businesses[name] = next;
|
||||
for (const [name, b] of Object.entries(this.#businesses)) {
|
||||
// Row 177: a human named `tester` would share the tester's threads.
|
||||
if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters))
|
||||
fail('invalid-business');
|
||||
for (const role of Object.keys(b.roles)) {
|
||||
if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business');
|
||||
const a = b.roles[role].authority;
|
||||
if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole))
|
||||
fail('invalid-authority');
|
||||
const all = [...a.withinRole, ...a.crossRole];
|
||||
if (new Set(all).size !== all.length || all.some((v) => !ACTIONS.includes(v) || GATED.has(v)))
|
||||
fail('invalid-authority');
|
||||
}
|
||||
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
|
||||
fail('invalid-arbiter');
|
||||
// Row 120 (#1614): a role definition's optional messaging lists. A malformed one refuses at boot.
|
||||
for (const d of Object.values(b.definitions ?? {})) {
|
||||
const m = d?.messaging;
|
||||
if (m === undefined || m === null) continue;
|
||||
if (
|
||||
!object(m) ||
|
||||
!['sendTo', 'receiveFrom'].every(
|
||||
(k) => Array.isArray(m[k]) && m[k].every((r) => id(r) && r !== 'tester'),
|
||||
)
|
||||
)
|
||||
fail('invalid-messaging');
|
||||
}
|
||||
}
|
||||
}
|
||||
#now() {
|
||||
this.#clock = Math.max(Date.now(), this.#clock + 1);
|
||||
@@ -367,92 +304,6 @@ export class Broker {
|
||||
owner,
|
||||
);
|
||||
}
|
||||
// Row 194 (#1692): the runs that hold a claim for an instance, one per thread for a thread instance.
|
||||
#running(business, instance) {
|
||||
if (this.#threaded(business, instance)) return this.#threadHolders(business, instance).map((h) => h.holder_run);
|
||||
const h = this.#holder(business, instance);
|
||||
return h?.op === 'claim' ? [h.holder_run] : [];
|
||||
}
|
||||
// Row 194 (#1692, D1.1): a lifecycle request and how far it got, or null. The request is the
|
||||
// action.allowed event the verb wrote. Its taken, done and cancelled events name it by id.
|
||||
#lifecycle(business, request) {
|
||||
const row = this.#store.get(
|
||||
"SELECT * FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.request')=? AND json_extract(body,'$.action') IN (?,?,?,?)",
|
||||
business, request, ...LIFECYCLE_REQUESTS);
|
||||
if (!row) return null;
|
||||
const steps = this.#store.all(
|
||||
"SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.request')=? AND json_extract(body,'$.action') IN (?,?,?) ORDER BY seq",
|
||||
business, request, ...LIFECYCLE_STEPS).map((e) => JSON.parse(e.body));
|
||||
const step = (action) => steps.find((x) => x.action === action) ?? null;
|
||||
const done = step('lifecycle.done'), cancelled = step('lifecycle.cancelled'), taken = step('lifecycle.taken') !== null;
|
||||
return { ...JSON.parse(row.body), at: row.at, instance: row.subject, taken, done, cancelled,
|
||||
state: done || cancelled ? 'closed' : taken ? 'taken' : 'open' };
|
||||
}
|
||||
// The requests not yet closed, oldest first, of one instance or of the whole business.
|
||||
#openRequests(business, instance = null) {
|
||||
return this.#store.all(
|
||||
`SELECT json_extract(r.body,'$.request') request FROM events r WHERE r.business=? AND r.kind='action.allowed'
|
||||
AND json_extract(r.body,'$.action') IN (?,?,?,?) AND (? IS NULL OR r.subject=?)
|
||||
AND NOT EXISTS (SELECT 1 FROM events c WHERE c.business=r.business AND c.kind='action.allowed'
|
||||
AND json_extract(c.body,'$.request')=json_extract(r.body,'$.request') AND json_extract(c.body,'$.action') IN ('lifecycle.done','lifecycle.cancelled'))
|
||||
ORDER BY r.seq`,
|
||||
business, ...LIFECYCLE_REQUESTS, instance, instance).map((r) => this.#lifecycle(business, r.request));
|
||||
}
|
||||
#expired(r) {
|
||||
return Math.max(Date.now(), this.#clock) - Date.parse(r.at) >= LIFECYCLE_REQUEST_MS;
|
||||
}
|
||||
// V5 instance-starting: a launch, regenerate or delete of I that is taken, or open and still young
|
||||
// enough to be taken. An open request past its 60 s can only close as request-expired.
|
||||
#starting(business, instance) {
|
||||
return this.#openRequests(business, instance).some((r) => r.action !== 'lifecycle.end' && (r.state === 'taken' || !this.#expired(r)));
|
||||
}
|
||||
// The verb's request event, with a human.input for the human's own, as launch.revoke writes. A
|
||||
// tester request records `tester` and has no human input.
|
||||
#lifecycleRecord(s, action, instance, extra = {}) {
|
||||
const request = randomUUID();
|
||||
const eid = this.#event(s, 'action.allowed', { action, request, via: s.tester ? 'tester' : s.via, by: this.#owner(s), ...extra }, instance);
|
||||
if (!s.tester) this.#event(s, 'human.input', { kind: 'admin', event: eid });
|
||||
return request;
|
||||
}
|
||||
// A step the launcher's host ops write, or a terminate's cancel. No session acts in it.
|
||||
#lifecycleStep(business, action, body) {
|
||||
this.#event({ business, role: null, run: null }, 'action.allowed', { action, ...body });
|
||||
}
|
||||
// D4.1: a tester lifecycle call, and the take of a tester request, need bus.tester exactly true in
|
||||
// the file as it stands.
|
||||
#testerFile(business) {
|
||||
if (this.#testerOn(business) !== true) fail('tester-off');
|
||||
}
|
||||
// V2 and V4 at the verb. The take repeats the revoked and retired checks.
|
||||
#launchable(s, instance, thread) {
|
||||
const b = this.#business(s.business);
|
||||
if ((b.retired ?? []).includes(instance)) fail('instance-retired');
|
||||
if (!Object.hasOwn(b.roles, instance)) fail('unknown-role');
|
||||
if (this.#threaded(s.business, instance)) fail(thread);
|
||||
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked') fail('launch-revoked');
|
||||
if (this.#firing(s.business, instance)) fail('instance-firing');
|
||||
}
|
||||
// W-N1 (webby-0014): a fire of I stopped its runs, and no fire of I has finished since.
|
||||
#firing(business, instance) {
|
||||
return !!this.#store.get(
|
||||
`SELECT 1 FROM events f WHERE f.business=? AND f.kind='action.allowed' AND f.subject=? AND json_extract(f.body,'$.action')='fire.stopped'
|
||||
AND NOT EXISTS (SELECT 1 FROM events d WHERE d.business=f.business AND d.kind='action.allowed' AND d.subject=f.subject
|
||||
AND json_extract(d.body,'$.action')='fire.done' AND d.seq>f.seq) LIMIT 1`,
|
||||
business, instance);
|
||||
}
|
||||
// D1.1 item 2: why the take closes a request instead of going, or null. A terminate's signal
|
||||
// always goes. Its claims were revoked at the verb.
|
||||
#takeRefusal(business, r) {
|
||||
if (r.action === 'lifecycle.end') return null;
|
||||
if (this.#expired(r)) return 'request-expired';
|
||||
if (r.action !== 'lifecycle.delete') {
|
||||
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', business)?.state === 'revoked') return 'launch-revoked';
|
||||
if ((this.#business(business).retired ?? []).includes(r.instance)) return 'instance-retired';
|
||||
if (this.#firing(business, r.instance)) return 'instance-firing';
|
||||
}
|
||||
if (r.by === 'tester' && this.#testerOn(business) !== true) return 'tester-off';
|
||||
return null;
|
||||
}
|
||||
// The claim a run acts under: its thread's for a thread run, its role's otherwise.
|
||||
#own(s) {
|
||||
return s.thread ? this.#threadHolder(s.thread) : this.#holder(s.business, s.role);
|
||||
@@ -525,12 +376,6 @@ export class Broker {
|
||||
if (s.reader) fail('read-only');
|
||||
if (!s.human || s.via !== 'cli' || s.outsideAgent !== true) fail('human-required');
|
||||
}
|
||||
// Row 194 (#1692, D4): until row 193, a lifecycle verb passes for the business's one human, the
|
||||
// owner. Row 193 replaces this body and nothing else.
|
||||
#mayHuman(s, verb, target) {
|
||||
if (!LIFECYCLE_HUMAN_VERBS.includes(verb) || typeof target !== 'string') fail('human-required');
|
||||
if (s.human !== this.#business(s.business).human) fail('human-required');
|
||||
}
|
||||
#cap(s) {
|
||||
const cap = randomBytes(32).toString('hex');
|
||||
this.#sessions.set(cap, Object.freeze(s));
|
||||
@@ -927,7 +772,6 @@ export class Broker {
|
||||
this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked'
|
||||
)
|
||||
fail('launch-revoked');
|
||||
if (action === 'role.launch' && this.#firing(s.business, target)) fail('instance-firing');
|
||||
const cls = this.#classification(s, action, target);
|
||||
if (cls === 'within-role' && !decision) return { class: cls };
|
||||
if (!decision) fail('decision-required');
|
||||
@@ -1225,70 +1069,6 @@ export class Broker {
|
||||
this.#store.transaction(() => this.#claimEnd(session, h, 'release', null, null, 'land-relaunch'));
|
||||
return { released: true };
|
||||
}
|
||||
// Row 194 (#1692, D1.1): the launcher's lifecycle pass. Trusted launcher API, not socket verbs.
|
||||
// lifecycleRequests lists the requests not yet closed, oldest first.
|
||||
lifecycleRequests(business) {
|
||||
this.#business(identifier(business));
|
||||
return this.#openRequests(business).map(lifecycleView);
|
||||
}
|
||||
// The take, in one transaction: an open request that passes #takeRefusal is taken and answers go,
|
||||
// one that fails it closes with the code. A taken request is never taken again.
|
||||
lifecycleTake(record) {
|
||||
keys(record, ['business', 'request'], ['business', 'request']);
|
||||
this.#business(identifier(record.business));
|
||||
identifier(record.request);
|
||||
return this.#store.transaction(() => {
|
||||
const r = this.#lifecycle(record.business, record.request) ?? fail('unknown-request');
|
||||
if (r.state !== 'open') return { go: false, refusal: 'request-closed', ...lifecycleView(r) };
|
||||
const refusal = this.#takeRefusal(record.business, r);
|
||||
this.#lifecycleStep(record.business, refusal ? 'lifecycle.done' : 'lifecycle.taken', { request: r.request, ...(refusal ? { refusal } : {}) });
|
||||
return { go: !refusal, refusal, ...lifecycleView(this.#lifecycle(record.business, r.request)) };
|
||||
});
|
||||
}
|
||||
// V5 step 2 for a taken delete: the running and starting checks again, then the writer's delete
|
||||
// against the file as it stands, with the human's revision. The bus reloads after the commit, as
|
||||
// after every write. A retry of a retired name commits nothing and goes to the rename, with the
|
||||
// transaction of the delete whose rename failed (row 194 S3), which names the rename's target.
|
||||
lifecycleDelete(record) {
|
||||
keys(record, ['business', 'request'], ['business', 'request']);
|
||||
this.#business(identifier(record.business));
|
||||
const r = this.#lifecycle(record.business, identifier(record.request)) ?? fail('unknown-request');
|
||||
if (r.action !== 'lifecycle.delete' || r.state !== 'taken') fail('request-not-taken');
|
||||
if (r.retry === true) {
|
||||
const failed = this.#store.all(
|
||||
"SELECT json_extract(body,'$.request') request FROM events WHERE business=? AND kind='action.allowed' AND subject=? AND json_extract(body,'$.action')='lifecycle.delete' AND json_extract(body,'$.request')<>? ORDER BY seq DESC",
|
||||
record.business, r.instance, r.request).map((x) => this.#lifecycle(record.business, x.request))
|
||||
.find((x) => x.done?.refusal === 'workspace-rename-failed');
|
||||
return { transaction: failed?.done?.transaction ?? null, retry: true };
|
||||
}
|
||||
if (this.#running(record.business, r.instance).length > 0) fail('instance-running');
|
||||
if (this.#openRequests(record.business, r.instance).some((o) => o.request !== r.request && o.action !== 'lifecycle.end' &&
|
||||
(o.state === 'taken' || !this.#expired(o)))) fail('instance-starting');
|
||||
if (!this.#organization) fail('not-configured');
|
||||
let out;
|
||||
try {
|
||||
out = this.#organization(record.business, 'instances.set', { revision: r.revision, instance: r.instance, op: 'delete' }, r.by);
|
||||
} catch (e) {
|
||||
this.#organizationFail(e, 'instances.set');
|
||||
}
|
||||
return { transaction: out.transaction, retry: false };
|
||||
}
|
||||
// The close of a taken request, with the run it started, the delete's transaction or the code
|
||||
// that stopped it.
|
||||
lifecycleDone(record) {
|
||||
keys(record, ['business', 'request', 'run', 'transaction', 'refusal'], ['business', 'request']);
|
||||
this.#business(identifier(record.business));
|
||||
identifier(record.request);
|
||||
for (const k of ['run', 'transaction']) if (k in record) identifier(record[k]);
|
||||
if ('refusal' in record && !LIFECYCLE_CODE.test(record.refusal)) fail('invalid-request');
|
||||
return this.#store.transaction(() => {
|
||||
const r = this.#lifecycle(record.business, record.request) ?? fail('unknown-request');
|
||||
if (r.state !== 'taken') fail(r.state === 'open' ? 'request-not-taken' : 'request-closed');
|
||||
const { business, ...body } = record;
|
||||
this.#lifecycleStep(business, 'lifecycle.done', body);
|
||||
return lifecycleView(this.#lifecycle(business, r.request));
|
||||
});
|
||||
}
|
||||
// The merge decisions landHeld would count, each with its raised context and closing event. A
|
||||
// null role is every non-thread instance's (row 156): raised outside a thread by a role that
|
||||
// isn't a thread instance.
|
||||
@@ -1465,9 +1245,7 @@ export class Broker {
|
||||
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
|
||||
if (PROJECT_FILE_VERBS.includes(request.verb))
|
||||
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
|
||||
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
|
||||
return this.#chiefLifecycle(s, request.verb, structuredClone(args));
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) {
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set"].includes(request.verb)) {
|
||||
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
|
||||
// file lists organization.change. It takes the same keys, runs the same write path and
|
||||
// logs the same receipt; only the actor differs: the role definition and instance.
|
||||
@@ -1484,42 +1262,13 @@ export class Broker {
|
||||
else if (request.verb === "plugins.set") keys(args, ["revision", "plugins"], ["revision", "plugins"]);
|
||||
// Row 165 (#1662): instances.set is human-only and outside ORGANIZATION_KEYS, so
|
||||
// organization.set and the chief of staff's tool never reach it (sage-0918).
|
||||
else if (request.verb === "instances.set") {
|
||||
keys(args, INSTANCE_KEYS, ["revision", "instance", "op"]);
|
||||
if (!["add", "change", "delete"].includes(args.op)) fail("invalid-request");
|
||||
// Row 194 (D4.1, B6): the tester never deletes.
|
||||
if (s.tester && args.op === "delete") fail("tester-refused");
|
||||
if (!s.tester) {
|
||||
this.#human(s);
|
||||
this.#mayHuman(s, `instances.set ${args.op}`, args.instance);
|
||||
}
|
||||
// A delete is a launcher request (V5): the checks here write nothing, and the launcher's
|
||||
// pass commits the file and renames the workspace after the take.
|
||||
if (args.op === "delete") return this.#deleteRequest(s, args);
|
||||
// Row 194 (D3 item 4): the write now goes live at once, so a live cap's authority must not
|
||||
// change under its run.
|
||||
if (args.op === "change" && "definition" in args && typeof args.instance === "string" &&
|
||||
Object.hasOwn(this.#business(s.business).roles, args.instance) && this.#running(s.business, args.instance).length > 0)
|
||||
fail("instance-running");
|
||||
}
|
||||
else if (request.verb === "instances.set") keys(args, INSTANCE_KEYS, ["revision", "instance", "op"]);
|
||||
// Row 65 (mos-0840): routes.set, the Console's routing table, is human-only and outside
|
||||
// ORGANIZATION_KEYS too. Through organization.set the chief of staff changes only the model
|
||||
// key of an instance, except the assistant, whose whole vars it may set (Q169); that write
|
||||
// runs the same route checks.
|
||||
else if (request.verb === "routes.set") keys(args, ["revision", "routes"], ["revision", "routes"]);
|
||||
// Row 194 (#1692, D2.4, V6): only the human writes launch.keep and launch.noHire. The tester
|
||||
// is refused by the gate above, as veto.set isn't in TESTER_ORGANIZATION_VERBS.
|
||||
else if (request.verb === "veto.set") {
|
||||
keys(args, VETO_KEYS, VETO_KEYS);
|
||||
this.#human(s);
|
||||
this.#mayHuman(s, "veto.set", args.name);
|
||||
} else {
|
||||
keys(args, ORGANIZATION_KEYS, ["revision"]);
|
||||
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
|
||||
// the next launch. The human's limits.set still can.
|
||||
const keep = this.#business(s.business).launch?.keep ?? [];
|
||||
if (role && object(args.instances) && Object.keys(args.instances).some(i => keep.includes(i))) fail("instance-kept");
|
||||
}
|
||||
else keys(args, ORGANIZATION_KEYS, ["revision"]);
|
||||
let actor = s.tester ? "tester" : s.human;
|
||||
// This branch runs outside the store transaction below, so the grant is checked before the writer
|
||||
// and consumed only once the writer has committed: a refused change logs action.refused alone,
|
||||
@@ -1534,14 +1283,20 @@ export class Broker {
|
||||
? { revision: this.#organization(s.business, "limits", {}, actor).revision }
|
||||
: this.#organization(s.business, role ? "limits.set" : request.verb, args, actor);
|
||||
} catch (e) {
|
||||
this.#organizationFail(e, request.verb);
|
||||
if (e.code === "revision-conflict") fail("revision-conflict");
|
||||
if (e.code?.startsWith("organization-")) fail(e.code);
|
||||
if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
|
||||
// Row 65: the route checks every write of an instance's route vars runs.
|
||||
if (["harness-off", "harness-refused", "thinking-unsupported"].includes(e.code)) fail(e.code);
|
||||
// The routing table names a model the table lacks; the older verbs keep invalid-request.
|
||||
if (request.verb === "routes.set" && e.code === "unknown-model") fail(e.code);
|
||||
if (e.name === "BusinessError" || e.code === "invalid-request" || e.code === "unknown-model") {
|
||||
fail("invalid-request");
|
||||
}
|
||||
throw new BusError("outcome-unknown");
|
||||
}
|
||||
if (role && !revisionRead) this.#store.transaction(() => this.#consumeAuthority(s, "organization.change"));
|
||||
this.#secretCheck(result);
|
||||
// Row 194 (#1692, D1): the read gains each instance's running runs, from the bus claims. An
|
||||
// answer with no instances map passes through as it did before.
|
||||
if (request.verb === "instances" && result?.instances && typeof result.instances === "object")
|
||||
for (const [name, entry] of Object.entries(result.instances)) entry.running = this.#running(s.business, name);
|
||||
return result;
|
||||
}
|
||||
return this.#store.transaction(() => {
|
||||
@@ -1561,153 +1316,6 @@ export class Broker {
|
||||
throw this.#refused(s, e, request);
|
||||
}
|
||||
}
|
||||
// Row 194 (#1692, D2.1, D2.2): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes both within the
|
||||
// chief's role with a reason, as role.end ends a run (S2e). The writer commits outside the store
|
||||
// transaction, so the grant is checked before it and the use logged only after it, as
|
||||
// organization.set's grant is.
|
||||
#chiefLifecycle(s, verb, a) {
|
||||
const hire = verb === 'instance.hire', action = hire ? 'role.hire' : 'role.fire';
|
||||
this.#agent(s);
|
||||
const [allowed, required] = HIRE_FIRE_ARGS[action];
|
||||
keys(a, allowed, required);
|
||||
this.#hireFireArgs(action, a);
|
||||
const args = a;
|
||||
const b = this.#business(s.business);
|
||||
if (b.launch?.by !== s.role) fail('role-not-chief');
|
||||
if (!this.#organization) fail('not-configured');
|
||||
// The grant: the action is routed and on the chief's withinRole. Without both it needs a decision
|
||||
// (decision-required), and these verbs take none.
|
||||
const authority = { target: args.name };
|
||||
this.#checkAuthority(s, action, authority);
|
||||
// D2.4: a kept name is neither hired nor fired.
|
||||
if ((b.launch?.keep ?? []).includes(args.name)) fail('instance-kept');
|
||||
const actor = b.roles[s.role].definition + '.' + s.role;
|
||||
const context = { instance: s.role, run: s.run, reason: args.reason };
|
||||
// The writer gets its own keys. The reason goes in the context, which its receipt logs.
|
||||
const { reason: _, ...written } = args;
|
||||
const write = (op) => {
|
||||
try {
|
||||
return this.#organization(s.business, op, written, actor, context);
|
||||
} catch (e) {
|
||||
this.#organizationFail(e, verb);
|
||||
}
|
||||
};
|
||||
if (hire) {
|
||||
this.#hireable(s, args.definition);
|
||||
const result = write('instance.hire');
|
||||
this.#store.transaction(() => this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction }));
|
||||
return result;
|
||||
}
|
||||
if (!Object.hasOwn(b.roles, args.name)) fail('unknown-role');
|
||||
if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused');
|
||||
if (b.launch?.by === args.name) fail('organization-instance-referenced');
|
||||
// The last listed name refuses before step A stops anything, as the writer would at step B.
|
||||
const listed = b.launch?.instances ?? [];
|
||||
if (listed.length === 1 && listed[0] === args.name) fail('invalid-request');
|
||||
// Step A (webby-0011 B2): the store side first, so a delisted agent never keeps a live process.
|
||||
// V3's revoke, cancel and end, with kill: true for the launcher's escalation.
|
||||
const stopped = this.#store.transaction(() => {
|
||||
const held = this.#threaded(s.business, args.name)
|
||||
? this.#threadHolders(s.business, args.name)
|
||||
: [this.#holder(s.business, args.name)].filter((h) => h?.op === 'claim');
|
||||
for (const h of held) this.#claimEnd(s, h, 'revoke', null, args.reason);
|
||||
const runs = held.map((h) => h.holder_run);
|
||||
for (const [key, r] of this.#runs)
|
||||
if (r.business === s.business && r.role === args.name && !this.#ended.has(key) && !runs.includes(r.run)) runs.push(r.run);
|
||||
const open = this.#openRequests(s.business, args.name).filter((r) => r.action !== 'lifecycle.end');
|
||||
const cancelled = open.filter((r) => r.state === 'open').map((r) => r.request);
|
||||
const taken = open.filter((r) => r.state === 'taken').map((r) => r.request);
|
||||
for (const request of cancelled) this.#lifecycleStep(s.business, 'lifecycle.cancelled', { request, by: s.role });
|
||||
// The request the launcher's pass takes to signal the runs.
|
||||
const request = runs.length === 0 ? null : randomUUID();
|
||||
if (request)
|
||||
this.#event(s, 'action.allowed', { action: 'lifecycle.end', request, via: 'chief', by: s.role, runs, reason: args.reason, kill: true }, args.name);
|
||||
this.#event(s, 'action.allowed', { action: 'fire.stopped', request }, args.name);
|
||||
return { request, runs, cancelled, taken };
|
||||
});
|
||||
// Step B: the delist. An instance already delisted counts as done.
|
||||
const result = write('instance.fire');
|
||||
// Step C: log the fire and record it done. A failure at B or C leaves I firing until a repeat.
|
||||
this.#store.transaction(() => {
|
||||
this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction ?? null });
|
||||
this.#event(s, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null }, args.name);
|
||||
});
|
||||
return { ...result, ...stopped };
|
||||
}
|
||||
// The shape of hire and fire arguments.
|
||||
#hireFireArgs(action, args) {
|
||||
identifier(args.name);
|
||||
if (args.name === 'human' || args.name === 'tester') fail('invalid-request');
|
||||
string(args.reason, 500);
|
||||
if (action === 'role.fire') return;
|
||||
identifier(args.definition);
|
||||
if ('vars' in args && (!object(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) fail('invalid-request');
|
||||
}
|
||||
// D2.3 at hire, against the role files as they stand: a version 2 definition whose every action is
|
||||
// on HIRE_ALLOWED (W-N2), named in the chief role file's hireable list and not in launch.noHire.
|
||||
#hireable(s, definition) {
|
||||
if (!this.#roleFile) fail('not-configured');
|
||||
const b = this.#business(s.business);
|
||||
const load = (name) => {
|
||||
try {
|
||||
return this.#roleFile(name);
|
||||
} catch (e) {
|
||||
if (e.name === 'BusinessError') fail('definition-not-hireable');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
const def = load(definition);
|
||||
if (def.roleVersion !== 2 || unhireableAction(def) !== null) fail('definition-not-hireable');
|
||||
if (!(load(b.roles[s.role].definition).hireable ?? []).includes(definition)) fail('definition-not-hireable');
|
||||
if ((b.launch?.noHire ?? []).includes(definition)) fail('definition-not-hireable');
|
||||
}
|
||||
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
|
||||
// outcome-unknown: the write may have committed.
|
||||
#organizationFail(e, verb) {
|
||||
if (e.code === "revision-conflict") fail("revision-conflict");
|
||||
if (e.code?.startsWith("organization-")) fail(e.code);
|
||||
if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
|
||||
// Row 194: the writer's own copy of the hire and fire checks, against the file as it stands.
|
||||
if ((verb === "instance.hire" || verb === "instance.fire") && ["definition-not-hireable", "instance-kept"].includes(e.code)) fail(e.code);
|
||||
// Row 65: the route checks every write of an instance's route vars runs.
|
||||
if (["harness-off", "harness-refused", "thinking-unsupported"].includes(e.code)) fail(e.code);
|
||||
// The routing table names a model the table lacks; the older verbs keep invalid-request.
|
||||
if (verb === "routes.set" && e.code === "unknown-model") fail(e.code);
|
||||
if (e.name === "BusinessError" || e.code === "invalid-request" || e.code === "unknown-model") {
|
||||
fail("invalid-request");
|
||||
}
|
||||
throw new BusError("outcome-unknown");
|
||||
}
|
||||
// Row 194 (#1692, V5): a delete's checks at the verb, in the design's order, then its request.
|
||||
// The writer's instances.check runs deleteRefusal then the last-listed check, with no write. Its
|
||||
// named refusals come first. The last-listed one (a BusinessError) waits until the broker's
|
||||
// running and starting checks have passed. A retired name is a retry of the workspace rename, and
|
||||
// only after a rename that failed.
|
||||
#deleteRequest(s, args) {
|
||||
keys(args, ["revision", "instance", "op"], ["revision", "instance", "op"]);
|
||||
identifier(args.instance);
|
||||
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) fail("invalid-request");
|
||||
const b = this.#business(s.business);
|
||||
if ((b.retired ?? []).includes(args.instance) && !Object.hasOwn(b.roles, args.instance)) {
|
||||
const last = this.#store.get(
|
||||
"SELECT json_extract(body,'$.request') request FROM events WHERE business=? AND kind='action.allowed' AND subject=? AND json_extract(body,'$.action')='lifecycle.delete' ORDER BY seq DESC LIMIT 1",
|
||||
s.business, args.instance);
|
||||
if (this.#lifecycle(s.business, last?.request ?? "")?.done?.refusal !== "workspace-rename-failed") fail("organization-instance-retired");
|
||||
return { request: this.#store.transaction(() => this.#lifecycleRecord(s, "lifecycle.delete", args.instance, { revision: args.revision, retry: true })) };
|
||||
}
|
||||
let lastListed = null, revision = null;
|
||||
try {
|
||||
({ revision } = this.#organization(s.business, "instances.check", { instance: args.instance }, s.human));
|
||||
} catch (e) {
|
||||
if (e.name !== "BusinessError") this.#organizationFail(e, "instances.set");
|
||||
lastListed = e;
|
||||
}
|
||||
if (this.#running(s.business, args.instance).length > 0) fail("instance-running");
|
||||
if (this.#starting(s.business, args.instance)) fail("instance-starting");
|
||||
if (lastListed) fail("invalid-request");
|
||||
if (revision !== args.revision) fail("revision-conflict");
|
||||
return { request: this.#store.transaction(() => this.#lifecycleRecord(s, "lifecycle.delete", args.instance, { revision: args.revision })) };
|
||||
}
|
||||
// Only the runner's bound session and delivered message identify a turn; no host path crosses the wire.
|
||||
// Row 156 (#1651, sage-0808): a role run's landing turn is a merge decision id instead. Its begin
|
||||
// needs the decision held for the run's role; its end needs that begin, since the landing may
|
||||
@@ -2002,9 +1610,6 @@ export class Broker {
|
||||
this.#consumeAuthority(s, 'role.end', { target: a.role }, { reason: a.reason });
|
||||
if (a.role === s.role) fail('role-end-refused');
|
||||
this.#protectedEnd(s, a.role);
|
||||
// Row 194 (#1692, D2.4): a kept instance's run ends only by a decision the human resolved,
|
||||
// the human's own end or the limits. End then launch would be a replace.
|
||||
if ((this.#business(s.business).launch?.keep ?? []).includes(a.role)) fail('instance-kept');
|
||||
} else {
|
||||
identifier(a.decision);
|
||||
this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });
|
||||
@@ -2680,60 +2285,6 @@ export class Broker {
|
||||
this.#claimEnd(s, h, 'revoke', null, s.tester ? 'stopped by the tester' : 'stopped by the human', s.tester ? 'tester' : s.human);
|
||||
return { stopped: held.map((h) => ({ thread: h.thread, run: h.holder_run })) };
|
||||
}
|
||||
// Row 194 (#1692, D1.1, V2, V4): launch and regenerate are requests the launcher's pass takes.
|
||||
// The tester launches (D4.1) and never regenerates: instance.regenerate isn't in TESTER_VERBS.
|
||||
case 'instance.launch':
|
||||
case 'instance.regenerate': {
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['instance'], ['instance']);
|
||||
identifier(a.instance);
|
||||
if (s.tester) this.#testerFile(s.business);
|
||||
else this.#mayHuman(s, verb, a.instance);
|
||||
this.#launchable(s, a.instance, verb === 'instance.launch' ? 'not-thread-launch' : 'regenerate-thread');
|
||||
const action = verb === 'instance.launch' ? 'lifecycle.launch' : 'lifecycle.regenerate';
|
||||
return { request: this.#lifecycleRecord(s, action, a.instance) };
|
||||
}
|
||||
// V3: every claim of I is revoked, and every open request of I cancelled, in this transaction.
|
||||
// lifecycle.end names the runs for the launcher's pass to signal, the bound runs that hold no
|
||||
// claim included. A tester ends only its own threads of a thread instance, as assistant.stop
|
||||
// does, and cancels only its own requests.
|
||||
case 'instance.end': {
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['instance', 'reason'], ['instance']);
|
||||
identifier(a.instance);
|
||||
if ('reason' in a) string(a.reason, 500);
|
||||
if (s.tester) this.#testerFile(s.business);
|
||||
else this.#mayHuman(s, verb, a.instance);
|
||||
if (!Object.hasOwn(b.roles, a.instance)) fail('unknown-role');
|
||||
const owner = s.tester ? 'tester' : null;
|
||||
const threaded = this.#threaded(s.business, a.instance);
|
||||
const held = threaded
|
||||
? this.#threadHolders(s.business, a.instance, owner)
|
||||
: [this.#holder(s.business, a.instance)].filter((h) => h?.op === 'claim');
|
||||
const reason = a.reason ?? (s.tester ? 'ended by the tester' : 'ended by the human');
|
||||
for (const h of held) this.#claimEnd(s, h, 'revoke', null, reason, this.#owner(s));
|
||||
const runs = held.map((h) => h.holder_run);
|
||||
for (const [key, r] of this.#runs)
|
||||
if (r.business === s.business && r.role === a.instance && !this.#ended.has(key) && !runs.includes(r.run) &&
|
||||
(!owner || !r.thread || this.#store.get('SELECT owner FROM threads WHERE id=?', r.thread)?.owner === owner))
|
||||
runs.push(r.run);
|
||||
const open = this.#openRequests(s.business, a.instance).filter((r) => r.action !== 'lifecycle.end' && (!owner || r.by === owner));
|
||||
const cancelled = open.filter((r) => r.state === 'open').map((r) => r.request);
|
||||
// A taken request is past cancelling. Its run reaches the registry, and the lifecycle.end
|
||||
// lets the pass signal it there.
|
||||
const taken = open.filter((r) => r.state === 'taken').map((r) => r.request);
|
||||
if (runs.length === 0 && cancelled.length === 0 && taken.length === 0) fail('not-held');
|
||||
for (const request of cancelled) this.#lifecycleStep(s.business, 'lifecycle.cancelled', { request, by: this.#owner(s) });
|
||||
return { request: this.#lifecycleRecord(s, 'lifecycle.end', a.instance, { runs, reason }), runs, cancelled, taken };
|
||||
}
|
||||
// D1.1 item 5: the read the CLI and the Console poll. A tester reads only its own requests.
|
||||
case 'instance.request': {
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['request'], ['request']);
|
||||
const r = this.#lifecycle(s.business, identifier(a.request));
|
||||
if (!r || (s.tester && r.by !== 'tester')) fail('unknown-request');
|
||||
return lifecycleView(r);
|
||||
}
|
||||
case 'names': {
|
||||
keys(a, []);
|
||||
if (!s.human && !s.tester) this.#agent(s);
|
||||
|
||||
@@ -20,10 +20,6 @@ const LAUNCH_OPS = new Set([
|
||||
'mayLaunch',
|
||||
'landHolders',
|
||||
'landRelease',
|
||||
'lifecycleRequests',
|
||||
'lifecycleTake',
|
||||
'lifecycleDelete',
|
||||
'lifecycleDone',
|
||||
]);
|
||||
function launchOp(m) {
|
||||
if (m.op === 'identity') return runtime.identity(m.cap);
|
||||
@@ -48,11 +44,6 @@ function launchOp(m) {
|
||||
// role run started before its decision was resolved.
|
||||
if (m.op === 'landHolders') return runtime.landHolders(m.business);
|
||||
if (m.op === 'landRelease') return runtime.landRelease(m.record);
|
||||
// Row 194 (#1692, D1.1): the launcher's lifecycle pass.
|
||||
if (m.op === 'lifecycleRequests') return runtime.lifecycleRequests(m.business);
|
||||
if (m.op === 'lifecycleTake') return runtime.lifecycleTake(m.record);
|
||||
if (m.op === 'lifecycleDelete') return runtime.lifecycleDelete(m.record);
|
||||
if (m.op === 'lifecycleDone') return runtime.lifecycleDone(m.record);
|
||||
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
|
||||
// Every LAUNCH_OPS name has its line above. A missing one used to answer as credentialStatus,
|
||||
// whose [] for an unknown role read as "no holders" for landHolders (row 156 mutant P02).
|
||||
|
||||
@@ -11,11 +11,6 @@ import { readSessions } from '../../seat/src/session.mjs';
|
||||
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
|
||||
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
|
||||
import { instanceSettings } from '../../business/src/instance-settings.mjs';
|
||||
import { loadBusiness } from '../../business/src/business.mjs';
|
||||
import { loadRole } from '../../business/src/role.mjs';
|
||||
import { resolveInstance } from '../../business/src/resolve.mjs';
|
||||
import { canonicalJson } from '../../business/src/util.mjs';
|
||||
import { busBusiness } from './business.mjs';
|
||||
import { webService } from './web.mjs';
|
||||
import { verifyHuman } from './human.mjs';
|
||||
import { createUsage } from './usage.mjs';
|
||||
@@ -108,40 +103,6 @@ export async function startBroker({
|
||||
const limits = createLimits({ store, read: id => settings ? settings.current(id) : businesses[id], log: limitLog });
|
||||
for (const id of Object.keys(businesses)) await limits.refresh(id);
|
||||
const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) });
|
||||
// Row 194 (#1692, D3): after every write that commits, the broker takes the business as a restart
|
||||
// would load it. The credential references, the project roots the Credentials store protects
|
||||
// and the tracker bots are fixed at start (the Credentials store, the tasks adapter), so a file
|
||||
// whose references, roots or bots differ from the boot copy isn't reloaded, and the write
|
||||
// answers restart: true. Create and hire add neither,
|
||||
// because credentialed definitions are refused.
|
||||
// A role not in the boot copy matches only with no references and no bot.
|
||||
const fixedRole = r => canonicalJson({ credentials: r?.credentials ?? {}, tracker: r?.tracker ?? null });
|
||||
const fixedAtStart = b => ({
|
||||
roles: Object.fromEntries(Object.entries(b.roles).map(([role, r]) => [role, fixedRole(r)])),
|
||||
sync: canonicalJson(b.tracker?.sync ?? null),
|
||||
roots: canonicalJson(Object.values(b.projects ?? {}).map(p => p.root).sort()),
|
||||
});
|
||||
const sameFixed = (boot, b) => fixedAtStart(b).sync === boot.sync && fixedAtStart(b).roots === boot.roots
|
||||
&& Object.entries(fixedAtStart(b).roles).every(([role, f]) => f === (boot.roles[role] ?? fixedRole(null)));
|
||||
const booted = new Map(Object.entries(businesses).map(([id, b]) => [id, fixedAtStart(b)]));
|
||||
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
|
||||
function reload(id) {
|
||||
try {
|
||||
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
|
||||
const next = busBusiness(b, resolved);
|
||||
if (!sameFixed(booted.get(id), next)) {
|
||||
reloadLog({ event: 'reload-needs-restart', business: id });
|
||||
return false;
|
||||
}
|
||||
broker.reloadBusiness(id, next);
|
||||
return true;
|
||||
} catch (e) {
|
||||
reloadLog({ event: 'reload-failed', business: id, message: e.message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const broker = new Broker({
|
||||
usage,
|
||||
limits,
|
||||
@@ -157,32 +118,19 @@ export async function startBroker({
|
||||
},
|
||||
// limits and limits.set (organization.set is limits.set) carry each token limit's count; a
|
||||
// plugins.set reloads the business's limit checks in the background.
|
||||
organization:settings?(id,verb,args,actor,context)=>{
|
||||
organization:settings?(id,verb,args,actor)=>{
|
||||
if(verb==='limits')return withTokens(id,settings.read(id));
|
||||
if(verb==='limits.set')return withTokens(id,settings.set(id,args,actor));
|
||||
if(verb==='plugins')return plugins.read(id);
|
||||
if(verb==='instances')return instances.read(id);
|
||||
if(verb==='instances.set')return instances.set(id,args,actor);
|
||||
// Row 65 (mos-0840): the Console's routing table.
|
||||
if(verb==='routes')return settings.routes(id);
|
||||
// Row 194 (#1692, V5): a delete's checks at the verb, with no write and no reload.
|
||||
if(verb==='instances.check')return instances.checkDelete(id,args.instance);
|
||||
let result;
|
||||
if(verb==='limits.set')result=withTokens(id,settings.set(id,args,actor));
|
||||
else if(verb==='instances.set')result=instances.set(id,args,actor);
|
||||
else if(verb==='routes.set')result=settings.setRoutes(id,args,actor);
|
||||
else if(verb==='plugins.set')result=plugins.set(id,args,actor);
|
||||
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
|
||||
else if(verb==='veto.set')result=instances.veto(id,args,actor);
|
||||
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
|
||||
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
|
||||
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
|
||||
else throw new BusError('invalid-request');
|
||||
// Row 194 (D3 item 2): one reload, after every verb that committed, then the limit checks.
|
||||
const live=reload(id);
|
||||
if(verb==='routes.set')return settings.setRoutes(id,args,actor);
|
||||
const result=plugins.set(id,args,actor);
|
||||
limits.refresh(id).catch(e=>limitLog({event:'limit-refresh-failed',business:id,message:e.message}));
|
||||
return verb==='instances.set'?{...result,restart:!live}:result;
|
||||
return result;
|
||||
}:null,
|
||||
// Row 194 (D2.3): the role files as they stand, for the hire checks.
|
||||
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
credentialStatus: () => credentials.status(),
|
||||
// Row 65: a new launch binds only on a harness its business switches on, read from the file
|
||||
@@ -195,15 +143,6 @@ export async function startBroker({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
// Row 194 (#1692, D4.1, B8): bus.tester as the file sets it now; an unreadable file is off.
|
||||
testerOn: (id) => {
|
||||
try {
|
||||
const b = settings ? settings.current(id) : businesses[id];
|
||||
return b?.vars?.['bus.tester'] === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
// Process identities must be supplied for every managed run when using the human transport.
|
||||
const launchRecords = [];
|
||||
@@ -302,11 +241,6 @@ export async function startBroker({
|
||||
// Row 156 (#1651): the launcher's landing pass.
|
||||
landHolders: (business) => broker.landHolders(business),
|
||||
landRelease: (r) => broker.landRelease(r),
|
||||
// Row 194 (#1692, D1.1): the launcher's lifecycle pass.
|
||||
lifecycleRequests: (business) => broker.lifecycleRequests(business),
|
||||
lifecycleTake: (r) => broker.lifecycleTake(r),
|
||||
lifecycleDelete: (r) => broker.lifecycleDelete(r),
|
||||
lifecycleDone: (r) => broker.lifecycleDone(r),
|
||||
refuse: (cap, code, subject) => broker.refuse(cap, code, subject),
|
||||
credentialStatus,
|
||||
launches: bound,
|
||||
|
||||
@@ -7,13 +7,9 @@ import { createHash } from 'node:crypto';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { Store, schemaDigest } from '../src/store.mjs';
|
||||
import { Broker, ACTIONS } from '../src/broker.mjs';
|
||||
import { HIRE_ALLOWED } from '../../business/src/vocabulary.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||
import { busBusiness } from '../src/business.mjs';
|
||||
import { loadBusiness } from '../../business/src/business.mjs';
|
||||
import { resolveInstance } from '../../business/src/resolve.mjs';
|
||||
|
||||
// Row 123 (#1617): the chief of staff ends runs without a decision and changes the organization
|
||||
// file through the shared write path. REQ-ROLE-2 (one holder; revoke), REQ-ROLE-3 (the
|
||||
@@ -31,7 +27,7 @@ const roles = {
|
||||
pm: { definition: 'pm', ...none },
|
||||
coder: { definition: 'coder', ...none },
|
||||
};
|
||||
const demo = (extra = {}) => ({
|
||||
const demo = () => ({
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
@@ -42,13 +38,12 @@ const demo = (extra = {}) => ({
|
||||
// Row 65: runtime launches bind only on a harness the business switches on.
|
||||
plugins: { harness: ['pi'] },
|
||||
vars: { 'bus.tester': true },
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
|
||||
const PATCH = { revision: 'a'.repeat(64), limits: { total: 3 } };
|
||||
|
||||
function setup(t, extra = {}, { roleFile = null } = {}) {
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-chief-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
@@ -56,16 +51,13 @@ function setup(t, extra = {}, { roleFile = null } = {}) {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const organization = [];
|
||||
const writer = { error: null, during: null };
|
||||
const writer = { error: null };
|
||||
const b = new Broker({
|
||||
store,
|
||||
businesses: demo(extra),
|
||||
roleFile,
|
||||
businesses: demo(),
|
||||
organization: (...args) => {
|
||||
organization.push(args);
|
||||
if (writer.during) writer.during(args);
|
||||
if (writer.error) throw writer.error;
|
||||
if (args[1] === 'instance.hire' || args[1] === 'instance.fire') return { revision: 'b'.repeat(64), transaction: 't-' + organization.length };
|
||||
// The human's limits read answers more than the revision; organization.revision must not.
|
||||
return args[1] === 'limits'
|
||||
? { revision: 'b'.repeat(64), limits: { total: 7 }, models: { m: {} }, tokens: { used: 1 } }
|
||||
@@ -653,403 +645,3 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
|
||||
);
|
||||
assert.throws(() => store.run(REVOKE_NEITHER), /revoke needs a resolved decision or a reason/);
|
||||
});
|
||||
|
||||
// Row 194 (#1692, D2.4): the per-agent forbid. The chief can't end a kept instance's run with a
|
||||
// reason or change its model. A decision the human resolved, and the human's own write, still can.
|
||||
const KEPT = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['coder'] } };
|
||||
|
||||
test('row 194 T10: the chief\'s reason end of a kept instance refuses instance-kept, holds the claim and spends nothing', (t) => {
|
||||
const { call, lastClaim, allowed } = setup(t, KEPT);
|
||||
assert.throws(() => call('chief', 'role.revoke', { role: 'coder', reason: 'replace it' }), /instance-kept/);
|
||||
assert.deepEqual(lastClaim('coder'), { op: 'claim', decision: null, reason: null, by: 'coder-run' });
|
||||
assert.equal(allowed('role.end').length, 0);
|
||||
// An instance not on the list still ends with a reason.
|
||||
assert.deepEqual(call('chief', 'role.revoke', { role: 'pm', reason: 'stuck' }), { revoked: true });
|
||||
});
|
||||
|
||||
test('row 194 T10 (Q2): a role.revoke citing a decision the human resolved still ends a kept run', (t) => {
|
||||
const { call, lastClaim } = setup(t, KEPT);
|
||||
const d = call('chief', 'decision.raise', {
|
||||
action: 'role.revoke', target: 'coder', question: 'Stop the coder?',
|
||||
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
|
||||
});
|
||||
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(call('chief', 'role.revoke', { role: 'coder', decision: d.id }), { revoked: true });
|
||||
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: d.id, reason: null, by: 'chief-run' });
|
||||
});
|
||||
|
||||
test('row 194 T10 (B7): the chief\'s organization.set of a kept instance\'s model refuses before the writer; the human\'s limits.set does not', (t) => {
|
||||
const { call, organization, allowed } = setup(t, KEPT);
|
||||
assert.throws(() => call('chief', 'organization.set', { revision: PATCH.revision, instances: { coder: 'm' } }), /instance-kept/);
|
||||
assert.throws(() => call('chief', 'organization.set', { revision: PATCH.revision, instances: { pm: 'm', coder: 'm' } }), /instance-kept/);
|
||||
assert.equal(organization.length, 0);
|
||||
assert.equal(allowed('organization.change').length, 0);
|
||||
call('chief', 'organization.set', { revision: PATCH.revision, instances: { pm: 'm' } });
|
||||
call('human', 'limits.set', { revision: PATCH.revision, instances: { coder: 'm' } });
|
||||
assert.deepEqual(organization.map(([, verb, args]) => [verb, Object.keys(args.instances)]), [['limits.set', ['pm']], ['limits.set', ['coder']]]);
|
||||
});
|
||||
|
||||
test('row 194 T13: organization.set and limits.set refuse the launch, retired, keep and noHire keys before the writer', (t) => {
|
||||
const { call, organization } = setup(t, KEPT);
|
||||
for (const extra of [{ launch: { keep: [] } }, { retired: [] }, { keep: ['pm'] }, { noHire: ['coder'] }])
|
||||
for (const [who, verb] of [['chief', 'organization.set'], ['human', 'limits.set']])
|
||||
assert.throws(() => call(who, verb, { ...PATCH, ...extra }), /invalid-request/, `${who} ${Object.keys(extra)[0]}`);
|
||||
assert.equal(organization.length, 0);
|
||||
});
|
||||
|
||||
test('row 194 T18 (D2.4): from a chief session veto.set and instances.set refuse, and nothing reaches the writer', (t) => {
|
||||
const { call, organization } = setup(t, KEPT);
|
||||
for (const op of ['keep', 'unkeep', 'nohire', 'hire'])
|
||||
assert.throws(() => call('chief', 'veto.set', { revision: PATCH.revision, op, name: 'coder' }), /human-required/, op);
|
||||
for (const op of ['add', 'change', 'delete'])
|
||||
assert.throws(() => call('chief', 'instances.set', { revision: PATCH.revision, instance: 'coder', op, definition: 'coder' }), /human-required/, op);
|
||||
assert.equal(organization.length, 0);
|
||||
// The human's veto reaches the writer with the human as actor.
|
||||
call('human', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' });
|
||||
assert.deepEqual(organization.at(-1), ['demo', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' }, 'jason']);
|
||||
});
|
||||
|
||||
// Row 194 (#1692, D2.1-D2.3): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes role.hire and
|
||||
// role.fire within-role for the chief (sage-1471, mos-1089), so they take the reason form, as
|
||||
// role.end does: no decision, a reason of 1 to 500 characters that goes into the log. The role
|
||||
// files are stubs, so a definition's authority is the test's to set.
|
||||
const LISTED = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['pm'], noHire: ['banned'] } };
|
||||
const roleDoc = (withinRole, extra = {}) => ({ roleVersion: 2, authority: { withinRole, crossRole: [] }, hireable: [], ...extra });
|
||||
const ROLE_FILES = {
|
||||
'chief-of-staff': roleDoc(chiefAuthority, { hireable: ['coder', 'researcher', 'wide', 'banned'] }),
|
||||
coder: roleDoc(['message.send', 'record.write']),
|
||||
// A version 1 file has empty authority lists, so only the version check refuses it (M60).
|
||||
researcher: { roleVersion: 1, authority: { withinRole: [], crossRole: [] }, hireable: [] },
|
||||
wide: roleDoc(['message.send', 'task.create']),
|
||||
banned: roleDoc(['message.send']),
|
||||
pm: roleDoc(['message.send']),
|
||||
};
|
||||
const roleFile = (files) => (name) => {
|
||||
if (!Object.hasOwn(files, name)) throw Object.assign(new Error(`no role ${name}`), { name: 'BusinessError' });
|
||||
return files[name];
|
||||
};
|
||||
function hireSetup(t, extra = LISTED, files = ROLE_FILES) {
|
||||
return setup(t, extra, { roleFile: roleFile(files) });
|
||||
}
|
||||
const HIRE = { name: 'helper', definition: 'coder', reason: 'more hands for the queue' };
|
||||
const FIRE = { name: 'coder', reason: 'the work is done' };
|
||||
const CONTEXT = (reason) => ({ instance: 'chief', run: 'chief-run', reason });
|
||||
const withoutReason = ({ reason, ...rest }) => rest;
|
||||
|
||||
test('row 194 T8: the chief hires with a reason and no decision, the writer gets the reason as context, and the log names it', (t) => {
|
||||
const { call, organization, allowed, store } = hireSetup(t);
|
||||
const result = call('chief', 'instance.hire', HIRE);
|
||||
assert.equal(result.transaction, 't-1');
|
||||
assert.deepEqual(organization, [['demo', 'instance.hire', withoutReason(HIRE), 'chief-of-staff.chief', CONTEXT(HIRE.reason)]]);
|
||||
const [use, ...rest] = allowed('role.hire');
|
||||
assert.equal(rest.length, 0);
|
||||
assert.equal(use.subject, 'helper');
|
||||
assert.deepEqual({ reason: use.body.reason, transaction: use.body.transaction, decision: use.body.decision ?? null }, { reason: HIRE.reason, transaction: 't-1', decision: null });
|
||||
// Within-role: no decision is raised or needed.
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM decisions').n, 0);
|
||||
// vars reach the writer with the name and definition.
|
||||
call('chief', 'instance.hire', { ...HIRE, name: 'aide', vars: { model: 'm', 'limits.x': 1 } });
|
||||
assert.deepEqual(organization.at(-1)[2], { name: 'aide', definition: 'coder', vars: { model: 'm', 'limits.x': 1 } });
|
||||
});
|
||||
|
||||
// T11 (D5): a business id names one business on one bus. The chief's hire and fire go to its own
|
||||
// business's writer, and no argument names another.
|
||||
test('row 194 T11: on a bus with two businesses, the chief of one hires and fires only in its own', (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-chief-two-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const organization = [];
|
||||
const one = demo(LISTED).demo;
|
||||
const b = new Broker({
|
||||
store,
|
||||
businesses: { demo: one, other: { ...one, id: 'other' } },
|
||||
roleFile: roleFile(ROLE_FILES),
|
||||
organization: (...args) => (organization.push(args), { revision: 'b'.repeat(64), transaction: 't-' + organization.length }),
|
||||
});
|
||||
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
b.request(chief, { verb: 'role.claim' });
|
||||
for (const verb of ['instance.hire', 'instance.fire'])
|
||||
for (const extra of [{ business: 'other' }, { target: 'other' }])
|
||||
assert.throws(() => b.request(chief, { verb, args: { ...(verb === 'instance.hire' ? HIRE : FIRE), ...extra } }), /invalid-request/, `${verb} ${Object.keys(extra)}`);
|
||||
assert.equal(organization.length, 0);
|
||||
b.request(chief, { verb: 'instance.hire', args: HIRE });
|
||||
b.request(chief, { verb: 'instance.fire', args: FIRE });
|
||||
assert.deepEqual(organization.map((c) => [c[0], c[1]]), [['demo', 'instance.hire'], ['demo', 'instance.fire']]);
|
||||
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE business='other'").n, 0);
|
||||
});
|
||||
|
||||
test('row 194 T8: hire and fire need a reason of 1 to 500 characters and refuse a decision key', (t) => {
|
||||
const { call, organization } = hireSetup(t);
|
||||
for (const reason of [undefined, '', 'x'.repeat(501), 7]) {
|
||||
const r = reason === undefined ? {} : { reason };
|
||||
assert.throws(() => call('chief', 'instance.hire', { name: 'helper', definition: 'coder', ...r }), /invalid-request/, String(reason));
|
||||
assert.throws(() => call('chief', 'instance.fire', { name: 'coder', ...r }), /invalid-request/, String(reason));
|
||||
}
|
||||
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: 'd-1' }), /invalid-request/);
|
||||
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, decision: 'd-1' }), /invalid-request/);
|
||||
for (const name of ['human', 'tester']) assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name }), /invalid-request/, name);
|
||||
assert.equal(organization.length, 0);
|
||||
call('chief', 'instance.hire', { ...HIRE, reason: 'x'.repeat(500) });
|
||||
assert.equal(organization.length, 1);
|
||||
});
|
||||
|
||||
test('row 194 T8 (sage-1471): only the chief hires or fires; every other role refuses role-not-chief, even one granted the actions', (t) => {
|
||||
const granted = { authority: { withinRole: ['message.send', 'role.hire', 'role.fire'], crossRole: [] } };
|
||||
const { call, organization, allowed } = hireSetup(t, { ...LISTED, roles: { ...roles, pm: { definition: 'pm', ...granted }, coder: { definition: 'coder', ...granted } } });
|
||||
for (const who of ['pm', 'coder', 'assistant']) {
|
||||
assert.throws(() => call(who, 'instance.hire', HIRE), /role-not-chief/, who);
|
||||
assert.throws(() => call(who, 'instance.fire', FIRE), /role-not-chief/, who);
|
||||
}
|
||||
assert.throws(() => call('tester', 'instance.hire', HIRE), /tester-refused/);
|
||||
assert.throws(() => call('reader', 'instance.hire', HIRE), /read-only/);
|
||||
assert.throws(() => call('human', 'instance.fire', FIRE), /agent-required/);
|
||||
assert.equal(organization.length, 0);
|
||||
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
|
||||
});
|
||||
|
||||
test('row 194 T8 (sage-1471): a chief whose authority lacks role.hire or role.fire refuses decision-required before the writer', (t) => {
|
||||
for (const missing of ['role.hire', 'role.fire']) {
|
||||
const authority = { withinRole: chiefAuthority.withinRole.filter((a) => a !== missing), crossRole: [] };
|
||||
const { call, organization, lastClaim } = hireSetup(t, { ...LISTED, roles: { ...roles, chief: { definition: 'chief-of-staff', authority } } });
|
||||
const [verb, args] = missing === 'role.hire' ? ['instance.hire', HIRE] : ['instance.fire', FIRE];
|
||||
assert.throws(() => call('chief', verb, args), /decision-required/, missing);
|
||||
assert.equal(organization.length, 0, missing);
|
||||
assert.equal(lastClaim('coder').op, 'claim', missing);
|
||||
// The other action still goes through.
|
||||
const [other, otherArgs] = missing === 'role.hire' ? ['instance.fire', FIRE] : ['instance.hire', HIRE];
|
||||
call('chief', other, otherArgs);
|
||||
assert.equal(organization.length, 1, missing);
|
||||
}
|
||||
});
|
||||
|
||||
test('row 194 T8a: a re-hire of a fired name goes to the writer like a hire, and the writer\'s refusals pass through by name', (t) => {
|
||||
const { call, organization, writer } = hireSetup(t);
|
||||
const again = { name: 'coder', definition: 'coder', reason: 'needed again' };
|
||||
for (const code of ['organization-instance-exists', 'organization-instance-retired', 'definition-not-hireable', 'instance-kept']) {
|
||||
writer.error = Object.assign(new Error(code), { code });
|
||||
assert.throws(() => call('chief', 'instance.hire', again), new RegExp(code));
|
||||
}
|
||||
writer.error = null;
|
||||
call('chief', 'instance.hire', again);
|
||||
assert.deepEqual(organization.at(-1).slice(1, 3), ['instance.hire', withoutReason(again)]);
|
||||
});
|
||||
|
||||
test('row 194 T8b: a definition is hireable only when every action is on HIRE_ALLOWED, it is version 2, the chief lists it and noHire doesn\'t', (t) => {
|
||||
assert.deepEqual([...HIRE_ALLOWED], ['message.send', 'record.write', 'review.request', 'task.update.assigned', 'task.scope.change', 'git.push.working']);
|
||||
for (const action of ACTIONS) {
|
||||
const files = { ...ROLE_FILES, wide: roleDoc(['message.send', action]) };
|
||||
const { call, organization } = hireSetup(t, LISTED, files);
|
||||
const run = () => call('chief', 'instance.hire', { ...HIRE, definition: 'wide' });
|
||||
if (HIRE_ALLOWED.includes(action)) {
|
||||
run();
|
||||
assert.equal(organization.length, 1, action);
|
||||
} else {
|
||||
assert.throws(run, /definition-not-hireable/, action);
|
||||
assert.equal(organization.length, 0, action);
|
||||
}
|
||||
}
|
||||
const { call, organization } = hireSetup(t);
|
||||
const hire = (definition) => call('chief', 'instance.hire', { ...HIRE, definition });
|
||||
// Version 1 (M60), noHire, not on the chief's list, and no role file.
|
||||
for (const definition of ['researcher', 'banned', 'pm', 'nothing'])
|
||||
assert.throws(() => hire(definition), /definition-not-hireable/, definition);
|
||||
assert.equal(organization.length, 0);
|
||||
hire('coder');
|
||||
assert.equal(organization.length, 1);
|
||||
});
|
||||
|
||||
test('row 194 T8b: a hire without the role files configured refuses not-configured', (t) => {
|
||||
const { call, organization } = setup(t, LISTED);
|
||||
assert.throws(() => call('chief', 'instance.hire', HIRE), /not-configured/);
|
||||
assert.equal(organization.length, 0);
|
||||
assert.throws(() => new Broker({ store: null, businesses: demo(), roleFile: 'roles' }), /roleFile must be a function/);
|
||||
});
|
||||
|
||||
test('row 194 T9: a fire revokes the claim, cancels open requests, asks the launcher to kill the runs, then delists and logs', (t) => {
|
||||
const { call, organization, allowed, lastClaim, writer } = hireSetup(t);
|
||||
// An open launch request of the coder, made by the human.
|
||||
const open = call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||
let seen = null;
|
||||
writer.during = ([, verb]) => { if (verb === 'instance.fire') seen = { stopped: allowed('fire.stopped').length, claim: lastClaim('coder').op, used: allowed('role.fire').length }; };
|
||||
const result = call('chief', 'instance.fire', FIRE);
|
||||
// Step A ran before the writer; step C after it.
|
||||
assert.deepEqual(seen, { stopped: 1, claim: 'revoke', used: 0 });
|
||||
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: null, reason: 'the work is done', by: 'chief-run' });
|
||||
assert.deepEqual(result.runs, ['coder-run']);
|
||||
assert.deepEqual(result.cancelled, [open]);
|
||||
assert.deepEqual(organization.at(-1), ['demo', 'instance.fire', { name: 'coder' }, 'chief-of-staff.chief', CONTEXT(FIRE.reason)]);
|
||||
const [end] = allowed('lifecycle.end');
|
||||
assert.equal(end.subject, 'coder');
|
||||
assert.deepEqual({ ...end.body, request: null }, { action: 'lifecycle.end', request: null, via: 'chief', by: 'chief', runs: ['coder-run'], reason: 'the work is done', kill: true });
|
||||
assert.equal(allowed('lifecycle.cancelled').filter((e) => e.body.request === open).length, 1);
|
||||
const [use] = allowed('role.fire');
|
||||
assert.equal(use.subject, 'coder');
|
||||
assert.equal(use.body.reason, 'the work is done');
|
||||
assert.equal(use.body.transaction, result.transaction);
|
||||
assert.deepEqual(allowed('fire.done').map((e) => [e.subject, e.body.transaction]), [['coder', result.transaction]]);
|
||||
// The human's read of the request shows the kill.
|
||||
assert.equal(call('human', 'instance.request', { request: end.body.request }).kill, true);
|
||||
});
|
||||
|
||||
test('row 194 T9: fire refusals come before step A and leave the claim, the requests and the file alone', (t) => {
|
||||
const { call, organization, allowed, lastClaim } = hireSetup(t, { launch: { by: 'chief', instances: ['coder'], keep: ['pm'] } });
|
||||
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'r' });
|
||||
assert.throws(() => fire('pm'), /instance-kept/);
|
||||
assert.throws(() => fire('nobody'), /unknown-role/);
|
||||
assert.throws(() => fire('assistant'), /organization-definition-refused/);
|
||||
assert.throws(() => fire('chief'), /organization-instance-referenced/);
|
||||
assert.throws(() => fire('coder'), /invalid-request/);
|
||||
assert.equal(organization.length, 0);
|
||||
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
|
||||
assert.equal(lastClaim('coder').op, 'claim');
|
||||
});
|
||||
|
||||
test('row 194 T9c: a writer failure at B leaves the instance firing, and the repeat delists and logs once', (t) => {
|
||||
const { call, b, caps, organization, allowed, writer } = hireSetup(t);
|
||||
writer.error = new Error('disk');
|
||||
assert.throws(() => call('chief', 'instance.fire', FIRE), /outcome-unknown/);
|
||||
writer.error = null;
|
||||
assert.equal(allowed('fire.stopped').length, 1);
|
||||
assert.equal(allowed('role.fire').length, 0);
|
||||
// W-N1: while it fires, no launch of the instance starts by any path.
|
||||
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
|
||||
assert.throws(() => b.authorize(caps.chief, 'role.launch', { target: 'coder' }), /instance-firing/);
|
||||
// The repeat has no claim to revoke. The revoked run is still bound until the launcher reports it
|
||||
// ended, so the repeat asks for its kill again, then delists and logs.
|
||||
const result = call('chief', 'instance.fire', FIRE);
|
||||
assert.deepEqual(result.runs, ['coder-run']);
|
||||
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
|
||||
assert.equal(allowed('role.fire').length, 1);
|
||||
assert.deepEqual(allowed('lifecycle.end').map((e) => e.body.kill), [true, true]);
|
||||
// Once done, the instance launches again.
|
||||
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
|
||||
});
|
||||
|
||||
test('row 194 T9c: a failure at C leaves the fire unlogged and the instance firing, and the repeat logs it', (t) => {
|
||||
const { call, organization, allowed, store, writer } = hireSetup(t);
|
||||
// The delist commits, then the step C transaction aborts.
|
||||
writer.during = () => store.run("CREATE TRIGGER fail_c BEFORE INSERT ON events WHEN json_extract(new.body,'$.action')='fire.done' BEGIN SELECT RAISE(ABORT, 'c failed'); END");
|
||||
assert.throws(() => call('chief', 'instance.fire', FIRE));
|
||||
writer.during = null;
|
||||
store.run('DROP TRIGGER fail_c');
|
||||
assert.equal(allowed('role.fire').length, 0);
|
||||
assert.equal(allowed('fire.done').length, 0);
|
||||
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
|
||||
call('chief', 'instance.fire', FIRE);
|
||||
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
|
||||
assert.equal(allowed('role.fire').length, 1);
|
||||
assert.equal(allowed('fire.done').length, 1);
|
||||
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
|
||||
});
|
||||
|
||||
test('row 194 W-N1: a fire.done before a later fire.stopped of the same instance leaves it firing; another instance\'s done does not end it', (t) => {
|
||||
const { call, store } = hireSetup(t);
|
||||
const step = (instance, action) =>
|
||||
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
|
||||
`${action}-${instance}-${Math.random()}`, new Date().toISOString(), instance, JSON.stringify({ action, request: null, transaction: null }));
|
||||
step('coder', 'fire.done');
|
||||
step('coder', 'fire.stopped');
|
||||
step('pm', 'fire.done');
|
||||
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
|
||||
step('coder', 'fire.done');
|
||||
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
|
||||
});
|
||||
|
||||
test('row 194 T10: a hire or fire of a kept name refuses instance-kept before the writer', (t) => {
|
||||
const { call, organization, lastClaim, allowed } = hireSetup(t);
|
||||
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name: 'pm' }), /instance-kept/);
|
||||
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, name: 'pm' }), /instance-kept/);
|
||||
assert.equal(organization.length, 0);
|
||||
assert.equal(lastClaim('pm').op, 'claim');
|
||||
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
|
||||
});
|
||||
|
||||
test('row 194 T18: hire and fire take only their own keys, and a refused call writes nothing', (t) => {
|
||||
const { call, organization, allowed } = hireSetup(t);
|
||||
for (const extra of [{ keep: ['x'] }, { launch: {} }, { revision: 'a'.repeat(64) }, { force: true }]) {
|
||||
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
|
||||
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
|
||||
}
|
||||
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, vars: { harness: 'pi' } }), /invalid-request/);
|
||||
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, definition: 'coder' }), /invalid-request/);
|
||||
assert.equal(organization.length, 0);
|
||||
assert.equal(allowed('fire.stopped').length, 0);
|
||||
});
|
||||
|
||||
test('row 194 T8, T9, T18: through the runtime the chief hires and fires with the real writer, the receipt carries the reason, and a refusal leaves the file alone', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-chief-hire-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
// A hireable scratch definition: version 2, no credentials, every action on HIRE_ALLOWED.
|
||||
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
|
||||
writeJson(join(rolesDir, 'aide.json'), {
|
||||
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
|
||||
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
|
||||
});
|
||||
const chiefFile = join(rolesDir, 'chief-of-staff.json');
|
||||
writeJson(chiefFile, { ...JSON.parse(readFileSync(chiefFile, 'utf8')), hireable: ['aide'] });
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
writeJson(file, { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
// The boot copy is the file's own bus form, as cli bootConfig builds it, so a write reloads.
|
||||
const busForm = (dir, roles) => {
|
||||
const b = loadBusiness('demo', { dir, rolesDir: roles });
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(dir), business: b, instance });
|
||||
return busBusiness(b, resolved);
|
||||
};
|
||||
const bus = await startBroker({
|
||||
dataRoot: join(root, 'data'),
|
||||
businesses: { demo: busForm(root, rolesDir) },
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||
});
|
||||
t.after(async () => {
|
||||
await bus.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const { broker } = bus;
|
||||
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
broker.request(chief, { verb: 'role.claim' });
|
||||
const call = (cap, verb, args) => broker.request(cap, { verb, args });
|
||||
const receipts = () =>
|
||||
readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).filter((r) => r.state === 'committed');
|
||||
const document = () => JSON.parse(readFileSync(file, 'utf8'));
|
||||
|
||||
// The shipped chief file grants both actions, so the runtime takes the reason form.
|
||||
// A refused hire: coder declares credentials and isn't on the chief's list. The file is unchanged.
|
||||
const bytes = readFileSync(file, 'utf8');
|
||||
assert.throws(() => call(chief, 'instance.hire', { name: 'helper', definition: 'coder', reason: 'r' }), /definition-not-hireable/);
|
||||
assert.equal(readFileSync(file, 'utf8'), bytes);
|
||||
|
||||
call(chief, 'instance.hire', { name: 'helper', definition: 'aide', reason: 'more hands' });
|
||||
assert.deepEqual(document().roles.helper, { definition: 'aide' });
|
||||
assert.ok(document().launch.instances.includes('helper'));
|
||||
const [h] = receipts().slice(-1);
|
||||
assert.equal(h.actor, 'chief-of-staff.chief');
|
||||
assert.deepEqual(h.context, CONTEXT('more hands'));
|
||||
|
||||
const result = call(chief, 'instance.fire', { name: 'helper', reason: 'the work is done' });
|
||||
assert.equal(result.delisted, true);
|
||||
assert.ok(!document().launch.instances.includes('helper'));
|
||||
assert.deepEqual(receipts().slice(-1)[0].context, CONTEXT('the work is done'));
|
||||
// The reloaded business sees the delist: a refused call leaves the file alone.
|
||||
const after = readFileSync(file, 'utf8');
|
||||
assert.throws(() => call(chief, 'instance.fire', { name: 'helper', reason: 'again', extra: 1 }), /invalid-request/);
|
||||
assert.equal(readFileSync(file, 'utf8'), after);
|
||||
});
|
||||
|
||||
test('row 194 W-N1: the launcher\'s take of a launch or regenerate of a firing instance closes it as instance-firing', (t) => {
|
||||
const { b, call, store } = hireSetup(t);
|
||||
const launch = call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||
const regenerate = call('human', 'instance.regenerate', { instance: 'pm' }).request;
|
||||
// A fire's step A as the store holds it with no step C, for requests step A didn't see.
|
||||
for (const instance of ['coder', 'pm'])
|
||||
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
|
||||
'stopped-' + instance, new Date().toISOString(), instance, JSON.stringify({ action: 'fire.stopped', request: null }));
|
||||
for (const request of [launch, regenerate]) {
|
||||
const { go, refusal } = b.lifecycleTake({ business: 'demo', request });
|
||||
assert.deepEqual({ go, refusal }, { go: false, refusal: 'instance-firing' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker, LIFECYCLE_HUMAN_VERBS, ORGANIZATION_KEYS, TESTER_ORGANIZATION_VERBS } from '../src/broker.mjs';
|
||||
import { Broker, ORGANIZATION_KEYS } from '../src/broker.mjs';
|
||||
|
||||
const chiefAuthority = JSON.parse(readFileSync(fileURLToPath(new URL('../../../roles/chief-of-staff.json', import.meta.url)), 'utf8')).authority;
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
@@ -75,8 +75,6 @@ test('row 165 runtime: an add and a change reach the organization file with the
|
||||
assert.equal(view.definitions['merge-gate'].accepted, true);
|
||||
const added = bus.broker.request(human, { verb: 'instances.set', args: { ...ADD, revision: view.revision } });
|
||||
assert.equal(added.instances['sec-1'].launched, true);
|
||||
// Row 194 (D3 item 3): this boot copy's coder has no credential references and the file's has, so
|
||||
// the bus doesn't reload and asks for a restart. reload.test.mjs boots from the file.
|
||||
assert.equal(added.restart, true);
|
||||
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).roles['sec-1'], { definition: 'security-reviewer' });
|
||||
const first = log().at(-1);
|
||||
@@ -96,42 +94,3 @@ test('row 165 runtime: an add and a change reach the organization file with the
|
||||
assert.equal(readFileSync(file, 'utf8'), before);
|
||||
assert.equal(log().length, entries, 'a refused write logs nothing');
|
||||
});
|
||||
|
||||
// Row 194 (#1692, D4, D4.1): the human lifecycle verbs so far, veto.set and instances.set's three ops.
|
||||
test('row 194 T12: agent and reader sessions refuse veto.set; the tester gets add and change but not delete or veto.set; the human passes the hook for each listed verb', (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'instances-verb-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const calls = [];
|
||||
const b = new Broker({ store, businesses: biz(), organization: (id, verb, args, actor) => (calls.push([verb, args.op, actor]), verb === 'instances.check' ? { revision: ADD.revision } : { ok: true }) });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const tester = b.bindTester({ business: 'demo' });
|
||||
const caps = { reader: b.bindReader({ business: 'demo' }) };
|
||||
for (const role of ['chief', 'pm']) caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
|
||||
const VETO = { revision: ADD.revision, op: 'keep', name: 'pm' };
|
||||
for (const [who, cap] of Object.entries(caps)) {
|
||||
assert.throws(() => b.request(cap, { verb: 'veto.set', args: VETO }), /human-required/, who);
|
||||
assert.throws(() => b.request(cap, { verb: 'instances.set', args: { ...ADD, op: 'delete' } }), /human-required/, who);
|
||||
}
|
||||
assert.throws(() => b.request(tester, { verb: 'veto.set', args: VETO }), /tester-refused/);
|
||||
assert.throws(() => b.request(tester, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'delete' } }), /tester-refused/);
|
||||
assert.equal(TESTER_ORGANIZATION_VERBS.includes('veto.set'), false);
|
||||
assert.equal(calls.length, 0);
|
||||
// veto.set takes exactly its three keys, and instances.set only its three ops.
|
||||
for (const args of [{ ...VETO, actor: 'forged' }, { revision: VETO.revision, op: 'keep' }, { ...VETO, holder: 'mos' }])
|
||||
assert.throws(() => b.request(human, { verb: 'veto.set', args }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'instances.set', args: { ...ADD, op: 'remove' } }), /invalid-request/);
|
||||
assert.equal(calls.length, 0);
|
||||
// The hook refuses a verb missing from LIFECYCLE_HUMAN_VERBS, so each call below passing shows the
|
||||
// hook got that exact entry.
|
||||
assert.deepEqual([...LIFECYCLE_HUMAN_VERBS].sort(), ['instance.end', 'instance.launch', 'instance.regenerate', 'instances.set add', 'instances.set change', 'instances.set delete', 'veto.set']);
|
||||
b.request(human, { verb: 'veto.set', args: VETO });
|
||||
b.request(human, { verb: 'instances.set', args: ADD });
|
||||
b.request(human, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'change', vars: {} } });
|
||||
// S2c: the delete is a request after the writer's check, and writes nothing yet.
|
||||
assert.ok(b.request(human, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'delete' } }).request);
|
||||
b.request(tester, { verb: 'instances.set', args: ADD });
|
||||
b.request(tester, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'change', vars: {} } });
|
||||
assert.deepEqual(calls, [['veto.set', 'keep', 'jason'], ['instances.set', 'add', 'jason'], ['instances.set', 'change', 'jason'],
|
||||
['instances.check', undefined, 'jason'], ['instances.set', 'add', 'tester'], ['instances.set', 'change', 'tester']]);
|
||||
});
|
||||
|
||||
@@ -1,373 +0,0 @@
|
||||
// Row 194 (#1692, D1.1, V2-V5, D4.1): the broker half of the lifecycle verbs. Launch, regenerate
|
||||
// and delete are requests the launcher's pass takes through the host ops. Terminate revokes and
|
||||
// cancels at the verb and names the runs the pass signals. The launcher half is in cli lifecycle.test.mjs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker, LIFECYCLE_REQUEST_MS, TESTER_VERBS } from '../src/broker.mjs';
|
||||
|
||||
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const R = 'a'.repeat(64);
|
||||
const doc = (extra = {}) => ({
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' },
|
||||
vars: { 'bus.tester': true },
|
||||
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: shipped('chief-of-staff').authority },
|
||||
pm: { definition: 'pm', ...none },
|
||||
coder: { definition: 'coder', ...none },
|
||||
assistant: { definition: 'assistant', ...none },
|
||||
},
|
||||
retired: ['old'],
|
||||
...extra,
|
||||
});
|
||||
const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'only-there', delivery: 'only-there' }, roles: { 'only-there': none } };
|
||||
|
||||
function setup(t, { organization = null, testerOn = undefined } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'lifecycle-verbs-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const tester = { on: true };
|
||||
const b = new Broker({ store, businesses: { demo: doc(), other: OTHER }, organization,
|
||||
testerOn: testerOn === undefined ? () => tester.on : testerOn });
|
||||
const caps = {
|
||||
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
||||
tester: b.bindTester({ business: 'demo' }),
|
||||
reader: b.bindReader({ business: 'demo' }),
|
||||
};
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
|
||||
const ask = (who, verb, args = {}) => code(() => call(who, verb, args));
|
||||
let runs = 0;
|
||||
const bind = (role, { claim = true, thread, name = `${role}-${++runs}` } = {}) => {
|
||||
caps[name] = b.bindLaunch({ business: 'demo', role, run: name, harness: 'pi', ...(thread ? { thread } : {}) });
|
||||
if (claim) call(name, 'role.claim');
|
||||
return name;
|
||||
};
|
||||
const lifecycle = () => store.get("SELECT count(*) n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action') LIKE 'lifecycle.%'").n;
|
||||
const inputs = () => store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n;
|
||||
const take = (request) => b.lifecycleTake({ business: 'demo', request });
|
||||
const done = (request, extra = {}) => b.lifecycleDone({ business: 'demo', request, ...extra });
|
||||
const state = (request) => call('human', 'instance.request', { request });
|
||||
return { b, store, caps, call, code, ask, bind, lifecycle, inputs, take, done, state, tester };
|
||||
}
|
||||
// Moves the clock past a request's 60 s, for the rest of the test.
|
||||
const later = (t) => { const now = Date.now(); t.mock.method(Date, 'now', () => now + LIFECYCLE_REQUEST_MS + 1000); };
|
||||
|
||||
test('row 194 T12: agent and reader sessions refuse the lifecycle verbs before any effect; the tester gets launch, end and the request read, never regenerate', (t) => {
|
||||
const f = setup(t);
|
||||
f.bind('chief');
|
||||
f.bind('pm');
|
||||
const verbs = [['instance.launch', { instance: 'coder' }], ['instance.regenerate', { instance: 'coder' }],
|
||||
['instance.end', { instance: 'coder' }], ['instance.request', { request: 'r1' }]];
|
||||
for (const [verb, args] of verbs) {
|
||||
assert.equal(f.ask('reader', verb, args), 'read-only', `reader ${verb}`);
|
||||
for (const who of ['chief-1', 'pm-2']) assert.equal(f.ask(who, verb, args), 'human-required', `${who} ${verb}`);
|
||||
}
|
||||
assert.equal(f.ask('tester', 'instance.regenerate', { instance: 'coder' }), 'tester-refused');
|
||||
assert.equal(f.lifecycle(), 0);
|
||||
for (const verb of ['instance.launch', 'instance.end', 'instance.request']) assert.ok(TESTER_VERBS.includes(verb), verb);
|
||||
assert.equal(TESTER_VERBS.includes('instance.regenerate'), false);
|
||||
// Each human verb passes the D4 hook with its LIFECYCLE_HUMAN_VERBS entry.
|
||||
const { request } = f.call('human', 'instance.launch', { instance: 'coder' });
|
||||
f.call('human', 'instance.regenerate', { instance: 'pm' });
|
||||
assert.deepEqual(f.call('human', 'instance.end', { instance: 'coder' }).cancelled, [request]);
|
||||
});
|
||||
|
||||
test('row 194 V2 and V4 at the verb: the request is open, by the human, with a human.input; thread, retired, unknown and revoked refuse with no event', (t) => {
|
||||
const f = setup(t);
|
||||
const before = f.inputs();
|
||||
const { request } = f.call('human', 'instance.launch', { instance: 'coder' });
|
||||
const r = f.state(request);
|
||||
assert.deepEqual([r.action, r.instance, r.by, r.state, r.done, r.cancelled], ['lifecycle.launch', 'coder', 'jason', 'open', null, null]);
|
||||
assert.equal(f.inputs(), before + 1);
|
||||
const raw = JSON.parse(f.store.get("SELECT body FROM events WHERE json_extract(body,'$.request')=? AND json_extract(body,'$.action')='lifecycle.launch'", request).body);
|
||||
assert.deepEqual(raw, { action: 'lifecycle.launch', request, via: 'cli', by: 'jason' });
|
||||
const count = f.lifecycle();
|
||||
assert.equal(f.ask('human', 'instance.launch', { instance: 'assistant' }), 'not-thread-launch');
|
||||
assert.equal(f.ask('human', 'instance.regenerate', { instance: 'assistant' }), 'regenerate-thread');
|
||||
assert.equal(f.ask('human', 'instance.launch', { instance: 'old' }), 'instance-retired');
|
||||
assert.equal(f.ask('human', 'instance.regenerate', { instance: 'old' }), 'instance-retired');
|
||||
assert.equal(f.ask('human', 'instance.launch', { instance: 'ghost' }), 'unknown-role');
|
||||
assert.equal(f.ask('human', 'instance.launch', { instance: 'coder', extra: 1 }), 'invalid-request');
|
||||
assert.equal(f.ask('human', 'instance.launch', { instance: '../x' }), 'invalid-request');
|
||||
f.call('human', 'launch.revoke');
|
||||
assert.equal(f.ask('human', 'instance.launch', { instance: 'coder' }), 'launch-revoked');
|
||||
assert.equal(f.ask('human', 'instance.regenerate', { instance: 'coder' }), 'launch-revoked');
|
||||
assert.equal(f.lifecycle(), count);
|
||||
assert.equal(f.ask('human', 'instance.request', { request: 'no-such' }), 'unknown-request');
|
||||
});
|
||||
|
||||
test('row 194 D1.1 take: a request is taken once, closes once, and lifecycleRequests lists only what is not closed', (t) => {
|
||||
const f = setup(t);
|
||||
const { request } = f.call('human', 'instance.launch', { instance: 'coder' });
|
||||
const second = f.call('human', 'instance.regenerate', { instance: 'pm' }).request;
|
||||
assert.deepEqual(f.b.lifecycleRequests('demo').map((r) => [r.request, r.state]), [[request, 'open'], [second, 'open']]);
|
||||
assert.equal(f.code(() => f.done(request, { run: 'run-x' })), 'request-not-taken');
|
||||
const go = f.take(request);
|
||||
assert.deepEqual([go.go, go.refusal, go.state, go.action, go.instance, go.by], [true, null, 'taken', 'lifecycle.launch', 'coder', 'jason']);
|
||||
const again = f.take(request);
|
||||
assert.deepEqual([again.go, again.refusal, again.state], [false, 'request-closed', 'taken']);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.action')='lifecycle.taken'").n, 1);
|
||||
assert.equal(f.code(() => f.done(request, { refusal: 'Not A Code' })), 'invalid-request');
|
||||
assert.equal(f.code(() => f.done(request, { run: '../x' })), 'invalid-request');
|
||||
assert.equal(f.code(() => f.done(request, { extra: 1 })), 'invalid-request');
|
||||
const closed = f.done(request, { run: 'run-x' });
|
||||
assert.deepEqual([closed.state, closed.done], ['closed', { run: 'run-x', transaction: null, refusal: null }]);
|
||||
assert.equal(f.code(() => f.done(request, { run: 'run-x' })), 'request-closed');
|
||||
assert.deepEqual(f.take(request).refusal, 'request-closed');
|
||||
assert.deepEqual(f.b.lifecycleRequests('demo').map((r) => r.request), [second]);
|
||||
assert.equal(f.code(() => f.take('no-such')), 'unknown-request');
|
||||
assert.equal(f.code(() => f.b.lifecycleRequests('ghost')), 'unknown-business');
|
||||
// A request from one business isn't another's.
|
||||
assert.equal(f.code(() => f.b.lifecycleTake({ business: 'other', request: second })), 'unknown-request');
|
||||
});
|
||||
|
||||
test('row 194 D1.1 take: a launch.revoke after the request closes it launch-revoked, and a request past 60 s closes request-expired', (t) => {
|
||||
const f = setup(t);
|
||||
const revoked = f.call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||
const regen = f.call('human', 'instance.regenerate', { instance: 'coder' }).request;
|
||||
f.call('human', 'launch.revoke');
|
||||
for (const request of [revoked, regen]) {
|
||||
const r = f.take(request);
|
||||
assert.deepEqual([r.go, r.refusal, r.state, r.done.refusal], [false, 'launch-revoked', 'closed', 'launch-revoked']);
|
||||
}
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.action')='lifecycle.taken'").n, 0);
|
||||
f.call('human', 'launch.restore');
|
||||
const old = f.call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||
later(t);
|
||||
const r = f.take(old);
|
||||
assert.deepEqual([r.go, r.refusal, r.state], [false, 'request-expired', 'closed']);
|
||||
assert.deepEqual(f.b.lifecycleRequests('demo'), []);
|
||||
});
|
||||
|
||||
test('row 194 T12a: the tester launches and ends on its own business with actor tester; bus.tester off in the file refuses the call and the take tester-off', (t) => {
|
||||
const f = setup(t);
|
||||
const inputs = f.inputs();
|
||||
const { request } = f.call('tester', 'instance.launch', { instance: 'coder' });
|
||||
const r = f.call('tester', 'instance.request', { request });
|
||||
assert.deepEqual([r.by, r.state], ['tester', 'open']);
|
||||
assert.equal(JSON.parse(f.store.get("SELECT body FROM events WHERE json_extract(body,'$.request')=? AND json_extract(body,'$.action')='lifecycle.launch'", request).body).via, 'tester');
|
||||
assert.equal(f.inputs(), inputs, 'a tester request is not human input');
|
||||
const human = f.call('human', 'instance.launch', { instance: 'pm' }).request;
|
||||
// A tester reads only its own requests.
|
||||
assert.equal(f.ask('tester', 'instance.request', { request: human }), 'unknown-request');
|
||||
// The cap is bound to demo: an instance only `other` declares is unknown here.
|
||||
assert.equal(f.ask('tester', 'instance.launch', { instance: 'only-there' }), 'unknown-role');
|
||||
// bus.tester switched off in the file, with no restart.
|
||||
f.tester.on = false;
|
||||
assert.equal(f.ask('tester', 'instance.launch', { instance: 'coder' }), 'tester-off');
|
||||
assert.equal(f.ask('tester', 'instance.end', { instance: 'coder' }), 'tester-off');
|
||||
const off = f.take(request);
|
||||
assert.deepEqual([off.go, off.refusal], [false, 'tester-off']);
|
||||
assert.equal(f.take(human).go, true, 'the human request is not the tester switch');
|
||||
// Anything but exactly true is off.
|
||||
f.tester.on = 'true';
|
||||
assert.equal(f.ask('tester', 'instance.launch', { instance: 'coder' }), 'tester-off');
|
||||
f.tester.on = true;
|
||||
const later2 = f.call('tester', 'instance.launch', { instance: 'coder' }).request;
|
||||
const ended = f.call('tester', 'instance.end', { instance: 'coder' });
|
||||
assert.deepEqual(ended.cancelled, [later2]);
|
||||
assert.equal(f.state(ended.request).by, 'tester');
|
||||
assert.equal(f.state(later2).cancelled.by, 'tester');
|
||||
});
|
||||
|
||||
test('row 194 V3 terminate: claims revoked, open requests cancelled, bound runs named, taken requests listed; nothing at all answers not-held', (t) => {
|
||||
const f = setup(t);
|
||||
const held = f.bind('coder');
|
||||
const unclaimed = f.bind('coder', { claim: false });
|
||||
const open = f.call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||
const taken = f.call('human', 'instance.regenerate', { instance: 'coder' }).request;
|
||||
f.take(taken);
|
||||
const other = f.call('human', 'instance.launch', { instance: 'pm' }).request;
|
||||
const out = f.call('human', 'instance.end', { instance: 'coder', reason: 'stuck on a loop' });
|
||||
assert.deepEqual([out.runs, out.cancelled, out.taken], [[held, unclaimed], [open], [taken]]);
|
||||
const claim = f.store.get("SELECT * FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1");
|
||||
assert.deepEqual([claim.op, claim.holder_run, claim.by, claim.reason], ['revoke', held, 'jason', 'stuck on a loop']);
|
||||
assert.deepEqual([f.state(open).state, f.state(open).cancelled], ['closed', { by: 'jason' }]);
|
||||
assert.equal(f.state(taken).state, 'taken', 'a taken request is past cancelling');
|
||||
assert.equal(f.state(other).state, 'open', 'another instance keeps its request');
|
||||
// The end is a request the pass takes, with the runs to signal. It never expires.
|
||||
const end = f.state(out.request);
|
||||
assert.deepEqual([end.action, end.runs, end.state], ['lifecycle.end', [held, unclaimed], 'open']);
|
||||
later(t);
|
||||
assert.equal(f.take(out.request).go, true);
|
||||
assert.equal(f.take(open).refusal, 'request-closed');
|
||||
// The runs stay named while bound and not ended.
|
||||
f.done(taken, { run: 'coder-new' });
|
||||
for (const run of [held, unclaimed]) f.b.endLaunch({ business: 'demo', run, reason: 'signalled' });
|
||||
assert.equal(f.ask('human', 'instance.end', { instance: 'coder' }), 'not-held');
|
||||
assert.equal(f.ask('human', 'instance.end', { instance: 'old' }), 'unknown-role');
|
||||
assert.equal(f.ask('human', 'instance.end', { instance: 'coder', reason: '' }), 'invalid-request');
|
||||
});
|
||||
|
||||
test('row 194 V3 terminate of a thread instance: the human ends every thread, the tester only its own', (t) => {
|
||||
const f = setup(t);
|
||||
const hp = f.call('human', 'project.create', { name: 'human work' }).id;
|
||||
const tp = f.call('tester', 'project.create', { name: 'test work' }).id;
|
||||
const H = f.call('human', 'thread.create', { project: hp, name: 'mine' }).id;
|
||||
const T = f.call('tester', 'thread.create', { project: tp, name: 'probe' }).id;
|
||||
const h = f.bind('assistant', { thread: H });
|
||||
const tr = f.bind('assistant', { thread: T });
|
||||
const tester = f.call('tester', 'instance.end', { instance: 'assistant' });
|
||||
assert.deepEqual(tester.runs, [tr]);
|
||||
assert.equal(f.store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', H).op, 'claim');
|
||||
assert.equal(f.store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', T).op, 'revoke');
|
||||
const human = f.call('human', 'instance.end', { instance: 'assistant' });
|
||||
assert.deepEqual(human.runs, [h, tr]);
|
||||
assert.equal(f.store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', H).op, 'revoke');
|
||||
});
|
||||
|
||||
// The writer stand-in: instances.check answers per instance, instances.set commits a transaction.
|
||||
function writer(answers) {
|
||||
const calls = [];
|
||||
const organization = (id, verb, args, actor) => {
|
||||
calls.push([verb, args, actor]);
|
||||
if (verb === 'instances.check') {
|
||||
const a = answers[args.instance] ?? { revision: R };
|
||||
if (a instanceof Error) throw a;
|
||||
return a;
|
||||
}
|
||||
if (verb === 'instances.set') return { transaction: `tx-${calls.length}`, restart: false };
|
||||
return { ok: true };
|
||||
};
|
||||
return { calls, organization };
|
||||
}
|
||||
const refused = (code) => Object.assign(new Error(code), { code });
|
||||
const lastListed = () => Object.assign(new Error('coder is the last name in launch.instances'), { name: 'BusinessError' });
|
||||
|
||||
test('row 194 T6 V5 order at the verb: the writer\'s named refusals, then instance-running, instance-starting, the last-listed refusal and the revision', (t) => {
|
||||
const w = writer({ chief: refused('organization-instance-referenced'), pm: lastListed(), coder: lastListed() });
|
||||
const f = setup(t, { organization: w.organization });
|
||||
const del = (instance, revision = R) => f.ask('human', 'instances.set', { revision, instance, op: 'delete' });
|
||||
// A named refusal comes before the broker's checks, even with a claim held.
|
||||
f.bind('chief');
|
||||
assert.equal(del('chief'), 'organization-instance-referenced');
|
||||
// The last-listed refusal waits until running and starting have passed.
|
||||
const run = f.bind('coder');
|
||||
assert.equal(del('coder'), 'instance-running');
|
||||
f.call('human', 'instance.end', { instance: 'coder' });
|
||||
f.b.endLaunch({ business: 'demo', run, reason: 'signalled' });
|
||||
const starting = f.call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||
assert.equal(del('coder'), 'instance-starting');
|
||||
f.take(starting);
|
||||
assert.equal(del('coder'), 'instance-starting', 'a taken request with no done is still starting');
|
||||
f.done(starting, { refusal: 'launch-refused' });
|
||||
assert.equal(del('coder'), 'invalid-request');
|
||||
assert.equal(del('pm'), 'invalid-request');
|
||||
assert.equal(del('assistant', 'b'.repeat(64)), 'revision-conflict');
|
||||
assert.equal(del('assistant', 'not-a-revision'), 'invalid-request');
|
||||
assert.equal(f.ask('human', 'instances.set', { revision: R, instance: 'assistant', op: 'delete', definition: 'x' }), 'invalid-request');
|
||||
assert.ok(w.calls.every(([verb]) => verb === 'instances.check'), 'the checks write nothing');
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.action')='lifecycle.delete'").n, 0);
|
||||
const { request } = f.call('human', 'instances.set', { revision: R, instance: 'assistant', op: 'delete' });
|
||||
const r = f.state(request);
|
||||
assert.deepEqual([r.action, r.instance, r.by, r.revision, r.retry, r.state], ['lifecycle.delete', 'assistant', 'jason', R, false, 'open']);
|
||||
assert.deepEqual(w.calls.at(-1), ['instances.check', { instance: 'assistant' }, 'jason']);
|
||||
// An open delete is a starting request too: a second delete waits for the first.
|
||||
assert.equal(del('assistant'), 'instance-starting');
|
||||
});
|
||||
|
||||
test('row 194 V5: an open launch past its 60 s is not starting, as it can only close request-expired', (t) => {
|
||||
const w = writer({});
|
||||
const f = setup(t, { organization: w.organization });
|
||||
f.call('human', 'instance.launch', { instance: 'coder' });
|
||||
later(t);
|
||||
assert.ok(f.call('human', 'instances.set', { revision: R, instance: 'coder', op: 'delete' }).request);
|
||||
});
|
||||
|
||||
test('row 194 V5 after the take: lifecycleDelete re-checks running and starting, then commits through the writer with the human as actor', (t) => {
|
||||
const w = writer({});
|
||||
const f = setup(t, { organization: w.organization });
|
||||
const { request } = f.call('human', 'instances.set', { revision: R, instance: 'coder', op: 'delete' });
|
||||
const del = () => f.b.lifecycleDelete({ business: 'demo', request });
|
||||
assert.equal(f.code(del), 'request-not-taken');
|
||||
assert.equal(f.take(request).go, true);
|
||||
// A launch recorded after the delete's verb and a claim taken since both stop it.
|
||||
const launch = f.call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||
assert.equal(f.code(del), 'instance-starting');
|
||||
f.call('human', 'instance.end', { instance: 'coder' });
|
||||
const run = f.bind('coder');
|
||||
assert.equal(f.code(del), 'instance-running');
|
||||
f.call('human', 'instance.end', { instance: 'coder' });
|
||||
f.b.endLaunch({ business: 'demo', run, reason: 'signalled' });
|
||||
assert.equal(f.state(launch).cancelled.by, 'jason');
|
||||
const out = del();
|
||||
assert.deepEqual(w.calls.at(-1), ['instances.set', { revision: R, instance: 'coder', op: 'delete' }, 'jason']);
|
||||
assert.deepEqual(out, { transaction: out.transaction, retry: false });
|
||||
assert.match(out.transaction, /^tx-/);
|
||||
assert.equal(f.done(request, { transaction: out.transaction }).done.transaction, out.transaction);
|
||||
const launchReq = f.call('human', 'instance.launch', { instance: 'pm' }).request;
|
||||
assert.equal(f.code(() => f.b.lifecycleDelete({ business: 'demo', request: launchReq })), 'request-not-taken');
|
||||
});
|
||||
|
||||
test('row 194 V5 retry: a retired name deletes again only after workspace-rename-failed, and that request commits nothing', (t) => {
|
||||
const w = writer({});
|
||||
const f = setup(t, { organization: w.organization });
|
||||
assert.equal(f.ask('human', 'instances.set', { revision: R, instance: 'old', op: 'delete' }), 'organization-instance-retired');
|
||||
const { request } = f.call('human', 'instances.set', { revision: R, instance: 'pm', op: 'delete' });
|
||||
f.take(request);
|
||||
const { transaction } = f.b.lifecycleDelete({ business: 'demo', request });
|
||||
f.done(request, { transaction, refusal: 'workspace-rename-failed' });
|
||||
// The bus reloads after the commit, as the runtime does.
|
||||
const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'coder', delivery: 'chief' } });
|
||||
delete next.roles.pm;
|
||||
f.b.reloadBusiness('demo', next);
|
||||
const writes = w.calls.length;
|
||||
const retry = f.call('human', 'instances.set', { revision: R, instance: 'pm', op: 'delete' }).request;
|
||||
assert.deepEqual([f.state(retry).retry, f.state(retry).revision], [true, R]);
|
||||
f.take(retry);
|
||||
// S3: the retry answers the failed delete's transaction, which names the rename's target.
|
||||
assert.ok(transaction);
|
||||
assert.deepEqual(f.b.lifecycleDelete({ business: 'demo', request: retry }), { transaction, retry: true });
|
||||
assert.equal(w.calls.length, writes, 'a retry neither checks nor writes the file');
|
||||
f.done(retry);
|
||||
assert.equal(f.ask('human', 'instances.set', { revision: R, instance: 'pm', op: 'delete' }), 'organization-instance-retired');
|
||||
});
|
||||
|
||||
test('row 194 V5 runtime: a delete request, the take and lifecycleDelete commit the file with retired, log the human and reload so the name no longer binds', async (t) => {
|
||||
const { startBroker } = await import('../src/runtime.mjs');
|
||||
const { busBusiness } = await import('../src/business.mjs');
|
||||
const { loadBusiness } = await import('../../business/src/business.mjs');
|
||||
const { resolveInstance } = await import('../../business/src/resolve.mjs');
|
||||
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'lifecycle-runtime-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const b = loadBusiness('demo', { dir: root, rolesDir });
|
||||
const resolved = {};
|
||||
for (const i of Object.keys(b.roles)) resolved[i] = resolveInstance({ system: systemFor(root), business: b, instance: i });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busBusiness(b, resolved) }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const ask = (verb, args) => bus.broker.request(human, { verb, args });
|
||||
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
|
||||
const revision = () => ask('instances').revision;
|
||||
ask('instances.set', { revision: revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
|
||||
const before = readFileSync(file, 'utf8');
|
||||
// The real writer's refusals reach the verb, and none of them writes.
|
||||
assert.equal(code(() => ask('instances.set', { revision: revision(), instance: 'coder', op: 'delete' })), 'organization-credentials-out-of-scope');
|
||||
assert.equal(code(() => ask('instances.set', { revision: revision(), instance: 'pm', op: 'delete' })), 'organization-instance-referenced');
|
||||
assert.equal(code(() => ask('instances.set', { revision: 'b'.repeat(64), instance: 'sec-1', op: 'delete' })), 'revision-conflict');
|
||||
assert.equal(readFileSync(file, 'utf8'), before);
|
||||
const { request } = ask('instances.set', { revision: revision(), instance: 'sec-1', op: 'delete' });
|
||||
assert.equal(readFileSync(file, 'utf8'), before, 'the verb only records the request');
|
||||
assert.deepEqual(bus.lifecycleRequests('demo').map((r) => [r.request, r.action, r.instance]), [[request, 'lifecycle.delete', 'sec-1']]);
|
||||
assert.equal(bus.lifecycleTake({ business: 'demo', request }).go, true);
|
||||
const { transaction } = bus.lifecycleDelete({ business: 'demo', request });
|
||||
const after = JSON.parse(readFileSync(file, 'utf8'));
|
||||
assert.equal(Object.hasOwn(after.roles, 'sec-1'), false);
|
||||
assert.deepEqual(after.retired, ['sec-1']);
|
||||
assert.equal(after.launch.instances.includes('sec-1'), false);
|
||||
const log = readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).at(-1);
|
||||
assert.deepEqual([log.actor, log.state, log.transaction], ['jason', 'committed', transaction]);
|
||||
assert.equal(bus.lifecycleDone({ business: 'demo', request, transaction }).state, 'closed');
|
||||
assert.equal(code(() => bus.broker.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' })), 'unknown-role');
|
||||
assert.equal(code(() => ask('instance.launch', { instance: 'sec-1' })), 'instance-retired');
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
// Row 194 (#1692, D3, T14): after every write that commits, the broker takes the business as a
|
||||
// restart would load it, so a created instance binds with no restart. A definition change on a
|
||||
// running instance refuses, and a file whose credential references changed isn't reloaded.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { busBusiness } from '../src/business.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { loadBusiness } from '../../business/src/business.mjs';
|
||||
import { resolveInstance } from '../../business/src/resolve.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||
|
||||
// The bus form of the business file, built as cli bootConfig builds it.
|
||||
function busForm(root, rolesDir, id = 'demo') {
|
||||
const b = loadBusiness(id, { dir: root, rolesDir });
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(root), business: b, instance });
|
||||
return busBusiness(b, resolved);
|
||||
}
|
||||
|
||||
async function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'reload-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { harness: ['pi', 'claude-code'] };
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busForm(root, rolesDir) }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const reloads = [];
|
||||
const reloadBusiness = bus.broker.reloadBusiness.bind(bus.broker);
|
||||
bus.broker.reloadBusiness = (id, next) => { reloads.push(structuredClone(next)); reloadBusiness(id, next); };
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const ask = (verb, args) => bus.broker.request(human, { verb, args });
|
||||
const revision = () => ask('instances').revision;
|
||||
return { root, rolesDir, file, bus, reloads, ask, revision };
|
||||
}
|
||||
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
|
||||
let runs = 0;
|
||||
const bind = (bus, role) => bus.broker.bindLaunch({ business: 'demo', role, run: `run-${++runs}`, harness: 'pi' });
|
||||
|
||||
test('T14 a create goes live with no restart: the new instance binds at once and the answer says restart false', async (t) => {
|
||||
const f = await setup(t);
|
||||
assert.equal(code(() => bind(f.bus, 'sec-1')), 'unknown-role');
|
||||
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
|
||||
assert.equal(added.restart, false);
|
||||
assert.equal(f.reloads.length, 1);
|
||||
assert.equal(f.reloads[0].roles['sec-1'].definition, 'security-reviewer');
|
||||
assert.ok(bind(f.bus, 'sec-1'));
|
||||
});
|
||||
|
||||
test('T14 every committed verb reloads once, limits.set, routes.set, plugins.set and veto.set included, and a refused one does not', async (t) => {
|
||||
const f = await setup(t);
|
||||
f.ask('limits.set', { revision: f.revision(), limits: { tokens: { instances: { coder: { limit: 100 } } } } });
|
||||
assert.equal(f.reloads.length, 1);
|
||||
assert.equal(f.reloads[0].launch.limits.tokens.instances.coder.limit, 100);
|
||||
f.ask('routes.set', { revision: f.revision(), routes: { coder: { harness: 'claude-code', model: 'claude-sonnet-5-5', thinking: 'low', fallback: null } } });
|
||||
assert.equal(f.reloads.length, 2);
|
||||
assert.equal(f.reloads[1].roles.coder.vars.harness, 'claude-code');
|
||||
f.ask('plugins.set', { revision: f.revision(), plugins: { harness: ['claude-code', 'pi'] } });
|
||||
assert.equal(f.reloads.length, 3);
|
||||
assert.deepEqual(f.reloads[2].plugins.harness, ['claude-code', 'pi']);
|
||||
f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'coder' });
|
||||
assert.equal(f.reloads.length, 4);
|
||||
assert.deepEqual(f.reloads[3].launch.keep, ['coder']);
|
||||
assert.equal(code(() => f.ask('instances.set', { revision: f.revision(), instance: 'ghost', op: 'change', vars: {} })), 'organization-unknown-instance');
|
||||
assert.equal(code(() => f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'coder' })), 'organization-no-change');
|
||||
assert.equal(f.reloads.length, 4);
|
||||
});
|
||||
|
||||
test('T14 a definition change on a running instance refuses instance-running; a vars change still goes live', async (t) => {
|
||||
const f = await setup(t);
|
||||
f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
|
||||
const cap = bind(f.bus, 'sec-1');
|
||||
f.bus.broker.request(cap, { verb: 'role.claim' });
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
assert.equal(code(() => f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'change', definition: 'merge-gate' })), 'instance-running');
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
const changed = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'change', vars: { model: 'claude-sonnet-5-5' } });
|
||||
assert.equal(changed.restart, false);
|
||||
assert.equal(f.reloads.at(-1).roles['sec-1'].vars.model, 'claude-sonnet-5-5');
|
||||
});
|
||||
|
||||
test('T14 a file whose credential references differ from the boot copy is not reloaded and the write answers restart true', async (t) => {
|
||||
const f = await setup(t);
|
||||
const doc = JSON.parse(readFileSync(f.file, 'utf8'));
|
||||
const moved = join(f.root, 'secrets', 'moved-gitea.token');
|
||||
writeFileSync(moved, 'placeholder-not-a-token', { mode: 0o600 });
|
||||
doc.roles.coder.credentials.gitea.file = moved;
|
||||
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
|
||||
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer' });
|
||||
assert.equal(added.restart, true);
|
||||
assert.equal(f.reloads.length, 0);
|
||||
assert.equal(code(() => bind(f.bus, 'sec-1')), 'unknown-role');
|
||||
});
|
||||
|
||||
test('T14 a file whose project root differs from the boot copy is not reloaded, because the Credentials store protects the boot roots', async (t) => {
|
||||
const f = await setup(t);
|
||||
const doc = JSON.parse(readFileSync(f.file, 'utf8'));
|
||||
const moved = join(f.root, 'project-moved');
|
||||
mkdirSync(moved);
|
||||
doc.projects.stack.root = moved;
|
||||
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
|
||||
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer' });
|
||||
assert.equal(added.restart, true);
|
||||
assert.equal(f.reloads.length, 0);
|
||||
});
|
||||
|
||||
test('T14 reloadBusiness refuses an unknown business, a changed human and a document the boot check refuses', (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'reload-unit-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const role = { authority: { withinRole: [], crossRole: [] } };
|
||||
const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } };
|
||||
const b = new Broker({ store: new Store(root), businesses: { demo } });
|
||||
assert.equal(code(() => b.reloadBusiness('other', demo)), 'unknown-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, human: 'mos' })), 'invalid-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: role, tester: role } })), 'invalid-business');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: { authority: { withinRole: ['spend'], crossRole: [] } } } })), 'invalid-authority');
|
||||
assert.equal(code(() => b.reloadBusiness('demo', null)), 'invalid-business');
|
||||
b.reloadBusiness('demo', { ...demo, roles: { coder: role, 'sec-1': role } });
|
||||
assert.ok(b.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' }));
|
||||
});
|
||||
@@ -21,8 +21,6 @@ test('row 175: TESTER_VERBS and the organization branch tester set are pinned',
|
||||
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials',
|
||||
// Row 177 (#1675): the tester's own threads.
|
||||
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
|
||||
// Row 194 (#1692, D4.1): launch and terminate, and the read of its own requests.
|
||||
'instance.launch', 'instance.end', 'instance.request',
|
||||
]);
|
||||
assert.deepEqual([...TESTER_ORGANIZATION_VERBS], ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
|
||||
assert.ok(Object.isFrozen(TESTER_VERBS) && Object.isFrozen(TESTER_ORGANIZATION_VERBS));
|
||||
|
||||
@@ -156,10 +156,7 @@ file through the broker's `organization.set`, limited to the keys the human's
|
||||
call them (row 132). Under Jason's ruling Q169 (row 155, #1649) the chief of
|
||||
staff is the one agent that ends the assistant, which ends every assistant
|
||||
thread run, and it configures the assistant through `assistants` (Assistant
|
||||
settings, below) and names instances through `set_name`. PRD 1.7 REQ-COS-3
|
||||
(row 194) grants it `role.hire` and `role.fire` within role: it hires and
|
||||
fires with a reason and no decision, through the broker's `instance.hire` and
|
||||
`instance.fire`, which the harness's `hire` and `fire` tools call. It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so
|
||||
settings, below) and names instances through `set_name`. It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so
|
||||
it is the only role `launch.by` can name. The PM holds no `role.launch`
|
||||
(REQ-LAUNCH-1); it asks the chief of staff for a session.
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { checkModels, checkLimits, checkAccount } from "./launch.mjs";
|
||||
import { checkVars } from "./vars.mjs";
|
||||
import { parseCredentialRef } from "./credentials.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { END_PROTECTED } from "./vocabulary.mjs";
|
||||
import { checkPlugins } from "../../plugins/src/kinds.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
@@ -19,7 +18,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
// How long a thread's session stays up with no turn before the launcher stops
|
||||
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
|
||||
export const THREAD_IDLE_SECONDS = 900;
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "retired"];
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins"];
|
||||
|
||||
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
||||
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
||||
@@ -119,7 +118,7 @@ function checkLaunch(launch, roles, definitions, file) {
|
||||
const where = `${file} launch`;
|
||||
requireObject(launch, where);
|
||||
if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`);
|
||||
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account", "keep", "noHire"], where);
|
||||
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account"], where);
|
||||
requireId(launch.by, `${where}.by`);
|
||||
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
|
||||
const launcher = definitions[roles[launch.by].definition];
|
||||
@@ -143,29 +142,7 @@ function checkLaunch(launch, roles, definitions, file) {
|
||||
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`);
|
||||
// No upper bound: limits have no ceiling in code (Q150).
|
||||
const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`);
|
||||
// Row 194 (#1692, D2.4, D2.5): the human's per-agent forbid. keep names declared instances the
|
||||
// chief can't fire, replace or end. noHire names definitions the chief can't hire, by the id
|
||||
// pattern only, so a stale entry is harmless. Both are optional and only the human writes them.
|
||||
const keep = launch.keep === undefined ? undefined : requireDistinctList(launch.keep, `${where}.keep`, (name) => {
|
||||
requireId(name, `${where}.keep entry`);
|
||||
if (!Object.hasOwn(roles, name)) refuse(`${where}.keep names ${name}, which the business file doesn't declare`);
|
||||
if (END_PROTECTED.includes(roles[name].definition)) refuse(`${where}.keep names ${name}, whose definition ${roles[name].definition} can't be kept`);
|
||||
});
|
||||
const noHire = launch.noHire === undefined ? undefined
|
||||
: requireDistinctList(launch.noHire, `${where}.noHire`, (name) => requireId(name, `${where}.noHire entry`));
|
||||
return {
|
||||
by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}),
|
||||
...(keep ? { keep } : {}), ...(noHire ? { noHire } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Row 194 (#1692, V5): instance names a delete retired. None may be declared again, so a later
|
||||
// create or hire can't reuse a name whose decisions outlive it.
|
||||
function checkRetired(retired, roles, file) {
|
||||
return requireDistinctList(retired, `${file} retired`, (name) => {
|
||||
requireId(name, `${file} retired entry`);
|
||||
if (Object.hasOwn(roles, name)) refuse(`${file} retired names ${name}, which roles still declares`);
|
||||
});
|
||||
return { by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}) };
|
||||
}
|
||||
|
||||
// Validate a parsed business document read from `file`. `rolesDir` is where
|
||||
@@ -228,11 +205,9 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
|
||||
// here; whether each module loads is the loader's check, at load time.
|
||||
const plugins = document.plugins === undefined ? {}
|
||||
: structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) }));
|
||||
const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file);
|
||||
|
||||
return deepFreeze({
|
||||
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
|
||||
...(retired ? { retired } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
export { BusinessError } from "./errors.mjs";
|
||||
export {
|
||||
ACTIONS, GATED_ONLY, HIRE_ALLOWED, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
} from "./vocabulary.mjs";
|
||||
export { validateRoleDocument, loadRoleFile, loadRole, unhireableAction } from "./role.mjs";
|
||||
export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
|
||||
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
|
||||
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
|
||||
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// Row 165 (#1662, Q171): the human adds a role instance and changes an existing one's definition,
|
||||
// vars and launch membership. It is human-only: the broker's instances and instances.set verbs
|
||||
// reach it, and neither is in ORGANIZATION_KEYS, so the chief of staff's tool can't (sage-0918).
|
||||
// holder, credentials and tracker stay hand edits. Row 194 (#1692) adds delete, the human's veto
|
||||
// lists and the chief's hire and fire. Every write goes through the organization writer, which
|
||||
// logs the actor and the old and new values.
|
||||
// holder, credentials and tracker stay hand edits, and there is no delete. Every write goes
|
||||
// through the organization writer, which logs the actor and the old and new values.
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { organizationWriter, OrganizationError } from "./organization.mjs";
|
||||
import { validateBusinessDocument, businessFilePath } from "./business.mjs";
|
||||
@@ -11,59 +10,11 @@ import { checkRoutes } from "./launch-settings.mjs";
|
||||
import { loadProject, projectFilePath } from "./project.mjs";
|
||||
import { resolveInstance } from "./resolve.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { isPlainObject, canonicalJson } from "./util.mjs";
|
||||
import { END_PROTECTED, ID_PATTERN } from "./vocabulary.mjs";
|
||||
|
||||
const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch"];
|
||||
const VETO_KEYS = ["revision", "op", "name"];
|
||||
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
|
||||
const id = value => typeof value === "string" && ID_PATTERN.test(value);
|
||||
const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
||||
// Row 194 (D2.2, N3): the vars a hire may set, the ones the chief can already set on an instance.
|
||||
// Every limits.* key merges by intersection, so it can only narrow.
|
||||
const HIRE_VAR = key => key === "model" || key.startsWith("limits.");
|
||||
|
||||
// Row 194 (#1692, V5): why an instance can't be deleted, checked in the design's order, or null.
|
||||
// The last-listed check runs when the proposed file is validated (deletedDocument).
|
||||
export function deleteRefusal(document, rolesDir, instance) {
|
||||
if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" };
|
||||
const entry = document.roles[instance];
|
||||
if (END_PROTECTED.includes(entry.definition)) return { code: "organization-definition-refused" };
|
||||
const references = [];
|
||||
if (document.launch?.by === instance) references.push("launch.by");
|
||||
for (const kind of ["delivery", "technical"]) if (document.arbiters?.[kind] === instance) references.push(`arbiters.${kind}`);
|
||||
if (references.length > 0) {
|
||||
return { code: "organization-instance-referenced", message: `${instance} is ${references.join(" and ")}; change that first` };
|
||||
}
|
||||
if (document.launch?.keep?.includes(instance)) {
|
||||
return { code: "organization-instance-kept", message: `${instance} is kept; run veto unkeep first` };
|
||||
}
|
||||
const definition = loadRole(rolesDir, entry.definition);
|
||||
if (definition.credentials.length > 0 || entry.tracker !== undefined || entry.credentials !== undefined) {
|
||||
return { code: "organization-credentials-out-of-scope", message: `${instance} holds credentials or a tracker bot; deleting it is a hand edit` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The document with `instance` deleted (V5 step 2). It removes roles.<instance> and the references
|
||||
// that exist only for it, launch.instances and launch.limits.tokens.instances, and adds the name to
|
||||
// retired. Every other reference (launch.by, the arbiters, launch.keep) refuses in deleteRefusal.
|
||||
// A business whose only listed instance is this one refuses with a BusinessError.
|
||||
export function deletedDocument(document, instance) {
|
||||
const next = structuredClone(document);
|
||||
delete next.roles[instance];
|
||||
if (next.launch) {
|
||||
if (next.launch.instances.length === 1 && next.launch.instances[0] === instance) {
|
||||
refuse(`${instance} is the last name in launch.instances; a business needs one instance the chief may launch`);
|
||||
}
|
||||
next.launch.instances = next.launch.instances.filter(name => name !== instance);
|
||||
const tokens = next.launch.limits?.tokens?.instances;
|
||||
if (tokens && Object.hasOwn(tokens, instance)) delete tokens[instance];
|
||||
}
|
||||
next.retired = [...(next.retired ?? []), instance];
|
||||
return next;
|
||||
}
|
||||
|
||||
// A definition this writer can give an instance: version 2, not END_PROTECTED, and declaring no
|
||||
// credential. An instance of one that declares credentials needs credentials and a tracker bot,
|
||||
@@ -113,19 +64,14 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
launched: launch ? launch.instances.includes(name) : false,
|
||||
launcher: launch ? launch.by === name : false,
|
||||
protected: END_PROTECTED.includes(entry.definition),
|
||||
kept: (launch?.keep ?? []).includes(name),
|
||||
}]));
|
||||
// Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs.
|
||||
return {
|
||||
business, revision, launch: launch !== null, instances, definitions: definitions(),
|
||||
noHire: [...(launch?.noHire ?? [])], retired: [...(document.retired ?? [])],
|
||||
};
|
||||
return { business, revision, launch: launch !== null, instances, definitions: definitions() };
|
||||
}
|
||||
|
||||
// args: { revision, instance, op: "add" | "change" | "delete", definition?, vars?, launch?: boolean }.
|
||||
// args: { revision, instance, op: "add" | "change", definition?, vars?, launch?: boolean }.
|
||||
// add needs definition; change needs at least one of definition, vars and launch. vars replaces
|
||||
// the instance's vars whole. Row 194 (D3): the writer's restart is false. The bus reloads the
|
||||
// business after the write and sets restart from that reload (runtime.mjs).
|
||||
// the instance's vars whole. The answer's restart is true when the running bus must restart
|
||||
// before the change counts: the broker holds the roles it loaded at start (broker.mjs bindLaunch).
|
||||
function set(business, args, actor) {
|
||||
if (!isPlainObject(args) || Object.keys(args).some(key => !SET_KEYS.includes(key))) {
|
||||
throw new OrganizationError("invalid-request");
|
||||
@@ -134,7 +80,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
throw new OrganizationError("invalid-request");
|
||||
}
|
||||
const { instance, op } = args;
|
||||
if (!id(instance) || !["add", "change", "delete"].includes(op)) throw new OrganizationError("invalid-request");
|
||||
if (!id(instance) || (op !== "add" && op !== "change")) throw new OrganizationError("invalid-request");
|
||||
// The broker refuses a business whose roles name human or tester (broker.mjs, the load check),
|
||||
// so an instance under either name would stop the bus at its next start (row 165 r2, Dewey F1).
|
||||
if (instance === "human" || instance === "tester") {
|
||||
@@ -145,18 +91,15 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
if (Object.hasOwn(args, "launch") && typeof args.launch !== "boolean") throw new OrganizationError("invalid-request");
|
||||
|
||||
const old = writer.read(business).document;
|
||||
if (op === "delete") {
|
||||
if (Object.keys(args).some(key => !["revision", "instance", "op"].includes(key))) throw new OrganizationError("invalid-request");
|
||||
return commit(business, old, planDelete(old, instance), { revision: args.revision, actor });
|
||||
}
|
||||
const next = structuredClone(old);
|
||||
const exists = Object.hasOwn(next.roles, instance);
|
||||
let redefined = false;
|
||||
if (op === "add") {
|
||||
if (!Object.hasOwn(args, "definition")) throw new OrganizationError("invalid-request");
|
||||
if (exists) throw new OrganizationError("organization-instance-exists");
|
||||
if ((old.retired ?? []).includes(instance)) throw new OrganizationError("organization-instance-retired");
|
||||
definitionFor(rolesDir, args.definition);
|
||||
next.roles[instance] = { definition: args.definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
|
||||
redefined = true;
|
||||
} else {
|
||||
if (!["definition", "vars", "launch"].some(key => Object.hasOwn(args, key))) throw new OrganizationError("invalid-request");
|
||||
if (!exists) throw new OrganizationError("organization-unknown-instance");
|
||||
@@ -172,7 +115,8 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
`${instance} holds ${entry.definition} credentials; moving them to another definition is a hand edit`);
|
||||
}
|
||||
entry.definition = args.definition;
|
||||
}
|
||||
redefined = true;
|
||||
}
|
||||
if (Object.hasOwn(args, "vars")) entry.vars = structuredClone(args.vars);
|
||||
}
|
||||
if (Object.hasOwn(args, "launch")) {
|
||||
@@ -181,95 +125,6 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
if (!args.launch) next.launch.instances = listed.filter(name => name !== instance);
|
||||
else if (!listed.includes(instance)) listed.push(instance);
|
||||
}
|
||||
return commit(business, old, next, { revision: args.revision, actor });
|
||||
}
|
||||
|
||||
// Row 194 (V5): the delete's checks against the file as it stands, and its document. The broker
|
||||
// runs this at the verb with no write, and the launcher's pass runs set op delete after the take.
|
||||
function planDelete(document, instance) {
|
||||
const refusal = deleteRefusal(document, rolesDir, instance);
|
||||
if (refusal) throw new OrganizationError(refusal.code, refusal.message);
|
||||
return deletedDocument(document, instance);
|
||||
}
|
||||
|
||||
function checkDelete(business, instance) {
|
||||
const { document, revision } = writer.read(business);
|
||||
const next = planDelete(document, instance);
|
||||
validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
|
||||
return { revision };
|
||||
}
|
||||
|
||||
// Row 194 (V6): args { revision, op: keep | unkeep | nohire | hire, name }. keep needs a declared
|
||||
// instance that isn't END_PROTECTED. nohire needs only the id pattern. Human-only at the broker.
|
||||
function veto(business, args, actor) {
|
||||
if (!isPlainObject(args) || Object.keys(args).some(key => !VETO_KEYS.includes(key))) throw new OrganizationError("invalid-request");
|
||||
if (!revisionOk(args.revision) || !VETO_OPS.includes(args.op) || !id(args.name)) throw new OrganizationError("invalid-request");
|
||||
const old = writer.read(business).document;
|
||||
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
||||
const next = structuredClone(old);
|
||||
const { op, name } = args;
|
||||
const key = op === "keep" || op === "unkeep" ? "keep" : "noHire";
|
||||
const list = next.launch[key] ?? [];
|
||||
if (op === "keep") {
|
||||
if (!Object.hasOwn(next.roles, name)) throw new OrganizationError("organization-unknown-instance");
|
||||
if (END_PROTECTED.includes(next.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
|
||||
}
|
||||
const add = op === "keep" || op === "nohire";
|
||||
if (add === list.includes(name)) throw new OrganizationError("organization-no-change");
|
||||
next.launch[key] = add ? [...list, name] : list.filter(entry => entry !== name);
|
||||
return commit(business, old, next, { revision: args.revision, actor });
|
||||
}
|
||||
|
||||
// Row 194 (#1692, D2.2): the chief's hire, after the broker's identity, grant, allowlist and
|
||||
// hireable checks. args { name, definition, vars? }. A new name is added and listed. A fired name
|
||||
// (declared, not listed) is listed again only with its own definition and no vars. The writer
|
||||
// reads the file as it stands, so a veto or retire written since the broker's copy still counts.
|
||||
function hire(business, args, { actor, context }) {
|
||||
const { document: old, revision } = writer.read(business);
|
||||
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
||||
const { name, definition } = args;
|
||||
if (!id(name) || !id(definition) || name === "human" || name === "tester") throw new OrganizationError("invalid-request");
|
||||
if (Object.hasOwn(args, "vars") && (!isPlainObject(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) {
|
||||
throw new OrganizationError("invalid-request", "hire vars may set only model and limits keys");
|
||||
}
|
||||
if ((old.launch.noHire ?? []).includes(definition)) throw new OrganizationError("definition-not-hireable");
|
||||
definitionFor(rolesDir, definition);
|
||||
if ((old.retired ?? []).includes(name)) throw new OrganizationError("organization-instance-retired");
|
||||
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
|
||||
const next = structuredClone(old);
|
||||
if (Object.hasOwn(old.roles, name)) {
|
||||
const fired = !old.launch.instances.includes(name) && old.launch.by !== name;
|
||||
if (!fired || old.roles[name].definition !== definition || Object.hasOwn(args, "vars")) {
|
||||
throw new OrganizationError("organization-instance-exists");
|
||||
}
|
||||
} else {
|
||||
next.roles[name] = { definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
|
||||
}
|
||||
next.launch.instances.push(name);
|
||||
return commit(business, old, next, { revision, actor, context });
|
||||
}
|
||||
|
||||
// Row 194 (D2.2 step B): the fire's delist. An instance already delisted is done, so a repeated
|
||||
// fire after a failed step C goes on to consume (webby-0011 B2). The checks repeat against the file.
|
||||
function fire(business, args, { actor, context }) {
|
||||
const { document: old, revision } = writer.read(business);
|
||||
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
||||
const { name } = args;
|
||||
if (!id(name) || !Object.hasOwn(old.roles, name)) throw new OrganizationError("organization-unknown-instance");
|
||||
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
|
||||
if (END_PROTECTED.includes(old.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
|
||||
if (old.launch.by === name) throw new OrganizationError("organization-instance-referenced", `${name} is launch.by`);
|
||||
if (!old.launch.instances.includes(name)) return { ...read(business), transaction: null, delisted: false };
|
||||
if (old.launch.instances.length === 1) {
|
||||
refuse(`${name} is the last name in launch.instances; a business needs one instance the chief may launch`);
|
||||
}
|
||||
const next = structuredClone(old);
|
||||
next.launch.instances = old.launch.instances.filter(entry => entry !== name);
|
||||
return { ...commit(business, old, next, { revision, actor, context }), delisted: true };
|
||||
}
|
||||
|
||||
// The checks every write runs, then the write.
|
||||
function commit(business, old, next, { revision, actor, context }) {
|
||||
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
|
||||
|
||||
// The checks mosaic business validate runs, before the writer publishes anything, then the
|
||||
@@ -296,9 +151,9 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
}
|
||||
}
|
||||
}
|
||||
const receipt = writer.write(business, next, { revision, actor, context });
|
||||
return { ...read(business), transaction: receipt.transaction, restart: false };
|
||||
const receipt = writer.write(business, next, { revision: args.revision, actor });
|
||||
return { ...read(business), transaction: receipt.transaction, restart: redefined };
|
||||
}
|
||||
|
||||
return { writer, read, set, checkDelete, veto, hire, fire };
|
||||
return { writer, read, set };
|
||||
}
|
||||
|
||||
@@ -174,9 +174,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
}
|
||||
}
|
||||
|
||||
// context, when given, is logged with the change: row 194's hire and fire carry the chief's run
|
||||
// and the decision there (D2.2).
|
||||
function write(id, document, { revision, actor, context }) {
|
||||
function write(id, document, { revision, actor }) {
|
||||
requireId(actor, "authenticated actor");
|
||||
requireId(id, "organization id");
|
||||
return withLock(id, ({ home, parent }) => {
|
||||
@@ -202,7 +200,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
durable(candidate, bytes);
|
||||
syncDir(tx);
|
||||
const event = {
|
||||
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
|
||||
transaction, actor, business: id, before, after,
|
||||
changes: changes(old, written), at: new Date().toISOString(),
|
||||
};
|
||||
log(id, { ...event, state: "prepared" });
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
// ceilings. Version 1 files still load: they carry no authority, so every
|
||||
// vocabulary action is gated for them.
|
||||
|
||||
import { existsSync, lstatSync } from "node:fs";
|
||||
import { lstatSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import {
|
||||
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE, unhireableAction,
|
||||
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
} from "./vocabulary.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/;
|
||||
const V1_KEYS = ["roleVersion", "name", "tools", "network"];
|
||||
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
|
||||
const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable"];
|
||||
const V2_KEYS = [...V2_REQUIRED, "messaging", "skills"];
|
||||
const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function checkTools(tools) {
|
||||
@@ -75,25 +75,6 @@ function checkSkills(skills) {
|
||||
});
|
||||
}
|
||||
|
||||
// Row 194 (#1692, D2.3): unhireableAction is vocabulary.mjs's, exported here as before.
|
||||
export { unhireableAction };
|
||||
|
||||
// Row 194 (#1692, D2.3): optional. The role definitions this role may hire, by name. Each one must
|
||||
// be a version 2 file in the same directory whose every action is on HIRE_ALLOWED. The named file
|
||||
// is validated without its own "hireable" check, so a list can't recurse.
|
||||
function checkHireable(hireable, file) {
|
||||
const dir = dirname(file);
|
||||
return requireDistinctList(hireable, "role hireable", (name) => {
|
||||
requireId(name, "role hireable entry");
|
||||
const path = join(dir, `${name}.json`);
|
||||
if (!existsSync(path)) refuse(`hireable names ${name}, which has no role file`);
|
||||
const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false });
|
||||
if (named.roleVersion !== 2) refuse(`hireable names ${name}, which is role version 1`);
|
||||
const outside = unhireableAction(named);
|
||||
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside}`);
|
||||
});
|
||||
}
|
||||
|
||||
function checkGiteaScopes(scopes) {
|
||||
const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
|
||||
const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null;
|
||||
@@ -153,9 +134,8 @@ function checkContract(contract, file) {
|
||||
// Validate a parsed role document read from `file`. Returns a frozen role:
|
||||
// { roleVersion, name, title, contract, contractPath, tools, network,
|
||||
// authority: { withinRole, crossRole }, credentials: [{ service, scopes }],
|
||||
// messaging: { sendTo, receiveFrom } | null, skills: [name], hireable: [name] }.
|
||||
// options.hireable false skips the hireable list's checks of other files (D2.3) and keeps the list.
|
||||
export function validateRoleDocument(document, file, options = {}) {
|
||||
// messaging: { sendTo, receiveFrom } | null, skills: [name] }.
|
||||
export function validateRoleDocument(document, file) {
|
||||
requireObject(document, "role");
|
||||
if (document.roleVersion !== 1 && document.roleVersion !== 2) refuse('role "roleVersion" must be 1 or 2');
|
||||
rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role");
|
||||
@@ -167,7 +147,7 @@ export function validateRoleDocument(document, file, options = {}) {
|
||||
const network = document.network === undefined ? "none" : checkNetwork(document.network);
|
||||
return deepFreeze({
|
||||
roleVersion: 1, name: document.name, title: null, contract: null, contractPath: null, tools, network,
|
||||
authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null, skills: [], hireable: [],
|
||||
authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null, skills: [],
|
||||
});
|
||||
}
|
||||
for (const key of V2_REQUIRED) {
|
||||
@@ -185,9 +165,6 @@ export function validateRoleDocument(document, file, options = {}) {
|
||||
credentials: checkCredentials(document.credentials),
|
||||
messaging: document.messaging === undefined ? null : checkMessaging(document.messaging),
|
||||
skills: document.skills === undefined ? [] : checkSkills(document.skills),
|
||||
hireable: document.hireable === undefined ? []
|
||||
: options.hireable === false ? requireDistinctList(document.hireable, "role hireable", (name) => requireId(name, "role hireable entry"))
|
||||
: checkHireable(document.hireable, file),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -35,10 +35,6 @@ export const ACTIONS = Object.freeze([
|
||||
// Row 141 (#1635): sets a role instance's name. Routed under PRD 1.5 REQ-MSG-2, which has the chief of
|
||||
// staff set names once its role file grants that within-role.
|
||||
"instance.name",
|
||||
// Row 194 (#1692): the chief of staff hires (adds and lists a role instance) and fires (ends its
|
||||
// runs and delists it). Hard waits under PRD 1.6 (mos-1017), routed under PRD 1.7 REQ-COS-3.
|
||||
"role.hire",
|
||||
"role.fire",
|
||||
"credential.mint",
|
||||
"spend",
|
||||
"deploy",
|
||||
@@ -58,7 +54,7 @@ export const ACTIONS = Object.freeze([
|
||||
// action joins this list only by the reviewed commit that names it routed.
|
||||
// git.merge.protected, policy.change, prd.approve and role.revoke left the
|
||||
// hard waits in 1.5 and route by REQ-DEC-2. instance.name (row 141) is routed
|
||||
// under REQ-MSG-2 (1.5). role.hire and role.fire left the hard waits in 1.7 (REQ-COS-3).
|
||||
// under REQ-MSG-2 (1.5).
|
||||
export const ROUTED = Object.freeze([
|
||||
"task.create",
|
||||
"task.assign",
|
||||
@@ -82,42 +78,15 @@ export const ROUTED = Object.freeze([
|
||||
"policy.change",
|
||||
"prd.approve",
|
||||
"decision.resolve.technical",
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
|
||||
"role.hire",
|
||||
"role.fire",
|
||||
]);
|
||||
|
||||
// The hard waits (PRD 1.5 REQ-WAIT-1): always gated, always the human's,
|
||||
// cited spec or not. A role file may not list these under withinRole or
|
||||
// crossRole (REQ-WAIT-2). Today this is spend, data.delete, network.expose,
|
||||
// account.create, credential.mint, message.external, git.push.protected,
|
||||
// account.create, credential.mint, message.external, git.push.protected and
|
||||
// deploy; a test pins the list.
|
||||
export const GATED_ONLY = Object.freeze(ACTIONS.filter((action) => !ROUTED.includes(action)));
|
||||
|
||||
// Row 194 (#1692, D2.3): the chief of staff hires only a definition whose every withinRole and
|
||||
// crossRole action is on this list. An allowlist, so an action added to ACTIONS later is never
|
||||
// hireable until a reviewed commit adds it here. git.push.remote (row 199) is not on it.
|
||||
export const HIRE_ALLOWED = Object.freeze([
|
||||
"message.send",
|
||||
"record.write",
|
||||
"review.request",
|
||||
"task.update.assigned",
|
||||
"task.scope.change",
|
||||
"git.push.working",
|
||||
]);
|
||||
|
||||
// Row 194 (#1692, D2.3): the first action of a definition's authority that isn't on
|
||||
// HIRE_ALLOWED, or null when every one is. role.mjs's hireable check and the broker's hire check
|
||||
// use it. It lives here because the session sandbox mounts this file and not role.mjs
|
||||
// (seat/src/sandbox.mjs RUNNER_FILES), and the runner imports broker.mjs.
|
||||
export function unhireableAction(role) {
|
||||
for (const key of ["withinRole", "crossRole"]) {
|
||||
const outside = role.authority[key].find((action) => !HIRE_ALLOWED.includes(action));
|
||||
if (outside !== undefined) return outside;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// pi's documented built-in tools; the same list scripts/mosaic-task.mjs
|
||||
// accepts for tasks.
|
||||
export const BUILTIN_TOOLS = Object.freeze(["read", "write", "edit", "bash", "grep", "find", "ls"]);
|
||||
|
||||
@@ -234,36 +234,3 @@ test("the shipped example refuses as written and validates once filled in", () =
|
||||
assert.equal(business.tracker.sync.credentials.vikunja.file, join(root, ".config/mosaic-dev/secrets/mosaic-stack/sync-vikunja.token"));
|
||||
assert.equal(id, 15);
|
||||
});
|
||||
|
||||
// Row 194 (#1692, D2.5, T17): the per-agent forbid and the retired list.
|
||||
test("row 194 keep, noHire and retired validate, and a file without them loads as before", () => {
|
||||
const plain = check();
|
||||
assert.equal(Object.hasOwn(plain.launch, "keep"), false);
|
||||
assert.equal(Object.hasOwn(plain.launch, "noHire"), false);
|
||||
assert.equal(Object.hasOwn(plain, "retired"), false);
|
||||
const business = check((d) => {
|
||||
d.launch.keep = ["coder", "chief"];
|
||||
d.launch.noHire = ["reviewer", "no-such-role"];
|
||||
d.retired = ["old-coder"];
|
||||
});
|
||||
assert.deepEqual(business.launch.keep, ["coder", "chief"]);
|
||||
// A stale noHire entry is harmless: only the id pattern is checked.
|
||||
assert.deepEqual(business.launch.noHire, ["reviewer", "no-such-role"]);
|
||||
assert.deepEqual(business.retired, ["old-coder"]);
|
||||
assert.ok(Object.isFrozen(business.launch.keep) && Object.isFrozen(business.retired));
|
||||
assert.deepEqual(check((d) => { d.launch.keep = []; d.launch.noHire = []; d.retired = []; }).launch.keep, []);
|
||||
});
|
||||
|
||||
test("row 194 each bad keep, noHire or retired entry refuses", () => {
|
||||
refuses(() => check((d) => { d.launch.keep = ["ghost"]; }), /launch\.keep names ghost, which the business file doesn't declare/);
|
||||
refuses(() => check((d) => { d.launch.keep = ["coder", "coder"]; }), /duplicate entry in .*launch\.keep/);
|
||||
refuses(() => check((d) => { d.launch.keep = "coder"; }), /launch\.keep must be an array/);
|
||||
refuses(() => check((d) => { d.roles.assistant = { definition: "assistant" }; d.launch.keep = ["assistant"]; }),
|
||||
/launch\.keep names assistant, whose definition assistant can't be kept/);
|
||||
refuses(() => check((d) => { d.launch.noHire = ["Bad Name"]; }), /launch\.noHire entry/);
|
||||
refuses(() => check((d) => { d.launch.noHire = ["coder", "coder"]; }), /duplicate entry in .*launch\.noHire/);
|
||||
refuses(() => check((d) => { d.retired = ["coder"]; }), /retired names coder, which roles still declares/);
|
||||
refuses(() => check((d) => { d.retired = ["x", "x"]; }), /duplicate entry in .*retired/);
|
||||
refuses(() => check((d) => { d.retired = ["../x"]; }), /retired entry/);
|
||||
refuses(() => check((d) => { d.retired = {}; }), /retired must be an array/);
|
||||
});
|
||||
|
||||
@@ -14,15 +14,12 @@ import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from ".
|
||||
|
||||
// Decision 104's list plus role.launch (decision 139) plus role.end and
|
||||
// organization.change (row 123, REQ-COS-1) and record.write (row 138), plus instance.name
|
||||
// (row 141, REQ-MSG-2, sage-0589), plus role.hire and role.fire (row 194, PRD 1.7 REQ-COS-3,
|
||||
// sage-1471, mos-1089). A role file that differs from it fails.
|
||||
// (row 141, REQ-MSG-2, sage-0589). A role file that differs from it fails.
|
||||
const APPROVED = Object.freeze({
|
||||
tools: ["read", "grep", "find", "ls"],
|
||||
network: "api-only",
|
||||
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"]), crossRole: Object.freeze([]) }),
|
||||
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"]), crossRole: Object.freeze([]) }),
|
||||
credentials: Object.freeze([]),
|
||||
// Row 194 (#1692, Q4, sage-1374): empty until a policy commit after the PRD 1.7 resolve.
|
||||
hireable: Object.freeze([]),
|
||||
});
|
||||
|
||||
// The fixture business's chief-of-staff instance, which is also its launcher.
|
||||
@@ -88,9 +85,9 @@ test("the resolved record matches the approved list, so a widened file fails", (
|
||||
assert.deepEqual(record.credentials, {});
|
||||
});
|
||||
|
||||
test("the chief of staff holds role.launch, message.send, role.end, organization.change, record.write, instance.name, role.hire and role.fire; every other action is gated", () => {
|
||||
test("the chief of staff holds role.launch, message.send, role.end, organization.change, record.write and instance.name; every other action is gated", () => {
|
||||
const record = resolved(loadFixture());
|
||||
const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"];
|
||||
const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"];
|
||||
for (const action of ACTIONS) {
|
||||
assert.equal(classify(record, action), held.includes(action) ? "within" : "gated", action);
|
||||
}
|
||||
@@ -142,8 +139,6 @@ test("the contract names no duty to talk to the human and the pm contract no lau
|
||||
assert.doesNotMatch(chief, /main (agent|point of interaction)/i);
|
||||
assert.doesNotMatch(chief, /\bJason\b/);
|
||||
assert.match(chief, /never talk to the user/);
|
||||
// Row 194 (#1692, D3 item 5, N3): a hire launches with no restart once the human sets its route.
|
||||
assert.match(chief, /A hire is listed for `launch` at once, with no restart, but it\s+runs only after the human sets its route \(harness and thinking\)/);
|
||||
const pm = readFileSync(join(REPO_ROLES, "pm.md"), "utf8");
|
||||
assert.doesNotMatch(pm, /^- Launch /m);
|
||||
assert.match(pm, /You don't launch sessions/);
|
||||
|
||||
@@ -33,11 +33,6 @@ test("REQ-ROLE-2: END_PROTECTED includes the assistant's thread definition", ()
|
||||
assert.ok(Object.isFrozen(END_PROTECTED));
|
||||
});
|
||||
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire left the hard waits and are routed.
|
||||
test("REQ-COS-3: role.hire and role.fire are routed, not hard waits", () => {
|
||||
for (const action of ["role.hire", "role.fire"]) assert.ok(ROUTED.includes(action) && !GATED_ONLY.includes(action), action);
|
||||
});
|
||||
|
||||
test("REQ-WAIT-3: every action is routed or a hard wait, never both, and the routed list names only actions", () => {
|
||||
for (const action of ROUTED) assert.ok(ACTIONS.includes(action), action);
|
||||
for (const action of ACTIONS) assert.notEqual(ROUTED.includes(action), GATED_ONLY.includes(action), action);
|
||||
|
||||
@@ -50,7 +50,7 @@ test("row 165 read lists each instance and the definitions add accepts", () => {
|
||||
const view = f.settings.read("acme");
|
||||
assert.match(view.revision, /^[a-f0-9]{64}$/);
|
||||
assert.equal(view.launch, true);
|
||||
assert.deepEqual(view.instances.coder, { definition: "coder", vars: { harness: "pi", "limits.network": "none" }, launched: true, launcher: false, protected: false, kept: false });
|
||||
assert.deepEqual(view.instances.coder, { definition: "coder", vars: { harness: "pi", "limits.network": "none" }, launched: true, launcher: false, protected: false });
|
||||
assert.equal(view.instances.chief.launcher, true);
|
||||
assert.equal(view.instances.assistant.protected, true);
|
||||
assert.deepEqual(view.definitions["security-reviewer"], { services: [], accepted: true });
|
||||
@@ -60,16 +60,16 @@ test("row 165 read lists each instance and the definitions add accepts", () => {
|
||||
assert.equal(Object.hasOwn(view.definitions, "conductor-policy"), false);
|
||||
});
|
||||
|
||||
test("row 165 add: two instances join, validate exits 0, the log names the human, and the writer asks for no restart (row 194, D3)", () => {
|
||||
test("row 165 add: two instances join, validate exits 0, the log names the human, and the bus restart is reported", () => {
|
||||
const f = fixture();
|
||||
const view = f.settings.read("acme");
|
||||
const added = f.settings.set("acme", { revision: view.revision, instance: "sec-1", op: "add", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launch: true }, "jason");
|
||||
assert.deepEqual(added.instances["sec-1"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false });
|
||||
assert.deepEqual(added.instances["sec-1"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false });
|
||||
assert.notEqual(added.revision, view.revision);
|
||||
assert.match(added.transaction, /\S/);
|
||||
assert.equal(added.restart, false);
|
||||
assert.equal(added.restart, true);
|
||||
const gate = f.settings.set("acme", { revision: added.revision, instance: "gate-1", op: "add", definition: "merge-gate" }, "jason");
|
||||
assert.deepEqual(gate.instances["gate-1"], { definition: "merge-gate", vars: {}, launched: false, launcher: false, protected: false, kept: false });
|
||||
assert.deepEqual(gate.instances["gate-1"], { definition: "merge-gate", vars: {}, launched: false, launcher: false, protected: false });
|
||||
const file = JSON.parse(readFileSync(f.file, "utf8"));
|
||||
assert.deepEqual(file.roles["gate-1"], { definition: "merge-gate" });
|
||||
assert.deepEqual(file.launch.instances, ["coder", "reviewer", "sec-1"]);
|
||||
@@ -84,11 +84,11 @@ test("row 165 add: two instances join, validate exits 0, the log names the human
|
||||
assert.equal(second.actor, "jason");
|
||||
});
|
||||
|
||||
test("row 165 change: definition, vars and launch membership log before and after; none asks for a restart, the bus reloads (row 194, D3)", () => {
|
||||
test("row 165 change: definition, vars and launch membership log before and after; only a definition change asks for a restart", () => {
|
||||
const f = fixture(doc => { harnessOn(doc); doc.roles["sec-1"] = { definition: "security-reviewer", vars: { harness: "pi" } }; });
|
||||
const redefined = f.settings.set("acme", { revision: f.settings.read("acme").revision, instance: "sec-1", op: "change", definition: "merge-gate" }, "jason");
|
||||
assert.equal(redefined.instances["sec-1"].definition, "merge-gate");
|
||||
assert.equal(redefined.restart, false);
|
||||
assert.equal(redefined.restart, true);
|
||||
let entry = f.log().at(-1);
|
||||
assert.ok(entry.changes.some(c => c.path === "roles.sec-1.definition" && c.old === "security-reviewer" && c.new === "merge-gate"));
|
||||
const revarred = f.settings.set("acme", { revision: redefined.revision, instance: "sec-1", op: "change", vars: { harness: "claude-code", model: "claude-opus-5-5" } }, "jason");
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
// Row 194 (#1692): the organization writer's delete (V5), the human's veto lists (V6) and the
|
||||
// chief's hire and fire (D2.2). The broker's identity, decision and allowlist checks are bus tests.
|
||||
// A refused write leaves the file's bytes alone.
|
||||
import test, { after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { instanceSettings } from "../src/instance-settings.mjs";
|
||||
import { businessDoc, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const CLI = join(REPO, "packages", "business", "src", "cli.mjs");
|
||||
const roots = [];
|
||||
after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true })));
|
||||
|
||||
const T0 = "2026-10-18T07:00:00.000Z";
|
||||
const CONTEXT = Object.freeze({ instance: "chief", run: "run-1", reason: "a security review is due" });
|
||||
|
||||
// sec-1 is a listed security-reviewer: no credential, no tracker bot, so the writer may delete it.
|
||||
function fixture(mutate) {
|
||||
const root = tmp();
|
||||
roots.push(root);
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = businessDoc(root, "acme");
|
||||
doc.roles["sec-1"] = { definition: "security-reviewer" };
|
||||
doc.launch.instances.push("sec-1");
|
||||
mutate?.(doc, root);
|
||||
const dir = join(root, "config");
|
||||
const file = writeJson(join(dir, "businesses", "acme.json"), doc);
|
||||
const config = systemConfig(root);
|
||||
const settings = instanceSettings({ dir, rolesDir, system: systemFor(root) });
|
||||
const validate = () => spawnSync(process.execPath, [CLI, "validate", "acme"], {
|
||||
env: { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesDir }, encoding: "utf8",
|
||||
});
|
||||
const log = () => readFileSync(settings.writer.logFile("acme"), "utf8").trim().split("\n").map(line => JSON.parse(line));
|
||||
const onDisk = () => JSON.parse(readFileSync(file, "utf8"));
|
||||
const revision = () => settings.read("acme").revision;
|
||||
return { root, file, settings, validate, log, onDisk, revision };
|
||||
}
|
||||
|
||||
// Each refusal: its code (or the validator's BusinessError), and the file's bytes unchanged.
|
||||
function refuses(f, act, code) {
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
assert.throws(act, e => (code === "BusinessError" ? e.name === code : e.code === code), code);
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes, code);
|
||||
}
|
||||
const del = (f, instance, extra = {}) => () => f.settings.set("acme", { revision: f.revision(), instance, op: "delete", ...extra }, "jason");
|
||||
const veto = (f, op, name, actor = "human") => f.settings.veto("acme", { revision: f.revision(), op, name }, actor);
|
||||
const hire = (f, args) => f.settings.hire("acme", args, { actor: "chief", context: CONTEXT });
|
||||
const fire = (f, name) => f.settings.fire("acme", { name }, { actor: "chief", context: CONTEXT });
|
||||
|
||||
test("T6 delete refuses in the design's order and leaves the file's bytes alone", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.roles.assistant = { definition: "assistant", holder: "mos" };
|
||||
doc.roles["sec-3"] = { definition: "security-reviewer" };
|
||||
doc.launch.keep = ["sec-3", "coder"];
|
||||
});
|
||||
refuses(f, del(f, "ghost"), "organization-unknown-instance");
|
||||
refuses(f, del(f, "assistant"), "organization-definition-refused");
|
||||
refuses(f, del(f, "chief"), "organization-instance-referenced");
|
||||
refuses(f, del(f, "pm"), "organization-instance-referenced");
|
||||
refuses(f, del(f, "cto"), "organization-instance-referenced");
|
||||
assert.throws(del(f, "cto"), /cto is arbiters\.technical; change that first/);
|
||||
refuses(f, del(f, "sec-3"), "organization-instance-kept");
|
||||
// coder declares credentials and is kept: kept is checked first, so the veto is named.
|
||||
refuses(f, del(f, "coder"), "organization-instance-kept");
|
||||
refuses(f, del(f, "reviewer"), "organization-credentials-out-of-scope");
|
||||
refuses(f, del(f, "sec-1", { definition: "security-reviewer" }), "invalid-request");
|
||||
// checkDelete runs the same checks with no write.
|
||||
refuses(f, () => f.settings.checkDelete("acme", "sec-3"), "organization-instance-kept");
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
assert.deepEqual(f.settings.checkDelete("acme", "sec-1"), { revision: f.revision() });
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes);
|
||||
});
|
||||
|
||||
test("T6 delete refuses the last listed instance", () => {
|
||||
const f = fixture(doc => { doc.launch.instances = ["sec-1"]; });
|
||||
refuses(f, del(f, "sec-1"), "BusinessError");
|
||||
refuses(f, () => f.settings.checkDelete("acme", "sec-1"), "BusinessError");
|
||||
});
|
||||
|
||||
test("T6 a clean delete removes the entry and its own references, adds retired, and the name can't come back", () => {
|
||||
const f = fixture(doc => { doc.launch.limits.tokens = { instances: { "sec-1": { limit: 100, since: T0 }, coder: { limit: 50, since: T0 } } }; });
|
||||
const done = del(f, "sec-1")();
|
||||
assert.equal(Object.hasOwn(done.instances, "sec-1"), false);
|
||||
assert.deepEqual(done.retired, ["sec-1"]);
|
||||
assert.equal(done.restart, false);
|
||||
const file = f.onDisk();
|
||||
assert.equal(Object.hasOwn(file.roles, "sec-1"), false);
|
||||
assert.deepEqual(file.launch.instances, ["coder", "reviewer"]);
|
||||
assert.deepEqual(file.launch.limits.tokens.instances, { coder: { limit: 50, since: T0 } });
|
||||
assert.deepEqual(file.retired, ["sec-1"]);
|
||||
const run = f.validate();
|
||||
assert.equal(run.status, 0, run.stderr);
|
||||
const entry = f.log().at(-1);
|
||||
assert.equal(entry.actor, "jason");
|
||||
assert.equal(entry.transaction, done.transaction);
|
||||
assert.ok(entry.changes.some(c => c.path === "roles.sec-1" && c.newPresent === false));
|
||||
assert.ok(entry.changes.some(c => c.path === "retired" && c.oldPresent === false));
|
||||
refuses(f, () => f.settings.set("acme", { revision: f.revision(), instance: "sec-1", op: "add", definition: "security-reviewer" }, "jason"),
|
||||
"organization-instance-retired");
|
||||
refuses(f, () => hire(f, { name: "sec-1", definition: "security-reviewer" }), "organization-instance-retired");
|
||||
refuses(f, del(f, "sec-1"), "organization-unknown-instance");
|
||||
});
|
||||
|
||||
test("T7 veto: keep, unkeep, nohire and hire each write once, a repeat is no change, and the log names the human", () => {
|
||||
const f = fixture();
|
||||
let view = veto(f, "keep", "sec-1");
|
||||
assert.equal(view.instances["sec-1"].kept, true);
|
||||
assert.equal(view.restart, false);
|
||||
assert.deepEqual(f.onDisk().launch.keep, ["sec-1"]);
|
||||
let entry = f.log().at(-1);
|
||||
assert.equal(entry.actor, "human");
|
||||
assert.ok(entry.changes.some(c => c.path === "launch.keep" && c.oldPresent === false && c.new.includes("sec-1")));
|
||||
refuses(f, () => veto(f, "keep", "sec-1"), "organization-no-change");
|
||||
view = veto(f, "unkeep", "sec-1");
|
||||
assert.equal(view.instances["sec-1"].kept, false);
|
||||
assert.deepEqual(f.onDisk().launch.keep, []);
|
||||
refuses(f, () => veto(f, "unkeep", "sec-1"), "organization-no-change");
|
||||
view = veto(f, "nohire", "merge-gate");
|
||||
assert.deepEqual(view.noHire, ["merge-gate"]);
|
||||
// A name with no role file is accepted: the list forbids, it doesn't declare.
|
||||
view = veto(f, "nohire", "no-such-role");
|
||||
assert.deepEqual(view.noHire, ["merge-gate", "no-such-role"]);
|
||||
refuses(f, () => veto(f, "nohire", "merge-gate"), "organization-no-change");
|
||||
view = veto(f, "hire", "merge-gate");
|
||||
assert.deepEqual(view.noHire, ["no-such-role"]);
|
||||
refuses(f, () => veto(f, "hire", "merge-gate"), "organization-no-change");
|
||||
entry = f.log().at(-1);
|
||||
assert.equal(entry.actor, "human");
|
||||
assert.ok(entry.changes.some(c => c.path === "launch.noHire"));
|
||||
assert.equal(f.validate().status, 0);
|
||||
});
|
||||
|
||||
test("T7 veto refuses an unknown or END_PROTECTED keep, a bad request, a stale revision and a business without launch", () => {
|
||||
const f = fixture(doc => { doc.roles.assistant = { definition: "assistant", holder: "mos" }; });
|
||||
refuses(f, () => veto(f, "keep", "ghost"), "organization-unknown-instance");
|
||||
refuses(f, () => veto(f, "keep", "assistant"), "organization-definition-refused");
|
||||
const revision = f.revision();
|
||||
for (const bad of [
|
||||
{ revision, op: "keep" }, { revision, op: "forbid", name: "sec-1" }, { revision, op: "keep", name: "Bad Name" },
|
||||
{ revision: "stale", op: "keep", name: "sec-1" }, { revision, op: "keep", name: "sec-1", by: "chief" },
|
||||
]) refuses(f, () => f.settings.veto("acme", bad, "human"), "invalid-request");
|
||||
refuses(f, () => f.settings.veto("acme", null, "human"), "invalid-request");
|
||||
veto(f, "keep", "sec-1");
|
||||
refuses(f, () => f.settings.veto("acme", { revision, op: "nohire", name: "coder" }, "human"), "revision-conflict");
|
||||
const none = fixture(doc => { delete doc.launch; doc.roles["sec-1"] = { definition: "security-reviewer" }; });
|
||||
refuses(none, () => veto(none, "nohire", "coder"), "organization-launch-not-configured");
|
||||
});
|
||||
|
||||
test("D2.2 hire adds and lists a new instance, logs the chief's context, and sets only model and limits", () => {
|
||||
const f = fixture();
|
||||
const done = hire(f, { name: "sec-2", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } });
|
||||
assert.deepEqual(done.instances["sec-2"], {
|
||||
definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false,
|
||||
});
|
||||
assert.equal(done.restart, false);
|
||||
assert.deepEqual(f.onDisk().launch.instances, ["coder", "reviewer", "sec-1", "sec-2"]);
|
||||
const entry = f.log().at(-1);
|
||||
assert.equal(entry.actor, "chief");
|
||||
assert.deepEqual(entry.context, CONTEXT);
|
||||
assert.equal(entry.transaction, done.transaction);
|
||||
assert.equal(f.validate().status, 0);
|
||||
for (const vars of [{ harness: "pi" }, { model: "claude-sonnet-5-5", thinking: "high" }, []]) {
|
||||
refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer", vars }), "invalid-request");
|
||||
}
|
||||
for (const args of [{ name: "Bad Name", definition: "security-reviewer" }, { name: "human", definition: "security-reviewer" },
|
||||
{ name: "tester", definition: "security-reviewer" }, { name: "sec-3", definition: "Bad Name" }]) {
|
||||
refuses(f, () => hire(f, args), "invalid-request");
|
||||
}
|
||||
refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer", vars: { model: "not-in-the-table" } }), "BusinessError");
|
||||
refuses(f, () => hire(f, { name: "coder-2", definition: "coder" }), "organization-credentials-out-of-scope");
|
||||
refuses(f, () => hire(f, { name: "asst-2", definition: "assistant" }), "organization-definition-refused");
|
||||
refuses(f, () => hire(f, { name: "sec-3", definition: "no-such-role" }), "BusinessError");
|
||||
});
|
||||
|
||||
test("D2.2 hire refuses noHire, a kept name and a listed name, and re-lists a fired one only as it was", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.launch.noHire = ["merge-gate"];
|
||||
doc.roles["sec-2"] = { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } };
|
||||
doc.roles["sec-3"] = { definition: "security-reviewer" };
|
||||
doc.launch.keep = ["sec-3"];
|
||||
});
|
||||
refuses(f, () => hire(f, { name: "gate-1", definition: "merge-gate" }), "definition-not-hireable");
|
||||
refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer" }), "instance-kept");
|
||||
refuses(f, () => hire(f, { name: "sec-1", definition: "security-reviewer" }), "organization-instance-exists");
|
||||
refuses(f, () => hire(f, { name: "chief", definition: "chief-of-staff" }), "organization-instance-exists");
|
||||
// sec-2 is declared and not listed: fired. Only its own definition with no vars lists it again.
|
||||
refuses(f, () => hire(f, { name: "sec-2", definition: "merge-gate" }), "definition-not-hireable");
|
||||
veto(f, "hire", "merge-gate");
|
||||
refuses(f, () => hire(f, { name: "sec-2", definition: "merge-gate" }), "organization-instance-exists");
|
||||
refuses(f, () => hire(f, { name: "sec-2", definition: "security-reviewer", vars: { model: "claude-opus-5-5" } }), "organization-instance-exists");
|
||||
const done = hire(f, { name: "sec-2", definition: "security-reviewer" });
|
||||
assert.equal(done.instances["sec-2"].launched, true);
|
||||
assert.deepEqual(f.onDisk().roles["sec-2"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } });
|
||||
const none = fixture(doc => { delete doc.launch; });
|
||||
refuses(none, () => hire(none, { name: "sec-9", definition: "security-reviewer" }), "organization-launch-not-configured");
|
||||
});
|
||||
|
||||
test("D2.2 fire delists and keeps the entry, a repeat is done with no write, and each refusal holds", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.roles.assistant = { definition: "assistant", holder: "mos" };
|
||||
doc.roles["sec-3"] = { definition: "security-reviewer" };
|
||||
doc.launch.instances.push("sec-3");
|
||||
doc.launch.keep = ["sec-3"];
|
||||
});
|
||||
const done = fire(f, "sec-1");
|
||||
assert.equal(done.delisted, true);
|
||||
assert.equal(done.instances["sec-1"].launched, false);
|
||||
assert.deepEqual(f.onDisk().roles["sec-1"], { definition: "security-reviewer" });
|
||||
assert.deepEqual(f.onDisk().launch.instances, ["coder", "reviewer", "sec-3"]);
|
||||
const entry = f.log().at(-1);
|
||||
assert.equal(entry.actor, "chief");
|
||||
assert.deepEqual(entry.context, CONTEXT);
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
const again = fire(f, "sec-1");
|
||||
assert.equal(again.delisted, false);
|
||||
assert.equal(again.transaction, null);
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes);
|
||||
refuses(f, () => fire(f, "ghost"), "organization-unknown-instance");
|
||||
refuses(f, () => fire(f, "Bad Name"), "organization-unknown-instance");
|
||||
refuses(f, () => fire(f, "sec-3"), "instance-kept");
|
||||
refuses(f, () => fire(f, "assistant"), "organization-definition-refused");
|
||||
refuses(f, () => fire(f, "chief"), "organization-instance-referenced");
|
||||
assert.equal(f.validate().status, 0);
|
||||
const one = fixture(doc => { doc.launch.instances = ["sec-1"]; });
|
||||
refuses(one, () => fire(one, "sec-1"), "BusinessError");
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadRole, loadRoleFile, validateRoleDocument, ACTIONS, GATED_ONLY, HIRE_ALLOWED, BusinessError } from "../src/index.mjs";
|
||||
import { loadRole, loadRoleFile, validateRoleDocument, GATED_ONLY, BusinessError } from "../src/index.mjs";
|
||||
import { REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
function refuses(fn, pattern, exitCode = 2) {
|
||||
@@ -74,13 +74,7 @@ test("shipped authority follows the note's table", () => {
|
||||
for (const name of others.map((f) => f.slice(0, -5))) {
|
||||
assert.ok(!auth(name).withinRole.includes("instance.name"), name);
|
||||
assert.ok(!auth(name).crossRole.includes("instance.name"), name);
|
||||
// Row 194 (PRD 1.7 REQ-COS-3, sage-1471): hire and fire are the chief's alone too.
|
||||
for (const action of ["role.hire", "role.fire"]) {
|
||||
assert.ok(!auth(name).withinRole.includes(action), `${name} ${action}`);
|
||||
assert.ok(!auth(name).crossRole.includes(action), `${name} ${action}`);
|
||||
}
|
||||
}
|
||||
assert.ok(auth("chief-of-staff").withinRole.includes("role.hire") && auth("chief-of-staff").withinRole.includes("role.fire"));
|
||||
assert.ok(auth("cto").withinRole.includes("decision.resolve.technical"));
|
||||
assert.ok(!auth("pm").withinRole.includes("decision.resolve.technical"));
|
||||
});
|
||||
@@ -221,64 +215,3 @@ test("contract: a non-empty regular Markdown file beside the role file", () => {
|
||||
symlinkSync("r.md", join(dir, "link.md"));
|
||||
refuses(() => validateRoleDocument(doc, file), /non-empty regular file/);
|
||||
});
|
||||
|
||||
// Row 194 (#1692, D2.3, T8c): the chief's hireable list, checked at load against HIRE_ALLOWED.
|
||||
// Each refused action is looped from the lists, not typed by hand.
|
||||
const REFUSED_FOR_HIRE = ACTIONS.filter((action) => !HIRE_ALLOWED.includes(action) && !GATED_ONLY.includes(action));
|
||||
|
||||
function hireDir(definitions) {
|
||||
const dir = tmp();
|
||||
for (const [name, doc] of Object.entries(definitions)) {
|
||||
writeFileSync(join(dir, `${name}.md`), "# contract\n");
|
||||
writeJson(join(dir, `${name}.json`), doc === 1 ? { roleVersion: 1, name, tools: ["read"] } : {
|
||||
roleVersion: 2, name, title: name, contract: `${name}.md`, tools: ["read"], network: "none",
|
||||
authority: { withinRole: doc.withinRole ?? ["message.send"], crossRole: doc.crossRole ?? [] }, credentials: [],
|
||||
...(doc.hireable === undefined ? {} : { hireable: doc.hireable }),
|
||||
});
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
test("hireable: HIRE_ALLOWED is exactly the six plain work actions, and every one is routed", () => {
|
||||
assert.deepEqual([...HIRE_ALLOWED], ["message.send", "record.write", "review.request", "task.update.assigned",
|
||||
"task.scope.change", "git.push.working"]);
|
||||
assert.ok(Object.isFrozen(HIRE_ALLOWED));
|
||||
for (const action of HIRE_ALLOWED) assert.ok(ACTIONS.includes(action) && !GATED_ONLY.includes(action), action);
|
||||
assert.ok(!HIRE_ALLOWED.includes("git.push.remote"));
|
||||
assert.ok(REFUSED_FOR_HIRE.length >= 15, "the loop covers the routed actions outside the list");
|
||||
});
|
||||
|
||||
test("hireable: a list naming a definition that holds any refused action refuses at load, one case per action", () => {
|
||||
for (const action of REFUSED_FOR_HIRE) {
|
||||
for (const key of ["withinRole", "crossRole"]) {
|
||||
const dir = hireDir({ chief: { hireable: ["worker"] }, worker: { [key]: [action], ...(key === "crossRole" ? { withinRole: [] } : {}) } });
|
||||
refuses(() => loadRole(dir, "chief"), new RegExp(`hireable names worker, which holds ${action.replace(/\./g, "\\.")}$`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("hireable: a mixed definition refuses, a clean list loads, and the named file's own hireable isn't read", () => {
|
||||
const mixed = hireDir({ chief: { hireable: ["worker"] }, worker: { withinRole: ["message.send", "role.launch"] } });
|
||||
refuses(() => loadRole(mixed, "chief"), /hireable names worker, which holds role\.launch/);
|
||||
const clean = hireDir({
|
||||
chief: { hireable: ["worker", "helper"] },
|
||||
worker: { withinRole: [...HIRE_ALLOWED] },
|
||||
// helper's own list names a definition that doesn't exist. Only the chief's list is checked.
|
||||
helper: { withinRole: ["record.write"], hireable: ["nobody"] },
|
||||
});
|
||||
assert.deepEqual(loadRole(clean, "chief").hireable, ["worker", "helper"]);
|
||||
assert.ok(Object.isFrozen(loadRole(clean, "chief").hireable));
|
||||
refuses(() => loadRole(clean, "helper"), /hireable names nobody, which has no role file/);
|
||||
});
|
||||
|
||||
test("hireable: a missing, version 1, duplicate or badly named entry refuses, and the key is optional", () => {
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["ghost"] } }), "chief"), /hireable names ghost, which has no role file/);
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["old"] }, old: 1 }), "chief"), /hireable names old, which is role version 1/);
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["w", "w"] }, w: {} }), "chief"), /duplicate entry in role hireable/);
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: ["../w"] } }), "chief"), /role hireable entry/);
|
||||
refuses(() => loadRole(hireDir({ chief: { hireable: "w" } }), "chief"), /role hireable must be an array/);
|
||||
assert.deepEqual(loadRole(hireDir({ chief: {} }), "chief").hireable, []);
|
||||
assert.deepEqual(loadRole(REPO_ROLES, "chief-of-staff").hireable, []);
|
||||
// A version 1 file can't carry the key.
|
||||
refuses(() => validateRoleDocument({ roleVersion: 1, name: "r", tools: ["read"], hireable: [] }, join(tmp(), "r.json")), /unsupported role key/);
|
||||
});
|
||||
|
||||
@@ -576,7 +576,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
|
||||
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
|
||||
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 165 (#1662): scripts/mosaic sends only `business instances`, `business instance` and (row 194) `business veto` here.
|
||||
// Row 165 (#1662): scripts/mosaic sends only `business instances` and `business instance` here.
|
||||
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
if (verb === "talk") return talk(rest, io, deps);
|
||||
if (verb === "follow") return talk(rest, io, deps, true);
|
||||
|
||||
@@ -3,19 +3,12 @@
|
||||
// and instances.set verbs, and with --tester the tester does (row 175, #1673, Q173). The broker
|
||||
// refuses an agent and a reader; the business package's instance writer refuses a stale revision
|
||||
// and every document `mosaic business validate` would refuse, and logs the change with its actor.
|
||||
// Row 194 (#1692, D1): `business instance launch|terminate|regenerate|delete` and `business veto`.
|
||||
// Launch, regenerate and delete are requests the launcher's pass carries out. The command polls
|
||||
// instance.request until the request closes or --wait runs out (D1.1 item 5).
|
||||
import { readFileSync } from "node:fs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
|
||||
export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] [--tester] | " +
|
||||
"business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester] | " +
|
||||
"business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester] | " +
|
||||
"business instance launch|regenerate BUSINESS INSTANCE [--wait SECONDS] [--tester] | " +
|
||||
"business instance terminate BUSINESS INSTANCE [--reason TEXT] [--tester] | " +
|
||||
"business instance delete BUSINESS INSTANCE --revision REV [--wait SECONDS] | " +
|
||||
"business veto BUSINESS keep|unkeep INSTANCE --revision REV | business veto BUSINESS nohire|hire DEFINITION --revision REV";
|
||||
"business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester]";
|
||||
|
||||
// The bus answers a refusal with its code alone, so the reason a human reads is here.
|
||||
export const REASONS = {
|
||||
@@ -30,32 +23,8 @@ export const REASONS = {
|
||||
"organization-project-mismatch": "a project file's id differs from its key in the business file; `mosaic business validate` shows it",
|
||||
"organization-unresolvable-instance": "an instance wouldn't resolve against a project file; `mosaic business validate` shows why",
|
||||
"revision-conflict": "the business file changed since that revision; read it again with `mosaic business instances`",
|
||||
// Row 194 (#1692): the lifecycle and veto refusals.
|
||||
"organization-instance-referenced": "the instance is launch.by or an arbiter; change that reference first",
|
||||
"organization-instance-kept": "the instance is kept; run `mosaic business veto BUSINESS unkeep INSTANCE` first",
|
||||
"organization-instance-retired": "that name was deleted and stays retired; pick another name",
|
||||
"instance-running": "the instance has a run; end it with `mosaic business instance terminate` first",
|
||||
"instance-starting": "a request for the instance has not closed yet; wait for it and try again",
|
||||
"instance-retired": "that name was deleted and launches no more",
|
||||
"not-thread-launch": "a thread instance starts on a thread message, not by launch",
|
||||
"regenerate-thread": "a thread instance isn't regenerated; its context is the thread's",
|
||||
"not-held": "the instance has no run and no open request",
|
||||
"launch-revoked": "launches are revoked for the business; `mosaic launches on --business BUSINESS` lifts it",
|
||||
"launches-disabled": "the bus host was started with --no-launch",
|
||||
"request-expired": "the launcher didn't take the request within its 60 s",
|
||||
"regenerate-timeout": "the old run didn't exit within the request's 60 s, so no new run started; it still runs",
|
||||
"workspace-rename-failed": "the business file is changed and the name retired, but the workspace stayed where it was; " +
|
||||
"run the same delete again to retry the rename",
|
||||
"capacity-full": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
|
||||
"limit-reached": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
|
||||
"unknown-role": "the business has no such instance",
|
||||
};
|
||||
|
||||
const LIFECYCLE_OPS = ["launch", "terminate", "regenerate", "delete"];
|
||||
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
|
||||
const WAIT_SECONDS = 30;
|
||||
const POLL_MS = 200;
|
||||
|
||||
export const RESTART_NEEDED = "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it";
|
||||
const NO_RESTART = "no restart: the launcher reads the change at its next launch check";
|
||||
|
||||
@@ -66,7 +35,7 @@ function parse(args) {
|
||||
if (["--json", "--launch", "--no-launch", "--tester"].includes(arg)) {
|
||||
if (out.flags.has(arg)) throw new CliError(INSTANCES_USAGE, 4);
|
||||
out.flags.add(arg);
|
||||
} else if (["--definition", "--vars", "--revision", "--wait", "--reason"].includes(arg)) {
|
||||
} else if (["--definition", "--vars", "--revision"].includes(arg)) {
|
||||
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(INSTANCES_USAGE, 4);
|
||||
out.values[arg] = args[++i];
|
||||
} else if (arg.startsWith("--")) throw new CliError(INSTANCES_USAGE, 4);
|
||||
@@ -79,7 +48,6 @@ function parse(args) {
|
||||
function setArgs(parsed) {
|
||||
const [, op, , instance] = parsed.positional;
|
||||
const { values, flags } = parsed;
|
||||
if (values["--wait"] !== undefined || values["--reason"] !== undefined) throw new CliError(INSTANCES_USAGE, 4);
|
||||
const launch = flags.has("--launch") ? true : flags.has("--no-launch") ? false : undefined;
|
||||
if (parsed.positional.length !== 4 || !["add", "set"].includes(op) || !values["--revision"] || flags.has("--json") ||
|
||||
(flags.has("--launch") && flags.has("--no-launch")) || (op === "add" && (!values["--definition"] || launch === false)) ||
|
||||
@@ -99,89 +67,10 @@ function setArgs(parsed) {
|
||||
return args;
|
||||
}
|
||||
|
||||
// Row 194: the lifecycle and veto args, or null when `instance OP` is add or set. Usage errors exit 4.
|
||||
function lifecycleArgs(parsed) {
|
||||
const { positional, values, flags } = parsed;
|
||||
const [sub, op, , name] = positional;
|
||||
const only = (allowed) => Object.keys(values).every((k) => allowed.includes(k)) && [...flags].every((f) => allowed.includes(f));
|
||||
let wait = WAIT_SECONDS;
|
||||
if (values["--wait"] !== undefined) {
|
||||
if (!/^[0-9]{1,3}$/.test(values["--wait"]) || Number(values["--wait"]) > 600) throw new CliError(INSTANCES_USAGE, 4);
|
||||
wait = Number(values["--wait"]);
|
||||
}
|
||||
if (sub === "veto") {
|
||||
const [, , vop, target] = positional;
|
||||
if (positional.length !== 4 || !VETO_OPS.includes(vop) || !values["--revision"] || !only(["--revision"])) throw new CliError(INSTANCES_USAGE, 4);
|
||||
return { verb: "veto.set", args: { revision: values["--revision"], op: vop, name: target } };
|
||||
}
|
||||
if (!LIFECYCLE_OPS.includes(op)) return null;
|
||||
if (positional.length !== 4) throw new CliError(INSTANCES_USAGE, 4);
|
||||
if (op === "terminate") {
|
||||
if (!only(["--reason", "--tester"])) throw new CliError(INSTANCES_USAGE, 4);
|
||||
return { verb: "instance.end", args: { instance: name, ...(values["--reason"] !== undefined ? { reason: values["--reason"] } : {}) } };
|
||||
}
|
||||
if (op === "delete") {
|
||||
if (!values["--revision"] || !only(["--revision", "--wait"])) throw new CliError(INSTANCES_USAGE, 4);
|
||||
return { verb: "instances.set", args: { revision: values["--revision"], instance: name, op: "delete" }, wait };
|
||||
}
|
||||
if (!only(["--wait", "--tester"])) throw new CliError(INSTANCES_USAGE, 4);
|
||||
return { verb: `instance.${op}`, args: { instance: name }, wait };
|
||||
}
|
||||
|
||||
// D1.1 item 5: polls instance.request until the request closes or `wait` seconds pass. A closed
|
||||
// request with a refusal or a cancel is exit 3 (refused) with its code. Still open or taken at the
|
||||
// end is exit 3 with the request id, as the design's timeout is: a taken request with no done is
|
||||
// outcome-unknown.
|
||||
async function follow(call, request, wait) {
|
||||
const end = Date.now() + wait * 1000;
|
||||
for (;;) {
|
||||
const r = await call("instance.request", { request });
|
||||
if (r.state === "closed") {
|
||||
if (r.cancelled) throw refusal("request-cancelled", `cancelled by ${r.cancelled.by} (request ${request})`);
|
||||
if (r.done?.refusal) {
|
||||
const kept = r.done.transaction ? `, transaction ${r.done.transaction}` : "";
|
||||
throw refusal(r.done.refusal, `${REASONS[r.done.refusal] ?? "the launcher refused the request"} (request ${request}${kept})`);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
if (Date.now() >= end) {
|
||||
const error = r.state === "taken"
|
||||
? new CliError(`outcome-unknown: request ${request} was taken and has not closed after ${wait} s; the launch log names any run it started`, 3)
|
||||
: new CliError(`request ${request} is still open after ${wait} s; the launcher takes it within its 60 s or closes it request-expired`, 3);
|
||||
error.code = r.state === "taken" ? "outcome-unknown" : "request-open";
|
||||
error.final = true;
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_MS, Math.max(0, end - Date.now()))));
|
||||
}
|
||||
}
|
||||
|
||||
// `final`: the catch below passes it on as it is, with its request id.
|
||||
function refusal(code, why) {
|
||||
const error = new CliError(`${code}: ${why}`, 3);
|
||||
error.code = code;
|
||||
error.final = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
function lifecycleLine(business, op, instance, r) {
|
||||
if (op === "delete") return `business ${business}: deleted ${instance}, now retired\ntransaction ${r.done.transaction}\n`;
|
||||
return `business ${business}: ${op === "launch" ? "launched" : "regenerated"} ${instance}, run ${r.done.run} (request ${r.request})\n`;
|
||||
}
|
||||
|
||||
function terminateLines(business, instance, out) {
|
||||
const lines = [`business ${business}: ended ${instance} (request ${out.request})`];
|
||||
lines.push(out.runs.length ? `runs ${out.runs.join(", ")}: claims revoked, the launcher signals each` : "no run held a claim");
|
||||
if (out.cancelled.length) lines.push(`cancelled ${out.cancelled.join(", ")}`);
|
||||
if (out.taken.length) lines.push(`taken ${out.taken.join(", ")}: past cancelling; a run it starts is ended by the launcher's pass, or by a second terminate`);
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function formatInstances(view) {
|
||||
const lines = [`business ${view.business}, revision ${view.revision}`];
|
||||
for (const [name, entry] of Object.entries(view.instances)) {
|
||||
const marks = [entry.launcher && "launcher", entry.launched && "launched", entry.protected && "protected", entry.kept && "kept",
|
||||
entry.running?.length && `running ${entry.running.join(", ")}`].filter(Boolean);
|
||||
const marks = [entry.launcher && "launcher", entry.launched && "launched", entry.protected && "protected"].filter(Boolean);
|
||||
const vars = Object.keys(entry.vars).length ? ` vars ${JSON.stringify(entry.vars)}` : "";
|
||||
lines.push(` ${name}: ${entry.definition}${marks.length ? ` (${marks.join(", ")})` : ""}${vars}`);
|
||||
}
|
||||
@@ -190,8 +79,6 @@ export function formatInstances(view) {
|
||||
const bound = definitions.filter(([, d]) => d.services.length).map(([name, d]) => `${name} (${d.services.join(", ")})`);
|
||||
lines.push(`definitions add and set accept: ${accepted.join(", ") || "none"}`);
|
||||
if (bound.length) lines.push(`definitions that declare credentials, a hand edit: ${bound.join(", ")}`);
|
||||
if (view.retired?.length) lines.push(`retired: ${view.retired.join(", ")}`);
|
||||
if (view.noHire?.length) lines.push(`the chief may not hire: ${view.noHire.join(", ")}`);
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
@@ -199,29 +86,16 @@ export function formatInstances(view) {
|
||||
export async function instancesCommand(args, io, callFor) {
|
||||
const parsed = parse(args);
|
||||
const sub = parsed.positional[0];
|
||||
const business = parsed.positional[sub === "instance" ? 2 : 1];
|
||||
if (!["instances", "instance", "veto"].includes(sub) || !business) throw new CliError(INSTANCES_USAGE, 4);
|
||||
const business = parsed.positional[sub === "instances" ? 1 : 2];
|
||||
if (!["instances", "instance"].includes(sub) || !business) throw new CliError(INSTANCES_USAGE, 4);
|
||||
let request;
|
||||
const lifecycle = sub === "instances" ? null : lifecycleArgs(parsed);
|
||||
if (lifecycle) {
|
||||
// veto and the lifecycle verbs, below.
|
||||
} else if (sub === "instances") {
|
||||
if (sub === "instances") {
|
||||
if (parsed.positional.length !== 2 || Object.keys(parsed.values).length || parsed.flags.has("--launch") || parsed.flags.has("--no-launch")) {
|
||||
throw new CliError(INSTANCES_USAGE, 4);
|
||||
}
|
||||
} else request = setArgs(parsed);
|
||||
try {
|
||||
const call = callFor({ positional: parsed.positional, flags: parsed.flags, values: { "--business": business } });
|
||||
if (lifecycle) {
|
||||
const out = await call(lifecycle.verb, lifecycle.args);
|
||||
if (sub === "veto") {
|
||||
return io.stdout.write(`business ${out.business}: veto ${lifecycle.args.op} ${lifecycle.args.name}\nrevision ${out.revision}\ntransaction ${out.transaction}\n`);
|
||||
}
|
||||
const op = parsed.positional[1];
|
||||
if (op === "terminate") return io.stdout.write(terminateLines(business, lifecycle.args.instance, out));
|
||||
io.stderr.write(`mosaic: request ${out.request}, waiting up to ${lifecycle.wait} s\n`);
|
||||
return io.stdout.write(lifecycleLine(business, op, lifecycle.args.instance, await follow(call, out.request, lifecycle.wait)));
|
||||
}
|
||||
if (!request) {
|
||||
const view = await call("instances", {});
|
||||
return io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(view, null, 2)}\n` : formatInstances(view));
|
||||
@@ -235,7 +109,6 @@ export async function instancesCommand(args, io, callFor) {
|
||||
result.restart ? RESTART_NEEDED : NO_RESTART,
|
||||
].join("\n") + "\n");
|
||||
} catch (error) {
|
||||
if (error.final) throw error;
|
||||
const code = error.code;
|
||||
if (Object.hasOwn(REASONS, code)) {
|
||||
const refused = new CliError(`${code}: ${REASONS[code]}`, error.exitCode ?? 2);
|
||||
|
||||
+13
-167
@@ -95,30 +95,10 @@
|
||||
//
|
||||
// Nothing here is persisted. After a restart recover() ends the earlier
|
||||
// host's runs, so the first tick starts each holder.
|
||||
//
|
||||
// Row 194 (#1692, D1.1): the lifecycle pass, on the same tick, carries out the
|
||||
// human's and the tester's requests the broker recorded (launch, end,
|
||||
// regenerate, delete). The broker never starts a process or touches a
|
||||
// workspace. Under serialize, each open request is taken (host op
|
||||
// lifecycleTake, which re-checks it and closes it with a code when it may not
|
||||
// run), then acted on, then closed with lifecycleDone. A taken request never
|
||||
// runs again, whatever happens after the take. A done that throws leaves it
|
||||
// taken with no done, which the CLI reports as outcome-unknown.
|
||||
// - launch: check() and start() with `human`, so an unlisted instance runs.
|
||||
// - end: SIGTERM to each named run's runner after the start-time check, and
|
||||
// to any other run of the instance in the registry. The exit handler
|
||||
// records the end. No SIGKILL here (row 191, S6).
|
||||
// - regenerate: the end's signals, then a new run once the old one has
|
||||
// left the registry. Not gone by the request's 60 s mark closes it
|
||||
// regenerate-timeout and starts nothing.
|
||||
// - delete: instance-running against the registry, then lifecycleDelete
|
||||
// (the writer's commit), then the rename of workspaces/<b>/I/ to
|
||||
// workspaces/<b>/.retired/I-<transaction>/. A failed rename closes it
|
||||
// workspace-rename-failed with the transaction; a later delete retries it.
|
||||
|
||||
import { createServer } from "node:net";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||||
import { limitHit } from "../../business/src/launch.mjs";
|
||||
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
|
||||
@@ -153,8 +133,6 @@ const LINE_MAX = 4096;
|
||||
const THREAD_TICK_MS = 2000;
|
||||
// The broker's thread instance definition (packages/bus/src/broker.mjs).
|
||||
const THREAD_DEFINITION = "assistant";
|
||||
// Row 194: the broker's LIFECYCLE_REQUEST_MS. A regenerate's old run must be gone by then.
|
||||
const LIFECYCLE_REQUEST_MS = 60000;
|
||||
|
||||
// A refusal the host decided; `code` goes back to the caller and to the broker.
|
||||
class Refusal extends Error {
|
||||
@@ -176,14 +154,13 @@ function call(name, what, fn) {
|
||||
|
||||
// host: startHost()'s handle. tracker: true when the broker has task verbs for
|
||||
// the business. launches: false for `bus start --no-launch` (row 163, the D2
|
||||
// rehearsal): start() refuses launches-disabled, the ticker runs only the
|
||||
// lifecycle pass (row 194), which closes each request launches-disabled, a
|
||||
// rehearsal): start() refuses launches-disabled, listen() starts no ticker, a
|
||||
// session's launch request is refused, recover() kills and ends nothing, and
|
||||
// sessions.json stays as it was.
|
||||
// makeLoader (the plug-in loader's factory, which tests point at a
|
||||
// scratch tree), bwrap, readOnly, sessionDefaults, harnessVersion,
|
||||
// stopTimeoutMs, threadTickMs, lifecycleRequestMs and git (createGitSocket
|
||||
// options) exist for the tests; the command line never sets them.
|
||||
// stopTimeoutMs, threadTickMs and git (createGitSocket options) exist for
|
||||
// the tests; the command line never sets them.
|
||||
export function createLauncher({
|
||||
host,
|
||||
system,
|
||||
@@ -197,7 +174,6 @@ export function createLauncher({
|
||||
harnessVersion = undefined,
|
||||
stopTimeoutMs = STOP_TIMEOUT_MS,
|
||||
threadTickMs = THREAD_TICK_MS,
|
||||
lifecycleRequestMs = LIFECYCLE_REQUEST_MS,
|
||||
git = {},
|
||||
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
|
||||
}) {
|
||||
@@ -233,7 +209,6 @@ export function createLauncher({
|
||||
const refusedLand = new Map();
|
||||
const refusedRelease = new Map();
|
||||
let refusedHolders = null;
|
||||
let refusedRequests = null;
|
||||
const sockets = new Set();
|
||||
const gitSocket = createGitSocket({ host, business: b, dataRoot, env, log, ...git });
|
||||
|
||||
@@ -253,13 +228,11 @@ export function createLauncher({
|
||||
|
||||
// Steps 2 to 9. Returns what the launch needs. Only `--pm` may name
|
||||
// launch.by itself. A thread instance launches only for a thread, and
|
||||
// nothing else launches for one. Row 194 (#1692, V2): `human` is the
|
||||
// human's launch request, which may name any declared instance. The listing
|
||||
// limits the chief, not the human. launch.by still takes the `pm` branch.
|
||||
async function check(instance, { pm = false, thread = null, human = false } = {}) {
|
||||
// nothing else launches for one.
|
||||
async function check(instance, { pm = false, thread = null } = {}) {
|
||||
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
|
||||
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
|
||||
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance))) throw new Refusal("instance-not-listed");
|
||||
const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
|
||||
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
|
||||
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
|
||||
@@ -354,7 +327,7 @@ export function createLauncher({
|
||||
}
|
||||
|
||||
// Bundle, spawn, bind, register, hand over the capability.
|
||||
async function start({ instance, launchedBy, resolved, model, account = null, thread = null, human = false }) {
|
||||
async function start({ instance, launchedBy, resolved, model, account = null, thread = null }) {
|
||||
// Every launch path ends here, so a --no-launch host spawns nothing whichever path asked.
|
||||
if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");
|
||||
// A thread's store is checked first, so a bad id or an unsafe directory
|
||||
@@ -387,7 +360,7 @@ export function createLauncher({
|
||||
else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason });
|
||||
}
|
||||
let harness, route, routeReason;
|
||||
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread,human}));
|
||||
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread}));
|
||||
const { run, runDir } = newRun(dataRoot, b.id);
|
||||
const sessionDir = store ? store.session : join(runDir, "session");
|
||||
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
|
||||
@@ -516,12 +489,8 @@ export function createLauncher({
|
||||
if (ticking || closed) return;
|
||||
ticking = true;
|
||||
try {
|
||||
// A --no-launch host runs only the lifecycle pass, which closes each request launches-disabled.
|
||||
if (launches) {
|
||||
await threadPass();
|
||||
await landPass();
|
||||
}
|
||||
await lifecyclePass();
|
||||
await threadPass();
|
||||
await landPass();
|
||||
} finally {
|
||||
ticking = false;
|
||||
}
|
||||
@@ -641,129 +610,6 @@ export function createLauncher({
|
||||
}
|
||||
}
|
||||
|
||||
// Row 194 (#1692, D1.1): the human's and the tester's lifecycle requests, oldest first.
|
||||
async function lifecyclePass() {
|
||||
let requests;
|
||||
try {
|
||||
requests = await host.op({ op: "lifecycleRequests", business: b.id });
|
||||
if (!Array.isArray(requests)) throw new Refusal("malformed-answer");
|
||||
refusedRequests = null;
|
||||
} catch (e) {
|
||||
const code = e.code ?? e.message;
|
||||
if (refusedRequests !== code) log(`lifecycleRequests refused: ${code}`);
|
||||
refusedRequests = code;
|
||||
return;
|
||||
}
|
||||
for (const r of requests) {
|
||||
if (closed) return;
|
||||
// A taken request is past this pass: it ran, or its done failed.
|
||||
if (r.state !== "open") continue;
|
||||
await serialize(() => carry(r));
|
||||
}
|
||||
}
|
||||
|
||||
// One request: the take, the act, the done. Runs under serialize, so no start() runs alongside it.
|
||||
async function carry(r) {
|
||||
if (closed) return;
|
||||
let take;
|
||||
try {
|
||||
take = await host.op({ op: "lifecycleTake", record: { business: b.id, request: r.request } });
|
||||
} catch (e) {
|
||||
log(`request ${r.request} (${r.instance}): lifecycleTake refused: ${e.code ?? e.message}`);
|
||||
return;
|
||||
}
|
||||
if (take?.go !== true) {
|
||||
if (take?.refusal && take.refusal !== "request-closed") record({ event: "refused", by: r.by, instance: r.instance, request: r.request, code: take.refusal });
|
||||
return;
|
||||
}
|
||||
let done;
|
||||
try {
|
||||
done = await act(take);
|
||||
} catch (e) {
|
||||
done = { refusal: e instanceof Refusal ? e.code : "launch-failed", why: e.message, limit: e.limit };
|
||||
}
|
||||
const { why, limit, ...closing } = done;
|
||||
if (closing.refusal) record({ event: "refused", by: r.by, instance: r.instance, request: r.request, code: closing.refusal, ...(why ? { why } : {}), ...(limit ? { limit } : {}) });
|
||||
try {
|
||||
await host.op({ op: "lifecycleDone", record: { business: b.id, request: r.request, ...closing } });
|
||||
} catch (e) {
|
||||
log(`request ${r.request} (${r.instance}): lifecycleDone refused: ${e.code ?? e.message}${closing.run ? `; run ${closing.run} started` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
// What the request did: { run } for a start, { transaction } for a delete, {} for an end, or { refusal }.
|
||||
async function act(r) {
|
||||
// Row 163: a --no-launch host starts, signals and renames nothing.
|
||||
if (!launches) return { refusal: "launches-disabled" };
|
||||
const launchedBy = r.by === "tester" ? "tester" : b.human;
|
||||
if (r.action === "lifecycle.launch") return launch(r.instance, launchedBy);
|
||||
if (r.action === "lifecycle.end") {
|
||||
terminate(r.instance, r.runs ?? []);
|
||||
return {};
|
||||
}
|
||||
if (r.action === "lifecycle.regenerate") {
|
||||
terminate(r.instance, []);
|
||||
const deadline = Date.parse(r.at) + lifecycleRequestMs;
|
||||
while (registry.sessions.some((s) => s.business === b.id && s.instance === r.instance)) {
|
||||
if (closed) return { refusal: "host-closing" };
|
||||
if (Date.now() >= deadline) return { refusal: "regenerate-timeout" };
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return launch(r.instance, launchedBy);
|
||||
}
|
||||
if (r.action === "lifecycle.delete") return remove(r);
|
||||
return { refusal: "invalid-request" };
|
||||
}
|
||||
|
||||
async function launch(instance, launchedBy) {
|
||||
const pm = instance === b.launch?.by;
|
||||
const { run } = await start({ instance, launchedBy, human: true, ...(await check(instance, { pm, human: true })) });
|
||||
return { run };
|
||||
}
|
||||
|
||||
// SIGTERM, after the start-time check, to the named runs and to every other run of the instance
|
||||
// the registry holds. A thread instance's end names only the runs it revoked (the tester's own).
|
||||
function terminate(instance, runs) {
|
||||
const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
|
||||
for (const s of registry.sessions) {
|
||||
if (s.business !== b.id || !(runs.includes(s.run) || (!threaded && s.instance === instance))) continue;
|
||||
stopping.add(s.run);
|
||||
record({ event: "lifecycle-end", instance: s.instance, run: s.run });
|
||||
try {
|
||||
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// V5 steps 1 to 3. The file goes first: once committed, check() refuses the name, so no launch
|
||||
// opens the old workspace even if the rename fails.
|
||||
async function remove(r) {
|
||||
if (registry.sessions.some((s) => s.business === b.id && s.instance === r.instance)) return { refusal: "instance-running" };
|
||||
let out;
|
||||
try {
|
||||
out = await host.op({ op: "lifecycleDelete", record: { business: b.id, request: r.request } });
|
||||
} catch (e) {
|
||||
return { refusal: e.code ?? "outcome-unknown" };
|
||||
}
|
||||
const transaction = out?.transaction ?? null;
|
||||
if (transaction === null) return { refusal: "workspace-rename-failed" };
|
||||
const from = workspaceDir(dataRoot, b.id, r.instance);
|
||||
if (!existsSync(from)) return { transaction };
|
||||
const to = join(dirname(from), ".retired", `${r.instance}-${transaction}`);
|
||||
try {
|
||||
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
|
||||
chmodSync(dirname(to), 0o700);
|
||||
if (existsSync(to)) throw new Error(`${to} exists`);
|
||||
renameSync(from, to);
|
||||
chmodSync(to, 0o700);
|
||||
} catch (e) {
|
||||
log(`request ${r.request} (${r.instance}): workspace rename failed: ${e.message}; left at ${from}`);
|
||||
return { transaction, refusal: "workspace-rename-failed", why: `${e.message}; left at ${from}` };
|
||||
}
|
||||
record({ event: "workspace-retired", instance: r.instance, request: r.request, transaction, path: to });
|
||||
return { transaction };
|
||||
}
|
||||
|
||||
// endLaunch's answer ({ released, limit? }), or null when it refused.
|
||||
async function endRun(run, reason, exitCode) {
|
||||
try {
|
||||
@@ -917,7 +763,7 @@ export function createLauncher({
|
||||
});
|
||||
chmodSync(path, 0o600);
|
||||
await gitSocket.listen();
|
||||
if (b.launch) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
|
||||
if (b.launch && launches) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
|
||||
}
|
||||
|
||||
// SIGTERM every runner, wait, then end what's left. Runs before the host
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Row 165 (#1662, Q171, REQ-ROLE-3): `mosaic business instances` and `mosaic business instance
|
||||
// add|set` reach the broker's human-only verbs, which write through the business package's
|
||||
// instance writer. The bus here is a real startBroker over a temporary organization directory.
|
||||
// Row 194 (#1692, D3): it boots from the file through bootConfig, as the unit does, so a create goes
|
||||
// live with no restart. A stub boot whose credential references differ from the file still answers
|
||||
// restart needed.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
@@ -14,26 +11,20 @@ import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
|
||||
import { main, run } from "../src/cli.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { RESTART_NEEDED } from "../src/instances.mjs";
|
||||
import { fixture, io, tmp } from "./helpers.mjs";
|
||||
|
||||
const BUSINESS_CLI = fileURLToPath(new URL("../../business/src/cli.mjs", import.meta.url));
|
||||
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
|
||||
|
||||
// hook(verb, args, bus), when given, runs before each call reaches the broker (row 194: a test moves a
|
||||
// request on as the launcher's pass would).
|
||||
async function setup(t, { stub = false, hook = null } = {}) {
|
||||
async function setup(t) {
|
||||
const root = tmp(t, "mosaic-cli-instances-");
|
||||
const f = fixture(root, "acme");
|
||||
const dir = join(root, "config");
|
||||
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
|
||||
const bus = await startBroker({
|
||||
dataRoot: f.dataRoot,
|
||||
businesses: stub
|
||||
? { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }
|
||||
: boot.businesses,
|
||||
organization: stub ? { dir, rolesDir: f.roles, system: systemFor(root) } : boot.organization,
|
||||
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir, rolesDir: f.roles, system: systemFor(root) },
|
||||
});
|
||||
t.after(() => bus.close());
|
||||
const human = bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
|
||||
@@ -42,7 +33,6 @@ async function setup(t, { stub = false, hook = null } = {}) {
|
||||
system: { dataRoot: f.dataRoot },
|
||||
transport: (business) => async (verb, args = {}) => {
|
||||
calls.push({ business, verb, args });
|
||||
await hook?.(verb, args, bus);
|
||||
try {
|
||||
return structuredClone(bus.broker.request(human, { verb, args }));
|
||||
} catch (e) {
|
||||
@@ -64,10 +54,10 @@ async function setup(t, { stub = false, hook = null } = {}) {
|
||||
return { code, out: x.out.text, err: x.err.text };
|
||||
};
|
||||
const revision = async () => JSON.parse((await mosaic(["instances", "acme", "--json"])).out).revision;
|
||||
return { root, f, file, log, validate, mosaic, revision, calls, bus };
|
||||
return { root, f, file, log, validate, mosaic, revision, calls };
|
||||
}
|
||||
|
||||
test("row 165 CLI: instance add puts an instance in the file, validate exits 0, the log names the human, and the instance binds with no restart (row 194, T1)", async (t) => {
|
||||
test("row 165 CLI: instance add puts an instance in the file, validate exits 0, and the log names the human", async (t) => {
|
||||
const s = await setup(t);
|
||||
const view = await s.mosaic(["instances", "acme"]);
|
||||
assert.equal(view.code, 0, view.err);
|
||||
@@ -81,10 +71,11 @@ test("row 165 CLI: instance add puts an instance in the file, validate exits 0,
|
||||
const entry = s.log().at(-1);
|
||||
const now = await s.revision();
|
||||
assert.deepEqual(added.out.split("\n"), [
|
||||
"business acme: added sec-1 (security-reviewer, launched)", `revision ${now}`, `transaction ${entry.transaction}`,
|
||||
"no restart: the launcher reads the change at its next launch check", "",
|
||||
"business acme: added sec-1 (security-reviewer, launched)", `revision ${now}`, `transaction ${entry.transaction}`, RESTART_NEEDED, "",
|
||||
]);
|
||||
assert.ok(s.bus.broker.bindLaunch({ business: "acme", role: "sec-1", run: "run-1", harness: "pi" }), "the running bus binds the new instance");
|
||||
assert.equal(RESTART_NEEDED, "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it");
|
||||
assert.doesNotMatch(RESTART_NEEDED, /Mos|Jason/, "product text names no seat (sage-0931)");
|
||||
assert.doesNotMatch(added.out, /--restart|run `mosaic bus/, "the CLI names the restart and doesn't offer to run it");
|
||||
const doc = JSON.parse(readFileSync(s.file, "utf8"));
|
||||
assert.deepEqual(doc.roles["sec-1"], { definition: "security-reviewer" });
|
||||
assert.ok(doc.launch.instances.includes("sec-1"));
|
||||
@@ -93,17 +84,6 @@ test("row 165 CLI: instance add puts an instance in the file, validate exits 0,
|
||||
assert.equal(checked.status, 0, checked.stderr);
|
||||
});
|
||||
|
||||
test("row 194 CLI: a bus whose boot credential references differ from the file answers restart needed, and doesn't bind the instance (D3 item 3)", async (t) => {
|
||||
const s = await setup(t, { stub: true });
|
||||
const added = await s.mosaic(["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--launch", "--revision", await s.revision()]);
|
||||
assert.equal(added.code, 0, added.err);
|
||||
assert.equal(added.out.split("\n").at(-2), RESTART_NEEDED);
|
||||
assert.equal(RESTART_NEEDED, "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it");
|
||||
assert.doesNotMatch(RESTART_NEEDED, /Mos|Jason/, "product text names no seat (sage-0931)");
|
||||
assert.doesNotMatch(added.out, /--restart|run `mosaic bus/, "the CLI names the restart and doesn't offer to run it");
|
||||
assert.throws(() => s.bus.broker.bindLaunch({ business: "acme", role: "sec-1", run: "run-1", harness: "pi" }), { code: "unknown-role" });
|
||||
});
|
||||
|
||||
test("row 165 CLI: instance set passes the vars file and --no-launch, and a vars-only change needs no restart", async (t) => {
|
||||
const s = await setup(t);
|
||||
const vars = writeJson(join(s.root, "vars.json"), { harness: "pi", model: "claude-sonnet-5-5" });
|
||||
@@ -163,169 +143,12 @@ test("row 165 CLI: usage errors exit 4 before any bus call, and an agent session
|
||||
await assert.rejects(main(["business", "validate", "acme"], io(s.f.env), {}), /mosaic business instances/);
|
||||
});
|
||||
|
||||
test("row 165: scripts/mosaic sends business instances, instance and (row 194) veto to packages/cli, and validate still to packages/business", (t) => {
|
||||
test("row 165: scripts/mosaic sends business instances and instance to packages/cli, and validate still to packages/business", (t) => {
|
||||
const root = tmp(t, "mosaic-cli-route-");
|
||||
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
|
||||
const routed = spawnSync(MOSAIC, ["business", "instance"], { env, encoding: "utf8" });
|
||||
assert.equal(routed.status, 4);
|
||||
assert.match(routed.stderr, /mosaic business instances BUSINESS/);
|
||||
// Row 194: veto goes to packages/cli too.
|
||||
const veto = spawnSync(MOSAIC, ["business", "veto"], { env, encoding: "utf8" });
|
||||
assert.equal(veto.status, 4);
|
||||
assert.match(veto.stderr, /\| business veto BUSINESS keep\|unkeep INSTANCE/);
|
||||
const business = spawnSync(MOSAIC, ["business", "validate"], { env, encoding: "utf8" });
|
||||
assert.doesNotMatch(business.stderr, /mosaic business instances BUSINESS/);
|
||||
});
|
||||
|
||||
test("row 194 CLI V6: veto keep, unkeep, nohire and hire write through veto.set, the read shows kept and noHire, and a no-op refuses", async (t) => {
|
||||
const s = await setup(t);
|
||||
const kept = await s.mosaic(["veto", "acme", "keep", "coder", "--revision", await s.revision()]);
|
||||
assert.equal(kept.code, 0, kept.err);
|
||||
assert.deepEqual(s.calls.at(-1), { business: "acme", verb: "veto.set", args: { revision: s.calls.at(-1).args.revision, op: "keep", name: "coder" } });
|
||||
const entry = s.log().at(-1);
|
||||
assert.deepEqual(kept.out.split("\n"), ["business acme: veto keep coder", `revision ${await s.revision()}`, `transaction ${entry.transaction}`, ""]);
|
||||
assert.equal(entry.actor, "jason");
|
||||
const nohire = await s.mosaic(["veto", "acme", "nohire", "merge-gate", "--revision", await s.revision()]);
|
||||
assert.equal(nohire.code, 0, nohire.err);
|
||||
const view = await s.mosaic(["instances", "acme"]);
|
||||
assert.match(view.out, /^ {2}coder: coder \(launched, kept\)/m);
|
||||
assert.match(view.out, /^the chief may not hire: merge-gate$/m);
|
||||
const json = JSON.parse((await s.mosaic(["instances", "acme", "--json"])).out);
|
||||
assert.deepEqual([json.instances.coder.kept, json.noHire, json.retired], [true, ["merge-gate"], []]);
|
||||
// A kept instance refuses delete at the verb.
|
||||
const del = await s.mosaic(["instance", "delete", "acme", "coder", "--revision", await s.revision()]);
|
||||
assert.equal(del.code, 2);
|
||||
assert.match(del.err, /^mosaic: organization-instance-kept: the instance is kept; run `mosaic business veto BUSINESS unkeep INSTANCE` first$/m);
|
||||
const again = await s.mosaic(["veto", "acme", "keep", "coder", "--revision", await s.revision()]);
|
||||
assert.equal(again.code, 2);
|
||||
assert.match(again.err, /^mosaic: organization-no-change: /);
|
||||
for (const [op, name] of [["unkeep", "coder"], ["hire", "merge-gate"]]) {
|
||||
const r = await s.mosaic(["veto", "acme", op, name, "--revision", await s.revision()]);
|
||||
assert.equal(r.code, 0, r.err);
|
||||
}
|
||||
const after = JSON.parse(readFileSync(s.file, "utf8"));
|
||||
assert.deepEqual([after.launch.keep, after.launch.noHire], [[], []]);
|
||||
});
|
||||
|
||||
test("row 194 CLI: the read lists each instance's running run from the bus claims", async (t) => {
|
||||
const s = await setup(t);
|
||||
const cap = s.bus.broker.bindLaunch({ business: "acme", role: "coder", run: "run-c1", harness: "pi" });
|
||||
s.bus.broker.request(cap, { verb: "role.claim", args: {} });
|
||||
const json = JSON.parse((await s.mosaic(["instances", "acme", "--json"])).out);
|
||||
assert.deepEqual([json.instances.coder.running, json.instances.reviewer.running], [["run-c1"], []]);
|
||||
assert.match((await s.mosaic(["instances", "acme"])).out, /^ {2}coder: coder \(launched, running run-c1\)/m);
|
||||
});
|
||||
|
||||
// The launcher's pass, played by the test: on the first poll the request is taken, then closed with `done`.
|
||||
const closing = (done) => (verb, args, bus) => {
|
||||
if (verb !== "instance.request") return;
|
||||
if (bus.lifecycleTake({ business: "acme", request: args.request }).go && done) bus.lifecycleDone({ business: "acme", request: args.request, ...done(bus, args.request) });
|
||||
};
|
||||
|
||||
test("row 194 CLI D1.1 item 5: launch and regenerate poll instance.request and print the run; a done with a refusal exits 3 with its code and the request id", async (t) => {
|
||||
let done = () => ({ run: "run-new" });
|
||||
const s = await setup(t, { hook: closing((bus, request) => done(bus, request)) });
|
||||
const launched = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
|
||||
assert.equal(launched.code, 0, launched.err);
|
||||
const request = s.calls.find((c) => c.verb === "instance.request").args.request;
|
||||
assert.deepEqual(s.calls.find((c) => c.verb === "instance.launch").args, { instance: "reviewer" });
|
||||
assert.equal(launched.out, `business acme: launched reviewer, run run-new (request ${request})\n`);
|
||||
assert.match(launched.err, new RegExp(`^mosaic: request ${request}, waiting up to 30 s$`, "m"));
|
||||
const regen = await s.mosaic(["instance", "regenerate", "acme", "reviewer"]);
|
||||
assert.equal(regen.code, 0, regen.err);
|
||||
assert.match(regen.out, /^business acme: regenerated reviewer, run run-new \(request [^)]+\)$/m);
|
||||
done = () => ({ refusal: "capacity-full" });
|
||||
const full = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
|
||||
assert.equal(full.code, 3);
|
||||
const id = s.calls.filter((c) => c.verb === "instance.request").at(-1).args.request;
|
||||
assert.match(full.err, new RegExp(`^mosaic: capacity-full: a launch limit is reached; .* \\(request ${id}\\)$`, "m"));
|
||||
done = () => ({ refusal: "some-new-code" });
|
||||
const other = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
|
||||
assert.equal(other.code, 3);
|
||||
assert.match(other.err, /^mosaic: some-new-code: the launcher refused the request \(request [^)]+\)$/m);
|
||||
});
|
||||
|
||||
test("row 194 CLI D1.1 item 5: an open request at --wait exits 3 with its id, and a taken one with no done is outcome-unknown", async (t) => {
|
||||
const s = await setup(t);
|
||||
const open = await s.mosaic(["instance", "launch", "acme", "reviewer", "--wait", "0"]);
|
||||
assert.equal(open.code, 3);
|
||||
const request = s.calls.find((c) => c.verb === "instance.request").args.request;
|
||||
assert.match(open.err, new RegExp(`^mosaic: request ${request} is still open after 0 s; the launcher takes it within its 60 s or closes it request-expired$`, "m"));
|
||||
const t2 = await setup(t, { hook: closing(null) });
|
||||
const taken = await t2.mosaic(["instance", "launch", "acme", "reviewer", "--wait", "1"]);
|
||||
assert.equal(taken.code, 3);
|
||||
const id = t2.calls.find((c) => c.verb === "instance.request").args.request;
|
||||
assert.match(taken.err, new RegExp(`^mosaic: outcome-unknown: request ${id} was taken and has not closed after 1 s; the launch log names any run it started$`, "m"));
|
||||
assert.ok(t2.calls.filter((c) => c.verb === "instance.request").length > 1, "it polled until --wait ran out");
|
||||
});
|
||||
|
||||
test("row 194 CLI: a request cancelled by a terminate exits 3 request-cancelled", async (t) => {
|
||||
const s = await setup(t, { hook: (verb, args, bus) => {
|
||||
if (verb === "instance.request") bus.broker.request(bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true }), { verb: "instance.end", args: { instance: "reviewer" } });
|
||||
} });
|
||||
const r = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
|
||||
assert.equal(r.code, 3);
|
||||
assert.match(r.err, /^mosaic: request-cancelled: cancelled by jason \(request [^)]+\)$/m);
|
||||
});
|
||||
|
||||
test("row 194 CLI V3: terminate prints the runs, the cancelled and the taken requests, and nothing held refuses not-held", async (t) => {
|
||||
const s = await setup(t);
|
||||
const cap = s.bus.broker.bindLaunch({ business: "acme", role: "coder", run: "run-c1", harness: "pi" });
|
||||
s.bus.broker.request(cap, { verb: "role.claim", args: {} });
|
||||
const human = s.bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
|
||||
const open = s.bus.broker.request(human, { verb: "instance.launch", args: { instance: "coder" } }).request;
|
||||
const taken = s.bus.broker.request(human, { verb: "instance.regenerate", args: { instance: "coder" } }).request;
|
||||
s.bus.lifecycleTake({ business: "acme", request: taken });
|
||||
const r = await s.mosaic(["instance", "terminate", "acme", "coder", "--reason", "stuck"]);
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.deepEqual(s.calls.at(-1).args, { instance: "coder", reason: "stuck" });
|
||||
const lines = r.out.split("\n");
|
||||
assert.match(lines[0], /^business acme: ended coder \(request [^)]+\)$/);
|
||||
assert.deepEqual(lines.slice(1), [
|
||||
"runs run-c1: claims revoked, the launcher signals each", `cancelled ${open}`,
|
||||
`taken ${taken}: past cancelling; a run it starts is ended by the launcher's pass, or by a second terminate`, "",
|
||||
]);
|
||||
const none = await s.mosaic(["instance", "terminate", "acme", "reviewer"]);
|
||||
assert.equal(none.code, 2);
|
||||
assert.match(none.err, /^mosaic: not-held: the instance has no run and no open request$/m);
|
||||
});
|
||||
|
||||
test("row 194 CLI V5: delete polls to the transaction and the read lists the name retired; workspace-rename-failed names the transaction", async (t) => {
|
||||
let rename = null;
|
||||
const s = await setup(t, { hook: closing((bus, request) => ({ transaction: bus.lifecycleDelete({ business: "acme", request }).transaction, ...(rename ? { refusal: rename } : {}) })) });
|
||||
for (const name of ["sec-1", "sec-2"]) {
|
||||
const added = await s.mosaic(["instance", "add", "acme", name, "--definition", "security-reviewer", "--revision", await s.revision()]);
|
||||
assert.equal(added.code, 0, added.err);
|
||||
}
|
||||
const del = await s.mosaic(["instance", "delete", "acme", "sec-1", "--revision", await s.revision()]);
|
||||
assert.equal(del.code, 0, del.err);
|
||||
const entry = s.log().at(-1);
|
||||
assert.equal(del.out, `business acme: deleted sec-1, now retired\ntransaction ${entry.transaction}\n`);
|
||||
assert.deepEqual(s.calls.find((c) => c.verb === "instances.set" && c.args.op === "delete").args, { revision: s.calls.find((c) => c.args.op === "delete").args.revision, instance: "sec-1", op: "delete" });
|
||||
assert.match((await s.mosaic(["instances", "acme"])).out, /^retired: sec-1$/m);
|
||||
rename = "workspace-rename-failed";
|
||||
const failed = await s.mosaic(["instance", "delete", "acme", "sec-2", "--revision", await s.revision()]);
|
||||
assert.equal(failed.code, 3);
|
||||
assert.match(failed.err, new RegExp(`^mosaic: workspace-rename-failed: .*run the same delete again to retry the rename \\(request [^,]+, transaction ${s.log().at(-1).transaction}\\)$`, "m"));
|
||||
});
|
||||
|
||||
test("row 194 CLI: lifecycle and veto usage errors exit 4 before any bus call", async (t) => {
|
||||
const s = await setup(t);
|
||||
const rev = "a".repeat(64);
|
||||
for (const args of [
|
||||
["instance", "launch", "acme"], ["instance", "launch", "acme", "coder", "extra"],
|
||||
["instance", "launch", "acme", "coder", "--wait", "x"], ["instance", "launch", "acme", "coder", "--wait", "601"],
|
||||
["instance", "launch", "acme", "coder", "--revision", rev], ["instance", "launch", "acme", "coder", "--reason", "x"],
|
||||
["instance", "terminate", "acme", "coder", "--wait", "5"], ["instance", "terminate", "acme", "coder", "--revision", rev],
|
||||
["instance", "delete", "acme", "coder"], ["instance", "delete", "acme", "coder", "--revision", rev, "--tester"],
|
||||
["instance", "delete", "acme", "coder", "--revision", rev, "--reason", "x"],
|
||||
["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--revision", rev, "--wait", "5"],
|
||||
["veto", "acme", "keep", "coder"], ["veto", "acme", "drop", "coder", "--revision", rev],
|
||||
["veto", "acme", "keep", "--revision", rev], ["veto", "acme", "keep", "coder", "--revision", rev, "--tester"],
|
||||
["veto", "acme", "keep", "coder", "--revision", rev, "--json"], ["veto"],
|
||||
]) {
|
||||
const r = await s.mosaic(args);
|
||||
assert.equal(r.code, 4, args.join(" "));
|
||||
}
|
||||
assert.equal(s.calls.length, 0);
|
||||
});
|
||||
|
||||
@@ -1,549 +0,0 @@
|
||||
// Row 194 (#1692, D1.1, T2-T5, V5): the launcher half of the lifecycle verbs. The broker records
|
||||
// the human's launch, end, regenerate and delete as requests (bus lifecycle-verbs.test.mjs), and
|
||||
// the launcher's lifecycle pass takes each one, acts on it and closes it. Real runners in the bwrap
|
||||
// sandbox on the fake adapter.
|
||||
//
|
||||
// The host is in process, as in thread-launch.test.mjs: the real host proves a human CLI process
|
||||
// for every human verb, which a test run under an agent can't. The tick interval is long and each
|
||||
// test calls tick() itself, so a request waits until the test says.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { launchDoc } from "../../business/tests/helpers.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { launchLogFile, readSessions, SESSION_CREDENTIALS, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { run } from "../src/cli.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, io, tmp } from "./helpers.mjs";
|
||||
|
||||
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
|
||||
async function until(fn, ms = 15000) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
throw new Error("timed out waiting");
|
||||
}
|
||||
|
||||
function gitIn(cwd, ...args) {
|
||||
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
}
|
||||
|
||||
// chief opus, coder and reviewer sonnet, at most one sonnet run. Only coder is listed, so reviewer
|
||||
// is an instance the chief may not launch and the human may. sec-1 holds no credential, so the
|
||||
// organization writer lets the human delete it. assistant is the thread instance.
|
||||
function prepare(t, edit = (doc) => doc) {
|
||||
const root = tmp(t, "mosaic-life-");
|
||||
const f = fixture(root, "acme", (doc) => {
|
||||
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
|
||||
doc.roles.coder.vars.model = "claude-sonnet-5-5";
|
||||
doc.roles.reviewer.vars = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
doc.roles["sec-1"] = { definition: "security-reviewer", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
|
||||
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
|
||||
doc.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 1 });
|
||||
return edit(doc);
|
||||
});
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
writeFileSync(join(project, "file.txt"), "one\n");
|
||||
gitIn(project, "add", "file.txt");
|
||||
gitIn(project, "commit", "-q", "-m", "one");
|
||||
const adapter = join(root, "adapter.sh");
|
||||
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
|
||||
return { root, f, adapter };
|
||||
}
|
||||
|
||||
async function start(t, { f, adapter }, options = {}) {
|
||||
const system = loadSystem({ env: f.env });
|
||||
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
|
||||
assert.equal(trackers, undefined);
|
||||
const runtime = await startBroker(boot);
|
||||
// The real host makes bus-host/ (0700). A --no-launch launcher's read-only registry doesn't.
|
||||
mkdirSync(join(f.dataRoot, "bus-host"), { recursive: true, mode: 0o700 });
|
||||
const ops = {
|
||||
identity: (m) => runtime.identity(m.cap),
|
||||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||
endLaunch: (m) => runtime.endLaunch(m.record),
|
||||
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
||||
threadsState: (m) => runtime.threadsState(m.business),
|
||||
threadIdle: (m) => runtime.threadIdle(m.record),
|
||||
mayLaunch: (m) => runtime.mayLaunch(m),
|
||||
landHeld: (m) => runtime.landHeld(m.record),
|
||||
landHolders: (m) => runtime.landHolders(m.business),
|
||||
landRelease: (m) => runtime.landRelease(m.record),
|
||||
lifecycleRequests: (m) => runtime.lifecycleRequests(m.business),
|
||||
lifecycleTake: (m) => runtime.lifecycleTake(m.record),
|
||||
lifecycleDelete: (m) => runtime.lifecycleDelete(m.record),
|
||||
lifecycleDone: (m) => runtime.lifecycleDone(m.record),
|
||||
};
|
||||
const refusing = (what, fn) => async (m) => {
|
||||
try {
|
||||
return fn(m);
|
||||
} catch (e) {
|
||||
const code = e instanceof BusError ? e.code : `${what}-refused`;
|
||||
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
|
||||
}
|
||||
};
|
||||
const hooks = [];
|
||||
let closing = null;
|
||||
// A host op named here throws once, as a bus that refused it would.
|
||||
const faults = new Set();
|
||||
const host = {
|
||||
business: "acme",
|
||||
path: runtime.path,
|
||||
dataRoot: f.dataRoot,
|
||||
op: refusing("launch-op", (m) => {
|
||||
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
|
||||
if (faults.delete(m.op)) throw new BusError("storage-refused");
|
||||
return ops[m.op](m);
|
||||
}),
|
||||
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
|
||||
beforeClose: (fn) => void hooks.push(fn),
|
||||
};
|
||||
const logs = [];
|
||||
const launcher = createLauncher({
|
||||
host,
|
||||
system,
|
||||
env: f.env,
|
||||
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
|
||||
sessionDefaults: { pollInterval: 100 },
|
||||
harnessVersion: "0.85.1",
|
||||
readOnly: [FAKE],
|
||||
threadTickMs: 3600000,
|
||||
stopTimeoutMs: 1000,
|
||||
log: (l) => logs.push(l),
|
||||
...options,
|
||||
});
|
||||
const close = () =>
|
||||
(closing ??= (async () => {
|
||||
for (const fn of hooks) await fn(0);
|
||||
await runtime.close();
|
||||
})());
|
||||
t.after(close);
|
||||
await launcher.listen();
|
||||
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
|
||||
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
|
||||
const state = (request) => call("instance.request", { request });
|
||||
// One pass, then the request's state.
|
||||
const carry = async (request) => {
|
||||
await launcher.tick();
|
||||
return state(request);
|
||||
};
|
||||
const running = (instance) => launcher.registry.sessions.filter((s) => s.instance === instance);
|
||||
const claimed = (run) => until(() => {
|
||||
const file = join(f.dataRoot, "launches", "acme", run, "runner.log");
|
||||
return existsSync(file) && readFileSync(file, "utf8").includes("claimed by run");
|
||||
});
|
||||
// The CLI through the in-process human. Each poll of instance.request runs one pass first, so a
|
||||
// --wait sees the request closed as the launcher's ticker would close it.
|
||||
const cli = async (args) => {
|
||||
const x = io(f.env);
|
||||
const deps = {
|
||||
system: { dataRoot: f.dataRoot },
|
||||
transport: () => async (verb, a = {}) => {
|
||||
if (verb === "instance.request") await launcher.tick();
|
||||
try {
|
||||
return structuredClone(call(verb, a));
|
||||
} catch (e) {
|
||||
if (!(e instanceof BusError)) throw e;
|
||||
throw Object.assign(new Error(e.code), { code: e.code });
|
||||
}
|
||||
},
|
||||
};
|
||||
const code = await run(["business", ...args], x, deps);
|
||||
return { code, out: x.out.text, err: x.err.text };
|
||||
};
|
||||
return { runtime, launcher, call, state, carry, running, claimed, logs, close, faults, cli };
|
||||
}
|
||||
|
||||
const launchLog = (f) =>
|
||||
existsSync(launchLogFile(f.dataRoot, "acme"))
|
||||
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
|
||||
: [];
|
||||
|
||||
// Stops a runner from outside its namespace, so it can't act on SIGTERM, and kills it at the end.
|
||||
function stop(t, s) {
|
||||
process.kill(s.runnerPid, "SIGSTOP");
|
||||
t.after(() => {
|
||||
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGKILL");
|
||||
});
|
||||
}
|
||||
|
||||
test("row 194 T2: the human's launch runs an unlisted instance and launch.by through the pm branch, and closes with the run", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const { request } = s.call("instance.launch", { instance: "reviewer" });
|
||||
assert.equal(s.state(request).state, "open");
|
||||
const done = await s.carry(request);
|
||||
assert.equal(done.state, "closed");
|
||||
assert.equal(done.done.refusal, null);
|
||||
assert.match(done.done.run, /^r[0-9a-f]{12}$/);
|
||||
const [r] = s.running("reviewer");
|
||||
assert.equal(r.run, done.done.run);
|
||||
const session = readSessions(p.f.dataRoot).find((x) => x.run === r.run);
|
||||
assert.equal(session.launchedBy, "jason");
|
||||
await s.claimed(r.run);
|
||||
// launch.by is not on launch.instances. The human's request takes the pm branch, as --pm does.
|
||||
const chief = s.call("instance.launch", { instance: "chief" }).request;
|
||||
const c = await s.carry(chief);
|
||||
assert.equal(c.done.refusal, null);
|
||||
assert.equal(s.running("chief")[0].run, c.done.run);
|
||||
// A request is taken once: the next pass starts nothing more.
|
||||
await s.launcher.tick();
|
||||
assert.equal(s.running("reviewer").length, 1);
|
||||
assert.equal(launchLog(p.f).filter((e) => e.event === "launch").length, 2);
|
||||
});
|
||||
|
||||
test("row 194 T2: a request past its 60 s never launches, and a refused check closes the request with its code", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const old = s.call("instance.launch", { instance: "coder" }).request;
|
||||
const now = Date.now();
|
||||
t.mock.method(Date, "now", () => now + 61000);
|
||||
const expired = await s.carry(old);
|
||||
assert.deepEqual([expired.state, expired.done.refusal, expired.done.run], ["closed", "request-expired", null]);
|
||||
t.mock.restoreAll();
|
||||
assert.equal(s.running("coder").length, 0);
|
||||
// A check refusal: the organization file now names a harness that isn't switched on.
|
||||
const file = join(p.root, "config", "businesses", "acme.json");
|
||||
const doc = JSON.parse(readFileSync(file, "utf8"));
|
||||
doc.roles.coder.vars.harness = "claude-code";
|
||||
doc.plugins.harness = ["pi"];
|
||||
writeFileSync(file, JSON.stringify(doc, null, 2));
|
||||
const off = s.call("instance.launch", { instance: "coder" }).request;
|
||||
const refused = await s.carry(off);
|
||||
assert.deepEqual([refused.state, refused.done.refusal, refused.done.run], ["closed", "harness-off", null]);
|
||||
assert.equal(s.running("coder").length, 0);
|
||||
const entry = launchLog(p.f).find((e) => e.event === "refused" && e.request === off);
|
||||
assert.deepEqual([entry.instance, entry.code, entry.by], ["coder", "harness-off", "jason"]);
|
||||
});
|
||||
|
||||
test("row 194 T3: a --no-launch host closes each request launches-disabled and starts nothing", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p, { launches: false });
|
||||
const launch = s.call("instance.launch", { instance: "coder" }).request;
|
||||
const regen = s.call("instance.regenerate", { instance: "reviewer" }).request;
|
||||
await s.launcher.tick();
|
||||
for (const request of [launch, regen]) assert.deepEqual([s.state(request).state, s.state(request).done.refusal], ["closed", "launches-disabled"]);
|
||||
assert.equal(s.launcher.registry.sessions.length, 0);
|
||||
assert.equal(existsSync(join(p.f.dataRoot, "launches", "acme")), false);
|
||||
assert.deepEqual(launchLog(p.f).filter((e) => e.event === "launch"), []);
|
||||
});
|
||||
|
||||
test("row 194 T3: a limit refusal closes the request with its code and the limit in the launch log", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const first = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
|
||||
assert.equal(first.done.refusal, null);
|
||||
// One sonnet run at a time: reviewer runs sonnet too.
|
||||
const full = s.call("instance.launch", { instance: "reviewer" }).request;
|
||||
const r = await s.carry(full);
|
||||
assert.deepEqual([r.state, r.done.refusal, r.done.run], ["closed", "capacity-full", null]);
|
||||
assert.equal(s.running("reviewer").length, 0);
|
||||
const entry = launchLog(p.f).find((e) => e.event === "refused" && e.request === full);
|
||||
assert.equal(entry.code, "capacity-full");
|
||||
assert.ok(entry.limit, JSON.stringify(entry));
|
||||
// The instance already running refuses a second launch of itself.
|
||||
const again = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
|
||||
assert.equal(again.done.refusal, "instance-running");
|
||||
});
|
||||
|
||||
test("row 194 T4: terminate revokes and cancels at the verb, then the pass signals the run, which ends once", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const launched = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
|
||||
const run = launched.done.run;
|
||||
await s.claimed(run);
|
||||
const open = s.call("instance.launch", { instance: "coder" }).request;
|
||||
const out = s.call("instance.end", { instance: "coder", reason: "stuck" });
|
||||
assert.deepEqual([out.runs, out.cancelled], [[run], [open]]);
|
||||
assert.equal(s.state(open).state, "closed");
|
||||
const end = await s.carry(out.request);
|
||||
assert.deepEqual([end.state, end.done], ["closed", { run: null, transaction: null, refusal: null }]);
|
||||
await until(() => s.running("coder").length === 0);
|
||||
const log = launchLog(p.f);
|
||||
assert.equal(log.filter((e) => e.event === "lifecycle-end" && e.run === run).length, 1);
|
||||
await until(() => launchLog(p.f).some((e) => e.event === "end" && e.run === run));
|
||||
assert.equal(launchLog(p.f).filter((e) => e.event === "end" && e.run === run).length, 1);
|
||||
// The cancelled request never ran.
|
||||
await s.launcher.tick();
|
||||
assert.equal(s.running("coder").length, 0);
|
||||
});
|
||||
|
||||
test("row 194 T5: regenerate ends the run in the registry and starts a new run on the same workspace", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const first = (await s.carry(s.call("instance.launch", { instance: "coder" }).request)).done.run;
|
||||
await s.claimed(first);
|
||||
const marker = join(workspaceDir(p.f.dataRoot, "acme", "coder"), "kept.txt");
|
||||
writeFileSync(marker, "kept\n");
|
||||
const regen = await s.carry(s.call("instance.regenerate", { instance: "coder" }).request);
|
||||
assert.equal(regen.done.refusal, null);
|
||||
const second = regen.done.run;
|
||||
assert.notEqual(second, first);
|
||||
assert.deepEqual(s.running("coder").map((x) => x.run), [second]);
|
||||
assert.ok(existsSync(join(p.f.dataRoot, "launches", "acme", second)));
|
||||
assert.equal(readFileSync(marker, "utf8"), "kept\n");
|
||||
const log = launchLog(p.f);
|
||||
assert.equal(log.filter((e) => e.event === "lifecycle-end" && e.run === first).length, 1);
|
||||
assert.equal(log.filter((e) => e.event === "end" && e.run === first).length, 1);
|
||||
// A thread instance is never regenerated: the verb refuses before any request.
|
||||
assert.throws(() => s.call("instance.regenerate", { instance: "assistant" }), (e) => e.code === "regenerate-thread");
|
||||
});
|
||||
|
||||
test("row 194 T5: a run that outlasts the request's mark closes the regenerate regenerate-timeout and starts nothing", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p, { lifecycleRequestMs: 1500 });
|
||||
const first = (await s.carry(s.call("instance.launch", { instance: "coder" }).request)).done.run;
|
||||
await s.claimed(first);
|
||||
stop(t, s.running("coder")[0]);
|
||||
const regen = await s.carry(s.call("instance.regenerate", { instance: "coder" }).request);
|
||||
assert.deepEqual([regen.state, regen.done.refusal, regen.done.run], ["closed", "regenerate-timeout", null]);
|
||||
assert.deepEqual(s.running("coder").map((x) => x.run), [first]);
|
||||
assert.equal(launchLog(p.f).filter((e) => e.event === "launch").length, 1);
|
||||
});
|
||||
|
||||
test("row 194 V5: delete retires the name, renames the workspace to .retired/I-<transaction> at 0700, and a retry renames after workspace-rename-failed", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const ws = workspaceDir(p.f.dataRoot, "acme", "sec-1");
|
||||
mkdirSync(ws, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(ws, "notes.txt"), "mine\n");
|
||||
// A file where .retired goes makes the rename fail after the commit.
|
||||
const retired = join(workspaceDir(p.f.dataRoot, "acme", ".retired"));
|
||||
writeFileSync(retired, "in the way\n");
|
||||
const revision = () => s.call("instances").revision;
|
||||
const del = s.call("instances.set", { revision: revision(), instance: "sec-1", op: "delete" }).request;
|
||||
const failed = await s.carry(del);
|
||||
assert.equal(failed.done.refusal, "workspace-rename-failed");
|
||||
const transaction = failed.done.transaction;
|
||||
assert.ok(transaction);
|
||||
const file = JSON.parse(readFileSync(join(p.root, "config", "businesses", "acme.json"), "utf8"));
|
||||
assert.equal(Object.hasOwn(file.roles, "sec-1"), false, "the commit stands");
|
||||
assert.ok(file.retired.includes("sec-1"));
|
||||
assert.equal(readFileSync(join(ws, "notes.txt"), "utf8"), "mine\n", "the workspace stays where it was");
|
||||
// A retired name launches no more, so nothing opens the old workspace before the retry.
|
||||
assert.throws(() => s.call("instance.launch", { instance: "sec-1" }), (e) => e.code === "instance-retired");
|
||||
rmSync(retired);
|
||||
const retry = s.call("instances.set", { revision: revision(), instance: "sec-1", op: "delete" }).request;
|
||||
assert.equal(s.state(retry).retry, true);
|
||||
const ok = await s.carry(retry);
|
||||
assert.deepEqual(ok.done, { run: null, transaction, refusal: null });
|
||||
const to = join(retired, `sec-1-${transaction}`);
|
||||
assert.equal(existsSync(ws), false);
|
||||
assert.equal(readFileSync(join(to, "notes.txt"), "utf8"), "mine\n");
|
||||
assert.equal(statSync(retired).mode & 0o777, 0o700);
|
||||
assert.equal(statSync(to).mode & 0o777, 0o700);
|
||||
const entry = launchLog(p.f).find((e) => e.event === "workspace-retired");
|
||||
assert.deepEqual([entry.instance, entry.request, entry.transaction, entry.path], ["sec-1", retry, transaction, to]);
|
||||
});
|
||||
|
||||
test("row 194 V5: a delete with no workspace commits and closes with the transaction", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const del = s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "delete" }).request;
|
||||
const r = await s.carry(del);
|
||||
assert.equal(r.done.refusal, null);
|
||||
assert.ok(r.done.transaction);
|
||||
assert.equal(existsSync(join(workspaceDir(p.f.dataRoot, "acme", ".retired"))), false);
|
||||
});
|
||||
|
||||
// Every regular file under root, by path, as a sha256. Sockets and other special files are left out.
|
||||
function snapshot(root) {
|
||||
const out = {};
|
||||
const walk = (dir) => {
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
const path = join(dir, name);
|
||||
const st = lstatSync(path);
|
||||
if (st.isDirectory()) walk(path);
|
||||
else if (st.isFile()) out[path.slice(root.length)] = createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
test("row 194 D1.1 item 5: the CLI's launch, regenerate, terminate and delete end to end through the pass, and a closed refusal exits 3 with the request id", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const launched = await s.cli(["instance", "launch", "acme", "reviewer"]);
|
||||
assert.equal(launched.code, 0, launched.err);
|
||||
const [r] = s.running("reviewer");
|
||||
assert.match(launched.out, new RegExp(`^business acme: launched reviewer, run ${r.run} \\(request [^)]+\\)\n$`));
|
||||
await s.claimed(r.run);
|
||||
const regen = await s.cli(["instance", "regenerate", "acme", "reviewer"]);
|
||||
assert.equal(regen.code, 0, regen.err);
|
||||
const [r2] = s.running("reviewer");
|
||||
assert.notEqual(r2.run, r.run);
|
||||
assert.match(regen.out, new RegExp(`^business acme: regenerated reviewer, run ${r2.run} \\(request [^)]+\\)\n$`));
|
||||
await s.claimed(r2.run);
|
||||
// One sonnet run at a time: the launcher closes coder's request capacity-full.
|
||||
const full = await s.cli(["instance", "launch", "acme", "coder"]);
|
||||
assert.equal(full.code, 3);
|
||||
const refused = launchLog(p.f).find((e) => e.event === "refused" && e.instance === "coder");
|
||||
assert.match(full.err, new RegExp(`^mosaic: capacity-full: .* \\(request ${refused.request}\\)$`, "m"));
|
||||
assert.equal(s.running("coder").length, 0);
|
||||
const ended = await s.cli(["instance", "terminate", "acme", "reviewer", "--reason", "done for today"]);
|
||||
assert.equal(ended.code, 0, ended.err);
|
||||
assert.match(ended.out, new RegExp(`^runs ${r2.run}: claims revoked, the launcher signals each$`, "m"));
|
||||
await s.launcher.tick();
|
||||
await until(() => s.running("reviewer").length === 0);
|
||||
const revision = JSON.parse((await s.cli(["instances", "acme", "--json"])).out).revision;
|
||||
const del = await s.cli(["instance", "delete", "acme", "sec-1", "--revision", revision]);
|
||||
assert.equal(del.code, 0, del.err);
|
||||
assert.match(del.out, /^business acme: deleted sec-1, now retired\ntransaction [^\n]+\n$/);
|
||||
assert.match((await s.cli(["instances", "acme"])).out, /^retired: sec-1$/m);
|
||||
});
|
||||
|
||||
test("row 194 T2a: a launch.revoke between the request and the pass closes it launch-revoked with no process; a done that fails once leaves the request taken and the next pass starts nothing", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const request = s.call("instance.launch", { instance: "coder" }).request;
|
||||
s.call("launch.revoke");
|
||||
const revoked = await s.carry(request);
|
||||
assert.deepEqual([revoked.state, revoked.done.refusal, revoked.done.run], ["closed", "launch-revoked", null]);
|
||||
assert.equal(s.launcher.registry.sessions.length, 0);
|
||||
assert.equal(existsSync(join(p.f.dataRoot, "launches", "acme")), false);
|
||||
s.call("launch.restore");
|
||||
const held = s.call("instance.launch", { instance: "coder" }).request;
|
||||
s.faults.add("lifecycleDone");
|
||||
const taken = await s.carry(held);
|
||||
assert.deepEqual([taken.state, taken.done], ["taken", null]);
|
||||
const [r] = s.running("coder");
|
||||
assert.ok(s.logs.some((l) => l === `request ${held} (coder): lifecycleDone refused: storage-refused; run ${r.run} started`), s.logs.join("\n"));
|
||||
await s.launcher.tick();
|
||||
assert.equal(s.state(held).state, "taken");
|
||||
assert.equal(s.running("coder").length, 1);
|
||||
assert.equal(launchLog(p.f).filter((e) => e.event === "launch").length, 1);
|
||||
});
|
||||
|
||||
test("row 194 T4: terminate signals a spawned run in the registry that the bus never bound, and not a run whose start time differs", { timeout: 30000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const spawned = (instance) => {
|
||||
const child = spawn("sleep", ["60"], { stdio: "ignore" });
|
||||
t.after(() => child.kill("SIGKILL"));
|
||||
const exited = new Promise((resolve) => child.on("exit", (code, signal) => resolve(signal)));
|
||||
return { child, exited, instance };
|
||||
};
|
||||
const a = spawned("coder");
|
||||
const b = spawned("reviewer");
|
||||
await until(() => startTimeOf(a.child.pid) !== null && startTimeOf(b.child.pid) !== null);
|
||||
// Neither holds a claim. An open request each lets the end through, and the end cancels it.
|
||||
for (const [x, start] of [[a, startTimeOf(a.child.pid)], [b, startTimeOf(b.child.pid) + 1]])
|
||||
s.launcher.registry.add({ business: "acme", instance: x.instance, run: `run-${x.instance}`, pid: x.child.pid, startTime: start, runnerPid: x.child.pid, runnerStartTime: start });
|
||||
t.after(() => ["coder", "reviewer"].forEach((i) => s.launcher.registry.remove("acme", `run-${i}`)));
|
||||
for (const x of [a, b]) {
|
||||
s.call("instance.launch", { instance: x.instance });
|
||||
const out = s.call("instance.end", { instance: x.instance });
|
||||
assert.deepEqual([out.runs, out.cancelled.length], [[], 1]);
|
||||
await s.carry(out.request);
|
||||
}
|
||||
assert.equal(await a.exited, "SIGTERM");
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
assert.equal(b.child.exitCode, null);
|
||||
assert.equal(b.child.signalCode, null);
|
||||
const ends = launchLog(p.f).filter((e) => e.event === "lifecycle-end").map((e) => e.run);
|
||||
assert.deepEqual(ends, ["run-coder", "run-reviewer"]);
|
||||
});
|
||||
|
||||
test("row 194 T6: a delete leaves the run evidence bytes unchanged, and the retired name refuses a create and a launch", { skip, timeout: 60000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
const s = await start(t, p);
|
||||
const run = (await s.carry(s.call("instance.launch", { instance: "sec-1" }).request)).done.run;
|
||||
await s.claimed(run);
|
||||
await s.carry(s.call("instance.end", { instance: "sec-1" }).request);
|
||||
await until(() => s.running("sec-1").length === 0 && launchLog(p.f).some((e) => e.event === "end" && e.run === run));
|
||||
const runDir = join(p.f.dataRoot, "launches", "acme", run);
|
||||
const before = snapshot(runDir);
|
||||
const log = readFileSync(launchLogFile(p.f.dataRoot, "acme"), "utf8");
|
||||
assert.ok(Object.keys(before).length > 0);
|
||||
const del = await s.carry(s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "delete" }).request);
|
||||
assert.equal(del.done.refusal, null);
|
||||
assert.deepEqual(snapshot(runDir), before);
|
||||
assert.ok(readFileSync(launchLogFile(p.f.dataRoot, "acme"), "utf8").startsWith(log), "the launch log only grows");
|
||||
assert.throws(
|
||||
() => s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "add", definition: "security-reviewer" }),
|
||||
(e) => e.code === "organization-instance-retired",
|
||||
);
|
||||
assert.throws(() => s.call("instance.launch", { instance: "sec-1" }), (e) => e.code === "instance-retired");
|
||||
});
|
||||
|
||||
test("row 194 T11: two stack roots with the same business id; a launch, a terminate and a delete in one leave the other byte-identical", { skip, timeout: 90000 }, async (t) => {
|
||||
const pa = prepare(t);
|
||||
const pb = prepare(t);
|
||||
const a = await start(t, pa);
|
||||
const b = await start(t, pb);
|
||||
b.call("instances");
|
||||
const before = snapshot(pb.root);
|
||||
const run = (await a.carry(a.call("instance.launch", { instance: "coder" }).request)).done.run;
|
||||
await a.claimed(run);
|
||||
await a.carry(a.call("instance.end", { instance: "coder" }).request);
|
||||
await until(() => a.running("coder").length === 0);
|
||||
const del = await a.carry(a.call("instances.set", { revision: a.call("instances").revision, instance: "sec-1", op: "delete" }).request);
|
||||
assert.equal(del.done.refusal, null);
|
||||
await b.launcher.tick();
|
||||
assert.deepEqual(snapshot(pb.root), before);
|
||||
assert.equal(b.launcher.registry.sessions.length, 0);
|
||||
assert.equal(Object.hasOwn(b.call("instances").instances, "sec-1"), true);
|
||||
});
|
||||
|
||||
// D3 item 5 and T8: the chief's session hires through its hire tool, and the same session launches
|
||||
// the new name with no restart. Its launch tool takes a name, so a name absent from the start-time
|
||||
// listing gets through to the host, which checks the file.
|
||||
test("row 194 D3 item 5: the chief's session hires with its tool and launches its hire in the same session, then fires it", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
// The shipped role file lists nothing hireable. The test's copy lists the CISO, which holds
|
||||
// only message.send and record.write and no credential.
|
||||
const roleFile = join(p.f.env.MOSAIC_ROLES_DIR, "chief-of-staff.json");
|
||||
const role = JSON.parse(readFileSync(roleFile, "utf8"));
|
||||
role.hireable = ["chief-information-security-officer"];
|
||||
writeFileSync(roleFile, JSON.stringify(role, null, 2));
|
||||
const s = await start(t, p);
|
||||
const chief = (await s.carry(s.call("instance.launch", { instance: "chief" }).request)).done.run;
|
||||
await s.claimed(chief);
|
||||
const ask = async (name, args) => {
|
||||
const sent = s.call("message.send", { to: "chief", class: "REQUEST", body: `TOOL ${name} ${JSON.stringify(args)}` }).id;
|
||||
return (await until(() => s.call("replies", { id: sent })[0], 30000)).body;
|
||||
};
|
||||
const hired = JSON.parse(await ask("hire", { name: "sec-2", definition: "chief-information-security-officer", vars: { model: "claude-sonnet-5-5" }, reason: "a security lead for the review queue" }));
|
||||
assert.ok(hired.transaction, JSON.stringify(hired));
|
||||
const listed = s.call("instances").instances["sec-2"];
|
||||
assert.equal(listed.launched, true);
|
||||
// Hire vars set only model and limits.* (N3). harness and thinking are agent-layer route vars the
|
||||
// human sets, so until then the launch refuses no-route.
|
||||
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: no-route");
|
||||
const route = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-2", op: "change", vars: route });
|
||||
const launched = JSON.parse(await ask("launch", { instance: "sec-2" }));
|
||||
assert.match(launched.run, /^r[0-9a-f]{12}$/);
|
||||
assert.deepEqual(s.running("sec-2").map((x) => x.run), [launched.run]);
|
||||
assert.equal(readSessions(p.f.dataRoot).find((x) => x.run === launched.run).launchedBy, "chief");
|
||||
// The schema's pattern guides the model. The host refuses any name its file doesn't list.
|
||||
assert.equal(await ask("launch", { instance: "NOT A NAME" }), "refused: instance-not-listed");
|
||||
assert.equal(await ask("launch", { instance: "reviewer" }), "refused: instance-not-listed");
|
||||
await s.claimed(launched.run);
|
||||
const fired = JSON.parse(await ask("fire", { name: "sec-2", reason: "the review is done" }));
|
||||
assert.ok(fired.transaction, JSON.stringify(fired));
|
||||
await until(() => s.running("sec-2").length === 0, 30000);
|
||||
// A fired instance stays in roles, off the listing, so the chief can't launch it again.
|
||||
assert.equal(s.call("instances").instances["sec-2"].launched, false);
|
||||
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: instance-not-listed");
|
||||
});
|
||||
@@ -381,7 +381,7 @@ async function seed(e,{copy=B,pm=false,store='3g',pointer:how='adopt'}={}){
|
||||
const rolledBack=(code,to=B)=>`stack-release: ${code}; rolled back to ${to}, which passed the gate\n`;
|
||||
const planBackup=(e)=>`backup: the run backs up the stopped store to ${e.data}/backups/stack/<activation>/ before any migration`;
|
||||
const planNoBackup='backup: none; the run rolls back before the backup';
|
||||
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off','send: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
|
||||
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
|
||||
|
||||
// --- The T table ---
|
||||
|
||||
@@ -1030,17 +1030,17 @@ test('T36 --plan shows the pending state and lists the row 169 refusals it can s
|
||||
return r.out.split('\n');
|
||||
};
|
||||
let out=await quiet(0,'activate',C1,'acme','--pm-after-harness','--plan');
|
||||
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
|
||||
assert.deepEqual(out.slice(-6),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
|
||||
biz(e,HARNESS);
|
||||
out=await quiet(3,'activate',A,'acme','--pm-after-harness','--plan');
|
||||
assert.deepEqual(out.slice(-8),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
|
||||
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
|
||||
out=await quiet(3,'activate',C1,'acme','--pm-after-harness','--plan');
|
||||
assert.equal(out.at(-2),'refuses pm-after-harness-set: the business file already sets harness ["claude-code"]; use --pm');
|
||||
biz(e);assert.equal((await run(e,'activate',C1,'acme','--pm-after-harness')).code,0);
|
||||
out=await quiet(3,'activate',C,'acme','--plan');
|
||||
assert.deepEqual(out.slice(-8),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
|
||||
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
|
||||
out=await quiet(0,'rollback','acme','--plan');
|
||||
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
|
||||
assert.deepEqual(out.slice(-6),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
|
||||
});
|
||||
|
||||
// Row 176 test 11: supported and older Console copies deliberately differ from the checkout.
|
||||
@@ -1706,113 +1706,6 @@ test('T54 row 188: a carried pair gets the proof file checks, and a proof file i
|
||||
});
|
||||
});
|
||||
|
||||
// Row 189 (#1687, mos-0972/0973): activate --no-send drops the pointer's --send; nothing turns it back on.
|
||||
const sendLines=(out)=>out.filter((l)=>l.startsWith('send:'));
|
||||
const noSend=(c)=>c.filter((x)=>x!=='--send');
|
||||
const LIVE_NOTE=`note: ${B} is the pointer's copy; the run re-pins it and only the Console flags change`;
|
||||
test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry it off',async(t)=>{
|
||||
await t.test('test 1: a live-copy --no-send drops --send from the pointer and the unit, keeps the rest in order, logs the step and backs up the stopped store',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']),want=noSend(base);
|
||||
assert.deepEqual(want.slice(-2),['--tester-port','7333']);
|
||||
const sum=createHash('sha256').update(readFileSync(e.store)).digest('hex'),n=calls(e).length;
|
||||
let r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,want);
|
||||
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));assert.doesNotMatch(conExec(e),/ --send( |$)/);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
|
||||
const act=pointer(e).activation,l=logLines(e).at(-1);
|
||||
assert.deepEqual([l.op,l.from,l.to,l.result,l.backup],['activate',B,B,'ok',act]);assert.equal(backupSum(e,act),sum);
|
||||
assert.deepEqual(startsStops(e,n),[`stop ${CON}`,`stop ${BUS}`,`start ${BUS}`,`start ${CON}`]);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 2: a plain live-copy activate still refuses already-active, and so does --no-send once --send is gone',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--send']);
|
||||
await refused(e,['activate',B,'acme'],'already-active',`${B} is the pointer's copy`);
|
||||
await refused(e,['activate',B,'acme','--tester-port','7333'],'already-active',`${B} is the pointer's copy`);
|
||||
await refused(e,['activate',B,'acme','--no-proof'],'already-active',`${B} is the pointer's copy`);
|
||||
const r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
await refused(e,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
|
||||
const g=await mk(t);await seedPreview(g);
|
||||
await refused(g,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
|
||||
for(const x of [e,g])await noLeak(x);
|
||||
});
|
||||
await t.test('test 3: --plan prints one send: line in each case, with the live-copy note on a same-copy run',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
|
||||
let out=await planOnly(e,0,'activate',B,'acme','--no-send');
|
||||
assert.deepEqual(sendLines(out),['send: dropped; --no-send was given']);
|
||||
assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[LIVE_NOTE]);
|
||||
assert.equal(out[out.indexOf('send: dropped; --no-send was given')+1],`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${noSend(base).join(' ')}`);
|
||||
out=await planOnly(e,0,'activate',C1,'acme');
|
||||
assert.deepEqual(sendLines(out),['send: carried']);assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[]);
|
||||
assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(' --send'));
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C1,'acme','--no-send')),['send: dropped; --no-send was given']);
|
||||
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,noSend(base));assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme','--no-send')),['send: off; --no-send was given and the pointer holds no --send']);
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme')),['send: off']);
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'rollback','acme')),['send: off']);
|
||||
r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.match(stepText(e),/Z --no-send was given and the pointer holds no --send, so there is nothing to drop\n/);
|
||||
assert.doesNotMatch(stepText(e),/drop --send/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 4: after --no-send, a rollback to the earlier copy leaves --send out of the pointer and the unit',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
|
||||
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
|
||||
assert.doesNotMatch(stepText(e),/--send/);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 4: pm-on after --no-send keeps --send off',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,{pm:true},['--send']);
|
||||
let r=await prun(e,'activate',C1,'acme','--pm-after-harness','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
biz(e,HARNESS);
|
||||
r=await prun(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).pm,true);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 5: --no-send on rollback, pm-on, status, adopt or rehearse is a usage error',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--send']);
|
||||
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
|
||||
for(const args of [['rollback','acme','--no-send'],['rollback','acme','--plan','--no-send'],['pm-on','acme','--no-send'],['status','acme','--no-send'],['adopt','acme','--no-send'],
|
||||
['rehearse','acme','--backup','/tmp/x','--port','7400','--no-send']]){
|
||||
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
|
||||
}
|
||||
assert.match(usageText,/\| --no-proof\] \[--no-send\]/);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 6: with both a pair and --send in the pointer, --no-send and --no-proof each drop what they name and keep the rest',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),pre=noSend(base),f=await proofFile(e),g=await proofFile(e,'proof2.189',0o400);
|
||||
let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send']);
|
||||
let out=await planOnly(e,0,'activate',C1,'acme','--no-proof');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: carried']);
|
||||
out=await planOnly(e,0,'activate',C1,'acme','--no-send');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: carried, header x-console-proof','send: dropped; --no-send was given']);
|
||||
out=await planOnly(e,0,'activate',B,'acme','--no-send','--no-proof');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: dropped; --no-send was given']);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,[...pre,...pair(f)]);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send',...pair(g,'X-Proof-Two'));assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,[...pre,...pair(g,'X-Proof-Two')]);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
const h=await mk(t),hbase=await seedPreview(h,undefined,['--send']),hpre=noSend(hbase),k=await proofFile(h);
|
||||
r=await prun(h,'activate',B,'acme',...pair(k));assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(h,'activate',C1,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(h).console,[...hpre,'--send']);
|
||||
r=await prun(h,'activate',C1,'acme','--no-send','--no-proof');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(h).console,hpre);assert.doesNotMatch(conExec(h),/--proof-| --send( |$)/);
|
||||
const steps=stepText(h);
|
||||
assert.match(steps,/Z --no-proof was given and the pointer holds no proof pair, so there is nothing to drop\n/);assert.match(steps,/Z drop --send; --no-send was given\n/);
|
||||
r=await prun(h,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
for(const x of [e,h])await noLeak(x);
|
||||
});
|
||||
});
|
||||
|
||||
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
|
||||
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
|
||||
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// name against this list and forwards the call with its own capability.
|
||||
|
||||
import { connect } from "node:net";
|
||||
import { ACTIONS, ID_PATTERN, THREAD_DEFINITION } from "../../business/src/vocabulary.mjs";
|
||||
import { ACTIONS, THREAD_DEFINITION } from "../../business/src/vocabulary.mjs";
|
||||
|
||||
const str = (description, extra = {}) => ({ type: "string", description, ...extra });
|
||||
const obj = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
|
||||
@@ -113,11 +113,9 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
decision: COMMON.decision,
|
||||
}, ["task_ref", "roles"]));
|
||||
}
|
||||
// Row 194 (#1692, D3 item 5): the instance is a name, not an enum fixed when the bundle was built,
|
||||
// so the chief launches its own hire without a restart. The host checks the listing at each launch.
|
||||
if (resolved.launch) {
|
||||
add("launch", "launch", "role.launch", `Launch a managed session for a role instance the organization lists for you to launch. When this session started they were: ${resolved.launch.instances.join(", ")}. An instance you hired since is listed too. The host refuses an instance that isn't listed, one that is already running, a full model family, or a launch while launches are switched off.`, obj({
|
||||
instance: str("Role instance to launch", { pattern: ID_PATTERN.source }),
|
||||
add("launch", "launch", "role.launch", `Launch a managed session for one of these role instances: ${resolved.launch.instances.join(", ")}. The host refuses an instance that is already running, a full model family, or a launch while launches are switched off.`, obj({
|
||||
instance: str("Role instance to launch", { enum: [...resolved.launch.instances] }),
|
||||
}, ["instance"]));
|
||||
}
|
||||
if (tracker) {
|
||||
@@ -172,22 +170,6 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
reason: str("Why the run ends, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
|
||||
}, ["role", "reason"]));
|
||||
}
|
||||
// Row 194 (#1692, PRD 1.7 REQ-COS-3): the chief of staff's hire and fire, routed within-role. The
|
||||
// broker checks the definition against the role files and the veto on every call.
|
||||
if (held.has("role.hire")) {
|
||||
add("hire", "instance.hire", "role.hire", "Hire a new role instance: add it to the organization file under a new name, with a definition your role file lists as hireable. A reason is required, 1 to 500 characters, and goes into the log. vars may set the model key and limits.* keys only. The broker refuses a definition that isn't hireable with definition-not-hireable. Launch the instance afterwards with launch.", obj({
|
||||
name: str("The new instance's name"),
|
||||
definition: str("The role definition it runs"),
|
||||
vars: { type: "object", description: "The instance's vars: model and limits.* keys only" },
|
||||
reason: str("Why you hire it, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
|
||||
}, ["name", "definition", "reason"]));
|
||||
}
|
||||
if (held.has("role.fire")) {
|
||||
add("fire", "instance.fire", "role.fire", "Fire a role instance: stop its run, then remove it from the organization file. A reason is required, 1 to 500 characters, and goes into the log. The broker refuses an instance the human has kept with instance-kept. It also refuses your own instance.", obj({
|
||||
name: str("Role instance to fire"),
|
||||
reason: str("Why you fire it, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
|
||||
}, ["name", "reason"]));
|
||||
}
|
||||
if (held.has("organization.change")) {
|
||||
// Row 134 (#1669): organization_revision is the read change_organization's revision comes from.
|
||||
// Row 155 (#1649, Q169): assistants configures the assistant.
|
||||
|
||||
@@ -92,23 +92,22 @@ test("a chief-of-staff bundle lists launch among the bus tools and the manifest'
|
||||
const b = build(t, r);
|
||||
const prompt = readFileSync(b.files.prompt, "utf8");
|
||||
assert.match(prompt, /Role instance: chief \(definition chief-of-staff\)\. Run: run-1\./);
|
||||
assert.match(prompt, /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
|
||||
assert.match(prompt, /Bus tools: send_message, launch, end_run, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
|
||||
assert.ok(b.manifest.typed.includes("launch"));
|
||||
assert.deepEqual(JSON.parse(readFileSync(b.files.policy, "utf8")).needs, []);
|
||||
});
|
||||
|
||||
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1): the 3d re-pin window checks this manifest: both tools typed.
|
||||
// Row 141 (#1635, REQ-MSG-2) granted instance.name, and row 155 (#1649) gives it set_name, so nothing
|
||||
// the chief holds is untyped and the prompt has no untyped line. Row 194 (#1692, PRD 1.7 REQ-COS-3)
|
||||
// granted role.hire and role.fire, with hire and fire.
|
||||
// the chief holds is untyped and the prompt has no untyped line.
|
||||
test("a chief-of-staff bundle lists end_run, change_organization and set_name as typed and nothing untyped, for either harness", (t) => {
|
||||
for (const harness of ["pi", "claude-code"]) {
|
||||
const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" }));
|
||||
const b = build(t, r);
|
||||
const prefix = harness === "pi" ? "" : "mcp__mosaic__";
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
|
||||
assert.deepEqual(b.manifest.untyped, [], harness);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
|
||||
assert.doesNotMatch(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import assert from "node:assert/strict";
|
||||
import { callTool, ORGANIZATION_KEYS, REPLY_CAP, replyText, typedTools, untypedActions } from "../src/tools.mjs";
|
||||
import { ORGANIZATION_KEYS as BROKER_ORGANIZATION_KEYS, TASK_PAGE, TASK_PAGE_MAX } from "../../bus/src/broker.mjs";
|
||||
import { SETTINGS_KEYS } from "../../business/src/launch-settings.mjs";
|
||||
import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
|
||||
import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs";
|
||||
|
||||
const names = (tools) => tools.map((t) => t.name);
|
||||
@@ -38,47 +37,19 @@ test("send_message offers the BLOCK class, beside the classes it already had", (
|
||||
});
|
||||
|
||||
// Row 119 (#1613): the chief of staff holds launch and message.send; row 123 adds role.end and organization.change, which row 132 gives tools.
|
||||
test("the chief of staff gets launch, send_message, end_run, hire, fire, change_organization and set_name, and no task verbs", (t) => {
|
||||
test("the chief of staff gets launch, send_message, end_run, change_organization and set_name, and no task verbs", (t) => {
|
||||
const chief = resolvedFor(t, "chief");
|
||||
const tools = typedTools({ resolved: chief, tracker: true });
|
||||
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
const launch = tools.find((x) => x.name === "launch");
|
||||
assert.equal(launch.verb, "launch");
|
||||
assert.equal(launch.action, "role.launch");
|
||||
// Row 194 (#1692, D3 item 5): a name with the id pattern, not an enum fixed at bundle time, so a hire
|
||||
// launches with no restart. The start-time listing stays in the description, and the host checks it.
|
||||
assert.deepEqual(launch.parameters.properties.instance, { type: "string", description: "Role instance to launch", pattern: ID_PATTERN.source });
|
||||
assert.match(launch.description, /When this session started they were: coder, reviewer\. An instance you hired since is listed too\. The host refuses an instance that isn't listed,/);
|
||||
assert.deepEqual(launch.parameters.properties.instance.enum, ["coder", "reviewer"]);
|
||||
// Row 132 (#1628): role.end and organization.change have their tools. Row 141 (#1635) granted
|
||||
// instance.name, and row 155 (#1649) gives it set_name, so no held action is untyped. Row 194 (#1692,
|
||||
// PRD 1.7 REQ-COS-3) granted role.hire and role.fire, with hire and fire.
|
||||
// instance.name, and row 155 (#1649) gives it set_name, so no held action is untyped.
|
||||
assert.deepEqual(untypedActions(chief, tools), []);
|
||||
});
|
||||
|
||||
// Row 194 (#1692, PRD 1.7 REQ-COS-3): hire and fire send the broker's argument keys, reason required,
|
||||
// and only a holder of the grant gets them.
|
||||
test("hire and fire send instance.hire and instance.fire under role.hire and role.fire, only for a holder of the grant", (t) => {
|
||||
const tools = typedTools({ resolved: resolvedFor(t, "chief"), tracker: true });
|
||||
const hire = tools.find((x) => x.name === "hire");
|
||||
const fire = tools.find((x) => x.name === "fire");
|
||||
assert.deepEqual([hire.verb, hire.action, fire.verb, fire.action], ["instance.hire", "role.hire", "instance.fire", "role.fire"]);
|
||||
assert.deepEqual(Object.keys(hire.parameters.properties), ["name", "definition", "vars", "reason"]);
|
||||
assert.deepEqual(hire.parameters.required, ["name", "definition", "reason"]);
|
||||
assert.deepEqual(Object.keys(fire.parameters.properties), ["name", "reason"]);
|
||||
assert.deepEqual(fire.parameters.required, ["name", "reason"]);
|
||||
for (const tool of [hire, fire]) {
|
||||
assert.equal(tool.parameters.additionalProperties, false);
|
||||
assert.deepEqual([tool.parameters.properties.reason.minLength, tool.parameters.properties.reason.maxLength], [1, 500]);
|
||||
}
|
||||
for (const instance of ["coder", "reviewer", "pm"])
|
||||
for (const name of ["hire", "fire"]) assert.ok(!names(typedTools({ resolved: resolvedFor(t, instance), tracker: true })).includes(name), `${instance} ${name}`);
|
||||
// Each tool follows its own grant.
|
||||
const only = (grant) => resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { ...d.roles.chief.vars, "limits.authority": ["message.send", grant] }));
|
||||
const HIRE_FIRE = (n) => n === "hire" || n === "fire";
|
||||
assert.deepEqual(names(typedTools({ resolved: only("role.hire") })).filter(HIRE_FIRE), ["hire"]);
|
||||
assert.deepEqual(names(typedTools({ resolved: only("role.fire") })).filter(HIRE_FIRE), ["fire"]);
|
||||
});
|
||||
|
||||
// Row 140 (#1634, REQ-COS-1): the pm asks for staff with request_staff, which spends message.send; the
|
||||
// chief of staff and every other role has no such tool, and the chief's launch offer is unchanged.
|
||||
test("request_staff is the pm's alone: staff.request over message.send, roles and counts bounded", (t) => {
|
||||
@@ -104,8 +75,7 @@ test("request_staff is the pm's alone: staff.request over message.send, roles an
|
||||
assert.ok(!names(tools).includes("launch"));
|
||||
const chief = resolvedFor(t, "chief");
|
||||
const launch = typedTools({ resolved: chief, tracker: true }).find((x) => x.name === "launch");
|
||||
assert.equal(launch.parameters.properties.instance.enum, undefined);
|
||||
assert.ok(launch.description.includes(`they were: ${chief.launch.instances.join(", ")}.`));
|
||||
assert.deepEqual(launch.parameters.properties.instance.enum, chief.launch.instances);
|
||||
});
|
||||
|
||||
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1, REQ-LIMIT-1): each tool maps to the broker verb and action row 123 added.
|
||||
|
||||
@@ -129,45 +129,6 @@ Switching the Console, bus or wrapper, changing any unit, and starting or
|
||||
stopping either service requires Mos's go for that live step. Agents build
|
||||
and test fixtures; they stop before the live sequence.
|
||||
|
||||
## Sign-in in the Console (Q175 phase 1, #1690)
|
||||
|
||||
`serve --sso [--send] [--tester-port N]` makes the Console an OpenID Connect
|
||||
relying party. It reads `console.sso` from the system config (`publicOrigin`
|
||||
and one or more providers, each with its issuer, client id, a client secret
|
||||
slot, the signing algorithms it accepts and the subjects allowed in). The
|
||||
design is `agents/sage/work/q175-sso/DESIGN.md`. `--sso` can't be combined
|
||||
with `--public-host`, `--auth-header`, `--allow-user` or `--proof-*`; `--send`
|
||||
takes `--sso` in place of the preview set.
|
||||
|
||||
- The client secret is read once at start from
|
||||
`<config dir>/secrets/mosaic-stack/<slot>`. The start refuses a slot that is
|
||||
a symbolic link, not a regular file, owned by another user, has any group or
|
||||
other mode bit, or sits in the repository, the `--checkout` directory or a
|
||||
stack copy. The value is never printed.
|
||||
- At start the Console fetches each provider's discovery document and JWKS. A
|
||||
provider that can't be reached (a network error, a timeout, a status other
|
||||
than 200, or a body that isn't JSON) leaves the Console up with that
|
||||
provider's `/auth/login` at 503 and a retry every 5 s, doubling, capped at 5
|
||||
minutes. A document that fails a check (issuer, algorithms, S256, a usable
|
||||
key) refuses the start. `/healthz` adds `sso: { <id>: "ready" | "unreachable"
|
||||
| "refused" }`.
|
||||
- The Console listener serves only the Host of `publicOrigin`, whatever port
|
||||
it listens on. Any other Host, a loopback one included, gets 403 `public
|
||||
Host required`, except `GET /healthz`, which stays open on loopback for the
|
||||
status and release gates. The loopback operator path in the preview section
|
||||
above does not exist under `--sso`.
|
||||
- Without a session, `/api/*` gets 401 `sign-in required` and a page load gets
|
||||
a 303 to `/auth/login?return=<path>`. The page then shows the signed-out line
|
||||
with a link back to sign-in. A session lasts at most 8 hours and ends after 1
|
||||
hour without a page load or a write; a restart ends every session.
|
||||
- The page shows Sign out only under `--sso`. It posts to `/auth/logout`, which
|
||||
ends the session and goes to the provider's end-session endpoint when it has
|
||||
one, or to `/auth/signed-out`.
|
||||
- With `--tester-port`, the tester listener refuses any request carrying an
|
||||
`Authorization` header, a `__Host-mosaic_session` or `__Host-mosaic_login`
|
||||
cookie, or an `x-authentik-*` header (403 `tester listener takes no
|
||||
sign-in`). It never serves `/auth/*`.
|
||||
|
||||
## Message a role (row 76, #1570)
|
||||
|
||||
```sh
|
||||
|
||||
@@ -58,9 +58,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
|
||||
if (one(req, 'origin') !== preview.origin) return refuse(403, 'cross-origin');
|
||||
} else {
|
||||
if (!send || !named || !preview) return refuse(403, 'read-only');
|
||||
// Q175 phase 1 (#1690): under --sso the session was checked before this route.
|
||||
if (preview.sso !== true && one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
|
||||
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin');
|
||||
if (one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin');
|
||||
}
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
|
||||
}
|
||||
|
||||
+10
-39
@@ -7,31 +7,20 @@ import { views } from '../../bus/src/views.mjs';
|
||||
import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs';
|
||||
import { configDir } from '../../business/src/business.mjs';
|
||||
import { consoleReads, redactor } from './reads.mjs';
|
||||
import { createSso, readSlot } from './sso.mjs';
|
||||
import { statSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
||||
// Row 194 (#1692, D7): the Agents section's verbs. The caller refuses any other, so the server's
|
||||
// agentWrite reaches only these.
|
||||
const AGENT_VERBS = ['instance.launch', 'instance.regenerate', 'instance.end', 'veto.set', 'instance.request'];
|
||||
const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request'];
|
||||
const agentCaller = (call, verbs) => async (verb, args) => {
|
||||
if (!verbs.includes(verb)) throw new Error(`the Console does not call ${verb}`);
|
||||
return call(verb, args);
|
||||
};
|
||||
const args = process.argv.slice(2);
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
|
||||
try {
|
||||
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
|
||||
if (args.shift() !== 'serve') throw new Error(usage);
|
||||
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
|
||||
let sendOn = false, ssoOn = false, testerPort = null;
|
||||
let sendOn = false, testerPort = null;
|
||||
while (args.length) {
|
||||
const flag = args.shift();
|
||||
// Row 76 (#1570): the one flag without a value.
|
||||
if (flag === '--send') { sendOn = true; continue; }
|
||||
// Q175 phase 1 (#1690): the Console signs people in itself, from console.sso in config.json.
|
||||
if (flag === '--sso') { ssoOn = true; continue; }
|
||||
const value = args.shift();
|
||||
if (!value) throw new Error(`missing value for ${flag}`);
|
||||
if (flag === '--port') {
|
||||
@@ -62,17 +51,16 @@ try {
|
||||
else throw new Error(`unknown option: ${flag}`);
|
||||
}
|
||||
if (testerPort !== null && (testerPort === options.port || testerPort === 7332)) throw new Error('--tester-port must differ from --port and cannot be the forwarded port 7332');
|
||||
if (ssoOn && (Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*');
|
||||
options.preview = previewOptions(preview);
|
||||
const proof = proofOptions(proofFlags, options.preview);
|
||||
if (sendOn && !options.preview && !ssoOn) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
|
||||
if (sendOn && !options.preview) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
|
||||
// The bus views (slice 1 S5) need the system config. Without it the
|
||||
// board still serves and the bus routes answer not-configured.
|
||||
// The Queue, Business and Settings reads (row 54) need no bus; without
|
||||
// the system config, Business can't resolve authority and Settings has
|
||||
// no notifier. A refusal printed here names no path (redactor).
|
||||
// --send without a bus refuses to start rather than serve a composer that can't send.
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, busLine, system = null;
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, busLine, system = null;
|
||||
try {
|
||||
system = loadSystem();
|
||||
const { dataRoot } = system;
|
||||
@@ -85,52 +73,35 @@ try {
|
||||
// Row 148: the spec and docs editor reads like the other reads and saves only under --send.
|
||||
projectFiles = args => call('project.files', args);
|
||||
projectFile = args => call('project.file', args);
|
||||
if (sendOn) {send = args => call('message.send', args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);routesWrite=args=>call('routes.set',args);agentWrite=agentCaller(call,AGENT_VERBS);}
|
||||
if (sendOn) {send = args => call('message.send', args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);routesWrite=args=>call('routes.set',args);}
|
||||
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
||||
} catch (err) {
|
||||
const why = redactor({ configDir: configDir() })(err.message);
|
||||
if (ssoOn) throw new Error(`--sso needs the system config (${why})`);
|
||||
if (sendOn) throw new Error(`--send needs the bus configured (${why})`);
|
||||
if (testerPort !== null) throw new Error(`--tester-port needs the bus configured (${why})`);
|
||||
busLine = `Bus: not configured (${why})`;
|
||||
}
|
||||
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
|
||||
// The sign-in config is checked again here, then each slot file, before anything listens. A
|
||||
// provider that can't be reached starts degraded; one whose discovery is refused stops the start.
|
||||
let sso = null;
|
||||
if (ssoOn) {
|
||||
if (!system.console?.sso) throw new Error('--sso needs console.sso in the system config');
|
||||
const secrets = new Map(system.console.sso.providers.map(p => [p.id, readSlot(configDir(), p, { repo: REPO, checkout: options.checkout ?? null })]));
|
||||
sso = await createSso(system.console.sso, { secrets });
|
||||
}
|
||||
let server;
|
||||
try {
|
||||
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite });
|
||||
} catch (err) { sso?.close(); throw err; }
|
||||
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, proof, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
|
||||
let testerServer = null;
|
||||
if (testerPort !== null) {
|
||||
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
|
||||
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
|
||||
try {
|
||||
testerServer = await startServer({ host: '127.0.0.1', port: testerPort, board: options.board,
|
||||
tester: true, testerNoSignIn: ssoOn, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
|
||||
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
|
||||
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
|
||||
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
|
||||
// Row 194 (#1692, D4.1): the tester launches, terminates and reads its own requests.
|
||||
agentWrite: agentCaller(call, TESTER_AGENT_VERBS),
|
||||
// Row 177 (#1675): the tester's own chat, through the tester CLI; 7332's stays the human's.
|
||||
chat: createChat({ call }),
|
||||
});
|
||||
} catch (err) { sso?.close(); await new Promise(resolve => server.close(resolve)); throw err; }
|
||||
} catch (err) { await new Promise(resolve => server.close(resolve)); throw err; }
|
||||
}
|
||||
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
|
||||
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
|
||||
// One line per provider: id, issuer, subject count and state. No client id, secret or sub.
|
||||
const health = sso?.health() ?? {};
|
||||
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send)' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
|
||||
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${proofLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { server.close(); testerServer?.close(); });
|
||||
} catch (err) {
|
||||
console.error(`refused: ${err.message}`);
|
||||
process.exitCode = 2;
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
/* Row 194 (#1692, D7): the Agents section of Settings. bus.js draws it with section(view, ctx) after the
|
||||
role instances, from the same instances read. Create and Delete go through /api/instances; Launch,
|
||||
Terminate and Regenerate through /api/agent/*; Keep, Unkeep, No-hire and Hire through /api/veto.
|
||||
Every answer that names a request is followed at /api/agent/request until the request closes.
|
||||
Regenerate and Delete take a second click to confirm. Nothing retries: an unknown outcome says so
|
||||
and asks for a fresh read. The tester listener offers Launch and Terminate only (D4.1). */
|
||||
(() => {
|
||||
'use strict';
|
||||
const UNAVAILABLE = 'Agent actions are unavailable here. Use mosaic business instance from the instance CLI.';
|
||||
const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const POLL_MS = 2000, POLL_LIMIT = 90;
|
||||
const UNKNOWN = 'Outcome unknown. Read the agents again before trying; nothing was retried.';
|
||||
const DONE = { launch: 'Launched', regenerate: 'Regenerated', end: 'Terminated', delete: 'Deleted' };
|
||||
let ctx = null, view = null, busy = false, receipt = '', confirming = null, pending = null, generation = 0;
|
||||
let create = { instance: '', definition: '' };
|
||||
const reasons = {};
|
||||
const usable = () => !!ctx && (ctx.canSend || ctx.isTester) && !ctx.out();
|
||||
const human = () => !!ctx && ctx.canSend && !ctx.isTester;
|
||||
const redraw = () => { if (ctx && !ctx.out()) ctx.render(); };
|
||||
const reread = () => { if (ctx && !ctx.out()) ctx.refresh(); };
|
||||
|
||||
function section(v, c) {
|
||||
ctx = c; view = v;
|
||||
const { txt, esc } = c, title = '<h2 id="agents-title">Agents</h2>';
|
||||
if (!v) return `<section class="panel" id="agents-section" aria-labelledby="agents-title">${title}<p class="small warn">The agents could not be read, so there is nothing to act on. Read again to retry.</p></section>`;
|
||||
const open = usable(), off = !open || busy || pending ? ' disabled' : '';
|
||||
const button = (action, name, label, extra = '') =>
|
||||
`<button type="button" data-agent-action="${esc(action)}" data-agent-name="${esc(name)}"${off}${extra}>${txt(label)}</button>`;
|
||||
const confirmPair = (action, name, label, ask) => confirming?.action === action && confirming?.name === name
|
||||
? `<span class="agent-confirm"><span>${txt(ask)}</span> ${button(action, name, `Confirm ${label.toLowerCase()}`, ' data-agent-confirmed=""')} <button type="button" data-agent-cancel=""${off}>Cancel</button></span>`
|
||||
: button(action, name, label);
|
||||
const rows = Object.entries(v.instances).map(([name, e]) => {
|
||||
const running = Array.isArray(e.running) ? e.running : [];
|
||||
const acts = [button('launch', name, 'Launch'),
|
||||
`<label>Reason <input data-agent-reason="${esc(name)}" value="${esc(reasons[name] ?? '')}" maxlength="500"${off}></label>`,
|
||||
button('end', name, 'Terminate')];
|
||||
if (!c.isTester) {
|
||||
acts.push(confirmPair('regenerate', name, 'Regenerate', `Regenerate ${name}? Its workspace is renamed under .retired and it starts fresh.`));
|
||||
acts.push(e.kept ? button('unkeep', name, 'Unkeep') : button('keep', name, 'Keep'));
|
||||
if (!e.protected) acts.push(confirmPair('delete', name, 'Delete', `Delete ${name}? Its evidence and its workspace under .retired stay.`));
|
||||
}
|
||||
return `<tr><th scope="row">${txt(name)}</th><td>${txt(e.definition)}</td><td>${e.launched ? 'yes' : 'no'}</td><td>${e.kept ? 'yes' : 'no'}</td><td>${running.length ? running.map(r => `<code>${txt(r)}</code>`).join(' ') : '<span class="muted">none</span>'}</td><td class="agent-actions">${acts.join(' ')}</td></tr>`;
|
||||
}).join('');
|
||||
const noHire = Array.isArray(v.noHire) ? v.noHire : [], retired = Array.isArray(v.retired) ? v.retired : [];
|
||||
const defs = Object.keys(v.definitions || {});
|
||||
const hiring = c.isTester ? '' : `<h3>Hiring per definition</h3><ul class="agent-defs">${defs.map(d => `<li>${txt(d)}: ${noHire.includes(d) ? `no hire ${button('hire', d, 'Allow hiring')}` : `hire allowed ${button('nohire', d, 'No hire')}`}</li>`).join('')}</ul>`;
|
||||
const accepted = defs.filter(d => v.definitions[d]?.accepted);
|
||||
const createForm = c.isTester ? '' : `<form id="agent-create-form"><fieldset><legend>Create an agent</legend><div class="filters"><label>Instance <input data-agent-create="instance" value="${esc(create.instance)}" maxlength="64"${off}></label><label>Definition <select data-agent-create="definition"${off}><option value="">Choose</option>${accepted.map(d => `<option value="${esc(d)}"${d === create.definition ? ' selected' : ''}>${txt(d)}</option>`).join('')}</select></label></div><button type="submit"${off}>Create agent</button></fieldset></form>`;
|
||||
const state = pending ? `<p class="small" id="agents-pending">Request <code>${txt(pending.request)}</code> to ${txt(pending.action)} ${txt(pending.instance)}: ${txt(pending.state)}.</p>` : '';
|
||||
const note = open ? `<p class="small muted">Actions are checked and logged with your identity.</p>` : `<p class="small warn" id="agents-unavailable">${txt(UNAVAILABLE)}</p>`;
|
||||
return `<section class="panel" id="agents-section" aria-labelledby="agents-title">${title}<p>Each agent with its definition, launch membership, keep and running runs. A launch refuses no-route until the agent has a route in Routing.</p>${note}<div class="table-wrap" tabindex="0" role="region" aria-label="Agents, scroll for all columns"><table id="agents-table"><thead><tr><th scope="col">Instance</th><th scope="col">Definition</th><th scope="col">Launched</th><th scope="col">Kept</th><th scope="col">Running</th><th scope="col">Actions</th></tr></thead><tbody>${rows}</tbody></table></div>${hiring}${createForm}${retired.length ? `<p class="small muted">Retired names, never reused: ${retired.map(txt).join(', ')}.</p>` : ''}${state}<p role="status" id="agents-receipt">${txt(receipt)}</p></section>`;
|
||||
}
|
||||
|
||||
// One POST. `null` means the page signed out; otherwise the status and the parsed answer.
|
||||
async function post(url, payload) {
|
||||
const res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), cache: 'no-store', redirect: 'manual' });
|
||||
if (res.type === 'opaqueredirect') { ctx.signOut(); return null; }
|
||||
let result = null;
|
||||
try { result = await res.json(); } catch { /* an unreadable answer is an unknown outcome */ }
|
||||
return { ok: res.ok, result };
|
||||
}
|
||||
const refused = r => r?.sent === false && typeof r.message === 'string' ? `${r.message}` : UNKNOWN;
|
||||
|
||||
function plan(action, name) {
|
||||
switch (action) {
|
||||
case 'launch': return ['/api/agent/launch', { instance: name }];
|
||||
case 'regenerate': return ['/api/agent/regenerate', { instance: name }];
|
||||
case 'end': { const reason = String(reasons[name] ?? '').trim(); return ['/api/agent/terminate', reason ? { instance: name, reason } : { instance: name }]; }
|
||||
case 'delete': return ['/api/instances', { revision: view.revision, instance: name, op: 'delete' }];
|
||||
default: return ['/api/veto', { revision: view.revision, op: action, name }];
|
||||
}
|
||||
}
|
||||
|
||||
async function act(action, name) {
|
||||
if (!usable() || busy || pending || !view) return;
|
||||
const [url, payload] = plan(action, name);
|
||||
busy = true; confirming = null; receipt = ''; redraw();
|
||||
let answer;
|
||||
try { answer = await post(url, payload); } catch { answer = { ok: false, result: { sent: 'unknown' } }; }
|
||||
busy = false;
|
||||
if (answer === null) return;
|
||||
const { ok, result } = answer;
|
||||
if (!ok) { receipt = refused(result); redraw(); return; }
|
||||
if (url === '/api/veto') { receipt = `${{ keep: 'Kept', unkeep: 'Unkept', nohire: 'No hire for', hire: 'Hiring allowed for' }[action]} ${name}.`; reread(); return; }
|
||||
if (!REQUEST_ID.test(result?.request ?? '')) { receipt = UNKNOWN; redraw(); return; }
|
||||
if (action === 'end') delete reasons[name];
|
||||
pending = { request: result.request, action, instance: name, state: 'open', tries: 0 };
|
||||
receipt = action === 'end' && Array.isArray(result.runs)
|
||||
? `Terminate asked for ${result.runs.length} run${result.runs.length === 1 ? '' : 's'}.` : '';
|
||||
follow(generation);
|
||||
}
|
||||
|
||||
// Polls the request until it closes, the page signs out or the limit passes. A read that fails
|
||||
// is shown and the poll goes on; the request itself is never sent again.
|
||||
async function follow(gen) {
|
||||
while (pending && gen === generation) {
|
||||
redraw();
|
||||
await new Promise(r => setTimeout(r, POLL_MS));
|
||||
if (!pending || gen !== generation || ctx.out()) return;
|
||||
if (++pending.tries > POLL_LIMIT) { receipt = `Request ${pending.request} is still ${pending.state}. Read the agents again later.`; pending = null; reread(); return; }
|
||||
let res, r = null;
|
||||
try { res = await fetch(`/api/agent/request?id=${encodeURIComponent(pending.request)}`, { cache: 'no-store', redirect: 'manual' }); } catch { continue; }
|
||||
if (res.type === 'opaqueredirect') { pending = null; ctx.signOut(); return; }
|
||||
try { r = await res.json(); } catch { continue; }
|
||||
if (!res.ok || r?.request !== pending.request) { receipt = `Could not read request ${pending.request}: ${r?.message ?? 'no answer'}. Still following it.`; continue; }
|
||||
pending.state = r.state;
|
||||
if (r.state !== 'closed') continue;
|
||||
const { action, instance } = pending;
|
||||
pending = null;
|
||||
receipt = r.reason ? `${instance}: ${r.reason}.` : `${DONE[action] ?? 'Done'} ${instance}${r.done?.run ? ` (run ${r.done.run})` : ''}.`;
|
||||
reread();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function createAgent() {
|
||||
if (!usable() || busy || pending || !view || ctx.isTester) return;
|
||||
const instance = create.instance.trim(), definition = create.definition;
|
||||
if (!instance || !definition) { receipt = 'Name the instance and choose a definition.'; redraw(); return; }
|
||||
busy = true; receipt = ''; redraw();
|
||||
let answer;
|
||||
try { answer = await post('/api/instances', { revision: view.revision, instance, op: 'add', definition }); } catch { answer = { ok: false, result: { sent: 'unknown' } }; }
|
||||
busy = false;
|
||||
if (answer === null) return;
|
||||
if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}.`; reread(); }
|
||||
else { receipt = answer.ok ? UNKNOWN : `${refused(answer.result)} Draft kept.`; redraw(); }
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const t = e.target;
|
||||
if (!t?.closest?.('#agents-section')) return;
|
||||
if (t.dataset.agentCancel !== undefined) { confirming = null; redraw(); return; }
|
||||
const action = t.dataset.agentAction, name = t.dataset.agentName;
|
||||
if (!action || name === undefined) return;
|
||||
if ((action === 'regenerate' || action === 'delete') && t.dataset.agentConfirmed === undefined) { confirming = { action, name }; redraw(); return; }
|
||||
act(action, name);
|
||||
});
|
||||
document.addEventListener('input', e => {
|
||||
const t = e.target;
|
||||
if (t?.dataset?.agentReason !== undefined) reasons[t.dataset.agentReason] = t.value;
|
||||
else if (t?.dataset?.agentCreate !== undefined) create[t.dataset.agentCreate] = t.value;
|
||||
});
|
||||
document.addEventListener('change', e => { const t = e.target; if (t?.dataset?.agentCreate !== undefined) create[t.dataset.agentCreate] = t.value; });
|
||||
document.addEventListener('submit', e => { if (e.target?.id !== 'agent-create-form') return; e.preventDefault(); createAgent(); });
|
||||
// A sign-out stops any poll; nothing that was asked is retried.
|
||||
document.addEventListener('mosaic-signed-out', () => { generation++; pending = null; confirming = null; });
|
||||
|
||||
window.MosaicAgents = Object.freeze({ section });
|
||||
})();
|
||||
@@ -16,11 +16,6 @@
|
||||
// The first one sets body.signed-out (the listener below); after that no Console fetch is made.
|
||||
const out = () => document.body.classList.contains('signed-out');
|
||||
const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
|
||||
// Q175 phase 1 (#1690): under --sso the Console itself answers a read without a session with 401
|
||||
// `sign-in required`; that signs the page out too, and the line links to the Console's sign-in.
|
||||
// Any other 401 stays an error. `sso` comes from /api/config.
|
||||
let sso = false;
|
||||
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
|
||||
const signedOut = () => Object.assign(new Error('the sign-in proxy redirected it, so it never reached the Console'), { signedOut: true });
|
||||
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
|
||||
const source = value => value ? `<span class="source">${esc(labels[value] || value)}</span>` : '';
|
||||
@@ -115,7 +110,6 @@
|
||||
const res = await fetch(path, { cache: 'no-store', redirect: 'manual' });
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
let body; try { body = await res.json(); } catch { body = { error: `HTTP ${res.status}` }; }
|
||||
if (signInRequired(res, body)) { sso = true; signOut(); throw signedOut(); }
|
||||
return { ok: res.ok, body };
|
||||
}
|
||||
function blockNode(b) {
|
||||
@@ -310,7 +304,6 @@
|
||||
const res = await fetch(path, value === undefined ? { cache: 'no-store', redirect: 'manual' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value), redirect: 'manual' });
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
const result = await res.json();
|
||||
if (signInRequired(res, result)) { sso = true; signOut(); throw signedOut(); }
|
||||
if (!res.ok) throw Object.assign(new Error(result.error || `HTTP ${res.status}`), { status: res.status });
|
||||
return result;
|
||||
}
|
||||
@@ -459,7 +452,7 @@
|
||||
});
|
||||
// Signed out: everything drawn from a Console read goes, because none of it can be refreshed. The
|
||||
// line says so once; the send form (bus.js) keeps its draft and receipt. Reload is the way back.
|
||||
document.addEventListener('mosaic-signed-out', e => {
|
||||
document.addEventListener('mosaic-signed-out', () => {
|
||||
if (out()) return;
|
||||
document.body.classList.add('signed-out');
|
||||
clearTimeout(timer); convGen++;
|
||||
@@ -468,13 +461,9 @@
|
||||
for (const id of ['projects', 'waiting', 'seen', 'sessions']) $(id).replaceChildren();
|
||||
$('refresh').disabled = $('pause').disabled = true;
|
||||
const line = $('signed-out');
|
||||
line.innerHTML = sso || e.detail?.signIn
|
||||
? `<p><b>Signed out.</b> <a href="/auth/login?${esc(new URLSearchParams({ return: location.pathname + location.search }))}">Sign in again</a>.</p>`
|
||||
: '<p><b>Signed out.</b> Reload the page to sign in.</p>';
|
||||
line.hidden = false; $('sign-out').hidden = true;
|
||||
line.innerHTML = '<p><b>Signed out.</b> Reload the page to sign in.</p>'; line.hidden = false;
|
||||
// Focus moves to the line only if what had it is gone or disabled, never out of a form being typed in.
|
||||
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
|
||||
});
|
||||
// Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso.
|
||||
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; }).catch(err => { if (machine) error(err); }).finally(refresh);
|
||||
})();
|
||||
|
||||
@@ -30,10 +30,6 @@
|
||||
const out = () => document.body.classList.contains('signed-out');
|
||||
const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
|
||||
const signedOut = () => new ReadError('signed-out', 'The sign-in proxy redirected the read.', 0);
|
||||
// Q175 phase 1 (#1690, sage-1340 F1): under --sso a read without a session gets 401 `sign-in
|
||||
// required` from the Console itself. That signs out the same way; any other 401 stays an error.
|
||||
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
|
||||
const signOutSso = () => { if (!out()) document.dispatchEvent(new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })); };
|
||||
// `replies` takes a message id as `id`; trail takes `subject`.
|
||||
async function fetchRead(verb, subject) {
|
||||
if (out()) throw signedOut();
|
||||
@@ -42,7 +38,6 @@
|
||||
catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
try { body = await res.json(); } catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||||
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
|
||||
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
|
||||
return { ...body, rows: body.rows, at: body.at };
|
||||
}
|
||||
@@ -55,7 +50,6 @@
|
||||
try { res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' }); } catch { throw unreachable(); }
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
try { body = await res.json(); } catch { throw unreachable(); }
|
||||
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
|
||||
if (!res.ok || !body || typeof body !== 'object' || Array.isArray(body)) throw Object.assign(new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status), { module: true });
|
||||
return body;
|
||||
}
|
||||
@@ -787,9 +781,6 @@
|
||||
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'} Draft kept.`;}}
|
||||
catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);}
|
||||
});
|
||||
// Row 194 (#1692, D7): the Agents section lives in agents.js (window.MosaicAgents) and draws from
|
||||
// the same instances read. A refresh drops that read so the next render asks the bus again.
|
||||
const agentsSection=view=>window.MosaicAgents?window.MosaicAgents.section(view,{txt,esc,canSend,isTester,out,signOut,render:()=>render(false),refresh:()=>{last.delete('instances');render(false);}}):'';
|
||||
// Tester management is deliberately separate from the human's assistant/settings forms.
|
||||
function testerNames(names, instances) {
|
||||
if (!instances || !names) return '';
|
||||
@@ -823,11 +814,11 @@
|
||||
try { [routes] = await get('routes'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; }
|
||||
let limitRows = [], limitsRefused = false;
|
||||
try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; }
|
||||
if (isTester) return `${head('Settings', `${readLine()} Tester management.`)}${routesSection(routes)}${instancesSection(instances)}${agentsSection(instances)}${testerNames(names, instances)}`;
|
||||
if (isTester) return `${head('Settings', `${readLine()} Tester management.`)}${routesSection(routes)}${instancesSection(instances)}${testerNames(names, instances)}`;
|
||||
const notify = typeof n.binding === 'string'
|
||||
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
|
||||
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`);
|
||||
return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${routesSection(routes)}${assistantSection(s.launch, names)}${instancesSection(instances)}${agentsSection(instances)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
|
||||
return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${routesSection(routes)}${assistantSection(s.launch, names)}${instancesSection(instances)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
|
||||
<section aria-labelledby="st-notify"><h2 id="st-notify">Notifications</h2>${notify}</section>
|
||||
<section aria-labelledby="st-about" class="panel"><h2 id="st-about">About</h2><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address) || 'unknown'}</code></dd><dt>Release</dt><dd>${s.release ? `<code>${txt(s.release)}</code>` : '<span class="muted">unknown: no RELEASE file was read</span>'}</dd><dt>Business</dt><dd>${txt(s.business) || none}</dd><dt>Board</dt><dd><code>${txt(s.board) || 'unknown'}</code></dd></dl></section>`;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
|
||||
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/shell.css">
|
||||
<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>
|
||||
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script></head>
|
||||
<script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/bus.js" defer></script></head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
|
||||
<button type="button" class="cmdk" id="cmdk" aria-haspopup="dialog" aria-controls="cmdk-dialog"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><span>Go to a view, decision or task</span><kbd>Ctrl K</kbd></button>
|
||||
<p class="fresh" id="fresh"><span id="fresh-board"></span><span id="fresh-bus"></span></p>
|
||||
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button><form id="sign-out" method="post" action="/auth/logout" hidden><button class="btn" type="submit">Sign out</button></form></div></header>
|
||||
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button></div></header>
|
||||
<div class="frame">
|
||||
<nav class="tree" aria-label="Console"><h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
|
||||
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
|
||||
|
||||
+32
-174
@@ -25,7 +25,7 @@ export function boardURL(value) {
|
||||
const root = resolve(import.meta.dirname, 'public');
|
||||
const files = new Map([
|
||||
['/', ['index.html', 'text/html; charset=utf-8']],
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'agents.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||||
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||||
['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']],
|
||||
['/editor.css', ['editor.css', 'text/css; charset=utf-8']],
|
||||
@@ -61,8 +61,6 @@ async function body(req, max = 4096) {
|
||||
// Row 65 (mos-0840) adds `routes`: each instance's harness, model, thinking and fallback.
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'];
|
||||
const DECISION_LISTS = ['roles', 'recent'];
|
||||
// Q175 phase 1 (#1690, F8): either sign-in cookie, which the tester listener refuses under --sso.
|
||||
const SIGN_IN_COOKIE = /(?:^|;)\s*__host-mosaic_(?:session|login)\s*=/i;
|
||||
// Row 177 (#1675): the chat writes the tester listener lets through, exactly the four chatHTTP routes.
|
||||
const TESTER_CHAT_WRITE = /^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/(?:messages|open))$/i;
|
||||
async function busRead(res, bus, verb, search) {
|
||||
@@ -162,18 +160,14 @@ async function busRead(res, bus, verb, search) {
|
||||
// broker's to refuse. Nothing retries; an outcome the bus can't confirm is reported as unknown.
|
||||
const SEND_KEYS = ['to', 'body', 'in_reply_to'];
|
||||
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
||||
// Q175 phase 1 (#1690): under --sso the writer comes from a session the handler already checked,
|
||||
// so it carries `sso: true` and the public origin; the preview writer still checks its header.
|
||||
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
|
||||
const ownOrigin = p => p.origin ?? `https://${p.host}`;
|
||||
async function busSend(req, res, { send, preview, log }) {
|
||||
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
|
||||
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required' });
|
||||
let to = null;
|
||||
// `to` is quoted and cut short so a crafted value can't forge a log line.
|
||||
const line = outcome => log(`${new Date().toISOString()} send user=${JSON.stringify(preview.user)} to=${typeof to === 'string' ? JSON.stringify(to.slice(0, 64)) : 'none'} ${outcome}`);
|
||||
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a send needs Sec-Fetch-Site: same-origin');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -247,10 +241,10 @@ export function proofOptions({ proofHeader, proofFile } = {}, preview = null) {
|
||||
// REQ-WEB-1: same signed-in send boundary, a distinct strict settings payload.
|
||||
async function settingsSend(req, res, { settingsWrite, preview, log }) {
|
||||
const refuse = (status, error, message) => json(res, status, { error, message, sent: false });
|
||||
if (!signedIn(req, preview)) {
|
||||
if (one(req, preview.header) !== preview.user) {
|
||||
return refuse(401, 'sign-in required', 'sign in again');
|
||||
}
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) {
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) {
|
||||
return refuse(403, 'cross-origin request refused', 'settings need the Console origin');
|
||||
}
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') {
|
||||
@@ -298,8 +292,8 @@ async function seatSend(req, res, { route, write, preview, log }) {
|
||||
// The instance is quoted and cut short so a crafted value can't forge a log line.
|
||||
const line = outcome => log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -336,67 +330,30 @@ const INSTANCE_REASONS = {
|
||||
'organization-unresolvable-instance': 'an instance wouldn\'t resolve against a project file; mosaic business validate shows why',
|
||||
'revision-conflict': 'the business file changed since you read it. Read the role instances again',
|
||||
};
|
||||
// Row 194 (#1692, D7): the lifecycle and veto refusals, for the delete op, the Agents routes and a
|
||||
// closed request's refusal. A code missing here shows as itself.
|
||||
const AGENT_REASONS = {
|
||||
'organization-instance-referenced': 'the instance is launch.by or an arbiter; change that reference first',
|
||||
'organization-instance-kept': 'the instance is kept; unkeep it first',
|
||||
'organization-instance-retired': 'that name was deleted and stays retired; pick another name',
|
||||
'instance-kept': 'the instance is kept; unkeep it first',
|
||||
'instance-running': 'the instance has a run; terminate it first',
|
||||
'instance-starting': 'a request for the instance has not closed yet; wait for it and try again',
|
||||
'instance-retired': 'that name was deleted and launches no more',
|
||||
'not-thread-launch': 'a thread instance starts on a thread message, not by launch',
|
||||
'regenerate-thread': 'a thread instance isn\'t regenerated; its context is the thread\'s',
|
||||
'not-held': 'the instance has no run and no open request',
|
||||
'launch-revoked': 'launches are revoked for the business',
|
||||
'launches-disabled': 'the bus host was started with --no-launch',
|
||||
'request-expired': 'the launcher didn\'t take the request within its 60 s',
|
||||
'request-cancelled': 'a terminate cancelled the request',
|
||||
'regenerate-timeout': 'the old run didn\'t exit within the request\'s 60 s, so no new run started; it still runs',
|
||||
'workspace-rename-failed': 'the business file is changed and the name retired, but the workspace stayed where it was; delete it again to retry the rename',
|
||||
'capacity-full': 'a launch limit is reached',
|
||||
'limit-reached': 'a launch limit is reached',
|
||||
'no-route': 'the instance has no harness, model and thinking yet; set its route first',
|
||||
'unknown-role': 'the business has no such instance',
|
||||
'unknown-request': 'the bus has no such request',
|
||||
'instance-not-listed': 'the instance isn\'t on the launch list',
|
||||
};
|
||||
const reasonFor = code => Object.hasOwn(INSTANCE_REASONS, code) ? INSTANCE_REASONS[code] : Object.hasOwn(AGENT_REASONS, code) ? AGENT_REASONS[code] : 'refused by the bus';
|
||||
async function instanceSend(req, res, { instancesWrite, preview, log }) {
|
||||
let instance = null;
|
||||
// The instance is quoted and cut short so a crafted value can't forge a log line. Vars are never logged.
|
||||
const line = outcome => log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
const has = key => Object.hasOwn(value, key);
|
||||
// Row 194 (#1692, D7, V5): op delete takes the revision and the instance alone. The bus answers a
|
||||
// request the launcher's pass carries out, which the page follows at /api/agent/request.
|
||||
const deleting = value?.op === 'delete';
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !INSTANCE_KEYS.includes(key)) || typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision) ||
|
||||
typeof value.instance !== 'string' || !['add', 'change', 'delete'].includes(value.op) || (deleting && ['definition', 'vars', 'launch'].some(has)) || (has('definition') && typeof value.definition !== 'string') ||
|
||||
if (Object.keys(value).some(key => !INSTANCE_KEYS.includes(key)) || typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision) ||
|
||||
typeof value.instance !== 'string' || !['add', 'change'].includes(value.op) || (has('definition') && typeof value.definition !== 'string') ||
|
||||
(has('vars') && (!value.vars || typeof value.vars !== 'object' || Array.isArray(value.vars))) || (has('launch') && typeof value.launch !== 'boolean')) {
|
||||
return refuse(400, 'invalid-request', 'this takes a revision, an instance, op add or change and a definition, vars or launch, or op delete alone, nothing else');
|
||||
return refuse(400, 'invalid-request', 'this takes a revision, an instance, op add or change, and a definition, vars or launch, nothing else');
|
||||
}
|
||||
({ instance } = value);
|
||||
// D4.1: the tester never deletes. The bus refuses it too (broker tester-refused); this makes no call.
|
||||
if (deleting && preview.tester) return refuse(403, 'tester-refused', 'the tester listener doesn\'t delete. Nothing changed.');
|
||||
let result;
|
||||
try { result = await instancesWrite(Object.fromEntries(INSTANCE_KEYS.filter(has).map(k => [k, value[k]]))); } catch (err) {
|
||||
const { code, sent } = sendOutcome(err);
|
||||
return sent === false
|
||||
? refuse(busStatus(code), code, `${code}: ${reasonFor(code)}. Nothing changed.`)
|
||||
? refuse(busStatus(code), code, `${code}: ${Object.hasOwn(INSTANCE_REASONS, code) ? INSTANCE_REASONS[code] : 'refused by the bus'}. Nothing changed.`)
|
||||
: refuse(503, 'outcome-unknown', 'the change may have been made; nothing was retried. Read the role instances again before trying', 'unknown');
|
||||
}
|
||||
if (deleting) {
|
||||
if (!REQUEST_ID.test(result?.request ?? '')) return refuse(503, 'outcome-unknown', 'the bus answered in an unexpected shape; the delete may have been asked for. Read the role instances again before trying', 'unknown');
|
||||
line(`done request=${result.request}`);
|
||||
return json(res, 200, { request: result.request, at: new Date().toISOString() });
|
||||
}
|
||||
const object = o => o && typeof o === 'object' && !Array.isArray(o);
|
||||
if (!/^[a-f0-9]{64}$/.test(result?.revision ?? '') || typeof result.restart !== 'boolean' || !object(result.definitions) || !object(result.instances) || !result.instances[instance]) {
|
||||
return refuse(503, 'outcome-unknown', 'the bus answered in an unexpected shape; the change may have been made. Read the role instances again before trying', 'unknown');
|
||||
@@ -404,68 +361,6 @@ async function instanceSend(req, res, { instancesWrite, preview, log }) {
|
||||
line(`done restart=${result.restart}`);
|
||||
return json(res, 200, { ...result, at: new Date().toISOString() });
|
||||
}
|
||||
// Row 194 (#1692, D7): the Agents section's launch, regenerate, terminate and veto. Each has
|
||||
// instanceSend's boundary and one human-transport call. Launch and regenerate answer a request the
|
||||
// page follows at /api/agent/request until it closes. `tester` marks the routes the tester listener
|
||||
// lets through (D4.1): launch, terminate and the request read.
|
||||
const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const VETO_OPS = ['keep', 'unkeep', 'nohire', 'hire'];
|
||||
const ids = list => Array.isArray(list) && list.every(x => typeof x === 'string');
|
||||
const AGENT_ROUTES = {
|
||||
'/api/agent/launch': { kind: 'launch', verb: 'instance.launch', keys: ['instance'], tester: true, done: r => REQUEST_ID.test(r?.request ?? '') },
|
||||
'/api/agent/regenerate': { kind: 'regenerate', verb: 'instance.regenerate', keys: ['instance'], tester: false, done: r => REQUEST_ID.test(r?.request ?? '') },
|
||||
'/api/agent/terminate': { kind: 'terminate', verb: 'instance.end', keys: ['instance', 'reason'], tester: true,
|
||||
done: r => REQUEST_ID.test(r?.request ?? '') && ids(r.runs) && ids(r.cancelled) && ids(r.taken) },
|
||||
'/api/veto': { kind: 'veto', verb: 'veto.set', keys: ['revision', 'op', 'name'], tester: false,
|
||||
done: r => /^[a-f0-9]{64}$/.test(r?.revision ?? '') && r.instances && typeof r.instances === 'object' && !Array.isArray(r.instances) },
|
||||
};
|
||||
async function agentSend(req, res, { route, agentWrite, preview, log }) {
|
||||
let target = null;
|
||||
// The instance or name is quoted and cut short so a crafted value can't forge a log line. A reason is never logged.
|
||||
const line = outcome => log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(target)} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
const required = route.keys.filter(k => k !== 'reason');
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !route.keys.includes(k)) || !required.every(k => Object.hasOwn(value, k)) ||
|
||||
!Object.values(value).every(v => typeof v === 'string') || (Object.hasOwn(value, 'revision') && !/^[a-f0-9]{64}$/.test(value.revision)) ||
|
||||
(Object.hasOwn(value, 'op') && !VETO_OPS.includes(value.op))) {
|
||||
return refuse(400, 'invalid-request', `this takes ${route.keys.join(', ')}${route.kind === 'terminate' ? ' (the reason may be left out)' : ''}, all text, nothing else`);
|
||||
}
|
||||
target = value.instance ?? value.name;
|
||||
let result;
|
||||
try { result = await agentWrite(route.verb, Object.fromEntries(route.keys.filter(k => Object.hasOwn(value, k)).map(k => [k, value[k]]))); } catch (err) {
|
||||
const { code, sent } = sendOutcome(err);
|
||||
return sent === false
|
||||
? refuse(busStatus(code), code, `${code}: ${reasonFor(code)}. Nothing changed.`)
|
||||
: refuse(503, 'outcome-unknown', 'the request may have been made; nothing was retried. Read the agents again before trying', 'unknown');
|
||||
}
|
||||
if (!route.done(result)) return refuse(503, 'outcome-unknown', 'the bus answered in an unexpected shape; the request may have been made. Read the agents again before trying', 'unknown');
|
||||
line(result.request ? `done request=${result.request}` : 'done');
|
||||
return json(res, 200, { ...result, at: new Date().toISOString() });
|
||||
}
|
||||
// D1.1 item 5: the read the page polls. A closed request's refusal gains its reason.
|
||||
const REQUEST_STATES = ['open', 'taken', 'closed'];
|
||||
async function agentRequest(res, { agentWrite, search }) {
|
||||
const params = new URLSearchParams(search);
|
||||
const id = params.get('id');
|
||||
if ([...params.keys()].some(k => k !== 'id') || params.getAll('id').length !== 1 || !REQUEST_ID.test(id)) {
|
||||
return json(res, 400, { error: 'invalid-request', message: 'this takes one request id' });
|
||||
}
|
||||
let r;
|
||||
try { r = await agentWrite('instance.request', { request: id }); } catch (err) {
|
||||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
return json(res, busStatus(code), { error: code, message: `${code}: ${reasonFor(code)}` });
|
||||
}
|
||||
if (r?.request !== id || !REQUEST_STATES.includes(r.state) || typeof r.instance !== 'string' || typeof r.action !== 'string') {
|
||||
return json(res, 502, { error: 'invalid-response', message: 'the bus answered the request read in an unexpected shape' });
|
||||
}
|
||||
const refusal = r.done?.refusal ?? (r.cancelled ? 'request-cancelled' : null);
|
||||
return json(res, 200, { ...r, ...(refusal ? { reason: reasonFor(refusal) } : {}) });
|
||||
}
|
||||
// Row 65 (mos-0840): the routing table. The Settings boundary and one bus call, `routes.set`,
|
||||
// through the human transport. It is human-only and outside ORGANIZATION_KEYS, so the chief of
|
||||
// staff's tool never reaches it. The bus checks the route rules; a refusal comes back with its code.
|
||||
@@ -487,8 +382,8 @@ async function routesSend(req, res, { routesWrite, preview, log }) {
|
||||
let names = [];
|
||||
const line = outcome => log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -525,8 +420,8 @@ async function projectFileSend(req, res, { projectSave, preview, log }) {
|
||||
const line = outcome => log(`${new Date().toISOString()} project-file user=${JSON.stringify(preview.user)} project=${quoted(target?.project)} path=${quoted(target?.path)} ${outcome}`);
|
||||
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a save needs the Console\'s own Origin');
|
||||
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a save needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a save needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req, FILE_SAVE_MAX)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
@@ -565,18 +460,13 @@ async function projectFileRead(res, read, path, search) {
|
||||
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
|
||||
}
|
||||
}
|
||||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode or `sso`.
|
||||
// `sso` is createSso() from sso.mjs (Q175 phase 1): the human listener signs people in itself.
|
||||
// `testerNoSignIn` makes the tester listener refuse anything that looks like a sign-in (--sso). `log`
|
||||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
|
||||
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||||
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerNoSignIn = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, tester = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
|
||||
if (tester && sso !== null) throw new Error('tester listener cannot use sso');
|
||||
if (sso !== null && (preview !== null || proof !== null)) throw new Error('--sso cannot be combined with --auth-header, --allow-user or --proof-*');
|
||||
if (tester && host !== '127.0.0.1') throw new Error('tester listener must bind 127.0.0.1');
|
||||
// Row 177 (#1675): chat is allowed. The tester listener's chat binds the tester, so the bus keeps it on tester threads.
|
||||
if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
|
||||
@@ -586,26 +476,21 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
|
||||
}
|
||||
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
|
||||
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
|
||||
if (send !== null && (preview === null || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
|
||||
const server = createServer(async (req, res) => {
|
||||
res.setHeader('x-content-type-options', 'nosniff');
|
||||
res.setHeader('referrer-policy', 'no-referrer');
|
||||
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
|
||||
// browsers apply form-action to that redirect, so those origins (from discovery) are allowed too.
|
||||
res.setHeader('content-security-policy', `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'${sso ? sso.formTargets().map(o => ` ${o}`).join('') : ''}`);
|
||||
res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
|
||||
// Presence, even with an empty value: never turn the public listener into a tester ingress.
|
||||
if (!tester && Object.hasOwn(req.headers, 'x-mosaic-tester')) return json(res, 403, { error: 'tester listener required' });
|
||||
if (tester && testerAuthHeader && Object.hasOwn(req.headers, testerAuthHeader.toLowerCase())) return json(res, 403, { error: 'proxy header refused' });
|
||||
if (tester && testerProofHeader && Object.hasOwn(req.headers, testerProofHeader.toLowerCase())) return json(res, 403, { error: 'proxy proof header refused' });
|
||||
// Under --sso the tester listener reads no cookie and takes no sign-in of any kind (design section 6).
|
||||
if (tester && testerNoSignIn && (Object.hasOwn(req.headers, 'authorization') || SIGN_IN_COOKIE.test(req.headers.cookie ?? '') || Object.keys(req.headers).some(h => h.startsWith('x-authentik-'))))
|
||||
return json(res, 403, { error: 'tester listener takes no sign-in' });
|
||||
let path, search;
|
||||
// Node keeps the first of repeated Host headers; a second one (a loopback Host, then NAME) is
|
||||
// never legitimate, so it is refused before any check reads the Host.
|
||||
if ((req.headersDistinct.host?.length ?? 0) > 1) return json(res, 400, { error: 'repeated Host refused' });
|
||||
// Preview mode: a request for the public name, exactly, as the proxy forwards it.
|
||||
const named = preview !== null ? (req.headers.host ?? '').toLowerCase() === preview.host : sso !== null && (req.headers.host ?? '').toLowerCase() === sso.publicHost;
|
||||
const named = preview !== null && (req.headers.host ?? '').toLowerCase() === preview.host;
|
||||
// The proof precedes every auth-header read, including the wrong-Host fence.
|
||||
if (proof !== null) {
|
||||
const values = req.headersDistinct[proof.header];
|
||||
@@ -627,35 +512,19 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
}
|
||||
({ pathname: path, search } = new URL(req.url, 'http://localhost'));
|
||||
} catch { return json(res, 400, { error: 'invalid URL' }); }
|
||||
// Under --sso the human listener serves only the public Host, whatever its port; GET /healthz
|
||||
// alone stays open on a loopback Host for the status and release gates (Q-C).
|
||||
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
|
||||
// No CORS. JSON content type and same-origin checks keep browser forms out.
|
||||
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
|
||||
let signer = preview;
|
||||
if (sso !== null && named) {
|
||||
if (path.startsWith('/auth/')) return sso.handle(req, res, path, search);
|
||||
// Only a page load or a write keeps a session alive; the page's background reads don't (Q-D).
|
||||
const session = path === '/healthz' ? null : sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) });
|
||||
if (!session && path !== '/healthz') {
|
||||
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
|
||||
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
|
||||
return res.end();
|
||||
}
|
||||
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin };
|
||||
}
|
||||
if (named && preview !== null) {
|
||||
if (req.headers.origin && req.headers.origin !== (named ? `https://${preview.host}` : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
|
||||
if (named) {
|
||||
// One value, equal to the user id; the proxy strips client copies and sets it after sign-in.
|
||||
const values = req.headersDistinct[preview.header];
|
||||
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
|
||||
}
|
||||
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : signer;
|
||||
// Row 194 (#1692, D4.1): the tester also launches and terminates.
|
||||
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
|
||||
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : preview;
|
||||
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name'].includes(path) && !TESTER_CHAT_WRITE.test(path))
|
||||
return json(res, 403, { error: 'read-only', sent: false });
|
||||
if(path==='/api/settings'&&req.method==='POST'){
|
||||
if(!send||!settingsWrite||!named)return json(res,403,{error:'read-only',sent:false});
|
||||
return settingsSend(req,res,{settingsWrite,preview:signer,log});
|
||||
return settingsSend(req,res,{settingsWrite,preview,log});
|
||||
}
|
||||
if (Object.hasOwn(SEAT_ROUTES, path)) {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
@@ -668,17 +537,6 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if ((!tester && (!send || !named)) || !instancesWrite) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return instanceSend(req, res, { instancesWrite, preview: writer, log });
|
||||
}
|
||||
// Row 194 (#1692, D7): the Agents section. No route gets a --send exception.
|
||||
if (Object.hasOwn(AGENT_ROUTES, path)) {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
if ((!tester && (!send || !named)) || (tester && !AGENT_ROUTES[path].tester) || !agentWrite) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return agentSend(req, res, { route: AGENT_ROUTES[path], agentWrite, preview: writer, log });
|
||||
}
|
||||
if (path === '/api/agent/request') {
|
||||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||||
if ((!tester && (!send || !named)) || !agentWrite) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return agentRequest(res, { agentWrite, search });
|
||||
}
|
||||
if (path === '/api/routes') {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
if ((!tester && (!send || !named)) || !routesWrite) return json(res, 403, { error: 'read-only', sent: false });
|
||||
@@ -686,18 +544,18 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
}
|
||||
if (path === '/api/project-file' && req.method === 'POST') {
|
||||
if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return projectFileSend(req, res, { projectSave, preview: signer, log });
|
||||
return projectFileSend(req, res, { projectSave, preview, log });
|
||||
}
|
||||
// Message sending has its own bounded payload and repeats the identity check.
|
||||
if (send && path === '/api/bus/message') {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
return busSend(req, res, { send, preview: signer, log });
|
||||
return busSend(req, res, { send, preview, log });
|
||||
}
|
||||
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
|
||||
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
|
||||
}
|
||||
// Nothing else but GET and HEAD gets past here in preview mode, whatever the Host.
|
||||
if ((preview !== null || sso !== null) && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
|
||||
if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
|
||||
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
|
||||
const conversation = path === '/api/conversations' || path === '/api/conversation';
|
||||
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
|
||||
@@ -749,8 +607,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
|
||||
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
|
||||
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
if (!file) return json(res, 404, { error: 'not found' });
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
// Q175 phase 1 (#1690): the shape of config.json's optional `console` key. The one loader,
|
||||
// scripts/mosaic-config.mjs, runs this on every read, so a bad key refuses the config for every
|
||||
// tool (design section 4, C1). The Console's --sso start runs it again before it uses the value.
|
||||
// Shape only: nothing here touches the network or a slot file. A refusal names the key path and
|
||||
// never quotes a value, so a refusal can't print a client id or a subject.
|
||||
|
||||
// The asymmetric algorithms oauth4webapi 3.8.8 verifies (its supported(), build/index.js). No HS*
|
||||
// and no none, so a client secret can never be a signing key.
|
||||
export const ALGS = Object.freeze(['PS256', 'ES256', 'RS256', 'PS384', 'ES384', 'RS384', 'PS512', 'ES512', 'RS512', 'Ed25519', 'EdDSA', 'ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87']);
|
||||
const PROVIDER_ID = /^[a-z0-9-]{1,32}$/;
|
||||
const SLOT = /^[a-z0-9-]{1,64}$/;
|
||||
const SUB = /^[\x21-\x7e]{1,255}$/;
|
||||
const CLIENT_ID = /^[\x21-\x7e]{1,255}$/;
|
||||
// The name goes where the preview's --allow-user went (serve.mjs USER): visible ASCII, no comma.
|
||||
const NAME = /^[\x21-\x2b\x2d-\x7e]{1,64}$/;
|
||||
// The bus keeps these for the tester and the human role (section 6), so no subject can sign in as one.
|
||||
const RESERVED_NAMES = ['tester', 'human'];
|
||||
|
||||
const object = v => typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
function refuse(where, why) {
|
||||
throw new Error(`${where} ${why}`);
|
||||
}
|
||||
function keys(value, where, allowed, required = allowed) {
|
||||
if (!object(value)) refuse(where, 'must be a JSON object');
|
||||
for (const key of Object.keys(value)) if (!allowed.includes(key)) refuse(`${where}.${key}`, 'is not a supported key');
|
||||
for (const key of required) if (!Object.hasOwn(value, key)) refuse(`${where}.${key}`, 'is required');
|
||||
}
|
||||
function httpsURL(value, where) {
|
||||
let url;
|
||||
if (typeof value !== 'string' || value.length > 512) refuse(where, 'must be an https URL of at most 512 characters');
|
||||
try { url = new URL(value); } catch { refuse(where, 'must be an https URL'); }
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || value.includes('?') || value.includes('#')) {
|
||||
refuse(where, 'must be https with no credentials, query or fragment');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
// Returns the value unchanged when it passes; throws an Error naming the bad key path otherwise.
|
||||
export function checkConsole(value, where = 'console') {
|
||||
keys(value, where, ['sso']);
|
||||
const sso = value.sso, at = `${where}.sso`;
|
||||
keys(sso, at, ['publicOrigin', 'providers']);
|
||||
const origin = httpsURL(sso.publicOrigin, `${at}.publicOrigin`);
|
||||
if (origin.origin !== sso.publicOrigin) refuse(`${at}.publicOrigin`, 'must be an https origin with no path, query, fragment or trailing slash');
|
||||
if (!Array.isArray(sso.providers) || sso.providers.length === 0 || sso.providers.length > 8) refuse(`${at}.providers`, 'must be a list of 1 to 8 providers');
|
||||
const ids = new Set();
|
||||
sso.providers.forEach((p, i) => {
|
||||
const pat = `${at}.providers[${i}]`;
|
||||
keys(p, pat, ['id', 'issuer', 'clientId', 'clientSecret', 'algs', 'subjects']);
|
||||
if (typeof p.id !== 'string' || !PROVIDER_ID.test(p.id)) refuse(`${pat}.id`, 'must match [a-z0-9-]{1,32}');
|
||||
if (ids.has(p.id)) refuse(`${pat}.id`, 'repeats an earlier provider id');
|
||||
ids.add(p.id);
|
||||
httpsURL(p.issuer, `${pat}.issuer`);
|
||||
if (typeof p.clientId !== 'string' || !CLIENT_ID.test(p.clientId)) refuse(`${pat}.clientId`, 'must be 1 to 255 visible characters');
|
||||
keys(p.clientSecret, `${pat}.clientSecret`, ['slot']);
|
||||
if (typeof p.clientSecret.slot !== 'string' || !SLOT.test(p.clientSecret.slot)) refuse(`${pat}.clientSecret.slot`, 'must match [a-z0-9-]{1,64}');
|
||||
if (!Array.isArray(p.algs) || p.algs.length === 0) refuse(`${pat}.algs`, 'must be a non-empty list');
|
||||
p.algs.forEach((alg, j) => {
|
||||
if (!ALGS.includes(alg)) refuse(`${pat}.algs[${j}]`, `must be one of ${ALGS.join(', ')}; HS* and none are refused`);
|
||||
});
|
||||
if (new Set(p.algs).size !== p.algs.length) refuse(`${pat}.algs`, 'repeats an algorithm');
|
||||
if (!Array.isArray(p.subjects) || p.subjects.length === 0 || p.subjects.length > 64) refuse(`${pat}.subjects`, 'must be a list of 1 to 64 subjects');
|
||||
const subs = new Set();
|
||||
p.subjects.forEach((s, j) => {
|
||||
const sat = `${pat}.subjects[${j}]`;
|
||||
keys(s, sat, ['sub', 'name']);
|
||||
if (typeof s.sub !== 'string' || !SUB.test(s.sub)) refuse(`${sat}.sub`, 'must be 1 to 255 visible characters');
|
||||
if (subs.has(s.sub)) refuse(`${sat}.sub`, 'repeats an earlier subject');
|
||||
subs.add(s.sub);
|
||||
if (typeof s.name !== 'string' || !NAME.test(s.name)) refuse(`${sat}.name`, 'must be 1 to 64 visible characters');
|
||||
if (RESERVED_NAMES.includes(s.name.toLowerCase())) refuse(`${sat}.name`, `must not be ${RESERVED_NAMES.join(' or ')}`);
|
||||
});
|
||||
});
|
||||
return value;
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md): the Console as an OIDC relying
|
||||
// party. Used only by the human listener under --sso; startServer refuses it on the tester listener.
|
||||
// Every URL handed to a provider is built from the configured publicOrigin, never from a request's
|
||||
// Host. A refusal logs one fixed code and the provider id, never an exception, its message or its
|
||||
// cause: oauth4webapi's errors carry token bodies and claims there.
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, relative, isAbsolute, resolve } from 'node:path';
|
||||
import { checkConsole } from './sso-config.mjs';
|
||||
|
||||
// oauth4webapi loads in createSso, not here: cli.mjs imports this module on every start, and a
|
||||
// Console without --sso must not need the package (pinned-copy.test.mjs builds copies whose lock
|
||||
// installs nothing). A real copy installs it from the root lock.
|
||||
let oauth;
|
||||
|
||||
export const SESSION_COOKIE = '__Host-mosaic_session';
|
||||
export const LOGIN_COOKIE = '__Host-mosaic_login';
|
||||
const ATTRS = 'Path=/; Secure; HttpOnly; SameSite=Lax';
|
||||
const LOGIN_MAX_AGE = 600;
|
||||
const SESSION_ABSOLUTE = 8 * 3600 * 1000, SESSION_IDLE = 3600 * 1000;
|
||||
const MAX_SESSIONS = 64, MAX_LOGINS = 32;
|
||||
const FETCH_TIMEOUT = 10000;
|
||||
const RETRY_CAP = 300000;
|
||||
const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'discovery-failed', 'jwks-failed']);
|
||||
|
||||
const sha256 = value => createHash('sha256').update(value).digest('hex');
|
||||
const escape = text => String(text).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
||||
const under = (parent, child) => { const rel = relative(parent, child); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
|
||||
|
||||
// The slot file holding a provider's client secret (design section 4, D1). Only the Console's --sso
|
||||
// start calls this. A refusal names the provider and the check, never the path's contents.
|
||||
export function readSlot(configDir, provider, { repo, checkout = null, copies = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'mosaic-stack', 'copies') } = {}) {
|
||||
const where = `console.sso provider ${provider.id} client secret slot`;
|
||||
const file = join(resolve(configDir), 'secrets', 'mosaic-stack', provider.clientSecret.slot);
|
||||
let st;
|
||||
try { st = lstatSync(file); } catch { throw new Error(`${where} is missing`); }
|
||||
if (st.isSymbolicLink()) throw new Error(`${where} must not be a symbolic link`);
|
||||
let fd, real, text;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
st = fstatSync(fd);
|
||||
if (!st.isFile()) throw new Error(`${where} must be a regular file`);
|
||||
if (st.uid !== process.getuid()) throw new Error(`${where} must be owned by the Console's user`);
|
||||
if (st.mode & 0o077) throw new Error(`${where} must have no group or other permission bits`);
|
||||
real = realpathSync(file);
|
||||
for (const root of [repo, checkout, copies].filter(Boolean)) {
|
||||
let base;
|
||||
try { base = realpathSync(root); } catch { base = resolve(root); }
|
||||
if (under(base, real)) throw new Error(`${where} must not be inside the repository or a stack copy`);
|
||||
}
|
||||
text = readFileSync(fd, 'utf8');
|
||||
} catch (err) {
|
||||
throw err.message?.startsWith(where) ? err : new Error(`${where} is not readable`);
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
const secret = text.endsWith('\n') ? text.slice(0, -1) : text;
|
||||
if (!secret || /[\r\n]/.test(secret)) throw new Error(`${where} must hold one non-empty line`);
|
||||
return secret;
|
||||
}
|
||||
|
||||
export function cookies(req, name) {
|
||||
const found = [];
|
||||
for (const part of (req.headers.cookie ?? '').split(';')) {
|
||||
const at = part.indexOf('=');
|
||||
if (at > 0 && part.slice(0, at).trim() === name) found.push(part.slice(at + 1).trim());
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// The JWKS check at discovery, the same filter the library's key selection uses (kty, alg, use,
|
||||
// key_ops, crv; RSA at least 2048 bits), without a kid since no token is at hand yet.
|
||||
function usableKey(jwk, alg) {
|
||||
const kty = { RS: 'RSA', PS: 'RSA', ES: 'EC', Ed: 'OKP', ML: 'AKP' }[alg.slice(0, 2)];
|
||||
if (jwk === null || typeof jwk !== 'object' || jwk.kty !== kty) return false;
|
||||
if (jwk.alg !== undefined && jwk.alg !== alg) return false;
|
||||
if (jwk.use !== undefined && jwk.use !== 'sig') return false;
|
||||
if (Array.isArray(jwk.key_ops) && !jwk.key_ops.includes('verify')) return false;
|
||||
const crv = { ES256: 'P-256', ES384: 'P-384', ES512: 'P-521', Ed25519: 'Ed25519', EdDSA: 'Ed25519' }[alg];
|
||||
if (crv && jwk.crv !== crv) return false;
|
||||
if (kty === 'RSA' && (typeof jwk.n !== 'string' || Buffer.from(jwk.n, 'base64url').length * 8 < 2048)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
class Unreachable extends Error {}
|
||||
class Refused extends Error {}
|
||||
const https = value => { try { return new URL(value).protocol === 'https:'; } catch { return false; } };
|
||||
|
||||
// One provider's discovery and JWKS. Throws Unreachable (network, timeout, a status other than 200,
|
||||
// a body that isn't JSON) or Refused (a JSON document that fails a check). Q-B, design section 4.
|
||||
async function discover(p, fetchOptions) {
|
||||
let response, text;
|
||||
const options = () => ({ ...fetchOptions, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
||||
try {
|
||||
response = await oauth.discoveryRequest(new URL(p.issuer), options());
|
||||
if (response.status !== 200 || !/^application\/json\b/i.test(response.headers.get('content-type') ?? '')) throw new Error();
|
||||
text = await response.text();
|
||||
JSON.parse(text);
|
||||
} catch { throw new Unreachable(); }
|
||||
let as;
|
||||
try {
|
||||
as = await oauth.processDiscoveryResponse(new URL(p.issuer), new Response(text, { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
} catch { throw new Refused(); }
|
||||
if (as.issuer !== p.issuer) throw new Refused();
|
||||
if (!Array.isArray(as.id_token_signing_alg_values_supported) || !p.algs.some(a => as.id_token_signing_alg_values_supported.includes(a))) throw new Refused();
|
||||
if (!Array.isArray(as.code_challenge_methods_supported) || !as.code_challenge_methods_supported.includes('S256')) throw new Refused();
|
||||
if (![as.authorization_endpoint, as.token_endpoint, as.jwks_uri].every(https)) throw new Refused();
|
||||
if (as.end_session_endpoint !== undefined && !https(as.end_session_endpoint)) throw new Refused();
|
||||
let jwks;
|
||||
try {
|
||||
const fetcher = fetchOptions[oauth.customFetch] ?? fetch;
|
||||
const res = await fetcher(as.jwks_uri, { method: 'GET', headers: { accept: 'application/json, application/jwk-set+json' }, redirect: 'manual', signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
||||
if (res.status !== 200) throw new Error();
|
||||
jwks = JSON.parse(await res.text());
|
||||
} catch { throw new Unreachable(); }
|
||||
if (jwks === null || typeof jwks !== 'object' || !Array.isArray(jwks.keys)) throw new Refused();
|
||||
const usable = p.algs.filter(alg => jwks.keys.some(k => usableKey(k, alg)));
|
||||
if (!usable.length) throw new Refused();
|
||||
return as;
|
||||
}
|
||||
|
||||
// `config` is console.sso, checked again here; `secrets` maps provider id to its client secret
|
||||
// (readSlot). `fetch` is for tests: a fetch that trusts only the fake OP's certificate.
|
||||
// `retryFirst` is for tests too; production waits 5 s, doubling, capped at 5 minutes.
|
||||
export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000 } = {}) {
|
||||
checkConsole({ sso: config });
|
||||
oauth ??= await import('oauth4webapi');
|
||||
const publicOrigin = config.publicOrigin;
|
||||
const publicHost = new URL(publicOrigin).host;
|
||||
const redirectUri = `${publicOrigin}/auth/callback`;
|
||||
const fetchOptions = customFetch ? { [oauth.customFetch]: customFetch } : {};
|
||||
const sessions = new Map(), logins = new Map();
|
||||
const timers = new Set();
|
||||
let closed = false;
|
||||
const refuse = (code, provider, extra = '') => {
|
||||
if (!CODES.has(code)) throw new Error(`unknown sso log code ${code}`);
|
||||
log(`${new Date().toISOString()} sso refused code=${code} provider=${provider}${extra}`);
|
||||
};
|
||||
|
||||
const providers = new Map(config.providers.map(p => {
|
||||
if (typeof secrets?.get(p.id) !== 'string') throw new Error(`console.sso provider ${p.id} has no client secret`);
|
||||
return [p.id, {
|
||||
config: p, state: 'unreachable', as: null, jwks: {},
|
||||
client: { client_id: p.clientId, id_token_signed_response_alg: [...p.algs] },
|
||||
auth: oauth.ClientSecretBasic(secrets.get(p.id)),
|
||||
subjects: new Map(p.subjects.map(s => [s.sub, s.name])),
|
||||
}];
|
||||
}));
|
||||
|
||||
async function attempt(entry, atStart) {
|
||||
try {
|
||||
entry.as = await discover(entry.config, fetchOptions);
|
||||
entry.jwks = {};
|
||||
entry.state = 'ready';
|
||||
} catch (err) {
|
||||
if (err instanceof Refused) {
|
||||
entry.state = 'refused';
|
||||
refuse('discovery-failed', entry.config.id);
|
||||
if (atStart) throw new Error(`console.sso provider ${entry.config.id} refused at discovery: the issuer, algorithms, S256 or JWKS check failed`);
|
||||
return;
|
||||
}
|
||||
entry.state = 'unreachable';
|
||||
refuse('discovery-failed', entry.config.id);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
function retry(entry, delay) {
|
||||
if (closed) return;
|
||||
const timer = setTimeout(async () => {
|
||||
timers.delete(timer);
|
||||
try { await attempt(entry, false); } catch { retry(entry, Math.min(delay * 2, RETRY_CAP)); }
|
||||
}, delay);
|
||||
timer.unref();
|
||||
timers.add(timer);
|
||||
}
|
||||
for (const entry of providers.values()) {
|
||||
try { await attempt(entry, true); } catch (err) {
|
||||
if (!(err instanceof Unreachable)) throw err;
|
||||
retry(entry, retryFirst);
|
||||
}
|
||||
}
|
||||
|
||||
const page = (res, status, title, html, headers = {}) => {
|
||||
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers });
|
||||
res.end(`<!doctype html><meta charset="utf-8"><title>${escape(title)}</title><main style="font-family:sans-serif;max-width:32rem;margin:4rem auto"><h1>${escape(title)}</h1>${html}</main>\n`);
|
||||
};
|
||||
const redirect = (res, location, setCookie = []) => {
|
||||
res.writeHead(303, { location, 'cache-control': 'no-store', ...(setCookie.length ? { 'set-cookie': setCookie } : {}) });
|
||||
res.end();
|
||||
};
|
||||
const failed = (res, setCookie = []) => page(res, 403, 'Sign-in failed', '<p><a href="/auth/login">Sign in again</a></p>', setCookie.length ? { 'set-cookie': setCookie } : {});
|
||||
const expire = name => `${name}=; ${ATTRS}; Max-Age=0`;
|
||||
const remember = (map, key, value, cap) => {
|
||||
while (map.size >= cap) map.delete(map.keys().next().value);
|
||||
map.set(key, value);
|
||||
};
|
||||
|
||||
// B2: only a URL on publicOrigin itself; anything else, or nothing, is publicOrigin's `/`. The
|
||||
// Location is absolute and its origin checked again: dot segments can leave a pathname of `//host`,
|
||||
// which as a relative Location a browser reads as another origin (webby-0019 B1, sage-1403).
|
||||
function returnURL(ret) {
|
||||
const home = `${publicOrigin}/`;
|
||||
try {
|
||||
if (typeof ret !== 'string' || !ret) return home;
|
||||
const u = new URL(ret, publicOrigin);
|
||||
if (u.origin !== publicOrigin) return home;
|
||||
const out = new URL(`${publicOrigin}${u.pathname}${u.search}`);
|
||||
return out.origin === publicOrigin ? out.href : home;
|
||||
} catch { return home; }
|
||||
}
|
||||
|
||||
async function start(res, entry, ret) {
|
||||
const state = oauth.generateRandomState(), nonce = oauth.generateRandomNonce(), verifier = oauth.generateRandomCodeVerifier();
|
||||
const challenge = await oauth.calculatePKCECodeChallenge(verifier);
|
||||
const id = randomBytes(32).toString('base64url');
|
||||
remember(logins, sha256(id), { provider: entry.config.id, state, nonce, verifier, ret: typeof ret === 'string' ? ret.slice(0, 2048) : '/', expires: Date.now() + LOGIN_MAX_AGE * 1000 }, MAX_LOGINS);
|
||||
const url = new URL(entry.as.authorization_endpoint);
|
||||
for (const [k, v] of Object.entries({ client_id: entry.config.clientId, response_type: 'code', scope: 'openid profile email', redirect_uri: redirectUri, state, nonce, code_challenge: challenge, code_challenge_method: 'S256' })) url.searchParams.set(k, v);
|
||||
return redirect(res, url.href, [`${LOGIN_COOKIE}=${id}; ${ATTRS}; Max-Age=${LOGIN_MAX_AGE}`]);
|
||||
}
|
||||
|
||||
function login(res, query) {
|
||||
const asked = query.get('provider');
|
||||
const ret = query.get('return');
|
||||
if (asked === null && providers.size > 1) {
|
||||
const items = [...providers.values()].map(e => `<li><a href="/auth/login?${escape(new URLSearchParams({ provider: e.config.id, ...(ret ? { return: ret } : {}) }))}">${escape(e.config.id)}</a></li>`).join('');
|
||||
return page(res, 200, 'Sign in', `<ul>${items}</ul>`);
|
||||
}
|
||||
const entry = asked === null ? [...providers.values()][0] : providers.get(asked);
|
||||
if (!entry) return page(res, 404, 'Unknown sign-in provider', '<p><a href="/auth/login">Sign in</a></p>');
|
||||
if (entry.state !== 'ready') return page(res, 503, 'sign-in unavailable', '<p>The sign-in provider can\'t be reached. Try again shortly.</p>');
|
||||
return start(res, entry, ret);
|
||||
}
|
||||
|
||||
// Design section 1, steps 2-7. Each refusal ends here with one log line and the 403 page.
|
||||
async function callback(res, query, req) {
|
||||
const clear = [expire(LOGIN_COOKIE)];
|
||||
const ids = cookies(req, LOGIN_COOKIE);
|
||||
const key = ids.length === 1 ? sha256(ids[0]) : null;
|
||||
const record = key === null ? undefined : logins.get(key);
|
||||
if (key !== null) logins.delete(key);
|
||||
// The provider comes from the record, never from the query.
|
||||
const entry = record && providers.get(record.provider);
|
||||
if (!record || record.expires <= Date.now() || !entry) {
|
||||
refuse('login-record-missing', record?.provider ?? 'none');
|
||||
return failed(res, clear);
|
||||
}
|
||||
const pid = entry.config.id;
|
||||
if (entry.state !== 'ready') { refuse('discovery-failed', pid); return failed(res, clear); }
|
||||
let params;
|
||||
try { params = oauth.validateAuthResponse(entry.as, entry.client, query, record.state); } catch (err) {
|
||||
refuse(err instanceof oauth.AuthorizationResponseError ? 'auth-error-response' : 'state-mismatch', pid);
|
||||
return failed(res, clear);
|
||||
}
|
||||
const options = () => ({ ...fetchOptions, [oauth.jwksCache]: entry.jwks, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
||||
let response;
|
||||
try { response = await oauth.authorizationCodeGrantRequest(entry.as, entry.client, entry.auth, params, redirectUri, record.verifier, options()); } catch {
|
||||
refuse('grant-failed', pid); return failed(res, clear);
|
||||
}
|
||||
let result, claims;
|
||||
try {
|
||||
result = await oauth.processAuthorizationCodeResponse(entry.as, entry.client, response, { expectedNonce: record.nonce, requireIdToken: true });
|
||||
claims = oauth.getValidatedIdTokenClaims(result);
|
||||
} catch (err) {
|
||||
refuse(err instanceof oauth.ResponseBodyError ? 'grant-failed' : 'id-token-invalid', pid);
|
||||
return failed(res, clear);
|
||||
}
|
||||
// Step 5: the library skips this for a token-endpoint ID token; point 2 needs it, on the same
|
||||
// Response object. Its throw ends the callback.
|
||||
try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) {
|
||||
const fetching = !(err instanceof oauth.OperationProcessingError || err instanceof oauth.UnsupportedOperationError) || [oauth.RESPONSE_IS_NOT_CONFORM, oauth.RESPONSE_IS_NOT_JSON].includes(err.code);
|
||||
refuse(fetching ? 'jwks-failed' : 'signature-invalid', pid);
|
||||
return failed(res, clear);
|
||||
}
|
||||
// Step 6: our own checks on top of the library's.
|
||||
let header;
|
||||
try { header = JSON.parse(Buffer.from(result.id_token.split('.')[0], 'base64url').toString('utf8')); } catch { header = null; }
|
||||
if (!header || !entry.config.algs.includes(header.alg)) { refuse('alg-refused', pid); return failed(res, clear); }
|
||||
if (claims.iss !== entry.config.issuer) { refuse('issuer-mismatch', pid); return failed(res, clear); }
|
||||
const name = typeof claims.sub === 'string' ? entry.subjects.get(claims.sub) : undefined;
|
||||
if (name === undefined) {
|
||||
refuse('subject-unknown', pid, ` iss=${JSON.stringify(String(claims.iss).slice(0, 255))} sub=${JSON.stringify(String(claims.sub).slice(0, 255))}`);
|
||||
return failed(res, clear);
|
||||
}
|
||||
// Step 7: the session, then the return path.
|
||||
const id = randomBytes(32).toString('base64url'), now = Date.now();
|
||||
remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS);
|
||||
return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]);
|
||||
}
|
||||
|
||||
// The session for this request, or null. `touch` is true only for a page load or a write (Q-D).
|
||||
function session(req, { touch = false } = {}) {
|
||||
const ids = cookies(req, SESSION_COOKIE);
|
||||
if (ids.length !== 1) return null;
|
||||
const key = sha256(ids[0]);
|
||||
const s = sessions.get(key);
|
||||
if (!s) return null;
|
||||
const now = Date.now();
|
||||
if (now - s.created >= SESSION_ABSOLUTE || now - s.seen >= SESSION_IDLE) { sessions.delete(key); return null; }
|
||||
if (touch) s.seen = now;
|
||||
return s;
|
||||
}
|
||||
|
||||
function logout(req, res) {
|
||||
const one = name => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
||||
if (one('origin') !== publicOrigin || one('sec-fetch-site') !== 'same-origin') {
|
||||
return page(res, 403, 'Sign-out refused', '<p>Sign out from the Console page.</p>');
|
||||
}
|
||||
const ids = cookies(req, SESSION_COOKIE);
|
||||
const key = ids.length === 1 ? sha256(ids[0]) : null;
|
||||
const s = key === null ? undefined : sessions.get(key);
|
||||
if (key !== null) sessions.delete(key);
|
||||
const entry = s && providers.get(s.provider);
|
||||
let location = `${publicOrigin}/auth/signed-out`;
|
||||
if (entry?.as?.end_session_endpoint) {
|
||||
const url = new URL(entry.as.end_session_endpoint);
|
||||
url.searchParams.set('id_token_hint', s.idToken);
|
||||
url.searchParams.set('post_logout_redirect_uri', `${publicOrigin}/auth/signed-out`);
|
||||
url.searchParams.set('client_id', entry.config.clientId);
|
||||
location = url.href;
|
||||
}
|
||||
return redirect(res, location, [expire(SESSION_COOKIE)]);
|
||||
}
|
||||
|
||||
// `/auth/*` on the human listener; the caller has already checked the Host.
|
||||
async function handle(req, res, path, search) {
|
||||
const query = new URLSearchParams(search);
|
||||
if (path === '/auth/logout') {
|
||||
if (req.method !== 'POST') return page(res, 405, 'Method not allowed', '');
|
||||
return logout(req, res);
|
||||
}
|
||||
if (req.method !== 'GET') return page(res, 405, 'Method not allowed', '');
|
||||
if (path === '/auth/login') return login(res, query);
|
||||
if (path === '/auth/callback') return callback(res, query, req);
|
||||
if (path === '/auth/signed-out') return page(res, 200, 'Signed out', '<p><a href="/auth/login">Sign in</a></p>');
|
||||
return page(res, 404, 'Not found', '');
|
||||
}
|
||||
|
||||
return {
|
||||
publicOrigin, publicHost, sessions, logins, handle, session, returnURL,
|
||||
health: () => Object.fromEntries([...providers].map(([id, e]) => [id, e.state])),
|
||||
formTargets: () => [...new Set([...providers.values()].filter(e => e.as?.end_session_endpoint).map(e => new URL(e.as.end_session_endpoint).origin))],
|
||||
summary: () => config.providers.map(p => ({ id: p.id, issuer: p.issuer, subjects: p.subjects.length })),
|
||||
close() { closed = true; for (const t of timers) clearTimeout(t); timers.clear(); },
|
||||
};
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// Row 194 (#1692, D7, T16): Settings' Agents section. Read-only mode shows every action unavailable
|
||||
// with the instance CLI named. In send mode a launch posts once and follows its request until it
|
||||
// closes; regenerate and delete take a second click and name what stays; a terminate carries its
|
||||
// reason; a refusal shows the bus's message and nothing is retried.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {browser} from './browser.mjs';
|
||||
import {startServer} from '../src/serve.mjs';
|
||||
import {close} from './fixture.mjs';
|
||||
|
||||
const hostile='<img src=x onerror="window.injected=true">';
|
||||
const REV='a'.repeat(64), ID='0b5e8f1c-3a2d-4c6e-9f10-1234567890ab';
|
||||
const view={business:'acme',revision:REV,launch:true,noHire:['reviewer'],retired:['old-coder'],
|
||||
instances:{coder:{definition:'coder',vars:{},launched:true,launcher:false,protected:false,kept:false,running:['run-1']},[hostile]:{definition:'coder',vars:{},launched:false,launcher:false,protected:false,kept:true,running:[]},assistant:{definition:'assistant',vars:{},launched:false,launcher:false,protected:true,kept:false,running:[]}},
|
||||
definitions:{assistant:{services:[],accepted:false},coder:{services:[],accepted:true},reviewer:{services:[],accepted:true}}};
|
||||
|
||||
test('row 194 T16 Settings Agents: unavailable read-only, launch follows its request, confirms, reason and refusal',{timeout:90000},async()=>{
|
||||
const bus={messages:async()=>({messages:[],unread:0}),usage:async()=>{throw Error('no usage');},instances:async()=>view};
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{}})}}),b=await browser();
|
||||
try{
|
||||
// POSTs answer from `replies`, request reads from `polls`; both record what was asked.
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.replies=[];window.polls=[];window.reads=[];window.enabled=new URL(location.href).searchParams.has('send');const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(options.method==='POST'&&/^\\/api\\/(agent|veto|instances)/.test(url)){posts.push([url,JSON.parse(options.body)]);const [status,body]=replies.shift();return new Response(JSON.stringify(body),{status});}if(String(url).startsWith('/api/agent/request')){reads.push(url);const [status,body]=polls.shift()??[200,{request:'${ID}',action:'launch',instance:'coder',state:'open',done:null,cancelled:null}];return new Response(JSON.stringify(body),{status});}return original(url,options);};`});
|
||||
const port=web.address().port;
|
||||
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<400;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
|
||||
const receipt=()=>b.evaluate('document.querySelector("#agents-receipt").textContent');
|
||||
const click=sel=>b.evaluate(`(()=>{document.querySelector(${JSON.stringify(sel)}).click();return true;})()`);
|
||||
|
||||
// Read-only: the table, the unavailable line, every action disabled, nothing injected.
|
||||
await b.navigate(`http://127.0.0.1:${port}/#/settings`);
|
||||
await wait('!!document.querySelector("#agents-table")');
|
||||
assert.deepEqual(await b.evaluate("[...document.querySelectorAll('#agents-table tbody tr')].map(r=>[...r.querySelectorAll('th,td')].slice(0,5).map(c=>c.textContent))"),[
|
||||
['coder','coder','yes','no','run-1'],[hostile,'coder','no','yes','none'],['assistant','assistant','no','no','none'],
|
||||
]);
|
||||
assert.equal(await b.evaluate('window.injected===true'),false);
|
||||
assert.equal(await b.evaluate('document.querySelector("#agents-unavailable").textContent'),'Agent actions are unavailable here. Use mosaic business instance from the instance CLI.');
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#agents-section button,#agents-section input,#agents-section select")].every(e=>e.disabled)'),true);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#agents-section [data-agent-action]").length>0'),true);
|
||||
// The protected assistant has no Delete; the kept instance offers Unkeep; reviewer is no-hire.
|
||||
assert.equal(await b.evaluate('!!document.querySelector(\'[data-agent-action="delete"][data-agent-name="assistant"]\')'),false);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-agent-action="unkeep"]').dataset.agentName`),hostile);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-agent-action="hire"]').dataset.agentName`),'reviewer');
|
||||
assert.match(await b.evaluate('document.querySelector("#agents-section").textContent'),/Retired names, never reused: old-coder\./);
|
||||
|
||||
// Send mode: a launch posts once, shows the request and follows it to its close.
|
||||
await b.navigate(`http://127.0.0.1:${port}/?send=1#/settings`);
|
||||
await wait(`document.querySelector('[data-agent-action="launch"][data-agent-name="coder"]')?.disabled===false`);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#agents-unavailable")'),false);
|
||||
await b.evaluate(`replies.push([200,{request:'${ID}',at:'2026-10-20T00:00:00Z'}]);polls.push([200,{request:'${ID}',action:'launch',instance:'coder',state:'taken',done:null,cancelled:null}],[200,{request:'${ID}',action:'launch',instance:'coder',state:'closed',done:{run:'run-2',transaction:null,refusal:null},cancelled:null}]);true`);
|
||||
await click('[data-agent-action="launch"][data-agent-name="coder"]');
|
||||
await wait('!!document.querySelector("#agents-pending")');
|
||||
assert.match(await b.evaluate('document.querySelector("#agents-pending").textContent'),new RegExp(`Request ${ID} to launch coder`));
|
||||
assert.equal(await b.evaluate('document.querySelector(\'[data-agent-action="launch"][data-agent-name="coder"]\').disabled'),true,'one request at a time');
|
||||
await wait('document.querySelector("#agents-receipt")?.textContent==="Launched coder (run run-2)."');
|
||||
assert.deepEqual(await b.evaluate('posts'),[['/api/agent/launch',{instance:'coder'}]]);
|
||||
assert.deepEqual(await b.evaluate('reads'),[`/api/agent/request?id=${ID}`,`/api/agent/request?id=${ID}`]);
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#agents-pending")'),false);
|
||||
|
||||
// Regenerate and delete: the first click only asks; Cancel asks nothing; the confirm posts.
|
||||
await click('[data-agent-action="regenerate"][data-agent-name="coder"]');
|
||||
await wait('!!document.querySelector("[data-agent-confirmed]")');
|
||||
assert.match(await b.evaluate('document.querySelector(".agent-confirm").textContent'),/Regenerate coder\?/);
|
||||
await click('[data-agent-cancel]');
|
||||
await wait('!document.querySelector("[data-agent-confirmed]")');
|
||||
await click('[data-agent-action="delete"][data-agent-name="coder"]');
|
||||
await wait('!!document.querySelector("[data-agent-confirmed]")');
|
||||
assert.match(await b.evaluate('document.querySelector(".agent-confirm").textContent'),/Its evidence and its workspace under \.retired stay\./);
|
||||
assert.equal(await b.evaluate('posts.length'),1,'asking posts nothing');
|
||||
await b.evaluate(`replies.push([200,{request:'${ID}',at:'x'}]);polls.push([200,{request:'${ID}',action:'delete',instance:'coder',state:'closed',done:{run:null,transaction:'t-9',refusal:null},cancelled:null}]);true`);
|
||||
await click('[data-agent-confirmed]');
|
||||
await wait('document.querySelector("#agents-receipt")?.textContent==="Deleted coder."');
|
||||
assert.deepEqual(await b.evaluate('posts.at(-1)'),['/api/instances',{revision:REV,instance:'coder',op:'delete'}]);
|
||||
|
||||
// Terminate carries the typed reason; a closed refusal shows its reason.
|
||||
await b.evaluate(`(()=>{const e=document.querySelector('[data-agent-reason="coder"]');e.value='done for today';e.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
|
||||
await b.evaluate(`replies.push([200,{request:'${ID}',runs:['run-1'],cancelled:[],taken:[]}]);polls.push([200,{request:'${ID}',action:'end',instance:'coder',state:'closed',done:{run:null,transaction:null,refusal:'not-held'},cancelled:null,reason:'the run no longer holds the instance'}]);true`);
|
||||
await click('[data-agent-action="end"][data-agent-name="coder"]');
|
||||
await wait('document.querySelector("#agents-receipt")?.textContent==="coder: the run no longer holds the instance."');
|
||||
assert.deepEqual(await b.evaluate('posts.at(-1)'),['/api/agent/terminate',{instance:'coder',reason:'done for today'}]);
|
||||
|
||||
// A refusal shows the bus's message and follows nothing; Keep posts the read's revision.
|
||||
await b.evaluate(`replies.push([400,{error:'no-route',message:'no-route: set a route in Routing first. Nothing changed.',sent:false}]);true`);
|
||||
const before=await b.evaluate('reads.length');
|
||||
await click('[data-agent-action="launch"][data-agent-name="coder"]');
|
||||
await wait('document.querySelector("#agents-receipt")?.textContent.startsWith("no-route:")');
|
||||
assert.equal(await b.evaluate('reads.length'),before);
|
||||
await b.evaluate(`replies.push([200,{...${JSON.stringify(view)},transaction:'t-2',restart:false}]);true`);
|
||||
await click('[data-agent-action="keep"][data-agent-name="coder"]');
|
||||
await wait('document.querySelector("#agents-receipt")?.textContent==="Kept coder."');
|
||||
assert.deepEqual(await b.evaluate('posts.at(-1)'),['/api/veto',{revision:REV,op:'keep',name:'coder'}]);
|
||||
// An unknown outcome says so and is not retried.
|
||||
await b.evaluate(`replies.push([503,{error:'outcome-unknown',sent:'unknown'}]);true`);
|
||||
const sent=await b.evaluate('posts.length');
|
||||
await click('[data-agent-action="nohire"][data-agent-name="coder"]');
|
||||
await wait('document.querySelector("#agents-receipt")?.textContent.startsWith("Outcome unknown")');
|
||||
assert.equal(await b.evaluate('posts.length'),sent+1);
|
||||
}finally{await b.close();await close(web);}
|
||||
});
|
||||
@@ -1,205 +0,0 @@
|
||||
// Row 194 (#1692, D7, T16): the Console's Agents routes. Launch, terminate, regenerate and veto each
|
||||
// have instanceSend's boundary and make one call through agentWrite; delete joins add and change on
|
||||
// /api/instances; the page follows a request at /api/agent/request. The tester listener lets launch,
|
||||
// terminate and the request read through (D4.1) and refuses regenerate and veto with no bus call.
|
||||
// Every write here uses a fixture writer; the bus verbs' own checks are in the bus tests.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
|
||||
const preview = { host: 'console.example', header: 'x-user', user: 'jason' };
|
||||
const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
||||
const REV = 'a'.repeat(64);
|
||||
const ID = '0b5e8f1c-3a2d-4c6e-9f10-1234567890ab';
|
||||
const read = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), launch: true, instances: { coder: { definition: 'coder', vars: {}, launched: true, launcher: false, protected: false, kept: false } }, definitions: {}, noHire: [], retired: [], ...extra });
|
||||
const ANSWERS = {
|
||||
'instance.launch': () => ({ request: ID }),
|
||||
'instance.regenerate': () => ({ request: ID }),
|
||||
'instance.end': () => ({ request: ID, runs: ['r-1'], cancelled: [], taken: [] }),
|
||||
'veto.set': () => ({ ...read(), transaction: 't-1', restart: false }),
|
||||
'instance.request': args => ({ request: args.request, action: 'launch', instance: 'coder', by: 'jason', at: '2026-10-20T00:00:00.000Z', state: 'closed', done: { run: 'r-2', transaction: null, refusal: null }, cancelled: null }),
|
||||
};
|
||||
|
||||
async function setup(t, { on = true, agent = async (verb, args) => ANSWERS[verb](args), instances = async args => ({ request: ID, args }), tester = false } = {}) {
|
||||
const calls = [], lines = [];
|
||||
const server = await startServer({
|
||||
port: 0, log: line => lines.push(line),
|
||||
...(tester ? { tester: true } : { preview, send: on ? async () => ({}) : null }),
|
||||
agentWrite: agent ? async (verb, args) => { calls.push([verb, args]); return agent(verb, args); } : null,
|
||||
instancesWrite: instances ? async args => { calls.push(['instances.set', args]); return instances(args); } : null,
|
||||
});
|
||||
t.after(() => new Promise(r => server.close(r)));
|
||||
const port = server.address().port;
|
||||
const hs = tester ? { host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } : headers;
|
||||
return { calls, lines, port, headers: hs };
|
||||
}
|
||||
const send = (s, path, data, hs = s.headers, method = 'POST') => new Promise((resolve, reject) => {
|
||||
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
|
||||
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, type: r.headers['content-type'], text, body: (() => { try { return JSON.parse(text); } catch { return null; } })() }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(data === undefined ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
|
||||
});
|
||||
const get = (s, path, hs = s.headers) => send(s, path, undefined, hs, 'GET');
|
||||
|
||||
test('row 194 T16: each agent action makes one call with only its keys, logs the user and answers the request', async t => {
|
||||
const s = await setup(t);
|
||||
const launched = await send(s, '/api/agent/launch', { instance: 'coder' });
|
||||
assert.equal(launched.status, 200, launched.text);
|
||||
assert.equal(launched.body.request, ID); assert.match(launched.body.at, /^\d{4}-/);
|
||||
const regenerated = await send(s, '/api/agent/regenerate', { instance: 'coder' });
|
||||
assert.equal(regenerated.status, 200, regenerated.text);
|
||||
const ended = await send(s, '/api/agent/terminate', { instance: 'coder', reason: 'secret reason text' });
|
||||
assert.equal(ended.status, 200, ended.text); assert.deepEqual(ended.body.runs, ['r-1']);
|
||||
const bare = await send(s, '/api/agent/terminate', { instance: 'coder' });
|
||||
assert.equal(bare.status, 200, bare.text);
|
||||
const kept = await send(s, '/api/veto', { revision: REV, op: 'keep', name: 'coder' });
|
||||
assert.equal(kept.status, 200, kept.text); assert.equal(kept.body.transaction, 't-1');
|
||||
assert.deepEqual(s.calls, [
|
||||
['instance.launch', { instance: 'coder' }], ['instance.regenerate', { instance: 'coder' }],
|
||||
['instance.end', { instance: 'coder', reason: 'secret reason text' }], ['instance.end', { instance: 'coder' }],
|
||||
['veto.set', { revision: REV, op: 'keep', name: 'coder' }],
|
||||
]);
|
||||
assert.deepEqual(s.lines.map(l => l.replace(/^\S+ /, '')), [
|
||||
`launch user="jason" instance="coder" done request=${ID}`, `regenerate user="jason" instance="coder" done request=${ID}`,
|
||||
`terminate user="jason" instance="coder" done request=${ID}`, `terminate user="jason" instance="coder" done request=${ID}`,
|
||||
'veto user="jason" instance="coder" done',
|
||||
]);
|
||||
assert.ok(s.lines.every(l => !l.includes('secret reason text')), 'a reason is never logged');
|
||||
});
|
||||
|
||||
test('row 194 T16: the boundary refuses before any bus call', async t => {
|
||||
const s = await setup(t);
|
||||
const cases = [
|
||||
['/api/agent/launch', { instance: 'coder' }, { ...headers, 'x-user': 'mallory' }, 401],
|
||||
['/api/agent/launch', { instance: 'coder' }, { ...headers, origin: 'https://evil.example' }, 403],
|
||||
['/api/agent/launch', { instance: 'coder' }, { ...headers, 'sec-fetch-site': 'cross-site' }, 403],
|
||||
['/api/agent/launch', { instance: 'coder', extra: 'x' }, headers, 400],
|
||||
['/api/agent/launch', {}, headers, 400],
|
||||
['/api/agent/launch', { instance: 7 }, headers, 400],
|
||||
['/api/agent/launch', ['coder'], headers, 400],
|
||||
['/api/agent/launch', 'not json', headers, 400],
|
||||
['/api/agent/launch', { instance: 'x'.repeat(17000) }, headers, 400],
|
||||
['/api/agent/terminate', { instance: 'coder', reason: 3 }, headers, 400],
|
||||
['/api/agent/regenerate', { instance: 'coder', reason: 'no' }, headers, 400],
|
||||
['/api/veto', { revision: REV, op: 'fire', name: 'coder' }, headers, 400],
|
||||
['/api/veto', { revision: 'nope', op: 'keep', name: 'coder' }, headers, 400],
|
||||
['/api/veto', { op: 'keep', name: 'coder' }, headers, 400],
|
||||
];
|
||||
for (const [path, body, hs, status] of cases) {
|
||||
const r = await send(s, path, body, hs);
|
||||
assert.equal(r.status, status, `${path} ${JSON.stringify(body).slice(0, 60)}: ${r.text}`);
|
||||
}
|
||||
for (const path of ['/api/agent/launch', '/api/agent/terminate', '/api/agent/regenerate', '/api/veto']) {
|
||||
assert.equal((await get(s, path)).status, 405, path);
|
||||
}
|
||||
assert.equal((await send(s, '/api/agent/request', { request: ID })).status, 405);
|
||||
assert.deepEqual(s.calls, []);
|
||||
});
|
||||
|
||||
test('row 194 T16: read-only when send is off or no writer is wired; no route has a --send exception', async t => {
|
||||
for (const options of [{ on: false }, { agent: null }]) {
|
||||
const s = await setup(t, options);
|
||||
for (const path of ['/api/agent/launch', '/api/agent/terminate', '/api/agent/regenerate', '/api/veto']) {
|
||||
const r = await send(s, path, { instance: 'coder' });
|
||||
assert.equal(r.status, 403, path); assert.equal(r.body.error, 'read-only');
|
||||
}
|
||||
const r = await get(s, `/api/agent/request?id=${ID}`);
|
||||
assert.equal(r.status, 403); assert.equal(r.body.error, 'read-only');
|
||||
assert.deepEqual(s.calls, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('row 194 T16: a bus refusal names its reason and nothing changed; an unknown outcome or a bad shape is never retried', async t => {
|
||||
let answer;
|
||||
const s = await setup(t, { agent: async () => answer() });
|
||||
answer = () => { throw new BusReadError('no-route'); };
|
||||
const refused = await send(s, '/api/agent/launch', { instance: 'coder' });
|
||||
assert.equal(refused.body.sent, false); assert.equal(refused.body.error, 'no-route');
|
||||
assert.match(refused.body.message, /^no-route: .+\. Nothing changed\.$/);
|
||||
assert.doesNotMatch(refused.body.message, /refused by the bus/, 'no-route has its own reason');
|
||||
answer = () => { throw new BusReadError('instance-running'); };
|
||||
const running = await send(s, '/api/agent/regenerate', { instance: 'coder' });
|
||||
assert.equal(running.body.error, 'instance-running'); assert.equal(running.body.sent, false);
|
||||
answer = () => { throw new BusReadError('outcome-unknown'); };
|
||||
const unknown = await send(s, '/api/agent/launch', { instance: 'coder' });
|
||||
assert.deepEqual([unknown.status, unknown.body.error, unknown.body.sent], [503, 'outcome-unknown', 'unknown']);
|
||||
for (const [path, body, bad] of [
|
||||
['/api/agent/launch', { instance: 'coder' }, { request: 'not-an-id' }],
|
||||
['/api/agent/terminate', { instance: 'coder' }, { request: ID, runs: 'r-1', cancelled: [], taken: [] }],
|
||||
['/api/veto', { revision: REV, op: 'nohire', name: 'coder' }, { revision: REV }],
|
||||
]) {
|
||||
answer = () => bad;
|
||||
const r = await send(s, path, body);
|
||||
assert.deepEqual([r.status, r.body.error, r.body.sent], [503, 'outcome-unknown', 'unknown'], path);
|
||||
}
|
||||
assert.equal(s.calls.length, 6, 'one call per POST, nothing retried');
|
||||
});
|
||||
|
||||
test('row 194 T16: the request read takes one id, adds the refusal reason and refuses an unexpected shape', async t => {
|
||||
let answer = ANSWERS['instance.request'];
|
||||
const s = await setup(t, { agent: async (verb, args) => answer(args) });
|
||||
const closed = await get(s, `/api/agent/request?id=${ID}`);
|
||||
assert.equal(closed.status, 200, closed.text);
|
||||
assert.deepEqual([closed.body.state, closed.body.done.run, closed.body.reason], ['closed', 'r-2', undefined]);
|
||||
answer = args => ({ ...ANSWERS['instance.request'](args), done: { run: null, transaction: null, refusal: 'no-route' } });
|
||||
assert.match((await get(s, `/api/agent/request?id=${ID}`)).body.reason, /route/);
|
||||
answer = args => ({ ...ANSWERS['instance.request'](args), done: null, cancelled: { by: 'jason' } });
|
||||
assert.ok((await get(s, `/api/agent/request?id=${ID}`)).body.reason);
|
||||
answer = args => ({ ...ANSWERS['instance.request'](args), state: 'taken', done: null });
|
||||
assert.equal((await get(s, `/api/agent/request?id=${ID}`)).body.state, 'taken');
|
||||
assert.deepEqual(s.calls.map(c => c[0]), Array(4).fill('instance.request'));
|
||||
for (const query of ['', '?id=nope', `?id=${ID}&id=${ID}`, `?id=${ID}&x=1`, `?id=${ID.toUpperCase()}`]) {
|
||||
const r = await get(s, `/api/agent/request${query}`);
|
||||
assert.equal(r.status, 400, query); assert.equal(r.body.error, 'invalid-request');
|
||||
}
|
||||
assert.equal(s.calls.length, 4, 'a bad id makes no call');
|
||||
answer = () => { throw new BusReadError('unknown-request'); };
|
||||
const unknown = await get(s, `/api/agent/request?id=${ID}`);
|
||||
assert.equal(unknown.body.error, 'unknown-request');
|
||||
for (const bad of [args => ({ ...ANSWERS['instance.request'](args), request: '0'.repeat(36) }), args => ({ ...ANSWERS['instance.request'](args), state: 'done' }), () => null]) {
|
||||
answer = bad;
|
||||
const r = await get(s, `/api/agent/request?id=${ID}`);
|
||||
assert.deepEqual([r.status, r.body.error], [502, 'invalid-response']);
|
||||
}
|
||||
});
|
||||
|
||||
test('row 194 T16: delete joins add and change on /api/instances with only revision, instance and op', async t => {
|
||||
const s = await setup(t);
|
||||
const deleted = await send(s, '/api/instances', { revision: REV, instance: 'coder', op: 'delete' });
|
||||
assert.equal(deleted.status, 200, deleted.text); assert.equal(deleted.body.request, ID);
|
||||
assert.deepEqual(s.calls, [['instances.set', { revision: REV, instance: 'coder', op: 'delete' }]]);
|
||||
assert.match(s.lines.at(-1), / instance user="jason" instance="coder" done request=/);
|
||||
for (const extra of [{ definition: 'coder' }, { vars: {} }, { launch: false }]) {
|
||||
const r = await send(s, '/api/instances', { revision: REV, instance: 'coder', op: 'delete', ...extra });
|
||||
assert.equal(r.status, 400, JSON.stringify(extra));
|
||||
}
|
||||
assert.equal(s.calls.length, 1);
|
||||
});
|
||||
|
||||
test('row 194 T16 D4.1: the tester listener launches, terminates and reads requests; regenerate, veto and delete are refused with no call', async t => {
|
||||
const s = await setup(t, { tester: true });
|
||||
assert.equal((await send(s, '/api/agent/launch', { instance: 'coder' })).status, 200);
|
||||
assert.equal((await send(s, '/api/agent/terminate', { instance: 'coder' })).status, 200);
|
||||
assert.equal((await get(s, `/api/agent/request?id=${ID}`)).status, 200);
|
||||
for (const [path, body] of [['/api/agent/regenerate', { instance: 'coder' }], ['/api/veto', { revision: REV, op: 'keep', name: 'coder' }],
|
||||
['/api/instances', { revision: REV, instance: 'coder', op: 'delete' }]]) {
|
||||
const r = await send(s, path, body);
|
||||
assert.equal(r.status, 403, path);
|
||||
assert.equal(r.body.sent, false, path);
|
||||
}
|
||||
assert.deepEqual(s.calls.map(c => c[0]), ['instance.launch', 'instance.end', 'instance.request']);
|
||||
assert.ok(s.lines.every(l => l.includes('user="tester"')));
|
||||
});
|
||||
|
||||
test('row 194 N5: agents.js is served as JavaScript and index.html loads it once', async t => {
|
||||
const s = await setup(t);
|
||||
const js = await get(s, '/agents.js', { host: 'console.example', 'x-user': 'jason' });
|
||||
assert.equal(js.status, 200); assert.match(js.type, /^text\/javascript/);
|
||||
assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section \}\)/);
|
||||
assert.match(js.text, /Agent actions are unavailable here\. Use mosaic business instance from the instance CLI\./);
|
||||
const html = await get(s, '/', { host: 'console.example', 'x-user': 'jason' });
|
||||
assert.equal(html.text.match(/<script src="\/agents\.js" defer><\/script>/g)?.length, 1);
|
||||
});
|
||||
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
// Rows 129, 154 and 194: chat.js, editor.js and agents.js are the scripts added, same origin; the list is the pin, so another script is a visible change.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/agents.js', '/bus.js']);
|
||||
// Rows 129 and 154: chat.js and editor.js are the scripts added, same origin; the list is the pin, so another script is a visible change.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/bus.js']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
// Project filtering does not hide another project's waiting requests.
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
// Q175 phase 1 (#1690, design section 9): a fake OpenID provider for the --sso tests. A loopback
|
||||
// https server with a self-signed certificate made in a temp dir; `op.fetch` is a fetch that trusts
|
||||
// only that certificate, so the code under test needs no insecure flag. It serves any number of
|
||||
// issuers (`/<name>/`) at once, each with discovery, a JWKS, authorization, token and end-session
|
||||
// endpoints. Keys come from node:crypto. Nothing here reaches any other host.
|
||||
import { constants, createHash, createHmac, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { createServer, request } from 'node:https';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const b64url = value => Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)).toString('base64url');
|
||||
|
||||
export function rsaKey(kid = `rsa-${randomBytes(4).toString('hex')}`, bits = 2048) {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: bits });
|
||||
return { kid, alg: 'RS256', privateKey, jwk: { ...publicKey.export({ format: 'jwk' }), kid, use: 'sig', alg: 'RS256' } };
|
||||
}
|
||||
export function ecKey(kid = `ec-${randomBytes(4).toString('hex')}`) {
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
return { kid, alg: 'ES256', privateKey, jwk: { ...publicKey.export({ format: 'jwk' }), kid, use: 'sig', alg: 'ES256' } };
|
||||
}
|
||||
export function psKey(kid = `ps-${randomBytes(4).toString('hex')}`) {
|
||||
const k = rsaKey(kid);
|
||||
return { ...k, alg: 'PS256', jwk: { ...k.jwk, alg: 'PS256' } };
|
||||
}
|
||||
|
||||
// A compact JWS. `alg` HS256 uses `hmac` as the key, `none` has an empty signature.
|
||||
export function jwt(header, payload, { key = null, hmac = null } = {}) {
|
||||
const input = `${b64url(header)}.${b64url(payload)}`;
|
||||
let sig = '';
|
||||
if (header.alg === 'HS256') sig = createHmac('sha256', hmac).update(input).digest('base64url');
|
||||
else if (header.alg === 'RS256') sig = sign('sha256', Buffer.from(input), key).toString('base64url');
|
||||
else if (header.alg === 'PS256') sig = sign('sha256', Buffer.from(input), { key, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }).toString('base64url');
|
||||
else if (header.alg === 'ES256') sig = sign('sha256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' }).toString('base64url');
|
||||
return `${input}.${sig}`;
|
||||
}
|
||||
|
||||
function certificate() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'fake-oidc-'));
|
||||
try {
|
||||
const r = spawnSync('openssl', ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-days', '1',
|
||||
'-subj', '/CN=127.0.0.1', '-addext', 'subjectAltName=IP:127.0.0.1', '-keyout', join(dir, 'key.pem'), '-out', join(dir, 'cert.pem')], { encoding: 'utf8' });
|
||||
if (r.status !== 0) throw new Error(`openssl failed: ${r.stderr}`);
|
||||
return { key: readFileSync(join(dir, 'key.pem')), cert: readFileSync(join(dir, 'cert.pem')) };
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
|
||||
// One issuer's state. `discovery` is merged over the defaults; `mode` is 'ok', 'down' (the socket is
|
||||
// destroyed) or 'html' (200 with an HTML page) for discovery.
|
||||
function issuerState(name, base, opts) {
|
||||
const keys = opts.keys ?? [rsaKey()];
|
||||
return {
|
||||
name, issuer: `${base}/${name}/`, keys, retired: [], clientId: opts.clientId ?? `client-${name}`, clientSecret: opts.clientSecret ?? `secret-${name}-${randomBytes(6).toString('hex')}`,
|
||||
discovery: opts.discovery ?? {}, mode: 'ok', jwksOverride: null, codes: new Map(), acceptTwice: false, requirePkce: true,
|
||||
tokenRequests: [], authorizeRequests: [], jwksFetches: 0, discoveryFetches: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fakeOidc() {
|
||||
const { key, cert } = certificate();
|
||||
const issuers = new Map();
|
||||
const server = createServer({ key, cert }, (req, res) => {
|
||||
const url = new URL(req.url, 'https://127.0.0.1');
|
||||
const [, name, ...rest] = url.pathname.split('/');
|
||||
const iss = issuers.get(name);
|
||||
const route = rest.join('/');
|
||||
const json = (status, body, type = 'application/json') => { res.writeHead(status, { 'content-type': type }); res.end(typeof body === 'string' ? body : JSON.stringify(body)); };
|
||||
if (!iss) return json(404, { error: 'no issuer' });
|
||||
if (route === '.well-known/openid-configuration') {
|
||||
iss.discoveryFetches++;
|
||||
if (iss.mode === 'down') return req.socket.destroy();
|
||||
if (iss.mode === 'html') return json(200, '<!doctype html><title>Sign in</title>', 'text/html');
|
||||
return json(200, discovery(iss));
|
||||
}
|
||||
if (route === 'jwks') {
|
||||
iss.jwksFetches++;
|
||||
return json(200, iss.jwksOverride ?? { keys: iss.keys.map(k => k.jwk) });
|
||||
}
|
||||
if (route === 'authorize') { iss.authorizeRequests.push(Object.fromEntries(url.searchParams)); return json(200, { ok: true }); }
|
||||
if (route === 'end-session') return json(200, { ok: true });
|
||||
if (route === 'token' && req.method === 'POST') {
|
||||
let text = '';
|
||||
req.on('data', c => (text += c));
|
||||
req.on('end', () => {
|
||||
const form = new URLSearchParams(text);
|
||||
const basic = Buffer.from((req.headers.authorization ?? '').replace(/^Basic /, ''), 'base64').toString();
|
||||
const [id, secret] = basic.split(':').map(decodeURIComponent);
|
||||
iss.tokenRequests.push({ form: Object.fromEntries(form), clientId: id });
|
||||
if (id !== iss.clientId || secret !== iss.clientSecret) return json(401, { error: 'invalid_client' });
|
||||
const grant = iss.codes.get(form.get('code'));
|
||||
if (!grant) return json(400, { error: 'invalid_grant' });
|
||||
if (!iss.acceptTwice) iss.codes.delete(form.get('code'));
|
||||
if (form.get('redirect_uri') !== grant.redirectUri) return json(400, { error: 'invalid_grant', error_description: 'redirect_uri' });
|
||||
if (iss.requirePkce && createHash('sha256').update(form.get('code_verifier') ?? '').digest('base64url') !== grant.challenge) return json(400, { error: 'invalid_grant', error_description: 'pkce' });
|
||||
return json(200, { access_token: randomBytes(16).toString('hex'), token_type: 'Bearer', expires_in: 300, id_token: grant.idToken });
|
||||
});
|
||||
return;
|
||||
}
|
||||
return json(404, { error: 'not found' });
|
||||
});
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||
const base = `https://127.0.0.1:${server.address().port}`;
|
||||
|
||||
const discovery = iss => ({
|
||||
issuer: iss.issuer, authorization_endpoint: `${iss.issuer}authorize`, token_endpoint: `${iss.issuer}token`, jwks_uri: `${iss.issuer}jwks`,
|
||||
end_session_endpoint: `${iss.issuer}end-session`, response_types_supported: ['code'], subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: [...new Set(iss.keys.map(k => k.alg))], code_challenge_methods_supported: ['S256'],
|
||||
...iss.discovery,
|
||||
});
|
||||
|
||||
// A fetch over node:https that trusts only this certificate. oauth4webapi passes a Headers object
|
||||
// and a string or URLSearchParams body.
|
||||
const opFetch = (input, init = {}) => new Promise((resolve, reject) => {
|
||||
const url = new URL(typeof input === 'string' ? input : input.href ?? input.url);
|
||||
if (url.protocol !== 'https:' || url.hostname !== '127.0.0.1' || url.port !== String(server.address().port)) return reject(new TypeError(`fake OP fetch refuses ${url.origin}`));
|
||||
const headers = Object.fromEntries(new Headers(init.headers ?? {}));
|
||||
const body = init.body === undefined || init.body === null ? undefined : String(init.body);
|
||||
if (body !== undefined) headers['content-length'] = Buffer.byteLength(body);
|
||||
const req = request(url, { method: init.method ?? 'GET', headers, ca: cert, signal: init.signal }, res => {
|
||||
const chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const h = new Headers();
|
||||
for (const [k, v] of Object.entries(res.headers)) h.set(k, Array.isArray(v) ? v.join(', ') : v);
|
||||
const response = new Response(res.statusCode === 204 ? null : Buffer.concat(chunks), { status: res.statusCode, headers: h });
|
||||
Object.defineProperty(response, 'url', { value: url.href });
|
||||
resolve(response);
|
||||
});
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
|
||||
return {
|
||||
base, fetch: opFetch, cert,
|
||||
issuer(name, opts = {}) { const iss = issuerState(name, base, opts); issuers.set(name, iss); return iss; },
|
||||
// A code for `iss` whose ID token is built from the start redirect's nonce, the claims and header
|
||||
// overrides. `challenge` defaults to the start redirect's; `idToken` replaces the token outright.
|
||||
grant(iss, authorizeUrl, { sub = `user-${iss.name}`, claims = {}, header = {}, key = iss.keys[0], idToken = null, challenge = null, redirectUri = null } = {}) {
|
||||
const q = new URL(authorizeUrl).searchParams;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = { iss: iss.issuer, sub, aud: iss.clientId, iat: now, exp: now + 300, nonce: q.get('nonce'), ...claims };
|
||||
for (const [k, v] of Object.entries(payload)) if (v === undefined) delete payload[k];
|
||||
const token = idToken ?? jwt({ alg: key.alg, kid: key.kid, typ: 'JWT', ...header }, payload, { key: key.privateKey, hmac: iss.clientSecret });
|
||||
const code = randomBytes(12).toString('base64url');
|
||||
iss.codes.set(code, { idToken: token, challenge: challenge ?? q.get('code_challenge'), redirectUri: redirectUri ?? q.get('redirect_uri') });
|
||||
return { code, idToken: token, payload };
|
||||
},
|
||||
rotate(iss, next = rsaKey()) { iss.retired.push(...iss.keys); iss.keys = [next]; return next; },
|
||||
close: () => new Promise(r => { server.close(r); server.closeAllConnections(); }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// Row 165 (#1662, Q171, REQ-ROLE-3): the Console's role instance read and write. The write has the
|
||||
// Settings boundary and makes one human-transport call, `instances.set`; the broker's and the
|
||||
// writer's own checks are in the bus and business tests. The first test runs a real broker over a
|
||||
// temporary organization directory, so the add reaches the file, the log and `validate`. Row 194
|
||||
// (#1692, D3): that broker boots from the file through bootConfig, so the add goes live with no restart.
|
||||
// temporary organization directory, so the add reaches the file, the log and `validate`.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
@@ -14,8 +13,8 @@ import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
import { BusError } from '../../bus/src/broker.mjs';
|
||||
import { startBroker } from '../../bus/src/runtime.mjs';
|
||||
import { systemFor } from '../../business/tests/helpers.mjs';
|
||||
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
|
||||
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
|
||||
|
||||
const BUSINESS_CLI = fileURLToPath(new URL('../../business/src/cli.mjs', import.meta.url));
|
||||
const preview = { host: 'console.example', header: 'x-user', user: 'jason' };
|
||||
@@ -23,7 +22,7 @@ const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://c
|
||||
const REV = 'a'.repeat(64);
|
||||
const view = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), launch: true, instances: { coder: { definition: 'coder', vars: {}, launched: true, launcher: false, protected: false } }, definitions: {}, ...extra });
|
||||
|
||||
async function setup(t, { on = true, writer = on, write = async args => ({ ...view(), instances: { [args.instance]: { definition: 'merge-gate', vars: {}, launched: false, launcher: false, protected: false } }, transaction: 't-1', restart: false }), bus = null } = {}) {
|
||||
async function setup(t, { on = true, writer = on, write = async args => ({ ...view(), instances: { [args.instance]: { definition: 'merge-gate', vars: {}, launched: false, launcher: false, protected: false } }, transaction: 't-1', restart: true }), bus = null } = {}) {
|
||||
const calls = [], lines = [];
|
||||
const server = await startServer({
|
||||
port: 0, preview, bus, log: line => lines.push(line), send: on ? async () => ({}) : null,
|
||||
@@ -40,12 +39,15 @@ const send = (s, path, data, hs = headers, method = 'POST') => new Promise((reso
|
||||
req.end(data === undefined ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
|
||||
});
|
||||
|
||||
test('row 165 Console: the signed-in human adds an instance through a real broker; validate exits 0, the log names the human and no restart is needed (row 194)', async t => {
|
||||
test('row 165 Console: the signed-in human adds an instance through a real broker; validate exits 0 and the log names the human', async t => {
|
||||
const root = tmp(t, 'webui-instances-');
|
||||
const f = fixture(root, 'acme');
|
||||
const dir = join(root, 'config');
|
||||
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
|
||||
const host = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
|
||||
const host = await startBroker({
|
||||
dataRoot: f.dataRoot,
|
||||
businesses: { acme: { id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir, rolesDir: f.roles, system: systemFor(root) },
|
||||
});
|
||||
t.after(() => host.close());
|
||||
const human = host.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
// The human transport's shape: an answer, or a BusReadError with the broker's code.
|
||||
@@ -66,10 +68,10 @@ test('row 165 Console: the signed-in human adds an instance through a real broke
|
||||
|
||||
const added = await send(s, '/api/instances', { revision: current.revision, instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
|
||||
assert.equal(added.status, 200, JSON.stringify(added.body));
|
||||
assert.equal(added.body.restart, false);
|
||||
assert.equal(added.body.restart, true);
|
||||
assert.equal(added.body.instances['sec-1'].launched, true);
|
||||
assert.deepEqual(s.calls, [{ revision: current.revision, instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true }]);
|
||||
assert.match(s.lines.at(-1), / instance user="jason" instance="sec-1" done restart=false$/);
|
||||
assert.match(s.lines.at(-1), / instance user="jason" instance="sec-1" done restart=true$/);
|
||||
const entry = log().at(-1);
|
||||
assert.deepEqual([entry.actor, entry.state, entry.transaction], ['jason', 'committed', added.body.transaction]);
|
||||
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).roles['sec-1'], { definition: 'security-reviewer' });
|
||||
|
||||
@@ -30,7 +30,7 @@ function fakeCopy(dir) {
|
||||
writeFileSync(join(copyRoot(dir), 'packages', 'webui', 'src', 'cli.mjs'), '');
|
||||
return copyRoot(dir);
|
||||
}
|
||||
const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\) or --sso$/m;
|
||||
const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\)$/m;
|
||||
const TEMPLATE = readFileSync(join(REPO, 'packages', 'webui', 'systemd', 'mosaic-webui.service.in'), 'utf8');
|
||||
|
||||
// The unit as the base script renders it (row 75): the template with its four
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
// Q175 phase 1 (#1690, design test 24, sage-1340 F1, sage-1347 F7): the page under --sso. A read
|
||||
// the Console answers 401 `sign-in required` signs the page out the way a proxy redirect does, and
|
||||
// the line links to /auth/login with the current path and query; any other 401 stays an error. Both
|
||||
// fetch paths in app.js and both in bus.js (fetchRead and fetchApi) do it. The Sign out form shows
|
||||
// only when /api/config says sso. The board-held.test.mjs stub, with two lists of paths: those in
|
||||
// window.__signIn answer 401 `sign-in required`, those in window.__other 401 `unauthenticated`. A
|
||||
// page URL holding `sso` gets `sso: true` added to /api/config. Poll timers fire after 150 ms.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../../bus/src/store.mjs';
|
||||
import { Broker } from '../../bus/src/broker.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { browser } from './browser.mjs';
|
||||
import { fixture, close } from './fixture.mjs';
|
||||
|
||||
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: { pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, cto: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
|
||||
|
||||
const stub = `(() => {
|
||||
const real = window.fetch.bind(window);
|
||||
window.__reqs = []; window.__signIn = []; window.__other = [];
|
||||
const timeout = window.setTimeout.bind(window);
|
||||
window.setTimeout = (f, ms, ...rest) => timeout(f, ms === 10000 ? 150 : ms, ...rest);
|
||||
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||
window.fetch = async (url, init = {}) => {
|
||||
const path = String(url).split('?')[0];
|
||||
window.__reqs.push(path);
|
||||
if (window.__signIn.includes(path)) return answer(401, { error: 'sign-in required' });
|
||||
if (window.__other.includes(path)) return answer(401, { error: 'unauthenticated' });
|
||||
if (path === '/api/config' && /sso/.test(location.search)) return answer(200, { ...(await (await real(url, init)).json()), sso: true });
|
||||
return real(url, init);
|
||||
};
|
||||
})();`;
|
||||
|
||||
async function setup() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'webui-sso-browser-'));
|
||||
const store = new Store(root), b0 = new Broker({ store, businesses });
|
||||
b0.request(b0.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi', address: 'pm-run' }), { verb: 'role.claim' });
|
||||
const reader = b0.bindReader({ business: 'demo' });
|
||||
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
|
||||
const f = await fixture(), b = await browser({ machine: true });
|
||||
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
|
||||
await b.viewport(1280);
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
|
||||
const shown = sel => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && !!e.getClientRects().length})()`);
|
||||
const reqs = () => b.evaluate('window.__reqs.length');
|
||||
const settle = () => new Promise(r => setTimeout(r, 600));
|
||||
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
|
||||
return { b, base, wait, text, shown, reqs, settle, done };
|
||||
}
|
||||
|
||||
const LOADED = 'document.querySelector("table.sessions [data-open=\'proj/agent1\']")';
|
||||
const LINE = 'Signed out. Sign in again.';
|
||||
|
||||
test('a 401 sign-in required on each fetch path signs the page out with a sign-in link and stops the refresh; another 401 stays an error', { timeout: 120000 }, async () => {
|
||||
const { b, base, wait, text, shown, reqs, settle, done } = await setup();
|
||||
// The line, its link back to this page, the Sign out form gone, and no further fetch.
|
||||
const out = async ret => {
|
||||
await wait('!document.querySelector("#signed-out").hidden');
|
||||
assert.equal(await text('#signed-out'), LINE);
|
||||
assert.equal(await b.evaluate('document.querySelector("#signed-out a").getAttribute("href")'), `/auth/login?return=${encodeURIComponent(ret)}`);
|
||||
assert.equal(await shown('#sign-out'), false);
|
||||
assert.equal(await b.evaluate('document.querySelector("#refresh").disabled'), true);
|
||||
const n = await reqs();
|
||||
await b.evaluate('document.querySelector("#refresh").click(), true');
|
||||
await settle();
|
||||
assert.equal(await reqs(), n, 'the page fetched after signing out');
|
||||
};
|
||||
try {
|
||||
// app.js api(): the Board read, with sso on from /api/config.
|
||||
await b.navigate(`${base}?sso&x=1`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__signIn.push('/api/board'), document.querySelector('#refresh').click(), true`);
|
||||
await out('/?sso&x=1');
|
||||
|
||||
// app.js getJSON: the conversation catalogue behind History.
|
||||
await b.navigate(`${base}?sso&conv`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__signIn.push('/api/conversations'), document.querySelector('table.sessions [data-history="proj/agent1"]').click(), true`);
|
||||
await out('/?sso&conv');
|
||||
|
||||
// app.js api(): the board POST behind Seen.
|
||||
await b.navigate(`${base}?sso&seen`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`);
|
||||
await wait('document.querySelector("#inspection [data-seen]")');
|
||||
await b.evaluate(`window.__signIn.push('/api/seen'), document.querySelector('#inspection [data-seen]').click(), true`);
|
||||
await out('/?sso&seen');
|
||||
|
||||
// bus.js fetchRead and fetchApi, on a page whose /api/config has no sso: the link comes from
|
||||
// the 401 itself (F1).
|
||||
await b.navigate(`${base}?read#/inbox`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__signIn.push('/api/bus/inbox'), document.querySelector('#refresh').click(), true`);
|
||||
await out('/?read');
|
||||
await b.navigate(`${base}?module`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__signIn.push('/api/queue'), location.hash = '#/queue', true`);
|
||||
await out('/?module');
|
||||
|
||||
// Any other 401 is an error: no sign-out, and the polls go on.
|
||||
await b.navigate(`${base}?sso&other`);
|
||||
await wait(LOADED);
|
||||
await b.evaluate(`window.__other.push('/api/board', '/api/bus/inbox', '/api/queue'), document.querySelector('#refresh').click(), true`);
|
||||
await settle();
|
||||
assert.equal(await b.evaluate('document.querySelector("#signed-out").hidden'), true);
|
||||
assert.equal(await b.evaluate('document.body.classList.contains("signed-out")'), false);
|
||||
const n = await reqs();
|
||||
await settle();
|
||||
assert.ok(await reqs() > n, 'the refresh stopped on another 401');
|
||||
assert.equal(await shown('#sign-out'), true);
|
||||
} finally { await done(); }
|
||||
});
|
||||
|
||||
test('the Sign out form shows only when /api/config says sso, and posts to /auth/logout', { timeout: 120000 }, async () => {
|
||||
const { b, base, wait, shown, done } = await setup();
|
||||
try {
|
||||
await b.navigate(base);
|
||||
await wait(LOADED);
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
assert.equal(await shown('#sign-out'), false);
|
||||
await b.navigate(`${base}?sso`);
|
||||
await wait(`${LOADED} && !document.querySelector("#sign-out").hidden`);
|
||||
assert.equal(await shown('#sign-out'), true);
|
||||
assert.deepEqual(await b.evaluate('(() => { const f = document.querySelector("#sign-out"); return [f.method, f.getAttribute("action"), f.querySelector("button").textContent, f.querySelector("button").type]; })()'), ['post', '/auth/logout', 'Sign out', 'submit']);
|
||||
} finally { await done(); }
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user