Compare commits

..
30 changed files with 834 additions and 5974 deletions
+6 -11
View File
@@ -25,15 +25,10 @@ green at every step. Not production software — a proven foundation.
1. **Root is bootstrap-only.** First-class system configuration lives at the
repository root; everything else gets a dedicated directory (`roles/`,
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
2. **Configuration**: each stack instance has exactly one system config. The
default instance's is `~/.config/mosaic-dev/config.json`. Instance NAME's
is `~/.config/mosaic-NAME/config.json`, and its data root carries a marker
that names it. `~` is the home of the OS user that runs the instance. A
process reads only the config `MOSAIC_CONFIG` names, or the default's when
that is unset. Configs are created only by
`scripts/bootstrap.sh [--instance NAME]`, never overwritten, fail-closed on
any problem. Repo-scoped role authority lives in `roles/*.json`
(versioned, reviewed commits only).
2. **Configuration**: `~/.config/mosaic-dev/config.json` is the sole system
config — created only by `scripts/bootstrap.sh`, never overwritten,
fail-closed on any problem. Repo-scoped role authority lives in
`roles/*.json` (versioned, reviewed commits only).
3. **Secrets** never enter the repository or container images; auth is
runtime-only (read-only mount or environment variable).
4. **Contracts** (`contracts/`) are immutable and image-baked. Missions and
@@ -182,8 +177,8 @@ Full reference — usage, fields, exit codes, safety notes:
## Data map (canon)
- `~/.config/mosaic-dev/config.json`, or `~/.config/mosaic-NAME/config.json`
per named instance: system config (user-authored; never auto-written).
- `~/.config/mosaic-dev/config.json` — system config (user-authored; never
auto-written).
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
- `sessions/` — pi JSONL session trees, one directory per named session
+538
View File
@@ -0,0 +1,538 @@
# Row 210 (#424, Gitea #1707): agent host access with sudo through the credential store
Gizmoduck, 2026-10-20, r3. Design only, no code. Reviewer Duckworth, gate owner Sage. Due 2026-10-27 (Q184).
- Brief: `docs/plans/2026-10-20_host-credentials.md` at 9db578ff5.
- Base read: `origin/refactor` 9ba31b5b3. Row 199 read as DESIGN.md r6 (4d3007ec9) and its code and
BUILD.md at addee664a on `megavolt/row199`.
- PRD: REQ-CRED-3 in Honker's 1.7 draft (`agents/honker/work/queue-194/PRD-1.7-DRAFT.md:111-115`, 11cb0423b).
Jason approved PRD 1.7 (d52febb5 yes at 20:36:47Z, mos-1089), so REQ-CRED-3 is in.
- Scope (sage-1468): this row ships the `host.run` broker op, the grant check, the executor and the receipts,
all testable over `broker.sock`. The `host_run` harness tool is out, as 199 left its publish tool out
(199 BUILD.md deviation 12). Sage files one follow-up row for the agent-facing harness tools, publish and
`host_run`, in `packages/harness`. Duckworth's r2 N5 (the tool help says a gated call leaves stdin under
4 KiB) belongs to that row.
- Branch `gizmoduck/row210`. Row claimed in-progress (rev 2361) after Sage cleared the after (rev 2359). It
lands only after 199, rebased onto 199's landing (Q177).
Rounds:
- r3 (duckworth-0043, REVIEW-r2.md at 52b5fee63, approved with C1, and sage-1468, sage-1472): C1 Q-J0's revoke
line now matches section 6, the connection is cut and a started command may keep running. Q-S3 answered,
the harness tool is out (scope line above, D10). N1 the runner list takes Duckworth's whole list, and D6
says the per-call decision is the control. N2 numeric shorthand, unspecified and mapped addresses and
`*.localhost` refuse (D3, T1). N3 the pids file records each pid's start time (D7, T27). N4 the mutant
table is in order.
- r2 (sage-1450): no `"any"` grant. The command boundary is an argv allowlist per host grant, and load refuses
a wildcard (D6, T26, M6, M28). PRD 1.7 is frozen, so `host.sudo` stays a hard wait and Jason decides each
call. Section 5 now holds the proposed 1.8 routing requirement. D5 is confirmed against 199's deviation 11.
The seat network gap is a known limit with what closes it (section 6). Cites now point at 199's code.
- r2 (duckworth-0042, REVIEW-r1.md at 8140b7f39): B1 events name host, user and argv in clear (D5 step 6,
T22, M30). B2 a gated call's question shows stdin in full (D4, T9, M31). B3 the key and processes die with
the bus, and bus start sweeps leftovers (D7, T27, M32 to M34). B4 Q-J0 for Jason in section 10, worded to
stand alone (mos-1088). Notes taken: N1 (E12, runGit's kill shape), N2 (loopback refused, own-host limit),
N3 (process groups), N4 (stdin can make a prefix root), N5 (`-l` and a separate address), N6 (D8 framing),
N7 (section 7).
## 1. What the code does today
| # | Fact | Cite |
|---|---|---|
| E1 | The bus reads credentials for two kinds only, `gitea` and `vikunja` | `bus/src/credentials.mjs:22`, `business/src/vocabulary.mjs:103` |
| E2 | A credential value must match `^[-A-Za-z0-9._~]+\n?$`, at least 16 and at most 16384 bytes. An OpenSSH private key fails it at the first `-----BEGIN` space | `bus/src/credentials.mjs:84-87` |
| E3 | `assertClean` searches `JSON.stringify(result)` for each token. JSON escapes a newline, so a multi-line secret is never found: `JSON.stringify("a\nb").includes("a\nb")` is `false` (run on node here) | `bus/src/credentials.mjs:95-103` |
| E4 | `use()` turns any exception in the operation into `service-failed`, then runs `assertClean` | `bus/src/credentials.mjs:104-117` |
| E5 | Host to bus-child IPC is serial, one request at a time, with a 10 s timeout. A slow reply breaks the channel | `cli/src/host.mjs:31`, `:270` |
| E6 | The web verbs run in the bus child over `broker.sock`. `requestWeb` checks keys, runs `#secretCheck`, checks the grant `roles[s.role].web`, records `action.allowed` (requested), calls `onAdmit()` so the server raises the socket timeout, runs the service and records completion | `bus/src/broker.mjs:1492-1530`, `bus/src/server.mjs:87-95` |
| E7 | `roles[role].web` comes from the resolved tool limit. A role entry in the bus holds `authority`, `web` and `credentials` | `bus/src/business.mjs:17-20` |
| E8 | A business-file role entry takes `definition`, `holder`, `vars`, `tracker`, `credentials`. Anything else refuses at load | `business/src/business.mjs:89` |
| E9 | The chief's `organization.set` takes only `ORGANIZATION_KEYS`. The human-only `instances.set` takes `INSTANCE_KEYS` and refuses any definition that declares a credential (`organization-credentials-out-of-scope`). Holder, credentials and tracker stay hand edits | `bus/src/broker.mjs:145-147`, `:1248-1298`, `business/src/instance-settings.mjs:1-5`, `:22-31` |
| E10 | An action outside ROUTED is a hard wait and classifies `gated` for every role. No role file may grant one | `bus/src/broker.mjs:44-70`, `business/src/vocabulary.mjs:58-88`, PRD REQ-WAIT-2 and 3 |
| E11 | The seat sandbox does not confine the network, abstract Unix sockets or the host UID | `seat/src/sandbox.mjs:29-31` |
| E12 | On refactor the bus child spawns nothing. With 199 it spawns git: `publishRemote` (`bus/src/runtime.mjs:245` at addee664a) runs `runGit`, a detached group leader that gets SIGTERM at the limit, then SIGKILL to the group after a grace period | `seat/src/checkout.mjs:95-138` at addee664a |
| E13 | Role networks are `none`, `api-only`, `open`. The bundle carries the value, and the sandbox's own comment says the network is not confined (E11) | `business/src/vocabulary.mjs:101`, `harness/src/bundle.mjs:252` |
Row 199 gives, in code at addee664a: `useFor` (`bus/src/credentials.mjs:228`), `issue` (`:156`), `revoke`
(`:197`), `remove` (`:211`), the kind list (`:49`), the token pattern (`:19`, `:143`), the store dir's file
regex `^(gitea|vikunja)(\.tmp)?$` (`:222`) and `withGitea` (`bus/src/runtime.mjs:233`). From its design: the store at `<configDir>/secrets/agents/<business>/<agent>/<kind>` (D1), the `{store: true}`
reference (D2), the read path (D3, D3a), `useFor(session, kind, op)` with the call-site allowlist test (D5),
the `publish` grant shape, a list on the role entry plus a hard-wait action whose decision binds the commit
(D9), and the human-only lifecycle verbs with `changes.jsonl` receipts (section 6).
## 2. Probe (throwaway keys, scratch sshd on 127.0.0.1:22410, OpenSSH 10.5p1)
Run in `/mnt/storage/scratch/gizmo-210-probe` with keys made for the probe and deleted after. No real key or
host was touched.
| Probe | Result |
|---|---|
| `ssh-add -` with the private key on stdin, to an agent on a private socket | works, "Identity added: (stdin)" |
| `IdentityAgent=<sock>` with `IdentitiesOnly=yes` and `IdentityFile=none` | refused, `Permission denied (publickey)`. With `IdentitiesOnly`, ssh offers only agent keys that match a configured identity |
| same with `IdentityFile=<the public key file>` | works, rc 0 |
| `-i /dev/fd/3` with the key on fd 3 | fails, "Identity file /dev/fd/3 not accessible". ssh closes inherited fds, so the fd route is out |
| a known_hosts file pinning the wrong key under `HostKeyAlias` with `StrictHostKeyChecking=yes` | refused, "Host key verification failed", rc 255 |
| `printf '%s|'` with `'a b'` and `'it'\''s'` single-quoted, plus an unquoted `$((1+1))` as a control | prints `a b|it's|2|`. The quoted elements arrive whole, and only the unquoted control expanded |
| remote `exit 7` | ssh exits 7 |
| killing the agent | its socket file is gone |
## 3. Decisions
### D1. The kind and its naming
One key per (agent, host). The kind name is `host.<hostId>`, so the store file is
`<configDir>/secrets/agents/<business>/<agent>/host.<hostId>` (199 D1, unchanged). `hostId` matches
`^[a-z0-9][a-z0-9-]{0,31}$`.
The closed kind set becomes `gitea`, `vikunja` and the family `host.<hostId>`. One exported helper,
`credentialKind(name)` in `vocabulary.mjs`, answers whether a name is a kind, and each place that checks
kinds calls it: `SERVICES` users (`role.mjs:105-118`), 199's `bus/src/credentials.mjs:49` and the store
file regex at `:222`, the broker's service
check, the CLI `issue` date flag and 199's remove regex. A build test greps for literal `'gitea', 'vikunja'`
lists outside `vocabulary.mjs` and fails on a new one.
Why per host and not per agent. A key that leaks, or a host that is retired, is revoked for that host alone.
ops-01 installs one public key per host, and an `authorized_keys` line names one agent. The cost is more keys
for Jason to issue, one per grant.
A role definition declares the kind once as `{"service": "host"}` with no scopes. The business file binds the
concrete hosts (D3). A definition can't name a host, because hosts belong to one stack instance.
The date is `--rotate-by DATE`, as for gitea. An ssh key doesn't expire on its own, so `status()` reports
`rotation-due` or `valid`, the gitea branch.
### D2. The value check
`checkValue(kind, text)` replaces the one regex at `credentials.mjs:84`. For `gitea` and `vikunja` it is
today's check, unchanged. For `host.*` the value must be:
- PEM framed by `-----BEGIN OPENSSH PRIVATE KEY-----` and `-----END OPENSSH PRIVATE KEY-----`, LF line ends,
at most 16384 bytes (the existing cap).
- An `openssh-key-v1` blob with cipher `none`, kdf `none`, exactly one key, type `ssh-ed25519`. Parsed in
node with `Buffer`, no ssh-keygen call, so the check runs at bus start without a process.
- The two check integers equal, and the public key in the private section equals the one in the header.
Anything else refuses `credential-format`. An encrypted key refuses, because the bus has no way to ask for a
passphrase and `BatchMode=yes` would fail at use time instead of at load. The parsed public key is kept with
the entry so `status` and D7 can print its fingerprint without touching the private part.
### D3. The grant
Four layers. Each must hold, and the code checks all four on every call.
1. **Role definition, reviewed commit.** `roles/<def>.json` lists `{"service": "host"}` in `credentials`. That
is the "reviewed role change" in the brief. Once a PRD version routes `host.sudo` (section 5), the same
reviewed commit puts `host.sudo` in that definition's `authority.withinRole`.
2. **Host registry, business file, hand edit.** A new top-level key:
```
"hosts": { "<hostId>": { "address": "10.1.1.53", "port": 22, "user": "localadmin",
"hostKeys": ["ssh-ed25519 AAAA..."] } }
```
`address` is an IPv4 or IPv6 literal or a DNS name matching `^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$`.
An address of this machine refuses at load (r2 N2, r3 N2). For an IP literal, which `net.isIP` decides,
that is `127.0.0.0/8`, `0.0.0.0/8`, `::1`, `::`, and an IPv4-mapped `::ffff:` form of either IPv4 range.
For a name, it is `localhost` and any name ending in `.localhost`. A name made only of digits and dots
refuses unless `net.isIP` reads it as IPv4, so the shorthand `127.1` and the integer `2130706433`, which
pass the DNS pattern and which the resolver reads as 127.0.0.1, refuse. A label starting `0x` refuses for
the same reason. `port` is 1 to 65535. `user` matches `^[a-z_][a-z0-9_-]{0,31}$`. `hostKeys` is a non-empty list of
`ssh-ed25519`, `ecdsa-sha2-nistp256` or `ssh-rsa` public key lines, type and base64 only, no comment.
3. **Agent grant, business file, hand edit.** A new role-entry key beside 199's `publish`:
```
"hosts": { "<hostId>": { "commands": [["systemctl", "status"], ["journalctl", "-u"]] } }
```
Each `hostId` must be in the registry, and the role's definition must declare `host`. A role entry with
`hosts` whose definition lacks `host` refuses at load and names `roles.<agent>.hosts`. `commands` is the
command boundary (D6).
4. **The key.** `credentials["host.<hostId>"] = {"store": true, "rotateBy": DATE}` with the file present
(199 D2, D3a). A grant without an issued key refuses `credential-unavailable` at call time, as 199 does.
Who writes each layer is in section 4. Neither the chief nor any agent can reach layers 2 to 4: `hosts` is
outside `ORGANIZATION_KEYS` and `INSTANCE_KEYS` (E9), `instances.set` already refuses a definition that
declares credentials (E9), and 199's `issue` is human-only.
### D4. The action and the decision binding
`host.sudo` is a new action in ACTIONS and in neither ROUTED list. Until a PRD version routes it, it is a hard
wait and classifies `gated` for every role (E10). Each call then needs a decision the human resolved. PRD 1.7
is frozen (sage-1450, decision d52febb5 cites its sha256), so under 1.7 Jason decides every `host.run`.
The decision binds one call, the way 199 D9 binds `pushCommit`. When the action is `host.sudo`, the
`decision.raise` event body gets `hostRun = {host, argvSha256, stdinSha256}`, from the raise's own args. The
question shown to the human ends with the host id, the user, the argv with each element JSON-quoted, and the
stdin in full as one JSON string, so Jason sees all of what runs (B2). The broker appends that text itself, and
the agent can't shape it. The whole question keeps the 4096-character cap (`broker.mjs:126`, `:1654`), so on a
gated call stdin is as large as the cap allows and no larger. A raise that doesn't fit refuses
`invalid-request`. `argv ["bash"]` can't be granted at all (D6), and `["tee", "/etc/x"]` with a stdin Jason
can't see is ruled out by showing it. `#authorityFor` refuses `decision-mismatch` when the call's host, argv hash or stdin hash differs, as
`holderRun` does at `broker.mjs:796`. A decision is spent once (`decision-consumed`).
After routing, a definition that lists `host.sudo` within-role runs without a decision, and `decision` is
optional for that class only. The grant layers in D3 still apply.
### D5. The executor: a `broker.sock` verb in the bus child
```
host.run {"host", "argv": [..], "stdin"?, "timeout"?, "decision"?}
-> {"exit", "signal", "stdout", "stderr", "truncated": {"stdout", "stderr"}, "ms"} or a refusal
```
It runs in the bus child, shaped like `requestWeb` (E6), not as a host IPC op. A sudo command can take
minutes and the host channel times out at 10 s and serves one request at a time (E5). The key is in the bus
child's memory already (199 D3), so no ticket has to cross to the CLI host. Row 199's publish needs a ticket
because git and the working repo live in the CLI host. ssh needs neither. 199's BUILD.md deviation 11
(addee664a) keeps credential use in the bus child too: the push runs there under `withGitea`, and the host
socket only checks and polls. Sage confirmed D5 matches that shape (sage-1450). `host.run` needs no host
socket and no poll, because the agent calls `broker.sock` directly. The helper is `withHost(session, host,
fn)`, beside `withGitea`, calling `useFor(session, "host." + host, fn)`.
Order of checks. Each refusal goes through `broker.refuse()` and logs `action.refused`.
1. `keys(args)`, then `#secretCheck(args)`, then identity from the cap.
2. `host` is in the registry (`host-unknown`).
3. `host` is in `roles[s.role].hosts` (`host-not-granted`). This is the REQ-CRED-3 check "a grant exists for
that agent and that host".
4. `argv` shape (D6) and `commands` match (`command-not-granted`).
5. `#checkAuthority(s, "host.sudo", {decision, target: host, context})` (D4).
6. Record `action.allowed` phase `requested` with host, address, user and argv in clear (B1), and stdin as
size and sha256. Every arg already passed `#secretCheck` in step 1, as the web verbs log their url
(`broker.mjs:1521`). After routing, the event log is the record of what ran as root. Then `onAdmit()`, which
sets the socket timeout to the call's timeout plus 10 s, as the web branch does (`server.mjs:95`).
7. `credentials.useFor(s, "host." + host, run)`, where `run` is D7.
8. D8's leak check on the output, then `#secretCheck` on the result.
9. Record phase `completed` with host, argv, exit, signal, ms, and size and sha256 of stdout and stderr. The
output itself never goes into an event. A failure records `host-failed` with the same fields.
Only one `host.run` per (agent, host) runs at a time. A second refuses `host-busy`. That keeps a stuck call
from piling up ssh processes and makes revoke (D9) simple.
Spawns reuse `runGit`'s shape (E12): an argv array, never a shell, `detached: true` so each process leads its
own group, SIGTERM to the group at the limit and SIGKILL after a grace period. If the helper can be shared, it
is, rather than a second copy. `env` is `{PATH: "/usr/bin:/bin"}` only, plus `SSH_AUTH_SOCK` for `ssh-add`.
Each spawn is wrapped in `setpriv --pdeathsig KILL --`, the prefix the seat sandbox already uses
(`seat/src/sandbox.mjs:44`), so it dies when the bus child dies (B3).
### D6. The command boundary
`argv` is an array of 1 to 256 strings. Each is 1 to 4096 bytes of UTF-8 with no NUL, and the total is at most
32768 bytes. `argv[0]` must not contain `/` unless it starts with `/`. `stdin` is optional, a string of at most
65536 bytes, and goes through `#secretCheck` with the args.
The remote command is built by the bus, never taken as a string:
```
sudo -n -- 'argv0' 'argv1' ...
```
Each element is POSIX single-quoted: wrap in `'`, and replace each `'` with `'\''`. The probe shows the
remote shell gets each element back exactly (section 2). `sudo -n` fails instead of prompting, so a host
without NOPASSWD returns sudo's error, not a hang. The host's login shell for `user` must be POSIX. That is a
host precondition for ops-01, listed in section 7.
`commands` in the grant is an argv allowlist and nothing else. It is a non-empty list of argv prefixes. An
argv passes when its first N elements equal some prefix of length N, compared element by element as literal
strings, with no globbing. `[["systemctl", "status"]]` admits `systemctl status nginx` and refuses
`systemctl restart nginx`.
There is no wildcard grant (sage-1450). Business-file load refuses, naming `roles.<agent>.hosts.<id>.commands`:
- any value that isn't a list, including the strings `"any"` and `"*"`,
- an empty list, an empty prefix, a non-string element, or a duplicate prefix,
- an element containing `*`, `?` or `[`, so nobody writes a glob that the matcher would read literally,
- a prefix whose first element, by basename, is a shell, a command runner or an interpreter. Shells: `sh`,
`bash`, `dash`, `zsh`, `ksh`, `fish`, `busybox`. Runners: `su`, `sudo`, `doas`, `pkexec`, `runuser`, `env`,
`xargs`, `nohup`, `setsid`, `timeout`, `nice`, `systemd-run`, `chroot`, `nsenter`, `unshare`, `setpriv`,
`capsh`, `flock`, `stdbuf`, `ionice`, `chrt`, `taskset`, `strace`, `script`, `watch`. Interpreters:
`python3`, `perl`, `ruby`, `node`, `awk` (Sage's ruling on duckworth-0043 N1). Each would turn the prefix
back into "any". T26 has one case per name.
That list can't be complete, and it isn't the control. Under 1.7 the control is Jason's decision on each
call, bound to host, argv and stdin (D4, Q-S1). The list only refuses the obvious cases at load, early. Many commands can still start a shell or an editor as root (`systemctl edit`,
`find -exec`, interpreters). The allowlist limits mistakes and narrows what a grant means, but it is not a
sandbox. Stdin goes to the command, so some prefixes are root on their own: `[["tee"]]` or `[["dd"]]` writes
any file as root from stdin (N4). Section 6 says what remains.
Output: stdout and stderr each capped at 1 MiB. Past the cap the bus keeps reading and drops the rest so the
remote side doesn't block, and sets `truncated`. `timeout` defaults to 60 s, maximum 900 s. At the timeout
the bus sends SIGTERM to the ssh process group, then SIGKILL after 5 s, and answers `signal`. ssh's own exit
code 255 and a remote command exiting 255 can't be told apart. The result returns stderr so the agent can see
which, and the doc says so.
### D7. Handing the key to ssh
Per call, inside `useFor`'s operation:
1. `mkdtemp` a dir under `<configDir>/secrets/run/` with mode 0700. `<configDir>` is never bound into a sandbox
(199 D3). The socket path is checked against the 107-byte Unix socket limit before use, refusing
`host-failed` if over.
2. Write two files in it, mode 0600: `known_hosts` with one line per pinned key, `mosaic-host-<hostId> <type>
<base64>`, and `id.pub` with the entry's public key (D2).
3. Write `<dir>/pids` with the bus child's pid. Each line in it is a pid and that process's start time,
field 22 of `/proc/<pid>/stat`, so a reused pid never matches (duckworth-0043 N3). Start `ssh-agent -D -a <dir>/a.sock` as its own group leader,
append its pid, and wait for the socket. Run `ssh-add -t <timeout + 30> -` with `SSH_AUTH_SOCK` set and the
private key written to its stdin. The key never touches disk, argv or env. `-t` makes ssh-agent drop the key
at the call's timeout plus 30 s even if nothing else runs (B3). ssh is the second group leader, and its pid
is appended too. Timeout, revoke and the `finally` kill both groups, ssh-agent's and ssh's (N3).
4. Run ssh with exactly these options and nothing from any config file:
```
ssh -F /dev/null -T -p <port>
-o BatchMode=yes -o IdentitiesOnly=yes -o IdentityFile=<dir>/id.pub -o IdentityAgent=<dir>/a.sock
-o StrictHostKeyChecking=yes -o UserKnownHostsFile=<dir>/known_hosts -o GlobalKnownHostsFile=/dev/null
-o HostKeyAlias=mosaic-host-<hostId> -o UpdateHostKeys=no -o CheckHostIP=no -o VerifyHostKeyDNS=no
-o ForwardAgent=no -o ForwardX11=no -o ClearAllForwardings=yes -o PermitLocalCommand=no
-o ProxyCommand=none -o ProxyJump=none -o ControlMaster=no -o ControlPath=none
-o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3
-l <user> -- <address> <remote command from D6>
```
The user goes in `-l` and the address is its own argument, so nothing depends on how ssh splits `@` or on
an IPv6 literal (N5). `-F /dev/null` stops `~/.ssh/config` from adding a ProxyCommand, a different key or forwarding. The pin is
`StrictHostKeyChecking=yes` against a file the bus wrote from the registry, so there is no trust on first
use: an unknown or changed host key refuses with rc 255 (probe, section 2) and the bus answers
`host-key-mismatch` when stderr has "Host key verification failed".
5. In a `finally`: kill both groups, remove the dir. A test asserts the dir is gone after success, refusal,
timeout and an exception thrown inside the operation.
6. If the bus child is killed, the `finally` never runs. `--pdeathsig` kills ssh-agent and ssh with it, and
`-t` drops the key anyway. At bus start, before `broker.sock` listens, the bus sweeps
`<configDir>/secrets/run/`. A recorded pid counts as alive only when it exists and its start time matches.
For each dir whose bus pid is no longer alive, it sends SIGKILL to each recorded pid that is alive and
whose `/proc/<pid>/cmdline` names that dir, then removes the dir. A dir whose bus pid is alive is left
alone. Any other entry there refuses start, as 199 refuses a stray file
in the store (`credentials.mjs:222`). Revoke after a restart has nothing in flight to reach.
The 0600 temp key file route the brief allows is not used. The agent route keeps the private key off disk,
and the fd route doesn't work (probe).
### D8. The leak check
This is defense in depth (N6). The remote never holds the private key and forwarding is off, so output can
carry the key only if the agent already had it. `assertClean` misses a multi-line key (E3). A `host.*` entry
in the shared token map makes its loop compare against a value that never matches, which is harmless. For a `host.*` entry, `useFor` checks the result with
`assertCleanKey(result, entry)`, which searches the JSON text for:
- each base64 body line of the PEM of 24 characters or more,
- the whole body with line breaks removed,
- the 32-byte ed25519 private seed as lowercase hex and as base64 at each of the three byte offsets.
A match refuses `credential-leak`. The output is dropped, and the event records only sizes and hashes. The
existing `assertClean` still runs for the token kinds. The same function runs on `stdin` before the call, so
an agent can't round-trip a key it somehow has back through the host.
### D9. Lifecycle
199's verbs carry over with `KIND = host.<hostId>`. Two additions:
- `issue AGENT host.<id>` checks D2, then prints the public key line and a suggested `authorized_keys` line
for ops-01: `from="<stack host address>",restrict <type> <base64> mosaic-<business>-<agent>-<id>`. It
prints only public material.
- `revoke` also kills a `host.run` in flight for that agent and host (process group SIGKILL) before it drops
the key. The call answers `credential-unavailable`. Remote processes that the killed ssh started may keep
running (section 6).
`status` shows each host entry with its fingerprint (SHA256 of the public key), the rotate date and state.
The tester gets `status` and `revoke` only, as in 199.
### D10. Where the files change
| File | Change |
|---|---|
| `business/src/vocabulary.mjs` | `host.sudo` in ACTIONS, `credentialKind`, host id pattern |
| `business/src/business.mjs` | top-level `hosts` registry, role-entry `hosts`, load checks from D3 |
| `business/src/role.mjs` | `{"service": "host"}` in `checkCredentials` with no scopes |
| `business/src/credentials.mjs` | `host.*` refs take `rotateBy` |
| `bus/src/credentials.mjs` | kind check via `credentialKind`, `checkValue` (D2), `assertCleanKey` (D8), busy and kill map |
| `bus/src/host-run.mjs` (new) | D6 argv and quoting, D7 ssh-agent and ssh spawn, cleanup |
| `bus/src/broker.mjs` | `host.run` verb (D5), `host.sudo` in `#authorityFor` with `hostRun` (D4) |
| `bus/src/server.mjs` | `host.run` branch beside the web branch with `onAdmit` |
| `bus/src/business.mjs` | carry `hosts` onto the bus role entry |
| `cli` | 199's `credentials issue` accepts `host.<id>`, prints D9's lines |
No `packages/harness` file changes. The `host_run` tool is Sage's follow-up row (sage-1468).
Row 199's own kind and regex are not changed inside row 199. This row rebases onto 199's landing and changes
them here.
## 4. Who grants (brief item, "yours to propose with reasons")
Proposal: **Jason, at every layer that widens access. No agent, chief included, and not the tester.**
| Layer | Who | How | Why |
|---|---|---|---|
| Definition declares `host` (and later lists `host.sudo`) | Jason | reviewed commit to `roles/*.json`, as for any role authority (AGENTS.md invariant 7) | It decides which kinds of agent may ever hold a host key. Review puts a second seat on it |
| Host registry and pins | Jason, with ops-01's key fingerprints | hand edit of the business file | The pin is the host identity. A wrong pin is a man-in-the-middle on root. It must come from the host side, not from a first connection |
| Agent grant `roles.<agent>.hosts` | Jason | hand edit of the business file | Q183: "not all agents will need to have that access". The per-agent, per-host choice is his |
| Key `issue` | Jason | 199's human-only `issue` | The key passes through the caller. The tester is an agent (199 section 6, duckworth N1) |
| `authorized_keys` on the host | ops-01 | host side, from the line `issue` prints | ops-01 owns the hosts (brief) |
| Revoke | Jason, Mos, or the tester | `revoke` | Taking access away should be quick and needs no new trust |
Why not a reviewed commit for the agent grant too. The business file lives in `<configDir>`, outside the
repository (AGENTS.md invariant 2), and per-instance grants already stay hand edits there (E9, 199 D9 step 2).
Moving grants into the repo would put host addresses and the per-instance roster under review, which is a
bigger change than this row. The definition layer is the reviewed one.
Why not the chief. A chief with this power could hire an agent and grant it root. 199 already keeps hires
uncredentialed (199 D13). `instances.set` refuses credentialed definitions today (E9), and this row keeps it
that way.
REQ-CRED-3 says the who-grants answer is set by this design and approved by Jason. The table above is that
answer.
## 5. The routing gap and a proposed 1.8 requirement
PRD 1.7 routes `role.hire`, `role.fire` and `git.push.remote`, not `host.sudo`, and 1.7 is frozen (sage-1450).
This row builds for 1.7 as it stands. `host.sudo` is a hard wait under REQ-WAIT-3, and Jason decides each
`host.run`, bound to host, argv and stdin (D4). That is safe, and slower than Q183's "allow agents to perform
their jobs". The routing goes in 1.8. Sage takes the line below to Mos for Jason. I don't edit the PRD.
Proposed for PRD 1.8, beside REQ-CRED-3:
> **REQ-CRED-4.** The action `host.sudo` is routed. A role whose definition lists it within-role may run a
> command with sudo on a host without a decision for each call, but only when all of these hold: the
> definition declares the host credential kind, the business file grants that agent that host, the command
> matches the grant's argv allowlist, and the agent's key for that host is issued. A grant names exact
> argv prefixes. A wildcard grant is refused. A role without `host.sudo` within-role still needs a decision
> for each call. Jason adds `host.sudo` to a definition only through a reviewed role change.
Routing is then one reviewed commit: `host.sudo` into both ROUTED lists and into the chosen definitions'
`withinRole`, as 199 does for `git.push.remote`.
The brief also says this row lands only after the PRD version holding REQ-CRED-3 is approved. Jason approved
1.7 (d52febb5 yes at 20:36:47Z, mos-1089), so that holds. 1.7 doesn't route `host.sudo`, so Q-S1 stands.
## 6. Limits, said plainly
- **Known limit: the seat network isn't confined, so root can outlive the grant.** `seat/src/sandbox.mjs:29-31`
says the sandbox doesn't confine the network (E11). An agent whose allowlist admits any command that can
write a file as root, or start a shell, can add its own key to `authorized_keys` on the host. It can then
ssh straight from its sandbox, around the bus, the events and revoke. An `authorized_keys` `from=` option
doesn't help, because the sandbox shares the stack host's address. The allowlist (D6) narrows this but
doesn't close it.
What closes it: a seat sandbox with its own network namespace (`bwrap --unshare-net`), where the only way
out is the bus sockets and the model proxy, so role network `none` and `api-only` are enforced and not just
carried in the bundle (E13). With that in place, a planted key is useless from the sandbox. Until then,
ops-01's host-side audit (sudo log, `authorized_keys` changes) is the check after the fact. Sage is
reporting the gap to Mos as its own follow-up (sage-1450). It is not this row.
- **The stack's own machine.** A registry entry for the machine the stack runs on, or any machine holding the
stack's `<configDir>`, gives an agent with a root-writing command every agent's keys and tokens, not only
root there (N2). Load refuses loopback addresses, but it can't know every address of the stack's machine.
Jason doesn't register it.
- **Revoke stops the stack's path, not remote processes.** Killing ssh may leave a remote process running.
`-T` gives no tty, so there is no SIGHUP from a closing terminal.
- **Same OS user.** Anything running as the stack's OS user outside a sandbox can read the key file, as 199 D6
says for every kind.
- **Exit 255** is ssh's failure code and also a remote exit code (D6).
- **The pin trusts the registry.** Whoever writes the business file controls which host key is trusted. That
is Jason (section 4).
## 7. Host preconditions (ops-01, not this row)
- `user` has NOPASSWD sudo and a POSIX login shell. `sudo` has no `requiretty` for that user.
- The agent's key is in `authorized_keys` with `from="<stack host>",restrict`.
- The host's public keys are handed to Jason for the registry.
- Duckworth's #423 r5 condition (duckworth-0035) applies. If ops-01 changes #423 step 1 to fit this design,
for example per-agent `from=...,restrict` lines for localadmin, that change goes to Duckworth as a diff
before it runs. That is the live part, not this row (N7).
## 8. Tests
All tests use a scratch sshd on 127.0.0.1 started by the test, with keys made by the test, and a fake `sudo`
on the test user's PATH that prints its argv as JSON and runs nothing as root. The sshd config sets
`AuthorizedKeysFile` and `HostKey` in the test's `mkdtemp` dir. A helper skips the ssh tests with a visible
reason only when `/usr/bin/sshd` is missing, and the gate fails if any of them skipped. No test reaches
another host.
| # | Test | Refusal or result |
|---|---|---|
| T1 | business load: `hosts` registry field checks, bad address, each own-machine form from D3 (`127.0.0.2`, `0.0.0.0`, `::1`, `::`, `::ffff:127.0.0.1`, `::ffff:0.0.0.0`, `localhost`, `a.localhost`, `127.1`, `2130706433`, `0x7f.1`), with `10.1.1.53` and `host.example` as controls, port, user, empty `hostKeys`, a key line with a comment | load refuses, names the key path |
| T2 | role entry `hosts` naming an unknown host, or on a definition without `host` | load refuses `roles.<agent>.hosts` |
| T3 | `organization.set` and `instances.set` with `hosts` in args | `invalid-request`, and the file is unchanged |
| T4 | value check: a valid unencrypted ed25519 key loads. An encrypted key, an RSA key, two keys, CRLF, a gitea token in a `host.*` slot, and a PEM in a `gitea` slot each refuse | `credential-format` |
| T5 | `host.run` with an unknown host | `host-unknown` |
| T6 | `host.run` on a host not in the caller's grant, including a host granted to another agent | `host-not-granted` |
| T7 | argv shape: empty, a NUL, too long, too many, a relative path with `/`, non-string | `invalid-request` |
| T8 | allowlist: a matching argv runs, a longer argv runs, a shorter one and a differing element refuse | `command-not-granted` |
| T9 | before routing: no decision refuses, a decision for another argv, another host or another stdin refuses, a spent one refuses. The stored question carries host, user, argv and the full stdin, and a raise whose question would pass 4096 characters refuses | `gated`, `decision-mismatch`, `decision-consumed`, `invalid-request` |
| T10 | quoting: argv with spaces, quotes, `$()`, backticks, `;`, newline and glob characters reaches fake sudo unchanged | fake sudo's JSON equals argv |
| T11 | the remote command starts `sudo -n --` | fake sudo sees `-n` and `--` first |
| T12 | exit code and signal pass through, timeout kills the process group and answers `signal` | result fields |
| T13 | output cap: 2 MiB of stdout returns 1 MiB and `truncated.stdout: true`, and the call doesn't hang | result fields |
| T14 | host key pin: the sshd presents a key not in `hostKeys` | `host-key-mismatch`, and no command ran (fake sudo's marker file absent) |
| T15 | a registry with the right key passes on a fresh dir with no `~/.ssh/known_hosts` in HOME. A HOME with a `~/.ssh/config` setting a ProxyCommand doesn't change the call | runs, and the proxy marker is absent |
| T16 | leak: the fake sudo prints the private key file (multi-line), and in a second case prints only one body line, and in a third the seed in hex | `credential-leak`, the event has no output |
| T17 | `stdin` containing a body line of the key | `credential-leak` before any spawn |
| T18 | cleanup: after success, refusal, timeout and a thrown error, the run dir is gone and no `ssh-agent` or `ssh` with that dir in argv survives | dir absent, pgrep empty |
| T19 | the private key never appears in the argv or env of any spawned process (read from `/proc/<pid>/cmdline` and `environ` while the fake sudo sleeps) | absent |
| T20 | second concurrent call for the same agent and host | `host-busy` |
| T21 | revoke during a sleeping call kills it, and the next call refuses | `credential-unavailable` |
| T22 | events: `action.allowed` requested and completed carry host, address, user and argv in clear, and sizes and hashes of stdin, stdout and stderr. Never the output, the stdin or the key | event bodies |
| T23 | `issue` of a `host.*` kind prints the public line and the `authorized_keys` suggestion and never the private key | stdout and stderr checked |
| T24 | build grep: no literal `'gitea', 'vikunja'` kind list outside `vocabulary.mjs`, and 199's `credentials.use(` allowlist unchanged | grep |
| T25 | no role file in `roles/` lists `host.sudo` while it is a hard wait (REQ-WAIT-2) | grep |
| T27 | SIGKILL the bus child while fake sudo sleeps: the ssh and ssh-agent pids are gone within 2 s. A call with `timeout` 1 leaves an agent whose key is gone after 31 s (`ssh-add -l` on its socket, agent kept alive by the test). The next bus start removes the run dir, kills a planted live process whose cmdline names it, leaves a dir whose bus pid is alive, and leaves a live process whose pid matches but whose start time doesn't | pids, `ssh-add -l`, dir state |
| T26 | load refuses `commands` as `"any"`, `"*"`, `{}`, `[]`, `[[]]`, a non-string element, a duplicate prefix, an element with `*`, `?` or `[`, and a prefix starting with each listed shell or runner, `/bin/bash` included | load refuses, names `roles.<agent>.hosts.<id>.commands` |
## 9. Mutants
One per refusal, plus the two the brief names. Each must be killed.
| # | Mutant | Killed by |
|---|---|---|
| M1 | registry check skipped | T5 |
| M2 | agent grant check skipped | T6 |
| M3 | grant check reads any agent's `hosts` | T6 |
| M4 | each argv shape check removed, one mutant each (5) | T7 |
| M5 | prefix match compares only `argv[0]` | T8 |
| M6 | load accepts a string `commands` (wildcard grant) | T26 |
| M7 | `#checkAuthority` call removed | T9 |
| M8 | `hostRun` argv hash not compared | T9 |
| M9 | `hostRun` stdin hash not compared | T9 |
| M10 | quoting drops the `'\''` replacement | T10 |
| M11 | `sudo -n` loses `-n` | T11 |
| M12 | **host key pin skipped**: `StrictHostKeyChecking=accept-new` | T14 |
| M13 | **pin skipped another way**: `UserKnownHostsFile` set to the user's default | T14, T15 |
| M14 | `-F /dev/null` removed | T15 |
| M15 | **key leaks into output**: `assertCleanKey` not called | T16 |
| M16 | leak check uses only the joined body | T16 (one-line case) |
| M17 | stdin leak check removed | T17 |
| M18 | `finally` cleanup removed | T18 |
| M19 | key passed with `-i` from a temp file left on disk | T18, T19 |
| M20 | output cap removed | T13 |
| M21 | timeout kill removed | T12 |
| M22 | busy check removed | T20 |
| M23 | revoke doesn't kill the in-flight call | T21 |
| M24 | event records stdout | T22 |
| M25 | each value check removed, one mutant each (encrypted, type, count, CRLF) | T4 |
| M26 | `organization.set` accepts `hosts` | T3 |
| M27 | load check for a definition without `host` removed | T2 |
| M28 | shell and runner check removed, and a second mutant compares `argv[0]` without taking the basename | T26 |
| M29 | glob character check removed | T26 |
| M30 | `requested` event drops argv | T22 |
| M31 | the question omits stdin | T9 |
| M32 | `--pdeathsig` dropped from the spawns | T27 |
| M33 | `ssh-add` without `-t` | T27 |
| M34 | start sweep removed, a second mutant sweeps without checking the bus pid, and a third ignores the start time | T27 |
| M35 | loopback check removed | T1 |
The mutant script follows row 201's shape: copy the packages to scratch, anchor each mutant once, run each
test file in its own process group with a timeout, control first, exit 1 on any survivor. It runs on Sage's
slot.
## 10. Questions
- Q-S1, answered (sage-1450): 1.7 is frozen. Hard wait under 1.7, and section 5 holds the 1.8 proposal.
- Q-S1 still holds after 1.7's approval, which doesn't route `host.sudo` (sage-1468).
- Q-S2, answered (sage-1450): 199's deviation 11 keeps credential use in the bus child, so D5 stays.
- **Q-J0 (Jason, through Mos, before landing, B4, mos-1088).** Word for word:
> Q-J0 (#424). Who may give an agent sudo on another machine?
> Proposal: only you. No agent can, and that includes the chief of staff and the tester.
> You make a grant in four steps, all yours. A reviewed change to the agent's role allows machine keys at all.
> You list the machine and its fingerprint. You give that agent that machine with an exact list of allowed
> commands. You issue the key, and ops-01 installs it. Until a later PRD says otherwise, you also approve
> each command before it runs. You, Mos or the tester can revoke at once. The stack drops the key and cuts
> its connection. A command already started on the machine may keep running. A grant of "any" command is
> root on that machine, so the design refuses it. Some listed commands, such as a file writer, still reach
> root, so grant a machine only to an agent you'd trust as root there.
> Do you approve: only you grant machine access, as above? Yes or no. I recommend yes.
Mos records the answer in DECISIONS.md in jason-master-restart.
- **Q-J1 (Jason, through Mos, at the live window):** the first grants, each an argv allowlist per agent and
host. Not needed for the build.
- Q-S3, answered (sage-1468): the `host_run` harness tool is out of this row. Sage files a follow-up row for
publish and `host_run` in `packages/harness` (scope line at the top).
+10 -89
View File
@@ -15,7 +15,7 @@ and changes nothing.
| Command | Purpose | Notes |
|---|---|---|
| `scripts/bootstrap.sh [--instance NAME]` | Create `~/.config/mosaic-dev/config.json` if absent; with `--instance NAME`, `~/.config/mosaic-NAME/config.json` and the claimed root `~/.mosaic-NAME` | Idempotent; existing config validated, never rewritten. See [Stack instances](#stack-instances---instance-name-row-190) |
| `scripts/bootstrap.sh` | Create `~/.config/mosaic-dev/config.json` if absent | Idempotent; existing config validated, never rewritten |
| `scripts/build.sh` | Build the release image | Tag derived from `RELEASE` + pinned pi version |
| `scripts/hello.sh` | One-shot startup request | Prints model response on stdout |
| `scripts/verify.sh` | Full gated test | Exit 0 only on exact `MOSAIC_HELLO_OK`; `EXPECTED_MARKER` overrides for negative drills |
@@ -270,9 +270,9 @@ scripts/mosaic follow --thread <thread> [--tester]
scripts/mosaic stop <run>
scripts/mosaic launches off | on [--business <id>]
scripts/mosaic launches list [--json]
scripts/bus-service.sh [--instance NAME] render [--copy ID [--copies DIR]] [--pm] [--claude PATH]
scripts/bus-service.sh [--instance NAME] install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]
scripts/bus-service.sh [--instance NAME] uninstall | status <business>
scripts/bus-service.sh render [--copy ID [--copies DIR]] [--pm] [--claude PATH]
scripts/bus-service.sh install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]
scripts/bus-service.sh uninstall | status <business>
```
The human commands read and resolve through the broker's human transport
@@ -321,89 +321,12 @@ stdin and stdout it exits 4 with the usage.
Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input ·
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
## Stack instances (`--instance NAME`, row 190)
Several stacks can run on one host, each with its own config, data root,
units, Console port and CLI wrapper. The default instance is today's stack
and keeps every name it had. A named instance is created once:
```
scripts/bootstrap.sh --instance NAME
```
NAME matches `^[a-z][a-z0-9]{1,15}$`; anything else exits 4
(`instance-name-invalid`), and `--instance dev` is the default. Bootstrap
writes `~/.config/mosaic-NAME/config.json` (0644) and claims `~/.mosaic-NAME`
with the marker `instance.json`, `{"instanceVersion":1,"name":"NAME"}`. It
creates either directory 0700; one that already exists keeps its mode. It refuses with exit 3 and writes
nothing when the root exists, is not empty and holds no marker naming NAME.
A second run changes nothing. `~` is the home of the OS user that runs the
instance.
`--instance NAME` is taken, once and in any position, by `bootstrap.sh`,
`bus-service.sh`, `webui-service.sh` (all but `copy`), `discord-service.sh`,
`stack-copy.sh` (`wrapper` and `check`) and `stack-release.sh`. Copies serve
every instance, so both `copy` verbs refuse it with exit 4. Inside a process the config
travels as `MOSAIC_CONFIG`. Selection, checked before any read or write:
| Given | Config | Names |
|---|---|---|
| neither | `~/.config/mosaic-dev/config.json` | the default's |
| `MOSAIC_CONFIG` only | that file | the default's |
| `--instance NAME` only | `~/.config/mosaic-NAME/config.json` | NAME's |
| both | must be one file (a symlink to it agrees), else exit 4 `instance-config-mismatch` | NAME's |
Every selection checks the data root's marker. A named instance whose root
lacks its own marker refuses `instance-root-unclaimed`; a root whose marker
names another instance refuses `instance-root-mismatch`, also when a config
reaches it with no flag. Both exit 3 before anything is read or changed. A
missing config exits 3 with the bootstrap command that creates it.
| | Default | Instance NAME |
|---|---|---|
| bus unit | `[email protected]` | `[email protected]` |
| Console unit | `mosaic-webui.service` | `mosaic-webui-NAME.service` |
| Discord unit | `[email protected]` | `[email protected]` |
| CLI wrapper | `$BIN/mosaic` (Q81 marker) | `$BIN/mosaic-NAME`, marker `# mosaic-cli wrapper, instance NAME (row 190)`, exports `MOSAIC_CONFIG` |
| bus session env | `%h/.config/mosaic-dev/secrets/claude-session.env` | `~/.config/mosaic-NAME/secrets/claude-session.env` |
| Console port | `--port`, 7332 when absent | `--port` required; 7330, 7332 and 7333 exit 4 `port-reserved` |
A named instance's units add `Environment=MOSAIC_CONFIG=<its config>` and
` [NAME]` after the Description, and the Console unit's comment on the
variables it sets names `MOSAIC_CONFIG`. The default's renders are
byte-identical to the templates' renders before row 190. `stack-copy.sh
wrapper --instance NAME` creates `$BIN/mosaic-NAME` or replaces one that
carries NAME's marker, and refuses any other file. `check --instance NAME`
needs `--business ID`, and fails with `CLI: <copy> (MOSAIC_CONFIG mismatch)`
when the wrapper does not export NAME's config.
For every instance, a Console `--port` or `--tester-port` that another
installed Console unit in the unit directory already takes refuses
`port-claimed`, naming that unit: `webui-service.sh install` exits 1, and
`stack-release.sh activate` and `rollback` exit 3 before anything stops
(`--plan` lists it). A unit with no `--port` holds 7332.
`stack-release.sh --instance NAME` reads only NAME's pointer, log, store and
backups. Its busy check sees only NAME's bus units. Its drop-in scan adds
`mosaic-bus-NAME@*.service.d` and the shared `mosaic-bus-.service.d`. Its
rollback set holds NAME's two unit files, their own `.d` directories and
`$BIN/mosaic-NAME`; it never captures or restores the shared prefix
directories (`mosaic-bus-.service.d`, `mosaic-webui-.service.d`,
`mosaic-discord-.service.d`). The live-session guard refuses a path inside
any named instance's config directory or `~/.mosaic-NAME`, even when that
instance's config is unreadable.
All instances run under one OS user for now. That user's processes can
read and control every instance; a separate user per instance is row 195.
A real second instance on this host is a Q173 window, and tunnel or Traefik
exposure of a named Console waits for Jason after Q175 SSO.
## Stack copies (`scripts/stack-copy.sh`)
```
scripts/stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree]
scripts/stack-copy.sh wrapper ID [--instance NAME] [--copies DIR] [--bin DIR]
scripts/stack-copy.sh check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID]
scripts/stack-copy.sh wrapper ID [--copies DIR] [--bin DIR]
scripts/stack-copy.sh check [--copies DIR] [--bin DIR] [--business ID]
```
One read-only tree supplies the bus, Console and human CLI (row 85,
@@ -449,7 +372,7 @@ scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--mig
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
scripts/stack-release.sh status BUSINESS
common options: --copies DIR, --bin DIR, --instance NAME
common options: --copies DIR, --bin DIR
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
every table in COUNTED.
```
@@ -679,7 +602,6 @@ its runs alone. No unit passes it, and `--pm` with it is a usage error.
```bash
scripts/webui-service.sh copy COMMIT [--copies DIR] # make a pinned copy
# render, install, uninstall and status also take --instance NAME (row 190); copy refuses it (exit 4)
scripts/webui-service.sh render [--copy ID [--copies DIR]] [FLAGS] # print the unit
scripts/webui-service.sh install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [FLAGS]
scripts/webui-service.sh uninstall [--dir DIR] [--no-reload]
@@ -757,9 +679,8 @@ Each value is held to a plain character set the unit can carry (no space,
`%`, `$`, quote, backslash, `&` or `|`), and the preview values must start
with a letter or digit; anything else is refused. With `--auth-header
X-authentik-uid`, in any letter case, `--allow-user` must be the Authentik
uid, `^[0-9a-f]{64}$` (lead decision 101). The default's unit sets no `MOSAIC_*`,
`CLAUDE*`, `CODEX*` or `PI_*` variable; a named instance's sets only
`MOSAIC_CONFIG` (row 190). Serve's exit 2 (usage, config, or a
uid, `^[0-9a-f]{64}$` (lead decision 101). The unit sets no `MOSAIC_*`,
`CLAUDE*`, `CODEX*` or `PI_*` variable. Serve's exit 2 (usage, config, or a
port already in use) is not restarted.
The update/rollback sequence is [Pinned stack update](#pinned-stack-update)
@@ -784,7 +705,7 @@ scripts/discord.sh stop <binding>
scripts/discord.sh unlock <binding>
scripts/discord.sh recover <binding>
scripts/discord.sh reload <binding>
scripts/discord-service.sh [--instance NAME] render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>
scripts/discord-service.sh render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>
```
One seat's conversation reachable from listed channels of one Discord
-81
View File
@@ -1,81 +0,0 @@
# Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426)
Sage, 2026-10-20, for Mos's order mos-1078 (Vikunja P19 #426) and its go mos-1083 item 1. Owner Rocko,
reviewer Dewey, gate owner Sage. Base: refactor at this brief's commit. Starts after row 191.
## Why
Morgana force-pushed a seat branch on 2026-10-20 (morgana-0016). Mos took it to Jason. Force-push is a
Q173 exclusion, and canon says never force-push, but nothing mechanical stops one. Mos: "Done means a
force-push from a seat worktree is refused, with a test that shows it."
Earlier the same day, a busy check chained as `| tail -1 &&` passed while the slot was BUSY. The script it
called prints BUSY or CLEAR, but it exits 0 either way.
The guard has two layers:
- (a) Gitea protection with force-push off on seat branch patterns and on refactor, next and main. ops-01
sets it up, through Mos (mos-1082). That part isn't this row.
- (b) The client hook below. It can be bypassed with `--no-verify`, so (a) is the real guard and (b) is the
early refusal.
## What ships
1. `scripts/git-hooks/pre-push` (new). For each ref line git passes on stdin:
- It refuses when the remote sha isn't all zeros and isn't an ancestor of the local sha (`git
merge-base --is-ancestor`). The refusal names the ref and both shas, and the hook exits 1.
- A new branch passes. A delete (local sha all zeros) is refused.
- Any failure of the check refuses too, for example an unknown remote object. The hook fetches nothing.
If the remote tip isn't present locally, it refuses and says to fetch first.
2. Install, following `scripts/queue-commit.sh --install-hook`. Either add a `--install-push-hook` there or
ship a small `scripts/install-git-hooks.sh`, whichever fits the existing code better. Say which in BUILD.md.
- The install copies the hook into the common `.git/hooks`, which every seat worktree shares.
- It refuses when `core.hooksPath` is set (the husky trap, 2026-10-09). It refuses when an installed
pre-push differs from the repository copy, unless `--force-install` is given.
- It never edits the installed copy in place.
3. `scripts/slot-busy.sh` (new), the shared busy check.
- It prints one BUSY line per matching process, or CLEAR.
- It exits 0 on CLEAR, 1 on BUSY and 2 on its own error.
- It matches `node --test`, `gate.sh`, `mutant`, `scripts/test-*.sh` and `docker compose` command lines.
It skips its own process and shell snapshots.
- It reads `/proc/<pid>/cmdline` and the cwd link only, never file contents under another seat's
directory.
- Usage in `docs/TOOLS.md`: `bash scripts/slot-busy.sh > LOG && gate`, with `set -o pipefail` in any
script that pipes it. Never `| tail -1 &&`.
## Tests
Add them to `scripts/test-land-work.sh` or a new `scripts/test-git-guard.sh` (say which). Run against a scratch
bare remote and a scratch clone with a worktree, never against origin:
- A rewritten branch pushed with `--force` from a worktree is refused, and the remote ref is unchanged.
- A `+refspec` push is refused.
- A fast-forward push passes.
- A new branch passes.
- A delete is refused.
- A remote tip that's missing locally is refused.
- The install refuses with `core.hooksPath` set, and refuses a differing installed hook.
- `slot-busy.sh` exits 1 with a fake `node --test` process running (a sleeping stand-in with that argv),
and 0 without one.
- `slot-busy.sh | tail -1 && false` doesn't apply here. The test runs `bash scripts/slot-busy.sh >/dev/null
&& echo ran`, and asserts it doesn't print `ran` while BUSY.
Mutants, each killed:
- drop the ancestor check
- treat an unknown remote object as passing
- let a delete pass
- install over a set `core.hooksPath`
- `slot-busy.sh` exits 0 on BUSY
## Boundaries
- Install the hook in the canonical checkout only after landing, as its own step with Sage. Tests use
scratch repositories.
- No push to origin from a test. No change to Gitea config. That's ops-01's.
- Don't touch `~/.mosaic`, `~/.mosaic-dev` or `~/.config/mosaic-dev`.
## Gate
Dewey approves the manifest. The new or changed `scripts/test-*.sh` file and every other `scripts/test-*.sh`
are green on Sage's gate, and the mutants are killed. Done when the hook is installed in the canonical
checkout and a scripted force-push from a seat worktree to a scratch remote is refused there. ops-01's
server proof (a refused force-push, plus a fast-forward push and a new branch that go through) is recorded
in Vikunja #426 by Mos.
+60 -49
View File
@@ -1,66 +1,77 @@
# Brief: the Case 1 install on 10.1.1.53 (row 195)
# Brief: Melanie's hub instance on its own host (row 195, was 190b)
Sage, 2026-10-20, third version, for Darkwing's rewrite request and sage-1439. Gitea #1693.
Sage, 2026-10-20. Rewritten for Mos's rulings mos-0998 and mos-1005 (Vikunja #418, #419). The first version
put the hub under its own Linux user on dragon-lin. That form can't pass Duckworth's B7 there, because the
factory uid's `sudo -n` succeeds on dragon-lin (Mos's recon, brain-redux
`projects/mosaic-mvp/MELANIE-HOST-RECON.md`). Melanie's own server is ruled out too: it has 1.2 GiB RAM free
with her two Hermes bots running, and co-tenancy would share her user and her passwordless sudo. The hub runs
on a separate host. Which host is Jason's decision (#419): hardware outside the cluster, a cluster VM with
factory PVE root removed, or a cluster VM with that residual accepted. This row is written so the host choice
doesn't change it. Gitea #1693. On the 10-27 MVP path. Federation R8 (Gizmoduck's design r4, 04a6112be) needs
this row or nothing.
Why it changed:
- The first two versions put Melanie's hub on its own Linux user, then on its own host, Jason's choice under
#419.
- Q181 settled the hosts: Case 1 (Jason's software factory) runs on 10.1.1.53 and Case 2 (Melanie's hub) on
10.1.1.54.
- Q182 and Q183 made the hub's privilege checks a recorded acceptance. They report and don't refuse
(federation R8, mos-1055, mos-1063, mos-1066).
- The Case 2 install on .54 is Gosalyn's C2-13 plus its window (#416, due by 2026-10-27). It is not this row.
This row now installs Case 1 on .53. On the 10-27 MVP path (mos-0974, T1 item 1).
Owner Darkwing (Opus 5.5), reviewer Duckworth, gate owner Sage. Chained on row 190's candidate (the config
canon) and on C2-13's candidate (the install script), Q177. It reuses C2-13's `case2-install.sh`,
`case2-precheck.sh` and `case2-unit.sh` by input, not by copy. A gap in them goes to Gosalyn as a C2-13
change, or into this row's manifest as a named, reviewed change to those files. Design first:
`agents/darkwing/work/queue-195/DESIGN.md`.
Owner Darkwing (Opus 5.5), reviewer Duckworth (author of B5 and B7), gate owner Sage. Chained on row 190
(Q177): the hub runs under row 190's config canon. Design first. The first deliverable is
`agents/darkwing/work/queue-190b/DESIGN.md`.
## Goal
Jason's software factory runs on 10.1.1.53 as `mosaic` under `/srv/mosaic`, in the same unit form as Case 2
(design 9, Q181). It uses row 190's config canon and the model account the factory uses today (Q185,
mos-1081 item 1). No new provider account.
Melanie's hub instance (Case 2, C2-13) runs on its own host. No factory agent can read or write the hub's
config, data root or secrets, reach its bus socket, or log in to the hub host. Her writing reaches the hub only
through the existing git.mosaicstack.dev remotes, books repo only.
## Provision
## Provision (blank until Jason answers #419)
- ops-01 runs the root steps over ssh as `localadmin`: `useradd mosaic`, `/srv/mosaic` and the unit through
`case2-unit.sh` from a root-owned checkout. #423 step 1 (localadmin with NOPASSWD sudo on .53 and .54) was
applied at 20:05:47Z (mos-1066, mos-1077), and nothing else in #423 gates this row.
- #423 step 4 (jarvis sudo) waits until Case 1 is live on .53.
- The host list comes from Q181. Agents may run read-only checks on .53 as `localadmin`.
Who provisions the host and how is left open. Sizing from Mos (mos-0998): 4 vCPU, 8 GB RAM, 64 GB disk,
internal network only. Each agent process measures 300 to 390 MB RSS on dragon-lin. The design takes the host
as given: a fresh Linux host with a named admin path, reachable by the deploying seat while the row runs.
## The design must state
1. The install as input values to C2-13's scripts: user, home, business file, roles, the human id, the
token slot path. Which commands run as `mosaic` through `sudo -u mosaic -H`, and which as root through
ops-01.
2. Where Case 1 differs from Case 2: the factory roles in place of the library business, no library sync,
and any precheck item that doesn't apply, each with a reason.
3. Model access through the existing factory account (Q185). Placing the credential on .53 is a step under
the Q173 secrets rule, done in the window. It is never copied by the script, and its value is never
printed.
4. Re-pin, rollback and backup on .53, and who runs them.
5. The acceptance checks, as code Duckworth can read. Each check names a path and gives it one of three
marks: closed, accepted under Q182 or Q183, or "not named in Q182 or Q183". An accepted path doesn't
fail the row. An unnamed one is reported to Mos, who takes it to Jason (the R8 N1 rule). The functional
checks fail the row when they fail: the unit runs as `mosaic`, the Console answers on its port on the
internal network only, and one tester message gets a reply.
1. The deploy. Exactly what Darkwing installs on the hub host and how: the stack copy, node, the units, the
config dir and data root under row 190's canon (O1: `~/.config/mosaic-NAME`, and "`~` is the home of the OS
user that runs the instance"). Which commands run as which user on the hub host, and which need its root.
2. Re-pin, rollback and backup on the hub host, and who runs them after the deploy key is gone.
3. How the hub gets model access without a copy of jwoltje's `claude-session.env` or any factory credential.
If that needs the credentials row (T1 item 6), say so and name the interim. If it needs a new subscription
or a new provider account, flag it for Jason and don't pick one (mos-1008).
4. The books repo path. Which git.mosaicstack.dev remote, which credential the hub holds for it, and that it
can't read or push any other repository.
5. The admin path and the end state. The host ends with no key the factory uid can read. The design names the
admin path that remains (for example the hypervisor console, or a key kept off the factory uid) and who
holds it.
6. The acceptance script, as code Duckworth can read. It takes Gizmoduck's list of infrastructure gate items
as its input (mos-1011), not a fixed set of checks. Each item names a path, its closed form and its
accepted form, and the script reports which form holds for each, or fails. The list covers at least:
- federation R8's separate-host form: the hub's bus socket, data root and config dir don't exist on the
factory host, the factory host mounts nothing of the hub's, and no factory agent holds a login or key
for the hub host
- B7: from the factory uid, `sudo -n`, `doas -n`, `su`, polkit, and `ssh -o BatchMode=yes` to the hub
host all fail
- the hypervisor and backup paths ops-01 measured (mos-1011: one PVE node the factory key has root on,
PBS on that node with unencrypted backups, a flat /16 LAN), each closed or accepted by Jason
7. The identity provider and TLS front end (B8, mos-1008, mos-1011). Either the factory holds no credential
that can create users, change groups, issue tokens or impersonate in the issuer the hub trusts, or the hub
uses an issuer the factory can't administer. A brand, tenant or app inside the same Authentik does not
count as separate. The same holds for the TLS front end that serves the hub. The proof is a failed admin
call from every token the factory's root can read. No credential value is printed in that proof.
## Boundaries
- Infra and k3s changes, exposure outside the internal network, and spend stay Jason's (Q173 exclusions).
- No change to dragon-lin's `~/.mosaic-dev`, `~/.config/mosaic-dev`, its live units or `~/.mosaic`.
- No credential value is printed, logged or committed.
- Tests use scratch roots and stand-ins. Nothing in a test reaches .53.
- The run on .53 is a window with Jason's or Mos's go, a backup first where state exists, and a STATE report
after.
- Provisioning, hypervisor access and any infra or k3s change are Jason's (Q173 exclusions). Mos takes the
hypervisor residual (factory root on the PVE nodes, Q172) to Jason. It doesn't block this row.
- Melanie's server (`[email protected]`) is reference only, read only, under Jason's authorization in
mos-0995. Nothing on it changes, and her Hermes bots keep running.
- No tunnel, Traefik or other exposure outside the internal network. That waits for Jason, after Q175 SSO.
- No credential value is printed, logged or committed. Keys made for the deploy are removed at the end, and
the acceptance script proves it.
- The admin-call proof in point 7 and any call to the issuer or the TLS front end run only in a window Mos
approves, never as a test or an anonymous probe (Q175).
- Tests in the repository use scratch roots and stand-ins. The live deploy and the acceptance run on the hub
host are a window with a backup first (where there is state) and a STATE report after.
## Gate
Duckworth approves DESIGN.md, then the manifest. Any repository change is green on Sage's gate (cli suite and
every `scripts/test-*.sh`), with the design's mutants killed. The row is done when the window's acceptance
run passes on .53, Duckworth reads its output, and Mos confirms.
every `scripts/test-*.sh`), with the design's mutants killed. The row is done when the acceptance script passes
on the hub host from the factory uid, Duckworth reads its output, and Mos confirms.
+3 -23
View File
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
<!-- mosaic-queue:begin -->
Generated from `docs/plans/queue.json` revision 2361 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
Generated from `docs/plans/queue.json` revision 2247 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|---|---|---|---|---|---|---|---|---|
@@ -225,29 +225,9 @@ Generated from `docs/plans/queue.json` revision 2361 by `queue render`. Do not e
| 186 | stack-release --plan: the backup line says what the run will do (P19 404, row 185 N1/N2) | darkwing; reviewers filbert | #1684 | done | — | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the three brief mutants killed (sage) | `docs/plans/2026-10-20_plan-backup-line.md` § Brief: the activate plan's backup line says what the run will do | Landed 269134d8d (author Darkwing) on 3c5da3a91. Patch-id c97b9f6e25cd matches the r1 candidate (manifest 449e9121, Filbert approve 29419). Sage gate on b367f5730+patch 09:00:20Z-09:08:59Z: node 2681/0, scripts 422/0, queue 156, network removed; 3c5da3a91 adds only a SESSIONS line. Not live: the stack runs copy 490e2771d3d0. Follow-ups on P19 404: Filbert M6 gap, store-unclean reading. |
| 187 | stack-release activate carries the row 178 proof pair (mos-0928 Part B A0) | darkwing; reviewers filbert | #1685 | done | 186 done | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-proof-pair.md` § Brief: activate carries the row 178 proof pair | Landed 9a71e4e80 (author Darkwing) on d17328a55; patch-id 6b57c47bc661 matches candidate 52a5ecc1d143 and Filbert's r1 reading. Sage gate passed 10:21:55Z: cli 446/446 and every package and scripts/test-*.sh green. Filbert approve 29424, rev 2204; follow-ups N1-N4 filed on P19. |
| 188 | stack-release tests: a carried proof pair and a proof file inside the repository (P19 409, 410; gates Part B) | darkwing; reviewers filbert | #1686 | done | 187 settled | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_proof-test-gaps.md` § Brief: tests for the two proof-file paths row 187 left open | Landed 8fc0ee216 (author Darkwing) on 23a609c5c; patch-id c9a56b167c07 matches candidate 86aa549bad75 and Filbert's r1 reading; stack-release.sh blob 2586a2a37cd2 unchanged. Sage gate passed 10:58Z: cli 453/453 and every package and scripts/test-*.sh green. Filbert approve 29433, rev 2212; N1 (REPO logical path) to P19 after Part B (mos-0959). |
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | done | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | Landed ace27dc5e on refactor (cherry-pick of candidate d6116771f onto a4a42dbac). Gate GATE_PASSED on 0c177e9ef (base 0217b71c8), 2026-10-20T19:09:37Z-19:20:36Z, 25 suites rc=0. patch-id ce89378718f6 for both 0c177e9ef and ace27dc5e; range-diff differs only in the message. Landed manifest hashes to 67af69bd (Dewey approve 29485). Base drift 0217b71c8..a4a42dbac: 7 doc/README files, none on manifest paths, no test reads them. Brief re-pinned to blob 29a9e6d2d. |
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-review, round 1, request posted | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | in-review, round 1, request posted | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | — |
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-progress | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | in-progress | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | — |
| 192 | Q175 phase 1: the Console verifies SSO itself (--sso), OIDC RP with oauth4webapi 3.8.8, no live change (MVP T1.2, #412) | launchpad; reviewers webby | #1690 | in-review, round 2, request posted | — | Webby approves the manifest; webui and cli suites, scripts/test-config.sh and every scripts/test-*.sh green on Sage's gate; design section 9 mutants killed (sage) | `docs/plans/2026-10-20_sso-phase1.md` § Brief: Q175 phase 1, the Console verifies SSO itself (--sso) | — |
| 193 | many humans per business, human roles, private and shared threads, one access.json store (MVP T1.3, Vikunja #414/#415) | morgana; reviewers duckworth | #1691 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, business, cli and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users.md` § Brief: many humans per business, human roles, private and shared threads | — |
| 194 | agent lifecycle from the Console and the CLI (create, launch, terminate, regenerate, delete) with a per-agent hire/fire veto (MVP T1.5, Vikunja #414/#415) | honker; reviewers webby | #1692 | in-progress | — | Webby approves DESIGN.md then the manifest; bus, cli, business and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto | — |
| 195 | row 195: the Case 1 install on 10.1.1.53 as mosaic under /srv/mosaic, reusing C2-13's scripts by input under row 190's canon (Q181, Q182/Q183, Q185; ops-01 root steps under #423 step 1) | darkwing; reviewers duckworth | #1693 | briefed | 190 done | Duckworth approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Brief: the Case 1 install on 10.1.1.53 (row 195) | sage-1439: rewritten to Case 1 on .53 (f24f2cd40). Chains on C2-13's candidate too once C2-13 is queued. Darkwing starts DESIGN.md after row 190's mutants. |
| 196 | the human boundary across uids and humans: Console, tester listener, broker.sock and the path-bound human proof (P19 333, before federation R3, mos-0989) | gander; reviewers duckworth | #1694 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-boundary.md` § Brief: the human boundary across uids and humans (P19 333) | — |
| 197 | a second gate lane: two full gates at once on dragon-lin, design first (mos-0977 item 6) | herb; reviewers webby | #1695 | in-progress | — | Webby approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate, singly and in the two-lane proof (sage) | `docs/plans/2026-10-20_second-gate-lane.md` § Brief: a second gate lane, two full gates at once on dragon-lin | Order with row 198 (Sage, mos-1040 N1): 197 lands first. After B1 the shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. Row 198 rebases onto 197's landing before its manifest review. |
| 198 | retire the control board writer on 7331 (unauthenticated loopback writer, Q175; mos-1008) | bushroot; reviewers webby | #1696 | in-progress | 192 done, 197 done | Webby approves INVENTORY.md then the manifest; control-board, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_retire-control-board.md` § Brief: retire the control board writer on 7331 | Order with row 197 (Sage, mos-1040 N1): 197 lands first. Row 198 rebases onto 197's landing before its manifest review; shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. |
| 199 | MVP T1 item 6: a credential store per agent, own git identity, pushes to a remote (G11, G13; mos-0974) | megavolt; reviewers duckworth | #1697 | in-progress | — | Duckworth approves DESIGN.md; Mos rules on the authority model; Duckworth approves the manifest; bus, business, cli and tasks suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: a credential store per agent, own git identity, pushes to a remote | — |
| 200 | stack-release.sh activate --sso for the Q175 phase 2 window (row 192 F3; chained on 192) | launchpad; reviewers webby | #1698 | in-review, round 2, request posted | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite) | — |
| 201 | FED R1: access.json loader and validator, and covers (#414) | gizmoduck; reviewers duckworth | #1699 | in-review, round 1, request posted | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R1, the access.json loader and validator | — |
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | in-review, round 1, request posted | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
| 203 | FED R2: the federation key in the system config (#414) | quackerjack; reviewers webby | #1701 | briefed | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R2, the federation key in the system config | Chained on row 192's candidate (Q177): starts on 192's candidate, lands after 192 (Sage) |
| 204 | 193a: access level check widened to viewer (#1691) | morgana; reviewers duckworth | #1702 | in-review, round 1, request posted | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193a, the access level check widened to viewer | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
| 205 | 193b1: viewer verbs, board refusals and the message filter (#1691; also after FED R3 and R4 when filed) | morgana; reviewers duckworth | #1703 | queued | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1, viewer verbs, board refusals and the message filter | — |
| 206 | 193b2: thread shares, schema 3j (#1691; live needs a --migrate window with Jason) | morgana; reviewers duckworth | #1704 | queued | 205 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3j) | — |
| 207 | 193c: mosaic access user verbs and --level viewer (#1691) | morgana; reviewers duckworth | #1705 | queued | 202 done | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193c, mosaic access user verbs and --level viewer | — |
| 208 | 193d: Console shared list, share controls and viewer read-only (#1691; also after FED R6 when filed) | morgana; reviewers duckworth | #1706 | queued | 192 done, 206 done | Duckworth approves the manifest; webui suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193d, the Console's shared list, share controls and viewer read-only | — |
| 209 | FED R8: the hub window on 10.1.1.54, a recorded acceptance (#414, r4.11 b93f098f0; live window run by Jason and Mos, after the Case 2 install #416, Bean and Chet live and both Hermes gateways stopped) | sage; reviewers duckworth | — | queued | — | Every entry check's output is in the R8 record with who ran it; Jason signs in to the hub and sends one message, Melanie revokes, his next request is refused; Duckworth checks the record (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R8, the hub window on 10.1.1.54 | Stays queued until its entry gates near: Case 2 install on .54 (#416, by 2026-10-27), Bean and Chet live, both Hermes gateways on .54 stopped, R7, #412 phase 2, #419 answer. Filed per mos-1073 after duckworth-0034 (Sage) |
| 210 | Agent host access with sudo through the credential store: grant per agent and host, the stack does ssh and sudo, the agent never sees the key (#1707, Vikunja #424, Q183/Q184, due 10-27; chained on row 199's candidate) | gizmoduck; reviewers duckworth | #1707 | in-progress | — | Duckworth approves DESIGN.md, then the manifest; bus, business, cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_host-credentials.md` § Brief: agent host access with sudo through the credential store (#424) | Design runs before 199 lands (Q177 chain on 199's candidate). after cleared so Gizmoduck can claim; lands only after 199 and rebases onto it. |
| 211 | Force-push guard: scripts/git-hooks/pre-push refuses non-fast-forward and delete pushes from every worktree, and scripts/slot-busy.sh exits 1 on BUSY (#1709, Vikunja #426, mos-1078/1083) | rocko; reviewers dewey | #1709 | briefed | — | Dewey approves the manifest; the new or changed scripts/test-*.sh and every other scripts/test-*.sh green on Sage's gate; the mutants killed; hook installed in the canonical checkout with a scripted refused force-push (sage) | `docs/plans/2026-10-20_force-push-guard.md` § Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426) | — |
<!-- mosaic-queue:end -->
+14 -4263
View File
File diff suppressed because it is too large Load Diff
-13
View File
@@ -717,19 +717,6 @@ without it, so Pi sessions still launch and a Claude Code launch refuses
(or this line and this paragraph), then restart the unit; a running host
keeps the environment it started with.
A named stack instance (row 190) takes `--instance NAME` with any verb, in
any position. The unit is then `[email protected]`, it sets
`Environment=MOSAIC_CONFIG=` to `~/.config/mosaic-NAME/config.json`, its
Description ends in ` [NAME]`, and its `EnvironmentFile=` is
`secrets/claude-session.env` in that config directory, not mosaic-dev's.
Install's busy and drop-in checks cover only `mosaic-bus-NAME@*` units and
their `.d` directories, plus the prefix directories systemd applies to them:
`mosaic-bus-.service.d`, `mosaic-.service.d` and `service.d`. A data root
that another instance claims, or a named root with no `instance.json`, exits
3 before anything is read. Without the flag the unit renders byte for byte
as before. Selection, names and refusals are in
[TOOLS.md, Stack instances](../../docs/TOOLS.md#stack-instances---instance-name-row-190).
The unit has no `network-online.target` ordering, since a user unit cannot
order on that system target. The notifier needs no network at start: a
send that fails is journaled and retried.
+1 -2
View File
@@ -13,7 +13,6 @@ StartLimitBurst=5
Type=simple
WorkingDirectory=@REPO@
Environment=PATH=@PATH@
@CONFIG_ENV@
# The Claude Code binary that managed sessions and the manifest's
# claudeVersion run, pinned at install (`--claude PATH`, default
# `command -v claude`). Without it they run `claude` from PATH above.
@@ -22,7 +21,7 @@ Environment=PATH=@PATH@
# operator writes: CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, or a proxy's
# ANTHROPIC_AUTH_TOKEN with ANTHROPIC_BASE_URL. The `-` lets the unit start
# without it, and a Claude Code launch then refuses.
EnvironmentFile=-@SESSION_ENV@
EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env
# The host reads the system config and the business file, boots the broker
# (packages/bus/src/process.mjs) and starts the notifier. Config problems and
# refusals exit 3, usage 4, invalid input 2: none of those is retried. A
+4 -9
View File
@@ -837,11 +837,7 @@ test("bus start --no-launch says launches are disabled in its host-up line and s
test("bus-service.sh renders the unit and installs it into a given directory", (t) => {
const dir = tmp(t);
const script = join(REPO, "scripts", "bus-service.sh");
// Row 190: the script selects an instance from the system config first; a
// scratch path keeps it off ~/.config/mosaic-dev. A missing config selects
// the default names, as before.
const cfgEnv = { ...process.env, MOSAIC_CONFIG: join(dir, "absent", "config.json") };
const render = spawnSync(script, ["render"], { encoding: "utf8", env: cfgEnv });
const render = spawnSync(script, ["render"], { encoding: "utf8" });
assert.equal(render.status, 0, render.stderr);
assert.match(render.stdout, new RegExp(`ExecStart=${REPO.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/scripts/mosaic bus start %i`));
assert.match(render.stdout, /RestartPreventExitStatus=2 3 4/);
@@ -859,7 +855,7 @@ test("bus-service.sh renders the unit and installs it into a given directory", (
const fakeBin=join(dir,"bin");mkdirSync(fakeBin);
writeFileSync(join(fakeBin,"systemctl"),"#!/bin/sh\ncase \"$*\" in *list-units*) exit 0;; *)exit 99;; esac\n");chmodSync(join(fakeBin,"systemctl"),0o755);
writeFileSync(join(fakeBin,"systemd-analyze"),`#!/bin/sh\nprintf '%s\\n' '${dir}/unit-search'\n`);chmodSync(join(fakeBin,"systemd-analyze"),0o755);
const unitEnv={...cfgEnv,PATH:`${fakeBin}:${process.env.PATH}`};
const unitEnv={...process.env,PATH:`${fakeBin}:${process.env.PATH}`};
const first = spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv });
assert.equal(first.status, 0, first.stderr);
assert.match(first.stdout, /written: /);
@@ -867,7 +863,7 @@ test("bus-service.sh renders the unit and installs it into a given directory", (
assert.equal(readFileSync(join(dir, "[email protected]"), "utf8"), render.stdout);
assert.match(spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv }).stdout, /unchanged: /);
assert.match(spawnSync(script, ["uninstall", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv }).stdout, /removed: /);
assert.equal(spawnSync(script, ["bogus"], { encoding: "utf8", env: cfgEnv }).status, 4);
assert.equal(spawnSync(script, ["bogus"], { encoding: "utf8" }).status, 4);
});
// Row 68: the unit pins the Claude Code binary as MOSAIC_CLAUDE_BIN, from
@@ -886,8 +882,7 @@ test("bus-service.sh pins the claude binary, refuses one the unit can't carry, a
const onPath = stand(join(dir, "bin", "claude"));
const named = stand(join(dir, "claude-named"));
symlinkSync(named, join(dir, "claude-link"));
// Row 190: a scratch MOSAIC_CONFIG keeps the instance selection off ~/.config/mosaic-dev.
const env = { ...process.env, MOSAIC_CONFIG: join(dir, "absent", "config.json"), PATH: `${join(dir, "bin")}:${dirname(process.execPath)}:/usr/bin:/bin` };
const env = { ...process.env, PATH: `${join(dir, "bin")}:${dirname(process.execPath)}:/usr/bin:/bin` };
const run = (args, e = env) => spawnSync(script, args, { encoding: "utf8", env: e });
const pinned = (out) => out.match(/^Environment=MOSAIC_CLAUDE_BIN=(.*)$/m)?.[1];
-17
View File
@@ -66,23 +66,6 @@ test('wrapper preserves Q81 lease and marker, rejects foreign/symlink; check use
assert.notEqual(s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin]).status,0);assert.equal(readFileSync(wrapper,'utf8'),foreign);
const backing=join(s.bin,'backing');writeFileSync(backing,`#!/usr/bin/env bash\n${marker}\n${lease}\nexec \"/old/scripts/mosaic\" \"$@\"\n`);unlinkSync(wrapper);symlinkSync(backing,wrapper);assert.notEqual(s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin]).status,0);
});
test('row 190 N6: check --instance passes only when the wrapper exports the instance\'s MOSAIC_CONFIG',t=>{
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
const boot=s.run('bootstrap.sh',['--instance','alpha']);assert.equal(boot.status,0,boot.stderr);
const copy=join(s.copies,s.id),wrapper=join(s.bin,'mosaic-alpha'),cfg=join(s.root,'.config','mosaic-alpha','config.json');
const w=s.run('stack-copy.sh',['wrapper',s.id,'--instance','alpha','--copies',s.copies,'--bin',s.bin]);assert.equal(w.status,0,w.stderr);
s.fake(`echo 'LoadState=loaded';echo 'WorkingDirectory=${copy}';case "$*" in *mosaic-bus-alpha@demo*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo ; }';; *mosaic-webui-alpha*) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; *) exit 1;; esac\n`);
const check=()=>s.run('stack-copy.sh',['check','--instance','alpha','--copies',s.copies,'--bin',s.bin,'--business','demo']);
const good=readFileSync(wrapper,'utf8');assert.equal(good.split('\n')[3],`export MOSAIC_CONFIG='${cfg}'`);
let r=check();assert.equal(r.status,0,r.stdout+r.stderr);assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`);
for(const [label,text] of [
['another config',good.replace(`export MOSAIC_CONFIG='${cfg}'`,`export MOSAIC_CONFIG='${join(s.root,'.config','mosaic-beta','config.json')}'`)],
['no config line',good.replace(`export MOSAIC_CONFIG='${cfg}'\n`,'')],
]){
writeFileSync(wrapper,text);r=check();
assert.equal(r.status,1,label);assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy} (MOSAIC_CONFIG mismatch)\n`,label);
}
});
test('bus unit copy/pm pins, install refuses all busy states and failed reads, drop-in refusal',t=>{
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
const args=['--copy',s.id,'--copies',s.copies,'--pm'];
-469
View File
@@ -1,469 +0,0 @@
// Row 190 (#1688): several stack instances on one host. The config and bootstrap cases (T-I1, T-I2,
// the config part of T-I7), two real bus hosts side by side (T-I5) and the guard (T-I10). Design and
// T table: agents/darkwing/work/queue-190/DESIGN.md D7. The unit, port and release cases are T56 in
// stack-release.test.mjs. HOME is a scratch directory in every test, so ~/.config/mosaic-* and
// ~/.mosaic-* resolve inside it; nothing here reads or writes ~/.mosaic-dev or ~/.config/mosaic-dev.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { once } from "node:events";
import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { LiveSessionGuard } from "../../conversation/src/guard.mjs";
import { businessDoc, rolesCopy, writeJson } from "../../business/tests/helpers.mjs";
import { REPO_SKILLS } from "../../harness/tests/helpers.mjs";
import { humanEnv, notifyConfig, tmp } from "./helpers.mjs";
const SCRIPTS = fileURLToPath(new URL("../../../scripts/", import.meta.url));
const CONFIG = join(SCRIPTS, "mosaic-config.mjs");
const BOOTSTRAP = join(SCRIPTS, "bootstrap.sh");
const BUS_SERVICE = join(SCRIPTS, "bus-service.sh");
const WEBUI_SERVICE = join(SCRIPTS, "webui-service.sh");
const STACK_COPY = join(SCRIPTS, "stack-copy.sh");
const MOSAIC = join(SCRIPTS, "mosaic");
// The environment for a scratch home: no MOSAIC_CONFIG unless a test sets one.
function homeEnv(home, extra = {}) {
const env = humanEnv({ HOME: home, ...extra });
if (!("MOSAIC_CONFIG" in extra)) delete env.MOSAIC_CONFIG;
delete env.XDG_CONFIG_HOME;
return env;
}
function run(cmd, args, env) {
const r = spawnSync(cmd, args, { env, encoding: "utf8", timeout: 30000 });
return { code: r.status, out: r.stdout, err: r.stderr };
}
const bootstrap = (home, ...args) => run(BOOTSTRAP, args, homeEnv(home));
const config = (home, extra, ...args) => run(process.execPath, [CONFIG, ...args], homeEnv(home, extra));
const cfgOf = (home, name) => join(home, ".config", `mosaic-${name}`, "config.json");
// The instance op's exports as an object.
function exportsOf(out) {
return Object.fromEntries(out.trim().split("\n").map((line) => {
const m = line.match(/^(\w+)='(.*)'$/);
assert.ok(m, `not a shell-safe export: ${line}`);
return [m[1], m[2]];
}));
}
// Every entry under `dir`: relative path, type, mode, size, mtime and content hash.
function snapshot(dir) {
const out = {};
const walk = (d) => {
for (const name of readdirSync(d).sort()) {
const p = join(d, name);
const s = lstatSync(p);
const key = relative(dir, p);
if (s.isDirectory()) {
out[key] = { dir: true, mode: s.mode, mtime: s.mtimeMs };
walk(p);
} else {
out[key] = { mode: s.mode, size: s.size, mtime: s.mtimeMs, sha: createHash("sha256").update(readFileSync(p)).digest("hex") };
}
}
};
if (existsSync(dir)) walk(dir);
return out;
}
const mode = (p) => statSync(p).mode & 0o777;
test("T-I1 bootstrap --instance creates each instance's config, root and marker once, and refuses what it must not claim", async (t) => {
await t.test("alpha and beta get a 0700 config dir, a config naming ~/.mosaic-NAME, a 0700 root and a 0644 marker", (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 0, r.err);
const cfg = cfgOf(home, name), root = join(home, `.mosaic-${name}`);
assert.equal(r.err, `mosaic-config: created configuration for instance ${name}: ${cfg}, data root ${root}\n`);
assert.equal(mode(join(home, ".config", `mosaic-${name}`)), 0o700);
assert.equal(mode(cfg), 0o644);
assert.equal(JSON.parse(readFileSync(cfg, "utf8")).dataRoot, root);
assert.equal(mode(root), 0o700);
const marker = join(root, "instance.json");
assert.equal(readFileSync(marker, "utf8"), `{"instanceVersion":1,"name":"${name}"}\n`);
assert.equal(mode(marker), 0o644);
assert.deepEqual(readdirSync(root), ["instance.json"]);
assert.deepEqual(readdirSync(join(home, ".config", `mosaic-${name}`)), ["config.json"]);
}
assert.equal(existsSync(join(home, ".config", "mosaic-dev")), false);
assert.equal(existsSync(join(home, ".mosaic-dev")), false);
});
await t.test("a second bootstrap of each changes no byte, mode or mtime", (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
const before = snapshot(home);
for (const name of ["alpha", "beta"]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 0, r.err);
assert.equal(r.err, `mosaic-config: configuration already present, validated without changes: ${cfgOf(home, name)}\n`);
}
assert.deepEqual(snapshot(home), before);
});
await t.test("a non-empty root without its own marker refuses before anything is written, worktrees included", (t) => {
const home = tmp(t, "si-");
mkdirSync(join(home, ".mosaic-gamma"));
writeFileSync(join(home, ".mosaic-gamma", "notes.txt"), "someone's data\n");
mkdirSync(join(home, ".mosaic-worktrees", "row190"), { recursive: true });
mkdirSync(join(home, ".mosaic-delta"));
writeFileSync(join(home, ".mosaic-delta", "instance.json"), `{"instanceVersion":1,"name":"alpha"}\n`);
writeFileSync(join(home, ".mosaic-delta", "other"), "x");
const before = snapshot(home);
for (const name of ["gamma", "worktrees", "delta"]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 3, r.err);
assert.match(r.err, new RegExp(`instance-root-unclaimed: ${join(home, `.mosaic-${name}`)} exists, is not empty and holds no instance.json naming ${name}; nothing was written`));
assert.equal(existsSync(join(home, ".config", `mosaic-${name}`)), false);
}
assert.deepEqual(snapshot(home), before);
// A root that is a file refuses the same way.
writeFileSync(join(home, ".mosaic-file"), "x");
const r = bootstrap(home, "--instance", "file");
assert.equal(r.code, 3);
assert.match(r.err, /instance-root-unclaimed: .* exists and is not a directory; nothing was written/);
assert.equal(existsSync(join(home, ".config", "mosaic-file")), false);
});
await t.test("an empty existing root is claimed, and an existing config whose root lost its marker refuses, not repairs", (t) => {
const home = tmp(t, "si-");
mkdirSync(join(home, ".mosaic-alpha"), { mode: 0o700 });
assert.equal(bootstrap(home, "--instance", "alpha").code, 0);
assert.equal(readFileSync(join(home, ".mosaic-alpha", "instance.json"), "utf8"), `{"instanceVersion":1,"name":"alpha"}\n`);
rmSync(join(home, ".mosaic-alpha", "instance.json"));
const before = snapshot(home);
const r = bootstrap(home, "--instance", "alpha");
assert.equal(r.code, 3);
assert.match(r.err, /instance-root-unclaimed: .*\.mosaic-alpha holds no instance\.json naming alpha/);
assert.deepEqual(snapshot(home), before);
});
await t.test("a dangling symlink at the config path is never written through, named or default", (t) => {
const home = tmp(t, "si-");
for (const [name, args] of [["alpha", ["--instance", "alpha"]], ["dev", []]]) {
const dir = join(home, ".config", `mosaic-${name}`), target = join(home, `elsewhere-${name}.json`);
mkdirSync(dir, { recursive: true, mode: 0o700 });
symlinkSync(target, join(dir, "config.json"));
const r = bootstrap(home, ...args);
assert.equal(r.code, 2, `${name}: ${r.err}`);
assert.match(r.err, /configuration path must be a regular, non-symbolic-link file/);
assert.equal(existsSync(target), false, `${name}: the link's target was created`);
assert.deepEqual(readdirSync(dir), ["config.json"]);
assert.ok(lstatSync(join(dir, "config.json")).isSymbolicLink());
}
assert.deepEqual(readdirSync(home).sort(), [".config"]);
});
await t.test("bad names and stray arguments exit 4 and write nothing", (t) => {
const home = tmp(t, "si-");
for (const name of ["A", "a-b", "x", "a", "abcdefghijklmnopq", "../x", "1ab", "a.b", ""]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 4, `${name}: ${r.err}`);
assert.match(r.err, /instance-name-invalid/);
}
assert.equal(bootstrap(home, "--instance", "abcdefghijklmnop").code, 0, "16 characters pass");
rmSync(join(home, ".config"), { recursive: true });
rmSync(join(home, ".mosaic-abcdefghijklmnop"), { recursive: true });
for (const args of [["--instance"], ["--instance", "alpha", "extra"], ["extra"], ["--name", "alpha"]]) {
const r = bootstrap(home, ...args);
assert.equal(r.code, 4, `${args.join(" ")}: ${r.err}`);
assert.match(r.err, /usage: mosaic-config\.mjs bootstrap \[--instance NAME\]/);
}
assert.deepEqual(readdirSync(home), []);
});
await t.test("--instance dev writes exactly what no flag writes", (t) => {
const plain = tmp(t, "si-"), dev = tmp(t, "si-");
const a = bootstrap(plain), b = bootstrap(dev, "--instance", "dev");
assert.equal(a.code, 0, a.err);
assert.equal(b.code, 0, b.err);
assert.equal(b.err.replaceAll(dev, "HOME"), a.err.replaceAll(plain, "HOME"));
const shape = (home) => Object.fromEntries(Object.entries(snapshot(home)).map(([k, v]) => [k, { ...v, mtime: 0, sha: v.sha && createHash("sha256").update(readFileSync(join(home, k), "utf8").replaceAll(home, "HOME")).digest("hex") }]));
assert.deepEqual(shape(dev), shape(plain));
assert.deepEqual(Object.keys(snapshot(plain)), [".config", ".config/mosaic-dev", ".config/mosaic-dev/config.json"]);
});
});
test("T-I2 selection: the four rows of D1 through the instance op and through an entry point", async (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
const scratch = join(home, "scratch-config.json");
writeJson(scratch, { configVersion: 1, environment: "development", dataRoot: join(home, "scratch-data"), execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash" } });
await t.test("row 1: no flag and no MOSAIC_CONFIG is the default; a missing default config leaves the data root empty", () => {
const r = config(home, {}, "instance");
assert.equal(r.code, 0, r.err);
assert.deepEqual(exportsOf(r.out), {
MOSAIC_INSTANCE: "dev",
MOSAIC_INSTANCE_CONFIG: cfgOf(home, "dev"),
MOSAIC_INSTANCE_CONFIG_DIR: join(home, ".config", "mosaic-dev"),
MOSAIC_INSTANCE_DATA_ROOT: "",
MOSAIC_UNIT_SUFFIX: "",
});
assert.deepEqual(exportsOf(config(home, {}, "instance", "--instance", "dev").out), exportsOf(r.out));
});
await t.test("row 2: MOSAIC_CONFIG alone is that config with the default names", () => {
const r = config(home, { MOSAIC_CONFIG: scratch }, "instance");
assert.equal(r.code, 0, r.err);
assert.deepEqual(exportsOf(r.out), {
MOSAIC_INSTANCE: "dev",
MOSAIC_INSTANCE_CONFIG: scratch,
MOSAIC_INSTANCE_CONFIG_DIR: home,
MOSAIC_INSTANCE_DATA_ROOT: join(home, "scratch-data"),
MOSAIC_UNIT_SUFFIX: "",
});
});
await t.test("row 3: --instance NAME alone is NAME's config, root and suffix", () => {
for (const name of ["alpha", "beta"]) {
const r = config(home, {}, "instance", "--instance", name);
assert.equal(r.code, 0, r.err);
assert.deepEqual(exportsOf(r.out), {
MOSAIC_INSTANCE: name,
MOSAIC_INSTANCE_CONFIG: cfgOf(home, name),
MOSAIC_INSTANCE_CONFIG_DIR: join(home, ".config", `mosaic-${name}`),
MOSAIC_INSTANCE_DATA_ROOT: join(home, `.mosaic-${name}`),
MOSAIC_UNIT_SUFFIX: `-${name}`,
});
}
});
await t.test("row 4: both must name one file; a symlink to it agrees, any other file exits 4", () => {
const link = join(home, "alpha-link.json");
symlinkSync(cfgOf(home, "alpha"), link);
for (const given of [cfgOf(home, "alpha"), link]) {
const r = config(home, { MOSAIC_CONFIG: given }, "instance", "--instance", "alpha");
assert.equal(r.code, 0, r.err);
assert.equal(exportsOf(r.out).MOSAIC_INSTANCE_CONFIG, cfgOf(home, "alpha"));
}
for (const given of [cfgOf(home, "beta"), scratch, join(home, "missing.json")]) {
for (const op of ["instance", "validate", "env", "bootstrap"]) {
const r = config(home, { MOSAIC_CONFIG: given }, op, "--instance", "alpha");
assert.equal(r.code, 4, `${op} ${given}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, new RegExp(`instance-config-mismatch: --instance alpha names ${cfgOf(home, "alpha")}, but MOSAIC_CONFIG names ${given}`));
}
}
});
await t.test("a named instance with no config exits 3 with its own bootstrap hint", () => {
const r = config(home, {}, "instance", "--instance", "gamma");
assert.equal(r.code, 3);
assert.match(r.err, /configuration not found: .*mosaic-gamma\/config\.json \(run scripts\/bootstrap\.sh --instance gamma to create it\)/);
});
await t.test("an entry point takes --instance in any position, once, and refuses a mismatch before it prints", () => {
const first = run(BUS_SERVICE, ["--instance", "alpha", "render"], homeEnv(home));
assert.equal(first.code, 0, first.err);
assert.match(first.out, new RegExp(`^Environment=MOSAIC_CONFIG=${cfgOf(home, "alpha")}$`, "m"));
assert.equal(run(BUS_SERVICE, ["render", "--instance", "alpha"], homeEnv(home)).out, first.out);
assert.equal(run(BUS_SERVICE, ["render", "--instance", "alpha"], homeEnv(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") })).out, first.out);
const mismatch = run(BUS_SERVICE, ["--instance", "alpha", "render"], homeEnv(home, { MOSAIC_CONFIG: cfgOf(home, "beta") }));
assert.equal(mismatch.code, 4);
assert.equal(mismatch.out, "");
assert.match(mismatch.err, /instance-config-mismatch/);
for (const args of [["--instance", "alpha", "render", "--instance", "alpha"], ["render", "--instance"]]) {
const r = run(BUS_SERVICE, args, homeEnv(home));
assert.equal(r.code, 4, args.join(" "));
assert.equal(r.out, "");
assert.match(r.err, /instance: usage: --instance NAME, given once/);
}
const bad = run(BUS_SERVICE, ["--instance", "a-b", "render"], homeEnv(home));
assert.equal(bad.code, 4);
assert.match(bad.err, /instance-name-invalid/);
});
await t.test("copy takes no --instance in either script: every instance shares the copies", () => {
const copies = join(home, "copies-probe");
const before = snapshot(home);
for (const [cmd, args, err] of [
[STACK_COPY, ["--instance", "alpha", "copy", "HEAD", "--copies", copies], /^usage: stack-copy\.sh copy COMMIT/],
[STACK_COPY, ["copy", "HEAD", "--copies", copies, "--instance", "alpha"], /^usage: stack-copy\.sh copy COMMIT/],
[WEBUI_SERVICE, ["--instance", "alpha", "copy", "HEAD", "--copies", copies], /^webui-service: copy takes no --instance; every instance shares the copies\n$/],
[WEBUI_SERVICE, ["copy", "HEAD", "--copies", copies, "--instance", "alpha"], /^webui-service: copy takes no --instance; every instance shares the copies\n$/],
]) {
const r = run(cmd, args, homeEnv(home));
assert.equal(r.code, 4, `${args.join(" ")}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, err, args.join(" "));
}
assert.deepEqual(snapshot(home), before);
});
});
test("T-I7 claims: the instance op refuses a root another instance claims, and a named root with no marker", async (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
const edit = (name, dataRoot) => writeJson(cfgOf(home, name), { ...JSON.parse(readFileSync(cfgOf(home, name), "utf8")), dataRoot });
const scratch = join(home, "scratch-config.json");
const scratchRoot = join(home, "scratch-data");
writeJson(scratch, { configVersion: 1, environment: "development", dataRoot: scratchRoot, execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash" } });
await t.test("alpha's config pointing at beta's root refuses instance-root-mismatch in every op", () => {
edit("alpha", join(home, ".mosaic-beta"));
for (const op of ["instance", "validate", "env", "bootstrap"]) {
const r = config(home, {}, op, "--instance", "alpha");
assert.equal(r.code, 3, `${op}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-beta")} is claimed by instance beta, not alpha`));
}
edit("alpha", join(home, ".mosaic-alpha"));
assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0);
});
await t.test("MOSAIC_CONFIG naming alpha's config with no flag refuses: the root is alpha's", () => {
for (const op of ["instance"]) {
const r = config(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") }, op);
assert.equal(r.code, 3, r.err);
assert.match(r.err, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-alpha")} is claimed by instance alpha; select it with --instance alpha`));
}
});
await t.test("a default config whose root carries alpha's marker refuses; one naming dev, or none, passes", () => {
mkdirSync(scratchRoot, { recursive: true });
assert.equal(config(home, { MOSAIC_CONFIG: scratch }, "instance").code, 0, "no marker passes as today");
writeFileSync(join(scratchRoot, "instance.json"), `{"instanceVersion":1,"name":"alpha"}\n`);
const r = config(home, { MOSAIC_CONFIG: scratch }, "instance");
assert.equal(r.code, 3);
assert.match(r.err, /claimed by instance alpha; select it with --instance alpha/);
writeFileSync(join(scratchRoot, "instance.json"), `{"instanceVersion":1,"name":"dev"}\n`);
assert.equal(config(home, { MOSAIC_CONFIG: scratch }, "instance").code, 0);
rmSync(join(scratchRoot, "instance.json"));
});
await t.test("a named root whose marker is missing, a symlink, unparsable or the wrong shape refuses instance-root-unclaimed", () => {
const marker = join(home, ".mosaic-alpha", "instance.json");
const good = readFileSync(marker, "utf8");
const cases = [
["missing", () => {}, /holds no instance\.json naming alpha/],
["a symlink", () => { writeFileSync(join(home, "real-marker.json"), good); symlinkSync(join(home, "real-marker.json"), marker); }, /must be a regular, non-symbolic-link file/],
["a directory", () => mkdirSync(marker), /must be a regular, non-symbolic-link file/],
["unparsable", () => writeFileSync(marker, "{"), /is not a valid marker/],
["an extra key", () => writeFileSync(marker, `{"instanceVersion":1,"name":"alpha","x":1}\n`), /is not a valid marker/],
["version 2", () => writeFileSync(marker, `{"instanceVersion":2,"name":"alpha"}\n`), /is not a valid marker/],
["a bad name", () => writeFileSync(marker, `{"instanceVersion":1,"name":"Alpha"}\n`), /is not a valid marker/],
];
for (const [name, change, why] of cases) {
rmSync(marker, { recursive: true, force: true });
change();
const r = config(home, {}, "instance", "--instance", "alpha");
assert.equal(r.code, 3, `${name}: ${r.err}`);
assert.match(r.err, /instance-root-unclaimed/, name);
assert.match(r.err, why, name);
rmSync(marker, { recursive: true, force: true });
writeFileSync(marker, good);
}
assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0);
});
});
async function until(check, ms) {
const end = Date.now() + ms;
while (!check()) {
if (Date.now() > end) throw new Error("timed out");
await new Promise((r) => setTimeout(r, 50));
}
}
// One named instance in `home`, ready for `bus start acme --no-launch`: bootstrap, a business file in
// its config dir, its own roles copy, the skills its roles name, and a notifier with no binding.
function hostInstance(home, root, name) {
assert.equal(bootstrap(home, "--instance", name).code, 0);
const cfg = cfgOf(home, name), dataRoot = join(home, `.mosaic-${name}`);
const files = join(root, `${name}-files`);
const roles = rolesCopy(files);
const doc = businessDoc(files, "acme");
for (const r of Object.values(doc.roles)) r.vars = { harness: "pi", model: "glm-5.3-flash", thinking: "high", ...r.vars };
writeJson(join(home, ".config", `mosaic-${name}`, "businesses", "acme.json"), { ...doc, plugins: { ...doc.plugins, harness: ["pi", "claude-code"] } });
cpSync(REPO_SKILLS, join(dataRoot, "skills-enabled"), { recursive: true });
notifyConfig(dataRoot, "acme", null);
return { name, cfg, dataRoot, env: homeEnv(home, { MOSAIC_CONFIG: cfg, MOSAIC_ROLES_DIR: roles }) };
}
test("T-I5 two real bus hosts, one per instance, run at once and each answers from its own socket under its own root", async (t) => {
const root = tmp(t, "si-");
const home = join(root, "h");
mkdirSync(home);
const both = ["alpha", "beta"].map((name) => hostInstance(home, root, name));
for (const inst of both) {
const sock = join(inst.dataRoot, "bus", "broker.sock");
// The kernel's limit for a Unix socket path is 107 bytes (the row 177 lesson).
assert.ok(Buffer.byteLength(sock) <= 107, `socket path is ${Buffer.byteLength(sock)} bytes: ${sock}`);
inst.sock = sock;
inst.child = spawn(MOSAIC, ["bus", "start", "acme", "--no-launch"], { env: inst.env, stdio: ["ignore", "pipe", "pipe"] });
t.after(() => inst.child.exitCode === null && inst.child.kill("SIGKILL"));
inst.out = "";
inst.child.stdout.on("data", (b) => (inst.out += b));
inst.child.stderr.on("data", (b) => (inst.out += b));
}
for (const inst of both) await until(() => /bus host up: business acme.*\n/.test(inst.out) || inst.child.exitCode !== null, 30000);
for (const inst of both) {
assert.match(inst.out, new RegExp(`bus host up: business acme, socket ${inst.sock}, notifier off, launches disabled\n`), inst.out);
assert.ok(lstatSync(inst.sock).isSocket());
}
for (const inst of both) {
const r = run(MOSAIC, ["bus", "status", "--json"], inst.env);
assert.equal(r.code, 0, r.err);
const s = JSON.parse(r.out);
assert.equal(s.host.business, "acme");
assert.equal(s.host.pid, inst.child.pid);
assert.equal(s.host.live, true);
assert.equal(s.socket, true);
// The broker process under the host holds the writer lock.
assert.equal(s.writerLock.live, true);
inst.lockPid = s.writerLock.pid;
}
assert.notEqual(both[0].child.pid, both[1].child.pid);
assert.notEqual(both[0].lockPid, both[1].lockPid);
// Stopping alpha leaves beta up.
const [alpha, beta] = both;
const exitA = once(alpha.child, "exit");
const stopA = run(MOSAIC, ["bus", "stop"], alpha.env);
assert.equal(stopA.code, 0, stopA.err);
assert.match(stopA.out, new RegExp(`stopped bus host for acme \\(pid ${alpha.child.pid}\\)`));
assert.equal((await exitA)[0], 0, alpha.out);
assert.equal(beta.child.exitCode, null);
const still = JSON.parse(run(MOSAIC, ["bus", "status", "--json"], beta.env).out);
assert.equal(still.host.pid, beta.child.pid);
assert.equal(still.writerLock.pid, beta.lockPid);
assert.equal(still.writerLock.live, true);
assert.equal(existsSync(join(alpha.dataRoot, "bus", "writer.lock")), false);
const exitB = once(beta.child, "exit");
assert.equal(run(MOSAIC, ["bus", "stop"], beta.env).code, 0);
assert.equal((await exitB)[0], 0, beta.out);
});
test("T-I10 the guard protects every instance's config dir and data roots, even under a corrupt config", (t) => {
const root = tmp(t, "si-");
const home = join(root, "h");
mkdirSync(home);
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
// alpha's root moved by its operator; beta's config is broken.
const moved = join(root, "alpha-data");
mkdirSync(moved);
writeJson(cfgOf(home, "alpha"), { ...JSON.parse(readFileSync(cfgOf(home, "alpha"), "utf8")), dataRoot: moved });
writeFileSync(cfgOf(home, "beta"), "{ not json");
for (const dir of ["mosaic.bak", "mosaic", "mosaic-a-b", "mosaic-Alpha", "mosaic-x"]) mkdirSync(join(home, ".config", dir));
const guard = new LiveSessionGuard({ fixtureRoot: root, homes: [home] });
const refuses = [
[join(home, ".config", "mosaic-alpha", "x"), "stack instance alpha's config dir"],
[join(home, ".mosaic-alpha", "x"), "stack instance alpha's default data root"],
[join(moved, "sessions", "x"), "stack instance alpha's configured data root"],
[join(home, ".config", "mosaic-beta", "x"), "stack instance beta's config dir"],
[join(home, ".mosaic-beta", "x"), "stack instance beta's default data root"],
[join(home, ".config"), "stack instance alpha's config dir"],
];
for (const [p, why] of refuses) {
assert.throws(() => guard.check({ workspace: p }, "bind"), (e) => e.message === `workspace overlaps ${why} (bind)`, p);
}
for (const dir of ["mosaic.bak", "mosaic", "mosaic-a-b", "mosaic-Alpha", "mosaic-x"]) {
assert.equal(guard.check({ workspace: join(home, ".config", dir, "x") }, "bind"), true, dir);
}
assert.equal(guard.check({ workspace: join(root, "work") }, "bind"), true);
});
+40 -229
View File
@@ -25,8 +25,7 @@ const script=(fn)=>`#!${NODE}\n(${fn})();\n`;
// systemctl --user: unit state in $SD_STATE/units.json, every call in calls.log. A bus start runs
// holder.cjs (the store held open in WAL mode, writer.lock, host.json) and, with --pm in the unit's
// ExecStart, writes the `launched` journal line and the launches entry unless a pm-<copy> fault
// names another case. Faults are files in $SD_STATE/faults named for the copy they hit. Units of a
// named instance (row 190) run on their own data root and host files beside the default's.
// names another case. Faults are files in $SD_STATE/faults named for the copy they hit.
function systemctl(){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process'),crypto=process.getBuiltinModule('node:crypto');
const sd=process.env.SD_STATE,data=process.env.SD_DATA,unitDir=path.join(process.env.XDG_CONFIG_HOME,'systemd','user');
@@ -38,16 +37,12 @@ function systemctl(){
const save=(s)=>{fs.writeFileSync(stFile+'.tmp',JSON.stringify(s));fs.renameSync(stFile+'.tmp',stFile)};
const sleep=(ms)=>Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,ms);
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,'utf8');return s[s.lastIndexOf(')')+2]!=='Z'}catch{return false}};
// Row 190: a bus unit is the default's mosaic-bus@B or instance NAME's mosaic-bus-NAME@B; %i is B.
const isBus=(u)=>/^mosaic-bus(-[a-z][a-z0-9]{1,15})?@[a-z0-9-]+\.service$/.test(u),inst=(u)=>u.slice(u.indexOf('@')+1,-'.service'.length);
const isBus=(u)=>/^mosaic-bus@[a-z0-9-]+\.service$/.test(u),inst=(u)=>u.slice('mosaic-bus@'.length,-'.service'.length);
const unitFile=(u)=>{
const main=isBus(u)?u.replace(/@[^@]*$/,'@.service'):u,file=path.join(unitDir,main);
const main=isBus(u)?'mosaic-bus@.service':u,file=path.join(unitDir,main);
if(!fs.existsSync(file))return null;
// The drop-in directories in systemd's order: service.d, the name cut after each hyphen, then
// the template's and the unit's own.
const stem=main.slice(0,main.search(/@?\.service$/)),dirs=['service.d'],drops=[];
for(let i=0;i<stem.length;i++)if(stem[i]==='-')dirs.push(stem.slice(0,i+1)+'.service.d');
for(const d of [...dirs,...(isBus(u)?[main+'.d',u+'.d']:[u+'.d'])]){const dir=path.join(unitDir,d);if(fs.existsSync(dir))for(const f of fs.readdirSync(dir).sort())if(f.endsWith('.conf'))drops.push(path.join(dir,f))}
const drops=[];
for(const d of isBus(u)?[main+'.d',u+'.d']:[u+'.d']){const dir=path.join(unitDir,d);if(fs.existsSync(dir))for(const f of fs.readdirSync(dir).sort())if(f.endsWith('.conf'))drops.push(path.join(dir,f))}
const r={wd:'',exec:'',env:[],drops};
for(const f of [file,...drops])for(let line of fs.readFileSync(f,'utf8').split('\n')){
line=line.replace(/%i/g,isBus(u)?inst(u):'').replace(/%h/g,process.env.HOME);
@@ -56,21 +51,17 @@ function systemctl(){
}
return r;
};
// A named instance's unit sets MOSAIC_CONFIG, and its bus works on that config's data root. A unit
// without it is the default's, on $SD_DATA. Each data root has its own host and launches files.
const dataOf=(f)=>{const c=f&&f.env.find((v)=>v.startsWith('MOSAIC_CONFIG='));return c?JSON.parse(fs.readFileSync(c.slice('MOSAIC_CONFIG='.length),'utf8')).dataRoot:data};
const perData=(d,n)=>path.join(sd,d===data?`${n}.json`:`${n}-${path.basename(d)}.json`);
const snap=(ev,d)=>{
const dir=path.join(d,'bus'),files={};
const snap=(ev)=>{
const dir=path.join(data,'bus'),files={};
try{for(const n of fs.readdirSync(dir).sort()){const p=path.join(dir,n),st=fs.statSync(p);if(st.isFile())files[n]=[st.size,st.mtimeMs,crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex')]}}catch{}
fs.appendFileSync(path.join(sd,'snap.jsonl'),JSON.stringify({ev,files})+'\n');
};
const pm=(unit,d,id,pid,off)=>{
const pm=(b,id,pid,off)=>{
let mode='';try{mode=fs.readFileSync(faultFile(`pm-${id}`),'utf8').trim()}catch{}
const b=inst(unit),now=Date.now()-off*1000,epoch=Math.floor(now/1000),at=new Date(now).toISOString(),run=`run-${pid}`;
const now=Date.now()-off*1000,epoch=Math.floor(now/1000),at=new Date(now).toISOString(),run=`run-${pid}`,unit=`mosaic-bus@${b}.service`;
const line=(text)=>fs.appendFileSync(path.join(sd,'journal.jsonl'),JSON.stringify({epoch,unit,text})+'\n');
const ok=()=>line(`launched pm-${b}: run ${run}, claude opus`);
const lf=perData(d,'launches');let list=[];try{list=JSON.parse(fs.readFileSync(lf,'utf8'))}catch{}
const lf=path.join(sd,'launches.json');let list=[];try{list=JSON.parse(fs.readFileSync(lf,'utf8'))}catch{}
const entry=(o)=>list.push({run,business:b,live:true,startedAt:at,...o});
line(`bus host up: business ${b}, socket /fixture/bus.sock, notifier off`);
switch(mode){
@@ -116,26 +107,26 @@ function systemctl(){
}
if(verb==='start'){
const u=rest[0],f=unitFile(u);if(!f){console.error(`Unit ${u} not found.`);process.exit(5)}
const s=load(),id=path.basename(f.wd),d=dataOf(f);
const s=load(),id=path.basename(f.wd);
if(!isBus(u)){s[u]={state:'active',pid:null,wd:f.wd};save(s);process.exit(0)}
if(fault(`start-dies-${id}`)){s[u]={state:'failed',pid:null,wd:f.wd};save(s);process.exit(0)}
// Row 185 (T48): the copy's start writes one event before the holder opens the store, so the
// store differs from a backup the run took before the start. The close leaves no -wal.
if(fault(`start-writes-row-${id}`)){
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(d,'bus','bus.sqlite'));
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(data,'bus','bus.sqlite'));
db.prepare('INSERT INTO events (at) VALUES (?)').run('2999-01-01T00:00:00.000Z');db.close();
}
snap('start',d);
snap('start');
const off=process.env.SD_CLOCK_OFFSET||'0';
// The holder must not inherit stack-release.sh's lock (fd 9) or any other fd above 2.
const close='for fd in /proc/self/fd/*; do n=${fd##*/}; [ "$n" -gt 2 ] && eval "exec $n>&-" 2>/dev/null; done; exec "$@"';
const ch=cp.spawn('bash',['-c',close,'holder',process.execPath,path.join(__dirname,'..','holder.cjs'),d,inst(u),sd,off,perData(d,'host')],{detached:true,stdio:'ignore'});
const ch=cp.spawn('bash',['-c',close,'holder',process.execPath,path.join(__dirname,'..','holder.cjs'),data,inst(u),sd,off],{detached:true,stdio:'ignore'});
ch.unref();
fs.appendFileSync(path.join(sd,'pids.log'),ch.pid+'\n');
const ready=path.join(sd,`ready-${ch.pid}`),end=Date.now()+10000;
while(!fs.existsSync(ready)){if(Date.now()>end){console.error('fixture holder did not start');process.exit(1)}sleep(20)}
s[u]={state:'active',pid:ch.pid,wd:f.wd};save(s);
if(f.exec.split(' ').slice(1).includes('--pm'))pm(u,d,id,ch.pid,Number(off));
if(f.exec.split(' ').slice(1).includes('--pm'))pm(inst(u),id,ch.pid,Number(off));
process.exit(0);
}
if(verb==='stop'){
@@ -147,18 +138,17 @@ function systemctl(){
}
if(st){s[u]={...st,state:'inactive',pid:null};save(s)}
if(isBus(u)){
const d=dataOf(unitFile(u));
// One-shot: the stop leaves an empty -wal or -shm beside the store, as an unclean close would.
for(const [name,ext] of [['stop-leaves-wal','-wal'],['stop-leaves-shm','-shm']])if(fault(name)){fs.rmSync(faultFile(name));fs.writeFileSync(path.join(d,'bus','bus.sqlite'+ext),'')}
for(const [name,ext] of [['stop-leaves-wal','-wal'],['stop-leaves-shm','-shm']])if(fault(name)){fs.rmSync(faultFile(name));fs.writeFileSync(path.join(data,'bus','bus.sqlite'+ext),'')}
// One-shot (T45): the stop writes one row after any STAMP into the table the fault names, as a
// write the bus made between rollback's count and its stop would. The close leaves no -wal.
if(fault('stop-writes-row')){
const t=fs.readFileSync(faultFile('stop-writes-row'),'utf8');fs.rmSync(faultFile('stop-writes-row'));
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(d,'bus','bus.sqlite'));
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(data,'bus','bus.sqlite'));
db.exec(`CREATE TABLE IF NOT EXISTS ${t} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);
db.prepare(`INSERT INTO ${t} (at) VALUES (?)`).run('2999-01-01T00:00:00.000Z');db.close();
}
snap('stop',d);
snap('stop');
}
process.exit(0);
}
@@ -188,11 +178,10 @@ function systemdAnalyze(){
if(fs.existsSync(extra))console.log(fs.readFileSync(extra,'utf8'));
}
// The bus host: holds the store open in WAL mode until SIGTERM, as the broker does. It writes its
// status to the host file systemctl names for its data root.
// The bus host: holds the store open in WAL mode until SIGTERM, as the broker does.
function holder(){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),{DatabaseSync}=process.getBuiltinModule('node:sqlite');
const [data,business,sd,offset,host]=process.argv.slice(2),bus=path.join(data,'bus');
const [data,business,sd,offset]=process.argv.slice(2),bus=path.join(data,'bus');
fs.mkdirSync(bus,{recursive:true,mode:0o700});
const db=new DatabaseSync(path.join(bus,'bus.sqlite'));
if(!db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE name='meta'").get().n){
@@ -202,7 +191,7 @@ function holder(){
db.prepare("INSERT INTO meta VALUES ('schema_version', '3g')").run();
}
fs.writeFileSync(path.join(bus,'writer.lock'),String(process.pid));
const at=new Date(Date.now()-Number(offset||0)*1000).toISOString();
const at=new Date(Date.now()-Number(offset||0)*1000).toISOString(),host=path.join(sd,'host.json');
fs.writeFileSync(host+'.tmp',JSON.stringify({host:{business,pid:process.pid,live:true,startedAt:at,notifier:'ok'},boot:{ok:true,business,at}}));
fs.renameSync(host+'.tmp',host);
process.on('SIGTERM',()=>{db.close();fs.rmSync(path.join(bus,'writer.lock'),{force:true});process.exit(0)});
@@ -210,24 +199,21 @@ function holder(){
setInterval(()=>{},1<<30);
}
// A copy's scripts/mosaic: bus status and launches list from what the holder and systemctl wrote
// for the data root of the config it runs under (row 190), the default's being $SD_DATA.
// A copy's scripts/mosaic: bus status and launches list from what the holder and systemctl wrote.
function mosaicStub(){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
const id=path.basename(path.resolve(__dirname,'..')),sd=process.env.SD_STATE,a=process.argv.slice(2).join(' ');
const fault=(n)=>fs.existsSync(path.join(sd,'faults',n));
let d=process.env.SD_DATA;try{d=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot}catch{}
const perData=(n)=>path.join(sd,d===process.env.SD_DATA?`${n}.json`:`${n}-${path.basename(d)}.json`);
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,'utf8');return s[s.lastIndexOf(')')+2]!=='Z'}catch{return false}};
if(a==='bus status --json'){
if(fault(`status-${id}`))process.exit(1);
let s={host:null,boot:null};
try{s=JSON.parse(fs.readFileSync(perData('host'),'utf8'));s.host.live=alive(s.host.pid)}catch{}
try{s=JSON.parse(fs.readFileSync(path.join(sd,'host.json'),'utf8'));s.host.live=alive(s.host.pid)}catch{}
console.log(JSON.stringify(s));
}else if(a==='bus status')console.log(`bus: fixture status for ${id}`);
else if(a==='launches list --json'){
if(fault(`launches-${id}`))process.exit(1);
let l=[];try{l=JSON.parse(fs.readFileSync(perData('launches'),'utf8'))}catch{}
let l=[];try{l=JSON.parse(fs.readFileSync(path.join(sd,'launches.json'),'utf8'))}catch{}
console.log(JSON.stringify(l));
}else{console.error(`fixture mosaic: ${a}`);process.exit(4)}
}
@@ -303,7 +289,7 @@ const posts=(e)=>readdirSync(e.data).filter((n)=>n.startsWith('bus.post-attempt-
const backupSum=(e,act)=>readFileSync(join(e.data,'backups','stack',act,'SHA256SUMS'),'utf8').split(' ')[0];
const commitLines=(range)=>git(repo,'log','--oneline','--no-merges','--max-count=200',range).split('\n').map((l)=>` ${l}`);
// The units, the wrapper, the pointer and the store, by sha256.
const fp=(e)=>({bus:sha(join(e.units,e.tmpl)),console:sha(join(e.units,e.con)),wrapper:sha(join(e.bin,e.wrapper)),pointer:existsSync(e.pointer)?sha(e.pointer):null,store:sha(e.store)});
const fp=(e)=>({bus:sha(join(e.units,'[email protected]')),console:sha(join(e.units,CON)),wrapper:sha(join(e.bin,'mosaic')),pointer:existsSync(e.pointer)?sha(e.pointer):null,store:sha(e.store)});
// The schema version: readOnly while the holder has the store open, immutable when it is stopped.
function storeVersion(file,held){
const db=held?new DatabaseSync(file,{readOnly:true}):new DatabaseSync(pathToFileURL(file).href+'?immutable=1',{readOnly:true});
@@ -331,8 +317,6 @@ function biz(e,plugins){
async function mk(t){
const root=realpathSync(mkdtempSync(join(tmpdir(),'sr-')));
const e={root,data:join(root,'data'),bin:join(root,'bin'),sd:join(root,'sd'),units:join(root,'xcfg','systemd','user'),cfg:join(root,'cfg','config.json'),claude:join(root,'claude-bin','claude')};
// The default instance's names; named() sets NAME's (row 190).
Object.assign(e,{flags:[],bus:BUS,tmpl:'[email protected]',con:CON,wrapper:'mosaic'});
Object.assign(e,{state:join(e.data,'state'),pointer:join(e.data,'state','stack-active.json'),log:join(e.data,'state','stack-activation-log.jsonl'),store:join(e.data,'bus','bus.sqlite'),lock:join(e.data,'state','stack-release.lock')});
for(const d of [join(root,'home'),dirname(e.cfg),join(e.data,'bus'),e.bin,dirname(e.claude),join(e.sd,'faults'),e.units,join(root,'xdata')])mkdirSync(d,{recursive:true});
chmodSync(join(e.data,'bus'),0o700);
@@ -342,47 +326,25 @@ async function mk(t){
writeFileSync(join(e.bin,'mosaic-old'),'#!/bin/sh\nexit 0\n',{mode:0o755});
writeFileSync(e.claude,'#!/bin/sh\necho "9.9.9 (Claude Code fixture)"\n',{mode:0o755});
e.env={PATH:`${SBIN}:${dirname(NODE)}:/usr/bin:/bin`,HOME:join(root,'home'),XDG_CONFIG_HOME:join(root,'xcfg'),XDG_DATA_HOME:join(root,'xdata'),MOSAIC_CONFIG:e.cfg,SD_STATE:e.sd,SD_DATA:e.data,TMPDIR:tmpdir()};
const server=await healthz(e);
t.after(()=>{
for(const pid of lines(join(e.sd,'pids.log')).map(Number)){
try{if(readFileSync(`/proc/${pid}/cmdline`,'utf8').includes(join(base,'holder.cjs')))process.kill(pid,'SIGKILL')}catch{}
}
for(const s of [server,...(e.servers??[])])s.close();
execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true});
});
return e;
}
// The Console's /healthz on e.port: 200 while the stand-in has e.con active and no healthz-<copy>
// fault names its copy.
async function healthz(e){
// The Console's /healthz: 200 while the stand-in has it active and no healthz-<copy> fault names its copy.
const server=createServer((q,r)=>{
let st={};try{st=units(e)[e.con]||{}}catch{}
let st={};try{st=units(e)[CON]||{}}catch{}
const ok=q.url==='/healthz'&&st.state==='active'&&!existsSync(join(e.sd,'faults',`healthz-${basename(st.wd||'')}`));
r.writeHead(ok?200:500);r.end();
});
server.listen(0,'127.0.0.1');await once(server,'listening');e.port=String(server.address().port);
return server;
}
// Row 190: stack instance NAME on e's host. It shares e's stand-ins, unit directory and $BIN, and has
// its own config and data root from bootstrap --instance, its own Console port and business acme.
// Its runs pass --instance NAME and no MOSAIC_CONFIG.
async function named(e,name){
const env={...e.env};delete env.MOSAIC_CONFIG;
execFileSync('bash',[join(repo,'scripts','bootstrap.sh'),'--instance',name],{env,stdio:'ignore'});
const data=join(env.HOME,`.mosaic-${name}`);
const n={...e,env,data,cfg:join(env.HOME,'.config',`mosaic-${name}`,'config.json'),flags:['--instance',name],bus:`mosaic-bus-${name}@acme.service`,tmpl:`mosaic-bus-${name}@.service`,con:`mosaic-webui-${name}.service`,wrapper:`mosaic-${name}`,name};
Object.assign(n,{state:join(data,'state'),pointer:join(data,'state','stack-active.json'),log:join(data,'state','stack-activation-log.jsonl'),store:join(data,'bus','bus.sqlite'),lock:join(data,'state','stack-release.lock')});
mkdirSync(join(data,'bus'),{mode:0o700});
biz(n);
(e.servers??=[]).push(await healthz(n));
return n;
t.after(()=>{
for(const pid of lines(join(e.sd,'pids.log')).map(Number)){
try{if(readFileSync(`/proc/${pid}/cmdline`,'utf8').includes(join(base,'holder.cjs')))process.kill(pid,'SIGKILL')}catch{}
}
server.close();execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true});
});
return e;
}
async function run(e,...args){
const before=existsSync(e.data)?kept(e):[];
const ch=spawn('bash',[e.sr??SR,...args,...e.flags,'--copies',COPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
const ch=spawn('bash',[e.sr??SR,...args,'--copies',COPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d});
const timer=setTimeout(()=>ch.kill('SIGKILL'),120000);
const [code]=await once(ch,'close');clearTimeout(timer);
@@ -402,12 +364,12 @@ async function seed(e,{copy=B,pm=false,store='3g',pointer:how='adopt'}={}){
for(let i=0;i<3;i++)db.prepare('INSERT INTO events (at) VALUES (?)').run(`2000-01-01T00:00:0${i}.000Z`);
db.close();
const sh=(name,...a)=>execFileSync('bash',[join(repo,'scripts',name),...a],{env:e.env,encoding:'utf8',stdio:['ignore','pipe','pipe']});
sh('bus-service.sh','install',...e.flags,'--copy',copy,'--copies',COPIES,...(pm?['--pm']:[]),'--claude',e.claude);
sh('webui-service.sh','install',...e.flags,'--copy',copy,'--copies',COPIES,'--port',e.port,'--business','acme');
sh('stack-copy.sh','wrapper',copy,...e.flags,'--copies',COPIES,'--bin',e.bin);
sh('bus-service.sh','install','--copy',copy,'--copies',COPIES,...(pm?['--pm']:[]),'--claude',e.claude);
sh('webui-service.sh','install','--copy',copy,'--copies',COPIES,'--port',e.port,'--business','acme');
sh('stack-copy.sh','wrapper',copy,'--copies',COPIES,'--bin',e.bin);
// The seeded host started two minutes ago, so its boot status and PM run predate any run's start.
execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'120'}});
sysctl(e,'start',e.con);
execFileSync(join(SBIN,'systemctl'),['--user','start',BUS],{env:{...e.env,SD_CLOCK_OFFSET:'120'}});
sysctl(e,'start',CON);
if(how==='adopt'){const r=await run(e,'adopt','acme');assert.equal(r.code,0,r.err)}
else if(how==='hand'){
mkdirSync(e.state,{recursive:true});
@@ -2229,154 +2191,3 @@ test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops t
assert.equal(existsSync(`/proc/${pid}`),false,`${sig}: host pid ${pid} is still there`);
}
});
// --- Row 190 (#1688): stack instances on one host. Design and the T-I table:
// agents/darkwing/work/queue-190/DESIGN.md. T-I1, T-I2, T-I5, T-I10 and the loader half of T-I7 are in
// stack-instances.test.mjs; these need the units, the stand-ins and stack-release.sh. Every existing
// test above is D1's "no flag, no marker" case and passes unchanged.
const shOut=(env,name,...a)=>spawnSync('bash',[join(repo,'scripts',name),...a],{env,encoding:'utf8'});
// The Console template's comment on the variables its unit sets, as the default and a named instance render it.
const CONSOLE_VARS={
default:'# and 94). The unit sets no MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.',
named:'# and 94). The unit sets MOSAIC_CONFIG and no other MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.',
};
const SHARED_DROPINS=['mosaic-bus-.service.d','mosaic-webui-.service.d','mosaic-discord-.service.d'];
// Every path under each of ps with its mode, size, mtime and, for a file, its sha256.
function stamp(...ps){
const out=[];
const walk=(q)=>{let st;try{st=lstatSync(q)}catch{out.push(`${q} absent`);return}
out.push(`${q} ${st.mode.toString(8)} ${st.size} ${st.mtimeMs}${st.isFile()?' '+sha(q):''}`);
if(st.isDirectory())for(const n of readdirSync(q).sort())walk(join(q,n))};
for(const p of ps)walk(p);return out;
}
const dirsUnder=(p)=>stamp(p).filter((l)=>!l.endsWith(' absent')).map((l)=>l.split(' ')[0]).filter((q)=>lstatSync(q).isDirectory());
// The T53 inotify watch on a directory: it counts each open or read of the directory or a file in it.
async function watchDir(e,d){
const ch=spawn('python3',['-I','-c',WATCH,d],{stdio:['pipe','pipe','inherit']});
let out='';ch.stdout.on('data',(b)=>{out+=b});
await new Promise((res,rej)=>{ch.stdout.on('data',()=>{if(out.startsWith('ready\n'))res()});ch.on('error',rej);ch.on('close',(c)=>rej(new Error(`inotify watch exited ${c}`)))});
WATCHERS.add(ch);ch.on('close',()=>WATCHERS.delete(ch));
(e.watches??=[]).push(async()=>{ch.stdin.end();if(ch.exitCode===null)await once(ch,'close');return [d,Number(out.split('\n')[1])]});
}
test('T56 row 190: named instances side by side on one unit directory, $BIN and set of stand-ins',async(t)=>{
await t.test('T-I3 each instance renders its own config, session env and Description, and nothing of another',async(t)=>{
const e=await mk(t),a=await named(e,'alpha'),b=await named(e,'beta');
for(const [x,y] of [[a,b],[b,a]]){
const renders={
bus:shOut(x.env,'bus-service.sh','render','--instance',x.name,'--copy',B,'--copies',COPIES,'--claude',e.claude),
console:shOut(x.env,'webui-service.sh','render','--instance',x.name,'--copy',B,'--copies',COPIES,'--port',x.port,'--business','acme'),
discord:shOut(x.env,'discord-service.sh','render','--instance',x.name),
};
for(const [k,r] of Object.entries(renders)){
assert.equal(r.status,0,`${x.name} ${k}: ${r.stderr}`);
assert.ok(r.stdout.includes(`\nEnvironment=MOSAIC_CONFIG=${x.cfg}\n`),`${x.name} ${k}`);
assert.match(r.stdout,new RegExp(`\\nDescription=[^\\n]* \\[${x.name}\\]\\n`),`${x.name} ${k}`);
for(const not of [y.name,'mosaic-dev'])assert.ok(!r.stdout.includes(not),`${x.name} ${k} holds ${not}`);
}
assert.ok(renders.bus.stdout.includes(`\nEnvironmentFile=-${dirname(x.cfg)}/secrets/claude-session.env\n`),x.name);
assert.ok(renders.console.stdout.includes(`\n${CONSOLE_VARS.named}\n`),x.name);
assert.ok(!renders.console.stdout.includes('sets no MOSAIC_'),x.name);
assert.equal(renders.bus.stdout.match(/^Environment(File)?=.*$/gm).filter((l)=>l.includes('MOSAIC_CONFIG')||l.startsWith('EnvironmentFile')).length,2,x.name);
}
// Sage's N3 ruling: the default Console keeps the template's comment byte for byte.
const dflt=shOut(e.env,'webui-service.sh','render','--copy',B,'--copies',COPIES,'--business','acme');
assert.equal(dflt.status,0,dflt.stderr);
assert.ok(dflt.stdout.includes(`\n${CONSOLE_VARS.default}\n`),dflt.stdout);
assert.ok(!dflt.stdout.includes('MOSAIC_CONFIG'),dflt.stdout);
await seed(a);await seed(b);
for(const x of [a,b]){
const r=shOut(x.env,'discord-service.sh','install','--instance',x.name,'--dir',e.units,'--no-reload');assert.equal(r.status,0,r.stderr);
for(const u of [x.tmpl,x.con,`mosaic-discord-${x.name}@.service`])assert.ok(existsSync(join(e.units,u)),u);
}
assert.deepEqual(readdirSync(e.units).sort(),[a.tmpl,a.con,`[email protected]`,b.tmpl,b.con,`[email protected]`].sort());
for(const x of [a,b])assert.equal(readFileSync(join(e.bin,x.wrapper),'utf8').split('\n')[1],`# mosaic-cli wrapper, instance ${x.name} (row 190)`);
});
await t.test('T-I4 a Console port another installed Console takes refuses port-claimed, and a named instance keeps off the default ports',async(t)=>{
const e=await mk(t),a=await named(e,'alpha'),b=await named(e,'beta');await seed(a);
const held=`port ${a.port} is taken by ${join(e.units,a.con)}`,free=await freePort();
const install=(x,...flags)=>shOut(x.env,'webui-service.sh','install',...x.flags,'--copy',B,'--copies',COPIES,...flags,'--business','acme');
const unitsBefore=stamp(e.units),binBefore=stamp(e.bin);
for(const [label,x,flags,code,msg] of [
['beta on alpha\'s port',b,['--port',a.port],1,`webui-service: refusing port-claimed: ${held}; nothing was changed\n`],
['beta\'s tester port on alpha\'s port',b,['--port',free,'--tester-port',a.port],1,`webui-service: refusing port-claimed: ${held}; nothing was changed\n`],
['the default on alpha\'s port',e,['--port',a.port],1,`webui-service: refusing port-claimed: ${held}; nothing was changed\n`],
['beta with no --port',b,[],4,'webui-service: stack instance beta needs --port; it has no default port\n'],
...['7330','7332','7333'].map((p)=>[`beta on ${p}`,b,['--port',p],4,`webui-service: port-reserved: ${p} is reserved for the default instance (7330 7332 7333)\n`]),
['beta\'s tester port on 7333',b,['--port',free,'--tester-port','7333'],4,'webui-service: port-reserved: 7333 is reserved for the default instance (7330 7332 7333)\n'],
]){
const r=install(x,...flags);assert.equal(r.status,code,`${label}: ${r.stderr}`);assert.equal(r.stderr,msg,label);
assert.deepEqual(stamp(e.units),unitsBefore,label);assert.deepEqual(stamp(e.bin),binBefore,label);
}
// beta on its own port passes: the seed installs both its units and adopts.
await seed(b);assert.ok(existsSync(join(e.units,b.con)));
// stack-release: a target Console port another Console takes refuses before anything stops.
const refused=async(label,...args)=>{
const before=fp(b),n=calls(e).length,logs=logLines(b).length;
const r=await run(b,'activate',C1,'acme',...args);assert.equal(r.code,3,`${label}: ${r.err}`);
assert.equal(r.err,`stack-release: refusing port-claimed: ${held}\n`,label);
assert.deepEqual(fp(b),before,label);assert.deepEqual(startsStops(e,n),[],label);
assert.equal(logLines(b).length,logs+1,label);assert.deepEqual([logLines(b).at(-1).result,logLines(b).at(-1).code],['refused','port-claimed'],label);
};
await refused('--tester-port','--tester-port',a.port);
const p=pointer(b);p.console[1]=a.port;writeFileSync(b.pointer,JSON.stringify(p)+'\n');
await refused('the pointer\'s --port');
const plan=await run(b,'activate',C1,'acme','--plan');assert.equal(plan.code,3,plan.err);
assert.ok(plan.out.split('\n').includes(`refuses port-claimed: ${held}`),plan.out);
});
// T-I6: activate and rollback on x leave y's every file, directory and the shared prefix drop-in
// directories as they were, and open nothing of y's config dir or data root.
const noCrossReach=async(t,xn,yn)=>{
const e=await mk(t),x=await named(e,xn),y=await named(e,yn);
for(const d of [...SHARED_DROPINS,`${x.tmpl}.d`,`${x.con}.d`,`${y.tmpl}.d`,`${y.con}.d`]){mkdirSync(join(e.units,d));writeFileSync(join(e.units,d,'t-i6.conf'),`# row 190 T-I6: ${d}\n`)}
await seed(x);await seed(y);
const mine=()=>stamp(dirname(y.cfg),y.data,...[y.tmpl,y.con,`${y.tmpl}.d`,`${y.con}.d`,...SHARED_DROPINS].map((u)=>join(e.units,u)),join(e.bin,y.wrapper),join(e.bin,'mosaic'));
const before=mine(),n=calls(e).length;
const watched=[...dirsUnder(dirname(y.cfg)),...dirsUnder(y.data)];
for(const d of [dirname(y.cfg),y.data,join(y.data,'bus'),y.state])assert.ok(watched.includes(d),d);
for(const d of watched)await watchDir(e,d);
const act=await run(x,'activate',C1,'acme');assert.equal(act.code,0,act.out+act.err);
const back=await run(x,'rollback','acme');assert.equal(back.code,0,back.out+back.err);
assert.deepEqual((await Promise.all(e.watches.map((s)=>s()))).filter(([,c])=>c!==0),[],`${xn}'s runs opened ${yn}'s files`);
assert.deepEqual(mine(),before);
assert.deepEqual(calls(e).slice(n).filter((c)=>c.includes(yn)),[]);
const sets=readdirSync(join(x.state,'stack-rollback')).sort();assert.equal(sets.length,2,sets.join(' '));
for(const s of sets){
const rs=join(x.state,'stack-rollback',s);
assert.deepEqual(readdirSync(rs).sort(),[x.tmpl,`${x.tmpl}.d`,x.con,`${x.con}.d`,'wrapper','WRAPPER_MODE',`show-${x.bus}.txt`,`show-${x.con}.txt`,'SHA256SUMS','steps.log'].sort(),s);
assert.equal(readFileSync(join(rs,'wrapper'),'utf8').split('\n')[1],`# mosaic-cli wrapper, instance ${xn} (row 190)`);
}
assert.equal(pointer(x).copy,B);assert.equal(pointer(y).copy,B);
};
await t.test('T-I6 alpha\'s activate and rollback reach nothing of beta\'s',(t)=>noCrossReach(t,'alpha','beta'));
await t.test('T-I6 swapped: beta\'s reach nothing of alpha\'s',(t)=>noCrossReach(t,'beta','alpha'));
await t.test('T-I7 MOSAIC_CONFIG naming alpha\'s config with no flag refuses instance-root-mismatch and changes nothing',async(t)=>{
const e=await mk(t),a=await named(e,'alpha');await seed(a);
const x={...a,env:{...a.env,MOSAIC_CONFIG:a.cfg},flags:[]};
const before=fp(a),n=calls(e).length,logs=logLines(a).length,unitsBefore=stamp(e.units),binBefore=stamp(e.bin);
const r=await run(x,'activate',C1,'acme');assert.equal(r.code,3,r.err);assert.match(r.err,/instance-root-mismatch/);assert.equal(r.out,'');
const i=shOut(x.env,'bus-service.sh','install','--copy',C1,'--copies',COPIES,'--claude',e.claude);
assert.equal(i.status,3,i.stderr);assert.match(i.stderr,/instance-root-mismatch/);
assert.deepEqual(calls(e).slice(n),[]);assert.deepEqual(fp(a),before);assert.equal(logLines(a).length,logs);
assert.deepEqual(stamp(e.units),unitsBefore);assert.deepEqual(stamp(e.bin),binBefore);
});
await t.test('T-I9 another instance\'s bus active refuses nothing; one of this instance\'s does',async(t)=>{
const e=await mk(t);await seed(e);
const a=await named(e,'alpha'),b=await named(e,'beta');await seed(a);await seed(b);
for(const x of [e,b]){const r=await run(x,'activate',C1,'acme');assert.equal(r.code,0,`${x.name??'default'}: ${r.out+r.err}`)}
const setUnit=(u,state)=>{const s=units(e);if(state)s[u]={...(s[u]||{pid:null,wd:''}),state};else delete s[u];writeFileSync(join(e.sd,'units.json'),JSON.stringify(s))};
for(const [u,state,msg] of [['[email protected]','active','[email protected] is active; the copy and wrapper switch reach it too'],[a.bus,'activating',`${a.bus} is activating`]]){
const was=units(e)[u];setUnit(u,state);
const before=fp(a),n=calls(e).length;
const r=await run(a,'activate',C1,'acme');assert.equal(r.code,3,r.err);
assert.equal(r.err,`stack-release: refusing instance-busy: ${msg}\n`);assert.deepEqual(fp(a),before);assert.deepEqual(startsStops(e,n),[]);
setUnit(u,was&&was.state);if(was){const s=units(e);s[u]=was;writeFileSync(join(e.sd,'units.json'),JSON.stringify(s))}
}
const r=await run(a,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
});
});
+4 -28
View File
@@ -4,9 +4,8 @@
// every session path are constructor arguments, with no default. At
// construction and again at bind, their real paths must lie inside the
// explicit fixture root and outside every protected location: the
// repository's .pi/state/, ~/.pi, ~/.claude, the configured data root, every
// stack instance's config dir and data roots (row 190) and any path a seat
// registration names. A path is refused when it is inside a
// repository's .pi/state/, ~/.pi, ~/.claude, the configured data root and any
// path a seat registration names. A path is refused when it is inside a
// protected location or contains one. The real protections always apply;
// options only add to them, so a test can't switch them off.
//
@@ -45,9 +44,9 @@ export function realPath(p) {
const inside = (child, parent) => child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
const overlaps = (a, b) => inside(a, b) || inside(b, a);
function configuredDataRoot(home, name = "dev") {
function configuredDataRoot(home) {
try {
const cfg = JSON.parse(readFileSync(join(home, ".config", `mosaic-${name}`, "config.json"), "utf8"));
const cfg = JSON.parse(readFileSync(join(home, ".config", "mosaic-dev", "config.json"), "utf8"));
if (typeof cfg?.dataRoot === "string" && cfg.dataRoot) return cfg.dataRoot.replace(/^~(?=$|\/)/, home);
} catch {
// An unreadable config adds no location; the default data root still applies.
@@ -55,21 +54,6 @@ function configuredDataRoot(home, name = "dev") {
return null;
}
// Row 190: the stack instances in `home`, one per ~/.config/mosaic-NAME whose
// NAME passes the instance name rule (scripts/mosaic-config.mjs), so
// ~/.config/mosaic.bak adds nothing. Only the names are read here.
const INSTANCE_NAME = /^[a-z][a-z0-9]{1,15}$/;
function instanceNames(home) {
try {
return readdirSync(join(home, ".config"))
.filter((entry) => entry.startsWith("mosaic-") && INSTANCE_NAME.test(entry.slice("mosaic-".length)))
.map((entry) => entry.slice("mosaic-".length))
.sort();
} catch {
return [];
}
}
// Registrations under <dataRoot>/seats/<layout>/<seat>/registration.json. An
// unreadable one adds nothing; the data root itself is protected anyway.
function registrationsUnder(dataRoot) {
@@ -127,14 +111,6 @@ export class LiveSessionGuard {
protectedPaths.push({ path: join(h, ".mosaic-dev"), why: "the default data root" });
const configured = configuredDataRoot(h);
if (configured) protectedPaths.push({ path: configured, why: "the configured data root" });
// Each instance's config dir and ~/.mosaic-NAME whatever its config
// says, so a broken config can't drop them, then its config's dataRoot.
for (const name of instanceNames(h)) {
protectedPaths.push({ path: join(h, ".config", `mosaic-${name}`), why: `stack instance ${name}'s config dir` });
protectedPaths.push({ path: join(h, `.mosaic-${name}`), why: `stack instance ${name}'s default data root` });
const root = configuredDataRoot(h, name);
if (root) protectedPaths.push({ path: root, why: `stack instance ${name}'s configured data root` });
}
}
for (const d of dataRoots) protectedPaths.push({ path: d, why: "the data root" });
const roots = protectedPaths.filter((p) => p.why.includes("data root")).map((p) => p.path);
-6
View File
@@ -133,12 +133,6 @@ the copy, and retains the unit's restart, brake and stop behavior. Without
pinning. Status reads effective WorkingDirectory, including drop-ins, and
prints the tree and copy id or checkout path.
`--instance NAME` (row 190, any verb) renders `[email protected]`,
which sets `Environment=MOSAIC_CONFIG=` to NAME's config, and status reads
that config. Uninstall's active check covers only `mosaic-discord-NAME@*`.
Without the flag the unit is as before. See
[TOOLS.md, Stack instances](../../docs/TOOLS.md#stack-instances---instance-name-row-190).
Every live step requires Mos's go after the SetSpark freeze lifts. Re-pin:
make the new pushed copy with stack-copy.sh; run
`node <copy>/packages/discord/src/cli.mjs check <binding> --repo <copy> --offline`;
@@ -14,7 +14,6 @@ StartLimitBurst=5
Type=simple
WorkingDirectory=@REPO@
Environment=PATH=@PATH@
@CONFIG_ENV@
# A supervised run first clears a lock left by a crash or a reboot, and
# refuses with exit 3 while STOP is present or the binding is held. Exit 3
# is never retried: the brake belongs to the operator. This happens inside
+1 -2
View File
@@ -14,8 +14,7 @@ test('pinned Discord render/install and missing prerequisites; checkout bytes un
for(const line of r.stdout.split('\n').filter(l=>/^(ExecStart|WorkingDirectory)=/.test(l)))assert.ok(!line.includes(ROOT),line);
const legacy=s.run(['render']);assert.equal(legacy.status,0,legacy.stderr);
const nodeDir=dirname(process.execPath),originalPath='/usr/local/bin:/usr/bin:/bin',path=originalPath.split(':').includes(nodeDir)?originalPath:nodeDir+':'+originalPath;
// Row 190: the default instance deletes the @CONFIG_ENV@ line.
const expected=readFileSync(join(ROOT,'packages/discord/systemd/[email protected]'),'utf8').replace('@CONFIG_ENV@\n','').replaceAll('@REPO@',ROOT).replaceAll('@PATH@',path);assert.equal(legacy.stdout,expected);
const expected=readFileSync(join(ROOT,'packages/discord/systemd/[email protected]'),'utf8').replaceAll('@REPO@',ROOT).replaceAll('@PATH@',path);assert.equal(legacy.stdout,expected);
assert.equal(r.stdout.split('\n').filter(l=>!/^(ExecStart|WorkingDirectory)=/.test(l)).join('\n'),legacy.stdout.split('\n').filter(l=>!/^(ExecStart|WorkingDirectory)=/.test(l)).join('\n'));
const installed=s.run(['install',...args,'--dir',s.units,'--no-reload']);assert.equal(installed.status,0,installed.stderr);assert.equal(readFileSync(join(s.units,'[email protected]'),'utf8'),r.stdout);
for(const id of ['','short','b'.repeat(40)])assert.equal(s.run(['install','--copy',id,'--copies',s.copies,'--dir',join(s.root,'missing'),'--no-reload']).status,1);
-10
View File
@@ -122,16 +122,6 @@ allow-user must be the 64-character Authentik uid (decision 101). Status
names the copy and refactor drift. Files and directories are read-only;
copies are retained. Existing Console-only copies are not migrated or pruned.
With `--instance NAME` (row 190) every verb works on
`mosaic-webui-NAME.service`, which sets NAME's `MOSAIC_CONFIG`; install
checks NAME's `mosaic-bus-NAME@` units for the copy. render and install then
need `--port`, and 7330, 7332 or 7333 as `--port` or `--tester-port` exits 4
with port-reserved. For every instance, install refuses a `--port` or
`--tester-port` that another installed Console unit in the unit directory
already takes. That refusal is port-claimed, exit 1, and writes nothing. A
unit with no `--port` holds 7332. See
[TOOLS.md, Stack instances](../../docs/TOOLS.md#stack-instances---instance-name-row-190).
The single update and rollback sequence is
[TOOLS.md, Pinned stack update](../../docs/TOOLS.md#pinned-stack-update).
A bus restart ends managed sessions, so no PM turn may be in flight.
@@ -23,7 +23,6 @@ StartLimitBurst=5
Type=simple
WorkingDirectory=@REPO@
Environment=PATH=@PATH@
@CONFIG_ENV@
# The server binds 127.0.0.1 only. Usage, config and listen refusals
# (a port already in use included) exit 2 and are not retried; fix the
# flags or free the port, then start it again.
+2 -4
View File
@@ -146,10 +146,8 @@ test('copy refuses a commit off refactor, an unknown one, a worktree and a bad c
test('copy writes nothing when an archived path is missing at the commit', async (t) => {
const { dir, repo, script } = fixtureRepo(t);
const copies = join(dir, 'copies');
// Row 190: the entry point runs scripts/mosaic-config.mjs to select the
// instance before it copies, so the missing path is one only the copy reads.
git(repo, 'rm', '-q', 'packages/webui/README.md');
git(repo, 'commit', '-q', '-m', 'drop the Console README');
git(repo, 'rm', '-q', 'scripts/mosaic-config.mjs');
git(repo, 'commit', '-q', '-m', 'drop the config check');
git(repo,'update-ref','refs/remotes/origin/refactor',git(repo,'rev-parse','HEAD'));
const r = await run(dir, script, ['copy', 'refactor', '--copies', copies]);
assert.equal(r.status, 1);
+2 -3
View File
@@ -35,12 +35,11 @@ const TEMPLATE = readFileSync(join(REPO, 'packages', 'webui', 'systemd', 'mosaic
// The unit as the base script renders it (row 75): the template with its four
// placeholders filled in, nothing else. Row 83 adds --send and must leave
// every unit rendered without it byte for byte as this. Row 190: the
// default instance deletes the @CONFIG_ENV@ line, so the unit is unchanged.
// every unit rendered without it byte for byte as this.
function filled(flags, root = REPO) {
const base = '/usr/local/bin:/usr/bin:/bin';
const path = `:${base}:`.includes(`:${NODE_DIR}:`) ? base : `${NODE_DIR}:${base}`;
return TEMPLATE.replace('@CONFIG_ENV@\n', '').replaceAll('@REPO@', root).replaceAll('@PATH@', path).replaceAll('@NODE@', process.execPath).replaceAll('@FLAGS@', flags);
return TEMPLATE.replaceAll('@REPO@', root).replaceAll('@PATH@', path).replaceAll('@NODE@', process.execPath).replaceAll('@FLAGS@', flags);
}
// A loopback port with nothing listening, so status's /healthz request
+1 -3
View File
@@ -3,11 +3,9 @@
#
# Creates ~/.config/mosaic-dev/config.json (or $MOSAIC_CONFIG) only when
# absent. An existing configuration is validated, never modified.
# `--instance NAME` (row 190) creates ~/.config/mosaic-NAME/config.json
# instead, with the data root ~/.mosaic-NAME claimed by its instance.json.
# Normal run paths (build/hello/verify) deliberately do NOT auto-bootstrap:
# missing configuration is an error there, not something to invent.
set -euo pipefail
cd "$(dirname "$0")/.."
exec node scripts/mosaic-config.mjs bootstrap "$@"
exec node scripts/mosaic-config.mjs bootstrap
+14 -31
View File
@@ -19,24 +19,12 @@
# (MOSAIC_CLAUDE_BIN); without it, `command -v claude` here. The path is
# kept as given, so a symlink that tracks updates keeps tracking them.
#
# --instance NAME (row 190, any position) selects stack instance NAME: the
# unit is [email protected], it sets MOSAIC_CONFIG to
# ~/.config/mosaic-NAME/config.json, and its session env file sits in that
# config directory. Busy and drop-in checks then cover only NAME's bus units
# and the prefix directories systemd applies to them. See scripts/instance.sh.
#
# Exit codes: 0 ok, 1 operation failed, 3 the data root is claimed by another
# instance or not claimed (scripts/instance.sh), 4 usage.
# Exit codes: 0 ok, 1 operation failed, 4 usage.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/cli/systemd/mosaic-bus.service.in"
# shellcheck source=scripts/instance.sh
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
instance_select
UNIT_NAME="$BUS_TEMPLATE"
USAGE="usage: scripts/bus-service.sh [--instance NAME] render [--copy ID [--copies DIR]] [--pm] [--claude PATH] | install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH] | uninstall [--dir DIR] [--no-reload] | status <business>"
UNIT_NAME="[email protected]"
USAGE="usage: scripts/bus-service.sh render [--copy ID [--copies DIR]] [--pm] [--claude PATH] | install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH] | uninstall [--dir DIR] [--no-reload] | status <business>"
die() { echo "bus-service: $*" >&2; exit 1; }
usage() { echo "$USAGE" >&2; exit 4; }
@@ -88,7 +76,7 @@ render() {
case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac
local claude
claude="$(claude_line)" || exit 1
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@PM_FLAGS@|$pm_flags|g" -e "s|^@CLAUDE_ENV@$|$claude|" "${INSTANCE_SED[@]}" "$TEMPLATE"
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@PM_FLAGS@|$pm_flags|g" -e "s|^@CLAUDE_ENV@$|$claude|" "$TEMPLATE"
}
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
@@ -111,10 +99,9 @@ parse_flags() {
# Any failed read refuses before a write, including --no-reload installations.
install_guard() {
local out unit file
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || die "refusing: systemctl --user list-units failed, so the bus state is unknown; nothing was changed"
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl --user list-units failed, so the bus state is unknown; nothing was changed"
while read -r unit load state rest; do
# shellcheck disable=SC2053
[[ "$unit" == $BUS_GLOB ]] || continue
[[ "$unit" == mosaic-bus@*.service ]] || continue
case "$state" in inactive|failed) ;; *) die "refusing: $unit is running ($state); Jason or Mos stops it first, then install, then start it" ;; esac
done <<< "$out"
# systemctl show rejects a template name. The lookup-path scan also
@@ -127,12 +114,8 @@ install_guard() {
case "$directory" in /*) ;; *) die "refusing: invalid systemd unit path $directory; nothing was changed" ;; esac
[ -e "$directory" ] || [ -L "$directory" ] || continue
[ -d "$directory" ] && [ -r "$directory" ] && [ -x "$directory" ] || die "refusing: cannot inspect unit path $directory; nothing was changed"
# * includes the empty instance, i.e. [email protected] (or
# mosaic-bus-NAME@.service.d). The globs come from scripts/instance.sh.
local glob dropdirs=()
# shellcheck disable=SC2206
for glob in "${BUS_DROPIN_GLOBS[@]}"; do dropdirs+=("$directory"/$glob); done
for dropdir in "${dropdirs[@]}"; do
# * includes the empty instance, i.e. [email protected].
for dropdir in "$directory"/mosaic-bus@*.service.d "$directory"/mosaic-.service.d "$directory"/service.d; do
[ -e "$dropdir" ] || [ -L "$dropdir" ] || continue
[ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || die "refusing: cannot inspect drop-ins $dropdir; nothing was changed"
for file in "$dropdir"/*.conf; do
@@ -178,10 +161,10 @@ next, for one business (one per data root):
mkdir -m 0700 -p <dataRoot>/notify/<business> the notifier refuses a looser directory
write <dataRoot>/notify/<business>/notify.json, mode 0600:
{"notifyVersion": 1, "binding": "<discord binding>"} or "binding": null for no DMs
systemctl --user enable --now ${BUS_TEMPLATE%.service}<business> start now and at login
systemctl --user status ${BUS_TEMPLATE%.service}<business>
journalctl --user -u ${BUS_TEMPLATE%.service}<business> -f the host's log
systemctl --user stop ${BUS_TEMPLATE%.service}<business> SIGTERM; restartable
systemctl --user enable --now mosaic-bus@<business> start now and at login
systemctl --user status mosaic-bus@<business>
journalctl --user -u mosaic-bus@<business> -f the host's log
systemctl --user stop mosaic-bus@<business> SIGTERM; restartable
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
MSG
}
@@ -191,7 +174,7 @@ uninstall_unit() {
[ "$CLAUDE_SET" = 0 ] && [ -z "$COPY_ID" ] && [ "$PM" = 0 ] || usage
local target="$UNIT_DIR/$UNIT_NAME"
local out active
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || die "refusing: systemctl read failed; nothing was changed"
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl read failed; nothing was changed"
active="$(awk '$3 != "inactive" && $3 != "failed" {print $1}' <<< "$out")"
[ -z "$active" ] || die "refusing: instance(s) still running: $active; Jason or Mos stops them first"
if [ -f "$target" ]; then
@@ -210,7 +193,7 @@ status_unit() {
[ $# -eq 1 ] || usage
local business="$1"
case "$business" in ''|*[!a-z0-9-]*) die "business id must be [a-z0-9-]+" ;; esac
local unit="${BUS_TEMPLATE%.service}$business.service"
local unit="mosaic-bus@$business.service"
echo "unit: $unit"
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)"
+10 -20
View File
@@ -14,21 +14,12 @@
# remove the unit; refuses while an instance is active
# status <binding> unit state, STOP and run.lock for one binding
#
# Every verb takes --instance NAME (row 190, scripts/instance.sh): the unit is
# then [email protected], it sets NAME's MOSAIC_CONFIG, and status
# reads NAME's config. Without it, everything is as before.
#
# Exit codes: 0 ok, 1 operation failed, 3 instance claim refused, 4 usage.
# Exit codes: 0 ok, 1 operation failed, 4 usage.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/discord/systemd/[email protected]"
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
instance_select
UNIT_NAME="$DISCORD_TEMPLATE"
UNIT_PREFIX="${DISCORD_TEMPLATE%.service}"
USAGE="usage: scripts/discord-service.sh [--instance NAME] render [--copy ID [--copies DIR]] | install [--copy ID [--copies DIR]] [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>"
UNIT_NAME="[email protected]"
USAGE="usage: scripts/discord-service.sh render [--copy ID [--copies DIR]] | install [--copy ID [--copies DIR]] [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>"
die() { echo "discord-service: $*" >&2; exit 1; }
usage() { echo "$USAGE" >&2; exit 4; }
@@ -57,7 +48,7 @@ render() {
case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac
case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac
local unit
unit="$(sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" "${INSTANCE_SED[@]}" "$TEMPLATE")" || return 1
unit="$(sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" "$TEMPLATE")" || return 1
if [ -n "$root" ]; then
local node
node="$(command -v node)"
@@ -110,10 +101,10 @@ install_unit() {
fi
cat <<MSG
next, for one binding:
systemctl --user enable --now $UNIT_PREFIX<binding> start now and at login
systemctl --user status $UNIT_PREFIX<binding>
journalctl --user -u $UNIT_PREFIX<binding> -f the connector's log
systemctl --user stop $UNIT_PREFIX<binding> SIGTERM; restartable
systemctl --user enable --now mosaic-discord@<binding> start now and at login
systemctl --user status mosaic-discord@<binding>
journalctl --user -u mosaic-discord@<binding> -f the connector's log
systemctl --user stop mosaic-discord@<binding> SIGTERM; restartable
scripts/discord.sh stop <binding> the brake: writes STOP, the unit stays down until STOP is removed
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
MSG
@@ -124,7 +115,7 @@ uninstall_unit() {
local target="$UNIT_DIR/$UNIT_NAME"
if [ "$RELOAD" = 1 ]; then
local active
active="$(systemctl --user list-units --no-legend --plain "$UNIT_PREFIX*" 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')"
active="$(systemctl --user list-units --no-legend --plain 'mosaic-discord@*' 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')"
[ -z "$active" ] || die "refusing: instance(s) still running: $(echo "$active" | tr '\n' ' ')stop them first"
fi
if [ -f "$target" ]; then
@@ -143,7 +134,7 @@ status_unit() {
[ $# -eq 1 ] || usage
local binding="$1"
case "$binding" in ''|*[!A-Za-z0-9_-]*) die "binding name must be [A-Za-z0-9_-]+" ;; esac
local unit="$UNIT_PREFIX$binding.service"
local unit="mosaic-discord@$binding.service"
echo "unit: $unit"
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)"
@@ -160,7 +151,6 @@ status_unit() {
echo " checkout: $working"
fi
local config="${XDG_CONFIG_HOME:-$HOME/.config}/mosaic-dev/config.json"
[ -z "$MOSAIC_UNIT_SUFFIX" ] || config="$MOSAIC_INSTANCE_CONFIG"
local data_root
data_root="$(node -e 'const c=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(String(c.dataRoot||""))' "$config" 2>/dev/null || true)"
if [ -z "$data_root" ]; then
-142
View File
@@ -1,142 +0,0 @@
# Sourced, not run: stack instance selection for the shell entry points (row 190).
#
# instance_args "$@" take `--instance NAME` out of the arguments. Sets
# INSTANCE_FLAG (NAME, or empty) and INSTANCE_ARGS (the
# rest, in order). A second --instance or a missing
# NAME exits 4.
# instance_select run `mosaic-config.mjs instance [--instance NAME]`,
# which maps NAME to ~/.config/mosaic-NAME/config.json
# and checks the data root's instance.json claim, then
# set the names below. A refusal exits with the
# loader's code (3 claim or missing config, 4 name or
# mismatch) before the caller reads or writes anything.
#
# With no flag, every name is today's and MOSAIC_CONFIG is left as the caller
# had it. With --instance NAME (other than dev), MOSAIC_CONFIG is exported as
# NAME's config, so everything the entry point runs reads that config.
#
# BUS_TEMPLATE [email protected] | [email protected]
# BUS_GLOB 'mosaic-bus@*.service' | 'mosaic-bus-NAME@*.service'
# BUS_DROPIN_GLOBS unit-path globs whose .conf files can override the bus
# ExecStart: mosaic-bus@*.service.d for the default;
# mosaic-bus-NAME@*.service.d and the shared prefix
# mosaic-bus-.service.d for NAME; then mosaic-.service.d
# and service.d for both
# CONSOLE_UNIT mosaic-webui.service | mosaic-webui-NAME.service
# DISCORD_TEMPLATE [email protected] | [email protected]
# WRAPPER_NAME mosaic | mosaic-NAME
# SESSION_ENV %h/.config/mosaic-dev/secrets/claude-session.env
# | <config dir>/secrets/claude-session.env
# INSTANCE_FLAGS () or (--instance NAME), for the entry points this one runs
# INSTANCE_RESERVED ports a named instance's Console never takes: 7330 (the
# operator's bare Console), 7332 (the tunnel) and 7333
# (the default's tester); empty for the default
# INSTANCE_SED sed arguments that fill @CONFIG_ENV@ and @SESSION_ENV@
# in a unit template: the default deletes the
# @CONFIG_ENV@ line, so its render is byte-identical to
# the template before row 190; NAME gets a comment and
# Environment=MOSAIC_CONFIG=<config>, " [NAME]" after
# the Description, and the Console comment's "sets no
# MOSAIC_*" says it sets MOSAIC_CONFIG (Sage, N3)
#
# Also set: MOSAIC_INSTANCE (dev or NAME), MOSAIC_INSTANCE_CONFIG,
# MOSAIC_INSTANCE_CONFIG_DIR, MOSAIC_INSTANCE_DATA_ROOT (empty when no flag
# was given and the config is missing or invalid; the caller's own load
# refuses that as before) and MOSAIC_UNIT_SUFFIX (empty or -NAME).
INSTANCE_FLAG=""
INSTANCE_ARGS=()
INSTANCE_FLAGS=()
INSTANCE_RESERVED=()
instance_args() {
local seen=0
INSTANCE_FLAG=""
INSTANCE_ARGS=()
while [ $# -gt 0 ]; do
if [ "$1" = "--instance" ]; then
if [ "$seen" = 1 ] || [ $# -lt 2 ]; then
echo "instance: usage: --instance NAME, given once" >&2
exit 4
fi
INSTANCE_FLAG="$2"
seen=1
shift 2
else
INSTANCE_ARGS+=("$1")
shift
fi
done
}
instance_select() {
local here out
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -n "$INSTANCE_FLAG" ]; then
out="$(node "$here/mosaic-config.mjs" instance --instance "$INSTANCE_FLAG")" || exit $?
else
out="$(node "$here/mosaic-config.mjs" instance)" || exit $?
fi
eval "$out"
local s="$MOSAIC_UNIT_SUFFIX"
BUS_TEMPLATE="mosaic-bus$s@.service"
BUS_GLOB="mosaic-bus$s@*.service"
CONSOLE_UNIT="mosaic-webui$s.service"
DISCORD_TEMPLATE="mosaic-discord$s@.service"
WRAPPER_NAME="mosaic$s"
if [ -z "$s" ]; then
BUS_DROPIN_GLOBS=('mosaic-bus@*.service.d' 'mosaic-.service.d' 'service.d')
SESSION_ENV='%h/.config/mosaic-dev/secrets/claude-session.env'
INSTANCE_SED=(-e '/^@CONFIG_ENV@$/d' -e "s|@SESSION_ENV@|$SESSION_ENV|g")
return 0
fi
# systemd expands % and splits Environment= on spaces, and the sed
# expressions use |, so a named instance's paths keep to a plain set.
case "$MOSAIC_INSTANCE_CONFIG" in
*[!A-Za-z0-9/._+-]*)
echo "instance: config path contains a character a unit cannot carry: $MOSAIC_INSTANCE_CONFIG" >&2
exit 4 ;;
esac
export MOSAIC_CONFIG="${MOSAIC_CONFIG:-$MOSAIC_INSTANCE_CONFIG}"
INSTANCE_FLAGS=(--instance "$MOSAIC_INSTANCE")
INSTANCE_RESERVED=(7330 7332 7333)
BUS_DROPIN_GLOBS=("mosaic-bus$s@*.service.d" 'mosaic-bus-.service.d' 'mosaic-.service.d' 'service.d')
SESSION_ENV="$MOSAIC_INSTANCE_CONFIG_DIR/secrets/claude-session.env"
INSTANCE_SED=(
-e "s|^@CONFIG_ENV@\$|# Stack instance $MOSAIC_INSTANCE (row 190): the system config its processes read.\\nEnvironment=MOSAIC_CONFIG=$MOSAIC_INSTANCE_CONFIG|"
-e "s|@SESSION_ENV@|$SESSION_ENV|g"
-e "s|^\\(Description=.*\\)\$|\\1 [$MOSAIC_INSTANCE]|"
-e 's|The unit sets no MOSAIC_\*, CLAUDE\*, CODEX\* or PI_\* variable\.$|The unit sets MOSAIC_CONFIG and no other MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.|'
)
}
# instance_port_holder DIR PORT: the installed Console unit in DIR, other than
# this instance's, whose ExecStart takes PORT as --port or --tester-port.
# A unit with no --port holds 7332, serve's default. Prints the unit and
# returns 0, or prints nothing and returns 1. Unit files are host
# configuration, so reading another instance's is not reading its state.
instance_port_holder() {
local dir="$1" want="$2" file unit line tok next port
want="$((10#$want))"
for file in "$dir"/mosaic-webui.service "$dir"/mosaic-webui-*.service; do
[ -f "$file" ] || continue
unit="${file##*/}"
[ "$unit" != "$CONSOLE_UNIT" ] || continue
line="$(sed -n 's/^ExecStart=//p' "$file" | tail -n 1)"
port=7332 next=""
local -a ports=() toks=()
read -r -a toks <<< "$line"
for tok in ${toks[@]+"${toks[@]}"}; do
case "$next" in
--port) [[ "$tok" =~ ^[0-9]{1,5}$ ]] && port="$((10#$tok))" ;;
--tester-port) [[ "$tok" =~ ^[0-9]{1,5}$ ]] && ports+=("$((10#$tok))") ;;
esac
next="$tok"
done
ports+=("$port")
for port in "${ports[@]}"; do
if [ "$port" = "$want" ]; then echo "$unit"; return 0; fi
done
done
return 1
}
+67 -292
View File
@@ -8,30 +8,13 @@
* validate Load and strictly validate; print resolved config on stdout.
* env Print shell-safe exports for launcher scripts:
* MOSAIC_DATA_ROOT, MOSAIC_PROVIDER, MOSAIC_MODEL.
* instance Print shell-safe exports naming the selected stack instance
* (row 190): MOSAIC_INSTANCE, MOSAIC_INSTANCE_CONFIG,
* MOSAIC_INSTANCE_CONFIG_DIR, MOSAIC_INSTANCE_DATA_ROOT and
* MOSAIC_UNIT_SUFFIX. scripts/instance.sh reads them.
*
* Each operation takes an optional `--instance NAME`. NAME matches
* ^[a-z][a-z0-9]{1,15}$ and selects ~/.config/mosaic-NAME/config.json;
* `dev` is the default instance, so `--instance dev` is the same as no flag.
* Without the flag the config is $MOSAIC_CONFIG or
* ~/.config/mosaic-dev/config.json, as before.
*
* A named instance's data root carries <dataRoot>/instance.json, written by
* `bootstrap --instance NAME`, naming the instance. With the flag every
* operation refuses a root whose marker is missing or names another
* instance; `instance` without the flag refuses a root that a named
* instance claims.
* Config location: $MOSAIC_CONFIG or ~/.config/mosaic-dev/config.json
*
* Exit codes:
* 0 success
* 2 configuration exists but is invalid (never modified by this tool)
* 3 configuration is missing for a read operation (validate/env/instance),
* or the data root's instance marker refuses the selection
* 4 usage: a bad instance name (instance-name-invalid), or --instance and
* $MOSAIC_CONFIG naming different files (instance-config-mismatch)
* 3 configuration is missing for a read operation (validate/env)
*
* Invariants (docs/plans/2026-09-02_atomic-mosaic-foundation.md):
* - Existing configuration is never overwritten.
@@ -49,112 +32,16 @@ const SUPPORTED_ENVIRONMENTS = new Set(["development", "production"]);
const SUPPORTED_BACKENDS = new Set(["docker"]);
const SUPPORTED_ADAPTERS = new Set(["pi", "mock"]); // mock: test-only, see adapters/README.md
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/;
// Row 190: stack instance names. No hyphen, so systemd's prefix drop-ins
// for mosaic-bus-NAME@ stay a fixed list and no name prefixes another's units.
const INSTANCE_PATTERN = /^[a-z][a-z0-9]{1,15}$/;
const DEFAULT_INSTANCE = "dev";
const MARKER = "instance.json";
class Failure extends Error {
constructor(exitCode, message) {
super(message);
this.exitCode = exitCode;
}
}
function fail(exitCode, message) {
throw new Failure(exitCode, message);
process.stderr.write(`mosaic-config: ${message}\n`);
process.exit(exitCode);
}
function instancePath(name) {
return path.join(os.homedir(), ".config", `mosaic-${name}`, "config.json");
}
function realOrNull(file) {
try {
return fs.realpathSync(file);
} catch {
return null;
}
}
// The config file for a selection. Without --instance, as before. With it,
// NAME's file; a $MOSAIC_CONFIG beside it must be the same file.
function configPath(instance) {
if (instance === null) {
return process.env.MOSAIC_CONFIG
? path.resolve(process.env.MOSAIC_CONFIG)
: instancePath(DEFAULT_INSTANCE);
}
const named = instancePath(instance);
if (process.env.MOSAIC_CONFIG) {
const given = path.resolve(process.env.MOSAIC_CONFIG);
const same = given === named || (realOrNull(given) !== null && realOrNull(given) === realOrNull(named));
if (!same) {
fail(4, `instance-config-mismatch: --instance ${instance} names ${named}, but MOSAIC_CONFIG names ${given}`);
}
}
return named;
}
// Operation arguments: nothing, or `--instance NAME` once.
function parseArgs(args) {
if (args.length === 0) return null;
if (args.length !== 2 || args[0] !== "--instance") {
fail(4, `usage: mosaic-config.mjs ${operation ?? "OP"} [--instance NAME]`);
}
const name = args[1];
if (!INSTANCE_PATTERN.test(name)) {
fail(4, `instance-name-invalid: ${JSON.stringify(name)} must match ${INSTANCE_PATTERN}`);
}
return name;
}
function isNamed(instance) {
return instance !== null && instance !== DEFAULT_INSTANCE;
}
// The marker in a data root: null when absent, else its name. Anything else
// at the path refuses.
function readMarker(dataRoot) {
const file = path.join(dataRoot, MARKER);
let stat;
try {
stat = fs.lstatSync(file);
} catch (error) {
if (error.code === "ENOENT") return null;
fail(3, `instance-root-unclaimed: cannot read ${file} (${error.code})`);
}
if (!stat.isFile() || stat.isSymbolicLink()) {
fail(3, `instance-root-unclaimed: ${file} must be a regular, non-symbolic-link file`);
}
let marker;
try {
marker = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (error) {
fail(3, `instance-root-unclaimed: ${file} is not a valid marker (${error.message})`);
}
if (!isPlainObject(marker) || Object.keys(marker).sort().join(",") !== "instanceVersion,name"
|| marker.instanceVersion !== 1 || typeof marker.name !== "string" || !INSTANCE_PATTERN.test(marker.name)) {
fail(3, `instance-root-unclaimed: ${file} is not a valid marker ({"instanceVersion":1,"name":NAME})`);
}
return marker.name;
}
// The claim check (row 190 D2). A named instance needs its own marker; the
// default needs none, or one naming dev.
function checkClaim(dataRoot, instance) {
const owner = readMarker(dataRoot);
if (isNamed(instance)) {
if (owner === null) {
fail(3, `instance-root-unclaimed: ${dataRoot} holds no ${MARKER} naming ${instance} (scripts/bootstrap.sh --instance ${instance} claims a new root)`);
}
if (owner !== instance) {
fail(3, `instance-root-mismatch: ${dataRoot} is claimed by instance ${owner}, not ${instance}`);
}
} else if (owner !== null && owner !== DEFAULT_INSTANCE) {
fail(3, `instance-root-mismatch: ${dataRoot} is claimed by instance ${owner}; select it with --instance ${owner}`);
}
function configPath() {
return process.env.MOSAIC_CONFIG
? path.resolve(process.env.MOSAIC_CONFIG)
: path.join(os.homedir(), ".config", "mosaic-dev", "config.json");
}
function readRaw(file) {
@@ -272,11 +159,10 @@ function validate(document, file) {
};
}
function load(file, instance = null) {
function load(file) {
const raw = readRaw(file);
if (raw === null) {
const bootstrap = isNamed(instance) ? `scripts/bootstrap.sh --instance ${instance}` : "scripts/bootstrap.sh";
fail(3, `configuration not found: ${file} (run ${bootstrap} to create it)`);
fail(3, `configuration not found: ${file} (run scripts/bootstrap.sh to create it)`);
}
let document;
try {
@@ -291,178 +177,67 @@ function shellQuote(value) {
return `'${String(value).replaceAll("'", `'\\''`)}'`;
}
function defaultConfig(dataRoot) {
return {
configVersion: 1,
environment: "development",
dataRoot,
execution: {
backend: "docker",
provider: "zai",
model: "glm-5.3-flash",
},
};
}
const DEFAULT_CONFIG = defaultConfig(path.join(os.homedir(), ".mosaic-dev"));
// 'wx': creation is exclusive; an existing file is never overwritten, and a
// symbolic link at the path is never followed.
function createExclusive(file, text, label = file) {
let fd;
try {
fd = fs.openSync(file, "wx", 0o644);
fs.writeFileSync(fd, text);
return true;
} catch (error) {
if (error.code === "EEXIST") return false;
fail(1, `unable to create ${label}: ${error.message}`);
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
}
function bootstrapDefault(file) {
if (fs.existsSync(file)) {
load(file); // validate only; never rewrite
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
return;
}
fs.mkdirSync(path.dirname(file), { recursive: true });
if (!createExclusive(file, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "configuration")) {
load(file);
return;
}
load(file);
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
}
// Row 190 D2: NAME's config dir (0700), its config, then the claimed data
// root. The root is checked before anything is written, so a refusal
// writes nothing.
function bootstrapNamed(file, instance) {
if (fs.existsSync(file)) {
const resolved = load(file, instance); // validate only; never rewrite
checkClaim(resolved.dataRoot, instance);
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
return;
}
const dataRoot = path.join(os.homedir(), `.mosaic-${instance}`);
let entries = null;
try {
const stat = fs.lstatSync(dataRoot);
if (!stat.isDirectory()) fail(3, `instance-root-unclaimed: ${dataRoot} exists and is not a directory; nothing was written`);
entries = fs.readdirSync(dataRoot);
} catch (error) {
if (error instanceof Failure) throw error;
if (error.code !== "ENOENT") fail(3, `instance-root-unclaimed: cannot read ${dataRoot} (${error.code}); nothing was written`);
}
if (entries !== null && entries.length > 0 && readMarker(dataRoot) !== instance) {
fail(3, `instance-root-unclaimed: ${dataRoot} exists, is not empty and holds no ${MARKER} naming ${instance}; nothing was written`);
}
const configDir = path.dirname(file);
fs.mkdirSync(path.dirname(configDir), { recursive: true });
try {
fs.mkdirSync(configDir, { mode: 0o700 });
} catch (error) {
if (error.code !== "EEXIST") fail(1, `unable to create ${configDir}: ${error.message}`);
}
if (!createExclusive(file, `${JSON.stringify(defaultConfig(dataRoot), null, 2)}\n`)) {
const resolved = load(file, instance);
checkClaim(resolved.dataRoot, instance);
return;
}
const resolved = load(file, instance);
try {
fs.mkdirSync(resolved.dataRoot, { mode: 0o700 });
} catch (error) {
if (error.code !== "EEXIST") fail(1, `unable to create ${resolved.dataRoot}: ${error.message}`);
}
createExclusive(path.join(resolved.dataRoot, MARKER), `${JSON.stringify({ instanceVersion: 1, name: instance })}\n`);
checkClaim(resolved.dataRoot, instance);
process.stderr.write(`mosaic-config: created configuration for instance ${instance}: ${file}, data root ${resolved.dataRoot}\n`);
}
// Without --instance, a missing or invalid config is left to the caller's
// own load, as before; only a loaded config's root is checked.
function selected(instance) {
const file = configPath(instance);
let dataRoot = "";
if (isNamed(instance)) {
dataRoot = load(file, instance).dataRoot;
checkClaim(dataRoot, instance);
} else {
let resolved = null;
try {
resolved = readRaw(file) === null ? null : load(file);
} catch (error) {
if (!(error instanceof Failure)) throw error;
}
if (resolved !== null) {
dataRoot = resolved.dataRoot;
checkClaim(dataRoot, instance);
}
}
const name = isNamed(instance) ? instance : DEFAULT_INSTANCE;
return [
`MOSAIC_INSTANCE=${shellQuote(name)}`,
`MOSAIC_INSTANCE_CONFIG=${shellQuote(path.resolve(file))}`,
`MOSAIC_INSTANCE_CONFIG_DIR=${shellQuote(path.dirname(path.resolve(file)))}`,
`MOSAIC_INSTANCE_DATA_ROOT=${shellQuote(dataRoot)}`,
`MOSAIC_UNIT_SUFFIX=${shellQuote(isNamed(instance) ? `-${instance}` : "")}`,
"",
].join("\n");
}
function loadSelected(instance) {
const file = configPath(instance);
const resolved = load(file, instance);
if (isNamed(instance)) checkClaim(resolved.dataRoot, instance);
return resolved;
}
const DEFAULT_CONFIG = {
configVersion: 1,
environment: "development",
dataRoot: path.join(os.homedir(), ".mosaic-dev"),
execution: {
backend: "docker",
provider: "zai",
model: "glm-5.3-flash",
},
};
const operation = process.argv[2];
const file = configPath();
try {
if (!["bootstrap", "validate", "env", "instance"].includes(operation)) {
fail(1, `unknown operation: ${JSON.stringify(operation ?? "")} (expected bootstrap | validate | env | instance)`);
switch (operation) {
case "bootstrap": {
if (fs.existsSync(file)) {
load(file); // validate only; never rewrite
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
process.exit(0);
}
fs.mkdirSync(path.dirname(file), { recursive: true });
let fd;
try {
// 'wx': creation is exclusive; an existing file is never overwritten.
fd = fs.openSync(file, "wx", 0o644);
fs.writeFileSync(fd, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
} catch (error) {
if (error.code === "EEXIST") {
load(file);
process.exit(0);
}
fail(1, `unable to create configuration: ${error.message}`);
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
load(file);
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
process.exit(0);
}
const instance = parseArgs(process.argv.slice(3));
switch (operation) {
case "bootstrap": {
const file = configPath(instance);
if (isNamed(instance)) bootstrapNamed(file, instance);
else bootstrapDefault(file);
process.exit(0);
}
case "validate": {
const resolved = loadSelected(instance);
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
process.exit(0);
}
case "env": {
const resolved = loadSelected(instance);
process.stdout.write(
[
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
`MOSAIC_PROVIDER=${shellQuote(resolved.execution.provider)}`,
`MOSAIC_MODEL=${shellQuote(resolved.execution.model)}`,
`MOSAIC_ADAPTER=${shellQuote(resolved.execution.adapter)}`,
"",
].join("\n"),
);
process.exit(0);
}
case "instance": {
process.stdout.write(selected(instance));
process.exit(0);
}
case "validate": {
const resolved = load(file);
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
process.exit(0);
}
} catch (error) {
if (!(error instanceof Failure)) throw error;
process.stderr.write(`mosaic-config: ${error.message}\n`);
process.exit(error.exitCode);
case "env": {
const resolved = load(file);
process.stdout.write(
[
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
`MOSAIC_PROVIDER=${shellQuote(resolved.execution.provider)}`,
`MOSAIC_MODEL=${shellQuote(resolved.execution.model)}`,
`MOSAIC_ADAPTER=${shellQuote(resolved.execution.adapter)}`,
"",
].join("\n"),
);
process.exit(0);
}
default:
fail(1, `unknown operation: ${JSON.stringify(operation ?? "")} (expected bootstrap | validate | env)`);
}
+9 -36
View File
@@ -1,22 +1,20 @@
#!/usr/bin/env bash
# One immutable pushed tree for the bus, Console and human CLI (#1579).
# copy creates files only; wrapper is a separately Mos-gated live step.
# wrapper and check take --instance NAME (row 190): the units mosaic-bus-NAME@ and mosaic-webui-NAME
# and the wrapper $BIN/mosaic-NAME, which sets NAME's MOSAIC_CONFIG. check needs --business for NAME.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
BIN="$HOME/.npm-global/bin"
BUSINESS=mosaic-stack BUSINESS_SET=0
BUSINESS=mosaic-stack
ALLOW_WORKTREE=0
MARKER='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)'
usage() { echo 'usage: stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree] | wrapper ID [--instance NAME] [--copies DIR] [--bin DIR] | check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID]' >&2; exit "${1:-4}"; }
usage() { echo 'usage: stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree] | wrapper ID [--copies DIR] [--bin DIR] | check [--copies DIR] [--bin DIR] [--business ID]' >&2; exit "${1:-4}"; }
die() { echo "stack-copy: $*" >&2; exit 1; }
plain_path() { case "$1" in /*) ;; *) die "$2 must be an absolute path: $1" ;; esac; case "$1" in *[!A-Za-z0-9/._+-]*) die "$2 contains a character the unit cannot carry: $1" ;; esac; }
parse() { while [ $# -gt 0 ]; do case "$1" in
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; shift 2;;
--bin) [ "$CMD" != copy ] && [ $# -ge 2 ] || usage; BIN="$2";shift 2;;
--business) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; BUSINESS="$2";BUSINESS_SET=1;shift 2;;
--business) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; BUSINESS="$2";shift 2;;
--allow-worktree) [ "$CMD" = copy ] || usage; ALLOW_WORKTREE=1;shift;;
*) usage;; esac; done; plain_path "$COPIES" 'copies directory'; }
copy_root() { [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || die '--copy must be a full 40-character commit id'; [ -f "$COPIES/$ID/scripts/mosaic" ] || die "no copy $ID in $COPIES"; echo "$COPIES/$ID"; }
@@ -66,7 +64,6 @@ copy_commit() {
echo "copied: $target"
}
wrapper() {
[ -z "$MOSAIC_UNIT_SUFFIX" ] || { wrapper_instance; return; }
plain_path "$BIN" 'bin directory'
local root file lease tmp
root="$(copy_root)";file="$BIN/mosaic"
@@ -80,22 +77,6 @@ wrapper() {
chmod 0755 "$tmp";mv -f "$tmp" "$file";trap - EXIT
echo "wrapper: $root"
}
# Row 190: $BIN/mosaic-NAME. It creates the file or replaces one with its own marker, never another.
# NAME's config path is plain (instance.sh refuses any other), so it goes in single quotes as is.
wrapper_instance() {
plain_path "$BIN" 'bin directory'
local root file tmp
root="$(copy_root)";file="$BIN/$WRAPPER_NAME"
if [ -e "$file" ] || [ -L "$file" ]; then
[ -f "$file" ] && [ ! -L "$file" ] || die "refusing: wrapper is not regular, or a symlink: $file"
[ "$(sed -n '2p' "$file")" = "$INSTANCE_MARKER" ] || die "refusing: foreign wrapper (missing the instance $MOSAIC_INSTANCE marker on line 2): $file"
fi
tmp="$(mktemp "$BIN/.$WRAPPER_NAME.XXXXXX")"
trap 'rm -f "$tmp"' EXIT
printf '#!/usr/bin/env bash\n%s\n# CLI runs from pinned copy: %s\nexport MOSAIC_CONFIG='"'"'%s'"'"'\nexec "%s/scripts/mosaic" "$@"\n' "$INSTANCE_MARKER" "$root" "$MOSAIC_INSTANCE_CONFIG" "$root" > "$tmp"
chmod 0755 "$tmp";mv -f "$tmp" "$file";trap - EXIT
echo "wrapper: $root"
}
unit_root() {
local out load root exec_start
out="$(systemctl --user show "$1" -p LoadState -p WorkingDirectory -p ExecStart 2>/dev/null)" || { echo 'absent (systemctl read failed)';return; }
@@ -103,37 +84,29 @@ unit_root() {
exec_start="$(sed -n 's/^ExecStart=//p' <<< "$out")"
if [ "$load" != loaded ] || [ -z "$root" ];then echo absent;return;fi
case "$1" in
mosaic-bus@*|mosaic-bus-*@*) [[ "$exec_start" == *"path=$root/scripts/mosaic ;"* ]] || { echo "$root (ExecStart mismatch)";return; };;
mosaic-bus@*) [[ "$exec_start" == *"path=$root/scripts/mosaic ;"* ]] || { echo "$root (ExecStart mismatch)";return; };;
mosaic-webui*) [[ "$exec_start" == *"$root/packages/webui/src/cli.mjs serve"* ]] || { echo "$root (ExecStart mismatch)";return; };;
esac
echo "$root"
}
check() {
plain_path "$BIN" 'bin directory';[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die 'invalid business id'
local bus console cli=absent line path marker="$MARKER" wfile="$BIN/$WRAPPER_NAME"
[ -z "$MOSAIC_UNIT_SUFFIX" ] || marker="$INSTANCE_MARKER"
bus="$(unit_root "${BUS_TEMPLATE%.service}$BUSINESS.service")";console="$(unit_root "$CONSOLE_UNIT")"
if [ -f "$wfile" ] && [ ! -L "$wfile" ] && [ "$(sed -n '2p' "$wfile")" = "$marker" ]; then
line="$(tail -n 1 "$wfile")"
local bus console cli=absent line path
bus="$(unit_root "mosaic-bus@$BUSINESS.service")";console="$(unit_root mosaic-webui.service)"
if [ -f "$BIN/mosaic" ] && [ ! -L "$BIN/mosaic" ] && [ "$(sed -n '2p' "$BIN/mosaic")" = "$MARKER" ]; then
line="$(tail -n 1 "$BIN/mosaic")"
path="${line#exec \"}";path="${path%\" \"\$@\"}"
if [ "$line" = "exec \"$path\" \"\$@\"" ] && [[ "$path" == */scripts/mosaic ]];then cli="${path%/scripts/mosaic}";fi
# A named wrapper must also carry NAME's config, as wrapper_instance writes it.
if [ -n "$MOSAIC_UNIT_SUFFIX" ] && [ "$cli" != absent ] && [ "$(sed -n '4p' "$wfile")" != "export MOSAIC_CONFIG='$MOSAIC_INSTANCE_CONFIG'" ];then cli="$cli (MOSAIC_CONFIG mismatch)";fi
fi
printf 'bus: %s\nConsole: %s\nCLI: %s\n' "$bus" "$console" "$cli"
[[ "$bus" == "$COPIES/"* ]] && [[ "${bus#"$COPIES/"}" =~ ^[0-9a-f]{40}$ ]] && [ -f "$bus/scripts/mosaic" ] && [ "$bus" = "$console" ] && [ "$bus" = "$cli" ]
}
# Sourced (stack-release.sh): define the functions, run nothing.
[ "${BASH_SOURCE[0]}" = "$0" ] || return 0
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
[ $# -gt 0 ] || usage
CMD="$1";shift
case "$CMD" in check|wrapper) instance_select;; *) [ -z "$INSTANCE_FLAG" ] || usage;; esac
INSTANCE_MARKER="# mosaic-cli wrapper, instance ${MOSAIC_INSTANCE:-dev} (row 190)"
case "$CMD" in
copy|wrapper) [ $# -gt 0 ] || usage;ID="$1";shift;case "$ID" in -*)usage;;esac;parse "$@";if [ "$CMD" = copy ];then copy_commit;else wrapper;fi;;
check) parse "$@";[ -z "$MOSAIC_UNIT_SUFFIX" ] || [ "$BUSINESS_SET" = 1 ] || usage;check;;
check) parse "$@";check;;
-h|--help) usage 0;;
*) usage;;esac
+31 -67
View File
@@ -24,11 +24,11 @@ set -euo pipefail
cd "$(dirname "$0")/.."
REPO="$(pwd)"
source scripts/common.sh
source scripts/instance.sh
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
BIN="$HOME/.npm-global/bin"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
CONSOLE_UNIT=mosaic-webui.service
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 R_BACKUP="" R_PORT=""
@@ -41,9 +41,7 @@ usage: stack-release.sh adopt BUSINESS
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
stack-release.sh status BUSINESS
stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]
common options: --copies DIR, --bin DIR, --instance NAME
--instance NAME (row 190) runs on stack instance NAME: its config, data root, units and the wrapper
mosaic-NAME. Without it, the default instance, as before.
common options: --copies DIR, --bin DIR
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
every table in COUNTED.
USAGE
@@ -71,10 +69,7 @@ refuse() {
# Reject tunnel/main ports and anything other than a whole high port (exit 4 via usage).
valid_tester_port() {
[[ "$1" =~ ^[0-9]{1,5}$ ]] && [ "$((10#$1))" -ge 1024 ] && [ "$((10#$1))" -le 65535 ] &&
[ "$((10#$1))" -ne 7332 ] && [ "$((10#$1))" -ne "$2" ] || return 1
# Row 190: a named instance's Console never takes 7330, 7332 or 7333.
local r
for r in "${INSTANCE_RESERVED[@]}"; do [ "$((10#$1))" -ne "$r" ] || return 1; done
[ "$((10#$1))" -ne 7332 ] && [ "$((10#$1))" -ne "$2" ]
}
parse_opts() {
@@ -421,17 +416,16 @@ capture() {
mkdir -m 0700 "$RS" 2>/dev/null || return 1
STEPS="$RS/steps.log"
local u d
for u in "$BUS_TEMPLATE" "$CONSOLE_UNIT"; do
for u in [email protected] "$CONSOLE_UNIT"; do
[ -f "$UNIT_DIR/$u" ] || { say "capture: no $UNIT_DIR/$u"; return 1; }
cp -p "$UNIT_DIR/$u" "$RS/$u" || return 1
done
# This instance's own directories only; the shared prefix ones are never captured (row 190).
for d in "$BUS_TEMPLATE.d" "$BUS_UNIT.d" "$CONSOLE_UNIT.d"; do
for d in [email protected] "mosaic-bus@$BUSINESS.service.d" "$CONSOLE_UNIT.d"; do
[ ! -d "$UNIT_DIR/$d" ] || cp -pr "$UNIT_DIR/$d" "$RS/$d" || return 1
done
[ -f "$BIN/$WRAPPER_NAME" ] || { say "capture: no wrapper $BIN/$WRAPPER_NAME"; return 1; }
cp -p "$BIN/$WRAPPER_NAME" "$RS/wrapper" || return 1
stat -c %a "$BIN/$WRAPPER_NAME" > "$RS/WRAPPER_MODE" || return 1
[ -f "$BIN/mosaic" ] || { say "capture: no wrapper $BIN/mosaic"; return 1; }
cp -p "$BIN/mosaic" "$RS/wrapper" || return 1
stat -c %a "$BIN/mosaic" > "$RS/WRAPPER_MODE" || return 1
for u in "$BUS_UNIT" "$CONSOLE_UNIT"; do show "$u" > "$RS/show-$u.txt" || return 1; done
sums "$RS" > "$RS/SHA256SUMS" || return 1
say "captured the rollback set: $RS"
@@ -464,9 +458,9 @@ install_stack() {
local id="$1" pm="$2" claude="$3"; shift 3
local -a pmflag=()
[ "$pm" = 0 ] || pmflag=(--pm)
scripts/bus-service.sh install "${INSTANCE_FLAGS[@]}" --copy "$id" --copies "$COPIES" "${pmflag[@]}" --claude "$claude" >> "$STEPS" 2>&1 || { FAIL=install-bus; return 1; }
scripts/webui-service.sh install "${INSTANCE_FLAGS[@]}" --copy "$id" --copies "$COPIES" "$@" >> "$STEPS" 2>&1 || { FAIL=install-console; return 1; }
scripts/stack-copy.sh wrapper "$id" "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" >> "$STEPS" 2>&1 || { FAIL=wrapper; return 1; }
scripts/bus-service.sh install --copy "$id" --copies "$COPIES" "${pmflag[@]}" --claude "$claude" >> "$STEPS" 2>&1 || { FAIL=install-bus; return 1; }
scripts/webui-service.sh install --copy "$id" --copies "$COPIES" "$@" >> "$STEPS" 2>&1 || { FAIL=install-console; return 1; }
scripts/stack-copy.sh wrapper "$id" --copies "$COPIES" --bin "$BIN" >> "$STEPS" 2>&1 || { FAIL=wrapper; return 1; }
say "installed $id"
}
@@ -492,7 +486,7 @@ process.stdout.write(why);' -- "$out" "$BUSINESS" "$main" "$START_MS" 2>&1)" ||
node -e '
fetch(`http://127.0.0.1:${process.argv[1]}/healthz`,{signal:AbortSignal.timeout(5000)}).then((r)=>process.exit(r.status===200?0:1),()=>process.exit(1));' -- "$(console_port)" || return 1
GATE=copy-check
out="$(scripts/stack-copy.sh check "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>/dev/null)" || return 1
out="$(scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>/dev/null)" || return 1
# check passes when the three agree on one copy; this run needs it to be the target (row 166 item 3).
out="$(sed -n 's/^bus: //p' <<< "$out")"
[ "$out" = "$copy" ] || { GATE_WHY="the bus, Console and CLI agree on $out, not $copy"; return 1; }
@@ -539,10 +533,10 @@ gate() {
# Puts back the captured unit files, drop-ins and wrapper, checked against SHA256SUMS.
put_back() {
local u f rel tmp
for u in "$BUS_TEMPLATE" "$CONSOLE_UNIT"; do
for u in [email protected] "$CONSOLE_UNIT"; do
tmp="$(mktemp "$UNIT_DIR/.$u.XXXXXX")" && cp -p "$RS/$u" "$tmp" && mv -f "$tmp" "$UNIT_DIR/$u" || return 1
done
for u in "$BUS_TEMPLATE.d" "$BUS_UNIT.d" "$CONSOLE_UNIT.d"; do
for u in [email protected] "mosaic-bus@$BUSINESS.service.d" "$CONSOLE_UNIT.d"; do
[ -d "$RS/$u" ] || continue
while IFS= read -r -d '' f; do
rel="${f#"$RS/$u/"}"
@@ -551,12 +545,12 @@ put_back() {
tmp="$(mktemp "$UNIT_DIR/$u/.restore.XXXXXX")" && cp -p "$f" "$tmp" && mv -f "$tmp" "$UNIT_DIR/$u/$rel" || return 1
done < <(find "$RS/$u" -type f -print0)
done
tmp="$(mktemp "$BIN/.$WRAPPER_NAME.XXXXXX")" && cp -p "$RS/wrapper" "$tmp" && chmod "$(cat "$RS/WRAPPER_MODE")" "$tmp" && mv -f "$tmp" "$BIN/$WRAPPER_NAME" || return 1
for u in "$BUS_TEMPLATE" "$CONSOLE_UNIT"; do
tmp="$(mktemp "$BIN/.mosaic.XXXXXX")" && cp -p "$RS/wrapper" "$tmp" && chmod "$(cat "$RS/WRAPPER_MODE")" "$tmp" && mv -f "$tmp" "$BIN/mosaic" || return 1
for u in [email protected] "$CONSOLE_UNIT"; do
[ "$(sha256sum < "$UNIT_DIR/$u" | cut -d' ' -f1)" = "$(sum_of "$RS" "$u")" ] || return 1
done
[ "$(sha256sum < "$BIN/$WRAPPER_NAME" | cut -d' ' -f1)" = "$(sum_of "$RS" wrapper)" ] || return 1
[ "$(stat -c %a "$BIN/$WRAPPER_NAME")" = "$(cat "$RS/WRAPPER_MODE")" ] || return 1
[ "$(sha256sum < "$BIN/mosaic" | cut -d' ' -f1)" = "$(sum_of "$RS" wrapper)" ] || return 1
[ "$(stat -c %a "$BIN/mosaic")" = "$(cat "$RS/WRAPPER_MODE")" ] || return 1
systemctl --user daemon-reload || return 1
say "put back the unit files and the wrapper from $RS"
}
@@ -613,9 +607,9 @@ lock() {
instance_busy() {
local out unit load state rest
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || refuse instance-busy "systemctl --user list-units failed, so the bus state is unknown"
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || refuse instance-busy "systemctl --user list-units failed, so the bus state is unknown"
while read -r unit load state rest; do
[[ "$unit" == $BUS_GLOB ]] || continue
[[ "$unit" == mosaic-bus@*.service ]] || continue
if [ "$unit" = "$BUS_UNIT" ]; then
case "$state" in activating|deactivating|reloading) refuse instance-busy "$unit is $state" ;; esac
else
@@ -627,17 +621,14 @@ instance_busy() {
# install_guard's drop-in scan (bus-service.sh), done before anything stops. A path it can't read
# refuses here, as install_guard would refuse it after the stop (row 166 item 1).
drop_in_override() {
local paths directory dropdir file content glob
local -a dropdirs
local paths directory dropdir file content
paths="$(systemd-analyze --user unit-paths 2>/dev/null)" && [ -n "$paths" ] || refuse drop-in-override "systemd unit paths are unknown"
while IFS= read -r directory; do
[ -n "$directory" ] || continue
case "$directory" in /*) ;; *) refuse drop-in-override "invalid systemd unit path $directory" ;; esac
[ -e "$directory" ] || [ -L "$directory" ] || continue
[ -d "$directory" ] && [ -r "$directory" ] && [ -x "$directory" ] || refuse drop-in-override "cannot inspect unit path $directory"
dropdirs=()
for glob in "${BUS_DROPIN_GLOBS[@]}"; do dropdirs+=("$directory"/$glob); done
for dropdir in "${dropdirs[@]}"; do
for dropdir in "$directory"/mosaic-bus@*.service.d "$directory"/mosaic-.service.d "$directory"/service.d; do
[ -e "$dropdir" ] || [ -L "$dropdir" ] || continue
[ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || refuse drop-in-override "cannot inspect drop-ins $dropdir"
for file in "$dropdir"/*.conf; do
@@ -693,7 +684,6 @@ console_for_target() {
[ "$PLAN" = 1 ] || usage
local other=""
[ "$main" = 7332 ] || other=" and the Console's --port $main"
[ "${#INSTANCE_RESERVED[@]}" = 0 ] || other+=" and instance $MOSAIC_INSTANCE's reserved ${INSTANCE_RESERVED[*]}"
check_refuse tester-port-invalid "--tester-port $TESTER_PORT is not a whole port from 1024 to 65535 other than 7332$other"
fi
[ "$supported" = 1 ] || check_refuse tester-port-target "$TO's Console does not support --tester-port"
@@ -823,30 +813,10 @@ proof_file_problem() {
[ "$uid" = "$(id -u)" ] || echo "is owned by uid $uid, not $(id -u), the uid this script runs as"
}
# Row 190: the target Console's --port (7332 when absent) and --tester-port against every other
# installed Console unit in UNIT_DIR, the default's and each named instance's.
ports_unclaimed() {
local main=7332 i holder
local -a ports=()
for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do
case "${RUN_CONSOLE[i]}" in
--port) main="${RUN_CONSOLE[i + 1]:-7332}" ;;
--tester-port) ports+=("${RUN_CONSOLE[i + 1]:-}") ;;
esac
done
for i in "$main" "${ports[@]}"; do
[[ "$i" =~ ^[0-9]{1,5}$ ]] || continue
if holder="$(instance_port_holder "$UNIT_DIR" "$i")"; then
check_refuse port-claimed "port $i is taken by $UNIT_DIR/$holder"
fi
done
}
# The item 3 checks after the pointer, shared by activate and rollback, for target $TO.
precheck() {
check_target
console_for_target
ports_unclaimed
instance_busy
drop_in_override
read_units || refuse unit-flags-drift "cannot read $BUS_UNIT or $CONSOLE_UNIT"
@@ -887,7 +857,7 @@ do_adopt() {
lock
[ ! -f "$POINTER" ] || { read_pointer; FROM="$P_COPY"; refuse already-adopted "$POINTER exists (copy $P_COPY)"; }
local out
out="$(scripts/stack-copy.sh check "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1)" || refuse check-failed "stack-copy.sh check did not pass:"$'\n'"$out"
out="$(scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1)" || refuse check-failed "stack-copy.sh check did not pass:"$'\n'"$out"
TO="$(sed -n 's/^bus: //p' <<< "$out")"
TO="${TO#"$COPIES/"}"
read_units || refuse check-failed "cannot read $BUS_UNIT or $CONSOLE_UNIT"
@@ -1000,13 +970,12 @@ plan() {
plan_commits
[ "$P_PENDING" = 0 ] || echo "pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM"
[ "$RUN_PENDING" = 0 ] || echo "pm: the bus starts without --pm and the PM stays pending; next: Jason's plugins set of harness, then pm-on"
local flags="${INSTANCE_FLAGS[*]:+${INSTANCE_FLAGS[*]} }"
echo "bus: scripts/bus-service.sh install $flags--copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
echo "bus: scripts/bus-service.sh install --copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
echo "$PROOF_PLAN"
echo "$SEND_PLAN"
echo "Console: scripts/webui-service.sh install $flags--copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
echo "CLI: scripts/stack-copy.sh wrapper $TO $flags--copies $COPIES --bin $BIN"
echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
echo "CLI: scripts/stack-copy.sh wrapper $TO --copies $COPIES --bin $BIN"
local r
for r in "${PLAN_REFUSE[@]}"; do echo "refuses $r"; done
[ "${#PLAN_REFUSE[@]}" = 0 ] || exit 3
@@ -1078,7 +1047,7 @@ do_activate() {
pointer_for_business
run_pm
[ "$P_PENDING" = 0 ] || check_refuse pm-pending "the pointer is pending (activate --pm-after-harness); run pm-on or rollback first"
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; plan; exit 0; fi
if [ "$PLAN" = 1 ]; then check_target; console_for_target; plugin_checks; plan; exit 0; fi
precheck
plugin_checks
new_activation
@@ -1090,7 +1059,7 @@ do_rollback() {
pointer_for_business
TO="$(from_log previous)" || refuse no-previous "the log names no earlier copy than $P_COPY"
run_pm
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; plan; exit 0; fi
if [ "$PLAN" = 1 ]; then check_target; console_for_target; plugin_checks; plan; exit 0; fi
precheck
plugin_checks
[ "$P_SCHEMA" = "$T_SCHEMA" ] || [ -n "$RESTORE" ] || refuse schema-differs "the store is at $P_SCHEMA and $TO expects $T_SCHEMA; pass --restore ACTIVATION"
@@ -1184,7 +1153,7 @@ do_pm_on() {
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
trap 'pm_on_fail signal' INT TERM HUP
stop_bus || pm_on_fail stop-timeout
scripts/bus-service.sh install "${INSTANCE_FLAGS[@]}" --copy "$P_COPY" --copies "$COPIES" --pm --claude "$P_CLAUDE" >> "$STEPS" 2>&1 || pm_on_fail install-bus
scripts/bus-service.sh install --copy "$P_COPY" --copies "$COPIES" --pm --claude "$P_CLAUDE" >> "$STEPS" 2>&1 || pm_on_fail install-bus
say "installed $BUS_UNIT for $P_COPY with --pm"
start_bus || pm_on_fail start
gate "$P_COPY" 1 || pm_on_fail "$GATE"
@@ -1204,7 +1173,7 @@ do_status() {
open="$(interrupted | paste -sd' ' -)"
[ -z "$open" ] || echo "interrupted run: $open (started, no final line; Jason or Mos settles it)"
echo "check:"
if scripts/stack-copy.sh check "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1 | sed 's/^/ /'; then echo " result: pass"; else echo " result: FAIL"; fi
if scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1 | sed 's/^/ /'; then echo " result: pass"; else echo " result: FAIL"; fi
[ -n "$P_COPY" ] || return 0
echo "bus status:"
"$COPIES/$P_COPY/scripts/mosaic" bus status 2>&1 | sed 's/^/ /' || true
@@ -1468,7 +1437,6 @@ do_rehearse() {
local -a reserved=(7332 7333 "$(console_port)")
for ((i = 0; i < ${#P_CONSOLE[@]}; i++)); do [ "${P_CONSOLE[i]}" != --tester-port ] || reserved+=("${P_CONSOLE[i + 1]:-}"); done
for i in "${reserved[@]}"; do [ "$((10#$R_PORT))" != "$i" ] || r_abort port-reserved "--port $R_PORT is a live Console port (7332, 7333 and the pointer's: ${reserved[*]:2})"; done
if i="$(instance_port_holder "$UNIT_DIR" "$R_PORT")"; then r_abort port-reserved "--port $R_PORT is $UNIT_DIR/$i's Console port"; fi
r_port_free "$R_PORT" || r_abort port-in-use "127.0.0.1:$R_PORT is in use; the rehearsal takes no other port"
r_pass port "127.0.0.1:$R_PORT is free"
case "$R_BACKUP" in /*) R_BK="$R_BACKUP" ;; *) R_BK="$DATA/backups/stack/$R_BACKUP" ;; esac
@@ -1521,9 +1489,6 @@ do_rehearse() {
# --- Dispatch ---
# Row 190: --instance NAME may come anywhere; instance.sh takes it out and sets the names.
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
[ $# -gt 0 ] || usage
OP="$1"; shift
ID=""
@@ -1536,14 +1501,13 @@ esac
[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage
parse_opts "$@"
COPIES_DIR="$COPIES"
instance_select
load_config
DATA="$MOSAIC_DATA_ROOT"
STATE="$DATA/state"
POINTER="$STATE/stack-active.json"
LOG="$STATE/stack-activation-log.jsonl"
STORE="$DATA/bus/bus.sqlite"
BUS_UNIT="${BUS_TEMPLATE%.service}$BUSINESS.service"
BUS_UNIT="mosaic-bus@$BUSINESS.service"
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
-5
View File
@@ -170,10 +170,6 @@ check "scripts/discord.sh reload without a binding exits 4" $?
# --- the service unit: rendered from the template, never touching systemd here ---
UNITS="$SANDBOX/units"
# Row 190: discord-service.sh reads the selected config first, so these runs name a scratch one
# that does not exist (the default's names, no data root) instead of ~/.config/mosaic-dev.
CALLER_MOSAIC_CONFIG="${MOSAIC_CONFIG-}"
export MOSAIC_CONFIG="$SANDBOX/absent/config.json"
scripts/discord-service.sh >/dev/null 2>&1
[ $? -eq 4 ]
check "scripts/discord-service.sh without a command exits 4" $?
@@ -250,7 +246,6 @@ HOME="$SANDBOX/home" XDG_CONFIG_HOME="$SANDBOX/config-home" PATH="$SANDBOX/fake-
check "status names the copy from fake systemctl effective cwd" $?
grep -qF "checkout: $REPO_DIR" "$SANDBOX/install.1" && grep -qF -- '--copy' "$SANDBOX/install.1"
check "checkout install explicitly recommends --copy" $?
if [ -n "$CALLER_MOSAIC_CONFIG" ]; then export MOSAIC_CONFIG="$CALLER_MOSAIC_CONFIG"; else unset MOSAIC_CONFIG; fi
# Offline wrapper entrypoint. Only a synthetic token/binding/config are used.
node - "$SANDBOX" <<'EOF_OFFLINE'
+17 -68
View File
@@ -62,33 +62,12 @@
# --no-reload: a unit rewritten under a running Console would take the new
# ExecStart at its next Restart=on-failure, with nobody starting it.
#
# --instance NAME (row 190, any position) selects stack instance NAME: the
# unit is mosaic-webui-NAME.service and sets MOSAIC_CONFIG to
# ~/.config/mosaic-NAME/config.json, its bus units are mosaic-bus-NAME@,
# and render and install need --port, which may not be 7330, 7332 or 7333
# (exit 4, port-reserved). For every instance, install refuses a --port or
# --tester-port that another installed Console unit in the unit directory
# already takes (port-claimed). copy refuses --instance (exit 4), as
# stack-copy.sh copy does: every instance shares the copies. See
# scripts/instance.sh.
#
# Exit codes: 0 ok, 1 operation failed, 3 the data root is claimed by another
# instance or not claimed (scripts/instance.sh), 4 usage.
# Exit codes: 0 ok, 1 operation failed, 4 usage.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
# shellcheck source=scripts/instance.sh
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
if [ "${1:-}" = copy ] && [ -n "$INSTANCE_FLAG" ]; then
echo "webui-service: copy takes no --instance; every instance shares the copies" >&2
exit 4
fi
instance_select
UNIT_NAME="$CONSOLE_UNIT"
BUS_UNIT_PREFIX="${BUS_TEMPLATE%.service}"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | [--instance NAME] render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
UNIT_NAME="mosaic-webui.service"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
--tester-port: 1024..65535, different from --port and never 7332; loopback tester listener.
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
@@ -104,7 +83,6 @@ ALLOW_WORKTREE=0
RELOAD=1
DIR_FLAGS=0
PORT=7332
PORT_SET=0
TESTER_PORT=""
TESTER_SET=0
BUSINESS=""
@@ -126,7 +104,7 @@ parse_flags() {
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; COPY_FLAGS=1; shift 2 ;;
--allow-worktree) ALLOW_WORKTREE=1; shift ;;
--no-reload) RELOAD=0; DIR_FLAGS=1; shift ;;
--port) [ $# -ge 2 ] || usage; PORT="$2"; PORT_SET=1; SERVE_SET=1; shift 2 ;;
--port) [ $# -ge 2 ] || usage; PORT="$2"; SERVE_SET=1; shift 2 ;;
--tester-port) [ $# -ge 2 ] || usage; TESTER_PORT="$2"; TESTER_SET=1; SERVE_SET=1; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;;
--board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;;
@@ -144,31 +122,6 @@ parse_flags() {
[ "$TESTER_SET" = 0 ] || tester_port_ok
}
# Row 190: a named instance's Console names its own port, outside the
# default's ports. Called by render and install only.
instance_ports_ok() {
[ "${#INSTANCE_RESERVED[@]}" -gt 0 ] || return 0
[ "$PORT_SET" = 1 ] || { echo "webui-service: stack instance $MOSAIC_INSTANCE needs --port; it has no default port" >&2; exit 4; }
local p r
for p in "$PORT" ${TESTER_PORT:+"$TESTER_PORT"}; do
[[ "$p" =~ ^[0-9]{1,5}$ ]] || continue
for r in "${INSTANCE_RESERVED[@]}"; do
[ "$((10#$p))" != "$r" ] || { echo "webui-service: port-reserved: $p is reserved for the default instance (${INSTANCE_RESERVED[*]})" >&2; exit 4; }
done
done
}
# Row 190: refuse a port another installed Console unit already takes.
ports_unclaimed() {
local p holder
for p in "$PORT" ${TESTER_PORT:+"$TESTER_PORT"}; do
[[ "$p" =~ ^[0-9]{1,5}$ ]] || continue
if holder="$(instance_port_holder "$UNIT_DIR" "$p")"; then
die "refusing port-claimed: port $p is taken by $UNIT_DIR/$holder; nothing was changed"
fi
done
}
tester_port_usage() {
echo "webui-service: --tester-port must be 1024..65535, different from --port and never 7332: $TESTER_PORT" >&2
exit 4
@@ -296,7 +249,7 @@ render() {
local flags
flags="$(serve_flags)" || exit 1
[ -z "$COPY_ID" ] || flags="--checkout $REPO $flags"
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "${INSTANCE_SED[@]}" "$TEMPLATE"
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "$TEMPLATE"
}
# The unit's active state from list-units, printed when it is anything but
@@ -316,17 +269,16 @@ running() {
# only with a bus on that same copy: install refuses unless every bus unit
# it would call has an ExecStart path inside the copy. With --business the
# unit is [email protected]; without it, every mosaic-bus@ unit systemd
# has loaded, and none loaded refuses. A named instance reads its own
# mosaic-bus-NAME@ units only. Like the state read, a failed call
# has loaded, and none loaded refuses. Like the state read, a failed call
# refuses with one line.
bus_on_copy() {
local copy="$1" units unit out load paths p working
if [ -n "$BUSINESS" ]; then
units="$BUS_UNIT_PREFIX$BUSINESS.service"
units="mosaic-bus@$BUSINESS.service"
else
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || die "refusing: systemctl --user list-units --all --no-legend --plain $BUS_GLOB failed, so the bus unit is unknown; nothing was changed"
units="$(printf '%s\n' "$out" | awk -v p="$BUS_UNIT_PREFIX" 'index($1, p) == 1 && length($1) > length(p) + 8 && substr($1, length($1) - 7) == ".service" {print $1}')"
[ -n "$units" ] || die "refusing: no $BUS_UNIT_PREFIX unit is loaded, and the Console on the copy $copy needs its bus on that copy; name the business with --business ID"
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl --user list-units --all --no-legend --plain mosaic-bus@*.service failed, so the bus unit is unknown; nothing was changed"
units="$(printf '%s\n' "$out" | awk '$1 ~ /^mosaic-bus@.+\.service$/ {print $1}')"
[ -n "$units" ] || die "refusing: no mosaic-bus@ unit is loaded, and the Console on the copy $copy needs its bus on that copy; name the business with --business ID"
fi
for unit in $units; do
out="$(systemctl --user show "$unit" -p LoadState -p WorkingDirectory -p ExecStart 2>/dev/null)" || die "refusing: systemctl --user show $unit failed, so the bus unit is unknown; nothing was changed"
@@ -346,15 +298,13 @@ install_unit() {
parse_flags "$@"
canonical
[ -n "$COPY_ID" ] || die "install needs --copy ID, a copy made with: scripts/webui-service.sh copy COMMIT"
instance_ports_ok
local unit
unit="$(render)" || exit 1
ports_unclaimed
bus_on_copy "$COPIES/$COPY_ID"
# N1: a running unit would take the new ExecStart at its next restart.
local state
state="$(running)" || exit 1
[ -z "$state" ] || die "refusing: $UNIT_NAME is running ($state); Jason or Mos stops it first (systemctl --user stop ${UNIT_NAME%.service}), then install, then start it"
[ -z "$state" ] || die "refusing: $UNIT_NAME is running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui), then install, then start it"
mkdir -p "$UNIT_DIR"
local target="$UNIT_DIR/$UNIT_NAME" tmp
tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")"
@@ -376,11 +326,11 @@ install_unit() {
this script starts nothing: the Console holds the human's capability. Jason
starts and restarts it, or Mos under lead decision 102 (sends then enter the
trail as Jason); no other agent does:
systemctl --user enable --now ${UNIT_NAME%.service} start now and at login
systemctl --user status ${UNIT_NAME%.service}
journalctl --user -u ${UNIT_NAME%.service} -f the Console's log
systemctl --user stop ${UNIT_NAME%.service} SIGTERM; restartable
scripts/webui-service.sh ${INSTANCE_FLAGS[*]:+${INSTANCE_FLAGS[*]} }status unit state and /healthz
systemctl --user enable --now mosaic-webui start now and at login
systemctl --user status mosaic-webui
journalctl --user -u mosaic-webui -f the Console's log
systemctl --user stop mosaic-webui SIGTERM; restartable
scripts/webui-service.sh status unit state and /healthz
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
MSG
}
@@ -392,7 +342,7 @@ uninstall_unit() {
# N3: checked with --no-reload too, which skips only daemon-reload.
local state
state="$(running)" || exit 1
[ -z "$state" ] || die "refusing: $UNIT_NAME is still running ($state); Jason or Mos stops it first (systemctl --user stop ${UNIT_NAME%.service})"
[ -z "$state" ] || die "refusing: $UNIT_NAME is still running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui)"
if [ -f "$target" ]; then
rm -f "$target"
echo "removed: $target"
@@ -476,7 +426,6 @@ case "$cmd" in
parse_flags "$@"
[ "$DIR_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage
{ [ -n "$COPY_ID" ] || [ "$COPY_FLAGS" = 0 ]; } || usage
instance_ports_ok
render ;;
install) install_unit "$@" ;;
uninstall) uninstall_unit "$@" ;;