Compare commits

...
Author SHA1 Message Date
jason.woltje d35447005a Isolate invalid workspace reads and cancel timed-out org work 2026-11-09 18:22:27 -06:00
jason.woltje 2109458b3f feat(webui,cli): U1 cross-organization read model and console verbs (#1873)
GET /api/workspace on the dev-local-owner Console: one snapshot of
projects, sessions, inbox, tasks and per-org metrics across every
organization the owner belongs to. Membership reuses the org list's
admission (organizations.members); each org is read over the same
per-org options the org pages use, with one deadline per org (4000 ms)
and a state (ok, down, starting, timeout, invalid) with a time. Every
row carries its org id; decide commands carry --org. Granted users
and the tester get 403 owner-only. Polling at 10 s, no push.

mosaic console restart and mosaic org reload <id> call the Console's
F3 routes (POST /api/console/restart, POST /api/orgs/<id>/restart/bus)
so gating, proof and trail are the Console's own; both refuse inside
an agent run first.

The cross-org assistant read is designed and stopped: it needs a new
broker read verb or role authority.
2026-11-09 17:54:06 -06:00
jason.woltje 1616d1dd6f docs: BUILD-LOG and session line for PRD 1.20 and defect l (#1873, #1886) 2026-11-09 17:38:26 -06:00
jason.woltje 7a97cfc4f3 fix(bus): make the private store root at start when a reference names the store (#1886)
Adding an agent with a store credential on an instance with no
<configDir>/secrets/agents left the bus unable to start
(credential-file): Credentials refuses a missing store root, and only
issue() made it, which needs a running bus. Start now makes the root
(0700) the same way issue() does, only when some reference has
store: true. Credentials' own refusal (credential-store T1) is unchanged,
and a root with loose modes or a symlink still refuses start.

Tests: two new credential-verbs tests; the creation test fails on
483eccb3d with credential-file. All 171 bus test files run one at a
time: 1713 pass, 0 fail.
2026-11-09 17:10:09 -06:00
jason.woltje 03fe321b12 docs(prd): land version 1.20, the unified workspace (#1873)
Jason approved draft 2 on 2026-11-09 at 22:57Z: "The file looks fine
other than that." The exception is the Montage module name, deferred
since 19:26Z. Amends REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3
and adds 17 requirements (101 to 118): REQ-WS-1..8, REQ-AGENT-1..3,
REQ-SYS-1..2, REQ-ACCT-1..2, REQ-SETUP-1..2. requirements.json and the
tasks and bus count pins follow.

Tests: 68 pass, 1 fail. The failure is stack-copy.test.mjs (20/1), which
fails the same way on origin/refactor 483eccb3d: npm's pinned install
can't find node-gyp/bin/node-gyp.js in the test Node installation.
2026-11-09 17:04:31 -06:00
jason.woltje 483eccb3dd docs: BUILD-LOG and session line for organizations F3 (#1873) 2026-11-09 16:45:56 -06:00
jason.woltje a8f9f09573 Merge remote-tracking branch 'origin/refactor' into org-f3-merge 2026-11-09 14:07:52 -06:00
jason.woltje 0a73279d62 fix(webui): F3 review round: instances trail line, honest dev-mode README
The instances route now passes the owner trail note, so an organization
instance change appends the actions.jsonl line the README promises (P3).

The README claimed no agent reaches the F3 controls. On a dev local owner
Console that is false: every local request acts as the owner, which the
pinned startup warning already says. The README now states it and names
sign-in as the boundary; a local-agent peer refusal is a follow-up (P1).
2026-11-09 14:07:52 -06:00
jason.woltje 999d173fd4 Merge refactor fdd6658a6 into F3 (E2E5 fixes)
app.js busDown conflict: refactor's order (org list read before
bus-status, E2E5 review P2) with F3's Restart the bus attachment on
the Bus unavailable banner.
2026-11-09 13:54:46 -06:00
jason.woltje e98909d907 Merge refactor 2bed0aebe into F3 (console restart, owner settings writes)
Conflicts kept both sides: rename stage B organization paths and the
relative-launch re-exec with F3 imports, F2 onboarding script and the
north-star fence exemption with F3 console-restart.js and ORG_OWNER_WRITE,
and the /api/config fields from both.
2026-11-09 13:41:20 -06:00
jason.woltje 1b6cac2bdf feat(webui): restart the Console, the org bus and agents from the WebUI (F3)
Self-service operation from the WebUI, for a dev instance first.

- Settings > Console > Restart Console. A foreground `mosaic console
  start` now supervises the webui: the child exits 75 after answering a
  restart and the supervisor relaunches it with the same argv. Org buses
  keep running. /api/config carries a per-process boot id, so the open
  page polls until a new process answers and then reloads. A detached
  Console has no supervisor and answers 409.
- Restart bus on every "Restart needed" receipt and on the bus-down
  banner: POST /api/orgs/<id>/restart/bus restarts a running bus or
  starts a stopped one, then shows a done receipt with the time.
- Settings > Routing: after a save, the page lists how the change
  reaches each agent (next start, session end, thread idle) and offers
  "Restart <agent> now" for a running one. A session is terminated with
  the reason "route change" and launched again; a thread is terminated.
- Organization settings: the signed-in owner's settings, routing,
  agent, plug-in and Model proxy writes now reach the selected org's
  bus instead of answering 403. Org mode exists only under
  --dev-local-owner. Proxy setup stays a terminal step; a dev-only note
  names the two commands.

Every control needs the signed-in owner session, the page's exact
Origin and Sec-Fetch-Site: same-origin. The tester listener, granted
users, API tokens, agents and bus roles are refused before any work.
Each use is written to <dataRoot>/console/actions.jsonl (0600, no
symlinks) with who, when, what and the outcome. The Console and bus
restart trails fail closed. No change to credential handling, the
broker, the bus tenant boundary or v2 schemas.
2026-11-09 13:37:28 -06:00
44 changed files with 2577 additions and 189 deletions

No files matched your search

+4
View File
@@ -6341,3 +6341,7 @@ R5 quote fallback room (#797 D3, 2026-11-06, MVP3 lead): r4 review P2 found that
2026-11-09 UTC | Unified workspace (#1873) — direction recorded | Before: the 11-07 rulings had one Console switch between organizations from Settings, tasks only in each org queue, and an agent bound to one organization (REQ-USER-1). After: Jason's 11-09 direction and answers recorded in docs/plans/2026-11-09_unified-workspace-owner-direction.md (all orgs visible at once, per-org task stores with a Vikunja option and an aggregated Kanban, root/org/project agents with one cross-org assistant, system type plug-ins, self-service restart and model switch). PRD 1.20 draft 1 written for his exact-text approval; the PRD itself is unchanged. The unpushed 11-07 rulings record (ad8140065) lands with it.
2026-11-09 UTC | Organizations friction round, landings #1876-#1881 (#1873) | Before: refactor 00ef0bd30. A Console launched by a relative path showed Request status unavailable, the overview printed the north star as raw markdown, packages/business and the business command spellings were the only names, Quick Setup ended with no route to a north star, an archived org could not be restored from a fresh page, and a dead bus after setup had no browser control. After, each through one Codex Sol xhigh review round plus a lead or builder fix round: #1876 re-execs a relative Console launch with its absolute script path (build 744753d7e, reproduced 121/0; review APPROVE 0/0/1). #1877 F1 renders the north star as sanitized markdown with a short preview (review APPROVE 0/0/1; live retest 4/0 twice). #1878 rename stage B moves packages/business to packages/organization and adds the org command spellings, keeping the business ones and their messages (builder 6828/6, see report; review CHANGES 0/1/0, fixed in e19db4009). #1879 F2 adds the onboarding interview after Quick Setup and on the project page, with human-only north-star approval (`mosaic project north-star --approve`) and no new agent grant (builder 411/0; review CHANGES 0/1/1: a refused start left its button disabled, fixed in 5937a977f along with the P3 stale approval capability). #1880 lands the U0 unified workspace samples, approved by Jason with the four-tile logo kept; the module names stay undecided at his request. #1881 E2E round 5 fixes restore from a fresh page, show an owner bus Start whenever the org list reads the bus down, say room changes are unavailable on org Consoles and recover the composer (builder 258/0; review CHANGES 0/1/0, 231/0: Settings kept Done after the bus died because only opening the org reread health). Lead fix fdd6658a6: every org list read is announced and the open setup redraws on a flip, and app.js reads the list before bus-status so a failed bus-status read cannot skip it; regression red on 8a8edc44b, 18 touched webui files 199/0 (Node 26.12.1, one file at a time). uiorg redeployed at fdd6658a6, 19:53Z. Open follow-ups: a down bus offers both the F3 banner Restart and the Settings Start, focus can move when setup redraws, and a Console stop also stops the org buses it started. Reports: /mnt/storage/scratch/mosaic-lead/reports/org-{reqget-2,f1,rename-b,f2,u0-design,e2e5}-*.md.
2026-11-09 UTC, organizations F3 (#1873, PR #1884, a8f9f0957). The WebUI restarts the Console (supervised Consoles exit and `mosaic console start` relaunches them), restarts an organization's bus from the bus-down banner with a done receipt, and sends the owner's routing, agent, instance, name, assistant, limits and model proxy writes to the selected organization's bus. Routing apply lists how a change reaches each saved agent and offers Restart NAME now. Console restarts, bus restarts and routing, agent and instance changes append to `<dataRoot>/console/actions.jsonl`. Builder plus two refactor merges: combined list 354/0 on 999d173fd. Sol review CHANGES 1/0/1. P1: on a `--dev-local-owner` Console a local agent can send the right Origin and Fetch Metadata headers and reach these controls. The pinned startup warning and the accepted no-auth dev flags already cover this, but the README claimed no agent reaches them. Lead decision: correct the README and file the cooperative local-agent peer refusal as #1883, without building it in this round. P3: the instances route wrote no trail line. Fixed in 0a73279d6; the regression test fails on 999d173fd and passes on the fix. After the refactor merge, 25 touched or README-reading files 195/0 (Node 26.12.1, one file at a time, env -i). uiorg redeployed at a8f9f0957, 22:45Z; live config offers consoleRestart and org settingsSend. Correction to this session's process: after the test run completed I replied "No response requested" and stopped until Jason asked. Open follow-ups: defect l (a bus restart after adding a credentialed agent can refuse to start), the bus restart refusal code, a stale-lock Recover control, one control for banner Restart and Settings Start, #1883. Report: /mnt/storage/scratch/mosaic-lead/reports/org-f3-review.md.
2026-11-09 UTC, PRD 1.20 and defect l (#1873, #1886; PRs #1887 03fe321b1 and #1888 7a97cfc4f). PRD 1.20: Jason approved draft 2 at 22:57Z ("The file looks fine other than that"; the Montage module name stays deferred). The PRD now has 118 requirements, with REQ-WS-1..8, REQ-AGENT-1..3, REQ-SYS-1..2, REQ-ACCT-1..2 and REQ-SETUP-1..2 added and REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3 amended. Builder Codex Terra xhigh declined to commit because stack-copy failed 20/1 (node-gyp missing from the test Node's npm); that failure is pre-existing on 483eccb3d, so the lead committed. Sol review APPROVE 0 (word-for-word diff against draft 2, catalog order, both requirements tests). Defect l: before, an agent that declared a store credential made the next bus start refuse, because Credentials refuses a missing store root and only issue() created it. After: start creates the root 0700 when any reference names the store; a root that exists with loose modes or as a symlink still refuses. Two new credential-verbs tests fail on the parent. Bus suite 171 files one at a time, 1713/0 (Node 26.12.1, env -i). Sol review APPROVE 0. uiorg redeployed at 7a97cfc4f, 23:15Z. Follow-up: the stack-copy node-gyp test environment. Reports: /mnt/storage/scratch/mosaic-lead/reports/{prd120-land,prd120-review,defectl-review}.md.
+2
View File
@@ -2121,3 +2121,5 @@ are never rewritten or removed; corrections are new entries.
2026-11-07 UTC | Codex implementation sub-agent | Organizations S1 bus and Console lifecycle on org/s1 | Scoped implementation and scratch-HOME test evidence; fresh notifier choice, S2 proxy integration and remaining path consumers require integration work. Inventory, commands and limits: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s1.md. Queue refused linked worktree; no push or deployment.
2026-11-07 UTC | Claude implementation sub-agent | Organizations S6 WebUI create and guided setup on org/s6 | Settings > Organizations: template form with JSON preview, C2 create/update with if-revision, setup cards for checkout, repositories and bus; webui suite 974/975 (inherited dev-owner test failure). Report and screenshots: /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-s6.md. No push, merge or deployment.
2026-11-07 UTC | Codex implementation sub-agent | Organizations release fix round 1 on ui-org-r1 | Local candidate for independent review; verification and findings recorded in /mnt/storage/scratch/mosaic-lead/uidev/org-release/report-fix-r1.md. Queue refused linked worktree; no push, merge or deployment.
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | Organizations F3 fix round and landing (#1873) | PR #1884 merged a8f9f0957; review P3 fixed, P1 README corrected with follow-up #1883; 195/0 focused suite; uiorg redeployed 22:45Z.
2026-11-09 UTC | Mos (lead, Claude Opus 5.5) | PRD 1.20 landing and defect l (#1873, #1886) | PR #1887 merged 03fe321b1, PR #1888 merged 7a97cfc4f; both Sol reviews APPROVE 0; bus suite 1713/0; uiorg redeployed 23:15Z; U1, U2 and U4a builds dispatched.
+4 -1
View File
@@ -311,7 +311,8 @@ scripts/mosaic decide <decision> <option> [--note <text>] [--yes]
scripts/mosaic trail <task|decision> [--json]
scripts/mosaic bus start <business> [--pm | --no-launch] [--dev-local-owner] | stop | status [--json]
scripts/mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
scripts/mosaic console stop | status
scripts/mosaic console stop | status | restart
scripts/mosaic org reload <org>
scripts/mosaic talk <role> <text> [--wait SECONDS] [--business <id>]
scripts/mosaic project create <name> [--repo <repo>] | list [--json]
scripts/mosaic threads [--project <project>] [--json] [--tester]
@@ -332,6 +333,8 @@ For local development on a named instance only, `mosaic bus start BUSINESS --dev
`mosaic console start --dev-local-owner --dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]` opens that dev owner Console to other machines, for UI testing from another device on a trusted LAN. It has no sign-in: anyone who can reach ADDR:PORT acts as the owner. Start, and `mosaic console status` while that Console runs, print `WARNING: dev owner Console listens on ADDR:PORT with no sign-in; anyone who can reach it acts as the owner.` on stderr. ADDR is `0.0.0.0` or an IPv4 address, and a specific address must also be one of the names. NAME is a host name or IPv4 address without a port; give several as one comma-separated value. The Console answers only `NAME:<bound port>` and the loopback names, and takes a write only when Origin is `http://` plus that same Host. A browser on such an http origin sends no Fetch Metadata, so for an allowed NAME a write with that exact Origin and no `Sec-Fetch-Site` counts as same-origin; a `Sec-Fetch-Site` the browser did send is still checked. Both flags are refused without `--dev-local-owner`, and `--dev-insecure-bind` is refused without a `--dev-allow-host`. SSO, preview and tester Consoles stay on loopback. The Console state file records `bind` and `allowHosts`. `mosaic console status` prints the state as JSON (`bind` is `127.0.0.1` for a loopback Console) and never moves a stale state file.
`mosaic console restart` and `mosaic org reload ORG` are the Console's Settings controls (F3) from a terminal (U1, #1873). Each sends one request to the running Console named in the instance's console state, as the page sends it (the Console's own Host and Origin, `Sec-Fetch-Site: same-origin`, a JSON `{}` body), so the Console applies the same owner, `--send` and origin checks and writes the same action trail, `<dataRoot>/console/actions.jsonl`. Both refuse inside an agent run (exit 3) before reading anything, and refuse with exit 3 when no Console runs. `console restart` works only for a Console started in the foreground by `mosaic console start` (otherwise the Console answers `not-supervised`); it waits up to 30 seconds for the relaunched Console to answer and prints `{"outcome":"restarted","from":OLD,"boot":NEW}`. `org reload ORG` restarts a running bus or starts a stopped one and prints the Console's done receipt. A request that left but got no answer is reported as outcome unknown (exit 1) and is never retried; check `mosaic console status`, `mosaic bus status ORG` and the trail. `mosaic bus restart ORG` remains the trail-less host verb.
The instance processing switch is separate from per-business launches off/on.
`mosaic halt` durably stops managed processing across every business in the
selected configuration's data root. The bus stays up for reads and human
+1 -2
View File
@@ -1,7 +1,6 @@
# PRD version 1.20, draft 2: unified workspace
- Status: draft, not approved. Jason approves the exact text before it
enters `docs/prd/mosaic-stack.md` as version 1.20.
- Status: approved as version 1.20 on 2026-11-09; landed in docs/prd/mosaic-stack.md
- Source: `docs/plans/2026-11-09_unified-workspace-owner-direction.md`,
Jason's direction and answers of 2026-11-09.
- Drafted by Mos (lead).
+126 -20
View File
@@ -1,12 +1,11 @@
# PRD: Mosaic Stack
- Status: approved, version 1.19. Jason approved the exact text in Q289.
- Version 1.19 adds disk-space governance, launch-capacity holds,
human-controlled retention rules, and the protected `data.delete.policy`
route. It preserves per-batch `data.delete` hard waits. Jason approved
the exact text at `e5400456` under REQ-WAIT-2: "1.18: yes" (DECISIONS
Q289, relayed by Mos in mos-2300). Q288 approved the recurring-rule principle.
- Prior approval history, through version 1.18: Jason approved draft 0.4 unchanged as
- Status: approved, version 1.20. Jason approved draft 2 on 2026-11-09.
- Version 1.20 adds the unified workspace: every organization visible at once,
cross-organization views, three levels of agent configuration, per-org task
stores behind the task tracker kind, system type plug-ins and self-service
operation. It amends REQ-ASSIST-1, REQ-USER-1, REQ-TASK-1 and REQ-WEB-3.
- Prior approval history, through version 1.19: Jason approved draft 0.4 unchanged as
1.0 on 2026-10-05 (lead decision 61). They approved version 1.1, the
Organization requirements, in decision 03130d08-ddce-480d-a601-e59f64744fc3
on 2026-10-14. They approved version 1.2, the chief of staff, the
@@ -64,7 +63,18 @@
the approved row 255 design. The stack prints the providers entry and
never writes config.json. What the requirement allows or forbids is
unchanged (mos-1671, sage-4418). Doc-only, so it takes the 1.11 path:
one seat drafts, a second reviews, Sage lands.
one seat drafts, a second reviews, Sage lands. Version 1.19 adds
disk-space governance, launch-capacity holds, human-controlled retention
rules, and the protected `data.delete.policy` route. It preserves per-batch
`data.delete` hard waits. Jason approved the exact text at `e5400456` under
REQ-WAIT-2: "1.18: yes" (DECISIONS Q289, relayed by Mos in mos-2300).
Q288 approved the recurring-rule principle. Jason approved the exact text in Q289.
- Version 1.20 source: Mos asked Jason to approve draft 2 in
`docs/plans/2026-11-09_prd-1-20-draft.md`, with the Montage name deferred.
Jason replied on 2026-11-09 at 22:57Z: "The file looks fine other than that."
The exception is the module name, which he had deferred at 19:26Z: "I don't
like these module names either. It isn't clicking for me. That may need to be
a later decision."
- Version 1.19 source, Jason's DECISIONS Q287, verbatim:
> All of the learnings from this drive space issue need to be integrated into Mosaic Stack. We need watchers, cleanups, enforcement tooling and agents to check for space waste, etc.
@@ -475,9 +485,11 @@ Added in version 1.2:
names the roles and how many of each. The chief of staff launches them
through role.launch, within the agent limits (REQ-LIMIT-1). (Version
1.5.)
- **REQ-ASSIST-1.** The assistant is the user's main agent. Each user has
one assistant role per organization. It talks with the user, and it
passes work for the organization to the chief of staff. It doesn't
- **REQ-ASSIST-1.** The assistant is the user's main agent. Each user has one
assistant across all of the organizations they
belong to. It knows the user's organizations, projects and sessions and
keeps one shared memory. It talks with the user and passes each
organization's work to that organization's chief of staff. It doesn't
direct other agents, create or assign tasks, or launch sessions.
- **REQ-ASSIST-2.** The broker limits the assistant's messages, not its
contract alone:
@@ -522,6 +534,24 @@ Added in version 1.7:
- Every hire and fire is logged with the chief's instance, its run and
the reason.
### Agent levels
Added in version 1.20.
- **REQ-AGENT-1.** Agents are configured at three levels: the user's root,
the organization and the project. A project's available agents are the
union of the three.
- **REQ-AGENT-2.** The user can see and change the configuration of agents,
organizations, projects and sessions, and can override any setting at any
time, through the write path (REQ-ROLE-3).
- **REQ-AGENT-3.** The chief of staff hires and fires agents to meet the
user's needs. At the organization and project levels it acts under
REQ-COS-3. At the root level its hire or fire changes the user's root
configuration, which every organization of that user sees, so it reaches
outside the chief's own organization. A root-level hire or fire is a
decision the user approves in the inbox (REQ-DEC-2) before it takes
effect. The user can also change root-level agents directly.
### Variables
- **REQ-VAR-1.** Variables come in four layers: system, business, project
@@ -879,8 +909,15 @@ consumed ordinary decision as standing permission.
### Tasks
- **REQ-TASK-1.** Tasks live in Vikunja, reached through `/api/v2` with a
bot token for each role. Each task cites a requirement id. Each task
- **REQ-TASK-1.** Each organization stores its own tasks, through the task tracker
plug-in kind (REQ-PLUG-1). The default module is a store built into Mosaic
Stack. Vikunja is an optional module, and when an organization selects it
the 1.19 rules for Vikunja (bot tokens per role, one writing role per field,
REQ-TASK-2 polling) apply to that organization. Every task tracker module
supports create, read, update, archive and delete, ordering and grouping.
Archiving or removing an organization removes its tasks without touching
another organization's.
Each task cites a requirement id. Each task
field has one writing role, listed in the slice 1 brief's field table,
and the assigned role writes the task's state. Since version 1.19, the
scoped disk-service principal in REQ-DISK-12 may initialize incident
@@ -1031,10 +1068,40 @@ Added in version 1.7:
- The decision event records via=console and the (iss, sub) pair.
- Without a signed-in session, the Console resolves nothing and shows
the `mosaic decide` line instead.
- **REQ-WEB-3.** The Console has controls to restart the bus and to
reload the organization file. Each control is a gated, trailed action.
A reload applies `launch.instances` changes to running sessions without
a manual `systemctl restart`. (Version 1.4, decision 021af0c8.)
- **REQ-WEB-3.** The Console has controls to restart itself, to restart an
organization's bus, to reload the organization file, and to change the model
an agent instance runs on and apply it. Each control is a gated, trailed
action that a human takes. No agent can take it. A reload applies
`launch.instances` changes to running sessions without a manual
`systemctl restart`. On a dev instance (`--dev-local-owner`) the controls
need no sign-in.
### Workspace
Added in version 1.20.
- **REQ-WS-1.** The WebUI shows every organization the user belongs to at
all times. A collapsible Organizations item in the sidebar lists them.
There is no organization switcher.
- **REQ-WS-2.** The Console reads across the buses of the user's
organizations to build shared views. Each organization keeps its own bus
as the tenant boundary (REQ-ORG-1). A shared view shows only
organizations the user belongs to.
- **REQ-WS-3.** The main dashboard sums activity, work and metrics across
all of the user's organizations and projects.
- **REQ-WS-4.** Each organization has a dashboard with its docs, every
configurable setting, its KPIs and metrics, and its active agent
sessions.
- **REQ-WS-5.** The projects list covers every organization. It sorts and
filters by organization, status, name and activity. Each project shows
its owning organization by icon, color and avatar.
- **REQ-WS-6.** One inbox holds every item that needs the user, from every
organization, project and session.
- **REQ-WS-7.** One view shows activity across all organizations and
projects. Organizations and projects can be open side by side.
- **REQ-WS-8.** The tasks view aggregates every organization's tasks, with
Kanban and list layouts, filtered by organization and project. It works
the same for every task tracker module.
### Launching
@@ -1173,6 +1240,17 @@ Added in version 1.3 (Q155).
changes it, not in one rewrite (Q155). Until it converts, Technical
considerations lists where it is hard-coded.
### System types
Added in version 1.20.
- **REQ-SYS-1.** System types are a plug-in kind. Its modules include
coding, creative writing, marketing, Mosaic Montage (the OpenMontage
fork) and business management. An organization enables a list of them.
- **REQ-SYS-2.** A project may use any system type its organization
enables, and none is required. An agent working in a project gets the
toolsets of the system types that project uses.
### Meta-harness
- **REQ-HARN-1.** The meta-harness generates each session's launch bundle
@@ -1329,9 +1407,13 @@ Added in version 1.1 (decision 03130d08).
- **REQ-ORG-2.** The data model is install, then organization, then
projects, then threads, so v1 doesn't lock in one human per
organization.
- **REQ-USER-1.** Users and role-based access control exist at install
and organization level. An agent belongs to one organization and
reaches only its allowed projects and threads.
- **REQ-USER-1.** Users and role-based access control exist at install and
organization level. An agent other than the assistant works in one
organization at a time. Its memory, files and credentials are scoped to that
organization, even when its definition comes from the user's root
configuration. It reaches only that organization's allowed projects and
threads. The assistant is the exception in REQ-ASSIST-1, and it reaches only
organizations its user belongs to.
- **REQ-USER-2.** Only the user who owns a thread reads it or writes to
it, plus that user's assistant and the chief of staff's replies on work
the thread sent. This fills in REQ-USER-1's "threads" for the chat
@@ -1462,6 +1544,15 @@ design `docs/plans/2026-10-21_t1-sessions.md`, 2c843d190 on `huey/t1-sessions-de
with `processing-halted` and changes nothing. A clear never sets or
clears the kill switch. (Design D8, test T6.)
### Accounts
Added in version 1.20.
- **REQ-ACCT-1.** A deployed system requires user sign-in (OIDC, Q203). A
user creates organizations and belongs to any number of them.
- **REQ-ACCT-2.** An organization's owner invites another user into it.
The invited user joins with the grant the owner chose (REQ-USER-3).
### Creative library
Added in version 1.12 (Q176 Case 2, Q211). Drafted from the Case 2
@@ -1788,6 +1879,17 @@ Added in version 1.11 (Q187, Q189, Q211):
monitored coverage. Install and upgrade enable no deletion policies
(REQ-DISK-1, REQ-DISK-4, REQ-DISK-10).
### Setup
Added in version 1.20.
- **REQ-SETUP-1.** Quick Setup leaves its project with its records
initialized.
- **REQ-SETUP-2.** After Quick Setup, the assistant offers an onboarding
interview. It asks for the project's outcome and limits and drafts the
north star and a first PRD. The user approves each through the existing
approval path.
## Acceptance criteria and success measures
v1 is done when Jason gives one-sentence requests to the assistant, in a
@@ -2367,6 +2469,10 @@ gate that slice.
## Risks and open questions
- The final name for the Mosaic Montage module is deferred to a later owner
decision. Mosaic Montage remains a working name; the existing four-tile
logo stays. (Version 1.20.)
- On each v1 instance, the stack runs as that instance's own OS user,
without sudo (Q181). A managed role session runs in a sandbox
(REQ-HARN-2): it writes only its workspace and run directory, sees no
+6 -1
View File
@@ -1,7 +1,7 @@
import { createRequirementsHost } from '../../tasks/src/builtin.mjs';
import { createRequirementCatalog } from './requirement-catalog.mjs';
import { readPrdMaterial } from '../../project/src/prd.mjs';
import { lstatSync, readdirSync, readFileSync } from 'node:fs';
import { lstatSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from './store.mjs';
@@ -360,6 +360,11 @@ export async function startBroker({
// Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to
// gitea, and `held` names the files memory must not load until reconcile or a revoke.
const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] };
// #1886 (F3 defect l): a store reference with no file yet reads as no key (design D3a), but Credentials
// refuses a missing store root, and only issue() made it. Start makes it the same way issue() does when
// any reference names the store. A root that exists with loose modes or as a symlink still refuses.
if (storeRoot && Object.values(references).some(refs => Object.values(refs ?? {}).some(ref => ref?.store === true)))
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held,
modelAgents: proxy ? Object.entries(businesses).flatMap(([id, b]) => Object.keys(b.roles).map(role => id + '/' + role)) : [] });
if (proxy) {
+3 -1
View File
@@ -35,7 +35,9 @@ const baselines = {
// Row 239 (#1745): outcome-unknown-revoked joins OPEN (duckworth-0165 C1), and STATUS gets
// revision-conflict-revoked 409 and outcome-unknown-revoked 503; no proof or transport change.
// #1839: artifact Console replies alone may use 2 MiB; the proof and other limits are unchanged.
"packages/webui/src/bus.mjs": "34fa64ffa17886d86243d7712fdb6f632c22b96412b642d785ddbc05fbd79692",
// #1873 U1: humanCall, busCaller, userCall and userCaller take an optional signal and absolute
// deadline; userCall shares transport(). Nonces, argv and limits are unchanged.
"packages/webui/src/bus.mjs": "3ef8064dd7911f9eee4475b52c74a1d551583a065474b1bcee968cd45f722747",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
+29 -3
View File
@@ -4,7 +4,7 @@
// scratch root only.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
@@ -18,7 +18,7 @@ const OTHER = 'fake_other_token_5555555555';
const DATE = '2099-01-01';
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
async function setup(t) {
async function setup(t, { storeRoot = true } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
@@ -26,7 +26,7 @@ async function setup(t) {
doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE };
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
if (storeRoot) mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
// The bus's resolved business carries the same references; a store one names its derived file.
const demo = {
@@ -161,3 +161,29 @@ test('T5: issue, rotate, revoke and delete are live, rewrite the business file a
for (const path of walk(c.root))
for (const token of [TOKEN, ROTATED, OTHER]) assert.equal(readFileSync(path).includes(token), false, path);
});
test('#1886: a store reference with no store root yet starts the bus, makes the root private and issues', async (t) => {
const c = await setup(t, { storeRoot: false });
const dir = join(c.root, 'secrets', 'agents');
assert.equal(statSync(dir).mode & 0o777, 0o700);
assert.equal(statSync(join(c.root, 'secrets')).mode & 0o077, 0);
assert.deepEqual(c.status('coder'), { gitea: 'not-issued', vikunja: 'valid' });
const issued = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN + '\n', date: DATE });
assert.deepEqual(issued, { agent: 'coder', verb: 'issue', service: 'gitea', date: DATE, state: 'valid' });
assert.equal(statSync(c.store('coder', 'gitea')).mode & 0o777, 0o600);
});
test('#1886: a store root that exists with loose modes still refuses start', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-loose-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o750 });
chmodSync(join(root, 'secrets', 'agents'), 0o750);
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: {},
roles: { coder: { ...role, credentials: { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: DATE } } } },
};
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/);
});
+1 -1
View File
@@ -45,7 +45,7 @@ test('requirements returns the committed list to a holding role session, and no
const pm = agent('pm');
const list = call(b, pm, 'requirements');
assert.deepEqual(list, loadRequirements());
assert.equal(list.requirements.length, 101);
assert.equal(list.requirements.length, 118);
assert.deepEqual(call(b, agent('coder'), 'requirements'), list);
assert.throws(() => call(b, human, 'requirements'), /agent-required/);
assert.throws(() => call(b, reader, 'requirements'), /read-only/);
+17 -2
View File
@@ -43,7 +43,7 @@ import { SeatError } from "../../seat/src/seat.mjs";
import { OrgError, resolveOrg, withOrgLifecycle } from "../../organization/src/org-registry.mjs";
import { orgSelector } from "./org-selector.mjs";
import { busStart, busStatus, busStop, busRestart, busRecover } from "./org-bus.mjs";
import { consoleStart, consoleStatus, consoleStop } from "./org-console.mjs";
import { consoleRestart, consoleStart, consoleStatus, consoleStop } from "./org-console.mjs";
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
import { orgCommand, ORG_USAGE } from "./org.mjs";
@@ -91,6 +91,7 @@ export const USAGE = `usage:
mosaic bus status <id> [--json]
mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
mosaic console stop|status
mosaic console restart
${DEV_USAGE}
${RUN_USAGE}`;
@@ -571,6 +572,14 @@ async function consoleCommand(rest, io, deps) {
io.stdout.write(`${JSON.stringify(consoleStatus(organizationContext(io, deps), { warn: s => io.stderr.write(s) }))}\n`);
return;
}
// U1 (#1873): the F3 Settings control from a terminal. The running Console applies its own owner
// and trail checks (org-console.mjs consoleRestart); an agent run is refused before any read.
if (sub === "restart") {
if (args.length) throw usage("console restart takes no arguments");
refuseInsideAgent(io.env);
io.stdout.write(`${JSON.stringify(await consoleRestart(organizationContext(io, deps), deps.consoleRequest ?? {}))}\n`);
return;
}
if (sub !== "start") throw usage();
const parsed = parse(args, { flags: ["--dev-local-owner", "--detach"], values: ["--port", "--dev-insecure-bind", "--dev-allow-host"] });
if (parsed.positional.length) throw usage("console start takes no positional arguments");
@@ -583,7 +592,13 @@ async function consoleCommand(rest, io, deps) {
const receipt = await consoleStart(organizationContext(io, deps), { devLocalOwner: devOwner, detach: parsed.flags.has("--detach"), port: parsed.values["--port"] ?? "auto", ...(lan ? { bind: lan.bind, allowHosts: lan.allowHosts } : {}), warn: s => io.stderr.write(s) });
io.stdout.write(`${JSON.stringify(receipt)}\n`);
if (!parsed.flags.has("--detach")) {
const code = await receipt.done;
// F3: this process supervises the Console. A stop signal (the unit's SIGTERM, Ctrl-C) goes to
// the Console and ends supervision cleanly; a restart the Console asks for is relaunched here.
const stop = () => receipt.stop();
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, stop);
let code;
try { code = await receipt.done; }
finally { for (const signal of ["SIGINT", "SIGTERM"]) process.off(signal, stop); }
if (code !== 0) throw new CliError("console stopped unexpectedly", code);
}
}
+11 -6
View File
@@ -43,12 +43,17 @@ function checkedState(org) {
if (s.host && !lock) fail('bus host lock is missing');
return s;
}
function answer(path) {
export function probeBusSocket(path, { signal, deadline } = {}) {
return new Promise(resolve => {
const remaining = deadline === undefined ? 1000 : Math.min(1000, deadline - Date.now());
if (signal?.aborted || remaining <= 0) return resolve(false);
let done = false, data = '';
const socket = connect(path);
const finish = value => { if (done) return; done = true; socket.destroy(); resolve(value); };
socket.setTimeout(1000, () => finish(false));
const abort = () => finish(false);
const finish = value => { if (done) return; done = true; signal?.removeEventListener('abort', abort); socket.destroy(); resolve(value); };
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) return abort();
socket.setTimeout(remaining, () => finish(false));
socket.on('error', () => finish(false));
socket.on('connect', () => socket.write('{}\n'));
socket.on('data', chunk => {
@@ -62,9 +67,9 @@ function answer(path) {
socket.on('end', () => finish(false));
});
}
export async function busStatus(ctx, id) {
export async function busStatus(ctx, id, bound) {
const org = await target(ctx, id), state = checkedState(org);
const healthy = Boolean(state.host?.live && state.socket && await answer(org.paths.socket));
const healthy = Boolean(state.host?.live && state.socket && await probeBusSocket(org.paths.socket, bound));
return { org: id, instance: ctx.instance, root: org.root, socket: org.paths.socket, healthy, pid: state.host?.pid ?? null, boot: readBootStatus(org.root) };
}
export async function busStop(ctx, id) {
@@ -123,7 +128,7 @@ export async function busStart(ctx, id, { detach = false, devLocalOwner = false,
while (Date.now() < deadline && !exited) {
// During boot the host holds its lock before its state is published.
const state = readHostState(org.root);
if (ready && state?.live && state.pid === child.pid && state.business === id && await answer(org.paths.socket)) {
if (ready && state?.live && state.pid === child.pid && state.business === id && await probeBusSocket(org.paths.socket)) {
child.disconnect();
if (detach) child.unref();
const receipt = { org: id, pid: child.pid, socket: org.paths.socket, root: org.root };
+178 -31
View File
@@ -2,6 +2,7 @@
import { randomBytes } from 'node:crypto';
import { processState } from '../../seat/src/process-state.mjs';
import { spawn } from 'node:child_process';
import { request as httpRequest } from 'node:http';
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -64,29 +65,22 @@ function takeLock(ctx) {
let fd; try { fd = openSync(path, 'wx', 0o600); } catch { fail('console start lock exists'); }
return () => { closeSync(fd); rmSync(path, { force: true }); };
}
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
const value = port === 'auto' ? 0 : Number(port);
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
let lan;
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
const release = takeLock(ctx);
let child = null, done = null;
// The exit code a supervised Console uses to ask for a relaunch (EX_TEMPFAIL). Only the Console's
// own restart route (POST /api/console/restart, a human action) exits with it.
export const RESTART_EXIT = 75;
const sleep = ms => new Promise(r => setTimeout(r, ms));
let lastStamp = 0;
// One Console process: spawn it, wait for the URL it prints, check the address and port.
async function launch(ctx, args, lan, detach) {
lastStamp = Math.max(Date.now(), lastStamp + 1);
const log = join(directory(ctx), `console-${process.pid}-${lastStamp}.log`);
const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
let child;
try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); }
finally { closeSync(fd); }
const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
let exited = false; done.then(() => { exited = true; });
try {
const prior = state(ctx);
if (prior && !removeStale(ctx, prior, warn)) fail('console state exists; stop or inspect the console first');
const org = (await listOrgs(ctx)).find(x => x.state === 'active');
if (org) await resolveOrg(ctx, org.id);
// The pre-S5 Console still accepts a business preselection.
const args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []),
...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : [])];
const log = join(directory(ctx), `console-${process.pid}-${Date.now()}.log`);
const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); }
finally { closeSync(fd); }
done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
let exited = false; done.then(() => { exited = true; });
const deadline = Date.now() + 30000;
while (Date.now() < deadline && !exited) {
const text = readFileSync(log, 'utf8');
@@ -97,22 +91,99 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false,
if (forbidden.has(Number(match[3]))) fail('console selected a forbidden port');
const startTime = startTimeOf(child.pid);
if (!startTime) fail('console exited during startup');
writeFileSync(stateFile(ctx), JSON.stringify({ pid: child.pid, startTime, url: match[1], ...(lan ?? {}) }) + '\n', { mode: 0o600, flag: 'wx' });
if (detach) child.unref();
// The child's stderr goes to the log; the person who started it sees the warning here.
if (lan) warn(lanWarning(lan.bind, match[3]));
const result = { url: match[1], pid: child.pid, ...(lan ?? {}) };
Object.defineProperty(result, 'done', { value: done });
return result;
return { child, done, url: match[1], port: match[3], startTime };
}
await new Promise(r => setTimeout(r, 50));
await sleep(50);
}
fail(`console failed to start; see ${log}`);
} catch (e) {
if (child && child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
throw e;
}
}
const record = (run, lan) => JSON.stringify({ pid: run.child.pid, startTime: run.startTime, url: run.url, ...(lan ?? {}) }) + '\n';
// F3: a relaunch keeps the arguments and the address. The state file must still name the process
// that asked for the restart; a stop that ran in between wins, and nothing is relaunched.
async function relaunch(ctx, old, args, lan) {
const next = args.map((a, i) => (args[i - 1] === '--port' ? old.port : a));
let release;
for (let n = 0; !release; n++) {
try { release = takeLock(ctx); } catch (e) { if (n >= 50) throw e; await sleep(100); }
}
try {
const s = state(ctx);
if (!s || s.pid !== old.child.pid || s.startTime !== old.startTime || s.url !== old.url) fail('console state changed during restart');
let fresh;
// The old listener has closed; a refused bind is retried briefly.
for (let n = 0; !fresh; n++) {
try { fresh = await launch(ctx, next, lan, false); } catch (e) { if (n >= 4) throw e; await sleep(500); }
}
try {
if (fresh.url !== old.url) fail('console restarted on another address');
const tmp = `${stateFile(ctx)}.tmp-${randomBytes(4).toString('hex')}`;
writeFileSync(tmp, record(fresh, lan), { mode: 0o600, flag: 'wx' });
renameSync(tmp, stateFile(ctx));
} catch (e) { fresh.child.kill('SIGTERM'); await fresh.done; throw e; }
return fresh;
} finally { release(); }
}
// Foreground supervision: relaunch on RESTART_EXIT, end on any other exit. `stop` forwards a
// stop signal (the unit's SIGTERM, a terminal's Ctrl-C) to the Console; a stop is a clean end.
function supervise(ctx, first, args, lan, warn) {
let current = first, stopping = false;
const stop = () => {
if (stopping) return;
stopping = true;
const child = current.child;
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill('SIGTERM');
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); }, 15000).unref();
};
const done = (async () => {
for (;;) {
const code = await current.done;
if (stopping) return 0;
if (code !== RESTART_EXIT) return code;
warn('mosaic: console asked for a restart; relaunching on the same arguments\n');
try { current = await relaunch(ctx, current, args, lan); }
catch (e) { warn(`mosaic: console restart failed: ${e.message}\n`); return 1; }
if (stopping) { stopping = false; stop(); await current.done; return 0; }
}
})();
return { done, stop };
}
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
// In the foreground the Console runs with --supervised: its restart route exits RESTART_EXIT and
// this process relaunches it in place (F3). A detached Console has no supervisor.
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
const value = port === 'auto' ? 0 : Number(port);
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
let lan;
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
const release = takeLock(ctx);
let run, args;
try {
const prior = state(ctx);
if (prior && !removeStale(ctx, prior, warn)) fail('console state exists; stop or inspect the console first');
const org = (await listOrgs(ctx)).find(x => x.state === 'active');
if (org) await resolveOrg(ctx, org.id);
// The pre-S5 Console still accepts a business preselection.
args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []),
...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : []), ...(detach ? [] : ['--supervised'])];
run = await launch(ctx, args, lan, detach);
try { writeFileSync(stateFile(ctx), record(run, lan), { mode: 0o600, flag: 'wx' }); }
catch (e) { run.child.kill('SIGTERM'); await run.done; throw e; }
} finally { release(); }
if (detach) run.child.unref();
// The child's stderr goes to the log; the person who started it sees the warning here.
if (lan) warn(lanWarning(lan.bind, run.port));
const result = { url: run.url, pid: run.child.pid, ...(lan ?? {}) };
const supervision = detach ? { done: run.done, stop: () => run.child.kill('SIGTERM') } : supervise(ctx, run, args, lan, warn);
Object.defineProperty(result, 'done', { value: supervision.done });
Object.defineProperty(result, 'stop', { value: supervision.stop });
return result;
}
export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } = {}) {
const release = takeLock(ctx);
try {
@@ -149,3 +220,79 @@ export function consoleStatus(ctx, { warn = s => process.stderr.write(s) } = {})
if (running && s.bind) warn(lanWarning(bind, port));
return { running, ...(running ? {} : { stale: true }), pid: s.pid, url: s.url, bind, ...(s.allowHosts ? { allowHosts: s.allowHosts } : {}) };
}
// U1 (#1873): `mosaic console restart` and `mosaic org reload ID` are the F3 page controls from a
// terminal. Each one is a request to the running Console named by console.json, sent as the page
// sends it: the Console's own Host and Origin, Sec-Fetch-Site: same-origin and a JSON `{}` body.
// The Console's route applies its owner, --send and origin checks and writes the action trail;
// nothing here writes the trail, signals a process or touches a bus. The CLI verbs refuse inside
// an agent run before they get here (transport.mjs refuseInsideAgent).
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
function consoleTarget(ctx) {
const s = state(ctx);
if (!s) fail('no Console runs for this instance; start one with mosaic console start');
if (!liveConsole(s)) fail('the Console in console state is not running; see mosaic console status');
const url = /^http:\/\/([0-9.]+):(\d+)\/$/.exec(s.url);
if (!url) fail('console state is invalid');
const port = Number(url[2]);
if (forbidden.has(port)) fail('console state names a forbidden port');
// A LAN Console on 0.0.0.0 answers on loopback too; one bound to an address answers only there.
const address = s.bind && s.bind !== '0.0.0.0' ? s.bind : '127.0.0.1';
return { address, port, host: `${address}:${port}` };
}
function consoleCall(target, { method = 'GET', path, timeoutMs }) {
return new Promise((resolve, reject) => {
const headers = { host: target.host, ...(method === 'POST' ? { origin: `http://${target.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': 2 } : {}) };
let sent = false;
const req = httpRequest({ host: target.address, port: target.port, method, path, headers, timeout: timeoutMs }, res => {
let text = '';
res.setEncoding('utf8');
res.on('data', chunk => { text += chunk; if (text.length > 65536) req.destroy(new Error('the Console answer is too large')); });
res.on('end', () => { let body = null; try { body = JSON.parse(text); } catch { /* not JSON */ } resolve({ status: res.statusCode, body }); });
res.on('error', e => reject(Object.assign(e, { sent: true })));
});
req.on('timeout', () => req.destroy(Object.assign(new Error('timed out'), { code: 'timeout' })));
req.on('finish', () => { sent = true; });
req.on('error', e => reject(Object.assign(e, { sent })));
req.end(method === 'POST' ? '{}' : undefined);
});
}
// A refused connection never reached the Console. Anything else after the request left may have
// changed something, so it is outcome-unknown and nothing is retried.
async function consolePost(target, path, { timeoutMs, unknown }) {
let answer;
try { answer = await consoleCall(target, { method: 'POST', path, timeoutMs }); }
catch (e) {
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
throw new CliError(`outcome unknown: ${unknown}`, 1);
}
return answer;
}
function refusal(answer, what) {
const code = typeof answer.body?.error === 'string' && /^[a-z-]{1,64}$/.test(answer.body.error) ? answer.body.error : `http ${answer.status}`;
const message = typeof answer.body?.message === 'string' ? `: ${answer.body.message.slice(0, 500)}` : '';
return new CliError(`${what} refused (${code})${message}`, answer.status >= 500 ? 1 : 3);
}
// POST /api/console/restart, then wait for the relaunched Console (a new boot id on /api/config).
export async function consoleRestart(ctx, { timeoutMs = 10000, waitMs = 30000 } = {}) {
const target = consoleTarget(ctx);
const answer = await consolePost(target, '/api/console/restart', { timeoutMs, unknown: 'the Console may be restarting; check mosaic console status and the action trail' });
if (answer.status !== 202 || answer.body?.outcome !== 'restarting' || typeof answer.body.boot !== 'string') throw refusal(answer, 'console restart');
const from = answer.body.boot, deadline = Date.now() + waitMs;
while (Date.now() < deadline) {
await sleep(250);
try {
const config = await consoleCall(target, { path: '/api/config', timeoutMs: 2000 });
if (config.status === 200 && typeof config.body?.boot === 'string' && config.body.boot !== from) return { outcome: 'restarted', from, boot: config.body.boot };
} catch { /* closed while it restarts */ }
}
throw new CliError('the Console accepted the restart but has not answered since; check mosaic console status', 1);
}
// POST /api/orgs/ID/restart/bus: a running bus restarts, a stopped one starts (org-admin.mjs restartBus).
export async function consoleBusRestart(ctx, id, { timeoutMs = 120000 } = {}) {
if (typeof id !== 'string' || !ORG_ID.test(id)) throw new CliError('invalid organization id', 4);
const target = consoleTarget(ctx);
const answer = await consolePost(target, `/api/orgs/${id}/restart/bus`, { timeoutMs, unknown: `the bus may be restarting; check mosaic bus status ${id} and the action trail` });
if (answer.status !== 200 || answer.body?.outcome !== 'done' || answer.body.step !== 'bus-restart') throw refusal(answer, 'org reload');
return answer.body;
}
+8 -1
View File
@@ -6,7 +6,9 @@ import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
import { loadSystem } from './config.mjs';
import { CliError } from './errors.mjs';
import { busStopForDelete } from './org-bus.mjs';
export const ORG_USAGE = 'mosaic org lock show|clear ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
import { consoleBusRestart } from './org-console.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const ORG_USAGE = 'mosaic org lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e.message}`, 2); } }
export async function orgCommand(args, io, deps = {}) {
@@ -16,12 +18,17 @@ export async function orgCommand(args, io, deps = {}) {
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
if (words[0] === 'template' && words[1] === 'render' && words.length === 3) { allowed([], ['--fixture', '--output']); result = await templateRender(words[2], { fixture: p.values['--fixture'] }); await writeFile(need('--output'), JSON.stringify(result, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); io.stdout.write(`rendered ${words[2]} to ${p.values['--output']}\n`); return; }
if (words[0] === 'validate' && words.length === 2) { allowed([], []); result = await validateOrg(await document(words[1])); if (!result.ok) throw new CliError(result.errors.map(e => `${e.path}: ${e.message}`).join('\n'), 2); io.stdout.write('valid\n'); return; }
// U1 (#1873): `org reload` is a human action; an agent run is refused before any config is read.
if (words[0] === 'reload') refuseInsideAgent(io.env);
const instance = devOwnerInstance(io.env); const system = deps.system ?? loadSystem({ env: io.env, instance }); const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER };
if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); }
else if (words[0] === 'create' && words.length === 2) { allowed([], ['--from']); result = await createOrg(ctx, words[1], await document(need('--from'))); }
else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); }
else if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json'], ['--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); const opts = { ifRevision: Number(r) }; result = words[0] === 'archive' ? await archiveOrg(ctx, words[1], { ...opts, busStop: busStopForDelete }) : await restoreOrg(ctx, words[1], opts); }
else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const r = p.values['--if-revision']; if (r !== undefined && !/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, confirm: need('--confirm'), ifRevision: r === undefined ? undefined : Number(r) }); }
// U1 (#1873): the F3 bus control from a terminal, through the running Console's own route, which
// checks the owner and writes the action trail (org-console.mjs consoleBusRestart).
else if (words[0] === 'reload' && words.length === 2) { allowed([], []); result = await consoleBusRestart(ctx, words[1], deps.consoleRequest ?? {}); }
else if (words[0] === 'show' && words.length === 2) { allowed(['--json'], []); result = await showOrg(ctx, words[1]); }
else if (words[0] === 'list' && words.length === 1) { allowed(['--json'], []); for (const entry of await listOrgs(ctx)) if (entry.state === 'active' || entry.state === 'archived') accepted.push(await showOrg(ctx, entry.id, { includeArchived: true })); result = accepted; }
else if ((words[0] === 'queue' && words.length === 3) || (words[0] === 'project' && words[1] === 'repo' && words.length === 5)) {
+187
View File
@@ -0,0 +1,187 @@
// U1 (#1873): `mosaic console restart` and `mosaic org reload ID` are the F3 page controls from a
// terminal. Each test runs the real scripts/mosaic on a scratch instance made by bootstrap.sh, and
// checks the refusal before any read, the request to the Console's own route and the action trail
// that route writes. The supervised Console is the real one; the org reload success path runs
// against an in-process Console whose bus service is a fake, so no bus host starts.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { request } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs';
import { processState } from '../../seat/src/process-state.mjs';
import { startServer } from '../../webui/src/serve.mjs';
import { createDevOwner } from '../../webui/src/dev-owner.mjs';
import { createOrgAdmin } from '../../webui/src/org-admin.mjs';
import { actionTrail } from '../../webui/src/action-trail.mjs';
import { close } from '../../webui/tests/fixture.mjs';
import { humanEnv } from './helpers.mjs';
import { consoleStart, consoleStop } from '../src/org-console.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
const MOSAIC = join(REPO, 'scripts/mosaic');
const FORBIDDEN = [7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350];
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
// A scratch instance, as bootstrap.sh makes one, with org alpha active.
async function instance(t) {
const home = mkdtempSync(join(tmpdir(), 'u1-'));
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-u1test/config.json') });
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'u1test'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-u1test'), dataRoot: join(home, '.mosaic-u1test'), instance: 'u1test', env };
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); });
return { ctx, env, trailFile: join(ctx.dataRoot, 'console/actions.jsonl') };
}
// The CLI as a child, without blocking this process: the in-process Console must keep answering.
function mosaic(env, args, timeout = 60000) {
return new Promise((resolve, reject) => {
const child = spawn(MOSAIC, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
child.stdout.setEncoding('utf8').on('data', c => { stdout += c; });
child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
child.on('error', reject);
child.on('close', status => { clearTimeout(timer); resolve({ status, stdout, stderr }); });
});
}
const trailLines = file => existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => { const { at, ...e } = JSON.parse(l); assert.ok(Number.isFinite(Date.parse(at))); return e; }) : [];
function get(port, path) {
return new Promise((resolve, reject) => {
request({ host: '127.0.0.1', port, path, headers: { host: `127.0.0.1:${port}` } }, res => {
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
}).on('error', reject).end();
});
}
// console.json naming a Console that runs in this test process (same checks as a real one).
function recordConsole(ctx, url, { pid = process.pid, startTime = processState(process.pid).startTime } = {}) {
const dir = join(ctx.dataRoot, 'console');
mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700);
const file = join(dir, 'console.json');
writeFileSync(file, JSON.stringify({ pid, startTime, url }), { mode: 0o600 }); chmodSync(file, 0o600);
}
test('both verbs refuse inside an agent run before any config is read', async () => {
// The config path is not an instance config, so a read would refuse with another message.
// humanEnv strips agent markers, so the marker goes in after it.
const env = { ...humanEnv({ MOSAIC_CONFIG: '/nonexistent/u1/config.json' }), CLAUDECODE: '1' };
for (const args of [['console', 'restart'], ['org', 'reload', 'alpha']]) {
const r = await mosaic(env, args);
assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`);
assert.match(r.stderr, /outside any agent run \(found CLAUDECODE/);
assert.equal(r.stdout, '');
}
});
test('with no live Console both verbs refuse and nothing is written', { timeout: 90000 }, async t => {
const { ctx, env, trailFile } = await instance(t);
for (const args of [['console', 'restart'], ['org', 'reload', 'alpha']]) {
const r = await mosaic(env, args);
assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`);
assert.match(r.stderr, /no Console runs for this instance/);
}
recordConsole(ctx, 'http://127.0.0.1:1/', { pid: 2147483647, startTime: '1' });
const stale = await mosaic(env, ['console', 'restart']);
assert.equal(stale.status, 3, stale.stderr);
assert.match(stale.stderr, /is not running/);
const bad = await mosaic(env, ['org', 'reload', 'Bad/Id']);
assert.equal(bad.status, 4, bad.stderr);
assert.match(bad.stderr, /invalid organization id/);
assert.equal((await mosaic(env, ['console', 'restart', 'extra'])).status, 4);
assert.equal(existsSync(trailFile), false);
});
test('mosaic console restart relaunches a supervised Console and the trail records it', { timeout: 120000 }, async t => {
const { ctx, env, trailFile } = await instance(t);
const warnings = [];
const result = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) });
t.after(async () => { result.stop(); await result.done; });
const port = Number(new URL(result.url).port);
assert.ok(!FORBIDDEN.includes(port));
const before = (await get(port, '/api/config')).body;
assert.equal(before.consoleRestart, true);
const r = await mosaic(env, ['console', 'restart']);
assert.equal(r.status, 0, r.stderr);
const answer = JSON.parse(r.stdout);
assert.equal(answer.outcome, 'restarted'); assert.equal(answer.from, before.boot);
assert.equal(typeof answer.boot, 'string'); assert.notEqual(answer.boot, before.boot);
assert.equal((await get(port, '/api/config')).body.boot, answer.boot);
assert.deepEqual(trailLines(trailFile), [{ by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot }]);
assert.equal(statSync(trailFile).mode & 0o777, 0o600);
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
});
test('a detached Console refuses mosaic console restart, and org reload of an unknown org, with no trail', { timeout: 90000 }, async t => {
const { ctx, env, trailFile } = await instance(t);
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
const port = Number(new URL(result.url).port);
assert.ok(!FORBIDDEN.includes(port));
const r = await mosaic(env, ['console', 'restart']);
assert.equal(r.status, 3, r.stderr);
assert.match(r.stderr, /console restart refused \(not-supervised\)/);
// The real organization service refuses an org that does not exist before any trail line.
const unknown = await mosaic(env, ['org', 'reload', 'nosuch']);
assert.equal(unknown.status, 3, unknown.stderr);
assert.match(unknown.stderr, /org reload refused \(org-[a-z-]+\)/);
assert.equal((await get(port, '/api/config')).status, 200);
assert.equal(existsSync(trailFile), false);
assert.equal((await consoleStop(ctx)).stopped, true);
});
// The F3 bus control through the Console's own route: a stopped bus starts and a running one restarts.
function fakeAdmin(ctx) {
const calls = [];
let running = false;
const known = id => { if (id !== 'acme') throw Object.assign(new Error('unknown organization'), { code: 'org-unknown' }); };
const service = { showOrg: async (c, id) => { known(id); return { id, root: join(ctx.dataRoot, 'org-runtime', id), revision: 1, doc: {}, principal: c.principal }; } };
const bus = {
busStatus: async (c, id) => { known(id); return { org: id, instance: ctx.instance, root: join(ctx.dataRoot, 'org-runtime', id), socket: '/run/u1.sock', healthy: running, pid: running ? 4242 : null }; },
busStart: async (c, id, opts) => { calls.push(['busStart', id, opts]); running = true; return { org: id, pid: 4242, socket: '/run/u1.sock', root: join(ctx.dataRoot, 'org-runtime', id) }; },
busRestart: async (c, id) => { calls.push(['busRestart', id]); return { org: id, pid: 4343, socket: '/run/u1.sock', root: join(ctx.dataRoot, 'org-runtime', id) }; },
};
const registry = { listOrgs: async () => [{ id: 'acme', state: 'active' }] };
return { calls, admin: createOrgAdmin({ service, registry, bus, context: async () => ({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: ctx.instance }) }) };
}
test('mosaic org reload starts or restarts the bus through the Console route and the trail records each', { timeout: 90000 }, async t => {
const { ctx, env, trailFile } = await instance(t);
const f = fakeAdmin(ctx);
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), orgAdmin: f.admin, log: () => {}, trail: actionTrail(join(ctx.dataRoot, 'console')) });
t.after(() => close(server));
const port = server.address().port;
assert.ok(!FORBIDDEN.includes(port));
recordConsole(ctx, `http://127.0.0.1:${port}/`);
let r = await mosaic(env, ['org', 'reload', 'acme']);
assert.equal(r.status, 0, r.stderr);
assert.deepEqual(JSON.parse(r.stdout), { outcome: 'done', step: 'bus-restart', restarted: false, healthy: true, receipt: { org: 'acme', pid: 4242, socket: '<host path>', root: '<data root>/org-runtime/acme' }, verb: 'mosaic bus start acme --dev-local-owner --detach' });
r = await mosaic(env, ['org', 'reload', 'acme']);
assert.equal(r.status, 0, r.stderr);
const second = JSON.parse(r.stdout);
assert.equal(second.restarted, true); assert.equal(second.verb, 'mosaic bus restart acme'); assert.equal(second.receipt.pid, 4343);
assert.deepEqual(f.calls, [['busStart', 'acme', { detach: true, devLocalOwner: true, pm: false }], ['busRestart', 'acme']]);
// An unknown org is refused by the route before the trail (as org-unavailable, which does not say
// whether it exists); extra arguments never reach the Console.
const unknown = await mosaic(env, ['org', 'reload', 'other']);
assert.equal(unknown.status, 3, unknown.stderr);
assert.match(unknown.stderr, /org reload refused \(org-unavailable\)/);
assert.equal((await mosaic(env, ['org', 'reload', 'acme', 'extra'])).status, 4);
assert.deepEqual(trailLines(trailFile), [
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: false },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: true },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
]);
// A read-only Console (no --send) refuses, and the CLI reports the Console's own reason.
await close(server);
const readonly = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), orgAdmin: f.admin, log: () => {}, trail: actionTrail(join(ctx.dataRoot, 'console')) });
t.after(() => close(readonly));
recordConsole(ctx, `http://127.0.0.1:${readonly.address().port}/`);
const denied = await mosaic(env, ['org', 'reload', 'acme']);
assert.equal(denied.status, 3, denied.stderr);
assert.match(denied.stderr, /org reload refused \(read-only\): start the Console with --send/);
assert.equal(trailLines(trailFile).length, 4);
});
+78 -1
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync, statSync } from 'node:fs';
import { get, request } from 'node:http';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
@@ -133,3 +133,80 @@ test('mosaic console start refuses the LAN flags without the dev owner and allow
assert.deepEqual(JSON.parse(none.stdout), { running: false });
assert.equal(mosaic(['console', 'status', '--json']).status, 4);
});
// F3: the owner restarts the Console from Settings. A foreground `mosaic console start` runs the
// Console with --supervised and relaunches it on the same arguments and address when it exits 75.
const WEBUI = join(REPO, 'packages/webui/src/cli.mjs');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const argvOf = pid => readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').slice(0, -1);
function call(port, path, { method = 'GET', body = null, headers = {} } = {}) {
const h = { host: `127.0.0.1:${port}`, ...(method === 'GET' ? {} : { origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }), ...headers };
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
return new Promise((resolve, reject) => {
const r = request({ host: '127.0.0.1', port, path, method, headers: h }, res => {
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch {} resolve({ status: res.statusCode, body: json }); });
});
r.on('error', reject); r.end(body ?? undefined);
});
}
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
test('a foreground console relaunches on the same address when the owner restarts it, and a stop ends it', { timeout: 90000 }, async t => {
const { ctx } = await instance(t);
const warnings = [];
const result = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) });
t.after(async () => { result.stop(); await result.done; });
const port = Number(new URL(result.url).port);
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(port));
assert.deepEqual(argvOf(result.pid), [process.execPath, WEBUI, 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner', '--supervised']);
const before = (await call(port, '/api/config')).body;
assert.equal(before.consoleRestart, true); assert.equal(typeof before.boot, 'string');
const stateFile = join(ctx.dataRoot, 'console/console.json');
const trailFile = join(ctx.dataRoot, 'console/actions.jsonl');
// Another origin or site restarts nothing.
for (const headers of [{ origin: 'http://evil.example' }, { origin: null }, { 'sec-fetch-site': 'cross-site' }, { host: `evil.example:${port}` }]) {
assert.equal((await call(port, '/api/console/restart', { method: 'POST', body: '{}', headers })).status, 403, JSON.stringify(headers));
}
assert.equal(JSON.parse(readFileSync(stateFile, 'utf8')).pid, result.pid);
assert.equal(existsSync(trailFile), false);
const answer = await call(port, '/api/console/restart', { method: 'POST', body: '{}' });
assert.equal(answer.status, 202);
assert.deepEqual(answer.body, { outcome: 'restarting', boot: before.boot });
let saved;
for (const deadline = Date.now() + 30000; ; await sleep(100)) {
saved = JSON.parse(readFileSync(stateFile, 'utf8'));
if (saved.pid !== result.pid) break;
if (Date.now() > deadline) assert.fail('the Console was not relaunched');
}
// Same address, same arguments; the auto port is now the port it had.
assert.equal(saved.url, result.url);
assert.deepEqual(argvOf(saved.pid), [process.execPath, WEBUI, 'serve', '--port', String(port), '--business', 'alpha', '--dev-local-owner', '--supervised']);
assert.equal(statSync(stateFile).mode & 0o777, 0o600);
const after = (await call(port, '/api/config')).body;
assert.notEqual(after.boot, before.boot);
assert.equal(after.consoleRestart, true);
const [line, ...more] = readFileSync(trailFile, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
assert.deepEqual(more, []);
assert.ok(Number.isFinite(Date.parse(line.at)));
assert.deepEqual({ ...line, at: undefined }, { at: undefined, by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot });
assert.equal(statSync(trailFile).mode & 0o777, 0o600);
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
assert.deepEqual(consoleStatus(ctx), { running: true, pid: saved.pid, url: result.url, bind: '127.0.0.1' });
result.stop();
assert.equal(await result.done, 0);
assert.equal(consoleStatus(ctx).running, false);
});
test('a detached console has no supervisor, so it refuses a restart and stays up', { timeout: 60000 }, async t => {
const { ctx } = await instance(t);
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
const port = Number(new URL(result.url).port);
assert.ok(!argvOf(result.pid).includes('--supervised'));
assert.equal((await call(port, '/api/config')).body.consoleRestart, false);
const r = await call(port, '/api/console/restart', { method: 'POST', body: '{}' });
assert.equal(r.status, 409); assert.equal(r.body.error, 'not-supervised');
assert.equal((await call(port, '/api/config')).status, 200);
assert.equal(existsSync(join(ctx.dataRoot, 'console/actions.jsonl')), false);
assert.equal((await consoleStop(ctx)).stopped, true);
});
+23 -6
View File
@@ -1,6 +1,6 @@
{
"prd": "docs/prd/mosaic-stack.md",
"version": "1.19",
"version": "1.20",
"requirements": [
{ "id": "REQ-ROLE-1", "title": "Each role is a reviewed roles/<name>.json: contract, authority map, credentials" },
{ "id": "REQ-ROLE-2", "title": "One holder per role instance, one per assistant session; the chief of staff may end a run, others need a decision" },
@@ -10,10 +10,13 @@
{ "id": "REQ-ROLE-6", "title": "Agents are proactive: each role contract says to act within its grants without waiting to be asked" },
{ "id": "REQ-COS-1", "title": "The chief of staff runs the organization: hires, terminates, launches; never talks to the user" },
{ "id": "REQ-COS-2", "title": "The PM asks the chief of staff for staff; a staff request names the roles and how many of each" },
{ "id": "REQ-ASSIST-1", "title": "The assistant is the user's main agent and passes organization work to the chief of staff" },
{ "id": "REQ-ASSIST-1", "title": "One assistant per user across organizations, with shared memory, passes work to each chief of staff" },
{ "id": "REQ-ASSIST-2", "title": "The broker limits the assistant to the user and the chief of staff in both directions; refusals logged" },
{ "id": "REQ-ASSIST-3", "title": "The assistant tells the user what other roles sent it in its own words and does not relay their text" },
{ "id": "REQ-COS-3", "title": "The chief of staff hires and fires role instances without a decision, within fixed bounds; the user forbids per agent" },
{ "id": "REQ-AGENT-1", "title": "Agents are configured at root, organization and project levels; a project's agents are their union" },
{ "id": "REQ-AGENT-2", "title": "The user can see and change agent, organization, project and session configuration through the write path" },
{ "id": "REQ-AGENT-3", "title": "The chief hires and fires at all levels; root-level changes need user approval" },
{ "id": "REQ-VAR-1", "title": "Variables in four layers, declared in code; limits intersect; unknown keys refused" },
{ "id": "REQ-VAR-2", "title": "One system config, never auto-written; secrets only as references the broker reads" },
{ "id": "REQ-CRED-1", "title": "Each role instance has its own Gitea and Vikunja token, held by the broker" },
@@ -37,7 +40,7 @@
{ "id": "REQ-WAIT-3", "title": "An action added to the vocabulary is a hard wait until a PRD version says it is not" },
{ "id": "REQ-MSG-1", "title": "Messages are addressed to a role, append-only, delivered to the current holder; assistant messages name a thread" },
{ "id": "REQ-MSG-2", "title": "Messages can be addressed by an instance name unique in its business; the message records the name and the instance" },
{ "id": "REQ-TASK-1", "title": "Tasks live in Vikunja, each citing a requirement id; the verbs enforce field writers" },
{ "id": "REQ-TASK-1", "title": "Each organization stores tasks through a task tracker module; Vikunja is optional" },
{ "id": "REQ-TASK-2", "title": "Polling is the source of truth: a 30-second board poll and an hourly reconcile" },
{ "id": "REQ-TASK-3", "title": "The installer points at an existing Vikunja or deploys the unmodified upstream image" },
{ "id": "REQ-TASK-4", "title": "The queue keeps the agents' lock, review rounds and audit log; no two-way sync" },
@@ -51,7 +54,15 @@
{ "id": "REQ-CLI-2", "title": "The assistant session mosaic talks to is launched and owned by the stack, not T3" },
{ "id": "REQ-WEB-1", "title": "The WebUI shows the CLI's data, a chat area of projects and threads, and Settings for agent limits" },
{ "id": "REQ-WEB-2", "title": "The Console resolves an open decision with one button per option, only for a human it signed in itself (OIDC)" },
{ "id": "REQ-WEB-3", "title": "The Console restarts the bus and reloads the organization file, gated and trailed; reload applies launch.instances live" },
{ "id": "REQ-WEB-3", "title": "Human-gated Console restart, bus restart, reload and agent model controls; dev-local owner needs no sign-in" },
{ "id": "REQ-WS-1", "title": "The WebUI always shows all organizations in a collapsible sidebar, with no switcher" },
{ "id": "REQ-WS-2", "title": "The Console builds shared views across the user's organization buses, subject to membership" },
{ "id": "REQ-WS-3", "title": "The main dashboard sums activity, work and metrics across the user's organizations and projects" },
{ "id": "REQ-WS-4", "title": "Each organization has a dashboard for docs, settings, KPIs, metrics and active sessions" },
{ "id": "REQ-WS-5", "title": "The projects list spans organizations, sorts and filters, and shows ownership" },
{ "id": "REQ-WS-6", "title": "One inbox holds items needing the user from all organizations, projects and sessions" },
{ "id": "REQ-WS-7", "title": "One view shows cross-organization activity; organizations and projects open side by side" },
{ "id": "REQ-WS-8", "title": "The tasks view aggregates every organization, with Kanban and list layouts and filters" },
{ "id": "REQ-LAUNCH-1", "title": "The chief of staff launches role sessions within the agent limits, logged and revocable; threads start on demand" },
{ "id": "REQ-LIMIT-1", "title": "Agent limits have no code ceiling and are set from WebUI Settings and mosaic: total, per provider, per model" },
{ "id": "REQ-LIMIT-2", "title": "A daily quota per OAuth account or CLIProxyAPI client key, counting only the stack's own use" },
@@ -59,6 +70,8 @@
{ "id": "REQ-LIMIT-4", "title": "Usage sources and limit checks are the first plug-in kinds; an unknown turn is logged and shown, never counted as zero" },
{ "id": "REQ-PLUG-1", "title": "Every part the core calls out to is a plug-in kind; modules load from packages/plugins, switched on in the org file" },
{ "id": "REQ-PLUG-2", "title": "New call-outs go through a plug-in kind from the start; a hard-coded part converts when a row changes it" },
{ "id": "REQ-SYS-1", "title": "System types are a plug-in kind; organizations enable a list of modules" },
{ "id": "REQ-SYS-2", "title": "Projects use enabled system types and their agents receive the corresponding toolsets" },
{ "id": "REQ-HARN-1", "title": "The meta-harness generates each session's launch bundle from role and variables" },
{ "id": "REQ-HARN-2", "title": "Credential scope, verbs, the tool limit and a container enforce the rules" },
{ "id": "REQ-EVT-1", "title": "Every slice 1 step emits an event; measures come from events" },
@@ -78,7 +91,7 @@
{ "id": "REQ-DISK-14", "title": "Events" },
{ "id": "REQ-ORG-1", "title": "Business becomes organization; an install holds 1-N, each with its own bus as tenant boundary" },
{ "id": "REQ-ORG-2", "title": "Data model: install > organization > projects > threads" },
{ "id": "REQ-USER-1", "title": "Users and RBAC at install and organization level; an agent reaches only its allowed projects and threads" },
{ "id": "REQ-USER-1", "title": "Users and RBAC at install and organization level; agents are scoped to one organization except the assistant" },
{ "id": "REQ-USER-2", "title": "Only a thread's owning user, their assistant and the chief of staff's replies reach the thread" },
{ "id": "REQ-THREAD-1", "title": "Threads live under projects" },
{ "id": "REQ-THREAD-2", "title": "The user creates projects and named threads; a thread holds independent assistant sessions, each with kept history" },
@@ -91,6 +104,8 @@
{ "id": "REQ-SESS-1", "title": "Each session shows its lifetime count of completed compactions on pi and Claude Code; an unproven count shows unknown" },
{ "id": "REQ-SESS-2", "title": "Clear context retires a session after a safe stop and starts a fresh successor with none of the old context" },
{ "id": "REQ-SESS-3", "title": "Every session start passes the launch checks: authority, route, revocation, kill switch, limits and admission" },
{ "id": "REQ-ACCT-1", "title": "Deployed systems require sign-in; users create and belong to organizations" },
{ "id": "REQ-ACCT-2", "title": "Organization owners invite users with a chosen grant" },
{ "id": "REQ-LIB-1", "title": "A human-set library maps the user's repository onto universes, books, types, playgrounds, supporting and archive folders" },
{ "id": "REQ-LIB-2", "title": "Scope comes from the path or a DOCX's source, lookups default to canon, and only canon files are ever cited as canon" },
{ "id": "REQ-LIB-3", "title": "Universes stay separate; a crossover is a hybrid universe only a human creates, reading its sources' canon" },
@@ -102,6 +117,8 @@
{ "id": "REQ-LIB-9", "title": "The Console's DOCX editor: only a human saves, each save is one commit, and the editor code is vendored and pinned" },
{ "id": "REQ-INST-1", "title": "The installer offers optional components: pinned, health-checked, removable, switched on in config; first is SearXNG" },
{ "id": "REQ-INST-2", "title": "With no OIDC provider to configure, the installer deploys a bundled Pocket ID, Q203 (post-v1)" },
{ "id": "REQ-INST-3", "title": "One-command install of a named instance from a profile, and one-command upgrade with backup and rollback" }
{ "id": "REQ-INST-3", "title": "One-command install of a named instance from a profile, and one-command upgrade with backup and rollback" },
{ "id": "REQ-SETUP-1", "title": "Quick Setup initializes the project records" },
{ "id": "REQ-SETUP-2", "title": "The assistant offers an onboarding interview and drafts a north star and first PRD for user approval" }
]
}
+5 -5
View File
@@ -18,12 +18,12 @@ test("the list holds the PRD's requirement ids, in its order, at its version", (
const ids = [...prd.matchAll(/^- \*\*(REQ-[A-Z]+-[0-9]+)\.\*\*/gm)].map((m) => m[1]);
// Any other mention of a bold id would be a bullet this pattern missed.
assert.equal(ids.length, [...prd.matchAll(/\*\*REQ-/g)].length);
assert.equal(ids.length, 101);
assert.equal(ids.length, 118);
assert.deepEqual(list.requirements.map((r) => r.id), ids);
assert.match(prd, new RegExp(`^- Status: approved, version ${list.version.replace('.', '\\.')}\\.`, 'm'));
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.19']);
assert.match(prd, /^- Status: approved, version 1\.19\. Jason approved the exact text in Q289\.$/m);
assert.match(prd, /^- Prior approval history, through version 1\.18:/m);
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.20']);
assert.match(prd, /^- Status: approved, version 1\.20\. Jason approved draft 2 on 2026-11-09\.$/m);
assert.match(prd, /^- Prior approval history, through version 1\.19:/m);
});
test('the committed list loads frozen', () => {
@@ -76,7 +76,7 @@ test('the schema refuses any other shape', () => {
const twice = raw();
twice.requirements.push(structuredClone(twice.requirements[0]));
writeFileSync(join(dir, 'twice.json'), JSON.stringify(twice));
assert.throws(() => loadRequirements(join(dir, 'twice.json')), listError('requirements[101].id: REQ-ROLE-1 twice'));
assert.throws(() => loadRequirements(join(dir, 'twice.json')), listError('requirements[118].id: REQ-ROLE-1 twice'));
} finally {
rmSync(dir, { recursive: true, force: true });
}
+25 -6
View File
@@ -20,6 +20,8 @@ baseline CLI.
| Recover a stopped bus with stale host state | `mosaic bus recover <id>` | CLI only |
| Inspect or clear a dead lifecycle lock | `mosaic org lock show <id>` / `mosaic org lock clear <id>` | CLI only |
| Start, stop or restart a bus | `mosaic bus start <id>` / `mosaic bus stop <id>` / `mosaic bus restart <id>` | S6 integration hook |
| Restart (or first start) an organization's bus | `mosaic bus restart <id>`, or `mosaic bus start <id>` for a bus never started | Enabled (F3): the owner's **Restart bus** control, offered where a page says the bus is down or a change needs a restart; trailed |
| Restart the Console | Stop and run `mosaic console start` again | Enabled (F3) when `mosaic console start` runs it in the foreground: Settings > Console; the owner only; trailed. Other Consoles answer `not-supervised` |
| Create chat project linked to a configured repository | `mosaic project create <name> --repo <project> --org <id>` | Enabled |
| Create a thread | `mosaic thread create <project> <name> --org <id>` | Planned enabled |
| Open a thread | `mosaic thread open <thread> --org <id>` | Planned enabled |
@@ -34,19 +36,36 @@ baseline CLI.
| Mark a board session seen/unseen; reply to a board seat | No CLI equivalent identified | Unavailable |
| Approve or deny a decision | `mosaic decide <id> <choice> --org <id>` | Owner-only confirmation and resolution on the selected organization bus |
| Answer, attach, change mode, cancel or revoke a request | No equivalent CLI identified | Unavailable |
| Edit or reset launch/assistant limits | `mosaic limits set` / `mosaic limits reset` | Unavailable pending parity |
| Rename an instance | `mosaic name` | Unavailable pending parity |
| Stop an assistant | No equivalent CLI identified | Unavailable |
| Add, change or delete an instance; launch, regenerate or terminate an agent; change a veto | `mosaic org instance add`, `set`, `delete`, `launch`, `regenerate`, `terminate`; `mosaic org veto` (old spelling `mosaic business instance`, `mosaic business veto`) | Unavailable pending parity |
| Change routes or enabled harnesses/providers | `mosaic org instance set`; `mosaic plugins set` | Unavailable pending parity |
| Edit or reset launch/assistant limits | `mosaic limits set` / `mosaic limits reset` | Enabled (F3) for the owner on a selected organization's bus; unavailable on other Consoles pending parity |
| Rename an instance | `mosaic name` | Enabled (F3) for the owner on a selected organization's bus; unavailable elsewhere pending parity |
| Stop an assistant | No equivalent CLI identified | Enabled (F3) for the owner on a selected organization's bus; unavailable elsewhere |
| Add, change or delete an instance; launch, regenerate or terminate an agent; change a veto | `mosaic org instance add`, `set`, `delete`, `launch`, `regenerate`, `terminate`; `mosaic org veto` (old spelling `mosaic business instance`, `mosaic business veto`) | Enabled (F3) for the owner on a selected organization's bus, with **Restart NAME now** (terminate, then launch) after a route change; unavailable elsewhere pending parity |
| Change routes or enabled harnesses/providers | `mosaic org instance set`; `mosaic plugins set` | Enabled (F3) for the owner on a selected organization's bus; the page lists how the change reaches each saved agent; unavailable elsewhere pending parity |
| Halt or resume processing | `mosaic halt` / `mosaic resume` | Unavailable pending parity |
| Save a project file or revert library content | No equivalent CLI identified | Unavailable |
| Change a project remote | No equivalent CLI identified | Unavailable |
| Start, submit or cancel proxy sign-in; add an API key; set account priority or models | No equivalent CLI identified | Unavailable |
| Start, submit or cancel proxy sign-in; add an API key; set account priority or models | No equivalent CLI identified | Enabled (F3) for the owner on a selected organization's bus once its proxy is set up; setup itself stays `mosaic proxy init` in a terminal |
Browser navigation, local appearance preferences and unsaved drafts are local
state, not server writes. Signing out is a session action.
F3 writes (Console restart, bus restart, and the owner's routing, agent and
instance changes on an organization) append one line each to
`<dataRoot>/console/actions.jsonl` (0600): who (provider, issuer, subject,
name), when, what and the outcome. A restart that cannot write its line is
refused. On a Console with sign-in, no tester, granted user or API token
reaches these controls: each needs the signed-in owner, the page's exact Origin
and `Sec-Fetch-Site: same-origin`. Those headers stop another site's page, not
a local program, which can send any header it likes.
A dev local owner Console (`--dev-local-owner`) has no sign-in. Every request
that passes the Host and Origin checks acts as the owner, and this includes the
F3 controls. An agent launched on the same machine runs as the same OS user, so
it can restart the Console or a bus and change routing, agents and settings just
as the owner's page can. The startup warning says so. Use a dev instance for UI
testing only. The real boundary is sign-in (`--sso`); a refusal for local agent
peers in dev mode is a recorded follow-up.
### Route registration
A module may export `registerRoutes(table)` and be included with one entry in
+24
View File
@@ -0,0 +1,24 @@
// F3: the Console's trail of human operations (Console restart, org bus restart, route and
// instance changes made from the page). One JSON line per use: who, when, what, outcome.
// The file is <dataRoot>/console/actions.jsonl, owner-only. A write that cannot be made safely
// throws, and the caller refuses the operation (fail closed).
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, writeSync } from 'node:fs';
import { join } from 'node:path';
// The person, never a token: provider, issuer, subject and display name of the session.
export const actor = session => ({ provider: session.provider, iss: session.iss, sub: session.sub, ...(typeof session.name === 'string' ? { name: session.name } : {}) });
export function actionTrail(dir) {
const file = join(dir, 'actions.jsonl');
return entry => {
mkdirSync(dir, { recursive: true, mode: 0o700 });
const st = lstatSync(dir);
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077) !== 0) throw new Error('unsafe trail directory');
const fd = openSync(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
try {
const f = fstatSync(fd);
if (!f.isFile() || f.uid !== process.getuid() || (f.mode & 0o777) !== 0o600 || f.nlink !== 1) throw new Error('unsafe trail file');
writeSync(fd, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`);
} finally { closeSync(fd); }
};
}
+21 -38
View File
@@ -27,19 +27,24 @@ export const subjectOk = s => typeof s === 'string' && s.length > 0 && s.length
// Same protocol as humanTransport in packages/cli/src/transport.mjs: the
// request on stdin, JSON on stdout, or a bus error code as the last
// `^[a-z-]{1,64}$` line of stderr.
export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
return (verb, args = {}) => transport({ cli, socket, env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, input: { business, verb, args }, timeoutMs, maxBytes });
export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024, signal, deadline }) {
return (verb, args = {}) => transport({ cli, socket, signal, deadline, env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, input: { business, verb, args }, timeoutMs, maxBytes });
}
function transport({ cli, socket, env, input, timeoutMs, maxBytes }) {
function transport({ cli, socket, env, input, timeoutMs, maxBytes, signal, deadline }) {
return new Promise((resolve, reject) => {
const remaining = deadline === undefined ? timeoutMs : Math.min(timeoutMs, deadline - Date.now());
if (signal?.aborted || (deadline !== undefined && remaining <= 0)) return reject(new BusReadError('outcome-unknown', 'the bus transport deadline expired'));
let child;
try {
child = spawn(process.execPath, [cli, socket], { env, stdio: ['pipe', 'pipe', 'pipe'] });
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
const out = [], err = [];
let size = 0, settled = false;
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs);
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); signal?.removeEventListener('abort', abort); fn(value); } };
const abort = () => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport deadline expired')); };
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, remaining);
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) abort();
child.stdout.on('data', chunk => {
size += chunk.length;
if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); }
@@ -82,9 +87,10 @@ export function consoleCall({ dataRoot, socket, business = null, env = process.e
// The business is the --business flag, or else the live bus host's, read
// on every request so a host started after the Console is picked up.
export function busCaller({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000 }) {
export function busCaller({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, signal, deadline }) {
return async (verb, args) => {
return humanCall({ socket, business: business || hostBusiness(dataRoot), env, cli, timeoutMs })(verb, args);
if (signal?.aborted || (deadline !== undefined && Date.now() >= deadline)) throw new BusReadError('outcome-unknown', 'the bus transport deadline expired');
return humanCall({ socket, business: business || hostBusiness(dataRoot), env, cli, timeoutMs, signal, deadline })(verb, args);
};
}
function hostBusiness(dataRoot) {
@@ -103,36 +109,10 @@ export const USER_CLI = fileURLToPath(new URL('../../bus/src/user-cli.mjs', impo
// The Console sets the user nonce itself, so user-cli.mjs doesn't re-exec and the broker sees the
// Console (`cli.mjs serve`) as its parent (principal.mjs). The human nonce is dropped, so this child
// can never pass as human-cli, and it only ever runs USER_CLI.
export function userCall({ socket, business, user, env = process.env, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
export function userCall({ socket, business, user, env = process.env, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024, signal, deadline }) {
const { MOSAIC_BUS_CLI_NONCE, ...rest } = env;
return (verb, args = {}) => new Promise((resolve, reject) => {
let child;
try {
child = spawn(process.execPath, [USER_CLI, socket], { env: { ...rest, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') }, stdio: ['pipe', 'pipe', 'pipe'] });
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
const out = [], err = [];
let size = 0, settled = false;
const done = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } };
const timer = setTimeout(() => { child.kill('SIGKILL'); done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish in time')); }, timeoutMs);
child.stdout.on('data', chunk => {
size += chunk.length;
if (size > maxBytes) { child.kill('SIGKILL'); return done(reject, new BusReadError('response-too-large')); }
out.push(chunk);
});
child.stderr.on('data', chunk => { if (err.length < 64) err.push(chunk); });
child.on('error', () => done(reject, new BusReadError('outcome-unknown', 'the bus transport did not start')));
child.on('close', status => {
if (status === null) return done(reject, new BusReadError('outcome-unknown', 'the bus transport did not finish'));
if (status !== 0) {
const code = Buffer.concat(err).toString('utf8').trim().split('\n').reverse().find(l => /^[a-z-]{1,64}$/.test(l)) ?? 'invalid-response';
return done(reject, new BusReadError(code));
}
try { done(resolve, JSON.parse(Buffer.concat(out).toString('utf8'))); }
catch { done(reject, new BusReadError('invalid-response')); }
});
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
child.stdin.end(`${JSON.stringify({ business, user, verb, args })}\n`);
});
return (verb, args = {}) => transport({ cli: USER_CLI, socket, env: { ...rest, MOSAIC_BUS_USER_NONCE: randomBytes(32).toString('hex') },
input: { business, user, verb, args }, timeoutMs, maxBytes, signal, deadline });
}
// The business is the --business flag, or else the live bus host's, read on every call, as busCaller does.
@@ -147,9 +127,12 @@ export function busBusiness({ dataRoot, business = null }) {
}
// One granted user's caller, for that user's session only.
export function userCaller({ dataRoot, socket, business = null, user, env = process.env, timeoutMs = 30000 }) {
export function userCaller({ dataRoot, socket, business = null, user, env = process.env, timeoutMs = 30000, signal, deadline }) {
const which = busBusiness({ dataRoot, business });
return async (verb, args) => userCall({ socket, business: which(), user, env, timeoutMs })(verb, args);
return async (verb, args) => {
if (signal?.aborted || (deadline !== undefined && Date.now() >= deadline)) throw new BusReadError('outcome-unknown', 'the bus transport deadline expired');
return userCall({ socket, business: which(), user, env, timeoutMs, signal, deadline })(verb, args);
};
}
// A send's error, sorted. The broker writes in one transaction, so any code it
+24 -9
View File
@@ -22,7 +22,9 @@ import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.m
import { createSso, readSlot } from './sso.mjs';
import { readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { isAbsolute } from 'node:path';
import { isAbsolute, join } from 'node:path';
import { actionTrail } from './action-trail.mjs';
import { RESTART_EXIT } from '../../cli/src/org-console.mjs';
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
import { selectorScope } from '../../organization/src/selector.mjs';
// The bus verifies the Console's exact same-tree script in /proc, whose argv preserves a
@@ -40,12 +42,12 @@ if (process.platform === 'linux') {
}
}
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--supervised] [--public-host NAME --auth-header HEADER --allow-user ID [--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, testerSocket = false, devOwner = false, devBind = null;
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false, devBind = null, supervised = false;
const devNames = [];
// --org and --business are one selector in this parser's scope (rename design r2). A flag-shaped
// or invalid value keeps the old wording.
@@ -62,6 +64,8 @@ try {
if (flag === '--dev-local-owner') { devOwner = true; continue; }
if (flag === '--sso') { ssoOn = true; continue; }
if (flag === '--tester-socket') { testerSocket = true; continue; }
// F3: `mosaic console start` supervises this process and relaunches it on exit 75.
if (flag === '--supervised') { supervised = true; continue; }
if (flag === '--tester-port') throw new Error('--tester-port retired; activate explicitly with --tester-socket');
const value = args.shift();
if (!value) throw new Error(`missing value for ${flag}`);
@@ -152,9 +156,12 @@ try {
const organizations = devOwner ? organizationConsole({
ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance },
listOrgs, showOrg, resolveOrg, orgPaths, busStatus: orgBusStatus,
options: ({ id, root, paths, session }) => {
const owner = busCaller({ dataRoot: root, socket: paths.socket, business: id });
const call = session.user === undefined ? owner : userCaller({ dataRoot: root, socket: paths.socket, business: id, user: session.user });
// U1 (#1873): the cross-organization read passes `timeoutMs` so a slow bus's children are
// killed at its deadline. The org pages pass none and keep the transport default.
options: ({ id, root, paths, session, timeoutMs, signal, deadline }) => {
const bound = timeoutMs === undefined ? {} : { timeoutMs, signal, deadline };
const owner = busCaller({ dataRoot: root, socket: paths.socket, business: id, ...bound });
const call = session.user === undefined ? owner : userCaller({ dataRoot: root, socket: paths.socket, business: id, user: session.user, ...bound });
return {
...consoleBus(call, { send: sendOn, ...(session.user === undefined ? {} : { owner }) }),
decisionWrite: session.user === undefined && sendOn ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
@@ -165,11 +172,19 @@ try {
};
},
}) : null;
let server;
let server, testerServer = null;
// F3: the restart route closes every listener and connection, then exits RESTART_EXIT for the
// supervisor to relaunch. Without --supervised the route answers not-supervised.
const restart = supervised ? () => {
sso?.close(); testerServer?.close();
setTimeout(() => process.exit(RESTART_EXIT), 2000).unref();
server.close(() => process.exit(RESTART_EXIT));
server.closeAllConnections();
} : null;
const trail = system ? actionTrail(join(system.dataRoot, 'console')) : null;
try {
server = await startServer({ ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, preselect: options.business ?? null });
server = await startServer({ restart, trail, ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, preselect: options.business ?? null });
} catch (err) { sso?.close(); throw err; }
let testerServer = null;
if (testerSocket) {
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') };
@@ -0,0 +1,39 @@
// F3: POST /api/console/restart. The owner restarts the Console from Settings.
//
// The route table's hook fence has already required a signed-in session on the page's own name,
// the exact Origin and Sec-Fetch-Site: same-origin. This handler adds the owner, --send, a JSON
// `{}` body and the trail. The Console then closes and exits RESTART_EXIT, and the supervisor in
// `mosaic console start` relaunches it on the same arguments and address. Org buses are separate
// processes and keep running. A Console with no supervisor answers not-supervised and stays up.
import { actor } from './action-trail.mjs';
const json = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(JSON.stringify(body));
};
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
async function emptyBody(req) {
if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(one(req, 'content-type') ?? '')) return 'JSON body required';
let text = '';
for await (const chunk of req) { text += chunk; if (text.length > 64) return 'body too large'; }
let value;
try { value = JSON.parse(text); } catch { return 'invalid JSON'; }
return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0 ? null : 'the body is {}';
}
export async function consoleRestartHTTP(req, res, { session = null, owner = false, send = false, restart = null, trail = null, boot, log = () => {} }) {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'restarting the Console is the owner\'s' });
if (!send) return json(res, 403, { error: 'read-only', message: 'start the Console with --send to make changes' });
const invalid = await emptyBody(req);
if (invalid) return json(res, invalid === 'JSON body required' ? 415 : 400, { error: 'invalid-request', message: invalid });
if (!restart) return json(res, 409, { error: 'not-supervised', message: 'This Console was not started in the foreground by mosaic console start, so nothing would start it again. Restart it where it was started.' });
if (!trail) return json(res, 503, { error: 'trail-unavailable', message: 'this Console has no action trail, so it does not restart itself' });
try { trail({ by: actor(session), action: 'console.restart', outcome: 'restarting', boot }); }
catch { return json(res, 500, { error: 'trail-unavailable', message: 'the action trail could not be written, so the Console did not restart' }); }
log('console: restart requested by the owner');
// The answer reaches the page first; then every listener and connection closes.
res.once('finish', () => restart());
return json(res, 202, { outcome: 'restarting', boot });
}
+50 -4
View File
@@ -16,6 +16,7 @@
// POST /api/orgs/ID/setup/queue C2 queueInit mosaic org queue init
// POST /api/orgs/ID/setup/project/P C2 projectRepoInit mosaic org project repo init
// POST /api/orgs/ID/setup/bus C3 busStart mosaic bus start --detach
// POST /api/orgs/ID/restart/bus C3 busRestart mosaic bus restart (F3; busStart when stopped)
//
// GET /api/orgs (the list) and other /api/orgs/ID/... routes are S5's; this
// handler returns false for them.
@@ -25,12 +26,13 @@ import { lstatSync } from 'node:fs';
import { checkProject, initProject, projectRepo, recordContext } from '../../project/src/index.mjs';
import { refuseInsideAgent } from '../../cli/src/transport.mjs';
import { join } from 'node:path';
import { actor } from './action-trail.mjs';
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
const MAX_BODY = 64 * 1024;
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore|restart)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
// The org-template placeholder (packages/organization/src/org-service.mjs templateRender).
export const placeholderRoot = (id, project) => `/__mosaic_org__/${id}/projects/${project}`;
// Project fields the server owns. The browser never sees or sends them.
@@ -47,6 +49,7 @@ export function verbFor(step, id, opts = {}) {
if (step === 'delete') return `mosaic org delete ${id} --confirm ${id} --if-revision ${opts.revision}`;
if (step === 'records') return `mosaic project init ${id} ${opts.project}`;
if (step === 'bus') return `mosaic bus start ${id} --dev-local-owner --detach`;
if (step === 'bus-restart') return `mosaic bus restart ${id}`;
throw new Error(`unknown step ${step}`);
}
@@ -295,7 +298,8 @@ export function createOrgAdmin({ service, registry = null, bus = null, context }
// The handler. Returns true when it answered. `owner` is the signed-in owner
// session (provider, iss, sub) or null; `send` says --send is on; `origin` is
// the page's own origin for the write fence.
export async function orgAdminHTTP(req, res, { path, search = '', admin, session = null, owner = false, send = false, origin = null, log = () => {} }) {
// `trail` (F3) records each bus restart: who, when, which organization and the outcome.
export async function orgAdminHTTP(req, res, { path, search = '', admin, session = null, owner = false, send = false, origin = null, log = () => {}, trail = null }) {
const org = path.match(ROUTE);
const top = ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin'].includes(path) || (path === '/api/orgs' && req.method === 'POST');
if (!org && !top) return false;
@@ -311,7 +315,7 @@ export async function orgAdminHTTP(req, res, { path, search = '', admin, session
const principal = { provider: session.provider, iss: session.iss, sub: session.sub };
if (![principal.provider, principal.iss, principal.sub].every(v => typeof v === 'string' && v)) { json(res, 403, { error: 'owner-only' }); return true; }
try {
await route(req, res, { path, search, org, admin, principal, write, log });
await route(req, res, { path, search, org, admin, principal, write, log, trail, by: actor(session) });
} catch (error) {
if (error?.status) json(res, error.status, { error: error.message, ...(error.detail ? { message: error.detail } : {}), ...(error.errors ? { errors: error.errors } : {}) });
else { log(`org-admin: ${path}: ${error?.code ?? error?.name ?? 'error'}`); json(res, 500, { error: 'internal' }); }
@@ -385,7 +389,7 @@ const gitOptions = (body, res, step) => {
return null;
};
async function route(req, res, { path, search, org, admin, principal, write, log }) {
async function route(req, res, { path, search, org, admin, principal, write, log, trail = null, by = null }) {
if (path === '/api/org-presets' && !write) {
if (new URLSearchParams(search).size) return json(res, 422, { error: 'invalid', message: 'unexpected query parameter' });
const service = await need(admin, 'service', res, 'presets'); if (!service) return;
@@ -508,6 +512,7 @@ async function route(req, res, { path, search, org, admin, principal, write, log
if (kind === 'setup' && !sub && !write) {
try { return json(res, 200, await readSetup(admin, ctx, id)); } catch (e) { return refusal(res, e, { step: 'setup', id }); }
}
if (kind === 'restart') return restartBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by });
if (kind !== 'setup' || !sub || !write) return json(res, 404, { error: 'not found' });
// Every setup write reads the step back first and never overwrites.
@@ -553,3 +558,44 @@ async function route(req, res, { path, search, org, admin, principal, write, log
}
return json(res, 404, { error: 'not found' });
}
// F3 (REQ-WEB-3): one control restarts an organization's bus and answers with a done receipt. A
// running host restarts on the options it was started with (C3 busRestart keeps --dev-local-owner);
// a stopped one starts as setup starts it. The trail line is written before anything changes, and
// a trail that cannot be written refuses the restart. Stale host state is never cleared here.
async function restartBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by }) {
const step = 'bus-restart';
if (sub !== 'bus' || !write) return json(res, 404, { error: 'not found' });
const body = await readBody(req); strictKeys(body, []);
try { await service.showOrg(ctx, id); } catch (e) { return refusal(res, e, { step, id }); }
const bus = await need(admin, 'bus', res, step); if (!bus) return;
if (!trail) return json(res, 503, { error: 'trail-unavailable', step, message: 'this Console has no action trail, so it does not restart buses' });
let status;
try { status = await bus.busStatus(ctx, id); }
catch (e) {
if (['org-unknown', 'org-mismatch', 'org-unavailable', 'org-archived'].includes(e?.code)) return refusal(res, e, { step, id });
if (isCliError(e)) {
// Refused before anything changed, but the owner asked: the trail records the attempt.
try { trail({ by, action: 'bus.restart', org: id, outcome: 'refused', running: null }); } catch { log(`org-admin: ${id} bus restart: trail write failed after refused`); }
return json(res, 409, { error: 'bus-refused', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}; mosaic bus recover ${id} clears a host that died.` });
}
return refusal(res, e, { step, id });
}
const running = Number.isSafeInteger(status?.pid);
try { trail({ by, action: 'bus.restart', org: id, outcome: 'requested', running }); }
catch { return json(res, 500, { error: 'trail-unavailable', step, message: 'the action trail could not be written, so nothing was restarted' }); }
const note = outcome => { try { trail({ by, action: 'bus.restart', org: id, outcome }); } catch { log(`org-admin: ${id} bus restart: trail write failed after ${outcome}`); } };
let receipt;
try {
receipt = running ? await bus.busRestart(ctx, id) : await bus.busStart(ctx, id, { detach: true, devLocalOwner: by?.provider === 'local-dev', pm: false });
} catch (e) {
note('refused');
if (!isCliError(e)) return refusal(res, e, { step, id, unknownOk: true });
return json(res, 409, { error: /^bus failed to start\b/.test(e.message) ? 'bus-failed' : 'bus-refused', step, message: plainMessage(ctx, e.message) });
}
note('done');
log(`org-admin: ${id} bus ${running ? 'restarted' : 'started'}`);
let healthy = null;
try { healthy = (await bus.busStatus(ctx, id)).healthy; } catch { /* the receipt still stands */ }
return json(res, 200, { outcome: 'done', step, restarted: running, healthy, receipt: forReceipt(ctx, receipt), verb: running ? verbFor('bus-restart', id) : verbFor('bus', id) });
}
+32
View File
@@ -35,8 +35,40 @@ export function organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPat
}
return result;
}
// U1 (#1873): the orgs a session belongs to, for the cross-organization read (workspace-read.mjs).
// The same admission as `list` and `resolve`: an org the session does not own is skipped, and a
// broken org shows only to its verified owner, as invalid. Bus status and options are lazy, so a
// stopped bus never gets a transport; `timeoutMs` bounds each bus read the options make.
async function members(session, { timeoutMs } = {}) {
const entries = await listOrgs(ctx), result = [];
for (const entry of entries) {
if (entry.state !== 'active') continue;
let verified;
try { verified = await admitted(entry.id, session); }
catch (error) {
if (error?.ownerVerified !== true) continue;
result.push({ id: entry.id, invalid: true, reason: publicReason(ctx, error) });
continue;
}
const { org, shown, context } = verified, id = entry.id;
result.push({
id, doc: shown.doc,
// Host paths in a refusal or boot reason become relative to the instance before they leave.
async status(bound) {
let s;
try { s = await busStatus(ctx, id, bound); }
catch (error) { throw new Error(plainMessage(ctx, typeof error?.message === 'string' ? error.message : 'bus state could not be read')); }
const boot = s?.boot && typeof s.boot === 'object' ? { ok: s.boot.ok, at: s.boot.at, lastGood: s.boot.lastGood ?? null, reason: s.boot.reason == null ? null : plainMessage(ctx, s.boot.reason) } : null;
return { healthy: s?.healthy === true, pid: Number.isSafeInteger(s?.pid) ? s.pid : null, boot };
},
options: bound => options({ id, root: org.root, paths: orgPaths(org.root), session, context, doc: shown.doc, ...(timeoutMs === undefined ? {} : { timeoutMs }), ...bound }),
});
}
return result;
}
return Object.freeze({
list,
members,
async resolve(id, session) {
const { org, shown, context } = await admitted(id, session);
let busHealthy = false;
+84 -7
View File
@@ -10,8 +10,12 @@
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.';
// The same text bus.js shows for an instances.set answer (bus.js RESTART_TEXT, NO_RESTART_TEXT).
const RESTART_TEXT = 'Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
// The same text bus.js shows for an instances.set answer (bus.js restartText(), NO_RESTART_TEXT).
// F3: on an organization Console its owner restarts the bus from the receipt (console-restart.js).
const restartText = () => window.MosaicConsole?.busRestart
? 'Restart needed: the running bus keeps the roles it loaded at start. Restart the bus to load the change.'
: 'Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
const busControl = text => (String(text).includes('Restart needed:') ? window.MosaicConsole?.busButton('Restart bus') ?? '' : '');
const NO_RESTART_TEXT = 'No restart: the launcher reads the change at its next launch check.';
const DONE = { launch: 'Launched', regenerate: 'Regenerated', end: 'Terminated', delete: 'Deleted' };
let ctx = null, view = null, busy = false, receipt = '', confirming = null, pending = null, generation = 0;
@@ -30,7 +34,7 @@
const { txt, esc } = c, title = '<h2 id="agents-title" class="sr-only">Agents</h2>';
if (!v) return `<section class="panel" id="agents-section">${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':'', addOff=!open||busy||pending||c.instanceBusyOps.includes('add')?' disabled':'', changeOff=!open||busy||pending||c.instanceBusyOps.includes('change')?' disabled':'', defs=Object.keys(v.definitions||{}), accepted=defs.filter(d=>v.definitions[d]?.accepted);
const instanceStatus=op=>{const text=c.instanceReceipts[op]||'', current=c.instanceReceiptOp===op||(!c.instanceReceiptOp&&op==='add');return `<p role="status" data-instance-receipt="${op}"${current?' id="instances-receipt"':''} class="${text.startsWith('Outcome unknown')?'banner ref':text.includes('Draft kept')?'banner err':text.startsWith('Added')||text.startsWith('Changed')?'settings-success':''}">${txt(text)}</p>`;};
const instanceStatus=op=>{const text=c.instanceReceipts[op]||'', current=c.instanceReceiptOp===op||(!c.instanceReceiptOp&&op==='add');return `<p role="status" data-instance-receipt="${op}"${current?' id="instances-receipt"':''} class="${text.startsWith('Outcome unknown')?'banner ref':text.includes('Draft kept')?'banner err':text.startsWith('Added')||text.startsWith('Changed')?'settings-success':''}">${txt(text)}</p>${busControl(text)}`;};
const option=(name,selected,first)=>`<option value="">${first}</option>${accepted.map(d=>`<option value="${esc(d)}"${d===selected?' selected':''}>${txt(d)}</option>`).join('')}`;
const button=(action,name,label)=>`<button type="button"${['keep','unkeep'].includes(action)?' role="menuitem"':''} data-agent-action="${esc(action)}" data-agent-name="${esc(name)}"${off}>${txt(label)}</button>`;
const rows=Object.entries(v.instances).map(([name,e])=>{
@@ -45,7 +49,7 @@
const noHire=Array.isArray(v.noHire)?v.noHire:[], retired=Array.isArray(v.retired)?v.retired:[];
const hiring=!c.isTester?`<section class="panel"><h3>Hiring</h3><p>Turn hiring off to stop new agents of a definition.</p><div class="hiring-grid">${defs.map(n=>`<div>${open?`<button type="button" role="switch" aria-label="Hiring allowed for ${esc(n)}" aria-checked="${!noHire.includes(n)}" data-agent-action="${noHire.includes(n)?'hire':'nohire'}" data-agent-name="${esc(n)}"${off}><span class="switch-track"></span></button>`:`<span>${noHire.includes(n)?'Off':'On'}</span>`}<span class="hiring-name">${txt(n)}</span></div>`).join('')}</div>${retired.length?`<p>Retired names, never reused: ${retired.map(txt).join(', ')}.</p>`:''}</section>`:'';
const state=pending?`<p id="agents-pending">Request <code>${txt(pending.request)}</code> to ${txt(pending.action)} ${txt(pending.instance)}: ${txt(pending.state)}.</p>`:'';
return `<div id="agents-section">${title}${state}<section class="panel"><div class="table-wrap" role="region" aria-label="Agents"><table id="agents-table"><thead><tr><th id="agent-col-name" scope="col">Agent</th><th id="agent-col-definition" scope="col">Definition</th><th id="agent-col-launch" scope="col">Launch</th><th id="agent-col-kept" scope="col">Kept</th><th id="agent-col-running" scope="col">Running</th><th scope="col">Actions</th></tr></thead><tbody>${rows}</tbody></table></div><p role="status" id="agents-receipt">${txt(receipt)}</p>${!open||c.isTester?instanceStatus('add'):''}</section>${add}${hiring}<dialog id="agent-dialog" aria-labelledby="agent-dialog-title"><h2 id="agent-dialog-title"></h2><p id="agent-dialog-copy"></p><label id="agent-reason-label" hidden>Reason <input id="agent-dialog-reason" maxlength="500"></label><div><button type="button" id="agent-dialog-confirm">Confirm</button><button type="button" id="agent-dialog-cancel">Cancel</button></div></dialog></div>`;
return `<div id="agents-section">${title}${state}<section class="panel"><div class="table-wrap" role="region" aria-label="Agents"><table id="agents-table"><thead><tr><th id="agent-col-name" scope="col">Agent</th><th id="agent-col-definition" scope="col">Definition</th><th id="agent-col-launch" scope="col">Launch</th><th id="agent-col-kept" scope="col">Kept</th><th id="agent-col-running" scope="col">Running</th><th scope="col">Actions</th></tr></thead><tbody>${rows}</tbody></table></div><p role="status" id="agents-receipt">${txt(receipt)}</p>${busControl(receipt)}${!open||c.isTester?instanceStatus('add'):''}</section>${add}${hiring}<dialog id="agent-dialog" aria-labelledby="agent-dialog-title"><h2 id="agent-dialog-title"></h2><p id="agent-dialog-copy"></p><label id="agent-reason-label" hidden>Reason <input id="agent-dialog-reason" maxlength="500"></label><div><button type="button" id="agent-dialog-confirm">Confirm</button><button type="button" id="agent-dialog-cancel">Cancel</button></div></dialog></div>`;
}
// One POST. `null` means the page signed out; otherwise the status and the parsed answer.
@@ -79,7 +83,7 @@
const { ok, result } = answer;
if (!ok) { receipt = refused(result); redraw(); return; }
// Row 194 b6 (webby-0094 B1): a veto says whether the reload took, as a create does.
if (url === '/api/veto') { receipt = `${{ keep: 'Kept', unkeep: 'Unkept', nohire: 'No hire for', hire: 'Hiring allowed for' }[action]} ${name}. ${result.restart ? RESTART_TEXT : NO_RESTART_TEXT}`; reread(); return; }
if (url === '/api/veto') { receipt = `${{ keep: 'Kept', unkeep: 'Unkept', nohire: 'No hire for', hire: 'Hiring allowed for' }[action]} ${name}. ${result.restart ? restartText() : NO_RESTART_TEXT}`; 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 };
@@ -122,7 +126,7 @@
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}. ${answer.result.restart ? RESTART_TEXT : NO_RESTART_TEXT}`; reread(); }
if (answer.ok && /^[a-f0-9]{64}$/.test(answer.result?.revision ?? '')) { create = { instance: '', definition: '' }; receipt = `Created ${instance}. ${answer.result.restart ? restartText() : NO_RESTART_TEXT}`; reread(); }
else { receipt = answer.ok ? UNKNOWN : `${refused(answer.result)} Draft kept.`; redraw(); }
}
@@ -151,5 +155,78 @@
// 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 });
// F3: restart an agent so a route change reaches it now, offered by the Routing receipt. One
// terminate; then reads of the instances until the run has left (up to the 60 s regenerate
// allows); then one launch. A thread definition is not launched: its next message starts it.
// A refusal, a sign-out or an unknown outcome stops the chain and says so; nothing is retried.
const restarts = Object.create(null);
const RESTART_WAIT = 30;
function restartMarkup(name, thread = false) {
const r = restarts[name];
return `<span class="agent-restart"><button type="button" class="btn" data-agent-restart="${esc(name)}" data-agent-thread="${thread ? 'yes' : 'no'}"${r?.busy ? ' disabled' : ''}>${r?.busy ? 'Restarting…' : `Restart ${esc(name)} now`}</button> <span role="status" data-agent-restart-status="${esc(name)}">${esc(r?.text ?? '')}</span></span>`;
}
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const paintRestart = name => document.querySelectorAll('.agent-restart').forEach(el => {
const b = el.querySelector('[data-agent-restart]');
if (b?.dataset.agentRestart === name) el.outerHTML = restartMarkup(name, b.dataset.agentThread === 'yes');
});
async function send(url, payload) {
let res, result = null;
try { res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), cache: 'no-store', redirect: 'manual' }); }
catch { return { unknown: true }; }
if (res.type === 'opaqueredirect') return { signedOut: true };
try { result = await res.json(); } catch { return { unknown: true }; }
return { ok: res.ok, result };
}
// The request's closing read, or a reason the chain stops.
async function closing(request) {
for (let tries = 0; tries < POLL_LIMIT; tries++) {
await new Promise(r => setTimeout(r, POLL_MS));
let res, r = null;
try { res = await fetch(`/api/agent/request?id=${encodeURIComponent(request)}`, { cache: 'no-store', redirect: 'manual' }); } catch { continue; }
if (res.type === 'opaqueredirect') return { stop: 'Sign in again, then read the agents.' };
try { r = await res.json(); } catch { continue; }
if (res.ok && r?.request === request && r.state === 'closed') return { done: r };
}
return { stop: `Request ${request} is still open. Read the agents again later.` };
}
async function runs(name) {
try {
const res = await fetch('/api/bus/instances', { cache: 'no-store', redirect: 'manual' });
if (!res.ok || res.type === 'opaqueredirect') return null;
const e = (await res.json())?.rows?.[0]?.instances?.[name];
return e ? (Array.isArray(e.running) ? e.running.length : 0) : 0;
} catch { return null; }
}
async function restartAgent(name, thread) {
const r = restarts[name] = { busy: true, text: `Terminating ${name}…` };
const stop = text => { r.busy = false; r.text = text; paintRestart(name); };
paintRestart(name);
const asked = await send('/api/agent/terminate', { instance: name, reason: 'route change' });
if (asked.signedOut) return stop('Sign in again, then retry.');
if (asked.unknown || !asked.ok || !REQUEST_ID.test(asked.result?.request ?? '')) return stop(asked.ok || asked.unknown ? UNKNOWN : refused(asked.result));
const ended = await closing(asked.result.request);
if (ended.stop) return stop(ended.stop);
if (ended.done.reason) return stop(`${name}: ${ended.done.reason}. It was not restarted.`);
r.text = `Terminated ${name}. Waiting for its session to end…`; paintRestart(name);
let left = false;
for (let i = 0; i < RESTART_WAIT && !left; i++) { await new Promise(res => setTimeout(res, POLL_MS)); left = (await runs(name)) === 0; }
if (!left) return stop(`${name} was asked to end but its session is still running. Launch it from Agents once it has ended.`);
if (thread) return stop(`Terminated ${name} at ${new Date().toLocaleTimeString()}. Its next message starts it on the new route.`);
r.text = `Launching ${name}…`; paintRestart(name);
const launch = await send('/api/agent/launch', { instance: name });
if (launch.signedOut) return stop('Sign in again. It was terminated and not launched.');
if (launch.unknown || !launch.ok || !REQUEST_ID.test(launch.result?.request ?? '')) return stop(launch.ok || launch.unknown ? `${UNKNOWN} It was terminated.` : `${refused(launch.result)} It was terminated and not launched.`);
const started = await closing(launch.result.request);
if (started.stop) return stop(started.stop);
if (started.done.reason) return stop(`${name}: ${started.done.reason}. It was terminated and not launched.`);
return stop(`Restarted ${name} on the new route${started.done.done?.run ? ` (run ${started.done.done.run})` : ''}.`);
}
document.addEventListener('click', e => {
const b = e.target.closest?.('[data-agent-restart]');
if (!b || b.disabled || restarts[b.dataset.agentRestart]?.busy) return;
restartAgent(b.dataset.agentRestart, b.dataset.agentThread === 'yes');
});
window.MosaicAgents = Object.freeze({ section, restartMarkup, isThread: definition => threadDefinitions.has(definition) });
})();
+2 -1
View File
@@ -490,7 +490,7 @@
// (likely when the bus is down) still refreshes the list, its banner and the Settings Start control.
if (window.MosaicOrg) {
const org = (await window.MosaicOrg.readList()).find(row => row.id === window.MosaicOrg.id);
if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; return; }
if (org?.busHealthy === false) { box.textContent = 'Bus unavailable'; box.hidden = false; window.MosaicConsole?.attachBus(box, 'Restart the bus'); return; }
}
try {
const s = await api('/api/bus-status');
@@ -498,6 +498,7 @@
const b = s.boot;
box.replaceChildren(node('b', '', 'Bus down: organization file invalid. '), node('span', '', `${b.reason} `), node('span', 'small muted', `File ${b.file}. Refused at ${b.at}. Last good load: ${b.lastGood ?? 'none'}. Fix the file, then restart the bus.`));
box.hidden = false;
window.MosaicConsole?.attachBus(box, 'Restart the bus');
} catch { /* no status to show: leave the banner as it was */ }
}
async function refresh() {
+40 -16
View File
@@ -319,7 +319,7 @@
});
let configRead = Promise.resolve();
let canSend = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
let canSend = false, canMessage = false, chatSend = false, sendRoles = null, sending = false, receipt = null, replies = [];
const REPLIES = 50; // the broker's cap
function repliesHtml() {
if (replies === null) return '<p class="small warn">The replies to it could not be read. Read again to retry; <code>mosaic inbox</code> in a terminal lists them.</p>';
@@ -344,7 +344,7 @@
<button class="btn primary" type="submit" data-send aria-disabled="${sending}">${sending ? 'Sending…' : 'Send'}</button></form>${said}</section>`;
}
async function composer(get) {
if (!canSend) return '<p class="small muted">Messaging a role needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.</p>';
if (!canMessage) return '<p class="small muted">Messaging a role needs a Console started with <code>--send</code> behind the sign-in proxy; this one only reads.</p>';
try { sendRoles = (await get('agents')).map(c => c.role).filter(r => typeof r === 'string' && SAFE.test(r)); } catch { sendRoles = null; }
if (receipt?.kind === 'sent') {
const r = receipt;
@@ -612,8 +612,8 @@
let heldOrganizations = { empty: false, invalid: [], error: null };
function heldTable(claims) {
const cols = ['Role', 'State', 'Attention', 'Address', 'Holder', 'Held since'];
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${c.address ? txt(c.address) : '<span class="muted">no address</span>'}</td><td>${txt(c.role || ('a session '+short(c.holder_run)))}${c.harness ? `<span class="source">${txt(c.harness)}</span>` : ''}</td><td>${when(c.at)}</td>${canSend ? `<td>${typeof c.role === 'string' && SAFE.test(c.role) ? `<button type="button" class="btn" data-message-role="${esc(c.role)}" aria-label="Message ${esc(c.role)}">Message</button>` : ''}</td>` : ''}</tr>`).join('');
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles on the board, scroll for all columns"><table class="s1-table s1-agents s1-held"><thead><tr>${cols.map(h => `<th scope="col">${h}</th>`).join('')}${canSend ? '<th scope="col"><span class="sr-only">Message</span></th>' : ''}</tr></thead><tbody>${rows}</tbody></table></div>`;
const rows = claims.map(c => `<tr><th scope="row">${txt(c.role)}</th><td>${stateMark(c.state)}${c.working ? `<span class="source">${esc(plural(c.working.messages, 'message'))} since ${when(c.working.since)}</span>` : ''}</td><td>${attention(c)}</td><td>${c.address ? txt(c.address) : '<span class="muted">no address</span>'}</td><td>${txt(c.role || ('a session '+short(c.holder_run)))}${c.harness ? `<span class="source">${txt(c.harness)}</span>` : ''}</td><td>${when(c.at)}</td>${canMessage ? `<td>${typeof c.role === 'string' && SAFE.test(c.role) ? `<button type="button" class="btn" data-message-role="${esc(c.role)}" aria-label="Message ${esc(c.role)}">Message</button>` : ''}</td>` : ''}</tr>`).join('');
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Held roles on the board, scroll for all columns"><table class="s1-table s1-agents s1-held"><thead><tr>${cols.map(h => `<th scope="col">${h}</th>`).join('')}${canMessage ? '<th scope="col"><span class="sr-only">Message</span></th>' : ''}</tr></thead><tbody>${rows}</tbody></table></div>`;
}
function paintHeld(err, noOrganizations, invalidOrganizations, organizationError) {
heldErr = err;
@@ -982,7 +982,8 @@
// offered: binding credentials stays a hand edit. When a change needs a bus restart the receipt
// says so; the Console never restarts the bus. Drafts survive polling and refusals; nothing retries.
let instanceView=null, instanceDrafts={add:null,change:null}, instanceBusyOps=new Set(), instanceReceipt='', instanceReceiptOp=null, instanceReceipts=Object.create(null);
const RESTART_TEXT='Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
// F3: an organization Console's owner restarts the bus from the receipt (console-restart.js).
const restartText=()=>window.MosaicConsole?.busRestart?'Restart needed: the running bus keeps the roles it loaded at start. Restart the bus to load the change.':'Restart needed: the running bus keeps the roles it loaded at start. The Console does not restart it.';
const NO_RESTART_TEXT='No restart: the launcher reads the change at its next launch check.';
function instancesSection(view){
const title='<h2 id="instances-title">Role instances</h2>';
@@ -1024,7 +1025,7 @@
try{payload=instancePayload(op,instanceDrafts[op]);}catch(err){instanceReceipt=err.message;instanceReceipts[op]=instanceReceipt;render(false);return;}
instanceBusyOps.add(op);instanceReceipt='';instanceReceipts[op]='';render(false);
try{const response=await fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){instanceReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?RESTART_TEXT:NO_RESTART_TEXT}`;}
if(response.ok&&instanceAnswer(result)){instanceDrafts[op]=null;instanceView=result;const prior=last.get('instances');if(prior)last.set('instances',{...prior,rows:[result]});instanceReceipt=`${op==='add'?'Added':'Changed'} ${payload.instance}. ${result.restart?restartText():NO_RESTART_TEXT}`;}
else instanceReceipt=response.ok||result?.sent==='unknown'?unknownInstance:`${result?.message||'Refused.'} Draft kept.`;}}
catch{instanceReceipt=unknownInstance;}finally{instanceBusyOps.delete(op);instanceReceiptOp=op;instanceReceipts[op]=instanceReceipt;if(!out())render(false);}
});
@@ -1035,6 +1036,21 @@
// choosing none for all three. Save sends the changed rows through routes.set. Drafts survive
// polling and refusals; nothing retries.
let routesView=null, routesDraft=null, routesBusy=false, routesReceipt='', routesEditVersion=0;
// F3: after a save, how the change reaches each saved agent: a session keeps the route it started
// with, so a running one moves when it ends (a thread when it goes idle) or when the owner
// restarts it here (agents.js restartMarkup). Read from the instances after the save.
let routesApply=null;
async function routesApplyRead(names){
let view=null;
try{const res=await fetch('/api/bus/instances',{cache:'no-store',redirect:'manual'});if(res.ok&&res.type!=='opaqueredirect')view=(await res.json())?.rows?.[0]??null;}catch{/* said below */}
return names.map(name=>{const e=view?.instances?.[name];
if(!view||!e)return {name,text:'Could not read whether it is running. A running session keeps its old route until it ends.'};
const running=Array.isArray(e.running)?e.running.length:0, thread=!!window.MosaicAgents?.isThread(e.definition);
if(e.launcher)return {name,text:'The launcher. It takes the new route at its next start.'};
if(!running)return {name,text:'Not running. Its next start uses the new route.'};
return {name,thread,restart:true,text:thread?'Running. Its session keeps the old route until the thread goes idle; the next message after that starts it on the new route. Restart it to move it now.':'Running. Its session keeps the old route until it ends. Restart it to move it now.'};});
}
const routesApplyMarkup=()=>routesApply?.length?`<ul class="routes-apply" aria-label="How the change reaches each agent">${routesApply.map(a=>`<li><b>${txt(a.name)}</b>: ${txt(a.text)}${a.restart&&canSend&&!isTester?` ${window.MosaicAgents?.restartMarkup(a.name,a.thread)??''}`:''}</li>`).join('')}</ul>`:'';
let harnessSettings=null, harnessDraft=null, harnessLoading=false, harnessReceipt='', harnessBusy=false;
function readHarnesses(){
if(harnessLoading || harnessSettings !== null || !canSend || isTester)return; harnessLoading=true;
@@ -1070,7 +1086,7 @@
const providerControl=canSend&&!isTester?`<button type="button" id="provider-switch" data-enabled="${v.catalogue?.status!=='disabled'}">${v.catalogue?.status!=='disabled'?'Disable':'Enable'} model proxy catalogue</button><p role="status" id="provider-switch-status"></p>`:'';
const harnessControl=canSend&&!isTester?`<fieldset class="harness-control"><legend>Harnesses</legend>${harnessSettings ? [...new Set([...harnessSettings.available.harness,...(harnessSettings.plugins.harness??[])])].map(name=>`<label><input type="checkbox" data-harness="${esc(name)}"${(harnessDraft??harnessSettings.plugins.harness??[]).includes(name)?' checked':''}${harnessBusy?' disabled':''}> ${txt(name)}</label>`).join('')+` <button type="button" id="harness-save"${harnessBusy?' disabled':''}>Save harnesses</button>` : '<p>Harnesses unavailable.</p><button type="button" id="harness-read">Read harnesses again</button>'}<p role="status" id="harness-status">${txt(harnessReceipt)}</p></fieldset>`:'';
const off=v.harnesses.length?'':'<p class="small warn">No harness is switched on in the business file\'s plugins.harness, so every launch refuses harness-off and a save that changes a harness refuses too. Switch one on under Harnesses.</p>';
return `<section class="panel routing-panel" aria-labelledby="routes-title"><span class="sr-only">${title}</span>${catalogueInfo}${providerControl}${harnessControl}${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col" rowspan="2">Agent</th><th scope="colgroup" colspan="3">Primary</th><th scope="colgroup" colspan="3">Fallback</th></tr><tr>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l.replace('Fallback ','')}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><div class="settings-actions route-actions${routesDraft?' has-draft':''}"><button type="submit"${disabled}>${routesBusy?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Save routes'}</button> <button type="button" id="routes-reload"${routesDraft?'':' hidden'}${routesBusy||!routesDraft?' disabled':''}>Discard draft</button><p role="status" id="routes-receipt" class="${routesReceipt.startsWith('Outcome unknown')?'banner ref':routesReceipt.includes('Draft kept')?'banner err':routesReceipt.includes('saved')?'settings-success':''}">${txt(routesReceipt)}</p></div></form></section>`;
return `<section class="panel routing-panel" aria-labelledby="routes-title"><span class="sr-only">${title}</span>${catalogueInfo}${providerControl}${harnessControl}${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col" rowspan="2">Agent</th><th scope="colgroup" colspan="3">Primary</th><th scope="colgroup" colspan="3">Fallback</th></tr><tr>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l.replace('Fallback ','')}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><div class="settings-actions route-actions${routesDraft?' has-draft':''}"><button type="submit"${disabled}>${routesBusy?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Save routes'}</button> <button type="button" id="routes-reload"${routesDraft?'':' hidden'}${routesBusy||!routesDraft?' disabled':''}>Discard draft</button><p role="status" id="routes-receipt" class="${routesReceipt.startsWith('Outcome unknown')?'banner ref':routesReceipt.includes('Draft kept')?'banner err':routesReceipt.includes('saved')?'settings-success':''}">${txt(routesReceipt)}</p></div>${routesApplyMarkup()}</form></section>`;
}
function routesFields(form){const draft=Object.fromEntries(Object.keys(routesView.instances).map(name=>[name,routeCells(routesView.instances[name])]));for(const el of form.querySelectorAll('[data-route]')){const [name,key]=JSON.parse(el.dataset.route);if(draft[name])draft[name][key]=el.value;}return draft;}
// The changed rows only. A fallback with all three cells empty is cleared; one with some set goes
@@ -1112,13 +1128,13 @@
}catch(error){status.textContent=error.message;button.disabled=false;}
});
document.addEventListener('click',e=>{if(e.target.matches?.('.fallback-toggle'))e.target.closest('tr')?.classList.add('fallback-open');});
document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';last.delete('routes');render(true);});
document.addEventListener('click',e=>{if(e.target.id!=='routes-reload')return;routesDraft=null;routesView=null;routesReceipt='';routesApply=null;last.delete('routes');render(true);});
document.addEventListener('submit',async e=>{
if(e.target.id!=='routes-form')return;e.preventDefault();if(!(canSend || isTester)||out()||routesBusy||!routesView)return;
routesDraft=routesFields(e.target);let payload;try{payload=routesPayload(routesDraft);}catch(err){routesReceipt=err.message;render(false);return;}
routesBusy=true;routesReceipt='';render(false);
routesBusy=true;routesReceipt='';routesApply=null;render(false);
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;}
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;routesApply=await routesApplyRead(Object.keys(payload.routes));}
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'} Draft kept.`;}}
catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);}
});
@@ -1148,8 +1164,8 @@
});
});
// Organizations is writable when the server allows org setup, which it does in organization mode where send is off.
const readonlyPage = page => !['appearance','business'].includes(page) && !(page === 'organizations' && orgSetup) && !canSend && !isTester;
const settingsPages = [['appearance','Appearance'],['business',window.MosaicOrg ? 'Organization' : 'Business'],['limits','Launch limits'],['organizations','Organizations'],['routing','Routing'],['agents','Agents'],['assistant','Assistant'],['tokens','Token use'],['model-proxy','Model proxy'],['about','About']];
const readonlyPage = page => !['appearance','business','console'].includes(page) && !(page === 'organizations' && orgSetup) && !canSend && !isTester;
const settingsPages = [['appearance','Appearance'],['business',window.MosaicOrg ? 'Organization' : 'Business'],['limits','Launch limits'],['organizations','Organizations'],['routing','Routing'],['agents','Agents'],['assistant','Assistant'],['tokens','Token use'],['model-proxy','Model proxy'],['console','Console'],['about','About']];
function markSettingsDraft(page,dirty){const link=document.querySelector(`#settings-nav a[href="#/settings/${page}"]`), dot='<span class="settings-dot"><span class="sr-only">unsaved changes</span></span>';if(link && dirty && !link.querySelector('.settings-dot'))link.insertAdjacentHTML('beforeend',dot);const button=$('settings-disclosure');if(dirty && link?.getAttribute('aria-current')==='page' && button && !button.querySelector('.settings-dot'))button.querySelector('strong').insertAdjacentHTML('beforeend',dot);}
function settingsShell(page, body, helper='') {
const pages=isTester ? [['appearance','Appearance'],['business','Business'],['routing','Routing'],['agents','Agents'],['names','Seat names']] : settingsPages;
@@ -1164,6 +1180,8 @@
if (page==='appearance') return settingsShell(page,appearance(),'These choices apply in this browser only.');
if (page==='organizations') { await configRead; return settingsShell(page,window.MosaicOrgCreate.markup,'Create an organization from the template, edit it, and finish its setup. Each step names the command it equals.'); }
if (page==='model-proxy') { await configRead; return settingsShell(page,window.MosaicProxy.markup,'Accounts used by this instance, their priority, and the client keys assigned to agents.'); }
// F3: Settings > Console (console-restart.js). The server checks the owner, Origin and trail.
if (page==='console') { await configRead; await window.MosaicConsole?.ready; return settingsShell(page,window.MosaicConsole?.settingsMarkup()??'<section class="panel"><p class="small warn">The Console controls did not load. Reload the page to try again.</p></section>','Restart this Console on the same address and configuration.'); }
const s = await get.api('settings'), n = s.notifier || {};
let usage, names = null, instances = null, routes = null, limitRows = [], limitsRefused = false;
if (page==='tokens') { try { [usage] = await get('usage'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; } try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; } }
@@ -1715,10 +1733,14 @@
});
// Whether this server takes a send here. The form is drawn on the next Inbox render; the Board's
// Message buttons are drawn from the roles already read.
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(c => {
// F3: `settingsSend` lets the owner of an organization Console save Settings on the selected
// organization's bus; messaging a role still follows `send` (canMessage) and `chatSend`.
// A bus restart reads the config again and sends it here as a mosaic-config event.
const applyConfig = c => {
isTester = c?.tester === true;
canEdit = !isTester && c?.edit === true;
canSend = !isTester && c?.send === true;
canSend = !isTester && (c?.send === true || c?.settingsSend === true);
canMessage = !isTester && c?.send === true;
chatSend = !isTester && c?.chatSend === true;
orgSetup = !isTester && c?.orgSetup === true;
window.MosaicOrgBusStopped = !!window.MosaicOrg && c?.devOwner === true && orgSetup && c?.chatSend === false;
@@ -1726,7 +1748,7 @@
if (orgSetup && !$('org-new-link')) { const a = document.createElement('a'); a.id = 'org-new-link'; a.href = NEW_ORG; a.setAttribute('role', 'menuitem'); a.textContent = 'New organization'; document.querySelector('#avatar-menu > a[href="#/settings"]')?.after(a); }
ssoOn = c?.sso === true;
canDecide = !isTester && c?.decide === true;
if (isTester) {
if (isTester && !$('tester-banner')) {
const banner = document.createElement('div'); banner.id = 'tester-banner'; banner.className = 'banner ref';
banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.';
$('main').prepend(banner);
@@ -1734,7 +1756,9 @@
if (!canSend && !isTester && !ssoOn) return;
const path = routeOf().split('?')[0];
if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
}).catch(() => {});
};
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {});
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') applyConfig(e.detail); });
// This context comes from the conversations renderer after its authorized read.
let projectContext = null;
document.addEventListener('mosaic-project-context', event => {
@@ -0,0 +1,172 @@
/* F3: restart the Console from Settings, restart an organization's bus, and bring an open page
back after any Console restart. The controls are the owner's: the server checks the human, the
exact Origin and Fetch Metadata, and writes the action trail. This file only asks and reports.
Reconnect: /api/config carries `boot`, an id the Console picks at each start. When a same-origin
request fails at the network level, or the owner asks for a restart, the page polls /api/config.
The same boot means the Console never went away, and the banner goes. A new boot means a new
Console process: the page reloads itself, so its scripts, icons and session match the new one.
A page with an unsaved draft does not reload; it says the Console is back and keeps the draft. */
(() => {
'use strict';
const nativeFetch = window.fetch.bind(window);
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const sleep = ms => new Promise(r => setTimeout(r, ms));
const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
let config = null, boot = null, reconnecting = false, planned = false;
let restartState = 'idle', restartReceipt = '', busReceipt = '', busBusy = false;
const readConfig = () => nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })
.then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null));
const ready = readConfig().then(c => { config = c; boot = typeof c?.boot === 'string' ? c.boot : null; return c; }).catch(() => null);
const dirty = () => !!(window.MosaicChat?.hasDraft?.() || window.MosaicOrgCreate?.hasDraft?.()
|| document.querySelector('.settings-dot')
|| [...document.querySelectorAll('#reply, #conv-reply, #doc-source, #send-body')].some(el => !!el.value?.trim()));
function banner(text, action = '') {
let box = $('console-reconnect');
if (!box) {
box = document.createElement('div'); box.id = 'console-reconnect'; box.className = 'banner ref console-reconnect';
box.setAttribute('role', 'status'); box.setAttribute('aria-live', 'polite');
($('main') ?? document.body).prepend(box);
}
box.innerHTML = `<span>${esc(text)}</span>${action}`;
box.hidden = false;
}
const hideBanner = () => { const box = $('console-reconnect'); if (box) box.hidden = true; };
// One poll loop at a time. It ends when the Console answers; it never sends anything but a read.
async function reconnect(reason) {
if (reconnecting) return;
reconnecting = true;
const started = Date.now();
banner(reason === 'restart' ? 'Console restarting. This page reconnects when it is back.' : 'The Console is not answering. Reconnecting…');
for (;;) {
const waited = Date.now() - started;
await sleep(waited < 120000 ? 1000 : 5000);
if (waited >= 120000) banner('The Console has not come back after two minutes. Check the terminal or service that started it. This page keeps trying.');
let c;
try {
const r = await nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' });
// A sign-in redirect: the new Console wants a sign-in, which the reload shows.
if (r.type === 'opaqueredirect') { location.reload(); return; }
if (!r.ok) continue;
c = await r.json();
} catch { continue; }
// The process that took the restart can still answer before it closes.
if (boot !== null && c?.boot === boot) { if (planned) continue; reconnecting = false; hideBanner(); return; }
reconnecting = false; planned = false;
if (dirty()) {
boot = typeof c?.boot === 'string' ? c.boot : boot; config = c;
banner('The Console is back. Your unsaved draft is kept on this page.', ' <button type="button" class="btn" id="console-reconnect-reload">Reload now</button>');
return;
}
location.reload();
return;
}
}
document.addEventListener('click', e => { if (e.target.id === 'console-reconnect-reload') location.reload(); });
// A same-origin request that fails at the network level (the Console went away) starts the poll.
// An aborted request is an AbortError, not a TypeError, and starts nothing.
const sameOrigin = input => { try { return new URL(typeof input === 'string' ? input : input.url, location.href).origin === location.origin; } catch { return false; } };
window.fetch = (input, init) => nativeFetch(input, init).catch(err => {
if (err?.name === 'TypeError' && sameOrigin(input) && boot !== null) reconnect('lost');
throw err;
});
// Settings > Console.
function settingsMarkup() {
if (!config) return '<section class="panel"><p class="small warn">The Console settings could not be read. Reload the page to try again.</p></section>';
if (config.tester === true || config.user === true) return '<section class="panel"><p>Restarting the Console is the owner\'s.</p></section>';
const signOut = config.sso === true && config.devOwner !== true ? ' Sign-in sessions are held in memory, so everyone signs in again after it.' : '';
let body;
if (config.consoleRestart === true) {
const busy = restartState !== 'idle' ? ' disabled' : '';
const confirm = restartState === 'confirm'
? '<div class="settings-actions" id="console-restart-confirm"><p>Restart the Console now?</p><button type="button" id="console-restart-go">Restart now</button> <button type="button" id="console-restart-cancel">Cancel</button></div>' : '';
body = `<p>The Console stops, and <code>mosaic console start</code> starts it again on the same address, arguments and configuration. Organization buses are separate processes and keep running. Open pages show the restart and reconnect when it is back.${signOut}</p><p class="small muted">Each restart is recorded in the Console's action trail with your identity.</p><div class="settings-actions"><button type="button" id="console-restart"${busy}${restartState === 'confirm' ? ' hidden' : ''}>Restart Console</button></div>${confirm}`;
} else if (config.orgSetup !== true && config.send !== true) {
body = '<p>This Console is read-only, so it does not restart itself. Restart it where it was started.</p>';
} else {
body = '<p>This Console was not started in the foreground by <code>mosaic console start</code>, so nothing would start it again. Restart it where it was started: in its terminal, or with <code>systemctl --user restart</code> and its unit.</p>';
}
return `<section class="panel" aria-labelledby="console-restart-title"><h3 id="console-restart-title">Restart the Console</h3>${body}<p role="status" id="console-restart-receipt">${esc(restartReceipt)}</p></section>`;
}
const repaint = () => { const panel = $('console-restart-title')?.closest('section'); if (panel) panel.outerHTML = settingsMarkup(); };
async function restartConsole() {
restartState = 'sending'; restartReceipt = 'Asking the Console to restart…'; repaint();
let response, answer = null;
try { response = await nativeFetch('/api/console/restart', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' }); }
catch { restartState = 'idle'; restartReceipt = 'Outcome unknown. If the Console restarted, this page reconnects by itself.'; repaint(); reconnect('lost'); return; }
try { answer = await response.json(); } catch { /* an unreadable answer is refused below */ }
if (response.status === 202 && answer?.outcome === 'restarting') {
planned = true; if (typeof answer.boot === 'string') boot = answer.boot;
restartState = 'idle'; restartReceipt = `Restart requested at ${clock()}.`; repaint();
reconnect('restart');
return;
}
restartState = 'idle';
restartReceipt = response.type === 'opaqueredirect' ? 'Sign in again, then retry.' : (answer?.message || 'The restart was refused. Nothing was retried.');
repaint();
}
document.addEventListener('click', e => {
if (e.target.id === 'console-restart' && config?.consoleRestart === true) { restartState = 'confirm'; restartReceipt = ''; repaint(); $('console-restart-go')?.focus(); }
else if (e.target.id === 'console-restart-cancel') { restartState = 'idle'; repaint(); $('console-restart')?.focus(); }
else if (e.target.id === 'console-restart-go' && restartState === 'confirm') restartConsole();
});
// The org bus restart control, offered on the organization's own pages to its owner.
const busAllowed = () => !!window.MosaicOrg && config?.orgSetup === true;
function busButton(label = 'Restart bus') {
if (!busAllowed()) return '';
return `<span class="bus-restart"><button type="button" class="btn" data-bus-restart data-label="${esc(label)}"${busBusy ? ' disabled' : ''}>${esc(busBusy ? 'Restarting…' : label)}</button> <span role="status" data-bus-restart-receipt>${esc(busReceipt)}</span></span>`;
}
async function attachBus(box, label) {
await ready;
// The bus is unavailable again: an earlier receipt no longer describes it.
if (box && !box.hidden) $('bus-restart-done')?.remove();
if (!box || box.querySelector('[data-bus-restart]') || !busAllowed()) return;
box.insertAdjacentHTML('beforeend', ` ${busButton(label)}`);
}
const paintBus = () => document.querySelectorAll('.bus-restart').forEach(el => {
const label = el.querySelector('[data-bus-restart]')?.dataset.label;
el.outerHTML = busButton(label);
});
document.addEventListener('click', async e => {
const button = e.target.closest?.('[data-bus-restart]');
if (!button || button.disabled || busBusy || !busAllowed()) return;
const id = window.MosaicOrg.id;
busBusy = true; busReceipt = ''; paintBus();
try {
const response = await nativeFetch(`/api/orgs/${encodeURIComponent(id)}/restart/bus`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' });
if (response.type === 'opaqueredirect') throw new Error('Sign in again, then retry.');
const body = await response.json();
if (!response.ok || body.outcome !== 'done' || body.step !== 'bus-restart') throw new Error(body.message || 'The restart was refused. Nothing was retried.');
busReceipt = `${body.restarted ? 'Bus restarted' : 'Bus started'} at ${clock()}. ${body.healthy ? 'It answers.' : `It has not answered yet. Check it with ${body.verb.replace('restart', 'status')}.`}`;
// The page's send flags were read while the bus was in its old state; read them again. This
// read goes through the page's fetch, which display.js scopes to the selected organization.
// The organization list goes first, so the view the config event redraws shows the bus as it is now.
await Promise.resolve(window.MosaicOrg.refreshList?.()).catch(() => {});
const fresh = await window.fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => (r.ok ? r.json() : null)).catch(() => null);
if (fresh) { config = fresh; document.dispatchEvent(new CustomEvent('mosaic-config', { detail: fresh })); }
// A healthy bus ends the "Bus unavailable" banner. Its receipt stays on the page in its place.
const down = $('bus-down');
if (down && body.healthy) {
if (!down.hidden) {
$('bus-restart-done')?.remove();
const done = document.createElement('p'); done.id = 'bus-restart-done'; done.className = 'banner'; done.setAttribute('role', 'status');
done.textContent = busReceipt; down.after(done);
}
down.hidden = true;
}
} catch (error) {
busReceipt = error instanceof SyntaxError || error?.name === 'TypeError' ? 'Outcome unknown. Check the bus with mosaic bus status before retrying.' : error.message;
} finally { busBusy = false; paintBus(); }
});
window.MosaicConsole = Object.freeze({ ready, settingsMarkup, busButton, attachBus, get busRestart() { return busAllowed(); }, get config() { return config; } });
})();
+13 -3
View File
@@ -65,7 +65,7 @@ window.MosaicLocalTime = value => Number.isFinite(Date.parse(value)) ? new Date(
const org = rows.find(row => row.id === decodeURIComponent(id) && !row.invalid);
const mark = document.querySelector('.wordmark');
if (mark && org) { const name = document.createElement('span'); name.id = 'current-organization'; name.textContent = ` · ${org.label || org.id}`; mark.append(name); }
if (org?.busHealthy === false) { const banner = document.getElementById('bus-down'); if (banner) { banner.textContent = 'Bus unavailable'; banner.hidden = false; } }
if (org?.busHealthy === false) { const banner = document.getElementById('bus-down'); if (banner) { banner.textContent = 'Bus unavailable'; banner.hidden = false; window.MosaicConsole?.attachBus(banner, 'Restart the bus'); } }
return rows;
};
let list = readList().then(paintHeader);
@@ -240,13 +240,23 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
});
const cleanup = () => { clearTimeout(poll); login = null; const redirect=$('proxy-login-submit')?.elements.redirect_url, key=$('proxy-apikey')?.elements.key; if(redirect)redirect.value='';if(key)key.value=''; };
window.addEventListener('pagehide', cleanup, {once:true});
let devOwner = false;
fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(config => {
canWrite = config.send === true && config.tester !== true && config.user !== true;
// F3: an organization's settings writes go with settingsSend (the org Console has no global send).
canWrite = (config.send === true || config.settingsSend === true) && config.tester !== true && config.user !== true;
devOwner = config.devOwner === true;
document.querySelectorAll('[data-write]').forEach(fieldset => { fieldset.disabled = !canWrite; });
$('proxy-readonly').hidden = canWrite;
if (!canWrite) document.querySelectorAll('[aria-labelledby=proxy-signin-title],[aria-labelledby=proxy-apikey-title]').forEach(el=>el.hidden=true);
return refresh();
}).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message); if(error.message==='The model proxy is not configured for this instance.') { $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel, #proxy-refresh').forEach(el=>el.hidden=true); } });
}).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message); if(error.message==='The model proxy is not configured for this instance.') { $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel, #proxy-refresh').forEach(el=>el.hidden=true);
// F3: on a dev instance, say plainly why this page cannot set the proxy up and what does.
// Setup takes a management key typed twice at a terminal and writes the proxy's credentials;
// this page never handles that key. Account sign-in (a provider's own OAuth) then runs here.
if (devOwner) { const org = window.MosaicOrg?.id, note = document.createElement('div'); note.id = 'proxy-dev-setup'; note.className = 'panel';
note.innerHTML = '<h2>Set up the model proxy</h2><p>Nothing else on this dev Console asks for a sign-in. The proxy is the exception: its setup asks for a management key, typed twice, and writes the proxy\'s credentials. This page never handles that key, so setup runs once in a terminal on this machine:</p><pre><code></code></pre><p>Then refresh this page. Signing in to a Claude or Codex account is the provider\'s own sign-in, which cannot be skipped. It runs here, under Sign in to an account, with one button.</p>';
note.querySelector('code').textContent = `mosaic proxy fetch\nmosaic proxy init${org ? ` --org ${org}` : ''}`;
$('proxy-message').after(note); } } });
return cleanup;
}
window.MosaicProxy={markup,mount};
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script>
<script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<body>
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
<a class="skip" href="#main">Skip to content</a>
+1 -1
View File
@@ -100,7 +100,7 @@
});
window.addEventListener('pagehide', () => { endLogin(); $('proxy-apikey').elements.key.value = ''; });
fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(config => {
canWrite = config.send === true && config.tester !== true && config.user !== true;
canWrite = (config.send === true || config.settingsSend === true) && config.tester !== true && config.user !== true;
document.querySelectorAll('[data-write]').forEach(fieldset => { fieldset.disabled = !canWrite; });
$('proxy-readonly').hidden = canWrite;
return refresh();
+10
View File
@@ -93,6 +93,16 @@ table.s1-table td code{font-size:12.5px;white-space:nowrap}
.banner.ref{border-color:color-mix(in srgb,var(--warning) 50%,var(--line));background:color-mix(in srgb,var(--warning) 9%,var(--surface))}
.banner b,.banner strong{color:var(--strong)}
.banner code{overflow-wrap:anywhere}
/* F3: the Console restart banner stays in view while the page waits for the Console, and the bus restart control sits inline in the receipt or banner that asks for it. */
.console-reconnect{position:sticky;top:0;z-index:30;align-items:center}
.console-reconnect>span{flex:1 1 220px;min-width:0;overflow-wrap:anywhere}
.bus-restart{display:inline-flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0}
.bus-restart [role=status]:empty{display:none}
.routes-apply{margin:12px 0 0;padding-left:18px;display:grid;gap:8px}
.routes-apply li{overflow-wrap:anywhere}
.agent-restart{display:inline-flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0}
.agent-restart [role=status]:empty{display:none}
#proxy-dev-setup pre{overflow-x:auto;max-width:100%}
/* Stream S4: which organization this is, in words. Colour (the theme) is never the only cue; the strip appears whenever the theme is not the built-in one. */
.org-cue{margin:0 0 12px;padding:6px 10px;border:1px solid var(--line);border-left:4px solid var(--action);border-radius:var(--r-table);background:var(--raised);color:var(--text);font-size:13px;overflow-wrap:anywhere}.skel-rows{display:grid;gap:10px;padding:14px 12px;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface)}
.skel{display:block;height:12px;border-radius:4px;background:var(--raised);animation:skel 1.4s ease-in-out infinite}
+39 -18
View File
@@ -2,7 +2,7 @@ import { proxyHTTP } from './proxy-http.mjs';
import { createServer } from 'node:http';
import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs';
import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
import { timingSafeEqual } from 'node:crypto';
import { timingSafeEqual, randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import { isIP } from 'node:net';
import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from './bus.mjs';
@@ -19,6 +19,9 @@ import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } fro
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
import { consoleRestartHTTP } from './console-restart-http.mjs';
import { workspaceHTTP } from './workspace-read.mjs';
import { actor } from './action-trail.mjs';
import { devHostName } from '../../cli/src/dev-local-owner.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
@@ -55,7 +58,7 @@ const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'console-restart.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
@@ -443,10 +446,11 @@ const reasonFor = (code, done) => {
return `A skill this role needs isn't turned on: scripts/skill.sh activate ${done.skill}`;
return Object.hasOwn(INSTANCE_REASONS, code) ? INSTANCE_REASONS[code] : Object.hasOwn(AGENT_REASONS, code) ? AGENT_REASONS[code] : typeof code === 'string' && code.startsWith('proxy-') ? 'Check the model proxy configuration and account status' : String(code ?? 'unknown');
};
async function instanceSend(req, res, { instancesWrite, preview, log }) {
let instance = null;
async function instanceSend(req, res, { instancesWrite, preview, log, note = null }) {
let instance = null, op = 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}`);
// F3 review P3: the owner's instance changes also go to the action trail, like routes and agents.
const line = outcome => { log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`); note?.({ action: 'instances.set', op, instance: typeof instance === 'string' ? instance.slice(0, 200) : null, 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');
@@ -462,7 +466,7 @@ async function instanceSend(req, res, { instancesWrite, preview, log }) {
(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');
}
({ instance } = value);
({ instance, op } = 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;
@@ -499,10 +503,13 @@ const AGENT_ROUTES = {
'/api/veto': { kind: 'veto', verb: 'veto.set', keys: ['revision', 'op', 'name'], tester: false,
done: r => /^[a-f0-9]{64}$/.test(r?.revision ?? '') && typeof r.restart === 'boolean' && r.instances && typeof r.instances === 'object' && !Array.isArray(r.instances) },
};
async function agentSend(req, res, { route, agentWrite, preview, log }) {
// F3: the writes the owner makes on a selected organization's bus from Settings (dev org Console).
const ORG_OWNER_WRITE = path => path === '/api/settings' || path === '/api/instances' || path === '/api/routes'
|| Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/');
async function agentSend(req, res, { route, agentWrite, preview, log, note = null }) {
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 line = outcome => { log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(target)} ${outcome}`); note?.({ action: route.verb, instance: typeof target === 'string' ? target.slice(0, 200) : null, 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');
@@ -562,10 +569,11 @@ const ROUTE_REASONS = {
};
const routeObject = o => o && typeof o === 'object' && !Array.isArray(o);
const routesAnswer = v => /^[a-f0-9]{64}$/.test(v?.revision ?? '') && Array.isArray(v.harnesses) && Array.isArray(v.models) && routeObject(v.levels) && routeObject(v.instances);
async function routesSend(req, res, { routesWrite, preview, log }) {
// `note` (F3) adds the owner's change to the Console's action trail; best effort, after the log line.
async function routesSend(req, res, { routesWrite, preview, log, note = null }) {
// Instance names only; routes are never logged.
let names = [];
const line = outcome => log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`);
const line = outcome => { log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`); note?.({ action: 'routes.set', instances: names, 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');
@@ -763,7 +771,7 @@ async function libraryStatusRead(res, read, search) {
// `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, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], 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, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = undefined } = {}) {
// org-lan-bind: the dev owner on the LAN is an explicit opt-in. `devAllowHosts` names the Host
// names (without a port) a remote browser may use; only the dev owner takes them, and only with
// them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback.
@@ -790,11 +798,16 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
const registered = routeTable();
for (const registerRoutes of routes) registerRoutes(registered);
registered.add('GET', '/api/themes', (req, res) => themesHTTP(req, res, { log }), { publicRead: true });
const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log });
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) {
const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log, trail });
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore|restart)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) {
for (const method of ['GET', 'POST']) registered.add(method, path, adminRoute);
}
registered.add('POST', '/api/orgs', adminRoute);
// F3: the owner restarts the Console (console-restart-http.mjs). `restart` is set only under the
// supervisor; `boot` names this process so an open page can tell when the new one answers.
registered.add('POST', '/api/console/restart', (req, res, context) => consoleRestartHTTP(req, res, { ...context, restart, trail, boot, log }));
// U1 (#1873): one read across every organization the owner belongs to (workspace-read.mjs).
registered.add('GET', '/api/workspace', (req, res, context) => workspaceHTTP(req, res, { ...context, organizations, log, ...(workspaceTimeoutMs === undefined ? {} : { timeoutMs: workspaceTimeoutMs }) }));
const upstream = boardURL(board);
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
@@ -942,7 +955,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null,
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, upstream = null } = selected);
}
if (!hook && !['GET', 'HEAD'].includes(req.method) && !decisionPath && path !== '/api/north-star/approve' && !(/^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f-]+\/(?:messages|open))$/i.test(path)))
// F3: the owner's settings writes reach the selected organization's bus too (limits, routing,
// agents, assistant, instances, model proxy). The routes below keep their own checks.
if (!hook && !['GET', 'HEAD'].includes(req.method) && !decisionPath && path !== '/api/north-star/approve' && !(/^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f-]+\/(?:messages|open))$/i.test(path))
&& !(orgId !== null && !tester && !granted && named && ORG_OWNER_WRITE(path)))
return json(res, 403, { error: 'unavailable', sent: false });
}
if (hook) {
@@ -953,6 +969,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
return hook.handler(req, res, { path: originalPath, search, session: ssoSession, orgId, origin, owner: !tester && !granted && named && sso !== null, send: owner.send !== null });
}
const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer;
// F3: the owner's routing and agent changes go to the action trail too (who, when, what).
const ownerNote = !tester && !granted && ssoSession && trail ? entry => {
try { trail({ by: actor(ssoSession), ...(orgId !== null ? { org: orgId } : {}), ...entry }); }
catch { log(`${new Date().toISOString()} trail write failed for ${entry.action}`); }
} : null;
// P4 page and verbs are owner-only, including reads and static page assets.
// This runs after owner sign-in and the granted-user fence, before generic
// tester writes or static-file serving. No localhost preview grants access.
@@ -1008,13 +1029,13 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (path === '/api/instances') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if ((!tester && (!send || !named)) || !instancesWrite) return json(res, 403, { error: 'read-only', sent: false });
return instanceSend(req, res, { instancesWrite, preview: writer, log });
return instanceSend(req, res, { instancesWrite, preview: writer, log, note: ownerNote });
}
// 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 });
return agentSend(req, res, { route: AGENT_ROUTES[path], agentWrite, preview: writer, log, note: ownerNote });
}
if (path === '/api/agent/request') {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
@@ -1024,7 +1045,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
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 });
return routesSend(req, res, { routesWrite, preview: writer, log });
return routesSend(req, res, { routesWrite, preview: writer, log, note: ownerNote });
}
if (path === '/api/library/revert') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
@@ -1138,7 +1159,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// `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.
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy } : {}), decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && !granted && named && sso !== null && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
+344
View File
@@ -0,0 +1,344 @@
// U1 (#1873, PRD 1.20 REQ-WS-1..8): the cross-organization read model behind GET /api/workspace.
//
// One snapshot across the bus of every organization the viewer belongs to. Membership is the
// per-org check the org list and the org pages already use (organizations.mjs `admitted`), so an
// org the viewer does not belong to is never read and never named. Each org keeps its own bus;
// this module only fans out over the same per-org options the org pages get (cli.mjs) and merges.
// There is no second path to a bus and no write.
//
// Every org has one deadline. A bus that is down, still starting or slow is marked with a state
// and a time, and the other orgs still answer. Every merged item carries its org id.
import { plainMessage } from './org-admin.mjs';
export const WORKSPACE_SECTIONS = Object.freeze(['projects', 'sessions', 'inbox', 'tasks', 'metrics']);
export const WORKSPACE_POLL_MS = 10000;
export const WORKSPACE_TIMEOUT_MS = 4000;
const PROJECT_STATUSES = ['active', 'archived'];
const PROJECT_STATES = ['working', 'waiting', 'idle', 'none'];
const SORTS = ['activity', 'name', 'org', 'status'];
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
// A project selector is `<org>:<project uuid or org-file project key>`.
const PROJECT_SELECTOR = /^([a-z0-9][a-z0-9._-]{0,63}):([A-Za-z0-9._-]{1,128})$/;
// Only these reach a copied shell command; anything else gets no command.
const SHELL_SAFE = /^[A-Za-z0-9._:-]{1,128}$/;
const WEEK_MS = 7 * 24 * 3600 * 1000;
const PER_ORG_CONCURRENCY = 3;
// The broker reads each section needs. Each name is one human-side read the org pages already make.
const NEEDS = {
projects: ['projects', 'agents', 'claims', 'threads', 'tasks', 'inbox'],
sessions: ['agents', 'claims', 'threads', 'projects', 'tasks'],
inbox: ['inbox', 'agents', 'tasks', 'projects'],
tasks: ['tasks', 'projects', 'inbox'],
metrics: ['inbox', 'agents', 'claims', 'tasks', 'projects', 'usage', 'limits', 'decisions'],
};
const READS = {
inbox: o => o.bus.inbox(),
agents: o => o.bus.agents(),
claims: o => o.bus['thread.claims'](),
threads: o => o.chat.threads({}),
tasks: o => o.bus.tasks(),
projects: o => o.chat.projects({ includeArchived: true }),
usage: o => o.bus.usage(),
limits: o => o.bus.limits(),
decisions: o => o.bus.decisions('recent'),
};
export class WorkspaceQueryError extends Error {}
const bad = message => { throw new WorkspaceQueryError(message); };
const list = value => value.split(',').map(v => v.trim()).filter(Boolean);
function oneOf(name, value, allowed) {
const values = list(value);
if (!values.length) bad(`${name} is empty`);
for (const v of values) if (!allowed.includes(v)) bad(`${name} takes ${allowed.join(', ')}`);
return new Set(values);
}
// Strict: an unknown or repeated key is refused, so a misspelled filter never reads as "all".
export function parseWorkspaceQuery(search = '') {
const params = new URLSearchParams(search), seen = new Set();
const q = { sections: new Set(WORKSPACE_SECTIONS), orgs: null, status: new Set(['active']), state: null, name: null, sort: 'activity', order: null, project: null };
for (const [key, value] of params) {
if (seen.has(key)) bad(`${key} is given twice`);
seen.add(key);
if (key === 'sections') q.sections = oneOf('sections', value, WORKSPACE_SECTIONS);
else if (key === 'org') {
const ids = list(value);
if (!ids.length || ids.some(id => !ORG_ID.test(id))) bad('org takes organization ids, comma separated');
q.orgs = new Set(ids);
} else if (key === 'status') q.status = oneOf('status', value, PROJECT_STATUSES);
else if (key === 'state') q.state = oneOf('state', value, PROJECT_STATES);
else if (key === 'name') {
if (!value.trim() || value.length > 200) bad('name takes 1 to 200 characters');
q.name = value.trim().toLowerCase();
} else if (key === 'sort') { if (!SORTS.includes(value)) bad(`sort takes ${SORTS.join(', ')}`); q.sort = value; }
else if (key === 'order') { if (!['asc', 'desc'].includes(value)) bad('order takes asc or desc'); q.order = value; }
else if (key === 'project') {
const m = PROJECT_SELECTOR.exec(value);
if (!m) bad('project takes <org>:<project id or key>');
q.project = { org: m[1], key: m[2] };
} else bad(`unknown query key ${key}`);
}
q.order ??= q.sort === 'activity' ? 'desc' : 'asc';
return q;
}
const TIMED_OUT = Symbol('timed-out');
function deadline(ms) {
const controller = new AbortController(), until = Date.now() + ms;
let timer;
const promise = new Promise(resolve => { timer = setTimeout(() => { controller.abort(); resolve(TIMED_OUT); }, ms); timer.unref?.(); });
return { promise, signal: controller.signal, deadline: until, abort: () => controller.abort(), clear: () => clearTimeout(timer) };
}
const code = e => (typeof e?.code === 'string' && /^[a-z-]{1,64}$/.test(e.code) ? e.code : 'read-failed');
// Runs `names` reads `limit` at a time and stops starting new ones once `stopped()` says so.
async function pooled(names, limit, run, stopped) {
const out = {}, queue = [...names];
const worker = async () => {
while (queue.length && !stopped()) {
const name = queue.shift();
try { out[name] = { ok: true, value: await run(name) }; } catch (e) { out[name] = { ok: false, error: code(e) }; }
}
};
await Promise.all(Array.from({ length: Math.min(limit, names.length) }, worker));
return out;
}
const iso = ms => new Date(ms).toISOString();
const latest = values => values.filter(v => typeof v === 'string' && !Number.isNaN(Date.parse(v))).sort().at(-1) ?? null;
function median(values) {
if (!values.length) return null;
const s = [...values].sort((a, b) => a - b), mid = s.length >> 1;
return s.length % 2 ? s[mid] : Math.round((s[mid - 1] + s[mid]) / 2);
}
// One org's bus state and reads. Never throws: every failure becomes the org's state.
async function readOrg(member, needs, { timeoutMs, now, ctx }) {
const started = now();
const head = { org: member.id, label: member.id, theme: member.doc?.appearance?.theme ?? null, tracker: member.doc?.tracker ? 'vikunja' : 'built-in', roles: Object.keys(member.doc?.roles ?? {}).sort() };
const finish = (state, extra = {}) => ({ ...head, state, at: iso(now()), readMs: now() - started, ...extra });
if (member.invalid) return { org: { ...head, roles: [], state: 'invalid', at: iso(now()), readMs: 0, reason: member.reason } };
const limit = deadline(timeoutMs);
const bound = { signal: limit.signal, deadline: limit.deadline };
const invalid = () => ({ org: finish('invalid', { error: 'invalid-response' }) });
try {
let status;
try {
status = await Promise.race([member.status(bound), limit.promise]);
} catch (e) {
if (limit.signal.aborted || Date.now() >= limit.deadline) return { org: finish('timeout', { reason: `the bus state was not read within ${timeoutMs} ms` }) };
// A stale or mismatched host state (org-bus.mjs checkedState) is a stopped bus to the reader.
// members() has already made host paths relative; any absolute path left is withheld.
return { org: finish('down', { reason: plainMessage(ctx, typeof e?.message === 'string' ? e.message : 'bus state could not be read').slice(0, 300) }) };
}
if (status === TIMED_OUT || limit.signal.aborted || Date.now() >= limit.deadline) return { org: finish('timeout', { reason: `the bus state was not read within ${timeoutMs} ms` }) };
if (status?.healthy !== true) {
const boot = status?.boot && typeof status.boot === 'object' ? { ok: status.boot.ok === true, at: status.boot.at ?? null, lastGood: status.boot.lastGood ?? null } : null;
if (Number.isSafeInteger(status?.pid)) return { org: finish('starting', { reason: 'the bus host is running but does not answer yet', boot }) };
const reason = boot && !boot.ok && typeof status.boot.reason === 'string'
? `the bus refused to start: ${plainMessage(ctx, status.boot.reason).slice(0, 300)}`
: 'the bus is not running';
return { org: finish('down', { reason, boot }) };
}
const options = member.options(bound);
const reads = pooled(needs, PER_ORG_CONCURRENCY, name => READS[name](options), () => limit.signal.aborted || Date.now() >= limit.deadline);
const results = await Promise.race([reads, limit.promise]);
if (results === TIMED_OUT || limit.signal.aborted || Date.now() >= limit.deadline) return { org: finish('timeout', { reason: `the bus did not answer within ${timeoutMs} ms` }) };
const errors = Object.fromEntries(Object.entries(results).filter(([, r]) => !r.ok).map(([name, r]) => [name, r.error]));
const data = Object.fromEntries(Object.entries(results).filter(([, r]) => r.ok).map(([name, r]) => [name, r.value]));
if (Object.values(errors).includes('invalid-response') || !Object.entries(data).every(([name, value]) => validRead(name, value))) return invalid();
const org = finish('ok', { errors });
return { org, rows: orgRows(org, data, member.doc, now()) };
} catch { return invalid(); } finally { if (Date.now() >= limit.deadline) limit.abort(); limit.clear(); }
}
// Validate the fields consumed by projection and by the cross-org filters and sorts. Unknown
// fields are retained by the bus contract; optional fields can be absent or null.
const record = v => v !== null && typeof v === 'object' && !Array.isArray(v);
const optional = (v, check) => v == null || check(v);
const string = v => typeof v === 'string';
const strings = (v, keys) => keys.every(k => optional(v[k], string));
const records = v => Array.isArray(v) && v.every(record);
function validRead(name, value) {
if (name === 'usage' || name === 'limits') {
if (!record(value)) return false;
return name === 'limits' ? optional(value.tokens, records) : optional(value.total, record);
}
if (!records(value)) return false;
return value.every(v => {
if (name === 'projects') return string(v.id) && strings(v, ['repo', 'name', 'at']);
if (name === 'threads') return strings(v, ['id', 'project']);
if (name === 'claims') return strings(v, ['thread', 'instance', 'holder_run', 'harness', 'at']);
if (name === 'inbox') return string(v.id) && strings(v, ['question', 'class', 'raised_by_role', 'task_ref', 'at']) && optional(v.options, a => records(a) && a.every(o => strings(o, ['key', 'text'])));
if (name === 'agents') return strings(v, ['role', 'holder_run', 'harness', 'state', 'at']) && optional(v.working, w => record(w) && strings(w, ['since'])) && ['inputNeeded', 'stranded'].every(k => optional(v[k], records)) && (v.inputNeeded ?? []).every(d => strings(d, ['task_ref', 'at']));
if (name === 'tasks') return strings(v, ['task_ref', 'project_key', 'at']) && optional(v.fields, f => record(f) && strings(f, ['title', 'bucket']) && ['assignees', 'labels'].every(k => optional(f[k], Array.isArray)));
if (name === 'decisions') return strings(v, ['at']) && optional(v.closed, c => record(c) && strings(c, ['op', 'at']));
return true;
});
}
const arr = value => (Array.isArray(value) ? value : []);
// Merges one org's reads into rows. Each row names its org; ids stay the bus's own.
function orgRows(org, data, doc, nowMs) {
const id = org.org;
const projects = arr(data.projects).filter(p => p && typeof p.id === 'string');
const byKey = new Map(projects.filter(p => typeof p.repo === 'string').map(p => [p.repo, p]));
const byId = new Map(projects.map(p => [p.id, p]));
const ref = p => (p ? { id: p.id, key: p.repo ?? null, name: p.name ?? null } : null);
const threadProject = new Map(arr(data.threads).map(t => [t.id, byId.get(t.project) ?? null]));
const taskRows = arr(data.tasks).filter(t => t && typeof t.task_ref === 'string' && !t.fields?.gone);
const bots = new Map(Object.entries(doc?.roles ?? {}).filter(([, r]) => r?.tracker?.botId !== undefined).map(([role, r]) => [r.tracker.botId, role]));
const tasks = taskRows.map(t => {
const f = t.fields ?? {};
return {
org: id, ref: t.task_ref, tracker: org.tracker, title: f.title ?? null, done: Boolean(f.done), bucket: f.bucket ?? null,
assignees: arr(f.assignees).filter(u => bots.has(u)).map(u => bots.get(u)).sort(), labels: arr(f.labels), priority: t.priority ?? null,
requirement: t.requirement ?? null, project: ref(typeof t.project_key === 'string' ? byKey.get(t.project_key) : null), changedAt: t.at ?? null,
};
});
const taskByRef = new Map(tasks.map(t => [t.ref, t]));
// A role session's project and task: the task its open decision names, else its first open
// assigned task. A role claim itself names no project, so this is inferred, and says so.
const roleWork = role => {
const open = tasks.filter(t => !t.done && t.assignees.includes(role));
return open[0] ?? null;
};
const sessions = [];
for (const c of arr(data.agents)) {
if (!c || typeof c.role !== 'string') continue;
const asked = arr(c.inputNeeded).map(d => taskByRef.get(d.task_ref)).find(Boolean);
const task = asked ?? roleWork(c.role);
sessions.push({
org: id, kind: 'role', role: c.role, run: c.holder_run ?? null, harness: c.harness ?? null, state: c.state ?? null,
since: c.working?.since ?? arr(c.inputNeeded)[0]?.at ?? c.at ?? null, claimedAt: c.at ?? null,
messages: c.working?.messages ?? 0, inputNeeded: arr(c.inputNeeded).length, stranded: arr(c.stranded).length,
task: task ? { ref: task.ref, title: task.title } : null, project: task?.project ?? null, projectFrom: task ? 'task' : null,
});
}
for (const c of arr(data.claims)) {
if (!c || typeof c.thread !== 'string') continue;
const p = threadProject.get(c.thread) ?? null;
sessions.push({ org: id, kind: 'thread', thread: c.thread, instance: c.instance ?? null, run: c.holder_run ?? null, harness: c.harness ?? null, state: 'live', since: c.at ?? null, claimedAt: c.at ?? null, project: ref(p), projectFrom: p ? 'thread' : null });
}
const sessionByRole = new Map(sessions.filter(s => s.kind === 'role').map(s => [s.role, s]));
const inbox = arr(data.inbox).filter(d => d && typeof d.id === 'string').map(d => {
const task = taskByRef.get(d.task_ref) ?? null, s = sessionByRole.get(d.raised_by_role) ?? null;
const options = arr(d.options).map(o => ({ key: o.key, text: o.text }));
const safe = SHELL_SAFE.test(d.id) && ORG_ID.test(id);
return {
org: id, id: d.id, question: d.question ?? null, class: d.class ?? null, blocking: Boolean(d.blocking), raisedBy: d.raised_by_role ?? null,
taskRef: d.task_ref ?? null, task: task ? { ref: task.ref, title: task.title } : null, project: task?.project ?? null,
session: s ? { role: s.role, run: s.run } : null, at: d.at ?? null, options, console: Boolean(d.console),
// A shared view mixes organizations, so a copied command always names the org's bus.
decide: options.map(o => ({ key: o.key, command: safe && SHELL_SAFE.test(String(o.key)) ? `mosaic decide ${d.id} ${o.key} --org ${id}` : null })),
};
});
const projectRows = projects.map(p => {
const mine = s => s.project?.id === p.id;
const ps = sessions.filter(mine), needs = inbox.filter(mine), open = tasks.filter(t => !t.done && t.project?.id === p.id);
const count = state => ps.filter(s => s.state === state).length;
const counts = { working: count('working') + count('live'), waiting: count('waiting'), idle: count('idle') };
const entry = typeof p.repo === 'string' ? doc?.projects?.[p.repo] : undefined;
return {
org: id, id: p.id, key: p.repo ?? null, name: p.name ?? null, status: p.archived ? 'archived' : 'active',
state: counts.working ? 'working' : counts.waiting || needs.length ? 'waiting' : counts.idle ? 'idle' : 'none',
git: entry ? (entry.git?.mode ?? (entry.remote ? 'remote' : null)) : null,
sessions: counts, openTasks: open.length, needsYou: needs.length,
lastActivity: latest([p.at, ...tasks.filter(t => t.project?.id === p.id).map(t => t.changedAt), ...needs.map(d => d.at), ...ps.map(s => s.claimedAt)]),
};
});
const decided = arr(data.decisions).filter(d => d?.closed?.op === 'resolved' && typeof d.at === 'string' && typeof d.closed.at === 'string')
.map(d => Date.parse(d.closed.at) - Date.parse(d.at)).filter(ms => Number.isFinite(ms) && ms >= 0);
const roleCount = state => sessions.filter(s => s.kind === 'role' && s.state === state).length;
const metrics = {
org: id, needsYou: inbox.length, oldestNeedsYouAt: inbox.map(d => d.at).filter(Boolean).sort()[0] ?? null,
sessions: { working: roleCount('working'), waiting: roleCount('waiting'), idle: roleCount('idle'), live: sessions.filter(s => s.kind === 'thread').length },
openTasks: tasks.filter(t => !t.done).length,
doneThisWeek: tasks.filter(t => t.done && t.changedAt && nowMs - Date.parse(t.changedAt) <= WEEK_MS).length,
projects: { active: projectRows.filter(p => p.status === 'active').length, archived: projectRows.filter(p => p.status === 'archived').length },
usage: data.usage?.total ?? null, limits: arr(data.limits?.tokens), decisionWaitMedianMs: median(decided),
};
return { projects: projectRows, sessions, inbox, tasks, metrics };
}
function sortProjects(rows, { sort, order }) {
const activity = (a, b) => (a.lastActivity ?? '').localeCompare(b.lastActivity ?? '');
const name = (a, b) => (a.name ?? '').localeCompare(b.name ?? '') || a.id.localeCompare(b.id);
const by = {
activity: (a, b) => activity(a, b) || name(a, b),
name,
org: (a, b) => a.org.localeCompare(b.org) || name(a, b),
status: (a, b) => PROJECT_STATUSES.indexOf(a.status) - PROJECT_STATUSES.indexOf(b.status) || PROJECT_STATES.indexOf(a.state) - PROJECT_STATES.indexOf(b.state) || name(a, b),
}[sort];
const sign = order === 'desc' ? -1 : 1;
return [...rows].sort((a, b) => sign * by(a, b));
}
// `organizations.members(session, { timeoutMs })` gives the orgs this session belongs to, each
// `{ id, doc, status(), options() }` or `{ id, invalid, reason }`. A registry that cannot be read
// throws, and the HTTP handler answers a fixed message.
export async function readWorkspace({ organizations, session, query = parseWorkspaceQuery(''), timeoutMs = WORKSPACE_TIMEOUT_MS, now = Date.now, ctx = {} }) {
const members = (await organizations.members(session, { timeoutMs })).filter(m => !query.orgs || query.orgs.has(m.id));
const needs = [...new Set([...query.sections].flatMap(s => NEEDS[s]))];
const read = await Promise.all(members.map(m => readOrg(m, needs, { timeoutMs, now, ctx })));
const nowMs = now();
const merged = { projects: [], sessions: [], inbox: [], tasks: [], metrics: [] };
for (const r of read) {
if (r.org.state !== 'ok') continue;
const rows = r.rows;
for (const key of Object.keys(merged)) merged[key].push(...(key === 'metrics' ? [rows.metrics] : rows[key]));
}
const body = {
readAt: iso(nowMs), pollMs: WORKSPACE_POLL_MS, timeoutMs,
summary: { read: read.filter(r => r.org.state === 'ok').length, of: read.length },
orgs: read.map(r => r.org),
};
if (query.sections.has('projects')) {
const rows = merged.projects.filter(p => query.status.has(p.status) && (!query.state || query.state.has(p.state)) && (!query.name || (p.name ?? '').toLowerCase().includes(query.name)));
body.projects = sortProjects(rows, query);
}
if (query.sections.has('sessions')) body.sessions = merged.sessions.sort((a, b) => a.org.localeCompare(b.org) || (b.since ?? '').localeCompare(a.since ?? ''));
if (query.sections.has('inbox')) body.inbox = merged.inbox.sort((a, b) => (a.class === 'gated' ? 0 : 1) - (b.class === 'gated' ? 0 : 1) || (a.at ?? '').localeCompare(b.at ?? ''));
if (query.sections.has('tasks')) {
const p = query.project;
body.tasks = merged.tasks.filter(t => !p || (t.org === p.org && (t.project?.id === p.key || t.project?.key === p.key)))
.sort((a, b) => a.org.localeCompare(b.org) || a.ref.localeCompare(b.ref));
}
if (query.sections.has('metrics')) {
const m = merged.metrics;
const sum = f => m.reduce((n, x) => n + f(x), 0);
body.metrics = {
orgs: m,
totals: {
needsYou: sum(x => x.needsYou), oldestNeedsYouAt: m.map(x => x.oldestNeedsYouAt).filter(Boolean).sort()[0] ?? null,
sessions: { working: sum(x => x.sessions.working), waiting: sum(x => x.sessions.waiting), idle: sum(x => x.sessions.idle), live: sum(x => x.sessions.live) },
openTasks: sum(x => x.openTasks), doneThisWeek: sum(x => x.doneThisWeek),
projects: { active: sum(x => x.projects.active), archived: sum(x => x.projects.archived) },
},
};
}
return body;
}
const json = (res, status, body) => {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
res.end(JSON.stringify(body));
};
// GET /api/workspace. The hook fence has required a signed-in session on the page's own name.
// Only the owner reads it: a granted user or the tester sees no organizations today, so they get
// owner-only here too, and nothing about any org.
export async function workspaceHTTP(req, res, { session = null, owner = false, organizations = null, search = '', timeoutMs = WORKSPACE_TIMEOUT_MS, now = Date.now, ctx = {}, log = () => {} }) {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
if (!organizations || typeof organizations.members !== 'function') return json(res, 404, { error: 'org-unavailable' });
if (!owner || !session) return json(res, 403, { error: 'owner-only', message: 'the cross-organization view is the owner\'s' });
let query;
try { query = parseWorkspaceQuery(search); }
catch (e) { if (e instanceof WorkspaceQueryError) return json(res, 400, { error: 'invalid-request', message: e.message }); throw e; }
let body;
try { body = await readWorkspace({ organizations, session, query, timeoutMs, now, ctx }); }
catch { log('workspace: organization registry could not be read'); return json(res, 503, { error: 'org-unavailable', message: 'Organization registry could not be read' }); }
return json(res, 200, body);
}
+2 -1
View File
@@ -218,7 +218,8 @@ test('row 194 N5: agents.js is served as JavaScript and index.html loads it once
const s = await setup(t);
const js = await get(s, '/agents.js');
assert.equal(js.status, 200); assert.match(js.type, /^text\/javascript/);
assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section \}\)/);
// F3 adds the agent restart receipt and the thread test that Settings > Routing uses.
assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section, restartMarkup, isThread: definition => threadDefinitions\.has\(definition\) \}\)/);
assert.match(js.text, /Agent actions are unavailable here\. Use mosaic org instance from the instance CLI\./);
const html = await get(s, '/');
assert.equal(html.text.match(/<script src="\/agents\.js" defer><\/script>/g)?.length, 1);
+2 -2
View File
@@ -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, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false);
@@ -0,0 +1,101 @@
// F3: the owner restarts the Console from Settings > Console. The real supervisor in
// `mosaic console start` relaunches the Console on the same address, and the open page shows the
// restart and comes back by itself, with no manual reload. A page holding an unsaved draft keeps
// it and offers the reload instead of taking it.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
import { AGENT_MARKERS } from '../../cli/src/transport.mjs';
import { consoleStart, consoleStop } from '../../cli/src/org-console.mjs';
import { browser } from './browser.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
const sleep = ms => new Promise(r => setTimeout(r, ms));
// The page reloads on its own, so a check can land between two documents; it is retried.
async function until(b, expression, ms = 30000) {
const deadline = Date.now() + ms;
for (;;) {
try { if (await b.evaluate(`!!(${expression})`)) return; } catch { /* between documents */ }
if (Date.now() > deadline) throw new Error(`timed out: ${expression}`);
await sleep(50);
}
}
// Every banner the page shows is kept in sessionStorage, which outlives the page's own reload.
const WATCH = `(() => {
const seen = JSON.parse(sessionStorage.getItem('f3-seen') || '[]');
new MutationObserver(() => {
const box = document.getElementById('console-reconnect');
if (box && !box.hidden && seen.at(-1) !== box.textContent) { seen.push(box.textContent); sessionStorage.setItem('f3-seen', JSON.stringify(seen)); }
}).observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
return true;
})()`;
test('Settings > Console restarts the Console under its supervisor and the open page reconnects by itself', { timeout: 120000 }, async t => {
const home = mkdtempSync(join(tmpdir(), 'f3-'));
const env = { ...process.env, HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-f3test/config.json') };
for (const k of AGENT_MARKERS) delete env[k];
delete env.NODE_TEST_CONTEXT;
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'f3test'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-f3test'), dataRoot: join(home, '.mosaic-f3test'), instance: 'f3test', env };
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
const warnings = [];
const run = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) });
t.after(async () => { run.stop(); await run.done; try { await consoleStop(ctx, { warn: () => {} }); } catch {} rmSync(home, { recursive: true, force: true }); });
const stateFile = join(ctx.dataRoot, 'console/console.json');
const pidNow = () => JSON.parse(readFileSync(stateFile, 'utf8')).pid;
const b = await browser({ machine: false }); t.after(() => b.close());
await b.viewport(1440, 900);
await b.navigate(`${run.url}#/settings/console`);
await until(b, 'document.getElementById("console-restart")');
const boot1 = await b.evaluate('window.MosaicConsole.config.boot');
assert.equal(typeof boot1, 'string');
assert.match(await b.evaluate('document.getElementById("console-restart-title").closest("section").textContent'), /Organization buses are separate processes and keep running/);
await b.evaluate(`window.__f3 = 'first'`); await b.evaluate(WATCH);
// The button asks first; Cancel restarts nothing.
await b.evaluate('document.getElementById("console-restart").click()');
await until(b, 'document.getElementById("console-restart-cancel")');
await b.evaluate('document.getElementById("console-restart-cancel").click()');
await until(b, '!document.getElementById("console-restart-go") && !document.getElementById("console-restart").hidden');
assert.equal(pidNow(), run.pid);
await b.evaluate('document.getElementById("console-restart").click()');
await until(b, 'document.getElementById("console-restart-go")');
await b.evaluate('document.getElementById("console-restart-go").click()');
// The page reloads itself once the new Console answers: the marker is gone, the boot is new.
await until(b, `window.__f3 === undefined && window.MosaicConsole?.config?.boot && window.MosaicConsole.config.boot !== ${JSON.stringify(boot1)}`);
const seen1 = JSON.parse(await b.evaluate(`sessionStorage.getItem('f3-seen')`));
assert.equal(seen1[0], 'Console restarting. This page reconnects when it is back.');
assert.equal(await b.evaluate('location.hash'), '#/settings/console');
await until(b, 'document.getElementById("console-restart")');
const pid2 = pidNow();
assert.notEqual(pid2, run.pid);
assert.equal(JSON.parse(readFileSync(stateFile, 'utf8')).url, run.url);
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
const trail = readFileSync(join(ctx.dataRoot, 'console/actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
assert.deepEqual(trail.map(e => [e.action, e.outcome, e.by.provider, e.boot]), [['console.restart', 'restarting', 'local-dev', boot1]]);
// An unsaved draft (a reply being written) is not thrown away: the page says the Console is
// back and offers the reload. The textarea stands in for the chat reply box.
const boot2 = await b.evaluate('window.MosaicConsole.config.boot');
await b.evaluate(`(() => { window.__f3 = 'second'; sessionStorage.removeItem('f3-seen'); const t = document.createElement('textarea'); t.id = 'reply'; t.value = 'half a reply'; document.body.append(t); return true; })()`);
await b.evaluate(WATCH);
await b.evaluate('document.getElementById("console-restart").click()');
await until(b, 'document.getElementById("console-restart-go")');
await b.evaluate('document.getElementById("console-restart-go").click()');
await until(b, 'document.getElementById("console-reconnect-reload")');
assert.equal(await b.evaluate('window.__f3'), 'second');
assert.equal(await b.evaluate('document.getElementById("reply").value'), 'half a reply');
assert.deepEqual(JSON.parse(await b.evaluate(`sessionStorage.getItem('f3-seen')`)), ['Console restarting. This page reconnects when it is back.', 'The Console is back. Your unsaved draft is kept on this page. Reload now']);
assert.notEqual(await b.evaluate('window.MosaicConsole.config.boot'), boot2);
assert.notEqual(pidNow(), pid2);
await b.evaluate('document.getElementById("console-reconnect-reload").click()');
await until(b, `window.__f3 === undefined && document.getElementById("console-restart")`);
assert.equal(readFileSync(join(ctx.dataRoot, 'console/actions.jsonl'), 'utf8').split('\n').filter(Boolean).length, 2);
});
+284
View File
@@ -0,0 +1,284 @@
// F3: the owner's Console restart and the organization settings writes, through the real server.
// Each control is refused without the human, refused from another origin, works for the dev local
// owner and writes the action trail. No test reaches a live Console, a real config or a secret.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { actionTrail } from '../src/action-trail.mjs';
import { consoleAccess } from '../src/access.mjs';
import { close } from './fixture.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
const REVISION = 'a'.repeat(64);
const REQUEST = '10000000-0000-4000-8000-000000000001';
const ROUTES = { assistant: { harness: 'claude', model: 'opus', thinking: 'high' } };
const ROUTES_ANSWER = { revision: 'b'.repeat(64), harnesses: [], models: [], levels: {}, instances: { assistant: ROUTES.assistant } };
function call(target, { method = 'GET', path, headers = {}, body }) {
return new Promise((resolve, reject) => {
const all = { ...headers };
for (const k of Object.keys(all)) if (all[k] === null) delete all[k];
if (body !== undefined) all['content-length'] = Buffer.byteLength(body);
const req = request({ ...target, method, path, headers: all }, res => {
let raw = '';
res.on('data', c => { raw += c; });
res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch { /* not JSON */ } resolve({ status: res.statusCode, body: json, raw }); });
});
req.on('error', reject);
req.end(body);
});
}
const scratch = t => { const root = mkdtempSync(join(tmpdir(), 'f3-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; };
const lines = dir => (existsSync(join(dir, 'actions.jsonl')) ? readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []);
const spy = () => { const calls = []; let hit; const once = new Promise(r => { hit = r; }); const fn = () => { calls.push(Date.now()); hit(); }; return { fn, calls, once }; };
// A loopback Console under the dev local owner with --send; `opts` overrides any option.
async function devConsole(t, opts = {}) {
const root = scratch(t), dir = join(root, 'console'), restart = spy();
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), log: () => {}, restart: restart.fn, trail: actionTrail(dir), ...opts });
t.after(() => close(server));
const port = server.address().port, host = `127.0.0.1:${port}`, origin = `http://${host}`;
const write = (path, body = '{}', headers = {}) => call({ host: '127.0.0.1', port }, { method: 'POST', path, body, headers: { host, origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...headers } });
const read = async path => (await call({ host: '127.0.0.1', port }, { path, headers: { host } })).body;
return { port, host, origin, dir, root, restart, write, read };
}
const FENCE = port => [
[{ origin: null }, 'cross-origin request refused'],
[{ origin: 'http://evil.example' }, 'cross-origin request refused'],
[{ origin: `http://localhost:${port + 1}` }, 'cross-origin request refused'],
[{ 'sec-fetch-site': null }, 'cross-site request refused'],
[{ 'sec-fetch-site': 'cross-site' }, 'cross-site request refused'],
[{ 'sec-fetch-site': 'same-site' }, 'cross-site request refused'],
[{ host: `evil.example:${port}` }, 'non-local Host refused'],
[{ 'x-mosaic-tester': '1' }, 'tester listener required'],
];
test('the dev owner restarts a supervised Console once, after the answer, and the trail names them', async t => {
const c = await devConsole(t);
const config = await c.read('/api/config');
assert.equal(config.consoleRestart, true);
assert.match(config.boot, /^[0-9a-f-]{36}$/);
const r = await c.write('/api/console/restart');
assert.equal(r.status, 202, r.raw);
assert.deepEqual(r.body, { outcome: 'restarting', boot: config.boot });
await c.restart.once;
assert.equal(c.restart.calls.length, 1);
const trail = lines(c.dir);
assert.equal(trail.length, 1);
const [{ at, ...entry }] = trail;
assert.ok(Number.isFinite(Date.parse(at)));
assert.deepEqual(entry, { by: DEV, action: 'console.restart', outcome: 'restarting', boot: config.boot });
assert.equal(statSync(join(c.dir, 'actions.jsonl')).mode & 0o777, 0o600);
assert.equal(statSync(c.dir).mode & 0o777, 0o700);
});
test('a Console restart from another origin, site, Host or the tester header is refused and leaves no trail', async t => {
const c = await devConsole(t);
for (const [headers, error] of FENCE(c.port)) {
const r = await c.write('/api/console/restart', '{}', headers);
assert.equal(r.status, 403, JSON.stringify(headers));
assert.equal(r.body.error, error, JSON.stringify(headers));
}
assert.equal(c.restart.calls.length, 0);
assert.deepEqual(lines(c.dir), []);
});
test('without the signed-in owner there is no Console restart', async t => {
const root = scratch(t), dir = join(root, 'console'), restart = spy();
// A loopback Console with no sign-in has no owner at all.
const anon = await startServer({ port: 0, board: 'http://127.0.0.1:9', log: () => {}, restart: restart.fn, trail: actionTrail(dir) });
t.after(() => close(anon));
const ap = anon.address().port, ah = `127.0.0.1:${ap}`;
const a = await call({ host: '127.0.0.1', port: ap }, { method: 'POST', path: '/api/console/restart', body: '{}', headers: { host: ah, origin: `http://${ah}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
assert.equal(a.status, 403); assert.equal(a.body.error, 'owner-only');
assert.equal((await call({ host: '127.0.0.1', port: ap }, { path: '/api/config', headers: { host: ah } })).body.consoleRestart, false);
// Under --sso: signed out, a granted user, then the signed-in owner.
const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] };
const s = await createSsoSession(t, { users: consoleAccess({ load: () => access, business: () => 'demo' }) });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: s.sso, send: async () => ({}), log: () => {}, restart: restart.fn, trail: actionTrail(dir) });
t.after(() => close(web));
const port = web.address().port, target = { host: '127.0.0.1', port };
const post = cookie => call(target, { method: 'POST', path: '/api/console/restart', body: '{}', headers: s.writeHeaders(cookie) });
const out = await post(null);
assert.equal(out.status, 401); assert.equal(out.body.error, 'sign-in required');
const alice = await s.signIn(port, { sub: 'alice-sub' });
const granted = await post(alice);
assert.equal(granted.status, 403); assert.equal(granted.body.error, 'owner-only');
assert.equal(restart.calls.length, 0);
assert.deepEqual(lines(dir), []);
const owner = await s.signIn(port);
assert.equal((await call(target, { path: '/api/config', headers: s.readHeaders(owner) })).body.consoleRestart, true);
const ok = await post(owner);
assert.equal(ok.status, 202, ok.raw);
await restart.once;
const [entry] = lines(dir);
assert.equal(entry.by.provider, 'writer'); assert.equal(entry.by.sub, 'owner-sub'); assert.equal(entry.by.name, 'jason');
assert.equal(entry.action, 'console.restart');
});
test('the tester listener never restarts the Console', async t => {
const root = scratch(t), dir = join(root, 'console'), restart = spy(), socketPath = join(root, 'tester.sock');
const server = await startServer({ tester: true, testerSocket: socketPath, board: 'http://127.0.0.1:9', log: () => {}, restart: restart.fn, trail: actionTrail(dir) });
t.after(() => close(server));
const headers = { host: 'mosaic-tester.invalid', origin: 'http://mosaic-tester.invalid', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
const r = await call({ socketPath }, { method: 'POST', path: '/api/console/restart', body: '{}', headers });
assert.equal(r.status, 403); assert.equal(r.body.error, 'owner-only');
assert.equal((await call({ socketPath }, { path: '/api/config', headers: { host: 'mosaic-tester.invalid' } })).body.consoleRestart, false);
assert.equal(restart.calls.length, 0);
assert.deepEqual(lines(dir), []);
});
test('a Console without --send, its supervisor or a safe trail says so and stays up', async t => {
const readOnly = await devConsole(t, { send: null });
assert.equal((await readOnly.read('/api/config')).consoleRestart, false);
const ro = await readOnly.write('/api/console/restart');
assert.equal(ro.status, 403); assert.equal(ro.body.error, 'read-only');
const detached = await devConsole(t, { restart: null });
assert.equal((await detached.read('/api/config')).consoleRestart, false);
const d = await detached.write('/api/console/restart');
assert.equal(d.status, 409); assert.equal(d.body.error, 'not-supervised');
assert.deepEqual(lines(detached.dir), []);
const noTrail = await devConsole(t, { trail: null });
assert.equal((await noTrail.read('/api/config')).consoleRestart, false);
const n = await noTrail.write('/api/console/restart');
assert.equal(n.status, 503); assert.equal(n.body.error, 'trail-unavailable');
const broken = await devConsole(t, { trail: () => { throw new Error('disk full'); } });
const b = await broken.write('/api/console/restart');
assert.equal(b.status, 500); assert.equal(b.body.error, 'trail-unavailable');
// A trail directory that is a symlink is unsafe; the restart is refused, nothing is written.
const root = scratch(t), real = join(root, 'real');
mkdirSync(real, { mode: 0o700 }); symlinkSync(real, join(root, 'console'));
const linked = await devConsole(t, { trail: actionTrail(join(root, 'console')) });
const l = await linked.write('/api/console/restart');
assert.equal(l.status, 500); assert.equal(l.body.error, 'trail-unavailable');
assert.equal(existsSync(join(real, 'actions.jsonl')), false);
for (const c of [readOnly, detached, noTrail, broken, linked]) assert.equal(c.restart.calls.length, 0);
});
test('the Console restart body is exactly {} as JSON', async t => {
const c = await devConsole(t);
for (const [body, headers, status] of [
['{}', { 'content-type': 'text/plain' }, 415],
['{}', { 'content-type': null }, 415],
['{"now":true}', {}, 400],
['[]', {}, 400],
['nope', {}, 400],
['', {}, 400],
]) {
const r = await c.write('/api/console/restart', body, headers);
assert.equal(r.status, status, `${body} ${JSON.stringify(headers)}`);
}
assert.equal(c.restart.calls.length, 0);
assert.deepEqual(lines(c.dir), []);
});
// The organization settings writes. `alpha` answers; `beta` is offline.
function orgs() {
const routed = [], launched = [], changed = [];
const organizations = {
list: async () => [{ id: 'alpha', label: 'Alpha', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: false }],
resolve: async id => (['alpha', 'beta'].includes(id) ? {
busHealthy: id === 'alpha', send: async () => ({}),
routesWrite: async args => { routed.push([id, args]); return ROUTES_ANSWER; },
agentWrite: async (verb, args) => { launched.push([id, verb, args]); return { request: REQUEST }; },
instancesWrite: async args => { changed.push([id, args]); return { revision: REVISION, restart: false, definitions: {}, instances: { coder: {} } }; },
} : null),
};
return { organizations, routed, launched, changed };
}
test('the dev owner changes an organization agent route and restarts it, and the trail names the organization', async t => {
const o = orgs();
const c = await devConsole(t, { organizations: o.organizations });
const alpha = await c.read('/api/orgs/alpha/config');
assert.equal(alpha.send, false); assert.equal(alpha.settingsSend, true); assert.equal(alpha.orgSetup, true);
assert.equal((await c.read('/api/orgs/beta/config')).settingsSend, false, 'an offline organization offers no settings writes');
const r = await c.write('/api/orgs/alpha/routes', JSON.stringify({ revision: REVISION, routes: ROUTES }));
assert.equal(r.status, 200, r.raw);
assert.deepEqual(o.routed, [['alpha', { revision: REVISION, routes: ROUTES }]]);
const launch = await c.write('/api/orgs/alpha/agent/launch', JSON.stringify({ instance: 'assistant' }));
assert.equal(launch.status, 200, launch.raw);
assert.deepEqual(o.launched, [['alpha', 'instance.launch', { instance: 'assistant' }]]);
const trail = lines(c.dir).map(({ at, ...e }) => e);
assert.deepEqual(trail, [
{ by: DEV, org: 'alpha', action: 'routes.set', instances: ['assistant'], outcome: 'done' },
{ by: DEV, org: 'alpha', action: 'instance.launch', instance: 'assistant', outcome: `done request=${REQUEST}` },
]);
// The limits route passes the gate and is refused by its own check: alpha has no settings transport.
const limits = await c.write('/api/orgs/alpha/settings', '{}');
assert.equal(limits.status, 403); assert.equal(limits.body.error, 'read-only');
assert.equal((await c.write('/api/orgs/missing/routes', JSON.stringify({ revision: REVISION, routes: ROUTES }))).status, 404);
});
test('F3 review P3: an organization instance change appends its trail line, as the README promises', async t => {
const o = orgs();
const c = await devConsole(t, { organizations: o.organizations });
const r = await c.write('/api/orgs/alpha/instances', JSON.stringify({ revision: REVISION, instance: 'coder', op: 'change', vars: {} }));
assert.equal(r.status, 200, r.raw);
assert.deepEqual(o.changed, [['alpha', { revision: REVISION, instance: 'coder', op: 'change', vars: {} }]]);
assert.deepEqual(lines(c.dir).map(({ at, ...e }) => e), [
{ by: DEV, org: 'alpha', action: 'instances.set', op: 'change', instance: 'coder', outcome: 'done restart=false' },
]);
});
test('organization settings writes from another origin or site are refused before the bus', async t => {
const o = orgs();
const c = await devConsole(t, { organizations: o.organizations });
const body = JSON.stringify({ revision: REVISION, routes: ROUTES });
for (const [headers] of FENCE(c.port)) {
for (const path of ['/api/orgs/alpha/routes', '/api/orgs/alpha/agent/terminate']) {
const r = await c.write(path, path.endsWith('routes') ? body : JSON.stringify({ instance: 'assistant' }), headers);
assert.equal(r.status, 403, `${path} ${JSON.stringify(headers)}`);
}
}
assert.deepEqual(o.routed, []); assert.deepEqual(o.launched, []);
});
test('organization settings writes need the signed-in owner: no sign-in, the tester listener and a granted user are refused', async t => {
const o = orgs(), root = scratch(t), dir = join(root, 'console');
const body = JSON.stringify({ revision: REVISION, routes: ROUTES });
const anon = await startServer({ port: 0, board: 'http://127.0.0.1:9', organizations: o.organizations, log: () => {}, trail: actionTrail(dir) });
t.after(() => close(anon));
const ap = anon.address().port, ah = `127.0.0.1:${ap}`;
const a = await call({ host: '127.0.0.1', port: ap }, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: { host: ah, origin: `http://${ah}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
assert.equal(a.status, 404); assert.equal(a.body.error, 'org-unavailable');
const socketPath = join(root, 'tester.sock');
const tester = await startServer({ tester: true, testerSocket: socketPath, board: 'http://127.0.0.1:9', organizations: o.organizations, log: () => {}, trail: actionTrail(dir) });
t.after(() => close(tester));
const tr = await call({ socketPath }, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: { host: 'mosaic-tester.invalid', origin: 'http://mosaic-tester.invalid', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } });
assert.equal(tr.status, 404); assert.equal(tr.body.error, 'org-unavailable');
const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] };
const s = await createSsoSession(t, { users: consoleAccess({ load: () => access, business: () => 'demo' }) });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: s.sso, send: async () => ({}), organizations: o.organizations, log: () => {}, trail: actionTrail(dir) });
t.after(() => close(web));
const port = web.address().port, target = { host: '127.0.0.1', port };
const alice = await s.signIn(port, { sub: 'alice-sub' });
const g = await call(target, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: s.writeHeaders(alice) });
assert.equal(g.status, 403); assert.equal(g.body.error, 'owner-only');
assert.deepEqual(o.routed, []);
assert.deepEqual(lines(dir), []);
// The signed-in owner on a deployed (non-dev) Console reaches the organization the same way.
const owner = await s.signIn(port);
const ok = await call(target, { method: 'POST', path: '/api/orgs/alpha/routes', body, headers: s.writeHeaders(owner) });
assert.equal(ok.status, 200, ok.raw);
assert.equal(o.routed.length, 1);
const [entry] = lines(dir);
assert.equal(entry.by.sub, 'owner-sub'); assert.equal(entry.org, 'alpha'); assert.equal(entry.action, 'routes.set');
});
+86
View File
@@ -9,6 +9,7 @@ import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { createOrgAdmin, orgAdminHTTP, browserProblems, pathFromMessage, toCanonical, forBrowser, plainMessage } from '../src/org-admin.mjs';
import { actionTrail } from '../src/action-trail.mjs';
import { close } from './fixture.mjs';
class OrgError extends Error { constructor(code, message = code) { super(message); this.code = code; } }
@@ -37,6 +38,7 @@ function fakes() {
busStatus: async (ctx, id) => { get(id); return { org: id, instance: 'test', root: `/data/org-runtime/${id}`, socket: '/run/s', healthy: running === id, pid: running === id ? 4242 : null }; },
busStart: async (ctx, id, opts) => { calls.push(['busStart', id, opts]); running = id; return { org: id, pid: 4242, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
busStopForDelete: async (ctx, id) => { calls.push(['busStopForDelete', id]); running = null; return { stopped: false, reason: 'no host runs' }; },
busRestart: async (ctx, id) => { calls.push(['busRestart', id]); running = id; return { org: id, pid: 4343, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
};
const admin = createOrgAdmin({ service, registry, bus, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
return { admin, orgs, calls, fail, service };
@@ -479,3 +481,87 @@ test('archive requires the injected bus stop and both lifecycle routes require o
for (const step of ['archive', 'restore']) assert.equal((await readonly.call('POST', `/api/orgs/acme/${step}`, { revision: 1 })).status, 403);
assert.equal(called, false);
});
// F3 (REQ-WEB-3): the owner restarts an organization's bus from the page, with a done receipt.
const trailIn = t => { const root = mkdtempSync(join(tmpdir(), 'f3-trail-')); t.after(() => rmSync(root, { recursive: true, force: true })); const dir = join(root, 'console'); return { dir, trail: actionTrail(dir), lines: () => { try { return readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => { const { at, ...e } = JSON.parse(l); assert.ok(Number.isFinite(Date.parse(at))); return e; }); } catch (e) { if (e.code === 'ENOENT') return []; throw e; } } }; };
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
test('the owner starts a stopped bus and restarts a running one, and the trail records each', async t => {
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
assert.equal((await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') })).status, 201);
let r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(r.status, 200, r.raw);
assert.deepEqual(r.body, { outcome: 'done', step: 'bus-restart', restarted: false, healthy: true, receipt: { org: 'acme', pid: 4242, socket: '<host path>', root: '<data root>/org-runtime/acme' }, verb: 'mosaic bus start acme --dev-local-owner --detach' });
// A stopped bus starts as setup starts it, keeping the dev local owner.
assert.deepEqual(c.calls.filter(x => x[0] === 'busStart').map(x => x.slice(1)), [['acme', { detach: true, devLocalOwner: true, pm: false }]]);
r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(r.status, 200, r.raw);
assert.equal(r.body.restarted, true); assert.equal(r.body.healthy, true); assert.equal(r.body.verb, 'mosaic bus restart acme');
assert.equal(r.body.receipt.pid, 4343);
assert.deepEqual(c.calls.filter(x => x[0] === 'busRestart'), [['busRestart', 'acme']]);
assert.deepEqual(tr.lines(), [
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: false },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: true },
{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' },
]);
});
test('a bus restart from another origin or site, without the owner or --send, or for no organization is refused', async t => {
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
for (const headers of [{ origin: null }, { origin: 'http://evil.example' }, { origin: `http://localhost:${c.port + 1}` }, { 'sec-fetch-site': null }, { 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': 'same-site' }, { 'x-mosaic-tester': '1' }]) {
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {}, headers);
assert.equal(r.status, 403, JSON.stringify(headers));
}
assert.equal((await c.call('POST', '/api/orgs/acme/restart/bus', {}, { 'content-type': 'text/plain' })).status, 415);
assert.equal((await c.call('POST', '/api/orgs/acme/restart/bus', { now: true })).status, 400);
assert.equal((await c.call('GET', '/api/orgs/acme/restart/bus')).status, 404);
assert.equal((await c.call('POST', '/api/orgs/acme/restart/queue', {})).status, 404);
const unknown = await c.call('POST', '/api/orgs/ghost/restart/bus', {});
assert.equal(unknown.status, 404); assert.equal(unknown.body.error, 'org-unavailable');
const readOnly = await console_(t, { trail: tr.trail, send: null });
const ro = await readOnly.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(ro.status, 403); assert.equal(ro.body.error, 'read-only');
const anon = await console_(t, { trail: tr.trail, sso: null, send: null });
const a = await anon.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(a.status, 403); assert.equal(a.body.error, 'owner-only');
assert.equal(c.calls.filter(x => ['busStart', 'busRestart'].includes(x[0])).length, 0);
assert.deepEqual(tr.lines(), []);
});
test('a bus restart with no trail, or a trail that cannot be written, changes nothing', async t => {
const c = await console_(t);
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(r.status, 503); assert.equal(r.body.error, 'trail-unavailable');
const broken = await console_(t, { trail: () => { throw new Error('disk full'); } });
await broken.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
const b = await broken.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(b.status, 500); assert.equal(b.body.error, 'trail-unavailable');
for (const x of [c, broken]) assert.equal(x.calls.filter(y => ['busStart', 'busRestart'].includes(y[0])).length, 0);
});
test('a bus that refuses to restart is a visible failure and the trail says refused', async t => {
const tr = trailIn(t);
const c = await console_(t, { trail: tr.trail });
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
await c.call('POST', '/api/orgs/acme/setup/bus', {});
const bus = await c.admin.bus();
bus.busRestart = async () => { throw Object.assign(new Error('bus failed to start; see /data/org-runtime/acme/host/host.log'), { name: 'CliError', exitCode: 3 }); };
const r = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
assert.equal(r.body.message, 'bus failed to start; see <data root>/org-runtime/acme/host/host.log');
assert.deepEqual(tr.lines().map(e => e.outcome), ['requested', 'refused']);
// A host whose status cannot be read (a stale lock) is refused before anything changes; the
// attempt is still in the trail, and nothing restarts or starts.
bus.busStatus = async () => { throw Object.assign(new Error('stale bus host lock'), { name: 'CliError', exitCode: 3 }); };
const before = c.calls.length;
const s = await c.call('POST', '/api/orgs/acme/restart/bus', {});
assert.equal(s.status, 409); assert.equal(s.body.error, 'bus-refused');
assert.equal(s.body.message, 'stale bus host lock. Inspect it with mosaic bus status acme; mosaic bus recover acme clears a host that died.');
assert.deepEqual(tr.lines().slice(2), [{ by: DEV, action: 'bus.restart', org: 'acme', outcome: 'refused', running: null }]);
assert.equal(c.calls.slice(before).filter(y => ['busStart', 'busRestart'].includes(y[0])).length, 0);
});
+4 -1
View File
@@ -54,7 +54,10 @@ test('organization routes bind reads and writes to the authorized organization',
assert.deepEqual(calls, [['alpha', { thread, body: 'hello' }]]);
assert.equal((await post(`/api/orgs/beta/chat/threads/${thread}/messages`, { body: 'hello' }, { origin: 'http://evil.example' }))[0], 403);
assert.equal(calls.length, 1);
assert.equal((await post('/api/orgs/alpha/settings', {}))[1].error, 'unavailable');
// F3: the owner's settings writes now reach the selected organization; this one offers no
// settings writer, so the route itself answers read-only and nothing is written.
assert.equal((await post('/api/orgs/alpha/settings', {}))[1].error, 'read-only');
assert.equal((await post('/api/orgs/alpha/settings', {}, { origin: 'http://evil.example' }))[0], 403);
assert.equal((await post('/api/settings', {}))[1].error, 'org-unavailable');
});
@@ -0,0 +1,105 @@
// F3: after a route save, Settings > Routing says how the change reaches each saved agent, read
// from the instances: the launcher and a stopped agent take it at their next start, a running
// session keeps its old route until it ends (a thread when it goes idle), and a running agent
// offers "Restart now". Restart now terminates with the reason "route change", waits for the
// session to end and launches it again; a thread is only terminated. Real server under the dev
// local owner, a stub bus, every write gated by the browser's own Origin and recorded in the trail.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { browser } from './browser.mjs';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { actionTrail } from '../src/action-trail.mjs';
import { close } from './fixture.mjs';
const REV = 'a'.repeat(64), REV2 = 'b'.repeat(64);
const END = '10000000-0000-4000-8000-0000000000e1', LAUNCH = '10000000-0000-4000-8000-0000000000a1', END2 = '10000000-0000-4000-8000-0000000000e2';
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' };
const route = thinking => ({ harness: 'claude', model: 'opus', thinking, fallback: null });
test('F3 Settings > Routing: a save lists how the change reaches each agent, and Restart now moves a running one', { timeout: 120000 }, async t => {
const root = mkdtempSync(join(tmpdir(), 'f3r-')); t.after(() => rmSync(root, { recursive: true, force: true }));
const entry = (definition, extra) => ({ definition, vars: {}, launched: true, launcher: false, protected: false, kept: false, running: [], alive: [], ...extra });
const instances = {
chief: entry('chief', { launcher: true }),
coder: entry('coder', { running: ['run-1'], alive: ['run-1'] }),
assistant: entry('assistant', { running: ['run-a'], alive: ['run-a'] }),
reviewer: entry('reviewer', { launched: false }),
};
const view = () => structuredClone({ business: 'acme', revision: REV, launch: true, noHire: [], retired: [], instances,
definitions: Object.fromEntries(Object.values(instances).map(e => [e.definition, { services: [], accepted: true }])) });
let routes = { revision: REV, harnesses: ['claude'], models: ['opus'], levels: { claude: ['low', 'high'] },
instances: Object.fromEntries(Object.keys(instances).map(n => [n, route('high')])) };
const asked = [], requests = new Map();
const bus = { messages: async () => ({ messages: [], unread: 0 }), usage: async () => { throw Error('no usage'); }, instances: async () => view(), routes: async () => structuredClone(routes) };
const routesWrite = async args => {
asked.push(['routes.set', args]);
routes = { ...routes, revision: REV2, instances: { ...routes.instances, ...structuredClone(args.routes) } };
return structuredClone(routes);
};
const agentWrite = async (verb, args) => {
asked.push([verb, args]);
if (verb === 'instance.end') {
const id = args.instance === 'coder' ? END : END2;
requests.set(id, { instance: args.instance, action: 'end' });
// The session ends a moment after the request closes, so the page has to wait for it.
setTimeout(() => { instances[args.instance].running = []; instances[args.instance].alive = []; }, 400);
return { request: id, runs: [...instances[args.instance].running], cancelled: [], taken: [] };
}
if (verb === 'instance.launch') { requests.set(LAUNCH, { instance: args.instance, action: 'launch' }); instances[args.instance].running = ['run-2']; return { request: LAUNCH }; }
if (verb === 'instance.request') {
const r = requests.get(args.request);
return { request: args.request, instance: r.instance, action: r.action, state: 'closed', done: { run: r.action === 'launch' ? 'run-2' : null, transaction: null, refusal: null }, cancelled: null };
}
throw Error(`unexpected ${verb}`);
};
const dir = join(root, 'console');
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), bus, routesWrite, agentWrite,
reads: { settings: () => ({ business: 'acme', release: 'fixture', notifier: {} }) }, trail: actionTrail(dir), log: () => {} });
t.after(() => close(web));
const b = await browser(); t.after(() => b.close());
const wait = (expr, ms = 20000) => b.evaluate(`(async()=>{const end=Date.now()+${ms};for(;;){if(${expr})return true;if(Date.now()>end)throw Error('wait failed: '+${JSON.stringify(expr)});await new Promise(r=>setTimeout(r,30));}})()`);
const cell = (name, key) => `[...document.querySelectorAll('[data-route]')].find(e=>e.dataset.route===${JSON.stringify(JSON.stringify([name, key]))})`;
const items = () => b.evaluate(`[...document.querySelectorAll('.routes-apply li')].map(li=>{const c=li.cloneNode(true);c.querySelector('.agent-restart')?.remove();return c.textContent.trim();})`);
await b.viewport(1440, 900);
await b.navigate(`http://127.0.0.1:${web.address().port}/#/settings/routing`);
await wait(`${cell('coder', 'thinking')}&&!${cell('coder', 'thinking')}.disabled`);
assert.equal(await b.evaluate(`!!document.querySelector('.routes-apply')`), false, 'nothing is listed before a save');
for (const name of ['chief', 'coder', 'assistant', 'reviewer']) await b.evaluate(`(()=>{const s=${cell(name, 'thinking')};s.value='low';s.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`);
await b.evaluate(`document.getElementById('routes-form').requestSubmit();true`);
await wait(`document.querySelector('.routes-apply')`);
assert.deepEqual(asked, [['routes.set', { revision: REV, routes: { chief: route('low'), coder: route('low'), assistant: route('low'), reviewer: route('low') } }]]);
assert.equal(await b.evaluate(`document.getElementById('routes-receipt').textContent`), 'Routes saved for chief, coder, assistant, reviewer. The next launch of each uses the new route.');
assert.deepEqual(await items(), [
'chief: The launcher. It takes the new route at its next start.',
'coder: Running. Its session keeps the old route until it ends. Restart it to move it now.',
'assistant: Running. Its session keeps the old route until the thread goes idle; the next message after that starts it on the new route. Restart it to move it now.',
'reviewer: Not running. Its next start uses the new route.',
]);
// Only the running agents offer the restart, and the thread is marked as one.
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('.routes-apply [data-agent-restart]')].map(e=>[e.dataset.agentRestart,e.dataset.agentThread,e.textContent])`),
[['coder', 'no', 'Restart coder now'], ['assistant', 'yes', 'Restart assistant now']]);
// Restart now on a session: terminate with the reason, wait for it to end, then one launch.
await b.evaluate(`document.querySelector('[data-agent-restart="coder"]').click();true`);
await wait(`document.querySelector('[data-agent-restart-status="coder"]')?.textContent==='Restarted coder on the new route (run run-2).'`, 30000);
assert.deepEqual(asked.slice(1).filter(([v]) => v !== 'instance.request'), [['instance.end', { instance: 'coder', reason: 'route change' }], ['instance.launch', { instance: 'coder' }]]);
assert.equal(asked.filter(([v, a]) => v === 'instance.launch' && a.instance === 'coder').length, 1, 'one launch, after the session ended');
// A thread is terminated, not launched: its next message starts it on the new route.
await b.evaluate(`document.querySelector('[data-agent-restart="assistant"]').click();true`);
await wait(`/^Terminated assistant at .+\\. Its next message starts it on the new route\\.$/.test(document.querySelector('[data-agent-restart-status="assistant"]')?.textContent??'')`, 30000);
assert.equal(asked.filter(([v, a]) => v === 'instance.launch' && a.instance === 'assistant').length, 0);
// The trail names the dev owner for the save and each lifecycle write.
const trail = readFileSync(join(dir, 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => { const { at, ...e } = JSON.parse(l); assert.match(at, /^\d{4}-\d\d-\d\dT/); return e; });
assert.deepEqual(trail.map(e => [e.action, e.by?.provider, e.outcome?.split(' ')[0]]), [
['routes.set', 'local-dev', 'done'], ['instance.end', 'local-dev', 'done'], ['instance.launch', 'local-dev', 'done'], ['instance.end', 'local-dev', 'done'],
]);
assert.deepEqual(trail[0].by, DEV);
await b.viewport(390, 844);
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=390`), true, 'no sideways scroll at phone width');
});
@@ -122,5 +122,16 @@ test('an unconfigured proxy is one empty state without forms or a false read-onl
await b.navigate(f.base+'/#/settings/model-proxy');await wait('document.querySelector("#proxy-message")?.textContent.includes("not configured")');
assert.equal(await b.evaluate('document.querySelector("#proxy-message").classList.contains("empty")'),true);
assert.equal(await b.evaluate('!!document.querySelector(".settings-readonly")'),false);
assert.equal(await b.evaluate('[...document.querySelectorAll(".settings-model-proxy .panel:not(#proxy-dev-setup)")].every(e=>e.hidden)'),true);
// F3: a dev Console adds one note naming the terminal setup. It holds no form and no input: the
// management key is never typed into this page.
assert.equal(await b.evaluate('document.querySelector("#proxy-dev-setup code")?.textContent'),'mosaic proxy fetch\nmosaic proxy init');
assert.equal(await b.evaluate('document.querySelectorAll("#proxy-dev-setup form,#proxy-dev-setup input,#proxy-dev-setup textarea").length'),0);
});
test('an unconfigured proxy without the dev owner shows no setup note', {timeout:90000}, async t => {
const {f,b,wait}=await w15Fixture(t,{scenario:{'/api/config':{send:true},'GET /api/proxy/accounts':{status:503,body:{error:'not-configured'}},'GET /api/proxy/keys':{status:503,body:{error:'not-configured'}}}});
await b.navigate(f.base+'/#/settings/model-proxy');await wait('document.querySelector("#proxy-message")?.textContent.includes("not configured")');
assert.equal(await b.evaluate('!!document.querySelector("#proxy-dev-setup")'),false);
assert.equal(await b.evaluate('[...document.querySelectorAll(".settings-model-proxy .panel")].every(e=>e.hidden)'),true);
});
@@ -0,0 +1,114 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { setTimeout as delay } from 'node:timers/promises';
import { createServer } from 'node:net';
import * as orgBus from '../../cli/src/org-bus.mjs';
import { busCaller } from '../src/bus.mjs';
import { consoleBus } from '../src/console-bus.mjs';
import { organizationConsole } from '../src/organizations.mjs';
import { readWorkspace, workspaceHTTP, parseWorkspaceQuery } from '../src/workspace-read.mjs';
const session = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
const goodOptions = overrides => ({
bus: { tasks: async () => [], agents: async () => [], inbox: async () => [], 'thread.claims': async () => [], usage: async () => ({}), limits: async () => ({}), decisions: async () => [], ...overrides?.bus },
chat: { projects: async () => [{ id: 'p', name: 'Healthy' }], threads: async () => [], ...overrides?.chat },
});
const member = (id, overrides) => ({ id, doc: {}, status: async () => ({ healthy: true }), options: () => goodOptions(overrides) });
const orgs = (...members) => ({ members: async () => members });
const response = () => ({ writeHead(status) { this.status = status; }, end(body) { this.body = JSON.parse(body); } });
for (const [name, overrides] of [
['null thread', { chat: { threads: async () => [null] } }],
['null inbox option', { bus: { inbox: async () => [{ id: 'd', options: [null] }] } }],
['invalid project name', { chat: { projects: async () => [{ id: 'bad', name: 42 }] } }],
['non-array tasks', { bus: { tasks: async () => ({ tasks: [] }) } }],
['invalid usage total', { bus: { usage: async () => ({ total: 42 }) } }],
]) {
test(`malformed ${name} is isolated from a healthy organization`, async () => {
const res = response();
await workspaceHTTP({ method: 'GET' }, res, { organizations: orgs(member('good'), member('bad', overrides)), session, owner: true });
assert.equal(res.status, 200);
assert.deepEqual(res.body.projects.map(p => p.org), ['good']);
assert.deepEqual(res.body.summary, { read: 1, of: 2 });
const bad = res.body.orgs.find(o => o.org === 'bad');
assert.equal(bad.state, 'invalid');
assert.equal(bad.error, 'invalid-response');
assert.ok(Number.isFinite(Date.parse(bad.at)));
});
}
test('options construction failure is isolated from a healthy organization', async () => {
const bad = { ...member('bad'), options() { throw new Error('private detail'); } };
const res = response();
await workspaceHTTP({ method: 'GET' }, res, { organizations: orgs(member('good'), bad), session, owner: true });
assert.equal(res.status, 200);
assert.deepEqual(res.body.projects.map(p => p.org), ['good']);
assert.deepEqual(res.body.summary, { read: 1, of: 2 });
assert.equal(res.body.orgs[1].state, 'invalid');
assert.equal(res.body.orgs[1].error, 'invalid-response');
assert.ok(Number.isFinite(Date.parse(res.body.orgs[1].at)));
});
for (const [name, statusDelay, pluginDelay] of [['in-flight child', 0, 100], ['late channel continuation', 220, 300]]) {
test(`deadline cancels ${name} and prevents any later child`, async t => {
const dir = await mkdtemp(join(tmpdir(), 'ws-'));
t.after(() => rm(dir, { recursive: true, force: true }));
const cli = join(dir, 'cli.mjs'), log = join(dir, 'log');
await writeFile(cli, `import {appendFileSync} from 'node:fs';let input='';process.stdin.on('data',x=>input+=x);process.stdin.on('end',()=>{const q=JSON.parse(input);appendFileSync(${JSON.stringify(log)},JSON.stringify({pid:process.pid,verb:q.verb})+'\\n');if(q.verb==='plugins')setTimeout(()=>process.stdout.write(JSON.stringify({plugins:{channel:['webui']}})),${pluginDelay});else if(q.verb==='project.list')setInterval(()=>{},1000);else process.stdout.write('[]');});`);
const timeoutMs = 500;
const organizations = organizationConsole({ ctx: {}, listOrgs: async () => ['good', 'slow'].map(id => ({ id, state: 'active' })),
showOrg: async () => ({ doc: {} }), resolveOrg: async (_, id) => ({ id, state: 'active', root: dir }), orgPaths: () => ({ socket: join(dir, 'unused.sock') }),
busStatus: async (_, id) => { if (id === 'slow') await delay(statusDelay); return { healthy: true }; },
options: ({ id, ...bound }) => id === 'good' ? goodOptions() : consoleBus(busCaller({ dataRoot: dir, socket: bound.paths.socket, business: id, cli, timeoutMs, ...bound })) });
const body = await readWorkspace({ organizations, session, timeoutMs, query: parseWorkspaceQuery('sections=tasks') });
assert.equal(body.orgs.find(o => o.org === 'slow').state, 'timeout');
assert.deepEqual(body.summary, { read: 1, of: 2 });
const rows = async () => (await readFile(log, 'utf8')).trim().split('\n').map(JSON.parse);
const atReturn = await rows();
if (!statusDelay) {
await delay(30);
for (const { pid } of atReturn) assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }, `transport ${pid} still alive`);
}
await delay(350);
const later = await rows();
assert.deepEqual(later, atReturn, 'no transport starts after timeout');
assert.ok(atReturn.some(r => r.verb === (statusDelay ? 'plugins' : 'project.list')));
for (const { pid } of later) assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }, `transport ${pid} still alive`);
});
}
// The real status probe must release its socket as soon as its org deadline expires.
test('status probe closes its in-flight socket on cancellation', async t => {
const dir = await mkdtemp(join(tmpdir(), 'sock-')), path = join(dir, 's');
const server = createServer(socket => { socket.on('data', () => {}); });
await new Promise(resolve => server.listen(path, resolve));
t.after(async () => { await new Promise(resolve => server.close(resolve)); await rm(dir, { recursive: true, force: true }); });
const controller = new AbortController();
assert.equal(typeof orgBus.probeBusSocket, 'function');
const connected = new Promise(resolve => server.once('connection', socket => socket.once('close', resolve)));
const result = orgBus.probeBusSocket(path, { signal: controller.signal, deadline: Date.now() + 100 });
await delay(30);
controller.abort();
assert.equal(await result, false);
await Promise.race([connected, delay(250).then(() => { throw new Error('status socket still open'); })]);
});
test('workspace deadline reaches the organization status socket', async t => {
const dir = await mkdtemp(join(tmpdir(), 'sock-')), path = join(dir, 's');
let closed;
const socketClosed = new Promise(resolve => { closed = resolve; });
const server = createServer(socket => { socket.on('data', () => {}); socket.once('close', closed); });
await new Promise(resolve => server.listen(path, resolve));
t.after(async () => { await new Promise(resolve => server.close(resolve)); await rm(dir, { recursive: true, force: true }); });
const organizations = organizationConsole({ ctx: {}, listOrgs: async () => ['good', 'slow'].map(id => ({ id, state: 'active' })),
showOrg: async () => ({ doc: {} }), resolveOrg: async (_, id) => ({ id, state: 'active', root: dir }), orgPaths: () => ({ socket: path }),
busStatus: async (_, id, bound) => id === 'good' ? { healthy: true } : { healthy: await orgBus.probeBusSocket(path, bound) },
options: () => goodOptions() });
const body = await readWorkspace({ organizations, session, timeoutMs: 150 });
assert.equal(body.orgs.find(o => o.org === 'slow').state, 'timeout');
assert.deepEqual(body.summary, { read: 1, of: 2 });
await Promise.race([socketClosed, delay(250).then(() => { throw new Error('status socket still open'); })]);
});
@@ -0,0 +1,264 @@
// U1 (#1873, PRD 1.20 REQ-WS-1..8): the cross-organization read behind GET /api/workspace.
// Merge, membership, partial failure, timeout, sort and filter, the copied decide command, the
// route through startServer, and the assistant-read stop. Every bus here is a fake options
// object or a real org registry in this test's own scratch; no test reaches a live bus.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as fs from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createOrg, showOrg, templateRender } from '../../organization/src/org-service.mjs';
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
import { organizationConsole } from '../src/organizations.mjs';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { parseWorkspaceQuery, readWorkspace, workspaceHTTP, WorkspaceQueryError, WORKSPACE_POLL_MS } from '../src/workspace-read.mjs';
import * as broker from '../../bus/src/broker.mjs';
const principal = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
const NOW = Date.parse('2026-11-09T13:00:00.000Z');
const now = () => NOW;
const never = () => new Promise(() => {});
// One org's bus as the org pages see it: options().bus and options().chat. `over` replaces reads.
function options(data, over = {}) {
const value = name => (over[name] ? over[name]() : Promise.resolve(data[name] ?? (['usage', 'limits'].includes(name) ? {} : [])));
return {
bus: { inbox: () => value('inbox'), agents: () => value('agents'), 'thread.claims': () => value('claims'), tasks: () => value('tasks'),
usage: () => value('usage'), limits: () => value('limits'), decisions: () => value('decisions') },
chat: { threads: () => value('threads'), projects: () => value('projects') },
};
}
const healthy = async () => ({ healthy: true, pid: 4242, boot: null });
function member(id, data, { status = healthy, over = {}, doc = {} } = {}) {
return { id, doc: { appearance: { theme: `${id}-theme` }, ...doc }, status, options: () => options(data, over) };
}
const organizationsOf = (...members) => ({ members: async () => members, list: async () => [], resolve: async () => null });
const ACME = {
projects: [
{ id: 'p-site', repo: 'site', name: 'Website', archived: false, at: '2026-11-09T10:00:00.000Z' },
{ id: 'p-old', repo: 'old', name: 'Old brochure', archived: true, at: '2026-10-01T00:00:00.000Z' },
],
tasks: [
{ task_ref: 'vk:1', project_key: 'site', priority: 2, at: '2026-11-09T11:00:00.000Z', fields: { title: 'Hero copy', done: false, assignees: [7, 99], bucket: 'Doing', labels: ['web'] } },
{ task_ref: 'vk:2', project_key: 'site', at: '2026-11-08T00:00:00.000Z', fields: { title: 'Logo', done: true } },
{ task_ref: 'vk:3', project_key: 'site', at: '2026-11-08T00:00:00.000Z', fields: { title: 'Removed', gone: true } },
],
agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'waiting', at: '2026-11-09T09:00:00.000Z', inputNeeded: [{ task_ref: 'vk:1', at: '2026-11-09T12:00:00.000Z' }], stranded: [] }],
claims: [{ thread: 't-1', instance: 'assistant', holder_run: 'run-2', harness: 'claude', at: '2026-11-09T08:00:00.000Z' }],
threads: [{ id: 't-1', project: 'p-site' }],
inbox: [{ id: 'd1234567', question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', task_ref: 'vk:1', at: '2026-11-09T12:00:00.000Z', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }] }],
usage: { total: { tokens: 1200 } },
limits: { tokens: [{ scope: 'org', used: 1200, cap: 5000 }] },
decisions: [{ at: '2026-11-09T00:00:00.000Z', closed: { op: 'resolved', at: '2026-11-09T00:10:00.000Z' } }],
};
const ACME_DOC = { tracker: { kind: 'vikunja' }, roles: { coder: { tracker: { botId: 7 } }, assistant: {} } };
const BETA = {
projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: '2026-11-07T00:00:00.000Z' }],
tasks: [{ task_ref: 'local:1', project_key: 'shop', at: '2026-11-07T00:00:00.000Z', fields: { title: 'Price list', done: false } }],
};
test('merge: every item from two buses carries its org, and the snapshot names both orgs', async () => {
const body = await readWorkspace({ organizations: organizationsOf(member('acme', ACME, { doc: ACME_DOC }), member('beta', BETA)), session: principal, now });
assert.equal(body.pollMs, WORKSPACE_POLL_MS);
assert.deepEqual(body.summary, { read: 2, of: 2 });
assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker, o.theme]), [['acme', 'ok', 'vikunja', 'acme-theme'], ['beta', 'ok', 'built-in', 'beta-theme']]);
assert.deepEqual(body.orgs[0].roles, ['assistant', 'coder']);
for (const key of ['projects', 'sessions', 'inbox', 'tasks']) for (const row of body[key]) assert.ok(['acme', 'beta'].includes(row.org), `${key} row without org`);
assert.deepEqual(body.metrics.orgs.map(m => m.org), ['acme', 'beta']);
// Archived projects are hidden by default; the default sort is newest activity first.
assert.deepEqual(body.projects.map(p => `${p.org}/${p.key}`), ['acme/site', 'beta/shop']);
const site = body.projects[0];
assert.deepEqual({ ...site }, { org: 'acme', id: 'p-site', key: 'site', name: 'Website', status: 'active', state: 'working', git: null,
sessions: { working: 1, waiting: 1, idle: 0 }, openTasks: 1, needsYou: 1, lastActivity: '2026-11-09T12:00:00.000Z' });
const role = body.sessions.find(s => s.kind === 'role'), thread = body.sessions.find(s => s.kind === 'thread');
assert.equal(role.org, 'acme'); assert.equal(role.role, 'coder'); assert.equal(role.state, 'waiting'); assert.equal(role.inputNeeded, 1);
assert.deepEqual(role.task, { ref: 'vk:1', title: 'Hero copy' }); assert.equal(role.project.key, 'site'); assert.equal(role.projectFrom, 'task');
assert.equal(thread.thread, 't-1'); assert.equal(thread.project.key, 'site'); assert.equal(thread.projectFrom, 'thread');
const ask = body.inbox[0];
assert.equal(ask.org, 'acme'); assert.equal(ask.raisedBy, 'coder'); assert.deepEqual(ask.session, { role: 'coder', run: 'run-1' }); assert.equal(ask.project.key, 'site');
// The gone task is dropped; only bot ids from the org file become role names.
assert.deepEqual(body.tasks.map(t => `${t.org}/${t.ref}`), ['acme/vk:1', 'acme/vk:2', 'beta/local:1']);
assert.deepEqual(body.tasks[0].assignees, ['coder']); assert.equal(body.tasks[0].bucket, 'Doing'); assert.equal(body.tasks[2].tracker, 'built-in');
assert.deepEqual(body.metrics.orgs[0], { org: 'acme', needsYou: 1, oldestNeedsYouAt: '2026-11-09T12:00:00.000Z', sessions: { working: 0, waiting: 1, idle: 0, live: 1 },
openTasks: 1, doneThisWeek: 1, projects: { active: 1, archived: 1 }, usage: { tokens: 1200 }, limits: [{ scope: 'org', used: 1200, cap: 5000 }], decisionWaitMedianMs: 600000 });
assert.deepEqual(body.metrics.totals, { needsYou: 1, oldestNeedsYouAt: '2026-11-09T12:00:00.000Z', sessions: { working: 0, waiting: 1, idle: 0, live: 1 }, openTasks: 2, doneThisWeek: 1, projects: { active: 2, archived: 1 } });
});
test('sections: only the asked sections are read and returned', async () => {
const asked = [];
const tracked = member('acme', ACME, { over: Object.fromEntries(['inbox', 'agents', 'claims', 'threads', 'tasks', 'projects', 'usage', 'limits', 'decisions'].map(n => [n, () => { asked.push(n); return Promise.resolve(ACME[n] ?? (['usage', 'limits'].includes(n) ? {} : [])); }])) });
const body = await readWorkspace({ organizations: organizationsOf(tracked), session: principal, query: parseWorkspaceQuery('sections=tasks'), now });
assert.deepEqual(Object.keys(body).filter(k => ['projects', 'sessions', 'inbox', 'tasks', 'metrics'].includes(k)), ['tasks']);
assert.deepEqual(asked.sort(), ['inbox', 'projects', 'tasks']);
});
test('membership: a foreign org is never read or named; a broken own org shows as invalid; options get the timeout', async t => {
const root = await fs.mkdtemp(join(tmpdir(), 'wsr-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test', principal };
await fs.mkdir(ctx.configDir, { mode: 0o700 }); await fs.mkdir(ctx.dataRoot, { mode: 0o700 });
for (const id of ['alpha', 'beta', 'private-tenant']) await createOrg(ctx, id, await templateRender(id));
await fs.writeFile(join(ctx.dataRoot, 'org-runtime', 'private-tenant', 'owner.json'), JSON.stringify({ ...principal, sub: 'different-owner' }));
const statusAsked = [], optionsAsked = [];
const orgs = organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPaths: () => ({}),
busStatus: async (_ctx, id) => { statusAsked.push(id); return id === 'beta' ? { healthy: false, pid: null, boot: { ok: false, at: '2026-11-09T12:59:00.000Z', reason: `config missing at ${ctx.dataRoot}/org-runtime/beta/x.json` } } : { healthy: true, pid: 7 }; },
options: o => { optionsAsked.push({ id: o.id, timeoutMs: o.timeoutMs }); return options(o.id === 'alpha' ? BETA : {}); } });
const body = await readWorkspace({ organizations: orgs, session: principal, timeoutMs: 1500, now, ctx });
assert.deepEqual(body.orgs.map(o => [o.org, o.state]), [['alpha', 'ok'], ['beta', 'down']]);
assert.equal(JSON.stringify(body).includes('private-tenant'), false);
assert.deepEqual(statusAsked.sort(), ['alpha', 'beta']);
assert.deepEqual(optionsAsked, [{ id: 'alpha', timeoutMs: 1500 }]);
// The boot refusal is shown with the data root made relative.
assert.equal(body.orgs[1].reason, 'the bus refused to start: config missing at <data root>/org-runtime/beta/x.json');
assert.equal(JSON.stringify(body).includes(root), false);
// A different principal sees none of these orgs.
const outsider = await readWorkspace({ organizations: orgs, session: { ...principal, sub: 'outsider' }, now, ctx });
assert.deepEqual(outsider.orgs, []);
// The owner of a broken org sees it as invalid, with no host path.
await fs.unlink(join(ctx.configDir, 'businesses', 'alpha.json'));
const broken = await readWorkspace({ organizations: orgs, session: principal, now, ctx });
assert.equal(broken.orgs[0].org, 'alpha'); assert.equal(broken.orgs[0].state, 'invalid');
assert.equal(broken.orgs[0].reason.includes(ctx.configDir), false);
assert.deepEqual(broken.summary, { read: 0, of: 2 });
});
test('partial failure: down, starting and a status that throws each get a state and a time; the others return', async () => {
const ctx = { dataRoot: '/srv/fake-data', configDir: '/srv/fake-config' };
const body = await readWorkspace({ now, ctx, session: principal, organizations: organizationsOf(
member('acme', ACME, { doc: ACME_DOC }),
member('down', BETA, { status: async () => ({ healthy: false, pid: null, boot: { ok: true, at: '2026-11-09T12:00:00.000Z' } }) }),
member('starting', BETA, { status: async () => ({ healthy: false, pid: 991, boot: null }) }),
member('stale', BETA, { status: async () => { throw new Error('host state names /srv/fake-data/org-runtime/stale/bus-host and /etc/secret-place'); } }),
member('reads-fail', BETA, { over: { tasks: () => Promise.reject(Object.assign(new Error('nope'), { code: 'bus-unavailable' })), projects: () => Promise.reject(new Error('raw text /etc/x')) } }),
) });
const byOrg = Object.fromEntries(body.orgs.map(o => [o.org, o]));
assert.equal(byOrg.acme.state, 'ok');
assert.equal(byOrg.down.state, 'down'); assert.equal(byOrg.down.reason, 'the bus is not running');
assert.equal(byOrg.starting.state, 'starting'); assert.equal(byOrg.starting.reason, 'the bus host is running but does not answer yet');
assert.equal(byOrg.stale.state, 'down');
assert.equal(byOrg.stale.reason, 'host state names <data root>/org-runtime/stale/bus-host and <host path>');
// A read that fails inside an answering bus is that read's error code, never its message.
assert.equal(byOrg['reads-fail'].state, 'ok');
assert.deepEqual(byOrg['reads-fail'].errors, { tasks: 'bus-unavailable', projects: 'read-failed' });
for (const o of body.orgs) { assert.equal(o.at, new Date(NOW).toISOString()); assert.equal(typeof o.readMs, 'number'); }
assert.deepEqual(body.summary, { read: 2, of: 5 });
assert.ok(body.projects.every(p => p.org === 'acme'));
});
test('timeout: a bus that never answers, or whose state is never read, is marked timeout within the bound', async () => {
const started = Date.now();
const body = await readWorkspace({ timeoutMs: 150, session: principal, organizations: organizationsOf(
member('acme', ACME, { doc: ACME_DOC }),
member('slow', BETA, { over: { tasks: never } }),
member('stuck', BETA, { status: never }),
) });
const elapsed = Date.now() - started;
assert.ok(elapsed < 1500, `took ${elapsed} ms`);
const byOrg = Object.fromEntries(body.orgs.map(o => [o.org, o]));
assert.equal(byOrg.acme.state, 'ok');
assert.equal(byOrg.slow.state, 'timeout'); assert.equal(byOrg.slow.reason, 'the bus did not answer within 150 ms');
assert.equal(byOrg.stuck.state, 'timeout'); assert.equal(byOrg.stuck.reason, 'the bus state was not read within 150 ms');
assert.equal(body.timeoutMs, 150);
assert.ok(body.tasks.every(t => t.org === 'acme'));
});
test('query: strict keys and values', () => {
const q = parseWorkspaceQuery('?sections=projects,tasks&org=acme,beta&status=active,archived&state=working&name=Web&sort=name&project=acme:site');
assert.deepEqual([...q.sections], ['projects', 'tasks']); assert.deepEqual([...q.orgs], ['acme', 'beta']);
assert.deepEqual([...q.status], ['active', 'archived']); assert.deepEqual([...q.state], ['working']);
assert.equal(q.name, 'web'); assert.equal(q.sort, 'name'); assert.equal(q.order, 'asc'); assert.deepEqual(q.project, { org: 'acme', key: 'site' });
assert.equal(parseWorkspaceQuery('').order, 'desc');
for (const bad of ['secton=projects', 'org=acme&org=beta', 'sections=', 'sections=chat', 'org=Bad Org', 'status=paused', 'state=busy', `name=${'x'.repeat(201)}`, 'name=%20', 'sort=size', 'order=up', 'project=acme', 'project=acme:a/b'])
assert.throws(() => parseWorkspaceQuery(bad), WorkspaceQueryError, bad);
});
test('sort and filter: projects by org, status, state, name and activity; tasks by org and project', async () => {
const organizations = organizationsOf(member('acme', ACME, { doc: ACME_DOC }), member('beta', BETA));
const read = search => readWorkspace({ organizations, session: principal, now, query: parseWorkspaceQuery(search) });
const keys = rows => rows.map(p => `${p.org}/${p.key}`);
assert.deepEqual(keys((await read('status=active,archived')).projects), ['acme/site', 'beta/shop', 'acme/old']);
assert.deepEqual(keys((await read('status=active,archived&sort=activity&order=asc')).projects), ['acme/old', 'beta/shop', 'acme/site']);
assert.deepEqual(keys((await read('status=active,archived&sort=name')).projects), ['acme/old', 'beta/shop', 'acme/site']);
assert.deepEqual(keys((await read('status=active,archived&sort=org&order=desc')).projects), ['beta/shop', 'acme/site', 'acme/old']);
assert.deepEqual(keys((await read('status=active,archived&sort=status')).projects), ['acme/site', 'beta/shop', 'acme/old']);
assert.deepEqual(keys((await read('status=archived')).projects), ['acme/old']);
assert.deepEqual(keys((await read('state=none')).projects), ['beta/shop']);
assert.deepEqual(keys((await read('name=SHO')).projects), ['beta/shop']);
const onlyBeta = await read('org=beta');
assert.deepEqual(onlyBeta.orgs.map(o => o.org), ['beta']); assert.deepEqual(keys(onlyBeta.projects), ['beta/shop']);
// An org filter naming an org the viewer is not in returns nothing, not an error.
assert.deepEqual((await read('org=nobody')).orgs, []);
assert.deepEqual((await read('project=acme:site')).tasks.map(t => t.ref), ['vk:1', 'vk:2']);
assert.deepEqual((await read('project=acme:p-site')).tasks.map(t => t.ref), ['vk:1', 'vk:2']);
assert.deepEqual((await read('project=beta:site')).tasks, []);
});
test('decide: a copied command names the org; an unsafe id or key gets none', async () => {
const data = { ...ACME, inbox: [...ACME.inbox, { id: 'd99 ; rm -rf ~', question: 'x', options: [{ key: 'ok', text: 'ok' }] }, { id: 'e7654321', question: 'y', options: [{ key: 'a b', text: 'a b' }] }] };
const body = await readWorkspace({ organizations: organizationsOf(member('acme', data, { doc: ACME_DOC })), session: principal, now, query: parseWorkspaceQuery('sections=inbox') });
const byId = Object.fromEntries(body.inbox.map(d => [d.id, d]));
assert.deepEqual(byId.d1234567.decide, [{ key: 'yes', command: 'mosaic decide d1234567 yes --org acme' }, { key: 'no', command: 'mosaic decide d1234567 no --org acme' }]);
assert.deepEqual(byId['d99 ; rm -rf ~'].decide, [{ key: 'ok', command: null }]);
assert.deepEqual(byId.e7654321.decide, [{ key: 'a b', command: null }]);
// Gated first, then oldest first.
assert.equal(body.inbox[0].id, 'd1234567');
});
function fakeRes() {
return { status: null, body: null, writeHead(status) { this.status = status; }, end(text) { this.body = JSON.parse(text); } };
}
test('handler: non-GET, no registry, non-owner, bad query and an unreadable registry', async () => {
const organizations = organizationsOf(member('acme', ACME));
const run = async (opts, method = 'GET') => { const res = fakeRes(); await workspaceHTTP({ method }, res, { session: principal, owner: true, organizations, ...opts }); return res; };
assert.equal((await run({}, 'POST')).status, 405);
assert.deepEqual((await run({ organizations: null })).body, { error: 'org-unavailable' });
// A granted user or the tester reaches the hook with owner false and no owner session.
for (const who of [{ owner: false }, { session: null }]) {
const res = await run(who);
assert.equal(res.status, 403); assert.deepEqual(res.body, { error: 'owner-only', message: 'the cross-organization view is the owner\'s' });
}
const bad = await run({ search: '?org=acme&org=acme' });
assert.equal(bad.status, 400); assert.equal(bad.body.error, 'invalid-request');
const logged = [];
const down = await run({ organizations: { members: async () => { throw new Error('/srv/x/org-runtime.json: Unexpected token'); } }, log: line => logged.push(line) });
assert.equal(down.status, 503); assert.deepEqual(down.body, { error: 'org-unavailable', message: 'Organization registry could not be read' });
assert.deepEqual(logged, ['workspace: organization registry could not be read']);
});
async function serve(t, organizations, sso = createDevOwner()) {
const s = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, organizations, log: () => {}, workspaceTimeoutMs: 200 });
t.after(() => new Promise(r => { s.close(r); s.closeIdleConnections(); }));
return `http://127.0.0.1:${s.address().port}`;
}
test('route: the dev owner reads GET /api/workspace; a granted user gets owner-only; the timeout option applies', async t => {
const seen = [];
const organizations = { list: async () => [], resolve: async () => null, members: async (session, opts) => { seen.push({ sub: session.sub, timeoutMs: opts.timeoutMs }); return [member('acme', ACME, { doc: ACME_DOC }), member('slow', BETA, { status: never })]; } };
const base = await serve(t, organizations);
const response = await fetch(`${base}/api/workspace?sections=projects,metrics`), body = await response.json();
assert.equal(response.status, 200); assert.equal(response.headers.get('cache-control'), 'no-store');
assert.deepEqual(body.orgs.map(o => [o.org, o.state]), [['acme', 'ok'], ['slow', 'timeout']]);
assert.equal(body.timeoutMs, 200); assert.deepEqual(seen, [{ sub: 'local-dev-owner', timeoutMs: 200 }]);
assert.equal((await fetch(`${base}/api/workspace?bogus=1`)).status, 400);
// POST has no route here: with no Origin the dev fence refuses it, and with the page's Origin the
// organizations fence answers as for any unknown /api path.
assert.equal((await fetch(`${base}/api/workspace`, { method: 'POST' })).status, 403);
const post = await fetch(`${base}/api/workspace`, { method: 'POST', headers: { origin: base, 'content-type': 'application/json' }, body: '{}' });
assert.equal(post.status, 404); assert.deepEqual(await post.json(), { error: 'org-unavailable' });
// A granted user's session (session.user set) is fenced before any org is read.
const granted = { ...createDevOwner(), session: () => ({ ...principal, sub: 'alice-sub', user: 'alice', name: 'Alice' }) };
const userBase = await serve(t, organizations, granted);
const refused = await fetch(`${userBase}/api/workspace`);
assert.equal(refused.status, 403); assert.equal((await refused.json()).error, 'owner-only');
assert.equal(seen.length, 1);
});
test('assistant read is designed, not built: no workspace broker verb, assistant authority unchanged', () => {
const role = JSON.parse(readFileSync(new URL('../../../roles/assistant.json', import.meta.url), 'utf8'));
assert.deepEqual(role.authority, { withinRole: ['message.send', 'record.write'], crossRole: [] });
const verbs = new Set(Object.values(broker).filter(Array.isArray).flat().filter(v => typeof v === 'string'));
assert.ok(verbs.has('inbox'), 'the verb lists were read');
assert.deepEqual([...verbs].filter(v => /^(workspace|orgs?)\b/.test(v)), []);
});