Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b53620185 | ||
|
|
7662b04902 | ||
|
|
04ce8dfd56 | ||
|
|
fc1cb856b0 | ||
|
|
c0c4907af9 | ||
|
|
5fb736b92d | ||
|
|
96bac588fb | ||
|
|
5d4a617762 | ||
|
|
e65a3ab4d2 | ||
|
|
0444acd084 | ||
|
|
052ba95c7c | ||
|
|
a52d64ed41 | ||
|
|
8fede5fc6a | ||
|
|
c084d8d423 | ||
|
|
f5753c0e8d | ||
|
|
9d165be8c7 | ||
|
|
c25785d3d5 | ||
|
|
ad95c888c3 | ||
|
|
584e2bd827 | ||
|
|
0adf8ddfe2 | ||
|
|
d7d9431990 | ||
|
|
a0e3d7cfdd | ||
|
|
9d24ff9b28 | ||
|
|
6c04b89dae | ||
|
|
6f7dacb417 | ||
|
|
d21b2615c1 | ||
|
|
816d48b03e | ||
|
|
011d41ba6a | ||
|
|
c8bc5cf4cb | ||
|
|
390ea5261c |
@@ -5174,3 +5174,4 @@ Before: managed project/thread verbs had no Console conversation area. After: fi
|
||||
2026-10-20 04:44:54 UTC | Row 178 round 2 preparation | Before: Dewey C1 five render guards lacked killing tests. Added independent refusal fixtures, a temporary uid+1 id stub with cleanup, and empty loopback proof case; activation ordering recorded in r2 BUILD. Product unchanged; syntax/text preflight only, no test or mutant execution. Pending row 181 rebase and Sage slot.
|
||||
|
||||
2026-10-20 05:10:34 UTC | Row 178 #1676 round 2 verified | Before: C1 render refusals lacked killing tests and row 181 base was pending. Rebased onto 931651f2d, kept all COUNTED/count code and tests, appended T178 tail; added independent owner/collision/absolute/charset/header and empty-loopback proof tests. Four files singly 156/156, all 24 mutants killed, metadata and sandbox fixture probes pass; scratch sweep empty. Packet agents/rocko/work/queue-178/r2/, candidate 9300a670, inventory d0dcde7b (59/59), scan 58 files no findings; in-review revs 2166-2167, expected no-token request failure. No live actions or commits.
|
||||
2026-10-21T01:28:05Z | Darkwing | row 232 #1734 installer I1 build, round 1 in review | Before: stack-release.sh managed only user units (systemctl --user), and stack-copy.sh had no bundle copy. After: --units external (adopt, activate, rollback --to, status, confirm, revert, gate --since, AWAITING-UNITS, stale-start, external-pm, --plan --expect-from) and stack-copy check --units external, copy --bundle FILE SHA, wrapper ID --create; user mode byte-identical to the base by test. First build 0af0c73c6 on 00d4cc1f6; rebased onto row 200 (cb5fdb064) as a0ffabc3f on darkwing/row232-r1; --sso refused under external (D9), row 200 config_checks run under external (D10). Gate on Sage's slot (sage-1618): cli 561/0, every scripts/test-*.sh green; 17/17 mutants killed. Results commit 84f1a91e6 (pushed, seat branch only). Manifest 7a3b4ea1 (9 paths), queue revs 2595-2596, request comment 29825, reviewer Filbert. Packet agents/darkwing/work/queue-232/BUILD.md.
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
# Row 232 (I1): stack-release external units and bundle copies
|
||||
|
||||
Brief: `docs/plans/2026-10-20_installer-rows.md` section I1, blob `f35e7e57daee` (commit ad6f0be7a). Gitea
|
||||
#1734, Vikunja P19 #432. Owner Darkwing, reviewer Filbert, gate owner Sage. Design input: row 195 DESIGN r6,
|
||||
`agents/darkwing/work/queue-195/DESIGN.md` at b7239bb66 (approved in duckworth-0055). Where the brief and
|
||||
r6 differ, the brief wins. sage-1561 ruled where the stack user's `stack-release.sh` runs: from its own
|
||||
clone of the release bundle, so `check_target`, the plan's commit list and status drift are unchanged and
|
||||
nothing adds `safe.directory`.
|
||||
|
||||
## Files
|
||||
|
||||
Base: cb5fdb064 (row 199's landing, on top of row 200's 9e1085816). Branch `darkwing/row232-r1`. The first
|
||||
build, 0af0c73c6 on `darkwing/row232`, sat on 00d4cc1f6 (row 190); row 200 landed first, so this one is
|
||||
rebased onto it (see D9 and D10).
|
||||
|
||||
- `scripts/stack-release.sh`: `--units user|external`, the `sc` and `jctl` backend, `confirm`, `revert`,
|
||||
`gate --since`, `rollback --to`, `--plan --expect-from`, the external gate (start floor, PM check from
|
||||
`launches list`), `external-pm`, and the `awaiting units:` status line.
|
||||
- `scripts/stack-copy.sh`: `check --units external`, `copy --bundle FILE SHA`, `wrapper ID --create`.
|
||||
- `packages/cli/tests/stack-release-external.test.mjs` (new, 25 tests).
|
||||
- `packages/cli/tests/stack-release-fixture.mjs` (new): the helpers `stack-release.test.mjs` defined, moved
|
||||
so both files share them. See deviation D1.
|
||||
- `packages/cli/tests/stack-release.test.mjs`: the helpers leave and one import line comes in. The 74 tests
|
||||
have the same names and the same bodies as on the base.
|
||||
- `packages/cli/tests/stack-copy.test.mjs`: three new `row 232:` tests and a wider `node:fs` import.
|
||||
- `docs/TOOLS.md`: the stack-copy usage block and three paragraphs, the stack-release usage block, and a new
|
||||
subsection "Root-owned units (`--units external`, row 232)".
|
||||
- `packages/cli/README.md`: one paragraph.
|
||||
- This file.
|
||||
|
||||
The worktree's `node_modules` symlink is not part of the candidate.
|
||||
|
||||
## User mode is unchanged
|
||||
|
||||
Every `systemctl` call goes through `sc` and every `journalctl` call through `jctl`. Under `user` they
|
||||
are `systemctl --user "$@"` and `journalctl --user "$@"`. Under `external`, `sc` allows only `show`,
|
||||
`is-active` and `list-units`, and `jctl` returns 1, so an external run can't start, stop, install or read
|
||||
the journal even through a path the tests miss.
|
||||
|
||||
Two tests hold the brief's "every command line and every byte of output":
|
||||
|
||||
- `user mode: every systemctl call carries --user, and activate, rollback and check make the base's
|
||||
calls`. A fixture repo gets the base's two scripts from `git show cb5fdb064:`. The same activate,
|
||||
rollback and `stack-copy.sh check` run from both trees, and the recorded argv (with the fixture root and
|
||||
port replaced by placeholders) must match line for line. Every call carries `--user`.
|
||||
- `user mode: a default activate --plan prints the base's bytes`, for four plans.
|
||||
|
||||
The base output is produced live from the base blobs, not stored as a fixture file. See deviation D3.
|
||||
|
||||
## r6 section 7, test by test
|
||||
|
||||
All in `stack-release-external.test.mjs` unless named.
|
||||
|
||||
| r6 :742-812 item | Test |
|
||||
|---|---|
|
||||
| `units-running` (N4), store, log and TAKEN left | `units-running (N4) refuses while a unit is active...`; also checks that `failed` counts as down and that the Console is checked first |
|
||||
| activate leaves the pointer, writes `AWAITING-UNITS` and a backup | `activate stages: ...`, which also checks the set's file list, `UNITS.sha256`, no `--user`, no journal call and `status` |
|
||||
| `open-activation` with zero, two, the wrong copy, one not open | `confirm and revert refuse open-activation ...` |
|
||||
| `confirm` polls: 3 s fails at `--timeout 1`, passes at `--timeout 10` | `confirm polls: bus status good after 3 s fails ...` and `confirm polls to a pass ...` |
|
||||
| `stale-start` floor with `started` at .900 | `stale-start: @1792540799 fails ...` and `stale-start is a gate code ...` |
|
||||
| `confirm` pass writes pointer, ok, removes marker; timeout writes `FAILED` and exits 1 | the two polling tests |
|
||||
| `activation-failed` when `FAILED` exists | `confirm polls: ... FAILED then refuses activation-failed` |
|
||||
| lines carry the started op, from, to, activation and backup | `sameAsStarted` in every confirm test, and sequence 7 |
|
||||
| `gate` (N2) with and without `--since` | `gate (N2, A2f): ...` and the usage test |
|
||||
| `--plan --expect-from` (N5), activate and `rollback --to` | sequence 7 |
|
||||
| A3 order: capture-failed, marker-failed, wrapper failure after migrate | `A3 order: ...` and sequence 2 |
|
||||
| refusals log no activation; `interrupted` unchanged | `open-activation` test and sequence 5 |
|
||||
| PM check: one, none, two, early, not live, other business, other instance; no journal call | `the external PM check reads launches list ...` (modes '', coder, none, twolive, early, dead, other) and `journalCalls` in every external test |
|
||||
| `external-pm` | `pm-on and activate --pm-after-harness refuse external-pm` |
|
||||
| `revert` restores TAKEN only after a migration | `revert puts the wrapper back and leaves the store ...` and sequences 2 and 8 |
|
||||
| `confirm --rollback` per cause, `unrecorded`, `rollback-failed`, mismatch | sequences 1, 2, 3, 5 and 8, and `confirm --rollback refuses rollback-mismatch, and logs rollback-failed ...` |
|
||||
| sequences 1-8 | `sequence 1:` to `sequence 8:`, each ending with the `closed` check (one started and one result line, empty `interrupted`, no marker or `FAILED`, a following `--plan` that doesn't refuse `interrupted-run`) |
|
||||
| `rollback --to` refuses not-previous; `rollback --restore` finds an external backup | sequence 7, and `confirm --rollback after a --restore swap ...` |
|
||||
| `gate` writes nothing | the `gate (N2, A2f)` test and sequence 4 compare the log, the pointer and the state tree |
|
||||
| `confirm`, `revert`, `gate`, `--to` refuse without `--units external` | `confirm, revert, gate, --to and --expect-from need --units external ...` |
|
||||
| default path: every call carries `--user`; default `--plan` bytes | the two user-mode tests above |
|
||||
|
||||
The brief's additions, in `stack-copy.test.mjs`:
|
||||
|
||||
- `row 232: copy --bundle copies a SHA the bundle's refactor reaches, byte-identical to copy SHA, and
|
||||
refuses the rest`: a short SHA, a missing file, a symlink, a bundle that fails `git bundle verify`, a
|
||||
bundle with no `refs/heads/refactor`, a SHA not in the bundle, and a SHA the bundle carries on another
|
||||
branch that refactor doesn't reach. The bundle copy's tree matches `copy SHA`'s byte for byte, and the
|
||||
`release:` and `bundle sha256:` lines are checked.
|
||||
- `row 232: wrapper --create writes the first wrapper only, the same bytes wrapper ID writes, and refuses
|
||||
over any file`, a symlink included. Without `--create` a missing wrapper still refuses.
|
||||
- `row 232: check --units external reads the system units, the default reads the user units`.
|
||||
|
||||
## Mutants
|
||||
|
||||
`mutants.sh` in my scratch (not committed, it edits the worktree), 17 mutants: r6's re-pin list (:817-838)
|
||||
minus the three `case1-repin.sh` ones, plus the brief's two. Each one applies exact replacements after a
|
||||
checked backup, runs `bash -n`, runs its named killers, restores the file and checks its sha256. Two
|
||||
controls run first with no change: the whole external file, and stack-copy's `row 232:` tests.
|
||||
|
||||
| Mutant | Killers |
|
||||
|---|---|
|
||||
| m01 `confirm` writes the pointer on a gate failure | `confirm polls: bus status` |
|
||||
| m02 `gate_once` in place of `gate` | `confirm polls to a pass`, `stale-start is a gate code` |
|
||||
| m03 PM check counts any instance | `external PM check` |
|
||||
| m04 PM check drops the `startedAt` bound | `external PM check` |
|
||||
| m05 `confirm` takes the newest `AWAITING-UNITS` | `open-activation` |
|
||||
| m06 `rollback --to` skips the comparison | `sequence 7:` |
|
||||
| m07 the default path drops `--user` from one call (`is-active`) | `user mode: every systemctl` |
|
||||
| m08 the timeout writes r4's interim `rollback pending` line | `confirm polls: bus status`, `sequence 1:`, `sequence 5:` |
|
||||
| m09 `AWAITING-UNITS` last, after the wrapper | `sequence 2:` |
|
||||
| m10 a refusal names the open activation | `sequence 5:`, `open-activation` |
|
||||
| m11 `stale-start` is a refusal | `stale-start is a gate code` |
|
||||
| m12 `revert` restores without `MIGRATING` | `revert puts the wrapper back` |
|
||||
| m13 `confirm` logs op `confirm` | `sequence 7:`, `confirm polls to a pass` |
|
||||
| m14 `MIGRATING` after the migration | `sequence 8:` |
|
||||
| m15 `gate` takes its floor from the log without `--since` | `gate (N2` |
|
||||
| m16 `copy --bundle` skips the reachability check | stack-copy `row 232:` |
|
||||
| m17 `wrapper --create` overwrites | stack-copy `row 232:` |
|
||||
|
||||
## Deviations and choices
|
||||
|
||||
D1. The shared helpers moved to a new file, `packages/cli/tests/stack-release-fixture.mjs`. The brief's
|
||||
files list names `stack-release.test.mjs` "where a shared helper moves" but not a destination. Importing
|
||||
from the other test file would run its 74 tests twice, so the helpers need a module of their own.
|
||||
|
||||
D2. The fixture's T17 check (what a run may never remove) now exempts a rollback set's `AWAITING-UNITS` and
|
||||
`FAILED`. `confirm` removes both when it closes the activation (r6 A5 and R4). User mode writes neither, so
|
||||
user-mode runs check exactly what they did.
|
||||
|
||||
D3. The user-mode argv and `--plan` tests read the base's scripts with `git show cb5fdb064:` rather than
|
||||
storing their output as a fixture file. A later row that changes user-mode output on purpose moves the
|
||||
`BASE` constant in the test to its own base, or the test fails. I chose this over a stored fixture because
|
||||
a stored copy can drift from the base without anyone noticing.
|
||||
|
||||
D4. `marker-failed` is reached by making `state/stack-rollback` unwritable. r6 says "an existing `$RS`", but
|
||||
`next_activation` never picks a taken name, so no test can make the run meet an existing `$RS`. The
|
||||
exclusive `mkdir` is still what refuses.
|
||||
|
||||
D5. The stale-start polling case turns the enter time to `@1792540800`, not r6's `@1792540805`, with the
|
||||
started line at 1792540800.900. That also checks that the floor is the whole second, so .900 doesn't push it
|
||||
to 1792540801.
|
||||
|
||||
D6. No fake `sudo` or `case2-unit.sh`. r6's sequences ran under them because r6 had `case1-repin.sh` drive
|
||||
the steps, and that script is I3's. Here the test plays root: it stops and starts the fake system units and
|
||||
installs with the existing `bus-service.sh` and `webui-service.sh` stand-ins, between the real
|
||||
`stack-release.sh` calls, on one log.
|
||||
|
||||
D7. The fake marker in the `open-activation` test is named `20000101T000000Z-<C1 prefix>` so it sorts
|
||||
before the real one. A marker that sorted after it would leave m05 (newest marker) alive.
|
||||
|
||||
D8. m10 sets `ACT` inside `open_activation`, where r6's wording ("ACT set before the checks") lands in this
|
||||
code. `ACT` is set only after every check in `confirm`.
|
||||
|
||||
D9. Row 200's `--sso` is a usage error under `--units external`, with the proof flags, `--no-proof` and
|
||||
`--no-send`. It is a Console flag, and root's Console unit carries the Console flags; the script under
|
||||
external renders no Console line and never calls `console_for_target`, so `sso_checks` doesn't run there
|
||||
either. The usage text, the TOOLS.md bullet and the external usage test name it.
|
||||
|
||||
D10. Row 200's `config_checks` runs under `--units external` too, after `plugin_checks`, in both the
|
||||
`--plan` and run paths of `activate` and `rollback --to`. It checks the target's loader against the stack
|
||||
user's `config.json`, which the bus on a VM still reads, so a `console` key the target refuses would break
|
||||
root's bus start the same way. The cost is one more refusal (`config-console-key`) an external run can
|
||||
print; I took it over a bus that fails after root's start. The rebase changed no user-mode line: the
|
||||
conflicts were the `SSO=0` globals line, the `plan` body (row 200's `SSO_PLAN` line sits in the user
|
||||
branch) and the two `--plan` lines that gained `config_checks`. In the tests, row 200's fixture lines
|
||||
(the loaders, `sso-config.mjs` and the `--sso` cli.mjs line for C1 and C) moved into
|
||||
`stack-release-fixture.mjs` with the rest of the copy setup, and the `ALGS` import stays in
|
||||
`stack-release.test.mjs`, which T57 uses.
|
||||
|
||||
## duckworth-0055's notes on r6 (brief line 169)
|
||||
|
||||
Duckworth's r6 approval (REVIEW-r5.md at 23f5520b5) carried two notes.
|
||||
|
||||
- N1, A2's stop timeout under `gate --since`. A unit that never stopped keeps its old `ActiveEnterTimestamp`,
|
||||
so `gate --since` fails it with `stale-start`, and the run ends at exit 2 with root stopping the units.
|
||||
That is intended: a unit that won't stop is a fair reason to stop. In I1 this is the gate code and its
|
||||
polling (`stale-start: @1792540799 fails ...`, `gate (N2, A2f)`, m11). The run sheet's A2f line and the
|
||||
re-pin test that points "exits 1 with OLD's gate passing" at an A3 exit 3 belong to I3, which owns
|
||||
`scripts/stack-upgrade.sh` and `docs/guides/host-run-sheet.md`. I'll name both in the I3 build.
|
||||
- N2, the claude path under `/opt/mosaic-stack` (gosalyn-0058, sage-1522). I1 doesn't touch it. Under
|
||||
`--units external` the script checks no claude pin of its own (`no-claude-pin` applies only to user
|
||||
mode), because root's unit carries it. The `--claude` value, the managed-settings reasoning, F6 and the
|
||||
run sheet's claude step go to I2 and I3.
|
||||
|
||||
## Run environment
|
||||
|
||||
Worktree `/mnt/storage/scratch/darkwing-r232`, `TMPDIR=/mnt/storage/scratch/tmp/r232`, node from PATH.
|
||||
One external file run takes about 94 s.
|
||||
|
||||
## Results
|
||||
|
||||
Singles on the rebased candidate, before the gate:
|
||||
|
||||
| File | Tests | Pass | Fail |
|
||||
|---|---|---|---|
|
||||
| `packages/cli/tests/stack-release-external.test.mjs` | 25 | 25 | 0 |
|
||||
| `packages/cli/tests/stack-copy.test.mjs` | 16 | 16 | 0 |
|
||||
| `packages/cli/tests/stack-release.test.mjs` (row 200's T57 included) | 192 | 192 | 0 |
|
||||
| `packages/cli/tests/stack-instances.test.mjs` | 23 | 23 | 0 |
|
||||
| `packages/webui/tests/pinned-copy.test.mjs` | 10 | 10 | 0 |
|
||||
| `packages/cli/tests/channel-recovery.test.mjs` (imports `channel-fixture.mjs`, which names the scripts) | 1 | 1 | 0 |
|
||||
| `packages/cli/tests/host.test.mjs` (reads `packages/cli/README.md`) | 42 | 42 | 0 |
|
||||
|
||||
These are every test file that reads `stack-release.sh` or `stack-copy.sh` (a grep of `packages/*/tests` and
|
||||
`scripts/`). None of them pins a sha256 of a changed file. `packages/cli/tests/host.test.mjs` reads
|
||||
`packages/cli/README.md` for a unit file name; this row only adds a paragraph there. The grep and the runs
|
||||
were repeated on the rebased tree on 2026-10-21; the reader set is the same. A dry run of `mutants.sh`
|
||||
matches each of the 17 replacements once on the rebased scripts.
|
||||
|
||||
### Gate, 2026-10-21, Sage's slot (sage-1618)
|
||||
|
||||
Candidate a0ffabc3f on `darkwing/row232-r1`, run by `~/darkwing-scratch/r232/gate.sh`: the cli suite, then
|
||||
every `scripts/test-*.sh`, one after another, stopping at the first failure. busy.sh passed at 01:07:49Z
|
||||
after Launchpad's single `stack-release.test.mjs` run ended. The gate stopped at the first one, so the
|
||||
chain waited for that PID to exit and checked again. The EXIT trap removed the compose network; no
|
||||
container was left.
|
||||
|
||||
| Step | Time (UTC) | Result |
|
||||
|---|---|---|
|
||||
| `packages/cli`, `node --test tests/*.test.mjs` | 01:07:50 to 01:17:53 | 561 pass, 0 fail, 0 skipped |
|
||||
| `scripts/test-auth.sh` | 01:17:53 to 01:17:54 | 15 passed, 0 failed |
|
||||
| `scripts/test-conductor.sh` | 01:17:54 to 01:18:00 | 17 passed, 0 failed |
|
||||
| `scripts/test-config.sh` | 01:18:00 to 01:18:02 | 31 passed, 0 failed |
|
||||
| `scripts/test-discord.sh` | 01:18:02 to 01:18:12 | 78 passed, 0 failed |
|
||||
| `scripts/test-extension-package.sh` | 01:18:12 to 01:18:13 | 18 passed, 0 failed |
|
||||
| `scripts/test-foundation.sh` | 01:18:13 to 01:19:01 | 44 passed, 0 failed |
|
||||
| `scripts/test-land-work.sh` | 01:19:01 to 01:19:14 | 86 passed, 0 failed |
|
||||
| `scripts/test-queue.sh` | 01:19:14 to 01:19:37 | 156 node tests pass, queue suite 28 passed, 0 failed |
|
||||
| `scripts/test-release.sh` | 01:19:37 to 01:19:51 | 14 passed, 0 failed |
|
||||
| `scripts/test-task.sh` | 01:19:51 to 01:20:44 | 98 passed, 0 failed |
|
||||
|
||||
### Mutants, 2026-10-21, same slot
|
||||
|
||||
`~/darkwing-scratch/r232/mutants.sh`, right after the gate. Both controls (the external file whole, and
|
||||
stack-copy's `^row 232: ` tests) exited 0 on the unmutated tree. Each mutant was applied after a checked
|
||||
backup and a syntax check, ran its killers from the table above, and was put back with its sha256
|
||||
checked. The script stops on any restore mismatch, and it ran to its end.
|
||||
|
||||
| Mutant | Killer exit | Result |
|
||||
|---|---|---|
|
||||
| m01 pointer on gate failure | 1 | killed |
|
||||
| m02 gate once | 1 | killed |
|
||||
| m03 PM from any instance | 1 | killed |
|
||||
| m04 no startedAt check | 1 | killed |
|
||||
| m05 newest marker | 1 | killed |
|
||||
| m06 --to not compared | 1 | killed |
|
||||
| m07 one call without --user | 1 | killed |
|
||||
| m08 interim pending line | 1 | killed |
|
||||
| m09 marker written last | 1 | killed |
|
||||
| m10 refusal names the open activation | 1 | killed |
|
||||
| m11 stale-start refuses | 1 | killed |
|
||||
| m12 revert always restores | 1 | killed |
|
||||
| m13 confirm op | 1 | killed |
|
||||
| m14 MIGRATING after migrate | 1 | killed |
|
||||
| m15 gate floor from the log | 1 | killed |
|
||||
| m16 bundle reachability unchecked | 1 | killed |
|
||||
| m17 --create overwrites | 1 | killed |
|
||||
|
||||
17 of 17 killed, 01:22:16 to 01:24:32Z. I read the logs for m01 and m09, two of the quick kills: each failed on
|
||||
its named test's assertion (m09 on `AWAITING-UNITS` in sequence 2), not on a load error.
|
||||
|
||||
## Round 2: rebase onto 8fede5fc6 (sage-1673)
|
||||
|
||||
Rebase only. Before the landing gate, a check of BASE..HEAD on the manifest paths found that row 230
|
||||
(c8bc5cf4c) had changed one of them after the round 1 base, cb5fdb064. Sage ruled a re-round with a
|
||||
scoped review, the same shape as row 210 round 3.
|
||||
|
||||
Branch `darkwing/row232-r2`: the two round 1 commits, a0ffabc3f and 84f1a91e6, cherry-picked onto
|
||||
8fede5fc6 with no conflict, then this section. No code or test changed.
|
||||
|
||||
- `git merge-tree --write-tree origin/refactor darkwing/row232-r1` (refactor at 8fede5fc6) gives tree
|
||||
837bde79236c with no conflict. The cherry-picked tip before this section has the same tree.
|
||||
- `packages/cli/README.md` is a clean 3-way merge. Row 230's hunk is at lines 710-717 (the session credential
|
||||
paragraph); this row's is a new paragraph at 773. With `git patch-id --stable`, the README diff from r1 to
|
||||
r2 is 02b679e2, the same as row 230's README diff `cb5fdb064..8fede5fc6`. This row's README diff is
|
||||
c395ecd2 on both bases (`cb5fdb064..r1` and `8fede5fc6..r2`).
|
||||
- The seven code, test and doc paths other than the README hash the same as in manifest 7a3b4ea1, which
|
||||
Filbert approved in round 1: `docs/TOOLS.md`, `packages/cli/tests/stack-copy.test.mjs`,
|
||||
`packages/cli/tests/stack-release-external.test.mjs`, `packages/cli/tests/stack-release-fixture.mjs`,
|
||||
`packages/cli/tests/stack-release.test.mjs`, `scripts/stack-copy.sh`, `scripts/stack-release.sh`.
|
||||
This file also matched until this section was appended. Its other lines are unchanged.
|
||||
- The one test that reads the README, `host.test.mjs:855`, looks for the unit's `EnvironmentFile=` line.
|
||||
That line is unchanged context in row 230's hunk.
|
||||
- No row 230 test reads `stack-release.sh` or `stack-copy.sh`, and no test of this row reads a row 230 file.
|
||||
|
||||
Nothing was run for this round. The landing gate runs on the r2 tip on Sage's slot.
|
||||
|
||||
Follow-up, doc-only (sage-1673): `scripts/case2-unit.sh:108` and `scripts/case2-precheck.sh:157` cite
|
||||
`scripts/stack-copy.sh:8` and `:22` for `COPIES` and `copy_root`. With this row those lines are `:10` and
|
||||
`:26`. Only comments are affected and no test reads them. Row 233 doesn't touch those scripts, so the fix
|
||||
goes on the #1732 carry list.
|
||||
@@ -1,207 +0,0 @@
|
||||
# Row 210 round 4 (#424, Gitea #1707): agent host access with sudo through the credential store
|
||||
|
||||
Gizmoduck, 2026-10-20. Reviewer Duckworth, gate owner Sage.
|
||||
|
||||
- Brief: `docs/plans/2026-10-20_host-credentials.md` at 9db578ff5 (on `refactor`, not under this base).
|
||||
- Design: `agents/gizmoduck/work/queue-210/DESIGN.md` r3, e021dfa26 on `gizmoduck/row210`, approved by
|
||||
Duckworth (duckworth-0043, C1 met in duckworth-0044) and Sage (sage-1468, sage-1472).
|
||||
- Base: row 199's candidate 07141966e on `megavolt/row199` (megavolt-0027), as sage-1472 and sage-1493
|
||||
said. 50d4102f8 carries the r3 design onto it. Branch `gizmoduck/row210-on-199-r2`. It lands after 199,
|
||||
and the rebase onto 199's landing goes to a new branch name.
|
||||
- Rebase: round 1 was built on addee664a (8ec923585 on `gizmoduck/row210-on-199`). 199's tip moved to
|
||||
07141966e, which touches `packages/cli/src/credentials.mjs` and its test, both also changed here. The
|
||||
rebase applied with no conflict, and every touched test file was rerun singly on the new base.
|
||||
- Round 2 answers duckworth-0052 (B1, N1 to N3) on the same base. The round-1 candidate after the rebase was
|
||||
12ffe0b56. Round 2 touches only B1's lines and the notes, plus a fixture line in `hard-waits.test.mjs` that N1
|
||||
needed. See "Round 2" below.
|
||||
- Rebase onto 199's landing (sage-1599): 199 landed as cb5fdb064 on `refactor`. The branch
|
||||
`gizmoduck/row210-r3` carries the seven commits 07141966e..a2fd30390 onto it with `git rebase --onto`. It
|
||||
applied with no conflict. `git range-diff` shows all seven patches identical (`=`), and
|
||||
`git diff 07141966e cb5fdb064` is empty on every path this row touches, so the approved round-2 code is
|
||||
unchanged and `candidate-manifest.sha256` still verifies. The old branch is kept as it was, not
|
||||
force-pushed or deleted (mos-1086). Singles on cb5fdb064 are under "Checks".
|
||||
- Round 4 answers sage-1617 B1 on the same branch and base. See "Round 4" below. `candidate-manifest.sha256`
|
||||
is regenerated from the round-4 tree: 23 files, the 20 before plus `project-runtime.test.mjs` and the two
|
||||
role contracts.
|
||||
- Candidate: the commit that adds this file. `candidate-manifest.sha256` lists the code and test files.
|
||||
- Scope (sage-1468): the `host.run` broker op, the grant check, the executor and the receipts, over
|
||||
`broker.sock`. No `packages/harness` change. The `host_run` tool is Sage's follow-up row.
|
||||
|
||||
## Design to code to test
|
||||
|
||||
| Design | Code | Tests |
|
||||
|--------|------|-------|
|
||||
| D1 kind `host.<id>`, `{"service": "host"}` with no scopes, `rotateBy` | vocabulary.mjs:26-28, 111-115, role.mjs:110-118, business credentials.mjs:26, credential-verbs.mjs:8, cli credentials.mjs:11-12 | T2, T4, T23 |
|
||||
| D2 value check, one unencrypted ed25519 key | bus credentials.mjs:26 (`parseHostKey`), used at :227 | T4 |
|
||||
| D3 registry, own-machine refusal, grant, key reference | hosts.mjs:35-127, business.mjs:124-131, 154, 210-211, 271 | T1, T2, T26 |
|
||||
| D3 `organization.set`, `limits.set` and `instances.set` refuse `hosts` | broker `keys()` (199) | T3 |
|
||||
| D4 `host.sudo` action, decision bound to host, argv and stdin, spent once | vocabulary.mjs:28, broker.mjs:100-101, 796, 829, 1955-2005 | T9, T25 |
|
||||
| D5 `host.run` verb, check order, events | broker.mjs:95, 1714-1765, server.mjs:12-13, 113-116, resolve.mjs:90, bus business.mjs:21 | T5, T6, T8, T20, T22 |
|
||||
| D6 argv shape, literal prefix, refused runners, quoting, `sudo -n --` | hosts.mjs:20-33, 92-112, vocabulary.mjs:121-144, host-run.mjs:25-26 | T7, T8, T10, T11, T26 |
|
||||
| D6 exit, signal, timeout, output cap | host-run.mjs:95-145 | T12, T13 |
|
||||
| D7 run dir, ssh-agent, `ssh-add -t`, `--pdeathsig`, pinned known_hosts, pids with start times | host-run.mjs:17-84, 147-187 | T14, T15, T18, T19, T27 |
|
||||
| D7 start sweep | host-run.mjs:189-226, runtime.mjs:185 | T27 (two cases) |
|
||||
| D8 leak check on stdin and on the result | bus credentials.mjs:89-98, 355-370 | T16, T17 |
|
||||
| D9 issue prints public material, revoke kills the call in flight | credential-verbs.mjs:33-35, cli credentials.mjs:77-82, bus credentials.mjs busy and kill map | T21, T23 |
|
||||
| D10 build greps | | T24 |
|
||||
|
||||
T1, T2 and T26 are in `packages/business/tests/hosts.test.mjs`. T23 is in `packages/cli/tests/credentials.test.mjs`.
|
||||
The rest are in `packages/bus/tests/host-run.test.mjs`, against a scratch sshd on 127.0.0.1 with a fake sudo.
|
||||
|
||||
## Round 2 (duckworth-0052)
|
||||
|
||||
- B1. `host-run.test.mjs` no longer skips. A missing `sshd`, `ssh`, `ssh-agent`, `ssh-add`, `ssh-keygen` or
|
||||
`setpriv` fails a first test that names the binary, and `sshd()` throws the same way, so every executor test
|
||||
fails too. I picked failing over the `# skipped 0` gate check because it needs no gate change. With `SSHD`
|
||||
edited in place to `/nonexistent/sshd` (backup, restored, sha checked), the file exits 1: tests 21, pass 8,
|
||||
fail 13, skipped 0. The binary test and all 12 executor tests fail with "/nonexistent/sshd is missing".
|
||||
- N1. A `host.sudo` raise now checks the raiser's own grant for the target and the argv prefix before it asks the
|
||||
human (broker.mjs:1963-1966). It uses the same check `host.run` uses (:1731-1734). T5 to T8 now raise for an
|
||||
ungranted host (`host-not-granted`) and an argv outside the grant (`command-not-granted`), and count zero
|
||||
`host.sudo` decisions after. T9 grants web2 through a new `coderWeb2` fixture option. `hard-waits.test.mjs`
|
||||
raised `host.sudo` from a coder with no grant, so its fixture now grants web1 `systemctl status`.
|
||||
- N2. `addKey` takes `limitMs` (default `HOST_LIMITS.agentWaitMs`) and `signal` (host-run.mjs:87-104). At the
|
||||
limit it kills ssh-add's group and refuses `host-failed`. On abort it does the same and refuses
|
||||
`credential-unavailable`. `runHost` passes both (:178). The new N2 test uses a unix socket that accepts and
|
||||
never answers. The 300 ms limit refuses in under 2 s, the abort at 100 ms refuses `credential-unavailable`, and
|
||||
the socket sees both ssh-add connections close. It carries a 10 s test limit so M37a and M37b fail in seconds.
|
||||
- N3. ssh writes its own messages to `<run dir>/ssh.log` with `-E` (host-run.mjs:33). `host-key-mismatch` now
|
||||
reads only that log, and only on exit 255 (:194-195). The remote stderr can't trigger it. On another exit 255
|
||||
the log goes after the remote stderr, so an ssh failure keeps its message. The new N3 test runs a fake
|
||||
`fail255` that prints the pin refusal text and exits 255: the answer is the command result with exit 255. A run
|
||||
to port 1 answers exit 255 with "port 1: Connection refused". T14 still refuses `host-key-mismatch`. T15 checks
|
||||
the `-E` argv.
|
||||
- N4. The deviations are accepted. No change.
|
||||
- N5. The business test helper leaving `mosaic-business-*` tmp dirs predates this row. It is #1624, below.
|
||||
|
||||
## Round 4 (sage-1617)
|
||||
|
||||
- B1. The gate failed pkg-bus 497/1 on 92fac9904. broker.mjs:7 imported `validArgv` and `commandAllowed` from
|
||||
`business/src/hosts.mjs`, which isn't in RUNNER_FILES and imports `node:net`, `errors.mjs` and `util.mjs`. The
|
||||
confined worker can't load it. The fix follows Sage's rule: broker.mjs imports only import-free files in
|
||||
RUNNER_FILES.
|
||||
- `ARGV_MAX`, `ARG_BYTES`, `ARGV_BYTES`, `validArgv` and `commandAllowed` move unchanged into `vocabulary.mjs`
|
||||
(:121-144). They need nothing but `Buffer`, a global. `vocabulary.mjs` is already in RUNNER_FILES and still
|
||||
has no import, so RUNNER_FILES and the seat sandbox pin don't change and no new file is mounted.
|
||||
- `hosts.mjs` imports `ARGV_MAX` back for `checkCommands` and re-exports all five (:7-11), so `hosts.test.mjs:9`, the
|
||||
only other reader, imports them as before.
|
||||
- broker.mjs:6 now reads `credentialKind`, `validArgv` and `commandAllowed` from the vocabulary, and the
|
||||
hosts.mjs line is gone. Every broker cite in this file after :7 is one lower than in round 2.
|
||||
- `project-runtime.test.mjs:12` pins `createHash` and the new vocabulary line, each with a Row 210 comment line
|
||||
in the existing style.
|
||||
- `mutants.py` points M04a to M04e and M05 at `vocabulary.mjs`. The anchors and the edits are unchanged.
|
||||
- Found by the whole business suite, not by the gate: `workflow-roles.test.mjs:105` failed 197/1. Round 1 put
|
||||
`host.sudo` in ACTIONS and not in ROUTED (vocabulary.mjs:28), so it is in GATED_ONLY, a hard wait. The hard
|
||||
wait lists in `roles/pm.md:54-57` and `roles/chief-of-staff.md:74-77` didn't name it, and the test pins those
|
||||
lists to GATED_ONLY. It failed the same way on 3cf63efb4, where `roles/` is unchanged from cb5fdb064. Both
|
||||
lists now name `host.sudo`. That only writes down the routing the broker already applies. No role gains
|
||||
or loses a grant. The other tests that read these files pass whole (Checks).
|
||||
- No other change. The host-run, hard-waits and credential code and tests are byte-identical to round 2.
|
||||
|
||||
## Deviations from the design
|
||||
|
||||
1. `resolve.mjs` filters `host.*` out of the session's credentials (D5). The bus needs them, so
|
||||
`bus/src/business.mjs` carries them back onto the bus role entry. D10 lists only the bus side.
|
||||
2. `business/src/hosts.mjs` is a new file D10 doesn't list. It holds the D3 and D6 checks so `business.mjs`
|
||||
and the broker share one argv check. `business/src/index.mjs` is unchanged. Since round 4 the argv check
|
||||
itself lives in `vocabulary.mjs`, because the sandboxed broker can't load hosts.mjs.
|
||||
3. N1 runner list. Beyond Duckworth's list, `python*`, `*awk`, `sed`, `vi`, `vim`, `less` and `php*` refuse
|
||||
as a prefix (hosts.mjs:20-28).
|
||||
4. `runGit` isn't shared. Its shape doesn't fit an agent socket and a stdin pipe, so host-run.mjs has its own
|
||||
spawn with the same group-kill shape.
|
||||
5. The `host-busy` check runs before the decision is spent (broker.mjs:1740), so a busy refusal leaves the
|
||||
decision usable. T20 checks that the held decision runs after the first call ends. The executor checks
|
||||
again under its own map (credentials.mjs:355).
|
||||
6. T15's HOME part. ssh reads the passwd home, not `$HOME`, so a planted `~/.ssh/config` in a scratch HOME
|
||||
proves nothing. T15 checks the argv instead: `-F /dev/null`, `UserKnownHostsFile` set to the run dir's
|
||||
file and `GlobalKnownHostsFile=/dev/null`. M13 points the file at the real `~/.ssh/known_hosts`, which ssh
|
||||
reads and never writes.
|
||||
7. T27's key lifetime runs `addKey` with lifetime 1 and checks `ssh-add -l` after about 2.5 s, instead of a
|
||||
31 s wait.
|
||||
8. The status row adds `publicKey` and `fingerprint` for host rows only.
|
||||
9. `spec-fixtures.mjs` takes a `hosts` param so the bus tests can build a business with a registry.
|
||||
10. T12. ssh exits 255 when it gets SIGTERM, so a timed-out call answers `signal: 'SIGTERM'` from the
|
||||
timeout flag and exit 255. That matches D6's "answers `signal`".
|
||||
11. T3 runs at the broker. The writer not being called stands in for "the file is unchanged". `organization.set`
|
||||
reaches the writer as `limits.set` (broker.mjs:1378). The nested paths can't carry a host grant:
|
||||
launch-settings.mjs:172 takes only a string model key and instance-settings.mjs:101 writes only
|
||||
`{definition, vars}`.
|
||||
12. T24's use-site list is `host-run.mjs`, `runtime.mjs` and `tasks/adapter.mjs`. It walks the
|
||||
`packages/*/src` tree, since `git ls-files` misses new files, and it skips comment lines.
|
||||
13. The design's T27 sweep item now has two cases. The first calls `sweepRunDir` directly (live bus dir kept,
|
||||
only a matching live process killed). The second starts a bus with `startBroker` and checks that a dead
|
||||
bus's run dir is gone and that a stray entry stops the start.
|
||||
|
||||
## Mutants
|
||||
|
||||
`mutants.py`: 53 mutants, design section 9 split where the design says "one mutant each".
|
||||
|
||||
- M04 is a to e (one per argv shape check).
|
||||
- M22 is a (broker pre-check) and b (executor map).
|
||||
- M25 is a to d with crafted keys (cipher and kdf name, public type, private type, key count). The CRLF
|
||||
mutant is left out because it is equivalent. The `\r` check (credentials.mjs:27) is redundant with the PEM
|
||||
frame and line regex (:29, :31).
|
||||
- M04a to M04e and M05 edit `vocabulary.mjs` since round 4.
|
||||
- M28 is a and b. M34 is a to d (no-op sweep, bus pid ignored, start time ignored, bus start never sweeps).
|
||||
- M12 dies on T15's argv check as well as T14, because `accept-new` still refuses a changed known key.
|
||||
- Round 2 adds M36a and M36b (N1 raise grant and prefix checks), M37a and M37b (N2 limit and abort) and M38a and
|
||||
M38b (N3 mismatch read from the remote stderr, `-E` dropped). M02's anchor now starts at the line break, since
|
||||
the raise's check at 10 spaces holds the host.run line at 6 as a substring.
|
||||
|
||||
All 53 anchors match once (dry count on the round-2 tree and again on the round-4 tree, no `__pycache__`). In round 1 I ran M34d by hand once
|
||||
as a single test file: it fails in under a second and the file restored to its sha. The new round-2 mutants are
|
||||
not run by hand. **The batch is not run**, as it needs Sage's slot.
|
||||
Run it as `python3 -I agents/gizmoduck/work/queue-210/mutants.py <checkout> <empty scratch dir>`. It runs
|
||||
the control first, puts each run in its own process group with a 240 s timeout and a killpg after it, checks
|
||||
the files restored at the end, and exits 1 on any survivor or missed anchor.
|
||||
|
||||
## Checks
|
||||
|
||||
Round 4 on cb5fdb064, the final tree, after the role contract edit. Counts are awk sums over the logs.
|
||||
|
||||
- Suites, each run whole: business 198 pass, 0 fail, seat 164 pass, 0 fail, bus 498 pass, 0 fail, all 0
|
||||
skipped. Before the role edit, business was 197/1 (workflow-roles, above).
|
||||
- Singles, each file run whole, no slot. 22 files, 172 pass, 0 fail, 0 skipped. These are round 3's 17 files
|
||||
with the same counts, plus:
|
||||
|
||||
| File | Pass |
|
||||
|------|------|
|
||||
| bus project-runtime (the import pin) | 3 |
|
||||
| seat sandbox (the RUNNER_FILES walk and the three confined sessions, none skipped) | 10 |
|
||||
| business workflow-roles, chief-of-staff | 7, 10 |
|
||||
| bus workflow-roles | 4 |
|
||||
|
||||
Round 3's 17 files on cb5fdb064, unchanged in round 4:
|
||||
|
||||
| File | Pass |
|
||||
|------|------|
|
||||
| business hosts, hard-waits | 7, 5 |
|
||||
| bus host-run | 21 |
|
||||
| bus hard-waits, credentials, credential-store, credential-verbs, credential-status | 13, 5, 12, 2, 4 |
|
||||
| bus business, web, approval-binding, decisions, single-use | 1, 19, 6, 7, 12 |
|
||||
| bus publish, transport, instances-verb | 12, 5, 2 |
|
||||
| cli credentials (199's new rotate test included) | 5 |
|
||||
|
||||
- After the runs, no sshd of mine is left and no `h-` dir is under the run roots.
|
||||
- Hash pins: no test pins a hash of broker.mjs, hosts.mjs, vocabulary.mjs or a role contract. The 64-hex pins
|
||||
are migration DDL, channel-proof's four files and one webui render, none touched here.
|
||||
- `node tools/unslop-hook/unslop-check.js` is clean on this file.
|
||||
- Not run: the cli and other suites, `scripts/test-*.sh` and the mutant batch. They need Sage's slot.
|
||||
|
||||
## Follow-up, N5
|
||||
|
||||
`packages/business/tests/` helpers leave `mosaic-business-*` dirs under the tmp dir after a run. This predates
|
||||
row 210 and isn't changed here. It is open as Gitea #1624 ("Test suites leak mosaic-business-* and
|
||||
mosaic-launch-* directories in TMPDIR"), so no new row (mos-1104).
|
||||
|
||||
## Follow-up for 199, closed
|
||||
|
||||
CLI `ISSUED` lacked `rotation-due`, so `credentials rotate` on a key in that state refused. Megavolt fixed it
|
||||
inside 199 at 07141966e (megavolt-0027), which is this round's base.
|
||||
|
||||
## Boundaries kept
|
||||
|
||||
Nothing touched `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files, the live units or `~/.mosaic`. No key was
|
||||
read, copied or printed. The keys the tests use are generated in `mkdtemp` dirs. No test or seat command made
|
||||
an ssh connection to anything but the scratch sshd on 127.0.0.1. Row 199's kinds and regex are unchanged
|
||||
inside 199. This row changes them on its own branch.
|
||||
@@ -1,538 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,23 +0,0 @@
|
||||
8fa8a321933e75315babe21f49ba434a61dbeffbfcfded6933ebfaf6c73d3aee packages/business/src/business.mjs
|
||||
e72f0898b5f373c5a76d4c41fbe504904f55e18b183768260a6423510625683e packages/business/src/credentials.mjs
|
||||
6339bf85c946a41a5cbe8e94d51b264e298a9d16900e5dfe46b01b803e76ca41 packages/business/src/hosts.mjs
|
||||
ab5f7dc38a2ca5978b02d257068f4f82706db847cf0d6400f7cd548f8f6abe43 packages/business/src/resolve.mjs
|
||||
636916b9868b3a3e7f962894cbfa1e91629b5713a568f55f4716f942dbcd4910 packages/business/src/role.mjs
|
||||
77c432e879f8611a077feb2fb7c9b67e826ade558e41dcf01433fc2cdab2e0fa packages/business/src/vocabulary.mjs
|
||||
6a2c9b371fc9236bb0730ed108cbd770e9487b8ba733dbebccd11891ccba6f17 packages/business/tests/hard-waits.test.mjs
|
||||
9ef8b8be3c290f072e7c943b176a1ec688fda03fab4ba114d31213caf2216e42 packages/business/tests/hosts.test.mjs
|
||||
fcaf50728e4599e5f10875f01c22b03610044b634c999830ef9684ef74cc62f3 packages/bus/src/broker.mjs
|
||||
ff927da3cf22fbacc9ace77b2e8b803bffc2953fb58cf7ab4e5f6f6493732996 packages/bus/src/business.mjs
|
||||
27d2ae155a2919788d7ad02da10a7056c23f5b71228e9af406b1b8aa0de55efc packages/bus/src/credentials.mjs
|
||||
86b4321dd436b97e389101873197dbab95b3d2202e5ef7da712c4978f4f9159f packages/bus/src/credential-verbs.mjs
|
||||
5d227edec1d33cb58607fae4e43784a0decc8aaf55cad6bc4318cec4eda6fbea packages/bus/src/host-run.mjs
|
||||
3721190045a4f114d67c9c7d32a448cf3d5dbbc6d4554dbc348e9f6fd428b5dc packages/bus/src/runtime.mjs
|
||||
3ef86e4f0b77cc513bdd58ec7eb984ceb02af6625f39d1be5254f2c0b0e99734 packages/bus/src/server.mjs
|
||||
58e45b7a0d0118be8827bd71d1a427ff84d0ecac46c22adece36dec36e6d521d packages/bus/tests/hard-waits.test.mjs
|
||||
28ad8ce2a29722768820ad916273f2100eb79281255c6a3689964cd24564237c packages/bus/tests/host-run.test.mjs
|
||||
be4e1eb88b689e8e4f859de75218af151feae8dce2c1409b726cb9c3dcd5ffe9 packages/bus/tests/spec-fixtures.mjs
|
||||
19755f7bb002d8455990c5dc7c458e64aeb95252ec7231a2ed2fd9c638fcb511 packages/cli/src/credentials.mjs
|
||||
152a3e57f7241db45bf8262fbf86ee029cf3b98f0d75503990abd46ac151a4b2 packages/cli/tests/credentials.test.mjs
|
||||
bdf88833cdb0c5ef76acec50c3c983b55c6053987a5c427cbc400ebd5f3c7110 packages/bus/tests/project-runtime.test.mjs
|
||||
e222a5fc3c117f62dbc854ef7397f7b5589e6f5d52e45398db0deeb43c53a9d5 roles/chief-of-staff.md
|
||||
d1016f8c34ee04601ab751c7514a20dfbeec0359d46dba8f94c44538dcc16133 roles/pm.md
|
||||
@@ -1,244 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Row 210 (#1707) mutants: design section 9, M1 to M35, and round 2's M36 to M38 for
|
||||
duckworth-0052 N1 to N3.
|
||||
|
||||
Usage: mutants.py <checkout> <scratch-dir>
|
||||
|
||||
Copies packages/ and roles/ from <checkout> into <scratch-dir> and links
|
||||
node_modules, applies one mutant at a time (each anchor must match exactly
|
||||
once), runs each listed test file in its own process group with a timeout, and
|
||||
expects at least one to fail. Prints one line per mutant and exits 1 if any
|
||||
survives, an anchor misses, or the unmutated control fails. Needs Sage's slot.
|
||||
Writes nothing outside <scratch-dir>. The host-run tests start their own sshd
|
||||
on 127.0.0.1 and reach no other host.
|
||||
"""
|
||||
import os, shutil, signal, subprocess, sys
|
||||
|
||||
BROKER = "packages/bus/src/broker.mjs"
|
||||
CREDS = "packages/bus/src/credentials.mjs"
|
||||
RUN = "packages/bus/src/host-run.mjs"
|
||||
HOSTS = "packages/business/src/hosts.mjs"
|
||||
# Row 210 r4: validArgv and commandAllowed moved here so the sandboxed broker can read them.
|
||||
VOCAB = "packages/business/src/vocabulary.mjs"
|
||||
BUSINESS = "packages/business/src/business.mjs"
|
||||
RUNTIME = "packages/bus/src/runtime.mjs"
|
||||
|
||||
HOST_TEST = ("packages/bus", "tests/host-run.test.mjs")
|
||||
HOSTS_TEST = ("packages/business", "tests/hosts.test.mjs")
|
||||
|
||||
# (name, file, [(old, new), ...], [test, ...])
|
||||
MUTANTS = [
|
||||
("M01 registry check", BROKER,
|
||||
[(" if (!Object.hasOwn(b.hosts ?? {}, a.host)) fail('host-unknown');\n", "")], [HOST_TEST]),
|
||||
("M02 agent grant check", BROKER,
|
||||
[("\n if (!grant) fail('host-not-granted');\n", "\n")], [HOST_TEST]),
|
||||
("M03 grant read from any agent", BROKER,
|
||||
[("const grant = Object.hasOwn(b.roles[s.role]?.hosts ?? {}, a.host) ? b.roles[s.role].hosts[a.host] : null;",
|
||||
"const any = Object.assign({}, ...Object.values(b.roles).map((r) => r.hosts ?? {})); const grant = any[a.host] ?? null;")],
|
||||
[HOST_TEST]),
|
||||
("M04a argv array and count", VOCAB,
|
||||
[("if (!Array.isArray(argv) || argv.length === 0 || argv.length > ARGV_MAX) return false;",
|
||||
"if (!Array.isArray(argv)) return false;")], [HOSTS_TEST, HOST_TEST]),
|
||||
("M04b argv element string, empty, NUL", VOCAB,
|
||||
[(' if (typeof element !== "string" || element === "" || element.includes("\\0")) return false;\n', "")],
|
||||
[HOSTS_TEST, HOST_TEST]),
|
||||
("M04c argv element bytes", VOCAB,
|
||||
[(" if (bytes > ARG_BYTES) return false;\n", "")], [HOSTS_TEST, HOST_TEST]),
|
||||
("M04d argv total bytes", VOCAB,
|
||||
[(' return total <= ARGV_BYTES && (!argv[0].includes("/") || argv[0].startsWith("/"));',
|
||||
' return (!argv[0].includes("/") || argv[0].startsWith("/"));')], [HOSTS_TEST, HOST_TEST]),
|
||||
("M04e argv[0] path form", VOCAB,
|
||||
[(' return total <= ARGV_BYTES && (!argv[0].includes("/") || argv[0].startsWith("/"));',
|
||||
" return total <= ARGV_BYTES;")], [HOSTS_TEST, HOST_TEST]),
|
||||
("M05 prefix compares argv[0] only", VOCAB,
|
||||
[("prefix.every((element, i) => element === argv[i])", "prefix[0] === argv[0]")], [HOSTS_TEST, HOST_TEST]),
|
||||
("M06 string commands accepted", HOSTS,
|
||||
[(" if (!Array.isArray(commands)) refuse(",
|
||||
' if (typeof commands === "string") return commands;\n if (!Array.isArray(commands)) refuse(')], [HOSTS_TEST]),
|
||||
("M07 authority not consumed", BROKER,
|
||||
[(" this.#store.transaction(() =>\n this.#consumeAuthority(\n s,\n 'host.sudo',",
|
||||
" false && this.#store.transaction(() =>\n this.#consumeAuthority(\n s,\n 'host.sudo',")],
|
||||
[HOST_TEST]),
|
||||
("M08 argv hash not bound", BROKER,
|
||||
[(" argvSha256: sha256(JSON.stringify(argv)),", " argvSha256: '',")], [HOST_TEST]),
|
||||
("M09 stdin hash not bound", BROKER,
|
||||
[(" stdinSha256: sha256(stdin ?? ''),", " stdinSha256: '',")], [HOST_TEST]),
|
||||
("M10 quote drops the '\\'' replacement", RUN,
|
||||
[("\"'\" + element.replaceAll(\"'\", \"'\\\\''\") + \"'\"", "\"'\" + element + \"'\"")], [HOST_TEST]),
|
||||
("M11 sudo loses -n", RUN,
|
||||
[("['sudo', '-n', '--',", "['sudo', '--',")], [HOST_TEST]),
|
||||
("M12 pin skipped by accept-new", RUN,
|
||||
[("...o('StrictHostKeyChecking=yes')", "...o('StrictHostKeyChecking=accept-new')")], [HOST_TEST]),
|
||||
("M13 user known_hosts", RUN,
|
||||
[("...o(`UserKnownHostsFile=${join(dir, 'known_hosts')}`)", "...o('UserKnownHostsFile=~/.ssh/known_hosts')")],
|
||||
[HOST_TEST]),
|
||||
("M14 -F /dev/null removed", RUN,
|
||||
[(" '-F', '/dev/null', '-T',", " '-T',")], [HOST_TEST]),
|
||||
("M15 result key check removed", CREDS,
|
||||
[(" assertCleanKey(result, entry.key);\n", "")], [HOST_TEST]),
|
||||
("M16 leak check uses the joined body only", CREDS,
|
||||
[("const needles = [...body.filter((l) => l.length >= 24), joined, seed.toString('hex')];",
|
||||
"const needles = [joined];"),
|
||||
(" for (const offset of [0, 1, 2]) {", " for (const offset of []) {")], [HOST_TEST]),
|
||||
("M17 stdin key check removed", CREDS,
|
||||
[(" assertCleanKey(input, entry.key);\n", "")], [HOST_TEST]),
|
||||
("M18 finally cleanup removed", RUN,
|
||||
[(" group(ssh, 'SIGKILL');\n group(agent, 'SIGKILL');\n rmSync(dir, { recursive: true, force: true });\n", "")],
|
||||
[HOST_TEST]),
|
||||
("M19 key left on disk", RUN,
|
||||
[(" writeFileSync(join(dir, 'id.pub'), key.publicLine + '\\n', { mode: 0o600, flag: 'wx' });\n",
|
||||
" writeFileSync(join(dir, 'id.pub'), key.publicLine + '\\n', { mode: 0o600, flag: 'wx' });\n"
|
||||
" writeFileSync(join(root, 'id-' + hostId), key.text, { mode: 0o600 });\n")], [HOST_TEST]),
|
||||
("M20 output cap removed", RUN,
|
||||
[(" const room = limits.outputMax - sizes[name];", " const room = Infinity;")], [HOST_TEST]),
|
||||
("M21 timeout kill removed", RUN,
|
||||
[(" group(ssh, 'SIGTERM');\n kill = setTimeout(() => group(ssh, 'SIGKILL'), limits.graceMs);\n", "")],
|
||||
[HOST_TEST]),
|
||||
("M22a busy check removed", BROKER,
|
||||
[(" if (executor.busy(s, a.host)) fail('host-busy');\n", "")], [HOST_TEST]),
|
||||
("M22b busy check removed in the store", CREDS,
|
||||
[(" if (this.#inflight.has(k)) deny('host-busy');\n", "")], [HOST_TEST]),
|
||||
("M23 revoke doesn't abort", CREDS,
|
||||
[(" this.#inflight.get(instance + ':' + service)?.abort();\n", "")], [HOST_TEST]),
|
||||
("M24 event records stdout", BROKER,
|
||||
[(" stdoutBytes: Buffer.byteLength(out.stdout),", " stdout: out.stdout,\n stdoutBytes: Buffer.byteLength(out.stdout),")],
|
||||
[HOST_TEST]),
|
||||
("M25a cipher and kdf check", CREDS,
|
||||
[(" if (str().toString() !== 'none' || str().toString() !== 'none' || str().length !== 0) deny('credential-format');\n",
|
||||
" str(); str(); str();\n")], [HOST_TEST]),
|
||||
("M25b public type check", CREDS,
|
||||
[(" publicBlob.subarray(4, 15).toString() !== 'ssh-ed25519' ||\n", "")], [HOST_TEST]),
|
||||
("M25c private type check", CREDS,
|
||||
[(" if (pstr().toString() !== 'ssh-ed25519') deny('credential-format');", " pstr();")], [HOST_TEST]),
|
||||
("M25d key count check", CREDS,
|
||||
[(" if (u32() !== 1) deny('credential-format');", " u32();")], [HOST_TEST]),
|
||||
("M26 organization.set takes hosts", BROKER,
|
||||
[("export const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances', 'reset', 'assistants'];",
|
||||
"export const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances', 'reset', 'assistants', 'hosts'];")],
|
||||
[HOST_TEST]),
|
||||
("M27 definition without host", BUSINESS,
|
||||
[(" if (!services.includes(HOST_SERVICE)) refuse(", " if (false) refuse(")], [HOSTS_TEST]),
|
||||
("M28a shell and runner check", HOSTS,
|
||||
[(" if (refusedCommand(prefix[0])) refuse(", " if (false) refuse(")], [HOSTS_TEST]),
|
||||
("M28b no basename", HOSTS,
|
||||
[(' const base = first.split("/").pop();', " const base = first;")], [HOSTS_TEST]),
|
||||
("M29 glob check", HOSTS,
|
||||
[(" if (/[*?[]/.test(element)) refuse(", " if (false) refuse(")], [HOSTS_TEST]),
|
||||
("M30 requested drops argv", BROKER,
|
||||
[(" argv: a.argv,\n stdinBytes:", " stdinBytes:")], [HOST_TEST]),
|
||||
("M31 question omits stdin", BROKER,
|
||||
[(" `\\nstdin: ${JSON.stringify(a.stdin ?? '')}`;", " '';")], [HOST_TEST]),
|
||||
("M32 no --pdeathsig", RUN,
|
||||
[(" spawn(KILL_WITH_PARENT[0], [...KILL_WITH_PARENT.slice(1), bin, ...args], { env, detached: true, stdio });",
|
||||
" spawn(bin, args, { env, detached: true, stdio });")], [HOST_TEST]),
|
||||
("M33 ssh-add without -t", RUN,
|
||||
[("['-q', '-t', String(lifetime), '-']", "['-q', '-']")], [HOST_TEST]),
|
||||
("M34a sweep does nothing", RUN,
|
||||
[("export function sweepRunDir(root) {\n", "export function sweepRunDir(root) {\n return { removed: [] };\n")],
|
||||
[HOST_TEST]),
|
||||
("M34b sweep ignores the bus pid", RUN,
|
||||
[(" if (bus && alive(Number(bus[0]), bus[1])) continue;\n", "")], [HOST_TEST]),
|
||||
("M34c sweep ignores the start time", RUN,
|
||||
[("const alive = (pid, start) => start !== null && startTime(pid) === start;",
|
||||
"const alive = (pid) => startTime(pid) !== null;")], [HOST_TEST]),
|
||||
("M34d bus start never sweeps", RUNTIME,
|
||||
[(" if (runRoot) sweepRunDir(runRoot);\n", "")], [HOST_TEST]),
|
||||
("M35 own-machine check", HOSTS,
|
||||
[(" if (ownMachine(address)) refuse(", " if (false) refuse(")], [HOSTS_TEST]),
|
||||
("M36a host.sudo raise skips the grant check", BROKER,
|
||||
[(" if (!grant) fail('host-not-granted');\n", "")], [HOST_TEST]),
|
||||
("M36b host.sudo raise skips the prefix check", BROKER,
|
||||
[(" if (!commandAllowed(a.argv, grant.commands)) fail('command-not-granted');\n", "")], [HOST_TEST]),
|
||||
("M37a addKey has no time limit", RUN,
|
||||
[(" const timer = setTimeout(() => stop('host-failed'), limitMs);", " const timer = null;")], [HOST_TEST]),
|
||||
("M37b addKey ignores abort", RUN,
|
||||
[(" const abort = () => stop('credential-unavailable');", " const abort = () => {};")], [HOST_TEST]),
|
||||
("M38a mismatch read from the remote stderr too", RUN,
|
||||
[(" if (log.includes('Host key verification failed')) deny('host-key-mismatch');",
|
||||
" if ((log + out.stderr).includes('Host key verification failed')) deny('host-key-mismatch');")], [HOST_TEST]),
|
||||
("M38b no -E log", RUN,
|
||||
[("'-T', '-E', join(dir, 'ssh.log'), ", "'-T', ")], [HOST_TEST]),
|
||||
]
|
||||
|
||||
# M21 and M23 leave a 30 s sleep running, so a test file gets room past it.
|
||||
TIMEOUT = 240
|
||||
|
||||
|
||||
def run(scratch, test):
|
||||
cwd, path = test
|
||||
proc = subprocess.Popen(["node", "--test", path], cwd=os.path.join(scratch, cwd),
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
|
||||
try:
|
||||
out, _ = proc.communicate(timeout=TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
proc.communicate()
|
||||
return None, "timeout"
|
||||
# A test's sshd, ssh-agent or sleep left in the group goes too.
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
return proc.returncode, out.decode(errors="replace")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 3:
|
||||
sys.exit("usage: mutants.py <checkout> <scratch-dir>")
|
||||
checkout, scratch = (os.path.abspath(a) for a in sys.argv[1:])
|
||||
if not os.path.isfile(os.path.join(checkout, RUN)):
|
||||
sys.exit(f"no {RUN} under {checkout}")
|
||||
if os.path.exists(scratch) and os.listdir(scratch):
|
||||
sys.exit(f"scratch dir not empty: {scratch}")
|
||||
os.makedirs(scratch, exist_ok=True)
|
||||
ignore = shutil.ignore_patterns("node_modules", "__pycache__")
|
||||
shutil.copytree(os.path.join(checkout, "packages"), os.path.join(scratch, "packages"), ignore=ignore)
|
||||
shutil.copytree(os.path.join(checkout, "roles"), os.path.join(scratch, "roles"))
|
||||
os.symlink(os.path.realpath(os.path.join(checkout, "node_modules")), os.path.join(scratch, "node_modules"))
|
||||
originals = {}
|
||||
for _, f, _, _ in MUTANTS:
|
||||
originals.setdefault(f, open(os.path.join(scratch, f)).read())
|
||||
|
||||
for test in (HOSTS_TEST, HOST_TEST):
|
||||
code, out = run(scratch, test)
|
||||
if code != 0:
|
||||
print(out)
|
||||
sys.exit(f"control failed on {test[1]} (exit {code}): the unmutated tests must pass")
|
||||
print("control: pass")
|
||||
|
||||
bad = 0
|
||||
for name, f, edits, tests in MUTANTS:
|
||||
target = os.path.join(scratch, f)
|
||||
mutated = originals[f]
|
||||
missed = [old for old, _ in edits if mutated.count(old) != 1]
|
||||
if missed:
|
||||
print(f"{name}: ANCHOR {[originals[f].count(old) for old in missed]}")
|
||||
bad += 1
|
||||
continue
|
||||
for old, new in edits:
|
||||
mutated = mutated.replace(old, new)
|
||||
open(target, "w").write(mutated)
|
||||
try:
|
||||
killed = None
|
||||
for test in tests:
|
||||
code, _ = run(scratch, test)
|
||||
if code != 0:
|
||||
killed = test[1] + (" (timeout)" if code is None else "")
|
||||
break
|
||||
finally:
|
||||
open(target, "w").write(originals[f])
|
||||
if killed is None:
|
||||
print(f"{name}: SURVIVED")
|
||||
bad += 1
|
||||
else:
|
||||
print(f"{name}: killed by {killed}")
|
||||
for f, text in originals.items():
|
||||
if open(os.path.join(scratch, f)).read() != text:
|
||||
print(f"restore check failed: {f}")
|
||||
bad += 1
|
||||
print(f"{len(MUTANTS)} mutants, {bad} bad")
|
||||
sys.exit(1 if bad else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,104 @@
|
||||
# Row 230: C2-13a unit render and host precheck
|
||||
|
||||
Gitea #1732. Vikunja #416 (track T2). Brief: docs/plans/2026-10-20_case2-rows.md, section "C2-13a",
|
||||
blob f6b4933f0f47 (01b40d357, sage-1562). Base: 00d4cc1f6 (row 190). Reviewer: Webby. Gate: the cli
|
||||
suite plus every scripts/test-*.sh.
|
||||
|
||||
## What ships
|
||||
|
||||
`scripts/case2-unit.sh` renders `[email protected]` and `mosaic-webui.service` from the two existing
|
||||
templates into the system unit directory, for the default instance only. It writes each unit through
|
||||
a temp file at mode 0644, runs `daemon-reload`, enables both units, starts the bus and then the
|
||||
Console. If the bus fails to start, the script refuses and leaves the Console unstarted. Both units
|
||||
get `WantedBy=multi-user.target`, and neither gets `After=` or `Wants=`. The script never links a unit
|
||||
file into place. A re-install refuses unless each installed unit passes the same checks, both units
|
||||
name one copy, and each equals the render apart from the copy id.
|
||||
|
||||
`--profile library` (the default) refuses `--pm`, `--claude`, `--tester-port` and `--sso`, and needs
|
||||
the preview proxy flags plus the proof pair. `--profile factory` refuses the six proxy flags and takes
|
||||
`--pm` and `--claude`. Both refuse `--checkout` and `--board`.
|
||||
|
||||
`scripts/case2-precheck.sh --business ID [--profile factory|library]` runs as `<user>`. It prints one
|
||||
`ok` or `REFUSED` line per check and exits 1 if any check refused. It prints variable names only,
|
||||
never values.
|
||||
|
||||
`packages/cli/README.md:713` now says nothing creates the session env file, only the Case 2 precheck
|
||||
reads it, and the precheck prints only variable names (webby-0034 N2, webby-0035).
|
||||
|
||||
## Deviations for review
|
||||
|
||||
Sage accepted all six for the build (sage-1570). Webby rules on each.
|
||||
|
||||
1. The precheck takes `--business ID`. The brief names no other way to find the business file.
|
||||
2. A run root `<dataRoot>/launches/<business>` that doesn't exist passes, with a note that the first
|
||||
launch creates it at 0700 (session.mjs:47 and newRun). A symlink refuses, and so does an existing
|
||||
entry that isn't a 0700 directory.
|
||||
3. The claude marker check passes when the reply holds the marker and input plus cache tokens are at
|
||||
most 2000. **The 2000 ceiling is unmeasured.** The tests use fakes at 40 and 9000 tokens. Measuring
|
||||
it on a real claude belongs to row 195's install run, not this row.
|
||||
4. A non-root test can't create a root-owned proof file. For the "proof not owned by the user" case,
|
||||
the fake getent reports another uid for the user, so the user's own file reads as someone else's.
|
||||
5. The unit script refuses `%h` left in any unit, rendered or installed. A system unit expands `%h`
|
||||
to root's home. The render replaces every `%h`, so the test edits an installed unit to reach it.
|
||||
6. With `--auth-header X-Authentik-Uid`, the allowed user must be 64 lower-case hex characters (lead
|
||||
decision 101). The test covers both the refusal and the pass.
|
||||
|
||||
## Tests (packages/cli/tests/case2-unit.test.mjs, 30 cases after round 2)
|
||||
|
||||
The tests use a scratch unit directory and a scratch home, plus fake systemctl, getent, sudo, id,
|
||||
node, bwrap and claude. They never touch /etc/systemd, ~/.mosaic-dev, ~/.config/mosaic-dev or a
|
||||
live unit. Every precheck case asserts its output doesn't hold the fixture key.
|
||||
|
||||
- Unit: the clean library render, PATH for a node outside the default list and for /usr/bin/node, the
|
||||
systemctl order, a failed bus start, no `systemctl link`, factory `--pm` and `--claude` rendered and
|
||||
absent, re-install to another copy, edits to an installed unit, template restrictions,
|
||||
EnvironmentFile placement, unknown tokens, PermissionsStartOnly and `+`/`-+` Exec prefixes,
|
||||
symlinked unit paths, bad `--node`, re-install drift, a split copy pair, bad copy ids, every library
|
||||
and factory flag refusal, and bad `--claude`.
|
||||
- Precheck: a good host, node older than 24, more than one group, sudo succeeding, XDG_DATA_HOME,
|
||||
HOME mismatch, a run root mode, a bad config, bwrap failing, the port floor at or below the Console
|
||||
port, a missing Console unit, bus.tester per profile, profile-library-business, the claude check
|
||||
(no route, replaces, appends, missing, the unit's MOSAIC_CLAUDE_BIN beating PATH), and the session
|
||||
env file (missing, 0644, symlink, empty, emptied by a later line, quoted).
|
||||
- Bind (mos-1100): inside `binds()` plus `sandboxArgv()`, a bind on port 980 gets EACCES, and a bind
|
||||
on a free port of 1024 or above succeeds. The test fails if the host's floor is at or below 980.
|
||||
|
||||
## Verification
|
||||
|
||||
Round 1 singles, run before the gate: case2-unit.test.mjs passes 28/28 with no skips, and host.test.mjs (it
|
||||
reads the cli README) passes 42/42. No test pins a hash of the README. No other test reads the two
|
||||
scripts.
|
||||
|
||||
## Round 2 (webby-0046, sage-1589, sage-1590, sage-1591)
|
||||
|
||||
Base: origin/refactor (rebased from 00d4cc1f6; the 15 commits between touch none of the five paths
|
||||
or the two unit templates). Branch gosalyn/c2-13a-r2.
|
||||
|
||||
B1. The precheck now has a case for each refusal Webby named: the user missing from passwd (:77),
|
||||
a business file that is unparsable, missing or fails the schema (:137), a run root that is a symlink
|
||||
to a 0700 directory (:151), a Console ExecStart with no --port (:179), an unreadable port floor
|
||||
(:180), a claude reply without the marker (:223) and a claude reply that isn't JSON (:224). The fake
|
||||
claude gained `nomarker` and `text` kinds. The no-claude-code case and the missing-claude case share
|
||||
a PATH without `/usr/bin/claude` (`pathWithoutClaude`), so the no-claude-code case now shows a host
|
||||
with no claude passes.
|
||||
|
||||
N1 (ruled in for this round). Before writing, `case2-unit.sh` asks `systemctl is-active` for both
|
||||
units. For each unit that was running, it prints that the unit is still on its old copy, never
|
||||
"started", and then that the bus and Console must restart together to reach the new copy. A unit
|
||||
that wasn't running gets "started ... on copy". A fresh install prints the round 1 line. The script
|
||||
still restarts nothing. The new test covers both units running and only the Console running.
|
||||
|
||||
N4. The README line at :713 is rewrapped.
|
||||
|
||||
Mutants, run in Sage's slot on a scratch worktree, one branch each: :77, :137, :151, :179, :180,
|
||||
:223, :224, a precheck that refuses with no claude on PATH (for the no-claude-code case), and an
|
||||
install that prints "started" for a running unit (sage-1591).
|
||||
|
||||
## Carry items for row 195
|
||||
|
||||
- N1's restart. Row 195 restarts the bus and Console together after a re-install over running
|
||||
units, as the install line now asks.
|
||||
- N2. A system drop-in check like `bus-service.sh:122-145`, so a drop-in under
|
||||
`[email protected]/` or `mosaic-webui.service.d/` can't change what the checked unit runs.
|
||||
- N3. The precheck finds claude on the shell PATH. The bus unit's `PATH=` is what a session uses
|
||||
when `MOSAIC_CLAUDE_BIN` is unset, so the check should search that PATH.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Row 237: one decision publishes once (#1743)
|
||||
|
||||
Megavolt, 2026-10-21 (from `date -u`). Brief: `docs/plans/2026-10-20_agent-credentials.md`
|
||||
§ "row 199 follow-up N1", blob dca67d310e1b. Source: Duckworth's row 199 round 1 note N1
|
||||
(duckworth-0068). Base: `cb5fdb064` (row 199's landing). Reviewer Duckworth, gate owner Sage.
|
||||
|
||||
## The defect
|
||||
|
||||
`checkPublish` opens a ticket after checking the decision is unspent. The decision is spent only at
|
||||
`recordPublish`, which runs after the bus process has pushed. Two `checkPublish` calls on one decision
|
||||
therefore both get tickets, and both push. With different branches, the commit lands on two fork refs:
|
||||
`refs/heads/<user>/feat` and `refs/heads/<user>/other`. The second `recordPublish` then refuses
|
||||
`decision-consumed`, but by then its push has already reached the fork.
|
||||
|
||||
## The fix, and why this option
|
||||
|
||||
`checkPublish` refuses `publish-busy` when an open ticket in `#publishes` holds the same business and
|
||||
decision (`packages/bus/src/broker.mjs:1038-1040`). It runs after the authority and fact checks, so a
|
||||
spent decision still refuses `decision-consumed` first.
|
||||
|
||||
The brief's other option, rechecking `#spent` in `publishTarget`, doesn't close the race. Both tickets
|
||||
reach `publishTarget` before either push ends, and nothing is spent until `recordPublish`. So both
|
||||
rechecks pass and both pushes run. The only state that exists while the first push runs is the open
|
||||
ticket, so the check reads that.
|
||||
|
||||
A refusal (`publishRefused`) drops the ticket and leaves the decision unspent, so a retry on the same
|
||||
decision gets a new ticket. That is row 199's behaviour, unchanged. A within-role publish has no
|
||||
decision and isn't affected.
|
||||
|
||||
## Tests
|
||||
|
||||
- `packages/bus/tests/publish.test.mjs:186`. A second ticket refuses `publish-busy`, on the same branch
|
||||
and on another. After `publishRefused` the retry gets a ticket and a third refuses busy. After
|
||||
`recordPublish` there is one `action.allowed` for git.push.remote and the next call refuses
|
||||
`decision-consumed`. Another agent's decision on the same task isn't held.
|
||||
- `packages/cli/tests/publish-socket.test.mjs:269`. End to end through the git socket, the bus runtime
|
||||
and the TLS fake Gitea. While the first publish holds its ticket, a second publish on the same
|
||||
decision to branch `other` gets `publish-busy`. The fake Gitea gets exactly one `git-receive-pack`,
|
||||
the fork has only `refs/heads/coder/feat`, and there is one `action.allowed`.
|
||||
|
||||
Runs on this branch, single files:
|
||||
- publish.test.mjs: 13/13.
|
||||
- publish-socket.test.mjs: 8/8.
|
||||
|
||||
## Mutant
|
||||
|
||||
M1 removes the `publish-busy` line. Both tests fail:
|
||||
- The bus test fails at the first busy assert.
|
||||
- The cli test fails because the second publish answers `ok: true` with `ref: refs/heads/coder/other`.
|
||||
That is the defect: two refs on the fork.
|
||||
|
||||
Logs are in `/mnt/storage/scratch/megavolt-r237/` (m1-bus.log, m1b-cli.log). `cmp` confirmed
|
||||
broker.mjs matched the saved copy after the restore. The first cli mutant run (m1-cli.log) was against
|
||||
a hook that recursed on every checkPublish. I fixed the hook to fire only for branch `feat` and reran
|
||||
it (m1b-cli.log).
|
||||
|
||||
## Residuals, not fixed here
|
||||
|
||||
1. **A ticket that never ends holds its decision until the bus restarts.** `#publishes` is in memory.
|
||||
That happens when the host dies between checkPublish and recordPublish or publishRefused. It fails
|
||||
closed (`publish-busy`), and a restart clears it.
|
||||
2. **publish-timeout (sage-1440).** The socket files `publishRefused` and drops the ticket, but the bus
|
||||
process's push may still be running. A retry on the same decision can then open a ticket while that
|
||||
push is live. This is the same residual the socket already logs ("the bus process's push may still
|
||||
reach the fork"). Closing it means the runtime's `remotes` must keep the decision busy until its push
|
||||
settles, which reaches into runtime.mjs. That is beyond the brief's two options.
|
||||
3. **checkLand has the same shape.** `#landings` takes a ticket per call and the decision is spent at
|
||||
`recordLand`. Whether two landings on one decision can both reach main isn't checked here. That's a
|
||||
follow-up for Sage to row or dismiss.
|
||||
|
||||
## Gate
|
||||
|
||||
Per the brief: Duckworth approves the manifest, the bus suite and every `scripts/test-*.sh` are green
|
||||
on Sage's gate, and M1 is killed. This row also changes a cli test file, so the cli suite (or that file
|
||||
alone) should run on the gate too.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Row 241: one decision lands once (#1747, P19 441)
|
||||
|
||||
Megavolt, 2026-10-21 (from `date -u`). Brief: `docs/plans/2026-10-20_agent-credentials.md`
|
||||
§ "Brief: row 237 follow-up, one decision lands once, with tests at the broker", c25785d3d. Source:
|
||||
row 237 residual 3, megavolt-0068, megavolt-0069 and sage-1651. Base: origin/refactor f5753c0e8, row 210's landing.
|
||||
Reviewer Duckworth, gate owner Sage. Tests only; no source changes.
|
||||
|
||||
## Why landing has no row 237 race
|
||||
|
||||
Row 237 fixed publish: two tickets on one decision both pushed, because the decision is spent only
|
||||
after the push, and a push to a remote fork can't be taken back. Landing has the same ticket shape
|
||||
(`checkLand` opens a ticket, `recordLand` spends the decision), but three guards keep one decision
|
||||
from landing twice:
|
||||
|
||||
1. **land-busy, the outer guard.** `doLand` in `packages/cli/src/git-socket.mjs:351` refuses
|
||||
`land-busy` while another landing runs. The slot is taken before the first await, and freed only
|
||||
when the landing ends. There is one git socket per launcher, and one host per dataRoot (host.lock).
|
||||
So through the socket two landings never overlap, and a later one stops at `checkLand` with
|
||||
`decision-consumed`. Even past that, applying the same reviewed patch to the new main fails
|
||||
`base-conflict` in `prepareLand`.
|
||||
2. **The CAS on main.** `finishLand` (`packages/seat/src/working.mjs:391`) moves main with
|
||||
`update-ref main <commit> <old>`, so a landing prepared on a tip that has since moved refuses
|
||||
`base-moved` and writes nothing.
|
||||
3. **The recordLand recheck.** `finishLand` calls `record()`, which is `recordLand`, under the same
|
||||
repository lock. `recordLand` runs `#landFacts` again (`packages/bus/src/broker.mjs:963`), which
|
||||
refuses `decision-consumed` once the decision is spent. When `record()` throws, `finishLand` moves
|
||||
main back.
|
||||
|
||||
Guards 2 and 3 can't be reached end to end, because guard 1 stops a second landing first. So they are
|
||||
pinned below the socket, and no test bypasses land-busy.
|
||||
|
||||
### The unrecorded-commit window
|
||||
|
||||
Between the `update-ref` and the end of `record()`, main names the squash, which isn't recorded yet.
|
||||
That window is under the repository's lock, so no other push, review or landing in this host sees
|
||||
it. If `record()` refuses, main moves back. If moving back fails, `finishLand` refuses
|
||||
`rollback-failed` and main keeps naming an unrecorded commit (tested at `working.test.mjs:555`, the
|
||||
main.lock case). That is existing behaviour, unchanged here.
|
||||
|
||||
## The test
|
||||
|
||||
`packages/bus/tests/land.test.mjs:312`. Two tickets pass `checkLand` on one decision before either
|
||||
records. `recordLand` on the first lands. `recordLand` on the second refuses `decision-consumed`
|
||||
(:319), and leaves one merge `action.allowed`, one RESULT and no `action.refused`. `landRefused`, as
|
||||
the socket would call it, then files the refusal and ends the ticket.
|
||||
|
||||
**The assert that pins the recordLand recheck is :319.** The `#spent` line is shared with
|
||||
`checkLand`, so dropping it also fails :144 (a `checkLand` after the decision is spent). Only :319
|
||||
shows the recheck at record time.
|
||||
|
||||
Single file on this branch: land.test.mjs 18/18.
|
||||
Whole suites on this branch before in-review: bus 500/500, seat 164/164, cli 565/565 (02:52 to 03:01Z).
|
||||
|
||||
## Mutants
|
||||
|
||||
| Mutant | Change | Killed by |
|
||||
|---|---|---|
|
||||
| M1 | drop the `#spent` line in `#landFacts` (broker.mjs:963) | land.test.mjs: :312 at :319, and :94 at :144 (16/2) |
|
||||
| M2 | drop `land.old` from the `update-ref` in `finishLand` (working.mjs:391) | working.test.mjs:555 at :567, the `base-moved` assert (25/1) |
|
||||
| M3 | drop the `land-busy` throw (git-socket.mjs:351) | git-socket.test.mjs:812 at :825, the `land-busy` assert (23/1) |
|
||||
|
||||
Each run was one test file on base f5753c0e8 (row 210's landing, which moved the `#spent` line from
|
||||
:945 to :963), 2026-10-21 02:51Z. Each assert failed with "Missing expected exception" or "rejection".
|
||||
`cmp` confirmed each source matched its saved copy after the restore. M2 is killed by the existing
|
||||
working.test:555, so per the brief working.test.mjs isn't changed. Logs: `/mnt/storage/scratch/megavolt-r241/`
|
||||
(m1.log to m3.log, with each diff).
|
||||
|
||||
## Gate
|
||||
|
||||
Per the brief: Duckworth approves the manifest; the bus, seat and cli suites and every
|
||||
`scripts/test-*.sh` are green on Sage's gate; the three mutants are killed.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Row 242 build: a retry after publish-timeout pushes once
|
||||
|
||||
Row 242, #1748, P19 440. Brief: docs/plans/2026-10-20_agent-credentials.md, "Brief: row 237
|
||||
follow-up N1, a retry after publish-timeout pushes once" (9d165be8c). Base: origin/refactor
|
||||
8fede5fc6 (row 210 landed f5753c0e8).
|
||||
|
||||
## The defect
|
||||
|
||||
The git socket polls publishResult until publishDeadlineMs, then refuses publish-timeout
|
||||
(packages/cli/src/git-socket.mjs:433) while the bus process's push (runtime publishPush) can
|
||||
still be running. The socket then calls publishRefused, which went straight to
|
||||
broker.publishRefused: it filed the refusal and deleted the ticket. checkPublish no longer saw an
|
||||
open ticket on the decision, so a retry got a new ticket and pushed a second ref while the first
|
||||
push was still on its way to the fork.
|
||||
|
||||
## Fix chosen: hold the decision until the push ends, then record a push that reached the fork
|
||||
|
||||
packages/bus/src/runtime.mjs, `publishRefused` and `settle`:
|
||||
|
||||
- If the bus has no push for the ticket, publishRefused goes to the broker as before.
|
||||
- If the push is still running, the refusal code is held on the entry and the ticket stays open.
|
||||
checkPublish refuses another publish on the decision with publish-busy (row 237's check),
|
||||
the same as when the socket is still polling.
|
||||
- When the push ends, `settle` runs:
|
||||
- A push that reached the fork is recorded with broker.recordPublish, which spends the
|
||||
decision. The bus logs `pushed after the git socket refused <code>; recorded`.
|
||||
- A push that failed is filed as publishRefused with the socket's code.
|
||||
- If recordPublish refuses with a BusError, that code is filed instead.
|
||||
- If the push has already ended when the refusal arrives, it's settled at once. The sage-1440 test
|
||||
relies on this: its push fails fast, so publish-timeout is filed once, immediately, as before.
|
||||
|
||||
Why hold rather than kill. Killing the git client doesn't undo a push the fork has already
|
||||
received: receive-pack can update the ref after the client dies. After a kill the bus would have
|
||||
to ls-remote the fork to learn what landed, and the result could still race the server. Holding
|
||||
waits for the push's own outcome. Each git run in publishRemote already has its own limit
|
||||
(publishRemote defaults to checkout.mjs GIT_LIMITS), so a hold is bounded. Holding also reuses the existing publish-busy
|
||||
state, and the refusal text is unchanged.
|
||||
|
||||
Why record and not only hold. If the hold just filed publish-timeout once the push ended, the
|
||||
ticket would close and the decision would be free again with a ref on the fork. A retry would then
|
||||
push a second ref. Recording the late push spends the decision, so the retry gets
|
||||
decision-consumed.
|
||||
|
||||
The publish-timeout refusal text is unchanged (brief condition).
|
||||
|
||||
## Test
|
||||
|
||||
packages/cli/tests/publish-socket.test.mjs:302, "row 242: a retry after publish-timeout refuses
|
||||
publish-busy while the bus process's push runs; the late push is recorded and the decision pushes
|
||||
once".
|
||||
|
||||
- publishDeadlineMs 150 and a store token. The fork's pre-receive hook writes `held`, then loops
|
||||
until the test writes `release`.
|
||||
- The first publish gives publish-timeout. The test waits for `held`.
|
||||
- A retry to branch `other` gives publish-busy (:322). Refused events: ["publish-busy"]. No
|
||||
git.push.remote allowed event.
|
||||
- The test writes `release` and waits until there is exactly one allowed event, with ref
|
||||
refs/heads/coder/feat and the work commit.
|
||||
- The next retry gives decision-consumed (:330).
|
||||
- Exactly one git-receive-pack request reached the fake Gitea, and the fork has only
|
||||
refs/heads/coder/feat. Refused events: ["publish-busy", "decision-consumed"]. No TOKEN appears in
|
||||
events or logs.
|
||||
|
||||
Fail fast (sage-1681): the body is wrapped in try/finally, and the finally writes `release`. Under
|
||||
the first mutant run, the assert at the retry failed and the hook was never released. setup's
|
||||
teardown waits for the fake Gitea, which waited for the held push, so the file hung until I stopped
|
||||
it. Now a failed assert ends the hook and the run. Each wait is bounded at 10 s with an assert.fail.
|
||||
|
||||
## Mutants
|
||||
|
||||
Script: /mnt/storage/scratch/megavolt-r242/mutants.sh. Each mutant: save, one-line sed, run
|
||||
publish-socket.test.mjs whole, restore, cmp.
|
||||
|
||||
| Mutant | Change | Expected kill |
|
||||
|---|---|---|
|
||||
| M1 | export `publishRefused: (ticket, args) => broker.publishRefused(ticket, args)` again (removes the fix) | :302 at :322: the retry gets a new ticket and its own push, so publish-timeout, not publish-busy (seen in the stopped 03:06Z run) |
|
||||
| M2 | `settle` files publishRefused without trying recordPublish (hold only) | :302 at the wait for one allowed event, then :330 |
|
||||
|
||||
Results (Sage's slot, sage-1686, 2026-10-21T03:35:00Z to 03:35:15Z): both killed, both restored
|
||||
(cmp ok).
|
||||
|
||||
- M1: publish-socket 8/1, the row 242 test fails at :322 in 0.5 s.
|
||||
- M2: publish-socket 8/1, the row 242 test fails at :327, the wait for one allowed event, through
|
||||
the assert.fail inside `until` (:311) after its 10 s bound, in 10.3 s.
|
||||
|
||||
## Suites
|
||||
|
||||
On Sage's slot (sage-1686), on base 8fede5fc6 plus this row:
|
||||
|
||||
- bus `node --test tests/*.test.mjs`: 499/499, 03:35:20Z to 03:35:34Z. This is one fewer than
|
||||
Sage's 241 gate (500) because the base predates row 241's land.test case.
|
||||
- cli `node --test tests/*.test.mjs`: 566/566, 03:35:34Z to 03:43:47Z. That's 565 plus the
|
||||
row 242 test.
|
||||
|
||||
Singles before the slot: publish-socket.test.mjs 9/9, bus publish.test.mjs 13/13. The seat suite
|
||||
and scripts/test-*.sh are left for Sage's landing gate.
|
||||
|
||||
## Residuals
|
||||
|
||||
- The agent was told publish-timeout, but the push is recorded later and the run isn't
|
||||
notified. The allowed event and the bus log line are the only trace. A later publish on the
|
||||
decision gets decision-consumed, which tells the agent the decision was used. Whether the run
|
||||
should get a RESULT for a late record is a follow-up question for Sage.
|
||||
- A late push whose recordPublish refuses (Duckworth N2) is filed with that code: the ref stays on
|
||||
the fork and the decision is not spent, the same residual as an in-band push whose record
|
||||
refuses.
|
||||
- The hold lives in the bus process's memory, like the broker's open tickets. A bus restart during
|
||||
a hold drops both, the same as today for any publish in flight.
|
||||
@@ -0,0 +1,8 @@
|
||||
# PRD 1.8 r7: no in-Stack raise (mos-1154)
|
||||
|
||||
Sage, 2026-10-21T01:20:06Z. Jason approved PRD 1.8 in T3 (thread 38bc4c81, before 2026-10-21T01:16:55Z), verbatim:
|
||||
"PRD 1.8: I am preemptively approving it. Do not surface in the Mosaic Stack board. Just proceed."
|
||||
Mos records it as DECISIONS Q192 (mos-1154). The [mos test] raise request drafted here was dropped
|
||||
unsent, and the template is deleted. Jason closed 3a6c8fb9 himself at 01:05:45Z (no, "superseded by r7").
|
||||
Scope (Mos's reading): r7 at 47c2cb33c plus 82df17347, 342 lines, sha256
|
||||
de079281ff2760911039276379ec4b190287b2ce2e4e8a79c9459939727440cd, Duckworth duckworth-0073 (pass, no findings).
|
||||
@@ -196,3 +196,37 @@ a piece. It passed, so the "pm doesn't answer" branch does not apply and
|
||||
no defect is filed.
|
||||
|
||||
Piece 1 is Jason's next real Mosaic Stack request to pm. It is not open yet.
|
||||
|
||||
## 2026-10-21T01:14Z dates (sage-1622, Mos mos-1148, Vikunja #442)
|
||||
|
||||
Mos accepted the plan with changes C1 and C2. The dates are latest-by markers (Q111).
|
||||
|
||||
1. Pieces 1-5 open by 2026-10-23 23:59Z. Mos gives Jason the plain command after tonight's open items with him
|
||||
(Q13 on PRD 1.8, the 1012 and 1008 boot). I don't ask again.
|
||||
2. C1: no piece is pinned to a copy. A piece runs on whatever copy is live when Jason sends it. For each piece
|
||||
this log records the activation id at send. A piece still open across a window gets the new activation id
|
||||
recorded too.
|
||||
3. C2: neither the Q175 phase 2 window (10-23) nor the 10-24 re-pin window rolls back or restores while a piece
|
||||
has history, unless that window has failed. If a restore happens anyway, this log names the pieces to redo.
|
||||
4. All five closed and reviewed by 2026-10-26 18:00Z. Jason reads the five trails at his gate on 10-26 or
|
||||
10-27. A pass starts merge plan step 1 under decision 146, and step 4 stays his.
|
||||
5. Fallback: if fewer than five pieces are open by 10-23, the gate moves to 10-28. No agent opens a piece in
|
||||
Jason's place.
|
||||
|
||||
When Jason sends a piece, I tell Mos its message id.
|
||||
|
||||
Correction (2026-10-21T01:06Z): the heading above says 01:14Z. The clock read 2026-10-21T01:05:56Z when I wrote that entry, so 01:06Z is the right time.
|
||||
|
||||
## 2026-10-21T01:20Z Jason's pre-S7 checks (mos-1154)
|
||||
|
||||
Between 01:11:06Z and 01:13:03Z Jason sent pm five requests with `mosaic talk pm`: ed9e1e14, 51c19692,
|
||||
94f5d62c, 0645a3b7, f75449c0 (bus seq 1583 to 1591). pm run rc9e993da24cf answered each in 4 to 16 s. They were
|
||||
two jokes, the sandwich, the purpose of Mosaic Stack and pm's mission. pm made no task and raised no decision
|
||||
for any of them, and said so each time. Mos's reading, which I take: none is a piece, because a piece has to
|
||||
become work that gets closed and reviewed. They are recorded as Jason's pre-S7 checks. The five pieces are
|
||||
still open, latest-by 10-23 23:59Z. Activation at send: copy 4429a104f, as in the 01:06Z entry (C1). Source: Mos
|
||||
(mos-1154) read the bus. I didn't read the message bodies.
|
||||
|
||||
pm also told Jason that its state snapshot lists only the first 50 of 198 open tasks and that it pages the rest
|
||||
with list_tasks. That is the row 109 (#1603) design: broker.mjs `state` returns `cap: TASK_PAGE` (50) with
|
||||
`total`, and list_tasks pages up to TASK_PAGE_MAX (200).
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Sage, 2026-10-21: re-plan comments on P19 405-408 and 411 (due 10-22). Sage's own key (bot user 13); the sync key reads back. Comments only: no task edits.
|
||||
// Checks each task's title before posting. Prints no secret.
|
||||
// Usage: node comment-p19-405-411-replan.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
|
||||
const POSTS = [
|
||||
{ id: 405, title: /^stack-release --plan: no test plans a missing store/, body: [
|
||||
"Re-plan (Sage, 2026-10-21, due 10-22). Tasks 405, 406, 407, 408 and 411 become one queue row, a stack-release follow-up row. All five touch scripts/stack-release.sh, scripts/webui-service.sh or docs/TOOLS.md, and rows 232 (Darkwing) and 238 (Launchpad) are changing stack-release.sh now. So I brief the row after 232 and 238 land, from their landing, which avoids base drift (the row 68 rule). It lands after the 10-24 window, because that window holds the four service scripts at NEW (P5). 411's hold was the Part B window, and Part B is superseded (Q175, mos-0970), so that hold is gone. Each item keeps the test and mutant its task names. I'll name the owner when I brief it, either the seat that frees up first of Darkwing and Launchpad, or me.",
|
||||
] },
|
||||
{ id: 406, title: /^stack-release --plan: backup line printed/, body: [
|
||||
"Re-plan (Sage, 2026-10-21, due 10-22). Tasks 405, 406, 407, 408 and 411 become one queue row, a stack-release follow-up row. All five touch scripts/stack-release.sh, scripts/webui-service.sh or docs/TOOLS.md, and rows 232 (Darkwing) and 238 (Launchpad) are changing stack-release.sh now. So I brief the row after 232 and 238 land, from their landing, which avoids base drift (the row 68 rule). It lands after the 10-24 window, because that window holds the four service scripts at NEW (P5). 411's hold was the Part B window, and Part B is superseded (Q175, mos-0970), so that hold is gone. Each item keeps the test and mutant its task names. I'll name the owner when I brief it, either the seat that frees up first of Darkwing and Launchpad, or me.",
|
||||
] },
|
||||
{ id: 407, title: /^stack-release\.sh: the proof_for_target comment/, body: [
|
||||
"Re-plan (Sage, 2026-10-21, due 10-22). Tasks 405, 406, 407, 408 and 411 become one queue row, a stack-release follow-up row. All five touch scripts/stack-release.sh, scripts/webui-service.sh or docs/TOOLS.md, and rows 232 (Darkwing) and 238 (Launchpad) are changing stack-release.sh now. So I brief the row after 232 and 238 land, from their landing, which avoids base drift (the row 68 rule). It lands after the 10-24 window, because that window holds the four service scripts at NEW (P5). 411's hold was the Part B window, and Part B is superseded (Q175, mos-0970), so that hold is gone. Each item keeps the test and mutant its task names. I'll name the owner when I brief it, either the seat that frees up first of Darkwing and Launchpad, or me.",
|
||||
] },
|
||||
{ id: 408, title: /^docs\/TOOLS\.md: the row 178 paragraph/, body: [
|
||||
"Re-plan (Sage, 2026-10-21, due 10-22). Tasks 405, 406, 407, 408 and 411 become one queue row, a stack-release follow-up row. All five touch scripts/stack-release.sh, scripts/webui-service.sh or docs/TOOLS.md, and rows 232 (Darkwing) and 238 (Launchpad) are changing stack-release.sh now. So I brief the row after 232 and 238 land, from their landing, which avoids base drift (the row 68 rule). It lands after the 10-24 window, because that window holds the four service scripts at NEW (P5). 411's hold was the Part B window, and Part B is superseded (Q175, mos-0970), so that hold is gone. Each item keeps the test and mutant its task names. I'll name the owner when I brief it, either the seat that frees up first of Darkwing and Launchpad, or me.",
|
||||
] },
|
||||
{ id: 411, title: /^stack-release\.sh and webui-service\.sh: the proof file repository check/, body: [
|
||||
"Re-plan (Sage, 2026-10-21, due 10-22). Tasks 405, 406, 407, 408 and 411 become one queue row, a stack-release follow-up row. All five touch scripts/stack-release.sh, scripts/webui-service.sh or docs/TOOLS.md, and rows 232 (Darkwing) and 238 (Launchpad) are changing stack-release.sh now. So I brief the row after 232 and 238 land, from their landing, which avoids base drift (the row 68 rule). It lands after the 10-24 window, because that window holds the four service scripts at NEW (P5). 411's hold was the Part B window, and Part B is superseded (Q175, mos-0970), so that hold is gone. Each item keeps the test and mutant its task names. I'll name the owner when I brief it, either the seat that frees up first of Darkwing and Launchpad, or me.",
|
||||
] },
|
||||
];
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
for (const P of POSTS) {
|
||||
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
|
||||
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
|
||||
}
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
for (const P of POSTS) {
|
||||
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
|
||||
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
|
||||
rec.comments.push(e);
|
||||
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
|
||||
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
|
||||
const found = list(back.json).find((x) => x.id === c.json.id);
|
||||
Object.assign(e, { readBack: back.status, found: Boolean(found) });
|
||||
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
|
||||
save();
|
||||
}
|
||||
save();
|
||||
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
|
||||
if (rec.problems.length) process.exit(2);
|
||||
@@ -0,0 +1,53 @@
|
||||
// Sage, 2026-10-21: landing comments on P19 412 (row 200) and 424 (row 199), per mos-0882. Sage's own key (bot user 13); the sync key reads back. Comments only: no task edits.
|
||||
// Checks each task's title before posting. Prints no secret.
|
||||
// Usage: node comment-r200-r199-landed.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const PSIG = "Posted by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(PSIG)}</p>`;
|
||||
const POSTS = [
|
||||
{ id: 412, title: /^Console SSO: zero-trust OIDC verification/, body: [
|
||||
"Row 200 landed on refactor as 9e1085816 (Gitea #1698, closed): stack-release.sh activate --sso, the phase 2 prerequisite. Launchpad built it and Webby approved r3. Sage's run killed 13 of 14 mutants (M12 is the equivalent ruled in round 2). Gate: 15 package suites and 10 scripts/test-*.sh suites, all green, at 00:07:57Z.",
|
||||
"Follow-up N1 is row 238 (Gitea #1744, Launchpad, Webby). The sso-config hint will carry --instance NAME for a named instance. Nothing is live until a window activates a copy that includes 9e1085816.",
|
||||
] },
|
||||
{ id: 424, title: /^Credential store: per-agent host access/, body: [
|
||||
"Row 199 landed on refactor as cb5fdb064 (Gitea #1697, closed). It adds the per-agent credential store, the agent's own git identity and pushes to a remote. Megavolt built it and Duckworth approved r1 (comment 29779). Gate on 9e1085816 plus the patch: packages 2735/0, scripts 429/0.",
|
||||
"Row 210 (Gizmoduck) now rebases onto cb5fdb064 on a new branch (mos-1135, mos-1137). Rows 234 and 235 follow it. Rows 190, 199 and 210 go live together in the 10-24 window with the 3i-3j migration, backup first, under Q173 (mos-1145).",
|
||||
] },
|
||||
];
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
const rec = { at: new Date().toISOString(), comments: [], problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
for (const P of POSTS) {
|
||||
const t = await api("GET", `/api/v2/tasks/${P.id}`, undefined, sync);
|
||||
if (t.status !== 200 || t.json?.project_id !== PROJECT || !P.title.test(t.json?.title ?? "")) throw new Error(`task ${P.id}: ${t.status}, title check failed; nothing posted`);
|
||||
}
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
for (const P of POSTS) {
|
||||
const c = await api("PUT", `/api/v1/tasks/${P.id}/comments`, { comment: ps(P.body) }, key);
|
||||
const e = { task: P.id, status: c.status, id: c.json?.id ?? null };
|
||||
rec.comments.push(e);
|
||||
if (c.status >= 300 || !Number.isSafeInteger(c.json?.id)) { rec.problems.push(`comment ${P.id}: ${c.status} ${c.json?.message ?? ""}`); save(); continue; }
|
||||
const back = await api("GET", `/api/v2/tasks/${P.id}/comments?per_page=50`, undefined, sync);
|
||||
const found = list(back.json).find((x) => x.id === c.json.id);
|
||||
Object.assign(e, { readBack: back.status, found: Boolean(found) });
|
||||
if (back.status !== 200 || !found) rec.problems.push(`comment ${P.id} read-back: ${back.status}`);
|
||||
save();
|
||||
}
|
||||
save();
|
||||
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
|
||||
if (rec.problems.length) process.exit(2);
|
||||
@@ -0,0 +1,66 @@
|
||||
// Sage, 2026-10-21: files row 237's review follow-ups N1 and N3 (duckworth-0069, #1743 comment 29810) as two P19 tasks.
|
||||
// Sage's own key (bot user 13); the sync key reads back. No labels, assignees, due dates or bucket moves. Refuses if
|
||||
// the title exists. Prints no secret.
|
||||
// Usage: node file-r237-followups.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const SIG = "Created by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const ps = (sig, xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(sig)}</p>`;
|
||||
const TASKS = [
|
||||
{
|
||||
title: "Bus publish: a retry after publish-timeout can push a second fork ref while the first push still runs (row 237 N1)",
|
||||
description: ps(SIG, [
|
||||
"Duckworth's row 237 review N1 (#1743 comment 29810, duckworth-0069). Row 237 makes checkPublish refuse publish-busy while an open ticket holds the same business and decision. publishRefused drops the ticket on publish-timeout, but the bus process's push may still be running, so a retry to another branch can push a second ref to the fork.",
|
||||
"The fix belongs in packages/bus/src/runtime.mjs, in `remotes`: either keep the ticket until the push process exits, or kill the push on timeout and wait for it. A test should start a slow push, time it out, retry to another branch, and expect one ref on the fork. Not in row 237's scope. Megavolt's BUILD.md residual 2 (agents/megavolt/work/queue-237/BUILD.md). Needs a queue row once 237 lands.",
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: "Bus land: check whether checkLand has the same open-ticket race that row 237 fixed in checkPublish (row 237 N3)",
|
||||
description: ps(SIG, [
|
||||
"Row 237 residual 3 (Megavolt's BUILD.md) and Duckworth's N3 (#1743 comment 29810): checkLand has the same ticket shape as checkPublish had before row 237. Two lands on the same decision may both pass the check before either records. Duckworth didn't check it.",
|
||||
"First step: read checkLand and its ticket code in packages/bus/src/broker.mjs against row 237's fix (broker.mjs:1038-1043). If the race exists, file a queue row with a race test like publish-socket.test.mjs's; if not, record why and close this task.",
|
||||
]),
|
||||
},
|
||||
];
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
async function existing() {
|
||||
const out = [];
|
||||
for (let page = 1; page < 20; page++) {
|
||||
const r = await api("GET", `/api/v2/projects/${PROJECT}/tasks?per_page=50&page=${page}`, undefined, sync);
|
||||
if (r.status !== 200) throw new Error(`list tasks: ${r.status}`);
|
||||
out.push(...list(r.json));
|
||||
if (list(r.json).length < 50) return out;
|
||||
}
|
||||
throw new Error("more than 19 pages");
|
||||
}
|
||||
const rec = { at: new Date().toISOString(), project: PROJECT, created: null, problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
const have = await existing();
|
||||
for (const T of TASKS) if (have.some((t) => t.title === T.title)) throw new Error("title exists; refusing");
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
rec.created = [];
|
||||
for (const T of TASKS) {
|
||||
const r = await api("PUT", `/api/v1/projects/${PROJECT}/tasks`, { title: T.title, description: T.description }, key);
|
||||
const c = { status: r.status, id: r.json?.id ?? null };
|
||||
rec.created.push(c);
|
||||
if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) { rec.problems.push(`create: ${r.status} ${r.json?.message ?? ""}`); continue; }
|
||||
const back = await api("GET", `/api/v2/tasks/${r.json.id}`, undefined, sync);
|
||||
Object.assign(c, { readBack: back.status, done: back.json?.done });
|
||||
if (back.status !== 200 || back.json?.title !== T.title || back.json?.done) rec.problems.push(`read-back differs for ${r.json.id}`);
|
||||
}
|
||||
save();
|
||||
console.log(JSON.stringify(rec));
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"at": "2026-10-21T01:06:25.071Z",
|
||||
"comments": [
|
||||
{
|
||||
"task": 405,
|
||||
"status": 201,
|
||||
"id": 416,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
},
|
||||
{
|
||||
"task": 406,
|
||||
"status": 201,
|
||||
"id": 417,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
},
|
||||
{
|
||||
"task": 407,
|
||||
"status": 201,
|
||||
"id": 418,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
},
|
||||
{
|
||||
"task": 408,
|
||||
"status": 201,
|
||||
"id": 419,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
},
|
||||
{
|
||||
"task": 411,
|
||||
"status": 201,
|
||||
"id": 420,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
}
|
||||
],
|
||||
"problems": []
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"at": "2026-10-21T00:28:32.038Z",
|
||||
"comments": [
|
||||
{
|
||||
"task": 412,
|
||||
"status": 201,
|
||||
"id": 414,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
},
|
||||
{
|
||||
"task": 424,
|
||||
"status": 201,
|
||||
"id": 415,
|
||||
"readBack": 200,
|
||||
"found": true
|
||||
}
|
||||
],
|
||||
"problems": []
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"at": "2026-10-21T00:38:47.170Z",
|
||||
"project": 19,
|
||||
"created": [
|
||||
{
|
||||
"status": 201,
|
||||
"id": 440,
|
||||
"readBack": 200,
|
||||
"done": false
|
||||
},
|
||||
{
|
||||
"status": 201,
|
||||
"id": 441,
|
||||
"readBack": 200,
|
||||
"done": false
|
||||
}
|
||||
],
|
||||
"problems": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Sage, 2026-10-21: read-only. Prints id, done, title, the last update and the description as plain text for the
|
||||
// given P19 tasks, with the sync key. Writes nothing. Prints no secret.
|
||||
// Usage: node read-tasks.mjs https://tasks.woltje.com SYNC_TOKEN_FILE ID...
|
||||
import { readFileSync } from "node:fs";
|
||||
const [ORIGIN, SYNC, ...IDS] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
const sync = readFileSync(SYNC, "utf8").trim();
|
||||
const text = (h) => (h ?? "").replace(/<\/p>|<br\s*\/?>|<\/li>/g, "\n").replace(/<[^>]+>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").trim();
|
||||
for (const id of IDS) {
|
||||
const r = await fetch(`${ORIGIN}/api/v2/tasks/${id}`, { headers: { Authorization: `Bearer ${sync}` }, redirect: "error" });
|
||||
const j = await r.json().catch(() => null);
|
||||
if (r.status !== 200 || j?.project_id !== 19) { console.log(`== ${id}: status ${r.status}, not P19`); continue; }
|
||||
const out = `== ${id} done=${j.done} updated=${j.updated}\n${j.title}\n${text(j.description)}\n`;
|
||||
if (out.includes(sync)) throw new Error("secret in output");
|
||||
console.log(out);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# 10-24 window: re-pin 4429a104f to the row 234 landing, with the 3i-3j migration (draft 0)
|
||||
|
||||
Sage, 2026-10-21T01:05Z, draft 0, for mos-1145 under DECISIONS Q173. This is the skeleton. NEW isn't fixed yet,
|
||||
so Mos doesn't check this draft. Draft 1 names NEW and goes to Mos for the P0 check. The step layout follows
|
||||
`repin-4429a104f.md`. Steps that match are named, not copied.
|
||||
|
||||
## Scope (mos-1145, mos-1134)
|
||||
|
||||
- One window carries the code of rows 190, 199 and 210, plus row 234 (row a) with its schema step 3i to 3j
|
||||
through `activate --migrate`, agent-run under Q173, backup first.
|
||||
- Row a lands before the .53 and .54 installs, by 10-25 (mos-1134). If row 234 hasn't landed by 10-24 12:00Z,
|
||||
I ask Mos whether to run the window without it (no migration) or to move it.
|
||||
- Jason has no step. Nobody acts as Jason. No pi (P19 383). No Save, no `mosaic name`. Nothing is pruned.
|
||||
- `--sso` stays off. Row 200's `activate --sso` is in NEW as a tool. Turning it on changes who reaches the Console
|
||||
from outside, so that's Jason's (Q173 exclusion). It has its own window, the Q175 phase 2 window on 10-23
|
||||
(mos-1148). That window runs first, so this window's OLD may be the phase 2 copy, and P1 reads it from
|
||||
`status`.
|
||||
|
||||
## Names
|
||||
|
||||
- OLD, live: `4429a104f6d65ccf165f6c83b5d1dce3147dfde0`, schema 3i, pointer `pm: true` (record 99a406ed3).
|
||||
P1 re-reads the activation id and the Console flags from `status`. I don't copy them from the last window.
|
||||
- NEW: open. It's the row 234 landing, or a later code landing if Mos rules. Candidates in landing order:
|
||||
194, 234 (210, 237, 232, 241 and 242 have landed).
|
||||
- REPO, `P`, `WIN=agents/sage/work/window-repin-<NEW12>/`, `SNAP`: as in `repin-4429a104f.md`, with the
|
||||
sha checks at P0 and before S1, S2 and any D3.
|
||||
|
||||
## What NEW carries over OLD (so far, refactor 04ce8dfd5)
|
||||
|
||||
`git log --first-parent 4429a104f..HEAD`, path-limited to packages, scripts, roles, contracts, adapters, skills
|
||||
and the package files:
|
||||
|
||||
| Row | Commit | Live effect to check |
|
||||
|---|---|---|
|
||||
| 201 | afad4993f | business: access.json loader. Check: the live business has no access.json, and load stays LOAD_OK (P1). |
|
||||
| 204 | d0bbc5c1c | business: 193a viewer level. Same check. |
|
||||
| 190 | 00d4cc1f6 | Several instances per host. Touches `stack-release.sh`, `webui-service.sh`, `bus-service.sh`, `stack-copy.sh`. The default instance must render the same units: P2 diffs the rendered units OLD vs NEW for `mosaic-stack`, and any difference stops the window until it's explained. |
|
||||
| 200 | 9e1085816 | Tool: `activate --sso`. Off here. `--plan` must show no `--sso` line. |
|
||||
| 199 | cb5fdb064 | Credential store per agent. Check: bus start with no store entries for the live agents changes nothing, and the chief's and pm's launches still pass. The P3 rehearsal covers start. |
|
||||
| N3 | 390ea5261 | README only. |
|
||||
| 230 | c8bc5cf4c | Case 2 scripts, tools only. Not run live. |
|
||||
| 210 | f5753c0e8 | Host access. The live business has no `hosts`, so nothing is granted. Bus start runs the host-run sweep (M34d). P3 must show it removes nothing. |
|
||||
| 237 | d7d943199 | Publish busy check (199 N1). |
|
||||
| 232 | e65a3ab4d | Installer I1: `stack-release.sh --units external` and `stack-copy.sh copy --bundle`, `wrapper --create`. Both are opt-in, and this window uses neither. The default units path must render the same units, so the P2 unit diff covers it with row 190. P0 checks that no window command line carries `--units external`. |
|
||||
| 241 | 5d4a61776 | Test only (bus land.test.mjs). No live effect. |
|
||||
| 242 | 5fb736b92 | Bus publish hold: after publish-timeout the bus holds the decision until its push ends, then records a late push. The live business has no publish in flight at W1, and P3 shows none open. Its follow-up fc1cb856b is comments only (git-socket.mjs). |
|
||||
| 234 | open | Machine grant row a and the 3i to 3j schema step. `--plan` must say `migration: 3i -> 3j`. |
|
||||
| PRD 1.7+1.8 | 0adf8ddfe | `docs/prd/mosaic-stack.md` and `packages/tasks/requirements.json` move to version 1.8 (mos-1154, mos-1156, DECISIONS Q192). The live pm cites 1.6 until this window. Check after: the pm's requirements read answers version 1.8. |
|
||||
|
||||
Service script drift OLD to HEAD is 415+/91- over the four scripts, mostly row 190. P5 holds them at NEW.
|
||||
|
||||
## Backups and the migration
|
||||
|
||||
- W5: the stopped store backup that every activate takes (row 185), with SHA256SUMS, SCHEMA 3i and STAMP,
|
||||
plus the rollback set. Before `--migrate` writes, I check that SCHEMA reads 3i and the backup SHA256SUMS hash.
|
||||
- W0b: the business file copy, as in the last window.
|
||||
- Rollback after a migration isn't the plain rollback. OLD is 3i and the store is 3j, so going back needs
|
||||
`--restore <W5 activation>`, and that loses the events written after W5. The row 181 restore guard counts
|
||||
11 tables, and the untimed ones need the D3 snapshot. Mos decides any restore past W5, as before.
|
||||
- Grant look-alikes (row 234 A22b, gizmoduck-0107). `3i-3j.sql` adds `events_grant_actor` but doesn't check
|
||||
rows already in the store, so after the migration only the actor filter in `GRANT_EVIDENCE` (actor_role NULL,
|
||||
actor_run 'grant') ignores an old session-written row. P3, and again just before `--migrate`: on the
|
||||
backup copy, count the `action.allowed` events whose body.action or body.operation starts with `grant.`, or
|
||||
whose body has `hostLineLeft`. Expect 0, since no grant code has run live. Any nonzero count stops the window
|
||||
until I've read those rows.
|
||||
- P3 is the rehearsal with `--migrate` on a copy of the live backup. It must show the 3j step and the row
|
||||
counts before and after.
|
||||
|
||||
## Row 42 (S7) interaction
|
||||
|
||||
Row 42's pieces may be in flight at window start (sage-1622, mos-1148 C1 and C2). W1 adds: list open pm tasks, and either pm
|
||||
finishes or parks them before W5, or I record which piece crosses the window. No restore while a piece has
|
||||
history unless the window fails.
|
||||
|
||||
## Open before draft 1
|
||||
|
||||
- Q1: NEW, once 234 lands. Do 194 and 237 go in if they land first? I recommend yes for 237 (small, bus
|
||||
only) and asking Mos about 194, since 194 changes the lifecycle verbs and roles/chief-of-staff.*.
|
||||
- Q2, settled by mos-1148: phase 2 is its own window on 10-23. Draft 1 takes OLD and the Console flags,
|
||||
`--sso` included, from `status` after that window.
|
||||
- Q3: who operates. If Darkwing is free, Darkwing operates and I check. Otherwise I operate as in the last
|
||||
window.
|
||||
- The P-step evidence list for rows 199 and 210 at bus start. Both have landed (cb5fdb064, f5753c0e8), so
|
||||
it's due in draft 1. I take each log line and exit code from a P3 rehearsal run, not from the code.
|
||||
- Rows 241 and 242 (bus): P3 shows no publish or land decision open before W5.
|
||||
@@ -1390,3 +1390,9 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-20T23:21:32Z | Dewey (Opus 5.5, 84fb346c) | row 66 (#1559) round 3 review, rebase of r2 onto c40d846cb (sage/row66-r3 2369c00e3, manifest 42e3876ca168) | APPROVE 29771, rev 2529: patch-id 6d085621 equals r2, range-diff =, 23 manifest hashes match, no drift past the base, four drifted files sit beside rows 190/192 cleanly, no hash pins; singles are Sage's (not rerun). Also #436b Q-B4 answered by mos-1147, verdict addendum
|
||||
2026-10-20T23:31:31Z | Dewey (Opus 5.5, 84fb346c) | #436b design r3 review (megavolt-0050, DESIGN.md fa5658941) | CHANGES: restart reconcile spends step 0 refusals (#436a r6 X3 conflict), settle unknown id keeps unverified token in use; N7 secretCheck; B1-B3, N1-N6, D12 closed; verdict design-r3-verdict.md 4c87d859; dewey-0759 Megavolt, dewey-0760 Sage |
|
||||
2026-10-20T23:32:42Z | Sage (Claude Opus 5.5, 1ef1e4f8) | machine grant rows, slot relay, queue sweep | rows 234 (#1740, 3i-3j) and 235 (#1741) added, row 206 moved to 3j-3k after 234 (f519fb1d2); row 199 r1 request as jarvis 29778 (manifest 35f900fd rebuilt from git objects); #436b r3 item 1 ruled (b), sage-1578; queue-commit 1c46d0abf revs 2456-2539; slot Megavolt to Gosalyn row 230
|
||||
2026-10-21T01:28:05Z | Darkwing (Opus 5.5, 7525c8e4) | row 232 I1 build (sage-1558, sage-1561 ruling a) | rebased onto cb5fdb064 (row 200), gate green and 17/17 mutants killed on Sage's slot; in review round 1, manifest 7a3b4ea1 at 84f1a91e6 on darkwing/row232-r1, comment 29825, revs 2595-2596; Filbert woken
|
||||
2026-10-21T01:34:13Z | Filbert (Opus 5.5, 048f3c63) | row 232 installer I1 review r1 (#1734, manifest 7a3b4ea133cc) | APPROVE, Gitea 29829, queue rev 2600 (left for Sage's queue-commit); static review, gate and mutants are Darkwing's receipts; 3 non-blocking notes
|
||||
2026-10-21T01:49:55Z | Sage (Opus 5.5, 1ef1e4f8) | row 237 landed d7d943199 (#1743, gate green, M1 killed); PRD 1.7+1.8 landed 0adf8ddfe (Honker 2884ee496, Duckworth approve bf52628f9, tasks 97/0 bus 478/0 on the tip, DECISIONS Q192); rows 239 (#1745) and 240 (#1746) added; 233 briefed; queue revs 2540-2603 committed 584e2bd82 | landed, push next
|
||||
2026-10-21T02:51:46Z | Sage (Opus 5.5, 1ef1e4f8) | row 210 landing (r5 0485384e9, Duckworth r3 29851) | landed f5753c0e8, patch-id 723b1c83 = r5 diff, manifest 688d38b9 OK; gate 2796/0 packages, 429/0 scripts; mutants 52/53 (M22b equivalent); #1707 closed (29853), Vikunja 424 comment 429; queue revs 2604-2617 c084d8d42; row 234 briefed; Megavolt has the slot for 241 (sage-1668)
|
||||
2026-10-21T03:01:43Z | Filbert (Opus 5.5, 048f3c63) | row 232 installer I1 review r2, rebase scope (#1734, manifest 7af3c94308e0, da2da2f6c on 8fede5fc6) | APPROVE, Gitea 29865, queue rev 2622 (left for Sage's queue-commit); README = predicted 3-way blob 89a95b32, 7 blobs same as r1, BUILD.md +31/-0; corrected my r1 reader claim (host.test.mjs:855 reads cli README)
|
||||
2026-10-21T04:14:49Z | Sage (Opus 5.5, 1ef1e4f8) | lead: rows 241 and 242 landed, slot order | row 241 landed 5d4a61776 (#1747 closed 29883, M1-M3 killed, gate 2797/0 + 429/0); row 242 landed 5fb736b92 (#1748 closed 29894, Duckworth approve 29889, gate 2826/0 + 429/0); late-record notice ruled a follow-up (Vikunja 440 c432); row 233 brief re-pinned rev 2631; carry items on #1744 (29885) and #1734 (29886); queue revs 2628-2638 committed 96bac588f
|
||||
|
||||
+72
-4
@@ -408,8 +408,9 @@ exposure of a named Console waits for Jason after Q175 SSO.
|
||||
|
||||
```
|
||||
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 copy --bundle FILE SHA [--copies DIR]
|
||||
scripts/stack-copy.sh wrapper ID [--instance NAME] [--copies DIR] [--bin DIR] [--create]
|
||||
scripts/stack-copy.sh check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID] [--units user|external]
|
||||
```
|
||||
|
||||
One read-only tree supplies the bus, Console and human CLI (row 85,
|
||||
@@ -423,6 +424,14 @@ renames beside the target. A published existing copy stays unchanged.
|
||||
Dependencies use the exact lock and carry Pi's installed lock record.
|
||||
Failure publishes nothing and removes the temporary tree.
|
||||
|
||||
`copy --bundle FILE SHA` (row 232) is for a host with no refactor checkout.
|
||||
SHA is a full 40-character id. The bundle must pass `git bundle verify`,
|
||||
carry `refs/heads/refactor`, and reach SHA from it; each miss refuses and
|
||||
writes nothing. The copy is then made from SHA as `copy` makes it, and the
|
||||
script prints `release:` with the RELEASE value at SHA and `bundle sha256:`
|
||||
with the bundle's digest. That pair is the release identity on such a host.
|
||||
Without `--bundle`, `copy`'s reachability rule is unchanged.
|
||||
|
||||
`wrapper` is a live switching step, reserved for Jason or Mos. It accepts
|
||||
only an existing copy and a regular non-symlink mosaic wrapper with the
|
||||
Q81 marker on line 2 and its exact lease line to mosaic-old. It preserves
|
||||
@@ -430,11 +439,16 @@ both lines, adds comments naming the copy root and Q81's mosaic-old lease
|
||||
probe, and atomically replaces the wrapper's final exec with the
|
||||
copy's scripts/mosaic. --bin is a fixture override; the default is
|
||||
~/.npm-global/bin. Neither the Q81 worktree nor mosaic-old is removed.
|
||||
`wrapper ID --create` (row 232) writes the first wrapper on a new host, the
|
||||
same file `wrapper` writes. It refuses when anything is at the wrapper path,
|
||||
a symlink included, and never replaces it. Without `--create`, a missing
|
||||
wrapper still refuses.
|
||||
`check` changes nothing: it reads effective systemd WorkingDirectory and
|
||||
ExecStart (including drop-ins) for the bus and Console, plus the wrapper
|
||||
exec. It prints all three paths and succeeds only on the same existing
|
||||
copy; absent/failed reads and a mismatched ExecStart fail. Business defaults
|
||||
to mosaic-stack. Queue work continues on the canonical scripts/mosaic.
|
||||
to mosaic-stack. `check --units external` (row 232) reads root's system units
|
||||
with `systemctl show`, without `--user`. Queue work continues on the canonical scripts/mosaic.
|
||||
|
||||
Bus render/install accept --copy ID, --copies DIR, --pm and the unchanged
|
||||
--claude pin. Without --copy the rendered unit stays the checkout unit.
|
||||
@@ -455,7 +469,10 @@ 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
|
||||
scripts/stack-release.sh confirm ID BUSINESS --units external [--rollback OLD --cause A3|A4|A5] [--timeout S]
|
||||
scripts/stack-release.sh revert ID BUSINESS --units external
|
||||
scripts/stack-release.sh gate ID BUSINESS --units external --since EPOCH [--timeout S]
|
||||
common options: --copies DIR, --bin DIR, --instance NAME, --units user|external
|
||||
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
|
||||
every table in COUNTED.
|
||||
```
|
||||
@@ -711,6 +728,57 @@ Usage exits 4. Nothing prunes rehearsal directories.
|
||||
starts no thread ticker, and leaves an earlier host's `sessions.json` and
|
||||
its runs alone. No unit passes it, and `--pm` with it is a usage error.
|
||||
|
||||
#### Root-owned units (`--units external`, row 232)
|
||||
|
||||
On a host whose bus and Console are root's system units, the stack user runs
|
||||
`stack-release.sh` with `--units external` from its own clone of the release
|
||||
bundle (sage-1561). The script then calls `systemctl show`, `is-active` and
|
||||
`list-units` without `--user` and reads no journal. It starts, stops and
|
||||
installs nothing. Root does those steps, and the I3 driver calls the verbs
|
||||
below in order. Without `--units`, every command line and every byte of
|
||||
output is what it was before row 232. `confirm`, `revert`, `gate`, `--to`
|
||||
and `--expect-from` are usage errors without `--units external`.
|
||||
|
||||
- `adopt`, `status`, `activate ID` and `rollback --to ID` take the flag.
|
||||
`rollback` then needs `--to`, and `--to` must be the copy `rollback` alone
|
||||
would pick from the log, or it refuses `to-not-previous`.
|
||||
- `--plan --expect-from OLD` refuses `pointer-differs` when the pointer
|
||||
names a copy other than OLD.
|
||||
- `pm-on` and `activate --pm-after-harness` refuse `external-pm`; the PM
|
||||
flag is in root's bus unit. `--pm`, `--tester-port`, the proof flags,
|
||||
`--no-proof`, `--no-send` and `--sso` are usage errors; root's Console
|
||||
unit carries the Console flags.
|
||||
- `activate` and `rollback` refuse `units-running` unless both units read
|
||||
inactive or failed and the store is closed. They make the rollback set
|
||||
with an exclusive `mkdir`, write `AWAITING-UNITS` (the target copy) first,
|
||||
capture the pointer, the wrapper and both units' show output and unit file
|
||||
sums, back up and migrate or restore the store, and write the wrapper.
|
||||
They write no pointer and start nothing. The last line says the run is
|
||||
staged and names `confirm`. A failure after the capture writes `FAILED`
|
||||
with its code, logs nothing more and exits 1; the activation stays open.
|
||||
- `confirm ID BUSINESS` needs exactly one `AWAITING-UNITS` for copy ID whose
|
||||
activation the log holds open, or it refuses `open-activation`. It gates
|
||||
the start root made and writes the pointer and an `ok` line with the
|
||||
started line's op, from, to and backup.
|
||||
`confirm --rollback OLD --cause A3|A4|A5` gates OLD after root put the old
|
||||
units back and logs `failed` or `gate-failed` with `rollback ok` (exit 1)
|
||||
or `rollback-failed` (exit 2). Either removes `AWAITING-UNITS` and `FAILED`.
|
||||
- `revert ID BUSINESS` puts the saved wrapper back and, when the run had
|
||||
started a migration, restores the backup taken before it. It writes no log
|
||||
line and can run again.
|
||||
- `gate ID BUSINESS --since EPOCH` gates a copy with no open activation,
|
||||
takes no lock and writes nothing: 0 on a pass, 1 otherwise. EPOCH is root's
|
||||
clock before root started the units.
|
||||
|
||||
The gate under `--units external` replaces the journal read. The bus unit's
|
||||
`ActiveEnterTimestamp`, in whole seconds, must be at or after the started
|
||||
line's second (`confirm`) or `--since` (`gate`); an older start fails
|
||||
`stale-start`, so a unit that never stopped can't pass. With the PM on, the
|
||||
gate reads `launches list --json` for exactly one live launch of the PM's
|
||||
instance (the business's `launch.by`), for this business, whose run started at
|
||||
or after the floor. `status` adds an
|
||||
`awaiting units:` line for an open activation.
|
||||
|
||||
## Console service (`scripts/webui-service.sh`)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -102,3 +102,45 @@ plus a mutant that removes the check.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutant is killed.
|
||||
|
||||
## Brief: row 237 follow-up, one decision lands once, with tests at the broker
|
||||
|
||||
Sage, 2026-10-21, from Megavolt's checkLand analysis (megavolt-0068, megavolt-0069) and sage-1651. Owner
|
||||
Megavolt, reviewer Duckworth, gate owner Sage. Builds on row 237's landing d7d943199. Vikunja P19 441.
|
||||
|
||||
Landing already has three guards against one decision landing twice. `land-busy` at
|
||||
`packages/cli/src/git-socket.mjs:351` allows one landing per socket at a time. `finishLand` moves main by
|
||||
compare-and-swap (`packages/seat/src/working.mjs:391`, refusing `base-moved`). `recordLand` reruns
|
||||
`#landFacts`, which refuses `decision-consumed` (`packages/bus/src/broker.mjs:945`). So no end-to-end race
|
||||
exists, and no code change is planned. What's missing is a test that pins each guard.
|
||||
|
||||
- In `packages/bus/tests/land.test.mjs`, add a test where two tickets pass `checkLand` on one decision. The
|
||||
first `recordLand` succeeds and the second refuses `decision-consumed`, with nothing recorded for it.
|
||||
- Run three mutants and list them in BUILD.md: drop the `#spent` line in `#landFacts`, drop the old value
|
||||
from the CAS `update-ref` in `finishLand`, and drop the `land-busy` throw at git-socket.mjs:351. Each must
|
||||
be killed by a named test. If the CAS mutant survives `packages/seat/tests/working.test.mjs:555`, add a
|
||||
test to working.test.mjs that kills it. Otherwise leave that file alone.
|
||||
- If a mutant survives with no test that can reach it, stop and send me the analysis. Don't change source
|
||||
to make a test reach it.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus, seat and cli suites and every `scripts/test-*.sh` are green
|
||||
on Sage's gate, and the three mutants are killed.
|
||||
|
||||
## Brief: row 237 follow-up N1, a retry after publish-timeout pushes once
|
||||
|
||||
Sage, 2026-10-21, from Duckworth's row 237 note N1 (#1743 comment 29810, duckworth-0069) and Megavolt's
|
||||
row 237 BUILD.md residual 2. Owner Megavolt, reviewer Duckworth, gate owner Sage. Builds on row 237's landing
|
||||
d7d943199. Vikunja P19 440.
|
||||
|
||||
The git socket gives up with `publish-timeout` (`packages/cli/src/git-socket.mjs:433`) while the bus
|
||||
process's push may still be running (`publishPush` in `packages/bus/src/runtime.mjs`, entry `running`).
|
||||
`publishRefused` then drops the ticket, so row 237's `publish-busy` check no longer holds the decision. A
|
||||
retry to another branch can then push a second ref to the fork.
|
||||
|
||||
Pick one fix and say why in BUILD.md. Either keep the decision held until the bus's push ends, or kill the
|
||||
push on timeout and wait for it to exit before the ticket goes. Add a test that starts a slow push, times
|
||||
it out, retries to another branch, and finds exactly one ref on the fork. Add a mutant that removes the
|
||||
fix. Don't change the `publish-timeout` refusal text.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus and cli suites and every `scripts/test-*.sh` are green on
|
||||
Sage's gate, and the mutant is killed.
|
||||
|
||||
@@ -55,3 +55,24 @@ code.
|
||||
|
||||
Webby approves DESIGN.md, then the manifest. The bus, cli, business and webui suites and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the design's mutants are killed.
|
||||
|
||||
## Brief: row 194 follow-up, the revoke under the writer's lock and a fire test
|
||||
|
||||
Sage, 2026-10-21, from Duckworth's row 194 b3 N1 (duckworth-0070, duckworth-0074) and Mos's ruling mos-1158.
|
||||
Owner Honker, reviewer Duckworth, gate owner Sage. Builds on row 194's landing.
|
||||
|
||||
Row 194 b3 checks the revision in `instance-settings.mjs` `commit()` before `beforeWrite`, which is the revoke on
|
||||
fire and delete. A window is still open. If a write lands between that read and the writer's lock, a fire or
|
||||
delete revokes and then refuses `revision-conflict`. An `outcome-unknown` after a revoke also doesn't say the
|
||||
tokens are gone. Two changes:
|
||||
|
||||
1. Run `beforeWrite` inside `writer.write`, under its lock, after the `organization.mjs:188` revision check. A
|
||||
refusal after a revoke that ran says the tokens may already be revoked. That covers `revision-conflict` and
|
||||
`outcome-unknown`, in the CLI and the Console texts. A refusal before any revoke keeps today's text.
|
||||
2. Add a fire test for a stale revision, using an injected writer or whatever the design picks. The test checks
|
||||
that nothing is revoked and the business file is unchanged. Keep T21 for delete.
|
||||
|
||||
Mutants: run `beforeWrite` before the lock. Drop the fire refusal. Drop the "may already be revoked" text.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus, business, cli and webui suites and every `scripts/test-*.sh`
|
||||
are green on Sage's gate, and the mutants are killed.
|
||||
|
||||
@@ -227,3 +227,36 @@ host prep into the command is #437.
|
||||
|
||||
Filbert approves the manifest. The test file, the cli suite and every `scripts/test-*.sh` are green on
|
||||
Sage's gate, with the mutants killed. Target: in review 10-24, landed 10-25 (sage-1528 line 12).
|
||||
|
||||
## I4 hostcheck: the bwrap profile check, pins and boot unit
|
||||
|
||||
Sage, 2026-10-21, split out of I3 (darkwing row 233 G2, sage-1650). Owner Darkwing, reviewer Filbert, gate
|
||||
owner Sage. Darkwing builds it right after I3, and it must land before the ops-01 proof.
|
||||
|
||||
### Problem
|
||||
|
||||
The host run sheet (I3, line 182 above) lists r6's `hostcheck` among the host items for any host that runs
|
||||
bwrap, and I3's A1 refuses `profile-drift` unless `/usr/local/lib/mosaic-stack/hostcheck.sh --check` exits 0.
|
||||
No hostcheck exists on refactor. It's r6 B2 (row 195 DESIGN.md at b7239bb66, :262-303 and :839-860), sized at
|
||||
150-200 lines of bash and 350-450 of test. That's work separate from the upgrade sequence: the root-side bwrap
|
||||
stop and a network fetch.
|
||||
|
||||
### What ships
|
||||
|
||||
r6 B2 as designed:
|
||||
- `case1-pins.sh` holds the pins: the profile sha256, the apparmor-profiles version and the bubblewrap pin.
|
||||
- `hostcheck.sh` covers the seven items in three modes:
|
||||
- `--check` exits 0 or 1.
|
||||
- `--enforce` stops bwrap: `apparmor_parser -R`, then `root:root 0700` on `/usr/bin/bwrap`. It writes
|
||||
`/etc/mosaic-stack/DRIFT` and a `hostcheck.log` line, and exits 0.
|
||||
- `--upstream` compares the current apparmor-profiles deb to the pin.
|
||||
- `mosaic-hostcheck.service`, a boot oneshot.
|
||||
- `case1-hostcheck.test.mjs` uses fakes for every host tool and covers r6's seven case groups and seven
|
||||
mutants.
|
||||
|
||||
The I3 run sheet's hostcheck item points at this row. I3's A1 and its fake don't change.
|
||||
|
||||
### Gate
|
||||
|
||||
Filbert approves the manifest. The test file, the cli suite and every `scripts/test-*.sh` are green on
|
||||
Sage's gate, with the mutants killed. Target: in review 10-25, landed 10-26, before the ops-01 proof.
|
||||
|
||||
+18
-11
@@ -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 2539 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 2641 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -230,23 +230,23 @@ Generated from `docs/plans/queue.json` revision 2539 by `queue render`. Do not e
|
||||
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | in-progress | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | — |
|
||||
| 192 | Q175 phase 1: the Console verifies SSO itself (--sso), OIDC RP with oauth4webapi 3.8.8, no live change (MVP T1.2, #412) | launchpad; reviewers webby | #1690 | done | — | Webby approves the manifest; webui and cli suites, scripts/test-config.sh and every scripts/test-*.sh green on Sage's gate; design section 9 mutants killed (sage) | `docs/plans/2026-10-20_sso-phase1.md` § Brief: Q175 phase 1, the Console verifies SSO itself (--sso) | Landed 4429a104f on refactor (squash of launchpad/row192 53bb59462..37582e820, round 2 manifest 6f8cee14...7787). patch-id 4a4869026 matches the reviewed diff on all 18 paths. Gate on 088087ac9: package suites 2758/0, scripts 429/0, mutants 22 of 23 killed (#23 equivalent, sage-1407). npm ci in canonical, oauth4webapi 3.8.8. Live re-pin is a window with Mos (mos-1099 item 3). |
|
||||
| 193 | many humans per business, human roles, private and shared threads, one access.json store (MVP T1.3, Vikunja #414/#415) | morgana; reviewers duckworth | #1691 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, business, cli and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users.md` § Brief: many humans per business, human roles, private and shared threads | — |
|
||||
| 194 | agent lifecycle from the Console and the CLI (create, launch, terminate, regenerate, delete) with a per-agent hire/fire veto (MVP T1.5, Vikunja #414/#415) | honker; reviewers webby | #1692 | in-progress | — | Webby approves DESIGN.md then the manifest; bus, cli, business and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto | — |
|
||||
| 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 | sage-1610 (honker-0049): a human add takes tracker {bot, botId}, required for a vikunja definition, refused otherwise, checked by checkBot and bot uniqueness; CLI and API only. Delete accepts a tracker bot when all credentials are store refs, and its result names the bot for removal by hand in Vikunja. No Vikunja account is created (Q188 untouched). Mutants: drop the requirement, refuse the bot on delete, skip checkBot on add. |
|
||||
| 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, 233 done | Duckworth approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Brief: the Case 1 install on 10.1.1.53 (row 195) | sage-1439: rewritten to Case 1 on .53 (f24f2cd40). Chains on C2-13's candidate too once C2-13 is queued. Darkwing starts DESIGN.md after row 190's mutants. |
|
||||
| 196 | the human boundary across uids and humans: Console, tester listener, broker.sock and the path-bound human proof (P19 333, before federation R3, mos-0989) | gander; reviewers duckworth | #1694 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-boundary.md` § Brief: the human boundary across uids and humans (P19 333) | — |
|
||||
| 197 | a second gate lane: two full gates at once on dragon-lin, design first (mos-0977 item 6) | herb; reviewers webby | #1695 | in-review, round 1, request posted | — | Webby approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate, singly and in the two-lane proof (sage) | `docs/plans/2026-10-20_second-gate-lane.md` § Brief: a second gate lane, two full gates at once on dragon-lin | Order with row 198 (Sage, mos-1040 N1): 197 lands first. After B1 the shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. Row 198 rebases onto 197's landing before its manifest review. |
|
||||
| 198 | retire the control board writer on 7331 (unauthenticated loopback writer, Q175; mos-1008) | bushroot; reviewers webby | #1696 | in-progress | 192 done, 197 done | Webby approves INVENTORY.md then the manifest; control-board, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_retire-control-board.md` § Brief: retire the control board writer on 7331 | Order with row 197 (Sage, mos-1040 N1): 197 lands first. Row 198 rebases onto 197's landing before its manifest review; shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. |
|
||||
| 199 | MVP T1 item 6: a credential store per agent, own git identity, pushes to a remote (G11, G13; mos-0974) | megavolt; reviewers duckworth | #1697 | in-review, round 1, request posted | — | 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 3, 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) | — |
|
||||
| 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 | done | — | 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 | Landed cb5fdb064 on refactor (base 76134dfde), pushed. Patch-id 828fa21a matches git diff 00d4cc1f6..86963d281 on the 37 manifest paths; docs/TOOLS.md 3-way with row 200, hunk unchanged. Manifest 35f900fd, Duckworth approve r1 29779. Gate on 9e1085816+patch: packages 2735/0, scripts 429/0; landed blobs equal the gate tree. |
|
||||
| 200 | stack-release.sh activate --sso for the Q175 phase 2 window (row 192 F3; chained on 192) | launchpad; reviewers webby | #1698 | done | — | 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) | Landed 9e1085816 on refactor (base 722136772), pushed. Patch-id 7ebfa6a5 matches git diff 00d4cc1f6..9df40878f on the 3 manifest paths. Manifest b3ead55c, Webby approve r3 29772. Gate on 9df40878f: packages 2675/0, scripts 429/0. Mutants 13/14, M12 ruled equivalent (sage-1446). |
|
||||
| 201 | FED R1: access.json loader and validator, and covers (#414) | gizmoduck; reviewers duckworth | #1699 | done | — | 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 | Landed afad4993f on refactor (squash of gizmoduck ae3c36f24..44e36fc4c, manifest 225597146daf...0c520). patch-id ba604ad5e matches the reviewed diff on all 3 paths. Gate on 44e36fc4c in the slot (gizmoduck-0060): business 173/0, 10 scripts 422/0, mutants 44/44. Duckworth approve 29581. |
|
||||
| 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 | done | — | 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 | Landed d0bbc5c1c on refactor (base c9f374284). Patch-id 92c18aea matches git diff 44e36fc4c..f869fd062 on the 3 manifest paths. Manifest d38de8c9, Duckworth approve 29595. Gate 2789/0 on 4385b9ec1, mutants 7/7. Unpushed. |
|
||||
| 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-3k after machine grant row a (#1691; live needs a --migrate window with Jason) | morgana; reviewers duckworth | #1704 | queued | 205 done, 234 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 3k) | — |
|
||||
| 206 | 193b2: thread shares, schema 3k-3l after the #436b build (#1691; live needs a --migrate window with Jason) | morgana; reviewers duckworth | #1704 | queued | 205 done, 234 done, 236 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 3l) | — |
|
||||
| 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-review, round 1, request posted | — | 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. |
|
||||
| 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 | done | — | 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) | Landed f5753c0e8 on refactor (base 9d165be8c), pushed. Patch-id 723b1c83 matches git diff ad95c888c..0485384e9 on the 23 manifest paths; landed files pass r5 manifest 688d38b9. Duckworth approve r2 rev 2592, r3 29851. Mutants 52/53, M22b equivalent (rev 2584). Gate on ad95c888c+patch: packages 2796/0, scripts 429/0; landed blobs equal the gate tree. |
|
||||
| 211 | Force-push guard: scripts/git-hooks/pre-push refuses non-fast-forward and delete pushes from every worktree, and scripts/slot-busy.sh exits 1 on BUSY (#1709, Vikunja #426, mos-1078/1083) | rocko; reviewers dewey | #1709 | briefed | — | Dewey approves the manifest; the new or changed scripts/test-*.sh and every other scripts/test-*.sh green on Sage's gate; the mutants killed; hook installed in the canonical checkout with a scripted refused force-push (sage) | `docs/plans/2026-10-20_force-push-guard.md` § Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426) | — |
|
||||
| 212 | Case 2 C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | gosalyn; reviewers webby | #1713 | in-review, round 1, request posted | — | Webby approves the manifest. The library tests and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | — |
|
||||
| 213 | Case 2 C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | morgana; reviewers webby | #1714 | in-review, round 2, request posted | — | Webby approves the manifest. The business, bus and harness suites, `scripts/test-config.sh` and every `scripts/test-*.sh` are green on Sage's gate, and the `models` mutant is killed. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | — |
|
||||
@@ -258,7 +258,7 @@ Generated from `docs/plans/queue.json` revision 2539 by `queue render`. Do not e
|
||||
| 219 | Case 2 C2-09a one thread per creative instance | rocko; reviewers dewey | #1720 | briefed | — | Dewey approves the manifest. The bus, cli and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. Watch for hash pins on `bus.js` and `broker.mjs` (`packages/bus/tests/channel-proof.test.mjs` pins a webui file). (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09a one thread per creative instance | — |
|
||||
| 220 | Case 2 C2-10a no raw session view (M7) and no raw Discord engine (M8) | researcher; reviewers filbert | #1721 | briefed | — | Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10a no raw session view (M7) and no raw Discord engine (M8) | — |
|
||||
| 221 | Case 2 C2-20 Bean and Chet persona import, with a count-only dry run | gosalyn; reviewers webby | #1722 | briefed | — | Webby approves the manifest. The test file runs green on its own and the cli suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20 Bean and Chet persona import, with a count-only dry run | — |
|
||||
| 222 | Case 2 C2-03a fetch-only sync | megavolt; reviewers dewey | #1723 | briefed | — | Dewey approves the manifest. The library and bus suites are green on Sage's gate, along with every `scripts/test-*.sh`, and the mutant is killed. A search of the test run's output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03a fetch-only sync | — |
|
||||
| 222 | Case 2 C2-03a fetch-only sync | megavolt; reviewers dewey | #1723 | in-progress | — | Dewey approves the manifest. The library and bus suites are green on Sage's gate, along with every `scripts/test-*.sh`, and the mutant is killed. A search of the test run's output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03a fetch-only sync | — |
|
||||
| 223 | Case 2 C2-07 runner retry on a guard refusal, and the runner's notices | researcher; reviewers filbert | #1724 | briefed | — | Filbert approves the manifest. The harness suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-07 runner retry on a guard refusal, and the runner's notices | — |
|
||||
| 224 | Case 2 C2-05b broker lookup verbs and typed tools | rocko; reviewers dewey | #1725 | briefed | — | Dewey approves the manifest. The bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05b broker lookup verbs and typed tools | — |
|
||||
| 225 | Case 2 C2-10b Console chat: cited items and the status line | researcher; reviewers dewey | #1726 | briefed | — | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. Grep for hash pins on `bus.js` and `serve.mjs` before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10b Console chat: cited items and the status line | — |
|
||||
@@ -266,12 +266,19 @@ Generated from `docs/plans/queue.json` revision 2539 by `queue render`. Do not e
|
||||
| 227 | Case 2 C2-14 end-to-end acceptance on a scratch root | launchpad; reviewers dewey | #1728 | briefed | — | Dewey runs the script and approves. It is green on Sage's gate with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-14 end-to-end acceptance on a scratch root | — |
|
||||
| 228 | Row A: ZAI_API_KEY passes through ENV_ALLOW to sessions, README line, pass-through test (XS, Haiku high) | researcher; reviewers webby | #1729 | in-review, round 1, request posted | — | Webby approves the manifest. The mutant is killed. The seat suite and the cli suite are green on Sage's gate with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row A, ZAI_API_KEY in the session env allowlist | — |
|
||||
| 229 | Row B: per-instance pi agent dir for Chet's Codex login, design only, starts after 10-27 (#428) | gosalyn; reviewers webby | #1730 | queued | — | A live check, with Jason's or Mos's go, that two clients on one ChatGPT login don't revoke each other's refresh tokens. No build row starts before it passes. Held until after 10-27. (sage) | `docs/plans/2026-10-20_case2-rows.md` § Brief: row B, a per-instance pi agent dir for Chet's Codex login (#428) | — |
|
||||
| 230 | Case 2 C2-13a unit render and host precheck (case2-unit.sh, case2-precheck.sh) | gosalyn; reviewers webby | #1732 | in-progress | — | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every scripts/test-*.sh. A run on a host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13a unit render and host precheck | — |
|
||||
| 230 | Case 2 C2-13a unit render and host precheck (case2-unit.sh, case2-precheck.sh) | gosalyn; reviewers webby | #1732 | done | — | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every scripts/test-*.sh. A run on a host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13a unit render and host precheck | Landed c8bc5cf4c on refactor (base 390ea5261), pushed. Patch-id f4b49eb68997 matches git diff 722136772..96768766c on the 5 manifest paths (manifest fbb0d7e42c67, Webby approve r2 rev 2563). packages/cli/README.md 3-way with row 199, hunks apart. Gosalyn gate on 96768766c: cli 541/0, scripts 429/0, mutants 10/10. Merged tree check: case2-unit 30/0, host 42/0. |
|
||||
| 231 | Case 2 C2-01b survey escapes, list item lines and attachment embeds (row 212 N1-N3) | gosalyn; reviewers webby | #1733 | briefed | 212 done | Webby approves the manifest. The library tests are green on Sage's gate, along with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01b survey escapes, list item lines and attachment embeds | — |
|
||||
| 232 | Installer I1: stack-release external units and stack-copy bundle copies (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1734 | in-progress | — | Filbert approves the manifest. The cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I1 stack-release external units and bundle copies | — |
|
||||
| 233 | Installer I3: stack-upgrade.sh root driver and the ops-01 host run sheet (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1735 | queued | 232 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I3 the upgrade command and the host run sheet | — |
|
||||
| 234 | Machine grant row a: host enrollment, store records and migration 3i-3j (#436a, design r6 f9039682e, lands by 10-25 before the .53/.54 installs) | gizmoduck; reviewers duckworth | #1740 | queued | 210 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. The design's section 10 row a tests and mutants pass, the migration test included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row a: enrollment, store records and migration 3i-3j | — |
|
||||
| 232 | Installer I1: stack-release external units and stack-copy bundle copies (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1734 | done | — | Filbert approves the manifest. The cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I1 stack-release external units and bundle copies | Landed e65a3ab4d on refactor (base 0444acd08), pushed. Patch-id 5adfbab9 matches git diff 8fede5fc6..da2da2f6c on the 9 manifest paths; tree entries (modes too) equal da2da2f6c, files pass manifest 7af3c943. Filbert approve r1 29829, r2 29865. Darkwing's landing gate on da2da2f6c: cli 593/0, scripts 429/0 (+156 queue node tests). Mutants 17/17. |
|
||||
| 233 | Installer I3: stack-upgrade.sh root driver and the ops-01 host run sheet (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1735 | in-progress | 232 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I3 the upgrade command and the host run sheet | — |
|
||||
| 234 | Machine grant row a: host enrollment, store records and migration 3i-3j (#436a, design r6 f9039682e, lands by 10-25 before the .53/.54 installs) | gizmoduck; reviewers duckworth | #1740 | in-progress | 210 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. The design's section 10 row a tests and mutants pass, the migration test included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row a: enrollment, store records and migration 3i-3j | — |
|
||||
| 235 | Machine grant row b: raise binding, executor, reconcile and revoke (#436, design r6 f9039682e, C1, N1, G27, G28) | gizmoduck; reviewers duckworth | #1741 | queued | 234 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 10's row b tests and mutants pass, G27, G28 and the upsert mutant included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b: raise binding, executor, reconcile and revoke | — |
|
||||
| 236 | #436b Gitea grant path build: seat accounts and tokens through an approved grant, schema 3j-3k (design r5 1be535aca, due 10-27 at risk; live waits for Jason's section 8 approval and bootstrap step) | megavolt; reviewers dewey | #1742 | queued | 210 done, 234 done | Dewey approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. Section 9's mutants are killed, N9's restart test included. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: #436b Gitea grant path, build (schema 3j-3k) | — |
|
||||
| 237 | Row 199 follow-up N1: one decision publishes once (publish-busy or #spent recheck) | megavolt; reviewers duckworth | #1743 | done | 199 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 199 follow-up N1, one decision publishes once | Landed d7d943199 on refactor (local, push pending), patch-id c5f4beb4 = e42415935, manifest bb777cb0 blobs checked. Sage gate 01:31-01:44Z on 816d48b03+4 paths: M1 killed (bus 1/13, cli 1/8), every package suite and scripts/test-*.sh green (bus 478, cli 564). |
|
||||
| 238 | row 200 follow-up N1: the sso-config hint carries --instance NAME (#1744) | launchpad; reviewers webby | #1744 | in-review, round 1, request posted | 200 done | Webby approves the manifest; stack-release.test.mjs whole, cli suite and every scripts/test-*.sh green on Sage's gate; the mutant killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: row 200 follow-up N1, the sso-config hint names the instance | — |
|
||||
| 239 | Row 194 follow-up: the credential revoke runs under the writer's lock, refusals after a revoke say so, and a stale-revision fire test (duckworth-0074, mos-1158) | honker; reviewers duckworth | #1745 | queued | 194 done | Duckworth approves the manifest. The bus, business, cli and webui suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: row 194 follow-up, the revoke under the writer's lock and a fire test | — |
|
||||
| 240 | Installer I4: hostcheck, the bwrap profile check, pins and boot unit, from r6 B2 (MVP, Q187, Vikunja #432; split from I3) | darkwing; reviewers filbert | #1746 | queued | 233 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) (sage) | `docs/plans/2026-10-20_installer-rows.md` § I4 hostcheck: the bwrap profile check, pins and boot unit | — |
|
||||
| 241 | Row 237 follow-up: one decision lands once, a two-ticket broker land test and three mutants on the landing guards (megavolt-0069, sage-1651, P19 441) | megavolt; reviewers duckworth | #1747 | done | 237 done | Duckworth approves the manifest. The bus, seat and cli suites and every scripts/test-*.sh are green on Sage's gate, and the three mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 237 follow-up, one decision lands once, with tests at the broker | Landed 5d4a61776 on refactor, pushed (base e65a3ab4d). Blobs from 3e3ed303e on 2 paths; patch-id a1649f80 equals git diff f5753c0e8..3e3ed303e on those paths; ls-tree modes equal; manifest e8266864 verifies on the landed files. Sage slot on bbf3b1ee8: M1-M3 killed; gate 15 packages 2797/0, 10 script suites 429/0. Duckworth approve 29870 rev 2628. |
|
||||
| 242 | Row 237 follow-up N1: a retry after publish-timeout pushes once; hold the decision or kill and wait for the push, a slow-push retry test and a mutant (duckworth-0069, P19 440) | megavolt; reviewers duckworth | #1748 | done | 237 done | Duckworth approves the manifest. The bus and cli suites and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 237 follow-up N1, a retry after publish-timeout pushes once | Landed 5fb736b92 on refactor, pushed (base 96bac588f). Blobs from 635c10924 on 3 paths; patch-id ceb741ee equals git diff 8fede5fc6..635c10924 on those paths; ls-tree modes equal; manifest 1fbfab6c verifies on the landed files. Megavolt M1/M2 killed on Sage's slot; Sage gate 15 packages 2826/0, 10 script suites 429/0. Duckworth approve 29889 rev 2639. |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+3512
-68
File diff suppressed because it is too large
Load Diff
+265
-2
@@ -1,6 +1,6 @@
|
||||
# PRD: Mosaic Stack
|
||||
|
||||
- Status: approved, version 1.6. Jason approved draft 0.4 unchanged as
|
||||
- Status: approved, version 1.8. Jason approved draft 0.4 unchanged as
|
||||
1.0 on 2026-10-05 (lead decision 61). They approved version 1.1, the
|
||||
Organization requirements, in decision 03130d08-ddce-480d-a601-e59f64744fc3
|
||||
on 2026-10-14. They approved version 1.2, the chief of staff, the
|
||||
@@ -15,7 +15,64 @@
|
||||
confirmation of the text is the approval, so there is no in-Stack
|
||||
prd.approve for it. Version 1.6 records Jason's ruling Q169 of
|
||||
2026-10-16, on who removes and who configures the assistant. It is
|
||||
never edited in place. Changes are a new version.
|
||||
never edited in place. Changes are a new version. Version 1.7 routes the
|
||||
chief of staff's hire and fire actions and the coder's push to its own
|
||||
fork, and adds agent host access through the credential store, approved
|
||||
by Jason in decision d52febb5 (prd.approve, yes). Version 1.8 records
|
||||
Jason's ruling Q188 of 2026-10-20: trusted actions within role
|
||||
guardrails by default, a manual mode for per-use raises, and grants the
|
||||
chief of staff requests and the user approves. Jason approved it in T3,
|
||||
relayed by Mos on 2026-10-21 (DECISIONS Q192), so there is no in-Stack
|
||||
prd.approve for it.
|
||||
- Version 1.8 source, Jason's approval in T3, relayed by Mos on 2026-10-21
|
||||
(DECISIONS Q192): "PRD 1.8: I am preemptively approving it. Do not
|
||||
surface in the Mosaic Stack board. Just proceed."
|
||||
- Version 1.8 source, Jason's ruling Q188 (DECISIONS, 2026-10-20), his
|
||||
answers to Q8 and Q10, in part. Q8: "I would rather grant the grant
|
||||
right to an agent that runs with strict guardrails. That agent presents
|
||||
the final authorization for the user. Upon approval, the grant is
|
||||
created programmatically. I see this as: User configures their PATs, API
|
||||
tokens, authorizations within the WebUI. The agent presents the request
|
||||
for grant with X rights for another agent. The user reviews, then clicks
|
||||
Approve/Deny. Click of Approve fires off protected mechanical methods to
|
||||
handle the grants on Github, Gitea, .... services as needed. The tooling
|
||||
would need to exist and must be guarded." And "Disallowing the agents to
|
||||
create grants autonomously is a necessary evil for better security.
|
||||
Requiring the user to manually perform the steps is a no-fly." Q10:
|
||||
"Agents should have tools and command access based on roles. Those roles
|
||||
dictate the allowed tools and commands. The use of a tool or command
|
||||
should not be raised to the user unless the system is configured with a
|
||||
manual mode that requires user interaction. By default I want to have
|
||||
the agents capable of running commands and tools at will within their
|
||||
sandbox/guardrails. This allows autonomous actions by agents within
|
||||
their lanes. Perhaps a "Trusted actions within approved guardrails"
|
||||
methodology." And "The main agent or chief of staff would be responsible
|
||||
for surfacing the request to the user's inbox for approval. Not all
|
||||
agents are capable or authorized to request directly. It must follow the
|
||||
chain of command properly." He agreed with Mos's Q10 points 3 to 6:
|
||||
protected stack code does every step on Approve, his tokens are entered
|
||||
in the WebUI and agents never see them, a command on the approved list
|
||||
runs without a click and any other is refused, and he, Mos or the tester
|
||||
can revoke a grant at once.
|
||||
- Version 1.8 source, Jason's ruling Q190 (DECISIONS, 2026-10-20), his
|
||||
answer "Q12: O1": each agent seat gets its own account on his Gitea,
|
||||
created through the Q188 approval path once the Gitea grants exist.
|
||||
- Version 1.8 source, Jason's ruling Q191 (DECISIONS, through Mos in
|
||||
mos-1149), that the assistant and the chief of staff are separate roles,
|
||||
and only the chief raises a grant request: "Q13: that looks better".
|
||||
- Version 1.7 source, TARGET.md line 66: "Chief of staff agent has
|
||||
hire/fire authority for agents as needed; unless user forbids per
|
||||
agent". And Jason through Mos (mos-1067), DECISIONS Q183, in part: "I
|
||||
will still want to allow infra agent(s) on my .53 instance to access
|
||||
other servers, etc. However, not all agents will need to have that
|
||||
access. I envision a credential store within Mosaic Stack that works as
|
||||
some sort of variable for the agent to call. The system will enforce
|
||||
access to the credentials to keep the agents sandboxed with proper
|
||||
security. I need to be able to trust my agents with assisting me. This
|
||||
will require sudo access to several systems. If we can utilize the
|
||||
credential store in Mosaic Stack and allow agents to perform their jobs
|
||||
with NOPASSWD sudo on various machines, I am a happy man." DECISIONS
|
||||
Q184, on whether that is in place by 10-27: "Q5: by 10.27"
|
||||
- Version 1.6 source, Jason's ruling Q169 (DECISIONS, brain repo,
|
||||
2026-10-16): "The only agent that should be able to remove the
|
||||
assistant role is the chief of staff. The COS and the user should have
|
||||
@@ -262,6 +319,39 @@ Added in version 1.2:
|
||||
in its own words. It summarizes and doesn't relay their text. (Version
|
||||
1.5.)
|
||||
|
||||
Added in version 1.7:
|
||||
|
||||
- **REQ-COS-3.** The chief of staff hires and fires role instances without
|
||||
a decision each time. Hiring adds a role instance to the organization
|
||||
file and lists it for launch. Firing ends the instance's runs and
|
||||
removes it from the launch list. It stays in the organization file. Only
|
||||
the user deletes an instance. These actions are `role.hire` and
|
||||
`role.fire`. They leave the hard waits and route under this requirement,
|
||||
within these bounds:
|
||||
- The chief acts only in its own organization.
|
||||
- The chief hires only role definitions that need no credentials, until
|
||||
a later version adds credentialed roles.
|
||||
- The chief hires only the role kinds its role file lists. Changing that
|
||||
list is a reviewed commit.
|
||||
- The chief hires only a role whose every action is on a fixed list of
|
||||
plain work actions: sending messages, writing records, asking for
|
||||
review, updating its assigned tasks, and the scope and working-push
|
||||
actions of an assigned task. A role holding any other action, such as
|
||||
launch, end, organization, naming, hire, review verdict or merge
|
||||
authority, or any action added later, is never hired. So the chief
|
||||
never hires a chief, never picks its own reviewers or merge gate, and
|
||||
the author is never the reviewer. This holds whatever the role file's
|
||||
list says. Adding to that list is a PRD change.
|
||||
- The chief may set a hired instance's model and narrow its limits, and
|
||||
nothing else about it.
|
||||
- The user can forbid per agent. The user marks an instance so the chief
|
||||
can't fire it, end its runs or change its model, and marks role kinds
|
||||
the chief can't hire. Only the user changes these marks, through the
|
||||
WebUI or the CLI.
|
||||
- The kill switch stops every hire and fire while it is on.
|
||||
- Every hire and fire is logged with the chief's instance, its run and
|
||||
the reason.
|
||||
|
||||
### Variables
|
||||
|
||||
- **REQ-VAR-1.** Variables come in four layers: system, business, project
|
||||
@@ -289,8 +379,111 @@ Added in version 1.2:
|
||||
- in Vikunja, a `bot-<business>-<role>` user per role, plus one
|
||||
read-only `bot-<business>-sync` that does all polling (lead
|
||||
decisions 52 and 58).
|
||||
|
||||
Since version 1.8, a token or key for a grant is made by the grant code
|
||||
on the user's Approve (REQ-GRANT-2), not by hand. Tokens that exist stay
|
||||
as they are.
|
||||
- **REQ-CRED-2.** A session that finds only founder credentials stops.
|
||||
|
||||
Added in version 1.7:
|
||||
|
||||
- **REQ-CRED-3.** An agent with a grant can run commands with sudo on
|
||||
another host. The agent asks for the host credential by name. The stack
|
||||
checks that a grant exists for that agent and that host before the
|
||||
credential is used. The agent never sees the key or any other secret
|
||||
value (REQ-CRED-1, REQ-VAR-2). An agent without a grant for that host is
|
||||
refused. Not every agent gets a grant. Who grants a host credential, and
|
||||
how, is set by the #424 design, which Jason approves.
|
||||
|
||||
Added in version 1.8 (Q188):
|
||||
|
||||
- **REQ-CRED-4.** An action a role definition lists within-role, and that
|
||||
uses a grant, runs without a decision for each use only when all of
|
||||
these hold:
|
||||
- the definition declares the credential kind the action uses;
|
||||
- a grant names that agent, and that host or service;
|
||||
- for a command, the command matches the grant's allowlist. A grant
|
||||
lists exact argv prefixes. A wildcard grant is refused;
|
||||
- the agent's key or token for that grant is issued and not revoked.
|
||||
|
||||
A command off the allowlist is refused, and adding one takes a new grant
|
||||
request (REQ-GRANT-1). An action whose definition doesn't list it
|
||||
within-role still needs a decision for each use. Only a reviewed role
|
||||
change adds an action to a definition. `host.sudo` routes under this
|
||||
requirement.
|
||||
- **REQ-GRANT-1.** No agent creates or writes a grant. A grant request
|
||||
follows the chain of command, and only the chief of staff raises it to
|
||||
the user's inbox. Any other role that needs a grant for itself or for an
|
||||
agent it directs asks the chief of staff, through its chain: a worker
|
||||
role through its PM, the PM and the CTO directly. The assistant passes
|
||||
the user's own request to the chief of staff (REQ-ASSIST-1). The request
|
||||
names the agent, the grant method and its target, the exact rights, and
|
||||
a rotate-by or expiry date. The chief raises one only for an agent whose
|
||||
reviewed role definition allows that kind of credential, and the broker
|
||||
refuses any other. A grant can't name a wildcard, a shell or an empty
|
||||
command. The request is a decision with the action `credential.mint`, so
|
||||
it goes to the user as a hard wait (REQ-WAIT-1). A grant whose method
|
||||
makes an account, such as a seat's own Gitea user (Q190), is the same
|
||||
one decision: the method in the grant says it makes an account, the
|
||||
request names the account, and the inbox shows the account name before
|
||||
Approve. The user's one resolution covers the account and its
|
||||
credential, so the hard-waits list doesn't change. An `account.create`
|
||||
raise isn't a grant request and routes as version 1.7 has it. The broker
|
||||
checks only that the chief of staff raised the request. The chain of
|
||||
command before that is in the messaging lists and the role contracts,
|
||||
not a broker check. The assistant and the chief of staff are separate
|
||||
roles. No agent holds both, and the broker refuses a claim that would.
|
||||
- **REQ-GRANT-2.** The user approves or denies each grant request in the
|
||||
inbox. Approve is the only thing that creates a grant, and the code uses
|
||||
a grant only when it has a matching Approve record. No agent creates a
|
||||
grant. A grant written into the business file by hand makes nothing. Row
|
||||
210's hand-written grants stay a test path until Vikunja #436a moves
|
||||
`host.run` to grants made by Approve. On Approve, protected stack code
|
||||
does every step, not an agent and not the user: it records the grant,
|
||||
makes the key or token, installs it on the machine or creates it on the
|
||||
service, and logs each step. On Deny nothing is made, and the denial is
|
||||
logged. The grant methods are:
|
||||
- machine key: a key for one agent on one enrolled host, with the
|
||||
command allowlist of REQ-CRED-4. A host's identity comes from the host
|
||||
side when the user approves its enrollment, never from a first
|
||||
connection. A host user with sudo receives a grant only after the user
|
||||
approved that user by name;
|
||||
- Gitea: a token for one agent, and for an agent seat its own Gitea user
|
||||
(Q190), with an expiry. The all scope and the admin scope are never
|
||||
granted;
|
||||
- GitHub: a grant for one agent.
|
||||
|
||||
The Gitea and GitHub rights units come in a later design that Jason
|
||||
approves.
|
||||
|
||||
The approval binds the exact rights the user saw and the target or its
|
||||
enrolled identity. A change to the agent, its definition, the target,
|
||||
the target's identity or the rights after the raise voids it. An
|
||||
approval is used once. A grant the code couldn't finish is undone at
|
||||
once on the stack's side, so it is never used. What it left on the host
|
||||
or service stays visible as pending until it is removed, as for a revoke
|
||||
(REQ-GRANT-4). A new grant needs a new approval, never an automatic
|
||||
retry. The grant code runs only on a request the user resolved. A
|
||||
request resolved by an arbiter, the tester or any agent makes nothing. A
|
||||
`credential.mint` decision raised before version 1.8, or without the
|
||||
fields of REQ-GRANT-1, makes nothing when it is approved. Until the
|
||||
Console knows the signed-in human (Vikunja #412), Approve is
|
||||
`mosaic decide` in the CLI (REQ-DEC-3).
|
||||
- **REQ-GRANT-3.** The user enters their own personal access tokens and
|
||||
API tokens in the WebUI, or the CLI. Only the grant code reads them, to
|
||||
make grants. No agent ever sees a token value or a key's private half.
|
||||
Values appear in no event, log, message, prompt or agent file, and no
|
||||
agent can read them back (REQ-VAR-2). Until the Console knows the
|
||||
signed-in human, they are entered through the CLI.
|
||||
- **REQ-GRANT-4.** A grant can be revoked at once by the user, by the
|
||||
assistant for a grant in its own organization, or by the tester on a dev
|
||||
instance (Q173). A revoke needs no decision and no new trust. It takes
|
||||
effect at once on the stack's side, without waiting on the network, so
|
||||
the next use is refused. Removing the key or token at the host or
|
||||
service follows. Until that removal is done it stays visible as pending.
|
||||
A revoke while the grant is being made stops it, and nothing it made
|
||||
stays usable. Every revoke is logged with who made it.
|
||||
|
||||
### Decisions
|
||||
|
||||
- **REQ-DEC-1.** Every decision is a record in an append-only SQLite
|
||||
@@ -338,6 +531,31 @@ Added in version 1.5 (Q167):
|
||||
settled under one commit stays settled when a later commit lands, and
|
||||
new raises cite the new commit.
|
||||
|
||||
### Trusted actions
|
||||
|
||||
Added in version 1.8 (Q188).
|
||||
|
||||
- **REQ-TRUST-1.** Agents act within their role guardrails without asking.
|
||||
The default mode is "trusted actions within approved guardrails". An
|
||||
action a role file grants within-role is done at will, inside the
|
||||
agent's sandbox, its tool limit and its grants, and is logged. It raises
|
||||
nothing to the user. An action the file doesn't grant within-role still
|
||||
routes by REQ-DEC-2, and a hard wait still goes to the user
|
||||
(REQ-WAIT-1). Only a reviewed role change adds an action to a role file,
|
||||
and REQ-WAIT-2 still keeps every hard wait out of every role file.
|
||||
- **REQ-TRUST-2.** The user can put chosen actions in manual mode. The
|
||||
organization file holds the list of actions in manual mode, empty by
|
||||
default. While an action is on the list, every use of it by any role
|
||||
becomes a decision for that use, raised to the user, and the action
|
||||
waits until the user resolves it. A decision denied, or not resolved,
|
||||
refuses the action. Each decision is used once. For a command on another
|
||||
host, the decision names the host, the command and its input. Actions
|
||||
not on the list are unchanged. Only the user changes the list, through
|
||||
the WebUI or the CLI. The chief of staff's organization writes
|
||||
(REQ-ROLE-3) can't change it, and no agent can. Every change is logged
|
||||
with who made it. A change applies to the next use, and a run already in
|
||||
progress keeps no exemption.
|
||||
|
||||
### Hard waits
|
||||
|
||||
Added in version 1.5 (Q167).
|
||||
@@ -371,6 +589,13 @@ Added in version 1.5 (Q167).
|
||||
PRD version says it isn't. The reviewed commit that adds it names which
|
||||
it is.
|
||||
|
||||
`role.hire` and `role.fire` were added as hard waits and route under
|
||||
REQ-COS-3 since version 1.7. `git.push.remote` was added as a hard wait
|
||||
and routes under REQ-LAND-2 since version 1.7.
|
||||
|
||||
`host.sudo` was added as a hard wait and routes under REQ-CRED-4 since
|
||||
version 1.8.
|
||||
|
||||
### Messages
|
||||
|
||||
- **REQ-MSG-1.** Messages are addressed to a role. They are append-only
|
||||
@@ -470,6 +695,37 @@ Added in version 1.5 (Q167, Mos's reading Q4).
|
||||
decision; the check ships with whichever of rows 137 and 139 lands
|
||||
second.
|
||||
|
||||
Added in version 1.7:
|
||||
|
||||
- **REQ-LAND-2.** A coder pushes its own work to its own fork. The action
|
||||
is `git.push.remote`.
|
||||
- What it pushes: one commit, by its oid, never a ref name. That commit
|
||||
is the latest work commit the bus recorded for the coder's task, and
|
||||
the coder made that push. A decision that authorizes a push names that
|
||||
commit, and it doesn't authorize any other.
|
||||
- Where it lands: only the branch
|
||||
`refs/heads/<its Gitea user>/<branch>`. The host builds that name, the
|
||||
agent never does. A push never forces.
|
||||
- Which remote: the agent's own fork of the project's upstream. The
|
||||
business file names the upstream, and the host never contacts it. For
|
||||
each push the host builds the fork's address over https from the
|
||||
upstream's host, port and repository name and the agent's own Gitea
|
||||
user. The history check, the push and the credential match all use
|
||||
that address. So the agent's own user owns the receiving repository on
|
||||
every push, and the agent never names it.
|
||||
- The host refuses an upstream on a host or port other than the agent's
|
||||
Gitea, and an upstream the agent's own user owns. It refuses a fork
|
||||
that is missing, empty or doesn't share the project's history. Gitea's
|
||||
push-to-create stays off, so a push never creates a repository.
|
||||
- Who may: the action routes under REQ-DEC-2 and is granted within-role
|
||||
to the coder role only. No other role file grants it.
|
||||
- Before version 1.7 the action was a hard wait under REQ-WAIT-3, so
|
||||
each push needed a decision the human resolved, and that decision
|
||||
named the commit.
|
||||
|
||||
Every push is logged with the remote, the ref, the commit and, when one
|
||||
was given, the decision.
|
||||
|
||||
### Interface
|
||||
|
||||
- **REQ-CLI-1.** `mosaic` is the front door. From it Jason can:
|
||||
@@ -925,6 +1181,13 @@ only hard waits in his inbox.
|
||||
file only picks among modules already in the tree. The reviewed commit
|
||||
that adds a module is the only check on what it does, which is why
|
||||
modules from outside the repository wait for Jason (REQ-PLUG-1).
|
||||
- Trusted mode lets an agent run its allowed commands at will. On another
|
||||
host, an allowed command that writes a file as root or starts a shell
|
||||
can plant a key, and the seat sandbox doesn't confine the network, so
|
||||
root can outlive the grant and a revoke. The allowlist narrows this. A
|
||||
seat sandbox with its own network namespace closes it. Until then the
|
||||
host's own audit log is the check after the fact (row 210 design section
|
||||
6).
|
||||
|
||||
## Milestones
|
||||
|
||||
|
||||
@@ -1053,6 +1053,9 @@ export class Broker {
|
||||
const decision = args.decision ?? null;
|
||||
const result = this.#checkAuthority(s, 'git.push.remote', { decision, target: task_ref });
|
||||
const facts = this.#publishFacts(s, task_ref, project, result);
|
||||
// Row 237 (#1743): one decision, one open publish. The decision is spent only at
|
||||
// recordPublish, after the push, so a second ticket on it would push too, maybe to another ref.
|
||||
if (result.decision && [...this.#publishes.values()].some((p) => p.s.business === s.business && p.decision === result.decision)) fail('publish-busy');
|
||||
const ticket = randomUUID();
|
||||
const dst = `refs/heads/${facts.user}/${branch}`;
|
||||
this.#publishes.set(ticket, { s, task_ref, project, decision, dst, ...facts });
|
||||
|
||||
@@ -257,9 +257,42 @@ export async function startBroker({
|
||||
if (!(e instanceof BusError)) process.stderr.write(`bus: publish git-failed: ${JSON.stringify({ name: e?.name, code: e?.code })}\n`);
|
||||
Object.assign(entry, { state: 'refused', code: e instanceof BusError ? e.code : 'git-failed' });
|
||||
},
|
||||
);
|
||||
).then(() => {
|
||||
if (entry.held) settle(ticket, entry, entry.held);
|
||||
});
|
||||
return { state: 'running' };
|
||||
}
|
||||
// Row 242 (#1748): the git socket gave up on a publish (publish-timeout) whose push it never
|
||||
// collected. Its publishRefused waits for the push: until that ends the ticket stays open, so
|
||||
// checkPublish refuses another publish on the decision with publish-busy (row 237). A push that
|
||||
// reached the fork is then recorded, which spends the decision; one that didn't is filed with
|
||||
// the socket's code. Either way the decision can't push a second ref.
|
||||
function settle(ticket, entry, code) {
|
||||
remotes.delete(ticket);
|
||||
try {
|
||||
if (entry.state === 'pushed') {
|
||||
try {
|
||||
broker.recordPublish(ticket, { ref: entry.ref, commit: entry.commit, previous: entry.previous });
|
||||
process.stderr.write(`bus: publish ${ticket}: pushed after the git socket refused ${code}; recorded\n`);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (!(e instanceof BusError)) throw e;
|
||||
code = e.code;
|
||||
}
|
||||
}
|
||||
broker.publishRefused(ticket, { code });
|
||||
} catch (e) {
|
||||
process.stderr.write(`bus: publish ${ticket}: not settled: ${JSON.stringify({ name: e?.name, code: e?.code })}\n`);
|
||||
}
|
||||
}
|
||||
function publishRefused(ticket, args) {
|
||||
const entry = remotes.get(ticket);
|
||||
if (!entry) return broker.publishRefused(ticket, args);
|
||||
if (!/^[a-z-]{1,64}$/.test(args?.code ?? '')) throw new BusError('invalid-request');
|
||||
if (entry.state === 'running') entry.held = args.code;
|
||||
else settle(ticket, entry, args.code);
|
||||
return args.code;
|
||||
}
|
||||
function publishResult(ticket) {
|
||||
const entry = remotes.get(ticket);
|
||||
if (!entry) throw new BusError('publish-unknown');
|
||||
@@ -296,7 +329,7 @@ export async function startBroker({
|
||||
publishPush,
|
||||
publishResult,
|
||||
recordPublish: (ticket, pushed) => broker.recordPublish(ticket, pushed),
|
||||
publishRefused: (ticket, args) => broker.publishRefused(ticket, args),
|
||||
publishRefused,
|
||||
landHeld: (record) => broker.landHeld(record),
|
||||
// Row 156 (#1651): the launcher's landing pass.
|
||||
landHolders: (business) => broker.landHolders(business),
|
||||
|
||||
@@ -306,6 +306,28 @@ test('a ticket outlives the run that asked, and recordLand checks the task again
|
||||
assert.throws(() => w.b.landRefused(ticket, { code: 'gate-failed' }), /land-unknown/);
|
||||
});
|
||||
|
||||
// Row 241 (#1747): one decision lands once. Two tickets on one decision can't both reach
|
||||
// recordLand through the git socket (land-busy, git-socket.mjs), so this pins the broker's own
|
||||
// guard: recordLand runs #landFacts again, and a decision spent by the first refuses the second.
|
||||
test('two tickets on one decision: the first recordLand lands, the second refuses decision-consumed and records nothing', (t) => {
|
||||
const w = setup(t);
|
||||
const { d } = w.ready();
|
||||
const first = w.check({ task_ref: TASK, decision: d.id }).ticket;
|
||||
const second = w.check({ task_ref: TASK, decision: d.id }).ticket;
|
||||
assert.notEqual(first, second);
|
||||
assert.deepEqual(w.b.recordLand(first, w.land), { task_ref: TASK, commit: B });
|
||||
assert.throws(() => w.b.recordLand(second, { ...w.land, commit: C, previous: B }), /decision-consumed/);
|
||||
assert.equal(w.merges().length, 1);
|
||||
assert.equal(w.merges()[0].body.commit, B);
|
||||
assert.equal(w.results().length, 1);
|
||||
assert.deepEqual(w.events('action.refused'), []);
|
||||
// The git socket then ends the second ticket with the broker's code, as it does any record refusal.
|
||||
assert.equal(w.b.landRefused(second, { code: 'decision-consumed', notify: true }), 'decision-consumed');
|
||||
assert.deepEqual(w.events('action.refused').map((e) => e.body), [{ code: 'decision-consumed' }]);
|
||||
assert.equal(w.merges().length, 1);
|
||||
assert.throws(() => w.b.recordLand(second, w.land), /land-unknown/);
|
||||
});
|
||||
|
||||
test('landRefused files the code on the task, tells the role only with notify, and ends the ticket', (t) => {
|
||||
const w = setup(t);
|
||||
const { d } = w.ready();
|
||||
|
||||
@@ -181,6 +181,32 @@ test('the request cannot name the fork, the user or the destination', (t) => {
|
||||
assert.throws(() => f.publish(d, 'coder', extra), /invalid-request/);
|
||||
});
|
||||
|
||||
// Row 237 (#1743, duckworth-0068 N1): the decision is spent at recordPublish, after the push, so
|
||||
// checkPublish refuses a second ticket on a decision that already has one open.
|
||||
test('one decision, one open publish: a second ticket refuses publish-busy, whatever its branch', (t) => {
|
||||
const f = setup(t);
|
||||
f.push(A);
|
||||
const d = f.approve();
|
||||
const r = f.publish(d);
|
||||
assert.throws(() => f.publish(d), /publish-busy/);
|
||||
assert.throws(() => f.publish(d, 'coder', { branch: 'other' }), /publish-busy/);
|
||||
// A refused publish frees the decision, which is still unspent; the retry gets a ticket.
|
||||
f.b.publishRefused(r.ticket, { code: 'fork-unavailable' });
|
||||
const retry = f.publish(d, 'coder', { branch: 'other' });
|
||||
assert.throws(() => f.publish(d), /publish-busy/);
|
||||
f.b.recordPublish(retry.ticket, { ref: retry.dst, commit: A, previous: null });
|
||||
assert.equal(f.events('action.allowed').filter((e) => e.body.action === 'git.push.remote' && e.body.operation !== 'decision.raise').length, 1);
|
||||
assert.throws(() => f.publish(d), /decision-consumed/);
|
||||
// Another agent's decision on the same task isn't held by this one's ticket.
|
||||
const f2 = setup(t);
|
||||
f2.push(A);
|
||||
const mine = f2.approve();
|
||||
f2.publish(mine);
|
||||
f2.push(B, A, 'second');
|
||||
const theirs = f2.approve('second');
|
||||
assert.equal(typeof f2.publish(theirs, 'second').ticket, 'string');
|
||||
});
|
||||
|
||||
test('publishRefused files the code on the task\'s trail and drops the ticket', (t) => {
|
||||
const f = setup(t);
|
||||
f.push(A);
|
||||
|
||||
@@ -45,7 +45,7 @@ test('requirements returns the committed list to a holding role session, and no
|
||||
const pm = agent('pm');
|
||||
const list = call(b, pm, 'requirements');
|
||||
assert.deepEqual(list, loadRequirements());
|
||||
assert.equal(list.requirements.length, 58);
|
||||
assert.equal(list.requirements.length, 68);
|
||||
assert.deepEqual(call(b, agent('coder'), 'requirements'), list);
|
||||
assert.throws(() => call(b, human, 'requirements'), /agent-required/);
|
||||
assert.throws(() => call(b, reader, 'requirements'), /read-only/);
|
||||
|
||||
@@ -50,10 +50,11 @@ in this repository, or `$MOSAIC_ROLES_DIR` if set.
|
||||
`withinRole` actions run without asking. `crossRole` actions need the
|
||||
arbiter. Any action not listed is gated: it goes to Jason, or to the
|
||||
arbiter for its domain when the raise cites the project's current spec
|
||||
commit (PRD 1.5 REQ-DEC-2). The eight hard waits in `GATED_ONLY`
|
||||
commit (PRD 1.5 REQ-DEC-2). The nine hard waits in `GATED_ONLY`
|
||||
(REQ-WAIT-1: `spend`, `data.delete`, `network.expose`, `account.create`,
|
||||
`credential.mint`, `message.external`, `git.push.protected`, `deploy`)
|
||||
always go to Jason and can't appear in either list (REQ-WAIT-2).
|
||||
`credential.mint`, `message.external`, `git.push.protected`,
|
||||
`git.push.remote`, `deploy`) always go to Jason and can't appear in
|
||||
either list (REQ-WAIT-2). `git.push.remote` joined in row 199 (#1697).
|
||||
`GATED_ONLY` is every action that `ROUTED` doesn't name, so an action
|
||||
added to the vocabulary is a hard wait until a PRD says otherwise and the
|
||||
commit adds it to `ROUTED` (REQ-WAIT-3). `git.merge.protected`,
|
||||
|
||||
+13
-2
@@ -710,8 +710,9 @@ The session credential comes from
|
||||
`EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env`, the
|
||||
one place the path is set. The file holds one line, for example
|
||||
`CLAUDE_CODE_OAUTH_TOKEN=<token>`, mode 0600. The operator writes it;
|
||||
nothing in the repository creates or reads it, and no session sees the
|
||||
file itself, only the variable. The leading `-` lets the unit start
|
||||
nothing in the repository creates it, and only the Case 2 precheck reads
|
||||
it, printing variable names, never values. No session sees the file
|
||||
itself, only the variable. The leading `-` lets the unit start
|
||||
without it, so Pi sessions still launch and a Claude Code launch refuses
|
||||
`no-session-credential`. To change the credential, edit or swap the file
|
||||
(or this line and this paragraph), then restart the unit; a running host
|
||||
@@ -773,6 +774,16 @@ Each live run requires Mos's go; agents build and test fixtures only, and
|
||||
so no PM turn may be in flight. Queue writes stay on the canonical
|
||||
scripts/mosaic.
|
||||
|
||||
On a host whose units are root's (row 232), `--units external` makes
|
||||
stack-release.sh read the system units and start or stop none. `activate`
|
||||
and `rollback --to` stage the copy and leave the `AWAITING-UNITS` marker;
|
||||
root installs and starts the units, then `confirm` gates the start and
|
||||
writes the pointer, `revert` undoes a staged run, and `gate --since` checks
|
||||
a copy with no open activation. stack-copy.sh adds `copy --bundle FILE SHA`
|
||||
for a host with no refactor checkout, `wrapper ID --create` for the first
|
||||
wrapper, and `check --units external`. Details are in
|
||||
[TOOLS.md, Root-owned units](../../docs/TOOLS.md#root-owned-units---units-external-row-232).
|
||||
|
||||
The human commands and `--tester` run only from the tree the bus runs from
|
||||
(row 130, #1625). The broker checks the path of the calling human-cli.mjs or
|
||||
tester-cli.mjs against its own. On a host with a pinned copy that tree is
|
||||
|
||||
@@ -38,7 +38,10 @@
|
||||
// one op at a time within 10 s. The poll ends at publishDeadlineMs
|
||||
// (publish-timeout), past git's own limit on each of the four git
|
||||
// runs. recordPublish files the push; any refusal after checkPublish
|
||||
// is filed once, by publishRefused.
|
||||
// is filed once, by publishRefused. After publish-timeout the bus
|
||||
// holds the decision until its push ends (a retry gets publish-busy),
|
||||
// then records a push that reached the fork or files the refusal
|
||||
// (row 242, #1748).
|
||||
//
|
||||
// A refusal is {"ok": false, "error": "<code>"}, and the connection ends.
|
||||
// The host checks, in order: identity (a role of this business), the request
|
||||
@@ -427,8 +430,9 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
|
||||
await op({ op: "publishPush", ticket });
|
||||
const until = Date.now() + publishDeadlineMs;
|
||||
for (;;) {
|
||||
// The bus's git may still finish after this; the fork could then hold the commit with
|
||||
// no record, as when recordPublish refuses after a push.
|
||||
// The bus's git may still finish after this. The bus holds the decision until it does,
|
||||
// then records a push that reached the fork (row 242); the fork can still hold the
|
||||
// commit with no record if recordPublish refuses, as after an in-band push.
|
||||
if (Date.now() >= until) {
|
||||
log(`git socket: publish ${task_ref}: publish-timeout; the bus process's push may still reach the fork`);
|
||||
throw new Refusal("publish-timeout");
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
// Case 2 row C2-13a (#1732): scripts/case2-unit.sh renders and installs the hub's two system units,
|
||||
// and scripts/case2-precheck.sh checks the host before they start. Brief: docs/plans/2026-10-20_case2-rows.md
|
||||
// "C2-13a". Every case runs against a scratch unit directory, a scratch home and fake systemctl,
|
||||
// getent, sudo, id, node, bwrap and claude. Nothing here reads or writes /etc/systemd,
|
||||
// ~/.mosaic-dev or ~/.config/mosaic-dev, and no fixture value is a real credential.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { businessDoc, writeJson } from "../../business/tests/helpers.mjs";
|
||||
import { binds, prepareRun, sandboxArgv, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { tmp } from "./helpers.mjs";
|
||||
|
||||
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const UNIT = join(REPO, "scripts", "case2-unit.sh");
|
||||
const PRECHECK = join(REPO, "scripts", "case2-precheck.sh");
|
||||
const BUS_TEMPLATE = join(REPO, "packages", "cli", "systemd", "mosaic-bus.service.in");
|
||||
const WEBUI_TEMPLATE = join(REPO, "packages", "webui", "systemd", "mosaic-webui.service.in");
|
||||
const COPY_A = "a".repeat(40);
|
||||
const COPY_B = "b".repeat(40);
|
||||
const UID = process.getuid();
|
||||
const GID = process.getgid();
|
||||
// A made-up key value; no output may hold it.
|
||||
const FIXTURE_KEY = "fixture-zai-key-3c9e51";
|
||||
|
||||
// A root-owned executable outside /usr/local/bin, /usr/bin and /bin, for the PATH case.
|
||||
function outsideNode() {
|
||||
const dir = "/usr/lib/systemd";
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
const p = join(dir, name);
|
||||
const s = statSync(p, { throwIfNoEntry: false });
|
||||
if (s?.isFile() && s.uid === 0 && (s.mode & 0o111) && (s.mode & 0o022) === 0 && realpathSync(p) === p) return p;
|
||||
}
|
||||
throw new Error(`no root-owned executable in ${dir}`);
|
||||
}
|
||||
const USR_NODE = realpathSync("/usr/bin/node");
|
||||
const TRUE_BIN = realpathSync("/usr/bin/true");
|
||||
|
||||
function script(file, text) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, `#!/usr/bin/env bash\n${text}\n`);
|
||||
chmodSync(file, 0o755);
|
||||
return file;
|
||||
}
|
||||
|
||||
// A scratch hub: a home with two copies and a proof file, a unit directory, and fakes.
|
||||
function hub(t, { uid = UID } = {}) {
|
||||
const root = tmp(t, "case2-unit-");
|
||||
const home = join(root, "home", "mosaic");
|
||||
for (const id of [COPY_A, COPY_B]) {
|
||||
mkdirSync(join(home, ".local", "share", "mosaic-stack", "copies", id, "scripts"), { recursive: true });
|
||||
writeFileSync(join(home, ".local", "share", "mosaic-stack", "copies", id, "scripts", "mosaic"), "");
|
||||
}
|
||||
const secrets = join(home, ".config", "mosaic-dev", "secrets");
|
||||
mkdirSync(secrets, { recursive: true, mode: 0o700 });
|
||||
const proof = join(secrets, "proxy-proof");
|
||||
writeFileSync(proof, "made-up-proof\n");
|
||||
chmodSync(proof, 0o600);
|
||||
const units = join(root, "units");
|
||||
mkdirSync(units);
|
||||
const bin = join(root, "bin");
|
||||
const log = join(root, "systemctl.log");
|
||||
script(join(bin, "getent"), `case "$1" in
|
||||
passwd) [ "$2" = mosaic ] && echo "mosaic:x:${uid}:${GID}::${home}:/bin/bash" ;;
|
||||
group) [ "$2" = "${GID}" ] && echo "mosaic:x:${GID}:" ;;
|
||||
*) exit 2 ;;
|
||||
esac`);
|
||||
// is-active answers yes for each unit named in CASE2_TEST_ACTIVE.
|
||||
script(join(bin, "systemctl"), `echo "$*" >>'${log}'
|
||||
if [ "$1" = is-active ]; then case " \${CASE2_TEST_ACTIVE:-} " in *" $3 "*) exit 0 ;; *) exit 3 ;; esac; fi
|
||||
[ "$*" != "\${CASE2_TEST_FAIL:-}" ]`);
|
||||
return { root, home, secrets, proof, units, bin, log };
|
||||
}
|
||||
|
||||
const libraryFlags = (h) => ["--port", "443", "--business", "acme", "--public-host", "books.example", "--auth-header", "X-Auth-User",
|
||||
"--allow-user", "melanie", "--send", "--proof-header", "X-Proxy-Proof", "--proof-file", h.proof];
|
||||
const factoryFlags = () => ["--port", "443", "--business", "acme", "--tester-port", "7333"];
|
||||
|
||||
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {} } = {}) {
|
||||
const serve = flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h));
|
||||
const args = ["--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
|
||||
const r = spawnSync(UNIT, args, {
|
||||
encoding: "utf8",
|
||||
timeout: 30000,
|
||||
env: { PATH: process.env.PATH, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, "systemctl"), CASE2_GETENT: join(h.bin, "getent"), ...env },
|
||||
});
|
||||
return { code: r.status, out: r.stdout, err: r.stderr };
|
||||
}
|
||||
|
||||
const busFile = (h) => join(h.units, "[email protected]");
|
||||
const webuiFile = (h) => join(h.units, "mosaic-webui.service");
|
||||
const read = (f) => readFileSync(f, "utf8");
|
||||
const lines = (text, key) => text.split("\n").filter((l) => l.startsWith(`${key}=`)).map((l) => l.slice(key.length + 1));
|
||||
const copyDir = (h, id = COPY_A) => join(h.home, ".local", "share", "mosaic-stack", "copies", id);
|
||||
const HARDENING = (h) => ["ProtectSystem=strict", `ReadWritePaths=${h.home}`, "ProtectHome=yes", "PrivateTmp=yes"];
|
||||
|
||||
function refuses(r, pattern) {
|
||||
assert.equal(r.code, 1, `expected a refusal, got ${r.code}: ${r.out}${r.err}`);
|
||||
assert.match(r.err, pattern);
|
||||
}
|
||||
|
||||
// A template copy with one edit, for the template cases.
|
||||
function template(h, from, name, edit) {
|
||||
const file = join(h.root, name);
|
||||
writeFileSync(file, edit(read(from)));
|
||||
return file;
|
||||
}
|
||||
|
||||
test("a clean library render writes both units 0644 with the fixed lines, and enables both then starts the bus then the Console", (t) => {
|
||||
const h = hub(t);
|
||||
const r = unit(h);
|
||||
assert.equal(r.code, 0, r.err);
|
||||
for (const f of [busFile(h), webuiFile(h)]) assert.equal(statSync(f).mode & 0o777, 0o644);
|
||||
const bus = read(busFile(h));
|
||||
const webui = read(webuiFile(h));
|
||||
assert.deepEqual(lines(bus, "Environment"), ["PATH=/usr/bin:/usr/local/bin:/bin"]);
|
||||
assert.deepEqual(lines(bus, "ExecStart"), [`${copyDir(h)}/scripts/mosaic bus start %i`]);
|
||||
assert.deepEqual(lines(bus, "EnvironmentFile"), [`-${h.home}/.config/mosaic-dev/secrets/claude-session.env`]);
|
||||
assert.deepEqual(lines(webui, "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${libraryFlags(h).join(" ")}`]);
|
||||
assert.deepEqual(lines(webui, "EnvironmentFile"), []);
|
||||
for (const text of [bus, webui]) {
|
||||
assert.deepEqual(lines(text, "WantedBy"), ["multi-user.target"]);
|
||||
assert.deepEqual(lines(text, "User"), ["mosaic"]);
|
||||
assert.deepEqual(lines(text, "Group"), ["mosaic"]);
|
||||
assert.deepEqual(lines(text, "NoNewPrivileges"), ["yes"]);
|
||||
const hardening = text.split("\n").filter((l) => /^(ProtectSystem|ReadWritePaths|ProtectHome|PrivateTmp)=/.test(l));
|
||||
assert.deepEqual(hardening, HARDENING(h));
|
||||
assert.doesNotMatch(text, /@[A-Z_]+@|%h|MOSAIC_CONFIG=|MOSAIC_CLAUDE_BIN|After=|Wants=/);
|
||||
}
|
||||
assert.deepEqual(lines(bus, "AmbientCapabilities"), []);
|
||||
assert.deepEqual(lines(bus, "CapabilityBoundingSet"), []);
|
||||
assert.deepEqual(lines(webui, "AmbientCapabilities"), ["CAP_NET_BIND_SERVICE"]);
|
||||
assert.deepEqual(lines(webui, "CapabilityBoundingSet"), ["CAP_NET_BIND_SERVICE"]);
|
||||
assert.match(r.out, /enabled and started mosaic-bus@acme\.service, then mosaic-webui\.service\n$/);
|
||||
assert.deepEqual(read(h.log).trim().split("\n"), [
|
||||
"is-active --quiet [email protected]",
|
||||
"is-active --quiet mosaic-webui.service",
|
||||
"daemon-reload",
|
||||
"enable [email protected] mosaic-webui.service",
|
||||
"start [email protected]",
|
||||
"start mosaic-webui.service",
|
||||
]);
|
||||
});
|
||||
|
||||
test("PATH starts with the --node directory, for a node outside the default list and for /usr/bin/node", (t) => {
|
||||
const h = hub(t);
|
||||
const node = outsideNode();
|
||||
assert.equal(unit(h, { node }).code, 0);
|
||||
for (const f of [busFile(h), webuiFile(h)]) assert.deepEqual(lines(read(f), "Environment"), [`PATH=${dirname(node)}:/usr/local/bin:/usr/bin:/bin`]);
|
||||
assert.match(lines(read(webuiFile(h)), "ExecStart")[0], new RegExp(`^${node} `));
|
||||
});
|
||||
|
||||
test("a failed bus start refuses with the Console not started", (t) => {
|
||||
const h = hub(t);
|
||||
const r = unit(h, { env: { CASE2_TEST_FAIL: "start [email protected]" } });
|
||||
refuses(r, /start mosaic-bus@acme\.service failed; the Console is not started/);
|
||||
assert.deepEqual(read(h.log).trim().split("\n").slice(2), ["daemon-reload", "enable [email protected] mosaic-webui.service", "start [email protected]"]);
|
||||
});
|
||||
|
||||
test("the script never links a unit file", () => {
|
||||
assert.doesNotMatch(read(UNIT), /systemctl link/);
|
||||
});
|
||||
|
||||
test("factory renders --pm once and one MOSAIC_CLAUDE_BIN line, and without them both tokens render empty", (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h, { profile: "factory", extra: ["--pm", "--claude", TRUE_BIN] }).code, 0);
|
||||
const bus = read(busFile(h));
|
||||
const exec = lines(bus, "ExecStart");
|
||||
assert.equal(exec.length, 1);
|
||||
assert.match(exec[0], / bus start %i --pm$/);
|
||||
assert.equal(exec[0].split("--pm").length, 2);
|
||||
assert.deepEqual(lines(bus, "Environment"), ["PATH=/usr/bin:/usr/local/bin:/bin", `MOSAIC_CLAUDE_BIN=${TRUE_BIN}`]);
|
||||
assert.deepEqual(lines(read(webuiFile(h)), "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${factoryFlags().join(" ")}`]);
|
||||
|
||||
const plain = hub(t);
|
||||
assert.equal(unit(plain, { profile: "factory" }).code, 0);
|
||||
const text = read(busFile(plain));
|
||||
assert.deepEqual(lines(text, "ExecStart"), [`${copyDir(plain)}/scripts/mosaic bus start %i`]);
|
||||
assert.doesNotMatch(text, /MOSAIC_CLAUDE_BIN|@PM_FLAGS@|@CLAUDE_ENV@/);
|
||||
});
|
||||
|
||||
test("a re-install over running units says they are still on the old copy and must restart together, never that it started them", (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h).code, 0);
|
||||
const both = unit(h, { copy: COPY_B, env: { CASE2_TEST_ACTIVE: "[email protected] mosaic-webui.service" } });
|
||||
assert.equal(both.code, 0, both.err);
|
||||
assert.doesNotMatch(both.out, /started/);
|
||||
assert.match(both.out, new RegExp(`mosaic-bus@acme\\.service was already running, so it is still on copy ${COPY_A}, not copy ${COPY_B}`));
|
||||
assert.match(both.out, new RegExp(`mosaic-webui\\.service was already running, so it is still on copy ${COPY_A}, not copy ${COPY_B}`));
|
||||
assert.match(both.out, new RegExp(`restart mosaic-bus@acme\\.service and mosaic-webui\\.service together to put both on copy ${COPY_B}`));
|
||||
assert.deepEqual(read(h.log).trim().split("\n").slice(-2), ["start [email protected]", "start mosaic-webui.service"]);
|
||||
|
||||
// Only the Console ran (the bus had stopped): the bus starts on the new copy, the Console stays.
|
||||
const one = unit(h, { copy: COPY_A, env: { CASE2_TEST_ACTIVE: "mosaic-webui.service" } });
|
||||
assert.equal(one.code, 0, one.err);
|
||||
assert.match(one.out, new RegExp(`started mosaic-bus@acme\\.service on copy ${COPY_A}`));
|
||||
assert.match(one.out, new RegExp(`mosaic-webui\\.service was already running, so it is still on copy ${COPY_B}, not copy ${COPY_A}`));
|
||||
assert.doesNotMatch(one.out, /started mosaic-webui/);
|
||||
assert.match(one.out, /restart mosaic-bus@acme\.service and mosaic-webui\.service together/);
|
||||
});
|
||||
|
||||
test("a re-install to another copy re-renders both units, and an edited installed unit refuses", (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h).code, 0);
|
||||
assert.equal(unit(h, { copy: COPY_B }).code, 0);
|
||||
assert.match(read(busFile(h)), new RegExp(`copies/${COPY_B}/scripts/mosaic`));
|
||||
assert.doesNotMatch(read(busFile(h)) + read(webuiFile(h)), new RegExp(COPY_A));
|
||||
|
||||
const edits = [
|
||||
[busFile, (s) => s.replace("User=mosaic", "User=root"), /User= must be exactly once in \[Service\], as User=mosaic/],
|
||||
[webuiFile, (s) => s.replace(/^ExecStart=(.*)$/m, "ExecStart=$1 --board http://127.0.0.1:1"), /ExecStart= must be exactly once/],
|
||||
[busFile, (s) => s.replace("ProtectHome=yes\n", ""), /ProtectHome= must be exactly once/],
|
||||
[webuiFile, (s) => s.replace("PrivateTmp=yes\n", ""), /PrivateTmp= must be exactly once/],
|
||||
[busFile, (s) => s.replace("Type=simple", "Type=simple\nX-Note=%h"), /%h is left/],
|
||||
];
|
||||
for (const [file, edit, pattern] of edits) {
|
||||
const before = read(file(h));
|
||||
writeFileSync(file(h), edit(before));
|
||||
refuses(unit(h, { copy: COPY_B }), pattern);
|
||||
writeFileSync(file(h), before);
|
||||
}
|
||||
assert.equal(unit(h, { copy: COPY_B }).code, 0);
|
||||
});
|
||||
|
||||
test("a template with a namespace, user or system call restriction refuses", (t) => {
|
||||
const h = hub(t);
|
||||
for (const line of ["RestrictNamespaces=yes", "PrivateUsers=yes", "SystemCallFilter=@system-service"]) {
|
||||
const key = line.split("=")[0];
|
||||
for (const [from, env] of [[BUS_TEMPLATE, "CASE2_BUS_TEMPLATE"], [WEBUI_TEMPLATE, "CASE2_WEBUI_TEMPLATE"]]) {
|
||||
const file = template(h, from, `t-${key}.in`, (s) => s.replace("NoNewPrivileges=yes", `NoNewPrivileges=yes\n${line}`));
|
||||
refuses(unit(h, { env: { [env]: file } }), new RegExp(`refusing ${key}=`));
|
||||
}
|
||||
}
|
||||
assert.equal(existsSync(busFile(h)), false);
|
||||
});
|
||||
|
||||
test("the bus template's EnvironmentFile line must be there and point at the session env, and the Console template must have none", (t) => {
|
||||
const h = hub(t);
|
||||
const gone = template(h, BUS_TEMPLATE, "no-env.in", (s) => s.replace(/^EnvironmentFile=.*\n/m, ""));
|
||||
refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: gone } }), /EnvironmentFile= must be exactly once/);
|
||||
const elsewhere = template(h, BUS_TEMPLATE, "env-elsewhere.in", (s) => s.replace("EnvironmentFile=-@SESSION_ENV@", "EnvironmentFile=-/etc/other.env"));
|
||||
refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: elsewhere } }), /EnvironmentFile= must be exactly once/);
|
||||
const added = template(h, WEBUI_TEMPLATE, "webui-env.in", (s) => s.replace("NoNewPrivileges=yes", "NoNewPrivileges=yes\nEnvironmentFile=-/etc/other.env"));
|
||||
refuses(unit(h, { env: { CASE2_WEBUI_TEMPLATE: added } }), /refusing EnvironmentFile=/);
|
||||
});
|
||||
|
||||
test("a template holding an unknown token, PermissionsStartOnly or a privileged Exec prefix refuses", (t) => {
|
||||
const h = hub(t);
|
||||
const token = template(h, BUS_TEMPLATE, "token.in", (s) => s.replace("Type=simple", "Type=simple\nX-Note=@SECRET@"));
|
||||
refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: token } }), /a token is left after the render: @SECRET@/);
|
||||
const webuiToken = template(h, WEBUI_TEMPLATE, "wtoken.in", (s) => s.replace("Type=simple", "Type=simple\nX-Note=@SECRET@"));
|
||||
refuses(unit(h, { env: { CASE2_WEBUI_TEMPLATE: webuiToken } }), /a token other than one @FLAGS@/);
|
||||
const pso = template(h, BUS_TEMPLATE, "pso.in", (s) => s.replace("Type=simple", "Type=simple\nPermissionsStartOnly=yes"));
|
||||
refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: pso } }), /refusing PermissionsStartOnly=/);
|
||||
for (const pre of ["+/srv/mosaic/x", "-+/srv/mosaic/x"]) {
|
||||
const file = template(h, BUS_TEMPLATE, "pre.in", (s) => s.replace("Type=simple", `Type=simple\nExecStartPre=${pre}`));
|
||||
refuses(unit(h, { env: { CASE2_BUS_TEMPLATE: file } }), /an Exec line that runs with full privileges: ExecStartPre=/);
|
||||
}
|
||||
});
|
||||
|
||||
test("a symlink at either unit path refuses", (t) => {
|
||||
for (const name of ["[email protected]", "mosaic-webui.service"]) {
|
||||
const h = hub(t);
|
||||
const target = join(h.root, "elsewhere");
|
||||
writeFileSync(target, "");
|
||||
symlinkSync(target, join(h.units, name));
|
||||
refuses(unit(h), new RegExp(`${name.replace(".", "\\.")} is a symlink`));
|
||||
}
|
||||
});
|
||||
|
||||
test("a --node that is relative, not root-owned or under <home> refuses", (t) => {
|
||||
const h = hub(t);
|
||||
refuses(unit(h, { node: "bin/node" }), /--node must be an absolute path/);
|
||||
const mine = script(join(h.root, "opt", "node"), "exit 0");
|
||||
refuses(unit(h, { node: mine }), /--node: .* is not owned by root/);
|
||||
const inside = script(join(h.home, "bin", "node"), "exit 0");
|
||||
refuses(unit(h, { node: inside }), /--node must be outside/);
|
||||
});
|
||||
|
||||
test("a re-install with a different --node or different serve flags refuses", (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h).code, 0);
|
||||
// A new node moves PATH too, so the installed unit fails its own Environment= check first.
|
||||
refuses(unit(h, { node: outsideNode() }), /installed .*: the Environment= lines must be exactly/);
|
||||
const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f));
|
||||
refuses(unit(h, { flags }), /installed .*: ExecStart= must be exactly once/);
|
||||
// A line check_unit doesn't fix still has to equal the render.
|
||||
writeFileSync(busFile(h), read(busFile(h)).replace(/^Description=.*$/m, "Description=edited"));
|
||||
refuses(unit(h), /differs from the render in more than the copy id/);
|
||||
});
|
||||
|
||||
test("an installed pair whose units name different copies refuses", (t) => {
|
||||
const h = hub(t);
|
||||
assert.equal(unit(h).code, 0);
|
||||
writeFileSync(webuiFile(h), read(webuiFile(h)).replaceAll(COPY_A, COPY_B));
|
||||
refuses(unit(h), new RegExp(`the installed units name different copies, ${COPY_A} and ${COPY_B}`));
|
||||
});
|
||||
|
||||
test("a copy id that is short, uppercase, holds / or .., or names no directory refuses", (t) => {
|
||||
const h = hub(t);
|
||||
for (const copy of ["a".repeat(39), "A".repeat(40), `${"a".repeat(19)}/${"a".repeat(20)}`, `..${"a".repeat(38)}`]) {
|
||||
refuses(unit(h, { copy }), /--copy must be a full 40-character lower-case commit id/);
|
||||
}
|
||||
refuses(unit(h, { copy: "c".repeat(40) }), /no copy directory/);
|
||||
});
|
||||
|
||||
test("library refuses a privileged-floor miss, a bad proof pair and every flag outside its set", (t) => {
|
||||
const h = hub(t);
|
||||
const base = libraryFlags(h);
|
||||
const without = (...names) => {
|
||||
const out = [];
|
||||
for (let i = 0; i < base.length; i++) {
|
||||
if (names.includes(base[i])) { if (base[i] !== "--send") i++; continue; }
|
||||
out.push(base[i]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
refuses(unit(h, { flags: base.map((f) => (f === "443" ? "7332" : f)) }), /--port must be 1\.\.1023/);
|
||||
refuses(unit(h, { flags: without("--proof-header", "--proof-file") }), /serve flag --proof-header is required/);
|
||||
refuses(unit(h, { flags: base.map((f) => (f === "X-Auth-User" ? "X-Authentik-Uid" : f)) }), /must be an Authentik uid/);
|
||||
assert.equal(unit(h, { flags: base.map((f) => (f === "X-Auth-User" ? "X-Authentik-Uid" : f === "melanie" ? "0".repeat(64) : f)) }).code, 0);
|
||||
rmSync(busFile(h));
|
||||
rmSync(webuiFile(h));
|
||||
for (const [flag, value, pattern] of [
|
||||
["--tester-port", "7333", /the library profile refuses --tester-port/],
|
||||
["--checkout", "/srv/x", /refusing --checkout/],
|
||||
["--board", "http://127.0.0.1:7331", /refusing --board/],
|
||||
["--sso", null, /refusing --sso/],
|
||||
["--nonsense", "1", /unknown serve flag: --nonsense/],
|
||||
]) {
|
||||
refuses(unit(h, { flags: [...base, flag, ...(value === null ? [] : [value])] }), pattern);
|
||||
}
|
||||
refuses(unit(h, { flags: base.map((f) => (f === "acme" ? "other" : f)) }), /serve --business other is not the unit's business acme/);
|
||||
refuses(unit(h, { extra: ["--pm"] }), /the library profile refuses --pm/);
|
||||
refuses(unit(h, { extra: ["--claude", TRUE_BIN] }), /the library profile refuses --claude/);
|
||||
|
||||
chmodSync(h.proof, 0o644);
|
||||
refuses(unit(h), /mode 0600 or 0400, not 644/);
|
||||
chmodSync(h.proof, 0o600);
|
||||
const link = join(h.secrets, "proof-link");
|
||||
symlinkSync(h.proof, link);
|
||||
refuses(unit(h, { flags: base.map((f) => (f === h.proof ? link : f)) }), /must not be a symlink/);
|
||||
const dataRoot = join(h.home, ".mosaic-dev");
|
||||
mkdirSync(dataRoot);
|
||||
const outside = join(dataRoot, "proof");
|
||||
writeFileSync(outside, "made-up-proof\n");
|
||||
chmodSync(outside, 0o600);
|
||||
refuses(unit(h, { flags: base.map((f) => (f === h.proof ? outside : f)) }), /must be under .*secrets, outside the data root/);
|
||||
assert.equal(existsSync(busFile(h)), false);
|
||||
});
|
||||
|
||||
// A non-root test can't make a root-owned file under the scratch home, so the
|
||||
// owner case names another uid in the passwd database instead.
|
||||
test("library refuses a proof file not owned by <user>'s uid", (t) => {
|
||||
const h = hub(t, { uid: UID + 4242 });
|
||||
refuses(unit(h), new RegExp(`proxy proof file must be owned by mosaic \\(uid ${UID + 4242}\\)`));
|
||||
});
|
||||
|
||||
test("factory refuses a tester port on --port, a missing --port or --business, and each proxy flag", (t) => {
|
||||
const h = hub(t);
|
||||
refuses(unit(h, { profile: "factory", flags: ["--port", "443", "--business", "acme", "--tester-port", "443"] }), /--tester-port must be 1024\.\.65535|--tester-port must differ from --port/);
|
||||
refuses(unit(h, { profile: "factory", flags: ["--port", "443", "--business", "acme", "--tester-port", "1443", "--port", "1443"] }), /given twice/);
|
||||
refuses(unit(h, { profile: "factory", flags: ["--business", "acme"] }), /serve flag --port is required/);
|
||||
refuses(unit(h, { profile: "factory", flags: ["--port", "443"] }), /serve flag --business is required/);
|
||||
for (const [flag, value] of [["--public-host", "x.example"], ["--auth-header", "X-A"], ["--allow-user", "u"], ["--proof-header", "X-P"], ["--proof-file", h.proof], ["--send", null]]) {
|
||||
refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), flag, ...(value === null ? [] : [value])] }), new RegExp(`the factory profile refuses ${flag}`));
|
||||
}
|
||||
for (const [flag, value, pattern] of [["--checkout", "/srv/x", /refusing --checkout/], ["--board", "http://127.0.0.1:7331", /refusing --board/], ["--sso", null, /refusing --sso/], ["--nonsense", "1", /unknown serve flag/]]) {
|
||||
refuses(unit(h, { profile: "factory", flags: [...factoryFlags(), flag, ...(value === null ? [] : [value])] }), pattern);
|
||||
}
|
||||
});
|
||||
|
||||
test("factory: the tester port equal to --port refuses", (t) => {
|
||||
const h = hub(t);
|
||||
refuses(unit(h, { profile: "factory", flags: ["--port", "443", "--business", "acme", "--tester-port", "443"] }), /--tester-port must be 1024\.\.65535/);
|
||||
// Ports 1024 and up can't be the Console's, so the equality check is reached only through a port below 1024; it stays as a guard.
|
||||
assert.match(read(UNIT), /--tester-port must differ from --port/);
|
||||
});
|
||||
|
||||
test("factory refuses a --claude that is relative, not root-owned or under <home>, and a re-install with another --pm or --claude", (t) => {
|
||||
const h = hub(t);
|
||||
refuses(unit(h, { profile: "factory", extra: ["--claude", "bin/claude"] }), /--claude must be an absolute path/);
|
||||
const mine = script(join(h.root, "opt", "claude"), "exit 0");
|
||||
refuses(unit(h, { profile: "factory", extra: ["--claude", mine] }), /--claude: .* is not owned by root/);
|
||||
const inside = script(join(h.home, "bin", "claude"), "exit 0");
|
||||
refuses(unit(h, { profile: "factory", extra: ["--claude", inside] }), /--claude must be outside/);
|
||||
|
||||
assert.equal(unit(h, { profile: "factory", extra: ["--pm", "--claude", TRUE_BIN] }).code, 0);
|
||||
refuses(unit(h, { profile: "factory", extra: ["--claude", TRUE_BIN] }), /--pm and --claude must equal the installed ones|ExecStart= must be exactly once/);
|
||||
refuses(unit(h, { profile: "factory", extra: ["--pm"] }), /Environment= lines must be exactly|differs from the render/);
|
||||
refuses(unit(h, { profile: "factory", extra: ["--pm", "--claude", USR_NODE] }), /Environment= lines must be exactly|differs from the render/);
|
||||
});
|
||||
|
||||
// ---- case2-precheck.sh ----
|
||||
|
||||
// A scratch user for the precheck: a config, a business file, an installed Console unit,
|
||||
// fakes on PATH and a fake /proc/sys.
|
||||
function host(t, { roles = {}, vars = {}, keyFile = `ZAI_API_KEY=${FIXTURE_KEY}\n`, port = "443", busClaude = null } = {}) {
|
||||
const root = tmp(t, "case2-pre-");
|
||||
const home = join(root, "home");
|
||||
const configDir = join(home, ".config", "mosaic-dev");
|
||||
const config = writeJson(join(configDir, "config.json"), {
|
||||
configVersion: 1,
|
||||
environment: "production",
|
||||
dataRoot: join(home, ".mosaic-dev"),
|
||||
execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash", adapter: "pi" },
|
||||
});
|
||||
const doc = businessDoc(root);
|
||||
doc.roles.cto.vars = { harness: "pi" };
|
||||
for (const [name, v] of Object.entries(roles)) doc.roles[name].vars = v;
|
||||
Object.assign(doc.vars, vars);
|
||||
writeJson(join(configDir, "businesses", "acme.json"), doc);
|
||||
const secrets = join(configDir, "secrets");
|
||||
mkdirSync(secrets, { recursive: true, mode: 0o700 });
|
||||
if (keyFile !== null) {
|
||||
writeFileSync(join(secrets, "claude-session.env"), keyFile);
|
||||
chmodSync(join(secrets, "claude-session.env"), 0o600);
|
||||
}
|
||||
const units = join(root, "units");
|
||||
mkdirSync(units);
|
||||
writeFileSync(join(units, "mosaic-webui.service"), `[Service]\nExecStart=/usr/bin/node /x/cli.mjs serve --port ${port} --business acme\n`);
|
||||
writeFileSync(join(units, "[email protected]"), `[Service]\nEnvironment=PATH=/usr/bin\n${busClaude ? `Environment=MOSAIC_CLAUDE_BIN=${busClaude}\n` : ""}`);
|
||||
const sys = join(root, "sys");
|
||||
mkdirSync(join(sys, "net", "ipv4"), { recursive: true });
|
||||
mkdirSync(join(sys, "kernel"), { recursive: true });
|
||||
writeFileSync(join(sys, "net", "ipv4", "ip_unprivileged_port_start"), "1024\n");
|
||||
writeFileSync(join(sys, "kernel", "apparmor_restrict_unprivileged_userns"), "1\n");
|
||||
const bin = join(root, "bin");
|
||||
const me = spawnSync("id", ["-un"], { encoding: "utf8" }).stdout.trim();
|
||||
script(join(bin, "getent"), `[ "$1" = passwd ] && [ "$2" = '${me}' ] && echo '${me}:x:${UID}:${GID}::${home}:/bin/bash'`);
|
||||
script(join(bin, "sudo"), "exit 1");
|
||||
script(join(bin, "id"), `if [ "$1" = -G ]; then echo "\${CASE2_TEST_GROUPS:-${GID}}"; else exec /usr/bin/id "$@"; fi`);
|
||||
script(join(bin, "node"), `if [ "$1" = --version ]; then echo "\${CASE2_TEST_NODE:-v24.3.0}"; exit 0; fi\nexec '${process.execPath}' "$@"`);
|
||||
script(join(bin, "bwrap"), `exit "\${CASE2_TEST_BWRAP:-0}"`);
|
||||
return { root, home, configDir, config, secrets, units, sys, bin, dataRoot: join(home, ".mosaic-dev") };
|
||||
}
|
||||
|
||||
// A fake claude: replace answers with the marker and a small prompt; append answers with a large one;
|
||||
// nomarker answers with a small prompt and no marker; text answers with plain text, not JSON.
|
||||
function fakeClaude(dir, kind) {
|
||||
return script(join(dir, "claude"), `[ "$1" = --version ] && { echo "9.9.9 (Claude Code, ${kind} fake)"; exit 0; }
|
||||
file=''
|
||||
while [ $# -gt 0 ]; do [ "$1" = --system-prompt-file ] && file="$2"; shift; done
|
||||
marker="$(awk '{ print $NF }' "$file")"
|
||||
${kind === "nomarker" ? "marker=something-else" : ""}
|
||||
${kind === "text" ? 'echo "$marker"; exit 0' : ""}
|
||||
tokens=${kind === "append" ? 9000 : 40}
|
||||
printf '{"result":"%s","usage":{"input_tokens":%s,"cache_read_input_tokens":0}}\\n' "$marker" "$tokens"`);
|
||||
}
|
||||
|
||||
// This host may have a claude in /usr/bin, so this PATH links every /usr/bin entry but that one.
|
||||
function pathWithoutClaude(x) {
|
||||
const usr = join(x.root, "usr-bin");
|
||||
mkdirSync(usr);
|
||||
for (const name of readdirSync("/usr/bin")) if (name !== "claude") symlinkSync(join("/usr/bin", name), join(usr, name));
|
||||
return `${x.bin}:${usr}`;
|
||||
}
|
||||
|
||||
function precheck(x, { profile = "library", env = {}, path } = {}) {
|
||||
const r = spawnSync(PRECHECK, ["--business", "acme", "--profile", profile], {
|
||||
encoding: "utf8",
|
||||
timeout: 60000,
|
||||
env: {
|
||||
PATH: path ?? `${x.bin}:/usr/bin:/bin`,
|
||||
HOME: x.home,
|
||||
MOSAIC_CONFIG: x.config,
|
||||
CASE2_UNIT_DIR: x.units,
|
||||
CASE2_PROC_SYS: x.sys,
|
||||
CASE2_GETENT: join(x.bin, "getent"),
|
||||
...env,
|
||||
},
|
||||
});
|
||||
const out = r.stdout + r.stderr;
|
||||
assert.doesNotMatch(out, new RegExp(FIXTURE_KEY), "the precheck printed the fixture key");
|
||||
return { code: r.status, out };
|
||||
}
|
||||
|
||||
function blocked(r, pattern) {
|
||||
assert.equal(r.code, 1, r.out);
|
||||
assert.match(r.out, pattern);
|
||||
}
|
||||
|
||||
test("the precheck passes on a good host and prints free memory, without the key", (t) => {
|
||||
const x = host(t);
|
||||
const r = precheck(x);
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.match(r.out, /ok {6}config validates/);
|
||||
assert.match(r.out, /ok {6}bus\.tester is off/);
|
||||
assert.match(r.out, /ok {6}net\.ipv4\.ip_unprivileged_port_start 1024 is above the Console's port 443/);
|
||||
assert.match(r.out, /claude-session\.env sets ZAI_API_KEY/);
|
||||
assert.match(r.out, /free memory: \d+ MiB/);
|
||||
assert.doesNotMatch(r.out, /claude .*fake|marker/);
|
||||
});
|
||||
|
||||
test("the precheck refuses each failed host check", (t) => {
|
||||
blocked(precheck(host(t), { env: { CASE2_TEST_NODE: "v22.22.2" } }), /REFUSED node v22\.22\.2 is older than 24/);
|
||||
blocked(precheck(host(t), { env: { CASE2_TEST_GROUPS: `${GID} 998` } }), /REFUSED id -G prints more than one group/);
|
||||
const sudo = host(t);
|
||||
script(join(sudo.bin, "sudo"), "exit 0");
|
||||
blocked(precheck(sudo), /REFUSED sudo -n true succeeds/);
|
||||
blocked(precheck(host(t), { env: { XDG_DATA_HOME: "/elsewhere" } }), /REFUSED XDG_DATA_HOME is \/elsewhere/);
|
||||
const home = host(t);
|
||||
assert.equal(precheck(home, { env: { XDG_DATA_HOME: `${home.home}/.local/share` } }).code, 0);
|
||||
blocked(precheck(host(t), { env: { HOME: "/tmp" } }), /REFUSED HOME is \/tmp/);
|
||||
|
||||
const runs = host(t);
|
||||
mkdirSync(join(runs.dataRoot, "launches", "acme"), { recursive: true, mode: 0o755 });
|
||||
chmodSync(join(runs.dataRoot, "launches", "acme"), 0o755);
|
||||
blocked(precheck(runs), /REFUSED run root .* is not a 0700 directory \(mode 755\)/);
|
||||
chmodSync(join(runs.dataRoot, "launches", "acme"), 0o700);
|
||||
assert.match(precheck(runs).out, /ok {6}run root .* is 0700/);
|
||||
|
||||
const bad = host(t);
|
||||
writeFileSync(bad.config, "{ not json");
|
||||
blocked(precheck(bad), /REFUSED config:/);
|
||||
});
|
||||
|
||||
test("the precheck refuses a user missing from passwd, a business file that does not load, and a symlinked run root", (t) => {
|
||||
const nouser = host(t);
|
||||
script(join(nouser.bin, "getent"), "exit 2");
|
||||
blocked(precheck(nouser), /REFUSED user: no .* in the passwd database/);
|
||||
|
||||
const unparsed = host(t);
|
||||
writeFileSync(join(unparsed.configDir, "businesses", "acme.json"), "{ not json");
|
||||
blocked(precheck(unparsed), /REFUSED business file: cannot read .*acme\.json/);
|
||||
const gone = host(t);
|
||||
rmSync(join(gone.configDir, "businesses", "acme.json"));
|
||||
blocked(precheck(gone), /REFUSED business file: cannot read .*acme\.json: ENOENT/);
|
||||
const invalid = host(t);
|
||||
const file = join(invalid.configDir, "businesses", "acme.json");
|
||||
const doc = JSON.parse(read(file));
|
||||
doc.notAField = true;
|
||||
writeJson(file, doc);
|
||||
const r = precheck(invalid);
|
||||
blocked(r, /REFUSED business file: /);
|
||||
assert.doesNotMatch(r.out, /REFUSED business file: cannot read/);
|
||||
|
||||
// stat follows a link, so a link to a 0700 directory would read as 0700.
|
||||
const link = host(t);
|
||||
const target = join(link.root, "elsewhere");
|
||||
mkdirSync(target, { mode: 0o700 });
|
||||
chmodSync(target, 0o700);
|
||||
mkdirSync(join(link.dataRoot, "launches"), { recursive: true });
|
||||
symlinkSync(target, join(link.dataRoot, "launches", "acme"));
|
||||
blocked(precheck(link), /REFUSED run root .* is a symlink/);
|
||||
});
|
||||
|
||||
test("a bwrap that fails refuses and prints the sysctl and where bwrap is", (t) => {
|
||||
const x = host(t);
|
||||
blocked(precheck(x, { env: { CASE2_TEST_BWRAP: "1" } }), new RegExp(`REFUSED bwrap .* failed; bwrap: ${join(x.bin, "bwrap")}; kernel\\.apparmor_restrict_unprivileged_userns: 1`));
|
||||
});
|
||||
|
||||
test("an unprivileged port floor at or below the Console's port refuses, and so does a missing Console unit", (t) => {
|
||||
for (const floor of ["443", "80"]) {
|
||||
const x = host(t);
|
||||
writeFileSync(join(x.sys, "net", "ipv4", "ip_unprivileged_port_start"), `${floor}\n`);
|
||||
blocked(precheck(x), new RegExp(`REFUSED net\\.ipv4\\.ip_unprivileged_port_start is ${floor}, at or below the Console's port 443`));
|
||||
}
|
||||
const x = host(t);
|
||||
rmSync(join(x.units, "mosaic-webui.service"));
|
||||
blocked(precheck(x), /REFUSED no installed Console unit/);
|
||||
const noPort = host(t);
|
||||
writeFileSync(join(noPort.units, "mosaic-webui.service"), "[Service]\nExecStart=/usr/bin/node /x/cli.mjs serve --business acme\n");
|
||||
blocked(precheck(noPort), /REFUSED .*mosaic-webui\.service has no --port in its ExecStart/);
|
||||
const noFloor = host(t);
|
||||
rmSync(join(noFloor.sys, "net", "ipv4", "ip_unprivileged_port_start"));
|
||||
blocked(precheck(noFloor), /REFUSED cannot read net\.ipv4\.ip_unprivileged_port_start/);
|
||||
});
|
||||
|
||||
test("bus.tester: library refuses it on, factory needs it on, and factory refuses a library business", (t) => {
|
||||
blocked(precheck(host(t, { vars: { "bus.tester": true } })), /REFUSED bus\.tester is on; the library profile needs it off/);
|
||||
blocked(precheck(host(t), { profile: "factory" }), /REFUSED bus\.tester is off; the factory profile needs it on/);
|
||||
const on = precheck(host(t, { vars: { "bus.tester": true } }), { profile: "factory" });
|
||||
assert.equal(on.code, 0, on.out);
|
||||
const lib = host(t, { vars: { "bus.tester": true } });
|
||||
const file = join(lib.configDir, "businesses", "acme.json");
|
||||
const doc = JSON.parse(read(file));
|
||||
doc.projects.stack.kind = "library";
|
||||
writeJson(file, doc);
|
||||
blocked(precheck(lib, { profile: "factory" }), /REFUSED profile-library-business: .* projects stack are library projects/);
|
||||
});
|
||||
|
||||
test("the claude check runs only for a claude-code instance, and refuses a claude that appends or answers wrong", (t) => {
|
||||
// No claude-code instance and no claude anywhere on PATH: the precheck passes.
|
||||
const none = host(t);
|
||||
const r = precheck(none, { path: pathWithoutClaude(none) });
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.doesNotMatch(r.out, /--system-prompt-file|claude is installed/);
|
||||
|
||||
const good = host(t, { roles: { cto: { harness: "claude-code" } } });
|
||||
fakeClaude(good.bin, "replace");
|
||||
const pass = precheck(good);
|
||||
assert.equal(pass.code, 0, pass.out);
|
||||
assert.match(pass.out, /claude .*9\.9\.9 \(Claude Code, replace fake\)/);
|
||||
assert.match(pass.out, /ok {6}claude replaces its prompt with --system-prompt-file \(40 input tokens\)/);
|
||||
|
||||
const bad = host(t, { roles: { cto: { harness: "claude-code" } } });
|
||||
fakeClaude(bad.bin, "append");
|
||||
blocked(precheck(bad), /REFUSED claude adds to its own prompt with --system-prompt-file: 9000 input tokens/);
|
||||
|
||||
const nomarker = host(t, { roles: { cto: { harness: "claude-code" } } });
|
||||
fakeClaude(nomarker.bin, "nomarker");
|
||||
blocked(precheck(nomarker), /REFUSED claude's reply to the marker prompt lacks the marker \(40 input tokens\)/);
|
||||
|
||||
const text = host(t, { roles: { cto: { harness: "claude-code" } } });
|
||||
fakeClaude(text.bin, "text");
|
||||
blocked(precheck(text), /REFUSED claude gave no JSON reply to the marker prompt/);
|
||||
|
||||
const missing = host(t, { roles: { cto: { harness: "claude-code" } } });
|
||||
blocked(precheck(missing, { path: pathWithoutClaude(missing) }), /REFUSED an instance runs claude-code and no claude is installed/);
|
||||
});
|
||||
|
||||
test("the claude check runs the bus unit's MOSAIC_CLAUDE_BIN over claude on PATH", (t) => {
|
||||
const pinned = join(tmp(t, "case2-claude-"), "pinned");
|
||||
const replace = fakeClaude(pinned, "replace");
|
||||
const x = host(t, { roles: { cto: { harness: "claude-code" } }, busClaude: replace });
|
||||
fakeClaude(x.bin, "append");
|
||||
const r = precheck(x);
|
||||
assert.equal(r.code, 0, r.out);
|
||||
assert.match(r.out, /replace fake/);
|
||||
|
||||
const pinnedBad = join(tmp(t, "case2-claude-"), "pinned");
|
||||
const append = fakeClaude(pinnedBad, "append");
|
||||
const y = host(t, { roles: { cto: { harness: "claude-code" } }, busClaude: append });
|
||||
fakeClaude(y.bin, "replace");
|
||||
blocked(precheck(y), /REFUSED claude adds to its own prompt/);
|
||||
});
|
||||
|
||||
test("the session env file refuses when missing, open to others, a symlink, empty, or emptied by a later line", (t) => {
|
||||
blocked(precheck(host(t, { keyFile: null })), /REFUSED an instance's provider is zai and .*claude-session\.env is missing/);
|
||||
const open = host(t);
|
||||
chmodSync(join(open.secrets, "claude-session.env"), 0o644);
|
||||
blocked(precheck(open), /REFUSED .*claude-session\.env must be owned by .* at 0600 \(mode 644\)/);
|
||||
const link = host(t, { keyFile: null });
|
||||
const real = join(link.root, "real.env");
|
||||
writeFileSync(real, `ZAI_API_KEY=${FIXTURE_KEY}\n`);
|
||||
chmodSync(real, 0o600);
|
||||
symlinkSync(real, join(link.secrets, "claude-session.env"));
|
||||
blocked(precheck(link), /REFUSED .*claude-session\.env is a symlink/);
|
||||
blocked(precheck(host(t, { keyFile: "ZAI_API_KEY=\n" })), /REFUSED .*sets no nonempty ZAI_API_KEY/);
|
||||
blocked(precheck(host(t, { keyFile: `ZAI_API_KEY=${FIXTURE_KEY}\nZAI_API_KEY=\n` })), /REFUSED .*sets no nonempty ZAI_API_KEY/);
|
||||
const quoted = precheck(host(t, { keyFile: `ZAI_API_KEY=\nZAI_API_KEY="${FIXTURE_KEY}"\n` }));
|
||||
assert.equal(quoted.code, 0, quoted.out);
|
||||
});
|
||||
|
||||
// ---- the bind test (mos-1100) ----
|
||||
|
||||
// A session can't take the Console's port: inside the sandbox a real session gets, a bind below
|
||||
// 1024 fails with EACCES, and the same bind on a free port of 1024 or above succeeds.
|
||||
const sandboxSkip = sandboxProblem() ?? false;
|
||||
const LOW_PORT = 980;
|
||||
test("inside a session's sandbox a bind below 1024 fails with EACCES, and a bind on a free high port succeeds", { skip: sandboxSkip, timeout: 30000 }, async (t) => {
|
||||
const floor = Number(read("/proc/sys/net/ipv4/ip_unprivileged_port_start").trim());
|
||||
assert.ok(floor > LOW_PORT, `this host's net.ipv4.ip_unprivileged_port_start is ${floor}, at or below the test port ${LOW_PORT}, so the test can't show the sandbox refuses it`);
|
||||
const root = tmp(t, "c2b-");
|
||||
const runDir = join(root, "run");
|
||||
const workspace = join(root, "ws");
|
||||
mkdirSync(runDir);
|
||||
mkdirSync(workspace);
|
||||
mkdirSync(join(runDir, "bundle"));
|
||||
writeFileSync(join(runDir, "session.json"), "{}");
|
||||
const adapter = join(root, "adapter.sh");
|
||||
const brokerSocket = join(root, "broker.sock");
|
||||
writeFileSync(adapter, "");
|
||||
writeFileSync(brokerSocket, "");
|
||||
prepareRun(runDir);
|
||||
const list = binds({ runDir, workspace, adapter, harnessMounts: [], brokerSocket });
|
||||
const high = await new Promise((done) => {
|
||||
const s = createServer().listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => done(port)); });
|
||||
});
|
||||
const bindIn = (port) => {
|
||||
const code = `require("net").createServer().on("error", (e) => { console.log(e.code); process.exit(0); }).listen(${port}, "127.0.0.1", function () { console.log("bound"); this.close(); });`;
|
||||
const argv = sandboxArgv(list, runDir, [process.execPath, "-e", code], { business: "acme", role: "coder" });
|
||||
const r = spawnSync(argv[0], argv.slice(1), { encoding: "utf8", timeout: 10000 });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
assert.equal(bindIn(LOW_PORT), "EACCES");
|
||||
assert.equal(bindIn(high), "bound");
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { connect } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -266,6 +266,25 @@ test("T3: a push recorded while the bus process pushes refuses decision-mismatch
|
||||
assert.ok(w.logs.some((l) => l.includes("wasn't recorded")));
|
||||
});
|
||||
|
||||
test("row 237: two publishes race on one decision; the second refuses publish-busy and exactly one push reaches the fork", async (t) => {
|
||||
const w = await setup(t);
|
||||
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
|
||||
const d = w.approve();
|
||||
// The second publish, to another branch, runs while the first holds its ticket.
|
||||
let second;
|
||||
w.hooks.checkPublish = async (m, next) => {
|
||||
const facts = await next();
|
||||
if (m.args.branch === "feat") second = await w.publish(d, { branch: "other" });
|
||||
return facts;
|
||||
};
|
||||
const first = await w.publish(d);
|
||||
assert.deepEqual(second, { ok: false, error: "publish-busy" });
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(w.gitea.requests.filter((r) => r.path.endsWith("/git-receive-pack")).length, 1);
|
||||
assert.equal(git(w.root, `--git-dir=${w.forkRepo}`, "for-each-ref", "--format=%(refname)", "refs/heads/coder/"), "refs/heads/coder/feat");
|
||||
assert.equal(w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise").length, 1);
|
||||
});
|
||||
|
||||
test("sage-1440: the publishResult poll is bounded: publish-timeout, filed once, and nothing recorded", async (t) => {
|
||||
const w = await setup(t, { publishDeadlineMs: 150 });
|
||||
let polls = 0;
|
||||
@@ -279,3 +298,41 @@ test("sage-1440: the publishResult poll is bounded: publish-timeout, filed once,
|
||||
assert.equal(w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise").length, 0);
|
||||
assert.ok(w.logs.some((l) => l.includes("publish-timeout")));
|
||||
});
|
||||
|
||||
test("row 242: a retry after publish-timeout refuses publish-busy while the bus process's push runs; the late push is recorded and the decision pushes once", async (t) => {
|
||||
const w = await setup(t, { publishDeadlineMs: 150 });
|
||||
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
|
||||
// The fork holds the push in pre-receive until the test writes `release`; `held` says it's there.
|
||||
const held = join(w.root, "held");
|
||||
const release = join(w.root, "release");
|
||||
writeFileSync(join(w.forkRepo, "hooks", "pre-receive"), `#!/bin/sh\n: > '${held}'\nwhile [ ! -e '${release}' ]; do sleep 0.05; done\ncat > /dev/null\n`, { mode: 0o755 });
|
||||
const until = async (what, ok) => {
|
||||
for (const end = Date.now() + 10_000; !ok(); ) {
|
||||
if (Date.now() > end) assert.fail(`timed out waiting for ${what}`);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
};
|
||||
const allowed = () => w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise");
|
||||
// A failed assert must still let the hook end: teardown waits for the fake Gitea, which waits for the push.
|
||||
try {
|
||||
const d = w.approve();
|
||||
assert.deepEqual(await w.publish(d), { ok: false, error: "publish-timeout" });
|
||||
await until("the push to reach pre-receive", () => existsSync(held));
|
||||
// The socket gave up, but the push still runs: the decision is held, and nothing is filed yet.
|
||||
assert.deepEqual(await w.publish(d, { branch: "other" }), { ok: false, error: "publish-busy" });
|
||||
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["publish-busy"]);
|
||||
assert.equal(allowed().length, 0);
|
||||
writeFileSync(release, "");
|
||||
// The push reached the fork, so it's recorded, which spends the decision.
|
||||
await until("the late push to be recorded", () => allowed().length === 1);
|
||||
assert.equal(allowed()[0].body.ref, "refs/heads/coder/feat");
|
||||
assert.equal(allowed()[0].body.commit, w.work);
|
||||
assert.deepEqual(await w.publish(d, { branch: "other" }), { ok: false, error: "decision-consumed" });
|
||||
assert.equal(w.gitea.requests.filter((r) => r.path.endsWith("/git-receive-pack")).length, 1);
|
||||
assert.equal(git(w.root, `--git-dir=${w.forkRepo}`, "for-each-ref", "--format=%(refname)", "refs/heads/coder/"), "refs/heads/coder/feat");
|
||||
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["publish-busy", "decision-consumed"]);
|
||||
} finally {
|
||||
writeFileSync(release, "");
|
||||
}
|
||||
assert.equal(JSON.stringify([w.events("action.allowed"), w.events("action.refused"), w.logs]).includes(TOKEN), false);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {execFileSync,spawnSync,spawn} from 'node:child_process';
|
||||
import {symlinkSync,unlinkSync,mkdtempSync,mkdirSync,writeFileSync,readFileSync,cpSync,rmSync,chmodSync,existsSync,statSync} from 'node:fs';
|
||||
import {symlinkSync,unlinkSync,mkdtempSync,mkdirSync,writeFileSync,readFileSync,cpSync,rmSync,chmodSync,existsSync,statSync,readdirSync,lstatSync,readlinkSync} from 'node:fs';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join,dirname} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
@@ -227,3 +228,108 @@ test('inherited drop-in unreadable directory or file refuses before writing',t=>
|
||||
unlinkSync(dropdir);mkdirSync(dropdir);const file=join(dropdir,'broken.conf');symlinkSync(join(s.root,'absent-file'),file);
|
||||
const broken=s.run('bus-service.sh',['install','--dir',units,'--no-reload']);assert.equal(broken.status,1);assert.ok(broken.stderr.includes(file));assert.equal(readFileSync(target,'utf8'),'previous unit bytes');
|
||||
});
|
||||
// Row 232: every file of a copy with its mode and bytes, for the byte-identical comparison.
|
||||
function tree(dir){
|
||||
const out=[];
|
||||
const walk=rel=>{for(const e of readdirSync(join(dir,rel),{withFileTypes:true}).sort((a,b)=>a.name<b.name?-1:1)){
|
||||
const r=rel?`${rel}/${e.name}`:e.name,st=lstatSync(join(dir,r));
|
||||
if(e.isDirectory()){out.push(`d ${(st.mode&0o7777).toString(8)} ${r}`);walk(r);}
|
||||
else if(e.isSymbolicLink())out.push(`l ${r} ${readlinkSync(join(dir,r))}`);
|
||||
else out.push(`f ${(st.mode&0o7777).toString(8)} ${r} ${createHash('sha256').update(readFileSync(join(dir,r))).digest('hex')}`);
|
||||
}};
|
||||
walk('');return out;
|
||||
}
|
||||
test('row 232: copy --bundle copies a SHA the bundle\'s refactor reaches, byte-identical to copy SHA, and refuses the rest',t=>{
|
||||
const s=setup(t),tmp=join(s.root,'t');mkdirSync(tmp);s.env.TMPDIR=tmp;
|
||||
const good=join(s.root,'good.bundle');git(s.repo,'bundle','create','-q',good,'refactor');
|
||||
const a=join(s.root,'copies-a'),b=join(s.root,'copies-b');
|
||||
let r=s.run('stack-copy.sh',['copy',s.id,'--copies',a]);assert.equal(r.status,0,r.stderr);
|
||||
r=s.run('stack-copy.sh',['copy','--bundle',good,s.id,'--copies',b]);assert.equal(r.status,0,r.stderr);
|
||||
const sum=createHash('sha256').update(readFileSync(good)).digest('hex'),release=git(s.repo,'show',`${s.id}:RELEASE`);
|
||||
assert.equal(r.stdout,`copied: ${join(b,s.id)}\nrelease: ${release}\nbundle sha256: ${sum}\n`);
|
||||
assert.deepEqual(tree(join(b,s.id)),tree(join(a,s.id)));
|
||||
assert.equal(statSync(join(b,s.id)).mode&0o777,0o555);
|
||||
const left=()=>readdirSync(tmp).filter(f=>f.startsWith('tmp.'));
|
||||
assert.deepEqual(left(),[],'the bundle\'s temporary repository is removed');
|
||||
// A second run finds the copy and still names the release.
|
||||
r=s.run('stack-copy.sh',['copy','--bundle',good,s.id,'--copies',b]);assert.equal(r.status,0,r.stderr);
|
||||
assert.equal(r.stdout,`exists: ${join(b,s.id)}\nrelease: ${release}\nbundle sha256: ${sum}\n`);
|
||||
// A relative bundle path works from the caller's directory.
|
||||
const c=join(s.root,'copies-c');
|
||||
r=spawnSync(join(s.repo,'scripts','stack-copy.sh'),['copy','--bundle','good.bundle',s.id,'--copies',c],{cwd:s.root,env:s.env,encoding:'utf8'});
|
||||
assert.equal(r.status,0,r.stderr);assert.deepEqual(tree(join(c,s.id)),tree(join(a,s.id)));
|
||||
// A commit on another branch of the bundle: the bundle carries it, refactor does not reach it.
|
||||
const side=git(s.repo,'commit-tree','-p',s.id,'-m','side',`${s.id}^{tree}`);git(s.repo,'update-ref','refs/heads/side',side);
|
||||
const both=join(s.root,'both.bundle');git(s.repo,'bundle','create','-q',both,'refactor','side');
|
||||
const only=join(s.root,'side.bundle');git(s.repo,'bundle','create','-q',only,'side');
|
||||
const corrupt=join(s.root,'corrupt.bundle'),bytes=readFileSync(good);bytes[bytes.length-30]^=0xff;writeFileSync(corrupt,bytes);
|
||||
const cut=join(s.root,'cut.bundle');writeFileSync(cut,readFileSync(good).subarray(0,200));
|
||||
const junk=join(s.root,'junk.bundle');writeFileSync(junk,'# v2 git bundle\nnot a ref line\n\n');
|
||||
const d=join(s.root,'copies-d');
|
||||
for(const [label,bundle,id,re] of [
|
||||
['not reachable',both,side,/not reachable from the bundle's refs\/heads\/refactor/],
|
||||
['no refactor',only,side,/no refs\/heads\/refactor/],
|
||||
// verify reads the header and prerequisites; a damaged pack fails the fetch's own check.
|
||||
['not a bundle',junk,s.id,/git bundle verify failed/],
|
||||
['corrupt pack',corrupt,s.id,/cannot fetch/],
|
||||
['truncated pack',cut,s.id,/cannot fetch/],
|
||||
['absent',join(s.root,'none.bundle'),s.id,/bundle is absent/],
|
||||
['short id',good,s.id.slice(0,12),/full 40-character commit id/],
|
||||
]){
|
||||
r=s.run('stack-copy.sh',['copy','--bundle',bundle,id,'--copies',d]);
|
||||
assert.equal(r.status,1,label);assert.match(r.stderr,re,label);assert.equal(r.stdout,'',label);
|
||||
assert.ok(!existsSync(join(d,id)),label);assert.deepEqual(left(),[],label);
|
||||
if(existsSync(d))assert.deepEqual(readdirSync(d),[],label);
|
||||
}
|
||||
const link=join(s.root,'link.bundle');symlinkSync(good,link);
|
||||
r=s.run('stack-copy.sh',['copy','--bundle',link,s.id,'--copies',d]);assert.equal(r.status,1);assert.match(r.stderr,/symlink/);
|
||||
for(const args of [['copy','--bundle',good,s.id,'--allow-worktree'],['copy','--bundle',good],['copy','--bundle'],['wrapper','--bundle',good,s.id],['check','--units','system'],['copy',s.id,'--units','external'],['copy',s.id,'--create']])
|
||||
assert.equal(s.run('stack-copy.sh',args).status,4,args.join(' '));
|
||||
});
|
||||
test('row 232: wrapper --create writes the first wrapper only, the same bytes wrapper ID writes, and refuses over any file',t=>{
|
||||
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
|
||||
const wrapper=join(s.bin,'mosaic'),root=join(s.copies,s.id);
|
||||
let r=s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin]);
|
||||
assert.equal(r.status,1);assert.match(r.stderr,/wrapper is absent/,'no --create keeps the missing-wrapper refusal');assert.ok(!existsSync(wrapper));
|
||||
r=s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin,'--create']);assert.equal(r.status,0,r.stderr);
|
||||
assert.equal(r.stdout,`wrapper: ${root}\n`);
|
||||
const created=readFileSync(wrapper,'utf8');assert.equal(statSync(wrapper).mode&0o777,0o755);
|
||||
r=s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin]);assert.equal(r.status,0,r.stderr);
|
||||
assert.equal(r.stdout,`wrapper: ${root}\n`);assert.equal(readFileSync(wrapper,'utf8'),created,'wrapper ID writes the same bytes');
|
||||
const foreign='#!/bin/sh\n# not ours\n';
|
||||
for(const [label,make] of [['own wrapper',()=>{}],['foreign file',()=>writeFileSync(wrapper,foreign)],['symlink',()=>{rmSync(wrapper);symlinkSync(join(s.root,'elsewhere'),wrapper);}]]){
|
||||
make();const before=existsSync(wrapper)||label==='symlink'?(label==='symlink'?readlinkSync(wrapper):readFileSync(wrapper,'utf8')):null;
|
||||
r=s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin,'--create']);
|
||||
assert.equal(r.status,1,label);assert.match(r.stderr,/--create and a wrapper exists/,label);
|
||||
assert.equal(label==='symlink'?readlinkSync(wrapper):readFileSync(wrapper,'utf8'),before,label);
|
||||
assert.deepEqual(readdirSync(s.bin).filter(f=>f.startsWith('.')),[],`${label}: no temporary file left`);
|
||||
}
|
||||
// A named instance: --create writes mosaic-NAME as wrapper --instance does, once.
|
||||
assert.equal(s.run('bootstrap.sh',['--instance','alpha']).status,0);
|
||||
const named=join(s.bin,'mosaic-alpha');
|
||||
r=s.run('stack-copy.sh',['wrapper',s.id,'--instance','alpha','--copies',s.copies,'--bin',s.bin,'--create']);assert.equal(r.status,0,r.stderr);
|
||||
const first=readFileSync(named,'utf8');
|
||||
r=s.run('stack-copy.sh',['wrapper',s.id,'--instance','alpha','--copies',s.copies,'--bin',s.bin]);assert.equal(r.status,0,r.stderr);
|
||||
assert.equal(readFileSync(named,'utf8'),first);
|
||||
r=s.run('stack-copy.sh',['wrapper',s.id,'--instance','alpha','--copies',s.copies,'--bin',s.bin,'--create']);
|
||||
assert.equal(r.status,1);assert.match(r.stderr,/--create and a wrapper exists/);assert.equal(readFileSync(named,'utf8'),first);
|
||||
});
|
||||
test('row 232: check --units external reads the system units, the default reads the user units',t=>{
|
||||
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
|
||||
assert.equal(s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin,'--create']).status,0);
|
||||
const copy=join(s.copies,s.id),argv=join(s.root,'argv.log');
|
||||
s.fake(`echo "$*" >> '${argv}'; echo 'LoadState=loaded'; echo 'WorkingDirectory=${copy}'; case "$*" in *mosaic-bus*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start mosaic-stack --pm ; }';; *) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; esac\n`);
|
||||
const want=`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`;
|
||||
let r=s.run('stack-copy.sh',['check','--copies',s.copies,'--bin',s.bin,'--units','external']);assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,want);
|
||||
assert.deepEqual(readFileSync(argv,'utf8').trimEnd().split('\n'),[
|
||||
'show [email protected] -p LoadState -p WorkingDirectory -p ExecStart',
|
||||
'show mosaic-webui.service -p LoadState -p WorkingDirectory -p ExecStart']);
|
||||
rmSync(argv);
|
||||
for(const args of [[],['--units','user']]){
|
||||
r=s.run('stack-copy.sh',['check','--copies',s.copies,'--bin',s.bin,...args]);assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,want);
|
||||
assert.deepEqual(readFileSync(argv,'utf8').trimEnd().split('\n'),[
|
||||
'--user show [email protected] -p LoadState -p WorkingDirectory -p ExecStart',
|
||||
'--user show mosaic-webui.service -p LoadState -p WorkingDirectory -p ExecStart']);
|
||||
rmSync(argv);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
// scripts/stack-release.sh --units external (row 232, #1734). Design: r6 section 4 and the section 7
|
||||
// test list, agents/darkwing/work/queue-195/DESIGN.md; deviations: agents/darkwing/work/queue-232/BUILD.md.
|
||||
// The stand-ins and the module fixture are stack-release.test.mjs's (stack-release-fixture.mjs).
|
||||
// Root's part (stop, unit install, start) runs here as the stand-in systemctl and bus-service.sh and
|
||||
// webui-service.sh installs; no sudo and no real unit.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {mkdirSync,writeFileSync,readFileSync,existsSync,readdirSync,rmSync,chmodSync,cpSync} from 'node:fs';
|
||||
import {DatabaseSync} from 'node:sqlite';
|
||||
import {join} from 'node:path';
|
||||
import {B,C,C1,COPIES,ROOT,SBIN,base,fault,lines,logLines,mk,pointer,repo,run,seed,sha,storeVersion,sysctl,tree,units} from './stack-release-fixture.mjs';
|
||||
|
||||
// The commit row 232 builds on: the user-mode tests compare the candidate's calls and --plan bytes
|
||||
// with its scripts. A later row that changes user mode on purpose moves this.
|
||||
const BASE='cb5fdb064fb4f12edd045374dd463dfdc25c2b42';
|
||||
|
||||
const X=(e)=>({...e,flags:[...e.flags,'--units','external']});
|
||||
const sh=(e,name,...a)=>execFileSync('bash',[join(repo,'scripts',name),...a],{env:e.env,encoding:'utf8',stdio:['ignore','pipe','pipe']});
|
||||
// Root's steps: R1/A2 stop, A4 install and start. offset is the start's SD_CLOCK_OFFSET.
|
||||
const down=(e)=>{sysctl(e,'stop',e.con);sysctl(e,'stop',e.bus)};
|
||||
const up=(e,copy,{pm=false,offset=0}={})=>{
|
||||
sh(e,'bus-service.sh','install','--copy',copy,'--copies',COPIES,...(pm?['--pm']:[]),'--claude',e.claude);
|
||||
sh(e,'webui-service.sh','install','--copy',copy,'--copies',COPIES,'--port',e.port,'--business','acme');
|
||||
execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:String(offset)}});
|
||||
sysctl(e,'start',e.con);
|
||||
};
|
||||
// The stack on B for acme, adopted under --units external, then stopped by root unless live.
|
||||
async function xseed(t,{pm=false,live=false}={}){
|
||||
const e=await mk(t);await seed(e,{pm,pointer:'none'});const x=X(e);
|
||||
const r=await run(x,'adopt','acme');assert.equal(r.code,0,r.err);
|
||||
if(!live)down(e);
|
||||
return {e,x};
|
||||
}
|
||||
const lastAct=(e)=>logLines(e).filter((o)=>o.result==='started').at(-1).activation;
|
||||
const rs=(e,a)=>join(e.state,'stack-rollback',a);
|
||||
const open=(e)=>{const m=new Set();for(const o of logLines(e)){if(!o.activation)continue;if(o.result==='started')m.add(o.activation);else m.delete(o.activation)}return [...m]};
|
||||
const mine=(e,a)=>logLines(e).filter((o)=>o.activation===a);
|
||||
const journalCalls=(e)=>lines(join(e.sd,'journal-since.log'));
|
||||
// A3: stop, stage TO. Returns the activation and the run.
|
||||
async function stage(e,x,to,...args){
|
||||
const r=await run(x,'activate',to,'acme',...args);
|
||||
return {r,a:r.code===0||r.code===1?lastAct(e):null};
|
||||
}
|
||||
// The started line's from, to and op on the activation's result line, and its backup the set's.
|
||||
function sameAsStarted(e,a){
|
||||
const [s,f,...more]=mine(e,a);assert.equal(more.length,0,JSON.stringify(mine(e,a)));
|
||||
assert.equal(s.result,'started');
|
||||
for(const k of ['op','from','to','activation'])assert.equal(f[k],s[k],k);
|
||||
assert.ok(['activate','rollback'].includes(f.op),f.op);
|
||||
assert.equal(f.units,'external');
|
||||
const b=join(rs(e,a),'BACKUP');assert.equal(f.backup,existsSync(b)?readFileSync(b,'utf8').trim():null);
|
||||
return f;
|
||||
}
|
||||
// B1's end state: one started and one result line, nothing open, no marker or FAILED, and a
|
||||
// following --plan that doesn't refuse interrupted-run.
|
||||
async function closed(e,x,a){
|
||||
sameAsStarted(e,a);
|
||||
assert.deepEqual(open(e),[]);
|
||||
for(const f of ['AWAITING-UNITS','FAILED'])assert.ok(!existsSync(join(rs(e,a),f)),f);
|
||||
const s=await run(x,'status','acme');assert.equal(s.code,0,s.err);
|
||||
assert.ok(!/interrupted run|awaiting units/.test(s.out),s.out);
|
||||
const p=await run(x,'activate',pointer(e).copy===C1?B:C1,'acme','--plan');
|
||||
assert.ok(!(p.out+p.err).includes('interrupted-run'),p.out+p.err);
|
||||
}
|
||||
const storeSha=(e)=>sha(e.store);
|
||||
const backupStore=(e,a)=>join(e.data,'backups','stack',a,'bus','bus.sqlite');
|
||||
|
||||
test('row 232: confirm, revert, gate, --to and --expect-from need --units external; the user-only flags are usage under it',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const logs=logLines(e).length;
|
||||
for(const a of [['confirm',C1,'acme'],['revert',C1,'acme'],['gate',B,'acme','--since','1'],['rollback','acme','--to',B],['activate',C1,'acme','--plan','--expect-from',B]]){
|
||||
const r=await run(e,...a);assert.equal(r.code,4,a.join(' '));assert.match(r.err,/^usage: stack-release.sh adopt BUSINESS\n/);
|
||||
}
|
||||
for(const a of [['activate',C1,'acme','--pm'],['activate',C1,'acme','--tester-port','7444'],['activate',C1,'acme','--no-proof'],['activate',C1,'acme','--no-send'],['activate',C1,'acme','--sso'],['activate',C1,'acme','--proof-header','X','--proof-file','/p'],['rollback','acme'],['gate',B,'acme'],['activate',C1,'acme','--expect-from',B],['confirm',C1,'acme','--rollback',B],['confirm',C1,'acme','--cause','A5']]){
|
||||
const r=await run(x,...a);assert.equal(r.code,4,a.join(' '));
|
||||
}
|
||||
assert.equal(logLines(e).length,logs);
|
||||
});
|
||||
|
||||
test('row 232: pm-on and activate --pm-after-harness refuse external-pm',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const p=await run(x,'pm-on','acme');assert.equal(p.code,3);
|
||||
assert.match(p.err,/^stack-release: refusing external-pm: under --units external the PM is a flag in root's bus unit/);
|
||||
const a=await run(x,'activate',C1,'acme','--pm-after-harness');assert.equal(a.code,3);
|
||||
assert.match(a.err,/^stack-release: refusing external-pm: /);
|
||||
const l=logLines(e).slice(-2);assert.deepEqual(l.map((o)=>[o.result,o.code,o.activation]),[['refused','external-pm',null],['refused','external-pm',null]]);
|
||||
assert.equal(readdirSync(e.state).includes('stack-rollback'),false);
|
||||
});
|
||||
|
||||
test('row 232: units-running (N4) refuses while a unit is active and leaves the store, the log and TAKEN; failed counts as down',async(t)=>{
|
||||
const {e,x}=await xseed(t,{live:true});
|
||||
sysctl(e,'stop',e.con);
|
||||
const before=storeSha(e),logs=logLines(e).length;
|
||||
const r=await run(x,'activate',C,'acme','--migrate');assert.equal(r.code,3,r.out+r.err);
|
||||
assert.equal(r.err,`stack-release: refusing units-running: ${e.bus} is active; root stops the units first\n`);
|
||||
assert.equal(storeSha(e),before);assert.equal(storeVersion(e.store,true),'3g');
|
||||
const l=logLines(e).slice(logs);assert.deepEqual(l.map((o)=>[o.result,o.code,o.activation,o.units]),[['refused','units-running',null,'external']]);
|
||||
assert.ok(!existsSync(join(e.data,'backups')));assert.ok(!existsSync(join(e.state,'stack-rollback')));
|
||||
// The Console active and the bus stopped refuses too.
|
||||
sysctl(e,'stop',e.bus);sysctl(e,'start',e.con);
|
||||
const c=await run(x,'activate',C1,'acme');assert.equal(c.code,3);assert.ok(c.err.startsWith(`stack-release: refusing units-running: ${e.con} is active`),c.err);
|
||||
// A bus unit root's stop left failed is down.
|
||||
sysctl(e,'stop',e.con);
|
||||
const s=units(e);s[e.bus].state='failed';writeFileSync(join(e.sd,'units.json'),JSON.stringify(s));
|
||||
const f=await run(x,'activate',C1,'acme');assert.equal(f.code,0,f.out+f.err);
|
||||
});
|
||||
|
||||
test('row 232: activate stages: the pointer stays, AWAITING-UNITS names TO, a backup of the stopped store, no directory in the set, no --user, no journal',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const p0=readFileSync(e.pointer,'utf8'),w0=sha(join(e.bin,'mosaic')),argv0=lines(join(e.sd,'argv.log')).length;
|
||||
const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(r.out.split('\n').at(-2),`stack-release: activate staged ${C1} for acme as ${a}; root installs and starts the units, then runs confirm ${C1} acme --units external`);
|
||||
assert.equal(readFileSync(e.pointer,'utf8'),p0);
|
||||
assert.equal(readFileSync(join(rs(e,a),'AWAITING-UNITS'),'utf8'),`${C1}\n`);
|
||||
assert.deepEqual(readdirSync(rs(e,a)).sort(),['AWAITING-UNITS','BACKUP','SHA256SUMS','UNITS.sha256','WRAPPER_MODE','pointer',`show-${e.bus}.txt`,`show-${e.con}.txt`,'steps.log','wrapper'].sort());
|
||||
assert.equal(readFileSync(join(rs(e,a),'BACKUP'),'utf8'),`${a}\n`);
|
||||
assert.equal(sha(backupStore(e,a)),storeSha(e));assert.equal(readFileSync(join(e.data,'backups','stack',a,'SCHEMA'),'utf8'),'3g\n');
|
||||
assert.equal(readFileSync(join(rs(e,a),'UNITS.sha256'),'utf8'),`${sha(join(e.units,e.tmpl))} ${join(e.units,e.tmpl)}\n${sha(join(e.units,e.con))} ${join(e.units,e.con)}\n`);
|
||||
assert.equal(sha(join(rs(e,a),'wrapper')),w0);assert.notEqual(sha(join(e.bin,'mosaic')),w0);
|
||||
assert.deepEqual(mine(e,a).map((o)=>o.result),['started']);
|
||||
assert.deepEqual([units(e)[e.bus].state,units(e)[e.con].state],['inactive','inactive']);
|
||||
const ext=lines(join(e.sd,'argv.log')).slice(argv0);
|
||||
assert.ok(ext.length>0);for(const c of ext)assert.match(c,/^(show|is-active|list-units) /,c);
|
||||
assert.deepEqual(journalCalls(e),[]);
|
||||
const s=await run(x,'status','acme');assert.equal(s.code,0,s.err);
|
||||
assert.ok(s.out.includes(`awaiting units: ${a} (copy ${C1})\n`),s.out);assert.ok(s.out.includes(`interrupted run: ${a} `),s.out);
|
||||
});
|
||||
|
||||
test('row 232: A3 order: capture-failed and marker-failed refuse with exit 3 and leave no marker or FAILED',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
fault(e,'no-fragment');
|
||||
const r=await run(x,'activate',C1,'acme');assert.equal(r.code,3,r.out+r.err);
|
||||
assert.match(r.err,/^stack-release: refusing capture-failed: could not write the rollback set .*; nothing was changed\n$/);
|
||||
const a=lastAct(e);
|
||||
assert.deepEqual(mine(e,a).map((o)=>[o.result,o.code]),[['started',undefined],['refused','capture-failed']]);
|
||||
for(const f of ['AWAITING-UNITS','FAILED'])assert.ok(!existsSync(join(rs(e,a),f)),f);
|
||||
assert.deepEqual(open(e),[]);
|
||||
rmSync(join(e.sd,'faults','no-fragment'));
|
||||
// The set can't be made: marker-failed, for that activation.
|
||||
chmodSync(join(e.state,'stack-rollback'),0o555);
|
||||
const m=await run(x,'activate',C1,'acme');assert.equal(m.code,3,m.out+m.err);
|
||||
assert.match(m.err,/^stack-release: refusing marker-failed: could not make the rollback set .*; nothing was changed\n$/);
|
||||
const b=lastAct(e);assert.notEqual(b,a);
|
||||
assert.deepEqual(mine(e,b).map((o)=>[o.result,o.code]),[['started',undefined],['refused','marker-failed']]);
|
||||
chmodSync(join(e.state,'stack-rollback'),0o755);
|
||||
assert.deepEqual(open(e),[]);
|
||||
});
|
||||
|
||||
test('row 232: confirm and revert refuse open-activation with no marker, two, the wrong copy, or one the log holds closed; refusals name no activation',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const tryBoth=async(id,why)=>{
|
||||
for(const v of ['confirm','revert']){
|
||||
const o=open(e),logs=logLines(e).length;
|
||||
const r=await run(x,v,id,'acme');assert.equal(r.code,3,`${v} ${why}: ${r.out}${r.err}`);
|
||||
assert.match(r.err,/^stack-release: refusing open-activation: /,why);
|
||||
assert.deepEqual(logLines(e).slice(logs).map((l)=>[l.result,l.code,l.activation,l.op]),[['refused','open-activation',null,v]],why);
|
||||
assert.deepEqual(open(e),o,why);
|
||||
}
|
||||
};
|
||||
await tryBoth(C1,'no marker');
|
||||
// One marker whose activation the log never started. It sorts before any real one, so a confirm
|
||||
// that took the newest of two markers would take the real one.
|
||||
const fake=join(e.state,'stack-rollback','20000101T000000Z-'+C1.slice(0,12));
|
||||
mkdirSync(fake,{recursive:true});writeFileSync(join(fake,'AWAITING-UNITS'),`${C1}\n`);
|
||||
await tryBoth(C1,'marker not open');
|
||||
rmSync(fake,{recursive:true});
|
||||
const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err);
|
||||
await tryBoth(B,'wrong copy');
|
||||
mkdirSync(fake);writeFileSync(join(fake,'AWAITING-UNITS'),`${C1}\n`);
|
||||
await tryBoth(C1,'two markers');
|
||||
rmSync(fake,{recursive:true});
|
||||
// gate refuses nothing and writes nothing, open or not.
|
||||
const logs=readFileSync(e.log,'utf8');
|
||||
const g=await run(x,'gate',C1,'acme','--since','1','--timeout','1');assert.equal(g.code,1);
|
||||
assert.equal(readFileSync(e.log,'utf8'),logs);assert.deepEqual(open(e),[a]);
|
||||
});
|
||||
|
||||
test('row 232: confirm polls: bus status good after 3 s fails with --timeout 1, writes FAILED and leaves the rest; FAILED then refuses activation-failed',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err);
|
||||
up(e,C1);
|
||||
const p0=readFileSync(e.pointer,'utf8'),l0=readFileSync(e.log,'utf8');
|
||||
fault(e,`status-until-${C1}`,String(Date.now()+3000));
|
||||
const c=await run(x,'confirm',C1,'acme','--timeout','1');assert.equal(c.code,1,c.out+c.err);
|
||||
assert.match(c.err,new RegExp(`^stack-release: activate failed with bus-status; ${a} stays open`));
|
||||
assert.equal(readFileSync(join(rs(e,a),'FAILED'),'utf8'),'bus-status\n');
|
||||
assert.equal(readFileSync(e.pointer,'utf8'),p0);assert.equal(readFileSync(e.log,'utf8'),l0);
|
||||
assert.ok(existsSync(join(rs(e,a),'AWAITING-UNITS')));
|
||||
const f=await run(x,'confirm',C1,'acme');assert.equal(f.code,3);
|
||||
assert.match(f.err,new RegExp(`^stack-release: refusing activation-failed: ${a} has a FAILED file`));
|
||||
assert.equal(logLines(e).at(-1).activation,null);assert.deepEqual(open(e),[a]);
|
||||
});
|
||||
|
||||
test('row 232: confirm polls to a pass with --timeout 10: the pointer, an ok line that reads as the started run, no marker',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err);
|
||||
up(e,C1);
|
||||
fault(e,`status-until-${C1}`,String(Date.now()+3000));
|
||||
const c=await run(x,'confirm',C1,'acme','--timeout','10');assert.equal(c.code,0,c.out+c.err);
|
||||
assert.equal(c.out.split('\n').at(-2),`stack-release: activate ok: ${C1} is live for acme`);
|
||||
const p=pointer(e);assert.deepEqual([p.copy,p.activation,p.schema,p.pm,p.console],[C1,a,'3g',false,['--port',e.port,'--business','acme']]);
|
||||
const f=sameAsStarted(e,a);assert.deepEqual([f.result,f.op,f.from,f.to,f.backup],['ok','activate',B,C1,a]);
|
||||
assert.ok(!existsSync(join(rs(e,a),'AWAITING-UNITS')));
|
||||
assert.deepEqual(journalCalls(e),[]);
|
||||
});
|
||||
|
||||
// N4 with the brief's numbers: the started line at 1792540800.900 floors to 1792540800. The host's
|
||||
// clock runs so boot status is after that second.
|
||||
const T0=1792540800,offsetTo=(s)=>Math.floor(Date.now()/1000)-s;
|
||||
function startedAt(e,a,ms){
|
||||
const l=readFileSync(e.log,'utf8').split('\n').map((x)=>{if(!x)return x;const o=JSON.parse(x);if(o.activation===a&&o.result==='started')o.at=new Date(ms).toISOString();return JSON.stringify(o)});
|
||||
writeFileSync(e.log,l.join('\n'));
|
||||
}
|
||||
|
||||
test('row 232: stale-start: @1792540799 fails a start floor of 1792540800 with stale-start',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err);
|
||||
startedAt(e,a,T0*1000+900);up(e,C1,{offset:offsetTo(T0+100)});
|
||||
fault(e,'enter',JSON.stringify({value:T0-1}));
|
||||
const c=await run(x,'confirm',C1,'acme','--timeout','1');assert.equal(c.code,1,c.out+c.err);
|
||||
assert.ok(c.out.includes(`gate failed on ${C1}: stale-start (the bus started at @${T0-1}, before @${T0})`),c.out);
|
||||
assert.equal(readFileSync(join(rs(e,a),'FAILED'),'utf8'),'stale-start\n');
|
||||
});
|
||||
|
||||
test('row 232: stale-start is a gate code: an enter time that turns to @1792540800 after 2 s passes with --timeout 10',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err);
|
||||
startedAt(e,a,T0*1000+900);up(e,C1,{offset:offsetTo(T0+100)});
|
||||
fault(e,'enter',JSON.stringify({value:T0-1,at:Date.now()+2000,then:T0}));
|
||||
const c=await run(x,'confirm',C1,'acme','--timeout','10');assert.equal(c.code,0,c.out+c.err);
|
||||
assert.equal(pointer(e).copy,C1);
|
||||
});
|
||||
|
||||
test('row 232: gate (N2, A2f): --since 1792540800 passes @1792540800 and fails @1792540799; it writes nothing',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
up(e,B,{offset:offsetTo(T0+100)});
|
||||
const snap=()=>[readFileSync(e.log,'utf8'),readFileSync(e.pointer,'utf8'),tree(e.state).join('\n')];
|
||||
const s0=snap();
|
||||
fault(e,'enter',JSON.stringify({value:T0}));
|
||||
const g=await run(x,'gate',B,'acme','--since',String(T0));assert.equal(g.code,0,g.out+g.err);
|
||||
assert.equal(g.out,`stack-release: gate passed on ${B}\n`);
|
||||
fault(e,'enter',JSON.stringify({value:T0-1}));
|
||||
const f=await run(x,'gate',B,'acme','--since',String(T0),'--timeout','1');assert.equal(f.code,1,f.out+f.err);
|
||||
assert.ok(f.out.includes(`gate failed on ${B}: stale-start (the bus started at @${T0-1}, before @${T0})`),f.out);
|
||||
assert.deepEqual(snap(),s0);
|
||||
const u=await run(x,'gate',B,'acme');assert.equal(u.code,4);
|
||||
assert.deepEqual(journalCalls(e),[]);
|
||||
});
|
||||
|
||||
test('row 232: the external PM check reads launches list: one live run of launch.by since the start passes',async(t)=>{
|
||||
const {e,x}=await xseed(t,{pm:true});
|
||||
const cases=[['',0,''],['coder',0,''],['none',1,'no live launch of chief'],['twolive',1,'2 live launches of chief'],['early',1,'started before this start'],['dead',1,'no live launch of chief'],['other',1,'the launch of chief is for other']];
|
||||
for(const [mode,code,why] of cases){
|
||||
rmSync(join(e.sd,'launches.json'),{force:true});
|
||||
if(mode)fault(e,`pm-${B}`,mode);else rmSync(join(e.sd,'faults',`pm-${B}`),{force:true});
|
||||
const since=Math.floor(Date.now()/1000);
|
||||
up(e,B,{pm:true});
|
||||
const g=await run(x,'gate',B,'acme','--since',String(since),'--timeout','1');assert.equal(g.code,code,`${mode}: ${g.out}${g.err}`);
|
||||
if(why)assert.ok(g.out.includes(`gate failed on ${B}: pm-launch-unconfirmed (`)&&g.out.includes(why),`${mode}: ${g.out}`);
|
||||
down(e);
|
||||
}
|
||||
assert.deepEqual(journalCalls(e),[]);
|
||||
});
|
||||
|
||||
test('row 232: revert puts the wrapper back and leaves the store when nothing migrated',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const w0=sha(join(e.bin,'mosaic'));
|
||||
const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err);
|
||||
// A write to the stopped store after A3: revert has no MIGRATING, so it is not its to undo.
|
||||
const db=new DatabaseSync(e.store);db.prepare('INSERT INTO events (at) VALUES (?)').run('2001-01-01T00:00:00.000Z');db.close();
|
||||
const s1=storeSha(e);assert.notEqual(s1,sha(backupStore(e,a)));
|
||||
const v=await run(x,'revert',C1,'acme');assert.equal(v.code,0,v.out+v.err);
|
||||
assert.equal(v.out.split('\n').at(-2),`stack-release: revert ok: ${a}'s wrapper is back; root installs the old units next`);
|
||||
assert.equal(sha(join(e.bin,'mosaic')),w0);assert.equal(storeSha(e),s1);
|
||||
assert.deepEqual(mine(e,a).map((o)=>o.result),['started']);assert.ok(existsSync(join(rs(e,a),'AWAITING-UNITS')));
|
||||
});
|
||||
|
||||
test('row 232: confirm --rollback refuses rollback-mismatch, and logs rollback-failed with exit 2 when OLD fails its gate',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err);
|
||||
const v=await run(x,'revert',C1,'acme');assert.equal(v.code,0,v.out+v.err);
|
||||
const m=await run(x,'confirm',C1,'acme','--rollback',C,'--cause','A5');assert.equal(m.code,3);
|
||||
assert.equal(m.err,`stack-release: refusing rollback-mismatch: ${C} is not the copy ${a} started from, ${B}\n`);
|
||||
assert.equal(logLines(e).at(-1).activation,null);assert.deepEqual(open(e),[a]);
|
||||
fault(e,`healthz-${B}`);up(e,B);
|
||||
const f=await run(x,'confirm',C1,'acme','--rollback',B,'--cause','A5','--timeout','1');assert.equal(f.code,2,f.out+f.err);
|
||||
assert.equal(f.err,`stack-release: rollback-failed: the run failed with unrecorded and the rollback with healthz; root stops the units. Rollback set: ${rs(e,a)}, backup ${join(e.data,'backups','stack',a)}\n`);
|
||||
const l=sameAsStarted(e,a);assert.deepEqual([l.result,l.code,l.rollback,l.rollbackCode],['rollback-failed','unrecorded','failed','healthz']);
|
||||
for(const n of ['AWAITING-UNITS','FAILED'])assert.ok(!existsSync(join(rs(e,a),n)),n);
|
||||
assert.deepEqual(open(e),[]);
|
||||
});
|
||||
|
||||
test('row 232: confirm --rollback after a --restore swap logs rollbackCode not-attempted; rollback --restore finds an external backup',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const {r,a}=await stage(e,x,C,'--migrate');assert.equal(r.code,0,r.out+r.err);
|
||||
up(e,C);assert.equal((await run(x,'confirm',C,'acme')).code,0);
|
||||
down(e);
|
||||
const b=await run(x,'rollback','acme','--to',B,'--restore',a);assert.equal(b.code,0,b.out+b.err);
|
||||
const ra=lastAct(e);
|
||||
assert.ok(b.out.includes(`restore: ${join(e.data,'backups','stack',a)}; `),b.out);
|
||||
assert.ok(existsSync(join(rs(e,ra),'RESTORED')));assert.equal(readFileSync(join(rs(e,ra),'BACKUP'),'utf8'),`${a}\n`);
|
||||
assert.equal(storeSha(e),sha(backupStore(e,a)));
|
||||
const f=await run(x,'confirm',B,'acme','--rollback',C,'--cause','A4');assert.equal(f.code,2,f.out+f.err);
|
||||
const l=sameAsStarted(e,ra);assert.deepEqual([l.op,l.result,l.code,l.rollbackCode,l.backup],['rollback','rollback-failed','units-start','not-attempted',a]);
|
||||
});
|
||||
|
||||
// --- B1: real sequences on one log ---
|
||||
|
||||
test('row 232: sequence 1: activate, confirm times out, revert, confirm --rollback --cause A5: gate-failed rollback ok',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const w0=sha(join(e.bin,'mosaic')),p0=readFileSync(e.pointer,'utf8');
|
||||
const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err);
|
||||
fault(e,`healthz-${C1}`);up(e,C1);
|
||||
const c=await run(x,'confirm',C1,'acme','--timeout','1');assert.equal(c.code,1,c.out+c.err);
|
||||
assert.deepEqual(mine(e,a).map((o)=>o.result),['started']);
|
||||
down(e);
|
||||
assert.equal((await run(x,'revert',C1,'acme')).code,0);assert.equal(sha(join(e.bin,'mosaic')),w0);
|
||||
up(e,B);
|
||||
const k=await run(x,'confirm',C1,'acme','--rollback',B,'--cause','A5');assert.equal(k.code,1,k.out+k.err);
|
||||
assert.equal(k.err,`stack-release: healthz; rolled back to ${B}, which passed the gate\n`);
|
||||
const l=sameAsStarted(e,a);assert.deepEqual([l.result,l.code,l.rollback],['gate-failed','healthz','ok']);
|
||||
assert.equal(readFileSync(e.pointer,'utf8'),p0);
|
||||
await closed(e,x,a);
|
||||
});
|
||||
|
||||
test('row 232: sequence 2: activate --migrate, the wrapper install fails, revert restores TAKEN, confirm --rollback --cause A3',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const s0=storeSha(e);
|
||||
chmodSync(e.bin,0o555);
|
||||
const {r,a}=await stage(e,x,C,'--migrate');chmodSync(e.bin,0o755);
|
||||
assert.equal(r.code,1,r.out+r.err);assert.match(r.err,new RegExp(`^stack-release: activate failed with wrapper; ${a} stays open`));
|
||||
assert.deepEqual(mine(e,a).map((o)=>o.result),['started']);
|
||||
for(const n of ['AWAITING-UNITS','MIGRATING'])assert.ok(existsSync(join(rs(e,a),n)),n);
|
||||
assert.equal(readFileSync(join(rs(e,a),'FAILED'),'utf8'),'wrapper\n');
|
||||
assert.equal(storeVersion(e.store,false),'3h');assert.equal(sha(backupStore(e,a)),s0);
|
||||
const v=await run(x,'revert',C,'acme');assert.equal(v.code,0,v.out+v.err);
|
||||
assert.equal(v.out.split('\n').at(-2),`stack-release: revert ok: ${a}'s wrapper and store are back; root installs the old units next`);
|
||||
assert.equal(storeSha(e),s0);
|
||||
// revert runs again: the store already matches, and nothing moves.
|
||||
const posts=readdirSync(e.data).filter((n)=>n.startsWith('bus.post-attempt-'));
|
||||
assert.equal((await run(x,'revert',C,'acme')).code,0);
|
||||
assert.deepEqual(readdirSync(e.data).filter((n)=>n.startsWith('bus.post-attempt-')),posts);
|
||||
up(e,B);
|
||||
const k=await run(x,'confirm',C,'acme','--rollback',B,'--cause','A3');assert.equal(k.code,1,k.out+k.err);
|
||||
const l=sameAsStarted(e,a);assert.deepEqual([l.result,l.code,l.rollback],['failed','wrapper','ok']);
|
||||
await closed(e,x,a);
|
||||
});
|
||||
|
||||
test('row 232: sequence 3: activate, root\'s A4 fails, revert, confirm --rollback --cause A4: failed code units-start',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal((await run(x,'revert',C1,'acme')).code,0);
|
||||
up(e,B);
|
||||
const k=await run(x,'confirm',C1,'acme','--rollback',B,'--cause','A4');assert.equal(k.code,1,k.out+k.err);
|
||||
assert.equal(k.err,`stack-release: units-start; rolled back to ${B}, which passed the gate\n`);
|
||||
const l=sameAsStarted(e,a);assert.deepEqual([l.result,l.code,l.rollback],['failed','units-start','ok']);
|
||||
await closed(e,x,a);
|
||||
});
|
||||
|
||||
test('row 232: sequence 4: a capture failure leaves nothing open, then A2f\'s gate OLD writes nothing',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
fault(e,'no-fragment');
|
||||
const r=await run(x,'activate',C1,'acme');assert.equal(r.code,3,r.out+r.err);rmSync(join(e.sd,'faults','no-fragment'));
|
||||
const a=lastAct(e);assert.deepEqual(mine(e,a).map((o)=>[o.result,o.code]),[['started',undefined],['refused','capture-failed']]);
|
||||
assert.deepEqual(open(e),[]);
|
||||
const since=Math.floor(Date.now()/1000);up(e,B);
|
||||
const s0=[readFileSync(e.log,'utf8'),readFileSync(e.pointer,'utf8'),tree(e.state).join('\n')];
|
||||
const g=await run(x,'gate',B,'acme','--since',String(since));assert.equal(g.code,0,g.out+g.err);
|
||||
assert.deepEqual([readFileSync(e.log,'utf8'),readFileSync(e.pointer,'utf8'),tree(e.state).join('\n')],s0);
|
||||
});
|
||||
|
||||
test('row 232: sequence 5: a confirm of the wrong copy is refused, and revert and confirm --rollback still close the activation',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err);
|
||||
up(e,C1);
|
||||
const w=await run(x,'confirm',B,'acme');assert.equal(w.code,3);assert.match(w.err,/^stack-release: refusing open-activation: /);
|
||||
assert.equal(logLines(e).at(-1).activation,null);assert.deepEqual(open(e),[a]);
|
||||
down(e);
|
||||
assert.equal((await run(x,'revert',C1,'acme')).code,0);
|
||||
up(e,B);
|
||||
const k=await run(x,'confirm',C1,'acme','--rollback',B,'--cause','A5');assert.equal(k.code,1,k.out+k.err);
|
||||
const l=sameAsStarted(e,a);assert.deepEqual([l.result,l.code,l.rollback],['gate-failed','unrecorded','ok']);
|
||||
await closed(e,x,a);
|
||||
});
|
||||
|
||||
test('row 232: sequence 6: a revert that fails leaves the activation open, and the next --plan refuses interrupted-run',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const {r,a}=await stage(e,x,C1);assert.equal(r.code,0,r.out+r.err);
|
||||
rmSync(join(rs(e,a),'wrapper'));
|
||||
const v=await run(x,'revert',C1,'acme');assert.equal(v.code,1,v.out+v.err);
|
||||
assert.equal(v.err,`stack-release: revert failed: the rollback set has no saved wrapper; ${a} stays open. Rollback set: ${rs(e,a)}\n`);
|
||||
assert.deepEqual(open(e),[a]);assert.ok(existsSync(join(rs(e,a),'AWAITING-UNITS')));
|
||||
const p=await run(x,'activate',C,'acme','--plan','--migrate');assert.equal(p.code,3,p.out+p.err);
|
||||
assert.ok(p.out.includes(`refuses interrupted-run: activation ${a} started and has no final line`),p.out);
|
||||
});
|
||||
|
||||
test('row 232: sequence 7: adopt, two confirmed activates, rollback --to the previous copy, confirmed, logs op rollback',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const one=await stage(e,x,C1);assert.equal(one.r.code,0,one.r.out+one.r.err);
|
||||
up(e,C1);assert.equal((await run(x,'confirm',C1,'acme')).code,0);down(e);
|
||||
const two=await stage(e,x,C,'--migrate');assert.equal(two.r.code,0,two.r.out+two.r.err);
|
||||
up(e,C);assert.equal((await run(x,'confirm',C,'acme')).code,0);down(e);
|
||||
const l1=sameAsStarted(e,one.a),l2=sameAsStarted(e,two.a);
|
||||
assert.deepEqual([l1.op,l1.result,l1.from,l1.to,l1.backup],['activate','ok',B,C1,one.a]);
|
||||
assert.deepEqual([l2.op,l2.result,l2.from,l2.to,l2.backup],['activate','ok',C1,C,two.a]);
|
||||
// N5: --expect-from names the copy root read from its units.
|
||||
const d1=await run(x,'activate',C1,'acme','--plan','--expect-from',C1);assert.equal(d1.code,3,d1.out+d1.err);
|
||||
assert.ok(d1.out.includes(`refuses pointer-differs: the pointer names ${C} and root's units name ${C1}; Jason or Mos settles it`),d1.out);
|
||||
const d2=await run(x,'rollback','acme','--to',C1,'--plan','--expect-from',B);assert.equal(d2.code,3,d2.out+d2.err);
|
||||
assert.ok(d2.out.includes(`refuses pointer-differs: the pointer names ${C} and root's units name ${B}`),d2.out);
|
||||
const ok=await run(x,'rollback','acme','--to',C1,'--restore',two.a,'--plan','--expect-from',C);assert.equal(ok.code,0,ok.out+ok.err);
|
||||
const logs=logLines(e).length;
|
||||
const no=await run(x,'rollback','acme','--to',B,'--plan');assert.equal(no.code,3);
|
||||
assert.equal(no.err,`stack-release: refusing to-not-previous: --to ${B} is not the previous copy; the log names ${C1}\n`);
|
||||
assert.equal(logLines(e).length,logs);
|
||||
const nr=await run(x,'rollback','acme','--to',B,'--restore',one.a);assert.equal(nr.code,3);assert.match(nr.err,/^stack-release: refusing to-not-previous: /);
|
||||
assert.deepEqual(logLines(e).slice(logs).map((o)=>[o.result,o.code,o.activation]),[['refused','to-not-previous',null]]);
|
||||
const rb=await run(x,'rollback','acme','--to',C1,'--restore',two.a);assert.equal(rb.code,0,rb.out+rb.err);
|
||||
const ra=lastAct(e);
|
||||
up(e,C1);
|
||||
const k=await run(x,'confirm',C1,'acme');assert.equal(k.code,0,k.out+k.err);
|
||||
assert.equal(k.out.split('\n').at(-2),`stack-release: rollback ok: ${C1} is live for acme`);
|
||||
const l3=sameAsStarted(e,ra);assert.deepEqual([l3.op,l3.result,l3.from,l3.to,l3.backup],['rollback','ok',C,C1,two.a]);
|
||||
assert.deepEqual([pointer(e).copy,pointer(e).schema],[C1,'3g']);
|
||||
const prev=await run(x,'rollback','acme','--to',B,'--plan');assert.match(prev.err,new RegExp(`the log names ${C}\\n$`));
|
||||
await closed(e,x,ra);
|
||||
});
|
||||
|
||||
test('row 232: sequence 8: a migration that writes and fails: revert restores the backup byte for byte, confirm --rollback --cause A3',async(t)=>{
|
||||
const {e,x}=await xseed(t);
|
||||
const s0=storeSha(e);
|
||||
fault(e,'migrate-fails');
|
||||
const {r,a}=await stage(e,x,C,'--migrate');assert.equal(r.code,1,r.out+r.err);rmSync(join(e.sd,'faults','migrate-fails'));
|
||||
assert.equal(readFileSync(join(rs(e,a),'FAILED'),'utf8'),'migrate-failed\n');assert.ok(existsSync(join(rs(e,a),'MIGRATING')));
|
||||
assert.equal(storeVersion(e.store,false),'3h-partial');
|
||||
assert.equal((await run(x,'revert',C,'acme')).code,0);
|
||||
assert.equal(storeSha(e),s0);assert.equal(storeSha(e),sha(backupStore(e,a)));
|
||||
assert.deepEqual(readdirSync(join(e.data,'bus')),readdirSync(join(e.data,'backups','stack',a,'bus')));
|
||||
up(e,B);
|
||||
const k=await run(x,'confirm',C,'acme','--rollback',B,'--cause','A3');assert.equal(k.code,1,k.out+k.err);
|
||||
const l=sameAsStarted(e,a);assert.deepEqual([l.result,l.code,l.rollback],['failed','migrate-failed','ok']);
|
||||
await closed(e,x,a);
|
||||
});
|
||||
|
||||
// --- User mode against the base ---
|
||||
|
||||
// The fixture repo with the base's two scripts: everything else is the same tree and history.
|
||||
const baseRepo=join(base,'base-repo');
|
||||
cpSync(repo,baseRepo,{recursive:true});
|
||||
for(const f of ['scripts/stack-release.sh','scripts/stack-copy.sh'])writeFileSync(join(baseRepo,f),execFileSync('git',['-C',ROOT,'show',`${BASE}:${f}`]),{mode:0o755});
|
||||
const BASE_SR=join(baseRepo,'scripts','stack-release.sh');
|
||||
|
||||
test('row 232: user mode: every systemctl call carries --user, and activate, rollback and check make the base\'s calls',async(t)=>{
|
||||
const calls=async(sr,dir)=>{
|
||||
const e=await mk(t);e.sr=sr;await seed(e);
|
||||
const n=lines(join(e.sd,'argv.log')).length;
|
||||
const a=await run(e,'activate',C1,'acme');assert.equal(a.code,0,a.out+a.err);
|
||||
const r=await run(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
execFileSync('bash',[join(dir,'scripts','stack-copy.sh'),'check','--copies',COPIES,'--bin',e.bin,'--business','acme'],{env:e.env,stdio:'ignore'});
|
||||
return lines(join(e.sd,'argv.log')).slice(n).map((l)=>l.split(e.root).join('<root>').split(e.port).join('<port>'));
|
||||
};
|
||||
const cand=await calls(undefined,repo),old=await calls(BASE_SR,baseRepo);
|
||||
assert.ok(cand.length>20);
|
||||
for(const c of cand)assert.match(c,/^--user /,c);
|
||||
assert.deepEqual(cand,old);
|
||||
});
|
||||
|
||||
test('row 232: user mode: a default activate --plan prints the base\'s bytes',async(t)=>{
|
||||
const e=await mk(t);await seed(e);
|
||||
for(const a of [['activate',C1,'acme','--plan'],['activate',C,'acme','--plan','--migrate'],['activate',C,'acme','--plan'],['rollback','acme','--plan']]){
|
||||
const cand=await run(e,...a),old=await run({...e,sr:BASE_SR},...a);
|
||||
assert.deepEqual(cand,old,a.join(' '));
|
||||
}
|
||||
assert.ok(!existsSync(join(e.state,'stack-rollback')));
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
// The stack-release stand-ins and module fixture, shared by stack-release.test.mjs and
|
||||
// stack-release-external.test.mjs (row 232). Moved unchanged from stack-release.test.mjs, plus
|
||||
// what the external tests need; each test file runs in its own process, so each gets its own base.
|
||||
import test, {after} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {execFileSync,spawn,spawnSync} from 'node:child_process';
|
||||
import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,appendFileSync,cpSync,rmSync,chmodSync,existsSync,statSync,lstatSync,symlinkSync,linkSync,readdirSync,realpathSync} from 'node:fs';
|
||||
import {createServer} from 'node:http';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {DatabaseSync} from 'node:sqlite';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join,dirname,basename} from 'node:path';
|
||||
import {fileURLToPath,pathToFileURL} from 'node:url';
|
||||
import {once} from 'node:events';
|
||||
|
||||
export const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,'');
|
||||
export const NODE=process.execPath;
|
||||
export const MARKER='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)';
|
||||
export const BUS='[email protected]',CON='mosaic-webui.service';
|
||||
export const git=(cwd,...args)=>execFileSync('git',['-c','user.name=fixture','-c','user.email=fixture@invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','-C',cwd,...args],{encoding:'utf8'}).trim();
|
||||
export const script=(fn)=>`#!${NODE}\n(${fn})();\n`;
|
||||
|
||||
// --- Stand-ins. Each is serialized into its own file, so it uses only process and its own scope. ---
|
||||
|
||||
// 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.
|
||||
export 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');
|
||||
const args=process.argv.slice(2).filter((a)=>a!=='--user'),[verb,...rest]=args;
|
||||
fs.appendFileSync(path.join(sd,'calls.log'),args.join(' ')+'\n');
|
||||
// Row 232: argv.log keeps each call as given, --user and all, for the user/external argv tests.
|
||||
fs.appendFileSync(path.join(sd,'argv.log'),process.argv.slice(2).join(' ')+'\n');
|
||||
const faultFile=(n)=>path.join(sd,'faults',n),fault=(n)=>fs.existsSync(faultFile(n));
|
||||
const stFile=path.join(sd,'units.json');
|
||||
const load=()=>{try{return JSON.parse(fs.readFileSync(stFile,'utf8'))}catch{return {}}};
|
||||
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 unitFile=(u)=>{
|
||||
const main=isBus(u)?u.replace(/@[^@]*$/,'@.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 r={file,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);
|
||||
const m=/^(WorkingDirectory|ExecStart|Environment)=(.*)$/.exec(line);if(!m)continue;
|
||||
if(m[1]==='WorkingDirectory')r.wd=m[2];else if(m[1]==='ExecStart'){if(m[2])r.exec=m[2]}else r.env.push(m[2]);
|
||||
}
|
||||
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={};
|
||||
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)=>{
|
||||
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 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{}
|
||||
// instance is launch.by's, chief in the example business, as the external PM check reads (row 232).
|
||||
const entry=(o)=>list.push({run,instance:'chief',business:b,live:true,startedAt:at,...o});
|
||||
line(`bus host up: business ${b}, socket /fixture/bus.sock, notifier off`);
|
||||
switch(mode){
|
||||
case 'none':break;
|
||||
case 'two':ok();line(`launched pm-${b}: run ${run}b, claude opus`);entry({});break;
|
||||
case 'badword':line(`launched pm-${b}: job ${run}, claude opus`);entry({});break;
|
||||
case 'notlisted':ok();break;
|
||||
case 'early':ok();entry({startedAt:new Date(now-3600e3).toISOString()});break;
|
||||
case 'human':entry({run:`human-${pid}`});break;
|
||||
case 'other':ok();entry({business:'other'});break;
|
||||
case 'dead':ok();entry({live:false});break;
|
||||
// Row 232: two live entries for the PM's instance, and one for it beside a live coder run.
|
||||
case 'twolive':ok();entry({});entry({run:`${run}b`});break;
|
||||
case 'coder':ok();entry({});entry({run:`${run}c`,instance:'coder'});break;
|
||||
default:ok();entry({});
|
||||
}
|
||||
fs.writeFileSync(lf,JSON.stringify(list));
|
||||
};
|
||||
if(verb==='show'){
|
||||
// `show [--timestamp=unix] UNIT -p P...` and `show -p P UNIT`, as the external path calls it.
|
||||
let u='';const props=[];for(let i=0;i<rest.length;i++){if(rest[i]==='-p')props.push(rest[++i]);else if(!rest[i].startsWith('--'))u=rest[i]}
|
||||
const f=unitFile(u),st=load()[u]||{},id=f?path.basename(f.wd):'';
|
||||
// ActiveEnterTimestamp (row 232) is the start's second, or what the enter fault says: a JSON
|
||||
// {value} or {value,at,then}, which answers then from epoch ms at on.
|
||||
const enter=()=>{
|
||||
if(!fault('enter'))return st.enter;
|
||||
const o=JSON.parse(fs.readFileSync(faultFile('enter'),'utf8'));return o.at!==undefined&&Date.now()>=o.at?o.then:o.value;
|
||||
};
|
||||
for(const p of props){
|
||||
if(p==='LoadState')console.log(`LoadState=${f?'loaded':'not-found'}`);
|
||||
else if(p==='WorkingDirectory')console.log(`WorkingDirectory=${f?f.wd+(!isBus(u)&&fault(`check-${id}`)?'-moved':''):''}`);
|
||||
else if(p==='ExecStart')console.log(f&&f.exec?`ExecStart={ path=${f.exec.split(' ')[0]} ; argv[]=${f.exec} ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }`:'ExecStart=');
|
||||
else if(p==='Environment')console.log(`Environment=${f?f.env.join(' '):''}`);
|
||||
else if(p==='MainPID')console.log(`MainPID=${st.pid||0}`);
|
||||
else if(p==='DropInPaths')console.log(`DropInPaths=${f?f.drops.join(' '):''}`);
|
||||
else if(p==='FragmentPath')console.log(`FragmentPath=${f&&!fault('no-fragment')?f.file:''}`);
|
||||
else if(p==='ActiveEnterTimestamp'){const v=st.state==='active'?enter():undefined;console.log(`ActiveEnterTimestamp=${v===undefined?'':'@'+v}`)}
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
if(verb==='is-active'){
|
||||
const q=rest[0]==='--quiet',u=q?rest[1]:rest[0];let s=(load()[u]||{}).state||'inactive';
|
||||
// bus-stops-after N (T29): the next N bus checks answer the real state, every later one answers
|
||||
// inactive, and units.json stays as it is, as if the bus stopped between two checks.
|
||||
if(isBus(u)&&fault('bus-stops-after')){const n=Number(fs.readFileSync(faultFile('bus-stops-after'),'utf8'));if(n>0)fs.writeFileSync(faultFile('bus-stops-after'),String(n-1));else s='inactive'}
|
||||
if(!q)console.log(s);process.exit(s==='active'?0:3);
|
||||
}
|
||||
if(verb==='is-enabled'){console.log('disabled');process.exit(1)}
|
||||
if(verb==='daemon-reload')process.exit(0);
|
||||
if(verb==='list-units'){
|
||||
if(fault('list-fails'))process.exit(1);
|
||||
const pats=rest.filter((a)=>!a.startsWith('--')).map((p)=>new RegExp('^'+p.replace(/\./g,'\\.').replace(/\*/g,'.*')+'$'));
|
||||
for(const [u,s] of Object.entries(load()))if(pats.some((re)=>re.test(u)))console.log(`${u} loaded ${s.state} ${s.state==='active'?'running':'dead'} fixture`);
|
||||
process.exit(0);
|
||||
}
|
||||
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 enter=Math.floor((Date.now()-Number(process.env.SD_CLOCK_OFFSET||'0')*1000)/1000);
|
||||
if(!isBus(u)){s[u]={state:'active',pid:null,wd:f.wd,enter};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'));
|
||||
db.prepare('INSERT INTO events (at) VALUES (?)').run('2999-01-01T00:00:00.000Z');db.close();
|
||||
}
|
||||
snap('start',d);
|
||||
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'});
|
||||
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,enter};save(s);
|
||||
if(f.exec.split(' ').slice(1).includes('--pm'))pm(u,d,id,ch.pid,Number(off));
|
||||
process.exit(0);
|
||||
}
|
||||
if(verb==='stop'){
|
||||
const u=rest[0],s=load(),st=s[u];
|
||||
if(st&&st.pid){
|
||||
try{process.kill(st.pid,'SIGTERM')}catch{}
|
||||
const end=Date.now()+10000;while(alive(st.pid)&&Date.now()<end)sleep(20);
|
||||
if(alive(st.pid))try{process.kill(st.pid,'SIGKILL')}catch{}
|
||||
}
|
||||
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),'')}
|
||||
// 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'));
|
||||
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);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(`fixture systemctl: unsupported: ${args.join(' ')}`);process.exit(1);
|
||||
}
|
||||
|
||||
// journalctl --user -u UNIT --since @EPOCH -o cat. A bare local time gets exit 1, as M35 needs.
|
||||
export function journalctl(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
|
||||
const sd=process.env.SD_STATE,a=process.argv.slice(2);let unit='',since='';
|
||||
for(let i=0;i<a.length;i++){if(a[i]==='-u')unit=a[++i];else if(a[i]==='--since')since=a[++i]}
|
||||
fs.appendFileSync(path.join(sd,'journal-since.log'),since+'\n');
|
||||
if(fs.existsSync(path.join(sd,'faults','journal-fails')))process.exit(1);
|
||||
let from;
|
||||
if(/^@\d+$/.test(since))from=Number(since.slice(1));
|
||||
else{const m=/^(\d{4}-\d\d-\d\d) (\d\d:\d\d:\d\d) UTC$/.exec(since);if(!m)process.exit(1);from=Date.parse(`${m[1]}T${m[2]}Z`)/1000}
|
||||
let lines=[];try{lines=fs.readFileSync(path.join(sd,'journal.jsonl'),'utf8').split('\n').filter(Boolean).map((l)=>JSON.parse(l))}catch{}
|
||||
for(const o of lines)if(o.unit===unit&&o.epoch>=from)console.log(o.text);
|
||||
}
|
||||
|
||||
export function systemdAnalyze(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
|
||||
if(process.argv[2]!=='--user'||process.argv[3]!=='unit-paths')process.exit(1);
|
||||
console.log(path.join(process.env.XDG_CONFIG_HOME,'systemd','user'));
|
||||
// The unit-path fault adds its text as one more path, as T28's relative path needs.
|
||||
const extra=path.join(process.env.SD_STATE,'faults','unit-path');
|
||||
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.
|
||||
export 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');
|
||||
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){
|
||||
db.exec('PRAGMA journal_mode=WAL');
|
||||
db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
||||
db.exec('CREATE TABLE events (id INTEGER PRIMARY KEY, at TEXT NOT NULL)');
|
||||
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();
|
||||
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)});
|
||||
fs.writeFileSync(path.join(sd,`ready-${process.pid}`),'');
|
||||
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.
|
||||
export 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);
|
||||
// Row 232: status-until-<copy> fails bus status until the epoch ms it holds.
|
||||
const until=path.join(sd,'faults',`status-until-${id}`);
|
||||
if(fs.existsSync(until)&&Date.now()<Number(fs.readFileSync(until,'utf8')))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{}
|
||||
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{}
|
||||
console.log(JSON.stringify(l));
|
||||
}else{console.error(`fixture mosaic: ${a}`);process.exit(4)}
|
||||
}
|
||||
|
||||
// A copy's migrate-schema.mjs: records the backups it saw, then sets 3h, or 3h-partial and exit 1.
|
||||
export function migrateStub(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),{DatabaseSync}=process.getBuiltinModule('node:sqlite');
|
||||
const data=process.argv[2],sd=process.env.SD_STATE,dir=path.join(data,'backups','stack');
|
||||
const backups=fs.existsSync(dir)?fs.readdirSync(dir).sort().map((name)=>({name,sums:fs.existsSync(path.join(dir,name,'SHA256SUMS'))})):[];
|
||||
fs.writeFileSync(path.join(sd,'migrate.json'),JSON.stringify({backups}));
|
||||
const fail=fs.existsSync(path.join(sd,'faults','migrate-fails'));
|
||||
const db=new DatabaseSync(path.join(data,'bus','bus.sqlite'));
|
||||
db.prepare("UPDATE meta SET value=? WHERE key='schema_version'").run(fail?'3h-partial':'3h');db.close();
|
||||
if(fail)process.exit(1);
|
||||
console.log('fixture migrate: 3h');
|
||||
}
|
||||
|
||||
// --- The module fixture: one repo, its copies and the stand-ins, shared by every test. ---
|
||||
|
||||
export const base=realpathSync(mkdtempSync(join(tmpdir(),'stack-release-')));
|
||||
after(()=>{execFileSync('chmod',['-R','u+w',base]);rmSync(base,{recursive:true,force:true})});
|
||||
export const repo=join(base,'repo'),COPIES=join(base,'copies'),SBIN=join(base,'sbin');
|
||||
mkdirSync(repo);mkdirSync(COPIES);mkdirSync(SBIN);
|
||||
writeFileSync(join(base,'package.json'),'{"type":"commonjs"}\n');
|
||||
export const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
|
||||
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
|
||||
export const pkg={name:'copy-fixture',version:'1.0.0'};
|
||||
writeFileSync(join(repo,'package.json'),JSON.stringify(pkg));
|
||||
writeFileSync(join(repo,'package-lock.json'),JSON.stringify({name:pkg.name,version:pkg.version,lockfileVersion:3,packages:{'':pkg}}));
|
||||
git(repo,'init','-q','-b','refactor');git(repo,'config','gc.auto','0');git(repo,'add','.');git(repo,'commit','-qm','fixture');
|
||||
export const commit=(msg)=>{git(repo,'commit','-q','--allow-empty','-m',msg);return git(repo,'rev-parse','HEAD')};
|
||||
// refactor: F A B C1 C, then M merging side branch S (from A). U sits on a branch nothing reaches.
|
||||
export const F=git(repo,'rev-parse','HEAD'),A=commit('row 901: A'),B=commit('row 902: B'),C1=commit('row 903: C1'),C=commit('row 904: C');
|
||||
git(repo,'checkout','-q','-b','side',A);export const S=commit('row 905: S');
|
||||
git(repo,'checkout','-q','-b','unreached',A);export const U=commit('row 906: U');
|
||||
git(repo,'checkout','-q','refactor');git(repo,'merge','-q','--no-ff','-m','merge side','side');
|
||||
export const M=git(repo,'rev-parse','HEAD');git(repo,'update-ref','refs/remotes/origin/refactor',M);
|
||||
// `to` false leaves out the `to:` lines (C1); a string puts that value in each one (A). The --plan
|
||||
// steps line can't be read from either (T27).
|
||||
export const storeText=(v,froms,to=true)=>`export const SCHEMA_VERSION = '${v}';\nexport const MIGRATIONS = [\n${froms.map((f,i)=>` {\n from: '${f}',\n${to===false?'':` to: '${to===true?froms[i+1]??v:to}',\n`} run() {},\n },\n`).join('')}];\n`;
|
||||
// Plug-in kinds (row 169): C1 and C know the harness kind, as a copy at or past c216f47db does. The
|
||||
// rest have it under another name, as ca15d73e9 has no harness kind. Every copy has the real business
|
||||
// loader and roles, which the tool imports to read the business file.
|
||||
export const KINDS_NEW=readFileSync(join(repo,'packages','plugins','src','kinds.mjs'),'utf8');
|
||||
export const KINDS_OLD=KINDS_NEW.replace('\n harness: Object.freeze({','\n "harness-before-c216f47db": Object.freeze({');
|
||||
assert.notEqual(KINDS_OLD,KINDS_NEW);
|
||||
// Row 200 (#1698): C1 and C take --sso and have the row 192 loader and checkConsole; C's checker
|
||||
// refuses RS256, a stand-in for a target whose rules differ from the repository's. B has the loader
|
||||
// from before row 192, which refuses a `console` key. The other copies have no loader.
|
||||
const LOADER=readFileSync(join(repo,'scripts','mosaic-config.mjs'),'utf8'),SSO_CONFIG=readFileSync(join(repo,'packages','webui','src','sso-config.mjs'),'utf8');
|
||||
const LOADER_OLD=LOADER.replace('["configVersion", "environment", "dataRoot", "execution", "console"]','["configVersion", "environment", "dataRoot", "execution"]');
|
||||
const SSO_CONFIG_C=SSO_CONFIG.replace("'ES256', 'RS256',","'ES256',");
|
||||
assert.notEqual(LOADER_OLD,LOADER);assert.notEqual(SSO_CONFIG_C,SSO_CONFIG);
|
||||
const ssoFiles=(id)=>id===B?[['scripts/mosaic-config.mjs',LOADER_OLD]]:[C1,C].includes(id)?[['scripts/mosaic-config.mjs',LOADER],['packages/webui/src/sso-config.mjs',id===C?SSO_CONFIG_C:SSO_CONFIG]]:[];
|
||||
for(const [id,store] of [[F,'// a copy from before SCHEMA_VERSION\n'],[A,storeText('3g',['3f'],'3x')],[B,storeText('3g',['3f'])],[C1,storeText('3g',['3f'],false)],[C,storeText('3h',['3f','3g'])],[S,storeText('3g',['3f'])],[U,storeText('3g',['3f'])]]){
|
||||
const d=join(COPIES,id);
|
||||
for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs',[B,C1,C].includes(id)?`// fixture Console, never run\n// serve accepts '--tester-port'\n// serve accepts '--proof-header' (row 178)\n${[C1,C].includes(id)?"// serve accepts '--sso' (row 192)\n":''}`:'// fixture Console before tester listener, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644],['packages/plugins/src/kinds.mjs',[C1,C].includes(id)?KINDS_NEW:KINDS_OLD,0o644],...ssoFiles(id).map(([f,text])=>[f,text,0o644])]){
|
||||
mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text,{mode});
|
||||
}
|
||||
for(const p of ['packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
|
||||
execFileSync('chmod',['-R','a-w',d]);
|
||||
}
|
||||
for(const [name,fn] of [['systemctl',systemctl],['journalctl',journalctl],['systemd-analyze',systemdAnalyze]])writeFileSync(join(SBIN,name),script(fn),{mode:0o755});
|
||||
writeFileSync(join(base,'holder.cjs'),`(${holder})();\n`);
|
||||
export const SR=join(repo,'scripts','stack-release.sh');
|
||||
|
||||
// --- Per-test roots and helpers ---
|
||||
|
||||
export const sha=(f)=>createHash('sha256').update(readFileSync(f)).digest('hex');
|
||||
export const lines=(f)=>existsSync(f)?readFileSync(f,'utf8').split('\n').filter(Boolean):[];
|
||||
export const calls=(e)=>lines(join(e.sd,'calls.log'));
|
||||
export const startsStops=(e,from)=>calls(e).slice(from).filter((c)=>/^(start|stop) /.test(c));
|
||||
export const logLines=(e)=>lines(e.log).map((l)=>JSON.parse(l));
|
||||
export const pointer=(e)=>existsSync(e.pointer)?JSON.parse(readFileSync(e.pointer,'utf8')):null;
|
||||
export const units=(e)=>JSON.parse(readFileSync(join(e.sd,'units.json'),'utf8'));
|
||||
export const snaps=(e)=>lines(join(e.sd,'snap.jsonl')).map((l)=>JSON.parse(l));
|
||||
export const fault=(e,name,text='')=>writeFileSync(join(e.sd,'faults',name),text);
|
||||
export const sysctl=(e,...a)=>execFileSync(join(SBIN,'systemctl'),['--user',...a],{env:e.env,encoding:'utf8'});
|
||||
export const edit=(f,from,to)=>{const s=readFileSync(f,'utf8');assert.ok(s.includes(from),`${f} has no ${from}`);writeFileSync(f,s.replace(from,to))};
|
||||
export const posts=(e)=>readdirSync(e.data).filter((n)=>n.startsWith('bus.post-attempt-'));
|
||||
export const backupSum=(e,act)=>readFileSync(join(e.data,'backups','stack',act,'SHA256SUMS'),'utf8').split(' ')[0];
|
||||
export 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.
|
||||
export 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)});
|
||||
// The schema version: readOnly while the holder has the store open, immutable when it is stopped.
|
||||
export function storeVersion(file,held){
|
||||
const db=held?new DatabaseSync(file,{readOnly:true}):new DatabaseSync(pathToFileURL(file).href+'?immutable=1',{readOnly:true});
|
||||
try{return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value}finally{db.close()}
|
||||
}
|
||||
// Every file and directory under p with size and mtime: T23 and T16 compare it before and after.
|
||||
export function tree(p){
|
||||
const out=[];
|
||||
const walk=(q)=>{let st;try{st=statSync(q)}catch{return}out.push(st.isDirectory()?`${q}/`:`${q} ${st.size} ${st.mtimeMs}`);if(st.isDirectory())for(const n of readdirSync(q).sort())walk(join(q,n))};
|
||||
walk(p);return out;
|
||||
}
|
||||
// T17: what a run may never remove. Under --units external confirm removes a set's AWAITING-UNITS
|
||||
// and FAILED when it closes the activation (row 232, r6 A5 and R4); user mode writes neither.
|
||||
const markers=/\/(AWAITING-UNITS|FAILED) /;
|
||||
export const kept=(e)=>[...tree(join(e.data,'backups','stack')),...tree(join(e.state,'stack-rollback')).filter((l)=>!markers.test(l)),...posts(e).flatMap((n)=>tree(join(e.data,n))),...tree(COPIES)].map((l)=>l.replace(/ .*/,''));
|
||||
|
||||
// The business file (row 169): the committed example made loadable, as acme, with `plugins` when given.
|
||||
export const EXAMPLE=readFileSync(join(repo,'packages','business','examples','mosaic-stack.example.json'),'utf8');
|
||||
export function biz(e,plugins){
|
||||
const b=JSON.parse(EXAMPLE);let n=0;
|
||||
const walk=(o)=>{for(const [k,v] of Object.entries(o)){if(k==='botId')o[k]=++n;else if(v==='YYYY-MM-DD')o[k]='2027-01-01';else if(v&&typeof v==='object')walk(v)}};
|
||||
walk(b);b.id='acme';if(plugins!==undefined)b.plugins=plugins;
|
||||
const f=join(dirname(e.cfg),'businesses','acme.json');mkdirSync(dirname(f),{recursive:true});
|
||||
writeFileSync(f,JSON.stringify(b,null,2)+'\n',{mode:0o600});chmodSync(f,0o600);
|
||||
}
|
||||
|
||||
export 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);
|
||||
writeFileSync(e.cfg,JSON.stringify({configVersion:1,environment:'development',dataRoot:e.data,execution:{backend:'docker',provider:'zai',model:'glm-5.3-flash',adapter:'mock'}}));
|
||||
biz(e);
|
||||
writeFileSync(join(e.bin,'mosaic'),`#!/usr/bin/env bash\n${MARKER}\n[ "\${1:-}" = __lease-capability ] && exec "${e.bin}/mosaic-old" "$@"\nexec "/old/scripts/mosaic" "$@"\n`,{mode:0o755});
|
||||
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.
|
||||
export async function healthz(e){
|
||||
const server=createServer((q,r)=>{
|
||||
let st={};try{st=units(e)[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.
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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']});
|
||||
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);
|
||||
const now=new Set(kept(e));
|
||||
for(const p of before)assert.ok(now.has(p),`T17: ${args.join(' ')} removed ${p}`);
|
||||
return {code,out,err};
|
||||
}
|
||||
|
||||
// The stack on copy `copy` for business acme, both units active. pointer: adopt (a real adopt and
|
||||
// its log line), hand (written here, no log) or none. Snapshots start after the seed.
|
||||
export async function seed(e,{copy=B,pm=false,store='3g',pointer:how='adopt'}={}){
|
||||
const db=new DatabaseSync(e.store);
|
||||
db.exec('PRAGMA journal_mode=WAL');
|
||||
db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
||||
db.exec('CREATE TABLE events (id INTEGER PRIMARY KEY, at TEXT NOT NULL)');
|
||||
db.prepare("INSERT INTO meta VALUES ('schema_version', ?)").run(store);
|
||||
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);
|
||||
// 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);
|
||||
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});
|
||||
writeFileSync(e.pointer,JSON.stringify({copy,business:'acme',pm,claude:e.claude,console:['--port',e.port,'--business','acme'],schema:'3g',activation:'manual',at:'2026-10-18T00:00:00Z'})+'\n');
|
||||
}
|
||||
writeFileSync(join(e.sd,'snap.jsonl'),'');
|
||||
}
|
||||
|
||||
export const rolledBack=(code,to=B)=>`stack-release: ${code}; rolled back to ${to}, which passed the gate\n`;
|
||||
export const planBackup=(e)=>`backup: the run backs up the stopped store to ${e.data}/backups/stack/<activation>/ before any migration`;
|
||||
export const planNoBackup='backup: none; the run rolls back before the backup';
|
||||
export const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off','send: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
|
||||
@@ -13,422 +13,7 @@ import {join,dirname,basename} from 'node:path';
|
||||
import {fileURLToPath,pathToFileURL} from 'node:url';
|
||||
import {once} from 'node:events';
|
||||
import {ALGS} from '../../webui/src/sso-config.mjs';
|
||||
|
||||
const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,'');
|
||||
const NODE=process.execPath;
|
||||
const MARKER='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)';
|
||||
const BUS='[email protected]',CON='mosaic-webui.service';
|
||||
const git=(cwd,...args)=>execFileSync('git',['-c','user.name=fixture','-c','user.email=fixture@invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','-C',cwd,...args],{encoding:'utf8'}).trim();
|
||||
const script=(fn)=>`#!${NODE}\n(${fn})();\n`;
|
||||
|
||||
// --- Stand-ins. Each is serialized into its own file, so it uses only process and its own scope. ---
|
||||
|
||||
// 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.
|
||||
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');
|
||||
const args=process.argv.slice(2).filter((a)=>a!=='--user'),[verb,...rest]=args;
|
||||
fs.appendFileSync(path.join(sd,'calls.log'),args.join(' ')+'\n');
|
||||
const faultFile=(n)=>path.join(sd,'faults',n),fault=(n)=>fs.existsSync(faultFile(n));
|
||||
const stFile=path.join(sd,'units.json');
|
||||
const load=()=>{try{return JSON.parse(fs.readFileSync(stFile,'utf8'))}catch{return {}}};
|
||||
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 unitFile=(u)=>{
|
||||
const main=isBus(u)?u.replace(/@[^@]*$/,'@.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 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);
|
||||
const m=/^(WorkingDirectory|ExecStart|Environment)=(.*)$/.exec(line);if(!m)continue;
|
||||
if(m[1]==='WorkingDirectory')r.wd=m[2];else if(m[1]==='ExecStart'){if(m[2])r.exec=m[2]}else r.env.push(m[2]);
|
||||
}
|
||||
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={};
|
||||
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)=>{
|
||||
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 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 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){
|
||||
case 'none':break;
|
||||
case 'two':ok();line(`launched pm-${b}: run ${run}b, claude opus`);entry({});break;
|
||||
case 'badword':line(`launched pm-${b}: job ${run}, claude opus`);entry({});break;
|
||||
case 'notlisted':ok();break;
|
||||
case 'early':ok();entry({startedAt:new Date(now-3600e3).toISOString()});break;
|
||||
case 'human':entry({run:`human-${pid}`});break;
|
||||
case 'other':ok();entry({business:'other'});break;
|
||||
case 'dead':ok();entry({live:false});break;
|
||||
default:ok();entry({});
|
||||
}
|
||||
fs.writeFileSync(lf,JSON.stringify(list));
|
||||
};
|
||||
if(verb==='show'){
|
||||
const u=rest[0],props=[];for(let i=1;i<rest.length;i++)if(rest[i]==='-p')props.push(rest[++i]);
|
||||
const f=unitFile(u),st=load()[u]||{},id=f?path.basename(f.wd):'';
|
||||
for(const p of props){
|
||||
if(p==='LoadState')console.log(`LoadState=${f?'loaded':'not-found'}`);
|
||||
else if(p==='WorkingDirectory')console.log(`WorkingDirectory=${f?f.wd+(!isBus(u)&&fault(`check-${id}`)?'-moved':''):''}`);
|
||||
else if(p==='ExecStart')console.log(f&&f.exec?`ExecStart={ path=${f.exec.split(' ')[0]} ; argv[]=${f.exec} ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }`:'ExecStart=');
|
||||
else if(p==='Environment')console.log(`Environment=${f?f.env.join(' '):''}`);
|
||||
else if(p==='MainPID')console.log(`MainPID=${st.pid||0}`);
|
||||
else if(p==='DropInPaths')console.log(`DropInPaths=${f?f.drops.join(' '):''}`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
if(verb==='is-active'){
|
||||
const q=rest[0]==='--quiet',u=q?rest[1]:rest[0];let s=(load()[u]||{}).state||'inactive';
|
||||
// bus-stops-after N (T29): the next N bus checks answer the real state, every later one answers
|
||||
// inactive, and units.json stays as it is, as if the bus stopped between two checks.
|
||||
if(isBus(u)&&fault('bus-stops-after')){const n=Number(fs.readFileSync(faultFile('bus-stops-after'),'utf8'));if(n>0)fs.writeFileSync(faultFile('bus-stops-after'),String(n-1));else s='inactive'}
|
||||
if(!q)console.log(s);process.exit(s==='active'?0:3);
|
||||
}
|
||||
if(verb==='is-enabled'){console.log('disabled');process.exit(1)}
|
||||
if(verb==='daemon-reload')process.exit(0);
|
||||
if(verb==='list-units'){
|
||||
if(fault('list-fails'))process.exit(1);
|
||||
const pats=rest.filter((a)=>!a.startsWith('--')).map((p)=>new RegExp('^'+p.replace(/\./g,'\\.').replace(/\*/g,'.*')+'$'));
|
||||
for(const [u,s] of Object.entries(load()))if(pats.some((re)=>re.test(u)))console.log(`${u} loaded ${s.state} ${s.state==='active'?'running':'dead'} fixture`);
|
||||
process.exit(0);
|
||||
}
|
||||
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);
|
||||
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'));
|
||||
db.prepare('INSERT INTO events (at) VALUES (?)').run('2999-01-01T00:00:00.000Z');db.close();
|
||||
}
|
||||
snap('start',d);
|
||||
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'});
|
||||
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));
|
||||
process.exit(0);
|
||||
}
|
||||
if(verb==='stop'){
|
||||
const u=rest[0],s=load(),st=s[u];
|
||||
if(st&&st.pid){
|
||||
try{process.kill(st.pid,'SIGTERM')}catch{}
|
||||
const end=Date.now()+10000;while(alive(st.pid)&&Date.now()<end)sleep(20);
|
||||
if(alive(st.pid))try{process.kill(st.pid,'SIGKILL')}catch{}
|
||||
}
|
||||
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),'')}
|
||||
// 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'));
|
||||
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);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
console.error(`fixture systemctl: unsupported: ${args.join(' ')}`);process.exit(1);
|
||||
}
|
||||
|
||||
// journalctl --user -u UNIT --since @EPOCH -o cat. A bare local time gets exit 1, as M35 needs.
|
||||
function journalctl(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
|
||||
const sd=process.env.SD_STATE,a=process.argv.slice(2);let unit='',since='';
|
||||
for(let i=0;i<a.length;i++){if(a[i]==='-u')unit=a[++i];else if(a[i]==='--since')since=a[++i]}
|
||||
fs.appendFileSync(path.join(sd,'journal-since.log'),since+'\n');
|
||||
if(fs.existsSync(path.join(sd,'faults','journal-fails')))process.exit(1);
|
||||
let from;
|
||||
if(/^@\d+$/.test(since))from=Number(since.slice(1));
|
||||
else{const m=/^(\d{4}-\d\d-\d\d) (\d\d:\d\d:\d\d) UTC$/.exec(since);if(!m)process.exit(1);from=Date.parse(`${m[1]}T${m[2]}Z`)/1000}
|
||||
let lines=[];try{lines=fs.readFileSync(path.join(sd,'journal.jsonl'),'utf8').split('\n').filter(Boolean).map((l)=>JSON.parse(l))}catch{}
|
||||
for(const o of lines)if(o.unit===unit&&o.epoch>=from)console.log(o.text);
|
||||
}
|
||||
|
||||
function systemdAnalyze(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
|
||||
if(process.argv[2]!=='--user'||process.argv[3]!=='unit-paths')process.exit(1);
|
||||
console.log(path.join(process.env.XDG_CONFIG_HOME,'systemd','user'));
|
||||
// The unit-path fault adds its text as one more path, as T28's relative path needs.
|
||||
const extra=path.join(process.env.SD_STATE,'faults','unit-path');
|
||||
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.
|
||||
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');
|
||||
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){
|
||||
db.exec('PRAGMA journal_mode=WAL');
|
||||
db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
||||
db.exec('CREATE TABLE events (id INTEGER PRIMARY KEY, at TEXT NOT NULL)');
|
||||
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();
|
||||
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)});
|
||||
fs.writeFileSync(path.join(sd,`ready-${process.pid}`),'');
|
||||
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.
|
||||
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{}
|
||||
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{}
|
||||
console.log(JSON.stringify(l));
|
||||
}else{console.error(`fixture mosaic: ${a}`);process.exit(4)}
|
||||
}
|
||||
|
||||
// A copy's migrate-schema.mjs: records the backups it saw, then sets 3h, or 3h-partial and exit 1.
|
||||
function migrateStub(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),{DatabaseSync}=process.getBuiltinModule('node:sqlite');
|
||||
const data=process.argv[2],sd=process.env.SD_STATE,dir=path.join(data,'backups','stack');
|
||||
const backups=fs.existsSync(dir)?fs.readdirSync(dir).sort().map((name)=>({name,sums:fs.existsSync(path.join(dir,name,'SHA256SUMS'))})):[];
|
||||
fs.writeFileSync(path.join(sd,'migrate.json'),JSON.stringify({backups}));
|
||||
const fail=fs.existsSync(path.join(sd,'faults','migrate-fails'));
|
||||
const db=new DatabaseSync(path.join(data,'bus','bus.sqlite'));
|
||||
db.prepare("UPDATE meta SET value=? WHERE key='schema_version'").run(fail?'3h-partial':'3h');db.close();
|
||||
if(fail)process.exit(1);
|
||||
console.log('fixture migrate: 3h');
|
||||
}
|
||||
|
||||
// --- The module fixture: one repo, its copies and the stand-ins, shared by every test. ---
|
||||
|
||||
const base=realpathSync(mkdtempSync(join(tmpdir(),'stack-release-')));
|
||||
after(()=>{execFileSync('chmod',['-R','u+w',base]);rmSync(base,{recursive:true,force:true})});
|
||||
const repo=join(base,'repo'),COPIES=join(base,'copies'),SBIN=join(base,'sbin');
|
||||
mkdirSync(repo);mkdirSync(COPIES);mkdirSync(SBIN);
|
||||
writeFileSync(join(base,'package.json'),'{"type":"commonjs"}\n');
|
||||
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
|
||||
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
|
||||
const pkg={name:'copy-fixture',version:'1.0.0'};
|
||||
writeFileSync(join(repo,'package.json'),JSON.stringify(pkg));
|
||||
writeFileSync(join(repo,'package-lock.json'),JSON.stringify({name:pkg.name,version:pkg.version,lockfileVersion:3,packages:{'':pkg}}));
|
||||
git(repo,'init','-q','-b','refactor');git(repo,'config','gc.auto','0');git(repo,'add','.');git(repo,'commit','-qm','fixture');
|
||||
const commit=(msg)=>{git(repo,'commit','-q','--allow-empty','-m',msg);return git(repo,'rev-parse','HEAD')};
|
||||
// refactor: F A B C1 C, then M merging side branch S (from A). U sits on a branch nothing reaches.
|
||||
const F=git(repo,'rev-parse','HEAD'),A=commit('row 901: A'),B=commit('row 902: B'),C1=commit('row 903: C1'),C=commit('row 904: C');
|
||||
git(repo,'checkout','-q','-b','side',A);const S=commit('row 905: S');
|
||||
git(repo,'checkout','-q','-b','unreached',A);const U=commit('row 906: U');
|
||||
git(repo,'checkout','-q','refactor');git(repo,'merge','-q','--no-ff','-m','merge side','side');
|
||||
const M=git(repo,'rev-parse','HEAD');git(repo,'update-ref','refs/remotes/origin/refactor',M);
|
||||
// `to` false leaves out the `to:` lines (C1); a string puts that value in each one (A). The --plan
|
||||
// steps line can't be read from either (T27).
|
||||
const storeText=(v,froms,to=true)=>`export const SCHEMA_VERSION = '${v}';\nexport const MIGRATIONS = [\n${froms.map((f,i)=>` {\n from: '${f}',\n${to===false?'':` to: '${to===true?froms[i+1]??v:to}',\n`} run() {},\n },\n`).join('')}];\n`;
|
||||
// Plug-in kinds (row 169): C1 and C know the harness kind, as a copy at or past c216f47db does. The
|
||||
// rest have it under another name, as ca15d73e9 has no harness kind. Every copy has the real business
|
||||
// loader and roles, which the tool imports to read the business file.
|
||||
const KINDS_NEW=readFileSync(join(repo,'packages','plugins','src','kinds.mjs'),'utf8');
|
||||
const KINDS_OLD=KINDS_NEW.replace('\n harness: Object.freeze({','\n "harness-before-c216f47db": Object.freeze({');
|
||||
assert.notEqual(KINDS_OLD,KINDS_NEW);
|
||||
// Row 200 (#1698): C1 and C take --sso and have the row 192 loader and checkConsole; C's checker
|
||||
// refuses RS256, a stand-in for a target whose rules differ from the repository's. B has the loader
|
||||
// from before row 192, which refuses a `console` key. The other copies have no loader.
|
||||
const LOADER=readFileSync(join(repo,'scripts','mosaic-config.mjs'),'utf8'),SSO_CONFIG=readFileSync(join(repo,'packages','webui','src','sso-config.mjs'),'utf8');
|
||||
const LOADER_OLD=LOADER.replace('["configVersion", "environment", "dataRoot", "execution", "console"]','["configVersion", "environment", "dataRoot", "execution"]');
|
||||
const SSO_CONFIG_C=SSO_CONFIG.replace("'ES256', 'RS256',","'ES256',");
|
||||
assert.notEqual(LOADER_OLD,LOADER);assert.notEqual(SSO_CONFIG_C,SSO_CONFIG);
|
||||
const ssoFiles=(id)=>id===B?[['scripts/mosaic-config.mjs',LOADER_OLD]]:[C1,C].includes(id)?[['scripts/mosaic-config.mjs',LOADER],['packages/webui/src/sso-config.mjs',id===C?SSO_CONFIG_C:SSO_CONFIG]]:[];
|
||||
for(const [id,store] of [[F,'// a copy from before SCHEMA_VERSION\n'],[A,storeText('3g',['3f'],'3x')],[B,storeText('3g',['3f'])],[C1,storeText('3g',['3f'],false)],[C,storeText('3h',['3f','3g'])],[S,storeText('3g',['3f'])],[U,storeText('3g',['3f'])]]){
|
||||
const d=join(COPIES,id);
|
||||
for(const [f,text,mode] of [['scripts/mosaic',script(mosaicStub),0o755],['packages/webui/src/cli.mjs',[B,C1,C].includes(id)?`// fixture Console, never run\n// serve accepts '--tester-port'\n// serve accepts '--proof-header' (row 178)\n${[C1,C].includes(id)?"// serve accepts '--sso' (row 192)\n":''}`:'// fixture Console before tester listener, never run\n',0o644],['packages/bus/src/store.mjs',store,0o644],['packages/bus/src/migrate-schema.mjs',`(${migrateStub})();\n`,0o644],['packages/plugins/src/kinds.mjs',[C1,C].includes(id)?KINDS_NEW:KINDS_OLD,0o644],...ssoFiles(id).map(([f,text])=>[f,text,0o644])]){
|
||||
mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text,{mode});
|
||||
}
|
||||
for(const p of ['packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
|
||||
execFileSync('chmod',['-R','a-w',d]);
|
||||
}
|
||||
for(const [name,fn] of [['systemctl',systemctl],['journalctl',journalctl],['systemd-analyze',systemdAnalyze]])writeFileSync(join(SBIN,name),script(fn),{mode:0o755});
|
||||
writeFileSync(join(base,'holder.cjs'),`(${holder})();\n`);
|
||||
const SR=join(repo,'scripts','stack-release.sh');
|
||||
|
||||
// --- Per-test roots and helpers ---
|
||||
|
||||
const sha=(f)=>createHash('sha256').update(readFileSync(f)).digest('hex');
|
||||
const lines=(f)=>existsSync(f)?readFileSync(f,'utf8').split('\n').filter(Boolean):[];
|
||||
const calls=(e)=>lines(join(e.sd,'calls.log'));
|
||||
const startsStops=(e,from)=>calls(e).slice(from).filter((c)=>/^(start|stop) /.test(c));
|
||||
const logLines=(e)=>lines(e.log).map((l)=>JSON.parse(l));
|
||||
const pointer=(e)=>existsSync(e.pointer)?JSON.parse(readFileSync(e.pointer,'utf8')):null;
|
||||
const units=(e)=>JSON.parse(readFileSync(join(e.sd,'units.json'),'utf8'));
|
||||
const snaps=(e)=>lines(join(e.sd,'snap.jsonl')).map((l)=>JSON.parse(l));
|
||||
const fault=(e,name,text='')=>writeFileSync(join(e.sd,'faults',name),text);
|
||||
const sysctl=(e,...a)=>execFileSync(join(SBIN,'systemctl'),['--user',...a],{env:e.env,encoding:'utf8'});
|
||||
const edit=(f,from,to)=>{const s=readFileSync(f,'utf8');assert.ok(s.includes(from),`${f} has no ${from}`);writeFileSync(f,s.replace(from,to))};
|
||||
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)});
|
||||
// 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});
|
||||
try{return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value}finally{db.close()}
|
||||
}
|
||||
// Every file and directory under p with size and mtime: T23 and T16 compare it before and after.
|
||||
function tree(p){
|
||||
const out=[];
|
||||
const walk=(q)=>{let st;try{st=statSync(q)}catch{return}out.push(st.isDirectory()?`${q}/`:`${q} ${st.size} ${st.mtimeMs}`);if(st.isDirectory())for(const n of readdirSync(q).sort())walk(join(q,n))};
|
||||
walk(p);return out;
|
||||
}
|
||||
// T17: what a run may never remove.
|
||||
const kept=(e)=>[...tree(join(e.data,'backups','stack')),...tree(join(e.state,'stack-rollback')),...posts(e).flatMap((n)=>tree(join(e.data,n))),...tree(COPIES)].map((l)=>l.replace(/ .*/,''));
|
||||
|
||||
// The business file (row 169): the committed example made loadable, as acme, with `plugins` when given.
|
||||
const EXAMPLE=readFileSync(join(repo,'packages','business','examples','mosaic-stack.example.json'),'utf8');
|
||||
function biz(e,plugins){
|
||||
const b=JSON.parse(EXAMPLE);let n=0;
|
||||
const walk=(o)=>{for(const [k,v] of Object.entries(o)){if(k==='botId')o[k]=++n;else if(v==='YYYY-MM-DD')o[k]='2027-01-01';else if(v&&typeof v==='object')walk(v)}};
|
||||
walk(b);b.id='acme';if(plugins!==undefined)b.plugins=plugins;
|
||||
const f=join(dirname(e.cfg),'businesses','acme.json');mkdirSync(dirname(f),{recursive:true});
|
||||
writeFileSync(f,JSON.stringify(b,null,2)+'\n',{mode:0o600});chmodSync(f,0o600);
|
||||
}
|
||||
|
||||
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);
|
||||
writeFileSync(e.cfg,JSON.stringify({configVersion:1,environment:'development',dataRoot:e.data,execution:{backend:'docker',provider:'zai',model:'glm-5.3-flash',adapter:'mock'}}));
|
||||
biz(e);
|
||||
writeFileSync(join(e.bin,'mosaic'),`#!/usr/bin/env bash\n${MARKER}\n[ "\${1:-}" = __lease-capability ] && exec "${e.bin}/mosaic-old" "$@"\nexec "/old/scripts/mosaic" "$@"\n`,{mode:0o755});
|
||||
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){
|
||||
const server=createServer((q,r)=>{
|
||||
let st={};try{st=units(e)[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;
|
||||
}
|
||||
|
||||
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']});
|
||||
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);
|
||||
const now=new Set(kept(e));
|
||||
for(const p of before)assert.ok(now.has(p),`T17: ${args.join(' ')} removed ${p}`);
|
||||
return {code,out,err};
|
||||
}
|
||||
|
||||
// The stack on copy `copy` for business acme, both units active. pointer: adopt (a real adopt and
|
||||
// its log line), hand (written here, no log) or none. Snapshots start after the seed.
|
||||
async function seed(e,{copy=B,pm=false,store='3g',pointer:how='adopt'}={}){
|
||||
const db=new DatabaseSync(e.store);
|
||||
db.exec('PRAGMA journal_mode=WAL');
|
||||
db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
||||
db.exec('CREATE TABLE events (id INTEGER PRIMARY KEY, at TEXT NOT NULL)');
|
||||
db.prepare("INSERT INTO meta VALUES ('schema_version', ?)").run(store);
|
||||
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);
|
||||
// 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);
|
||||
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});
|
||||
writeFileSync(e.pointer,JSON.stringify({copy,business:'acme',pm,claude:e.claude,console:['--port',e.port,'--business','acme'],schema:'3g',activation:'manual',at:'2026-10-18T00:00:00Z'})+'\n');
|
||||
}
|
||||
writeFileSync(join(e.sd,'snap.jsonl'),'');
|
||||
}
|
||||
|
||||
const rolledBack=(code,to=B)=>`stack-release: ${code}; rolled back to ${to}, which passed the gate\n`;
|
||||
const planBackup=(e)=>`backup: the run backs up the stopped store to ${e.data}/backups/stack/<activation>/ before any migration`;
|
||||
const planNoBackup='backup: none; the run rolls back before the backup';
|
||||
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off','send: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
|
||||
import {A,B,BUS,C,C1,CON,COPIES,EXAMPLE,F,KINDS_NEW,KINDS_OLD,M,MARKER,NODE,ROOT,S,SBIN,SR,backupSum,base,biz,calls,commit,commitLines,edit,fault,fp,git,healthz,holder,journalctl,kept,lines,logLines,migrateStub,mk,mosaicStub,named,pkg,planBackup,planNoBackup,planTail,pointer,posts,repo,rolledBack,run,script,seed,sha,snaps,startsStops,storeText,storeVersion,sysctl,systemctl,systemdAnalyze,tracked,tree,U,units} from './stack-release-fixture.mjs';
|
||||
|
||||
// --- The T table ---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"prd": "docs/prd/mosaic-stack.md",
|
||||
"version": "1.6",
|
||||
"version": "1.8",
|
||||
"requirements": [
|
||||
{ "id": "REQ-ROLE-1", "title": "Each role is a reviewed roles/<name>.json: contract, authority map, credentials" },
|
||||
{ "id": "REQ-ROLE-2", "title": "One holder per role instance, one per thread for the assistant; the chief of staff may end a run, others need a decision" },
|
||||
@@ -13,16 +13,25 @@
|
||||
{ "id": "REQ-ASSIST-1", "title": "The assistant is the user's main agent and passes organization work to the chief of staff" },
|
||||
{ "id": "REQ-ASSIST-2", "title": "The broker limits the assistant to the user and the chief of staff in both directions; refusals logged" },
|
||||
{ "id": "REQ-ASSIST-3", "title": "The assistant tells the user what other roles sent it in its own words and does not relay their text" },
|
||||
{ "id": "REQ-COS-3", "title": "The chief of staff hires and fires role instances without a decision, within fixed bounds; the user forbids per agent" },
|
||||
{ "id": "REQ-VAR-1", "title": "Variables in four layers, declared in code; limits intersect; unknown keys refused" },
|
||||
{ "id": "REQ-VAR-2", "title": "One system config, never auto-written; secrets only as references the broker reads" },
|
||||
{ "id": "REQ-CRED-1", "title": "Each role instance has its own Gitea and Vikunja token, held by the broker" },
|
||||
{ "id": "REQ-CRED-2", "title": "A session that finds only founder credentials stops" },
|
||||
{ "id": "REQ-CRED-3", "title": "An agent with a grant runs sudo commands on another host through the credential store and never sees the secret" },
|
||||
{ "id": "REQ-CRED-4", "title": "A within-role grant action needs no decision only if kind declared, grant, live key or token, any command allowlisted" },
|
||||
{ "id": "REQ-GRANT-1", "title": "No agent creates a grant; only the chief of staff raises a grant request, as a credential.mint hard wait" },
|
||||
{ "id": "REQ-GRANT-2", "title": "Only the user's Approve creates a grant; protected stack code does every step, bound to the exact rights" },
|
||||
{ "id": "REQ-GRANT-3", "title": "The user enters their own tokens in the WebUI or the CLI; only the grant code reads them and no agent sees a value" },
|
||||
{ "id": "REQ-GRANT-4", "title": "The user, the assistant in its organization or the tester on a dev instance revokes a grant at once" },
|
||||
{ "id": "REQ-DEC-1", "title": "Every decision is an append-only record: raiser, class, options, resolver, times" },
|
||||
{ "id": "REQ-DEC-2", "title": "Decisions route by class: logged, to the domain arbiter, or to the human" },
|
||||
{ "id": "REQ-DEC-3", "title": "Authorized-human resolutions come only from a mosaic CLI session outside any agent run" },
|
||||
{ "id": "REQ-DEC-4", "title": "A blocking gated decision reaches an authorized human at once; the rest go in a daily digest" },
|
||||
{ "id": "REQ-DEC-5", "title": "An approval outlives its run, binds to the decision, instance and thread, and is consumed once" },
|
||||
{ "id": "REQ-DEC-6", "title": "A spec commit never reopens a settled decision" },
|
||||
{ "id": "REQ-TRUST-1", "title": "Agents act within their role guardrails without asking; a within-role action is done at will and logged" },
|
||||
{ "id": "REQ-TRUST-2", "title": "The user puts chosen actions in manual mode, so each use becomes a decision the user resolves" },
|
||||
{ "id": "REQ-WAIT-1", "title": "Hard waits always go to the human: spend, delete, irreversible, exposure, accounts, external messages, protected push" },
|
||||
{ "id": "REQ-WAIT-2", "title": "No role file grants a hard wait; changing the list or granting a listed action is itself a hard wait" },
|
||||
{ "id": "REQ-WAIT-3", "title": "An action added to the vocabulary is a hard wait until a PRD version says it is not" },
|
||||
@@ -37,6 +46,7 @@
|
||||
{ "id": "REQ-PROJ-3", "title": "The Console edits the spec and docs Markdown as source with a preview; a stale save is refused" },
|
||||
{ "id": "REQ-PROJ-4", "title": "Each human spec commit saved in the Console sends one notice to the PM and the CTO for review" },
|
||||
{ "id": "REQ-LAND-1", "title": "A resolved merge decision lands into the working repository main through the host, only for a granted role" },
|
||||
{ "id": "REQ-LAND-2", "title": "A coder pushes one recorded work commit by oid to its own fork, on a host-built branch, never forced" },
|
||||
{ "id": "REQ-CLI-1", "title": "mosaic is the front door: talk to the assistant in a thread, the inbox, tasks, agents and trails" },
|
||||
{ "id": "REQ-CLI-2", "title": "The assistant session mosaic talks to is launched and owned by the stack, not T3" },
|
||||
{ "id": "REQ-WEB-1", "title": "The WebUI shows the CLI's data, a chat area of projects and threads, and Settings for agent limits" },
|
||||
|
||||
@@ -18,10 +18,10 @@ test("the list holds the PRD's requirement ids, in its order, at its version", (
|
||||
const ids = [...prd.matchAll(/^- \*\*(REQ-[A-Z]+-[0-9]+)\.\*\*/gm)].map((m) => m[1]);
|
||||
// Any other mention of a bold id would be a bullet this pattern missed.
|
||||
assert.equal(ids.length, [...prd.matchAll(/\*\*REQ-/g)].length);
|
||||
assert.equal(ids.length, 58);
|
||||
assert.equal(ids.length, 68);
|
||||
assert.deepEqual(list.requirements.map((r) => r.id), ids);
|
||||
assert.match(prd, new RegExp(`^- Status: approved, version ${list.version.replace('.', '\\.')}\\.`, 'm'));
|
||||
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.6']);
|
||||
assert.deepEqual([list.prd, list.version], ['docs/prd/mosaic-stack.md', '1.8']);
|
||||
});
|
||||
|
||||
test('the committed list loads frozen', () => {
|
||||
@@ -74,7 +74,7 @@ test('the schema refuses any other shape', () => {
|
||||
const twice = raw();
|
||||
twice.requirements.push(structuredClone(twice.requirements[0]));
|
||||
writeFileSync(join(dir, 'twice.json'), JSON.stringify(twice));
|
||||
assert.throws(() => loadRequirements(join(dir, 'twice.json')), listError('requirements[58].id: REQ-ROLE-1 twice'));
|
||||
assert.throws(() => loadRequirements(join(dir, 'twice.json')), listError('requirements[68].id: REQ-ROLE-1 twice'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Executable
+241
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env bash
|
||||
# Case 2 row C2-13a (design 10 step 8): the host checks a hub needs before
|
||||
# its units start. It runs as <user>, after root's 2c step, and changes
|
||||
# nothing. It runs every check, prints one line for each, and refuses if
|
||||
# any one failed.
|
||||
#
|
||||
# case2-precheck.sh --business ID [--profile factory|library]
|
||||
#
|
||||
# Checks: the config and business file validate; `sudo -n true` fails and
|
||||
# `id -G` prints one group; bus.tester is off for library and on for factory,
|
||||
# and factory refuses a business file with a library project
|
||||
# (profile-library-business); the run root is 0700; XDG_DATA_HOME is unset or
|
||||
# <home>/.local/share; node is 24 or later; bwrap runs; the unprivileged port
|
||||
# floor is above the installed Console unit's --port; the host's claude
|
||||
# replaces its default prompt with --system-prompt-file, when some instance
|
||||
# runs claude-code; and claude-session.env sets ZAI_API_KEY, when some
|
||||
# instance's provider is zai. It prints free memory and checks that HOME is
|
||||
# <home>. It never prints a value from claude-session.env.
|
||||
#
|
||||
# Test seams, for packages/cli/tests/case2-unit.test.mjs only:
|
||||
# CASE2_UNIT_DIR the installed units, default /etc/systemd/system
|
||||
# CASE2_PROC_SYS default /proc/sys
|
||||
# CASE2_GETENT the getent command, default getent
|
||||
#
|
||||
# Exit 0 every check passed, 1 refused, 4 usage.
|
||||
set -uo pipefail
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
UNIT_DIR="${CASE2_UNIT_DIR:-/etc/systemd/system}"
|
||||
PROC_SYS="${CASE2_PROC_SYS:-/proc/sys}"
|
||||
GETENT="${CASE2_GETENT:-getent}"
|
||||
BUS_UNIT="$UNIT_DIR/[email protected]"
|
||||
WEBUI_UNIT="$UNIT_DIR/mosaic-webui.service"
|
||||
# The most input tokens a marker run may report with its prompt replaced.
|
||||
# Claude Code's own prompt is many times this.
|
||||
MARKER_TOKEN_CEILING=2000
|
||||
|
||||
usage() { echo 'usage: case2-precheck.sh --business ID [--profile factory|library]' >&2; exit 4; }
|
||||
PROFILE=library BUSINESS=''
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--profile) [ $# -ge 2 ] || usage; PROFILE="$2"; shift 2 ;;
|
||||
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$BUSINESS" ] || usage
|
||||
case "$PROFILE" in factory|library) ;; *) echo "case2-precheck: --profile must be factory or library: $PROFILE" >&2; exit 4 ;; esac
|
||||
[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] || { echo "case2-precheck: --business must be a business id: $BUSINESS" >&2; exit 4; }
|
||||
|
||||
FAILED=0
|
||||
ok() { echo "ok $1"; }
|
||||
refuse() { echo "REFUSED $1"; FAILED=1; }
|
||||
|
||||
# One setting's value from an installed unit: the last KEY= line in the file.
|
||||
unit_value() { [ -f "$1" ] && sed -n "s/^[[:space:]]*$2[[:space:]]*=[[:space:]]*//p" -- "$1" | tail -n 1; }
|
||||
|
||||
# The last assignment of NAME in an EnvironmentFile, quotes removed, as
|
||||
# systemd reads it. Prints the value; the callers never echo it.
|
||||
env_value() {
|
||||
awk -v name="$2" '
|
||||
{ sub(/^[ \t]+/, "") }
|
||||
/^[#;]/ || $0 == "" { next }
|
||||
{
|
||||
i = index($0, "="); if (i == 0) next
|
||||
k = substr($0, 1, i - 1); sub(/[ \t]+$/, "", k)
|
||||
if (k != name) next
|
||||
v = substr($0, i + 1); sub(/^[ \t]+/, "", v); sub(/[ \t]+$/, "", v)
|
||||
if (v ~ /^".*"$/ || v ~ /^\x27.*\x27$/) v = substr(v, 2, length(v) - 2)
|
||||
last = v; found = 1
|
||||
}
|
||||
END { if (found) printf "%s", last }' "$1"
|
||||
}
|
||||
|
||||
# The user and its home from the passwd database.
|
||||
ME="$(id -un)"
|
||||
ME_UID="$(id -u)"
|
||||
PW="$("$GETENT" passwd "$ME")" || { refuse "user: no $ME in the passwd database"; PW=''; }
|
||||
IFS=: read -r _ _ _ _ _ PW_HOME _ <<<"$PW"
|
||||
if [ -n "${PW_HOME:-}" ] && [ "${HOME:-}" = "$PW_HOME" ]; then ok "HOME is $PW_HOME"
|
||||
else refuse "HOME is ${HOME:-unset}, not $ME's home ${PW_HOME:-unknown}"; fi
|
||||
HOME_DIR="${PW_HOME:-$HOME}"
|
||||
|
||||
# The config, then the business file through the same loaders the bus uses.
|
||||
# The helper prints tab-separated facts and never a credential.
|
||||
facts="$(cd "$REPO" && node --input-type=module -e '
|
||||
import { readFileSync } from "node:fs";
|
||||
const [repo, id] = process.argv.slice(1);
|
||||
const { loadSystem, rolesDir } = await import(`${repo}/packages/cli/src/config.mjs`);
|
||||
const { businessFilePath, configDir, loadBusiness, resolveInstance, systemVars } = await import(`${repo}/packages/business/src/index.mjs`);
|
||||
const say = (...f) => console.log(f.join("\t"));
|
||||
let system;
|
||||
try { system = loadSystem(); } catch (e) { say("CONFIG", "bad", e.message.split("\n")[0]); process.exit(0); }
|
||||
say("CONFIG", "ok", system.dataRoot, configDir());
|
||||
const file = businessFilePath(id);
|
||||
let raw;
|
||||
try { raw = JSON.parse(readFileSync(file, "utf8")); } catch (e) { say("BUSINESS", "bad", `cannot read ${file}: ${e.code ?? e.message}`); process.exit(0); }
|
||||
const library = Object.entries(raw?.projects ?? {}).filter(([, p]) => p && p.kind === "library").map(([k]) => k);
|
||||
if (library.length) say("LIBRARY", library.join(","));
|
||||
let b;
|
||||
try { b = loadBusiness(id, { rolesDir: rolesDir() }); } catch (e) { say("BUSINESS", "bad", e.message.split("\n")[0]); process.exit(0); }
|
||||
say("BUSINESS", "ok", file);
|
||||
say("TESTER", b.vars["bus.tester"] === true ? "on" : "off");
|
||||
const vars = systemVars(system);
|
||||
const models = b.launch?.models ?? null;
|
||||
for (const instance of Object.keys(b.roles)) {
|
||||
const v = resolveInstance({ system: vars, business: b, instance }).vars;
|
||||
for (const [h, m] of [["harness", "model"], ["fallback.harness", "fallback.model"]]) {
|
||||
if (typeof v[h] !== "string") continue;
|
||||
const provider = models && Object.hasOwn(models, v[m]) ? models[v[m]].provider : "";
|
||||
say("ROUTE", instance, v[h], provider);
|
||||
}
|
||||
}
|
||||
' "$REPO" "$BUSINESS" 2>&1)" || true
|
||||
|
||||
line() { printf '%s\n' "$facts" | awk -F'\t' -v k="$1" '$1 == k' ; }
|
||||
cfg="$(line CONFIG)"
|
||||
DATA_ROOT='' CONFIG_DIR=''
|
||||
case "$cfg" in
|
||||
"CONFIG ok "*) IFS=$'\t' read -r _ _ DATA_ROOT CONFIG_DIR <<<"$cfg"; ok "config validates (data root $DATA_ROOT)" ;;
|
||||
"CONFIG bad "*) refuse "config: ${cfg#CONFIG bad }" ;;
|
||||
*) refuse "config: the check did not run: $(printf '%s' "$facts" | head -n 1)" ;;
|
||||
esac
|
||||
|
||||
# Tester and profile.
|
||||
library="$(line LIBRARY | cut -f2)"
|
||||
biz="$(line BUSINESS)"
|
||||
if [ "$PROFILE" = factory ] && [ -n "$library" ]; then
|
||||
refuse "profile-library-business: the factory profile refuses business $BUSINESS, whose projects $library are library projects"
|
||||
elif [ -n "$cfg" ] && [ "${cfg#CONFIG ok}" != "$cfg" ]; then
|
||||
case "$biz" in
|
||||
"BUSINESS ok "*)
|
||||
ok "business file ${biz#BUSINESS ok } validates"
|
||||
tester="$(line TESTER | cut -f2)"
|
||||
if [ "$PROFILE" = library ] && [ "$tester" = on ]; then refuse "bus.tester is on; the library profile needs it off"
|
||||
elif [ "$PROFILE" = factory ] && [ "$tester" != on ]; then refuse "bus.tester is off; the factory profile needs it on"
|
||||
else ok "bus.tester is $tester"; fi ;;
|
||||
*) refuse "business file: ${biz#BUSINESS bad }" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# No sudo, and one group.
|
||||
if sudo -n true >/dev/null 2>&1; then refuse "sudo -n true succeeds as $ME"; else ok "sudo -n true fails as $ME"; fi
|
||||
groups="$(id -G)"
|
||||
if [ "$(printf '%s\n' "$groups" | wc -w)" -eq 1 ]; then ok "id -G prints one group ($groups)"
|
||||
else refuse "id -G prints more than one group: $groups"; fi
|
||||
|
||||
# The run root (packages/seat/src/session.mjs runsDir). newRun makes it
|
||||
# 0700, so before the first launch it may not exist yet.
|
||||
if [ -n "$DATA_ROOT" ]; then
|
||||
runs="$DATA_ROOT/launches/$BUSINESS"
|
||||
if [ -L "$runs" ]; then refuse "run root $runs is a symlink"
|
||||
elif [ ! -e "$runs" ]; then ok "run root $runs does not exist yet; the first launch makes it 0700"
|
||||
elif [ "$(stat -c '%a' -- "$runs")" = 700 ] && [ -d "$runs" ]; then ok "run root $runs is 0700"
|
||||
else refuse "run root $runs is not a 0700 directory (mode $(stat -c '%a' -- "$runs"))"; fi
|
||||
fi
|
||||
|
||||
# XDG_DATA_HOME, which copy_root reads (scripts/stack-copy.sh:8).
|
||||
if [ -z "${XDG_DATA_HOME+x}" ] || [ "$XDG_DATA_HOME" = "$HOME_DIR/.local/share" ]; then ok "XDG_DATA_HOME is ${XDG_DATA_HOME:-unset}"
|
||||
else refuse "XDG_DATA_HOME is $XDG_DATA_HOME; the unit runs the copy under $HOME_DIR/.local/share"; fi
|
||||
|
||||
# node 24 or later, every package's engines field.
|
||||
nv="$(node --version 2>/dev/null)" || nv=''
|
||||
major="${nv#v}"; major="${major%%.*}"
|
||||
if [[ "$major" =~ ^[0-9]+$ ]] && [ "$major" -ge 24 ]; then ok "node $nv"
|
||||
else refuse "node ${nv:-not found} is older than 24"; fi
|
||||
|
||||
# bwrap, as a session needs it.
|
||||
if bwrap --unshare-all --ro-bind / / true >/dev/null 2>&1; then ok "bwrap --unshare-all runs"
|
||||
else
|
||||
userns="$(cat "$PROC_SYS/kernel/apparmor_restrict_unprivileged_userns" 2>/dev/null || echo absent)"
|
||||
where="$(command -v bwrap || echo 'not on PATH')"
|
||||
refuse "bwrap --unshare-all --ro-bind / / true failed; bwrap: $where; kernel.apparmor_restrict_unprivileged_userns: $userns"
|
||||
fi
|
||||
|
||||
# The port floor, against the installed Console unit's --port (design 9.3).
|
||||
floor="$(cat "$PROC_SYS/net/ipv4/ip_unprivileged_port_start" 2>/dev/null)" || floor=''
|
||||
port="$(unit_value "$WEBUI_UNIT" ExecStart | awk '{ for (i = 1; i < NF; i++) if ($i == "--port") p = $(i + 1) } END { print p }')"
|
||||
if [ ! -f "$WEBUI_UNIT" ]; then refuse "no installed Console unit $WEBUI_UNIT"
|
||||
elif ! [[ "$port" =~ ^[0-9]+$ ]]; then refuse "$WEBUI_UNIT has no --port in its ExecStart"
|
||||
elif ! [[ "$floor" =~ ^[0-9]+$ ]]; then refuse "cannot read net.ipv4.ip_unprivileged_port_start"
|
||||
elif [ "$floor" -le "$port" ]; then refuse "net.ipv4.ip_unprivileged_port_start is $floor, at or below the Console's port $port, so any $ME process can bind it"
|
||||
else ok "net.ipv4.ip_unprivileged_port_start $floor is above the Console's port $port"; fi
|
||||
|
||||
routes="$(line ROUTE)"
|
||||
SESSION_ENV="${CONFIG_DIR:-$HOME_DIR/.config/mosaic-dev}/secrets/claude-session.env"
|
||||
# The session env file, checked as the bus unit's EnvironmentFile.
|
||||
env_file_ok() {
|
||||
[ ! -L "$SESSION_ENV" ] && [ -f "$SESSION_ENV" ] && [ "$(stat -c '%u' -- "$SESSION_ENV")" = "$ME_UID" ] && [ "$(stat -c '%a' -- "$SESSION_ENV")" = 600 ]
|
||||
}
|
||||
|
||||
# Claude Code replaces its prompt with --system-prompt-file.
|
||||
if printf '%s\n' "$routes" | awk -F'\t' '$3 == "claude-code" { f = 1 } END { exit !f }'; then
|
||||
claude="$(unit_value "$BUS_UNIT" Environment | sed -n 's/^MOSAIC_CLAUDE_BIN=//p')"
|
||||
claude="${claude:-$(command -v claude || true)}"
|
||||
if [ -z "$claude" ] || [ ! -x "$claude" ]; then refuse "an instance runs claude-code and no claude is installed"
|
||||
else
|
||||
echo " claude $claude: $("$claude" --version 2>/dev/null | head -n 1)"
|
||||
work="$(mktemp -d)"
|
||||
marker="case2-marker-$(od -An -N6 -tx1 /dev/urandom | tr -d ' \n')"
|
||||
printf 'Reply with exactly this text and nothing else: %s\n' "$marker" >"$work/prompt.md"
|
||||
out="$(cd "$work" && (
|
||||
if env_file_ok; then
|
||||
while IFS= read -r l; do
|
||||
case "$l" in [A-Za-z_]*=*) k="${l%%=*}"; [[ "$k" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] && export "$k=$(env_value "$SESSION_ENV" "$k")" ;; esac
|
||||
done <"$SESSION_ENV"
|
||||
fi
|
||||
timeout 180 "$claude" -p --system-prompt-file "$work/prompt.md" --tools "" --setting-sources "" \
|
||||
--no-session-persistence --output-format json "Reply now." 2>/dev/null
|
||||
))" || out=''
|
||||
rm -rf -- "$work"
|
||||
verdict="$(printf '%s' "$out" | node -e '
|
||||
let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => {
|
||||
const [marker, ceiling] = process.argv.slice(1);
|
||||
let r; try { r = JSON.parse(s); } catch { console.log("noreply"); return; }
|
||||
const u = r.usage ?? {};
|
||||
const n = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
|
||||
if (!String(r.result ?? "").includes(marker)) console.log(`nomarker ${n}`);
|
||||
else console.log(`${n <= Number(ceiling) ? "replaced" : "appended"} ${n}`);
|
||||
});' "$marker" "$MARKER_TOKEN_CEILING")"
|
||||
case "$verdict" in
|
||||
replaced\ *) ok "claude replaces its prompt with --system-prompt-file (${verdict#replaced } input tokens)" ;;
|
||||
appended\ *) refuse "claude adds to its own prompt with --system-prompt-file: ${verdict#appended } input tokens, more than $MARKER_TOKEN_CEILING" ;;
|
||||
nomarker\ *) refuse "claude's reply to the marker prompt lacks the marker (${verdict#nomarker } input tokens)" ;;
|
||||
*) refuse "claude gave no JSON reply to the marker prompt" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
# ZAI_API_KEY, when some instance's provider is zai (design 10 step 2d).
|
||||
if printf '%s\n' "$routes" | awk -F'\t' '$4 == "zai" { f = 1 } END { exit !f }'; then
|
||||
if [ -L "$SESSION_ENV" ]; then refuse "$SESSION_ENV is a symlink"
|
||||
elif [ ! -f "$SESSION_ENV" ]; then refuse "an instance's provider is zai and $SESSION_ENV is missing"
|
||||
elif ! env_file_ok; then refuse "$SESSION_ENV must be owned by $ME at 0600 (mode $(stat -c '%a' -- "$SESSION_ENV"))"
|
||||
elif [ -z "$(env_value "$SESSION_ENV" ZAI_API_KEY)" ]; then refuse "$SESSION_ENV sets no nonempty ZAI_API_KEY"
|
||||
else ok "$SESSION_ENV sets ZAI_API_KEY"; fi
|
||||
fi
|
||||
|
||||
echo " free memory: $(awk '$1 == "MemAvailable:" { printf "%d MiB", $2 / 1024 }' /proc/meminfo)"
|
||||
if [ "$FAILED" = 0 ]; then echo "case2-precheck: every check passed"; exit 0; fi
|
||||
echo "case2-precheck: refused"
|
||||
exit 1
|
||||
Executable
+425
@@ -0,0 +1,425 @@
|
||||
#!/usr/bin/env bash
|
||||
# Case 2 row C2-13a (design 9.3, 10 step 2c): render and install the two
|
||||
# system units a hub runs as, [email protected] (enabled as
|
||||
# mosaic-bus@<business>.service) and mosaic-webui.service, from the existing
|
||||
# templates, which it doesn't change.
|
||||
#
|
||||
# case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH
|
||||
# [--profile factory|library] [--pm] [--claude PATH]
|
||||
# -- SERVE FLAGS
|
||||
#
|
||||
# Root runs it from a root-owned checkout of the reviewed commit outside
|
||||
# <home>, never from the release, because <user> can write the release. It
|
||||
# writes both units under /etc/systemd/system as root:root 0644, runs
|
||||
# `systemctl daemon-reload`, enables both, then starts the bus and then the
|
||||
# Console (Q189). Both come back after a boot on their own. It never links
|
||||
# a unit file into place, and it opens no file under <home>: it only stats the
|
||||
# copy, its scripts/mosaic and the proof file. It renders the default
|
||||
# instance only.
|
||||
#
|
||||
# The library profile takes exactly these serve flags, each once:
|
||||
# --port N (1..1023) --business ID (the unit's) --public-host NAME
|
||||
# --auth-header HEADER --allow-user ID --send
|
||||
# --proof-header NAME --proof-file PATH
|
||||
# The factory profile takes --port N (1..1023) and --business ID, and an
|
||||
# optional --tester-port N (1024..65535, never --port). Only factory takes
|
||||
# --pm and --claude.
|
||||
#
|
||||
# On a re-install the installed units must equal the render in everything
|
||||
# but the copy id, and both must name one copy, so a re-pin or rollback
|
||||
# re-renders the pair together. A unit that was already running stays on its
|
||||
# old copy, since `systemctl start` leaves it alone; the script says so and
|
||||
# that the bus and Console must restart together, and restarts nothing.
|
||||
#
|
||||
# Test seams, for packages/cli/tests/case2-unit.test.mjs only:
|
||||
# CASE2_UNIT_DIR the unit directory, default /etc/systemd/system;
|
||||
# the root and root-owned checks apply only there
|
||||
# CASE2_SYSTEMCTL the systemctl command, default systemctl
|
||||
# CASE2_GETENT the getent command, default getent
|
||||
# CASE2_BUS_TEMPLATE default packages/cli/systemd/mosaic-bus.service.in
|
||||
# CASE2_WEBUI_TEMPLATE default packages/webui/systemd/mosaic-webui.service.in
|
||||
#
|
||||
# Exit 0 done, 1 refused, 4 usage.
|
||||
set -euo pipefail
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DEFAULT_UNIT_DIR=/etc/systemd/system
|
||||
UNIT_DIR="${CASE2_UNIT_DIR:-$DEFAULT_UNIT_DIR}"
|
||||
SYSTEMCTL="${CASE2_SYSTEMCTL:-systemctl}"
|
||||
GETENT="${CASE2_GETENT:-getent}"
|
||||
BUS_TEMPLATE="${CASE2_BUS_TEMPLATE:-$REPO/packages/cli/systemd/mosaic-bus.service.in}"
|
||||
WEBUI_TEMPLATE="${CASE2_WEBUI_TEMPLATE:-$REPO/packages/webui/systemd/mosaic-webui.service.in}"
|
||||
BUS_UNIT=[email protected]
|
||||
WEBUI_UNIT=mosaic-webui.service
|
||||
|
||||
usage() { echo 'usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--pm] [--claude PATH] -- SERVE FLAGS' >&2; exit 4; }
|
||||
die() { echo "case2-unit: $*" >&2; exit 1; }
|
||||
# A value the unit carries as itself: no space, %, $, quote, backslash, & or |.
|
||||
plain() { case "$1" in *[!A-Za-z0-9/._+-]*) return 1 ;; esac; }
|
||||
abs_plain() {
|
||||
case "$1" in /*) ;; *) die "$2 must be an absolute path: $1" ;; esac
|
||||
plain "$1" || die "$2 contains a character the unit cannot carry: $1"
|
||||
case "$1/" in */./*|*/../*|?*//*) die "$2 must be a normalized path: $1" ;; esac
|
||||
[ "$1" = / ] || [ "${1%/}" = "$1" ] || die "$2 must not end in /: $1"
|
||||
}
|
||||
|
||||
PROFILE=library USER_NAME='' HOME_DIR='' BUSINESS='' COPY_ID='' NODE='' PM=0 CLAUDE='' CLAUDE_SET=0
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--profile|--user|--home|--business|--copy|--node|--claude)
|
||||
[ $# -ge 2 ] || usage
|
||||
case "$1" in
|
||||
--profile) PROFILE="$2" ;; --user) USER_NAME="$2" ;; --home) HOME_DIR="$2" ;;
|
||||
--business) BUSINESS="$2" ;; --copy) COPY_ID="$2" ;; --node) NODE="$2" ;;
|
||||
--claude) CLAUDE="$2"; CLAUDE_SET=1 ;;
|
||||
esac
|
||||
shift 2 ;;
|
||||
--pm) PM=1; shift ;;
|
||||
--) shift; break ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
SERVE=("$@")
|
||||
[ -n "$USER_NAME" ] && [ -n "$HOME_DIR" ] && [ -n "$BUSINESS" ] && [ -n "$COPY_ID" ] && [ -n "$NODE" ] || usage
|
||||
case "$PROFILE" in factory|library) ;; *) die "--profile must be factory or library: $PROFILE" ;; esac
|
||||
if [ "$PROFILE" = library ]; then
|
||||
[ "$PM" = 0 ] || die "the library profile refuses --pm"
|
||||
[ "$CLAUDE_SET" = 0 ] || die "the library profile refuses --claude"
|
||||
fi
|
||||
|
||||
if [ "$UNIT_DIR" = "$DEFAULT_UNIT_DIR" ]; then
|
||||
[ "$(id -u)" = 0 ] || die "writing $DEFAULT_UNIT_DIR needs root"
|
||||
fi
|
||||
|
||||
# The user, its uid, primary group and home, from the passwd database.
|
||||
[[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "--user must be a user name: $USER_NAME"
|
||||
PW="$("$GETENT" passwd "$USER_NAME")" || die "no user $USER_NAME in the passwd database"
|
||||
IFS=: read -r _ _ USER_UID USER_GID _ PW_HOME _ <<<"$PW"
|
||||
[[ "$USER_UID" =~ ^[0-9]+$ ]] && [ "$USER_UID" -ne 0 ] || die "--user must not be root: $USER_NAME"
|
||||
abs_plain "$HOME_DIR" '--home'
|
||||
[ "$HOME_DIR" = "$PW_HOME" ] || die "--home $HOME_DIR is not $USER_NAME's home in the passwd database ($PW_HOME)"
|
||||
GR="$("$GETENT" group "$USER_GID")" || die "no group $USER_GID in the group database"
|
||||
GROUP="${GR%%:*}"
|
||||
[[ "$GROUP" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "the primary group of $USER_NAME is not a group name: $GROUP"
|
||||
|
||||
case "$BUSINESS" in [a-z0-9]*) ;; *) die "--business must be a business id: $BUSINESS" ;; esac
|
||||
case "$BUSINESS" in *[!a-z0-9._-]*) die "--business must be a business id: $BUSINESS" ;; esac
|
||||
[ "${#BUSINESS}" -le 64 ] || die "--business must be a business id: $BUSINESS"
|
||||
|
||||
# The copy: copy_root's rule (scripts/stack-copy.sh:8, :22), under <home>.
|
||||
COPIES="$HOME_DIR/.local/share/mosaic-stack/copies"
|
||||
[[ "$COPY_ID" =~ ^[0-9a-f]{40}$ ]] || die "--copy must be a full 40-character lower-case commit id: $COPY_ID"
|
||||
COPY="$COPIES/$COPY_ID"
|
||||
[ -d "$COPY" ] && [ ! -L "$COPY" ] || die "no copy directory $COPY"
|
||||
[ -f "$COPY/scripts/mosaic" ] || die "no copy $COPY_ID in $COPIES: $COPY/scripts/mosaic is missing"
|
||||
|
||||
# Owned by root and writable by no one else, the file and every directory above it.
|
||||
root_only() {
|
||||
local p="$1" what="$2" owner mode
|
||||
while :; do
|
||||
owner="$(stat -c '%u' -- "$p")" || die "cannot stat $p"
|
||||
mode="$(stat -c '%a' -- "$p")" || die "cannot stat $p"
|
||||
[ "$owner" = 0 ] || die "$what: $p is not owned by root"
|
||||
(( (8#$mode & 8#022) == 0 )) || die "$what: $p is writable by its group or others"
|
||||
[ "$p" = / ] && break
|
||||
p="$(dirname -- "$p")"
|
||||
done
|
||||
}
|
||||
|
||||
# --node: an absolute path to a root-owned regular file outside <home>.
|
||||
abs_plain "$NODE" '--node'
|
||||
case "$NODE/" in "$HOME_DIR"/*) die "--node must be outside $HOME_DIR: $NODE" ;; esac
|
||||
[ ! -L "$NODE" ] || die "--node must not be a symlink; pass its target: $NODE"
|
||||
[ -f "$NODE" ] && [ -x "$NODE" ] || die "--node must be an executable regular file: $NODE"
|
||||
root_only "$NODE" '--node'
|
||||
|
||||
# --claude follows the --node rule, so nothing running as <user> can replace
|
||||
# the binary the bus runs.
|
||||
if [ "$CLAUDE_SET" = 1 ]; then
|
||||
abs_plain "$CLAUDE" '--claude'
|
||||
case "$CLAUDE/" in "$HOME_DIR"/*) die "--claude must be outside $HOME_DIR: $CLAUDE" ;; esac
|
||||
[ ! -L "$CLAUDE" ] || die "--claude must not be a symlink; pass its target: $CLAUDE"
|
||||
[ -f "$CLAUDE" ] && [ -x "$CLAUDE" ] || die "--claude must be an executable regular file: $CLAUDE"
|
||||
root_only "$CLAUDE" '--claude'
|
||||
fi
|
||||
|
||||
# The checkout the templates come from, when installing for real.
|
||||
if [ "$UNIT_DIR" = "$DEFAULT_UNIT_DIR" ]; then
|
||||
case "$REPO/" in "$HOME_DIR"/*) die "run this from a root-owned checkout outside $HOME_DIR, not $REPO" ;; esac
|
||||
root_only "$BUS_TEMPLATE" 'bus template'
|
||||
root_only "$WEBUI_TEMPLATE" 'Console template'
|
||||
fi
|
||||
|
||||
# The serve flags, checked as scripts/webui-service.sh serve_flags checks
|
||||
# them (:201-264), and kept in the order given. That script compares the
|
||||
# proof file's owner to `id -u`, which is root here, so this copy has its own.
|
||||
serve_flags() {
|
||||
local -A seen=()
|
||||
local flags='' name value
|
||||
local port='' public_host='' auth_header='' allow_user='' proof_header='' proof_file='' tester_port=''
|
||||
local i=0 n=${#SERVE[@]}
|
||||
while [ "$i" -lt "$n" ]; do
|
||||
name="${SERVE[$i]}"
|
||||
case "$name" in
|
||||
--tester-port) [ "$PROFILE" = factory ] || die "the library profile refuses --tester-port: the tester listener takes writes from loopback with no sign-in" ;;
|
||||
--checkout) die "refusing --checkout: Case 2 has no queue panel" ;;
|
||||
--board) die "refusing --board: Case 2 runs no control board" ;;
|
||||
--sso) die "refusing --sso: the Console signs people in through the proxy, with the preview set and the proof pair (Q175 phase 2 is a follow-up)" ;;
|
||||
--port|--business) ;;
|
||||
--public-host|--auth-header|--allow-user|--proof-header|--proof-file|--send)
|
||||
[ "$PROFILE" = library ] || die "the factory profile refuses $name: its host has no proxy" ;;
|
||||
*) die "unknown serve flag: $name" ;;
|
||||
esac
|
||||
[ -z "${seen[$name]:-}" ] || die "serve flag $name given twice"
|
||||
seen[$name]=1
|
||||
if [ "$name" = --send ]; then flags="$flags --send"; i=$((i + 1)); continue; fi
|
||||
[ $((i + 1)) -lt "$n" ] || die "serve flag $name needs a value"
|
||||
value="${SERVE[$((i + 1))]}"
|
||||
case "$name" in
|
||||
--port) port="$value"
|
||||
[[ "$port" =~ ^[1-9][0-9]{0,3}$ ]] && [ "$port" -le 1023 ] || die "--port must be 1..1023, so only the unit's capability can bind it: $port" ;;
|
||||
--business) [ "$value" = "$BUSINESS" ] || die "serve --business $value is not the unit's business $BUSINESS" ;;
|
||||
--public-host) public_host="$value"
|
||||
case "$value" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $value" ;; esac
|
||||
case "$value" in *[!A-Za-z0-9.-]*) die "public host must be a hostname, no port or scheme: $value" ;; esac ;;
|
||||
--auth-header) auth_header="$value"
|
||||
case "$value" in [A-Za-z0-9]*) ;; *) die "auth header must be a header name: $value" ;; esac
|
||||
case "$value" in *[!A-Za-z0-9-]*) die "auth header must be a header name: $value" ;; esac ;;
|
||||
--allow-user) allow_user="$value"
|
||||
case "$value" in [A-Za-z0-9]*) ;; *) die "allowed user must start with a letter or digit: $value" ;; esac
|
||||
case "$value" in *[!A-Za-z0-9._@+-]*) die "allowed user contains a character the unit cannot carry: $value" ;; esac ;;
|
||||
--proof-header) proof_header="$value"
|
||||
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,63}$ ]] || die "invalid proxy proof header" ;;
|
||||
--proof-file) proof_file="$value" ;;
|
||||
--tester-port) tester_port="$value"
|
||||
[[ "$value" =~ ^[1-9][0-9]{3,4}$ ]] && [ "$value" -ge 1024 ] && [ "$value" -le 65535 ] || die "--tester-port must be 1024..65535: $value" ;;
|
||||
esac
|
||||
flags="$flags $name $value"
|
||||
i=$((i + 2))
|
||||
done
|
||||
local required=(--port --business)
|
||||
[ "$PROFILE" = factory ] || required+=(--public-host --auth-header --allow-user --send --proof-header --proof-file)
|
||||
for name in "${required[@]}"; do
|
||||
[ -n "${seen[$name]:-}" ] || die "serve flag $name is required"
|
||||
done
|
||||
[ -z "$tester_port" ] || [ "$tester_port" -ne "$port" ] || die "--tester-port must differ from --port"
|
||||
if [ "$PROFILE" = factory ]; then echo "${flags# }"; return 0; fi
|
||||
# Lead decision 101: the uid header carries the Authentik uid.
|
||||
if [ "${auth_header,,}" = x-authentik-uid ] && ! [[ "$allow_user" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
die "with --auth-header $auth_header the allowed user must be an Authentik uid, 64 lower-case hex characters (lead decision 101): $allow_user"
|
||||
fi
|
||||
case "${proof_header,,}" in host|origin|x-mosaic-tester|"${auth_header,,}") die "proxy proof header must differ from Host, Origin, tester and auth headers" ;; esac
|
||||
# The proof file: metadata only, never opened (design 9.3).
|
||||
local secrets="$HOME_DIR/.config/mosaic-dev/secrets" real owner mode
|
||||
abs_plain "$proof_file" 'proxy proof file'
|
||||
[ ! -L "$proof_file" ] || die "proxy proof file must not be a symlink: $proof_file"
|
||||
[ -f "$proof_file" ] || die "proxy proof file must be a regular file: $proof_file"
|
||||
real="$(realpath -e -- "$proof_file")" || die "cannot resolve proxy proof file: $proof_file"
|
||||
[ "$real" = "$proof_file" ] || die "proxy proof file path must have no symlink in it: $proof_file"
|
||||
case "$proof_file" in "$secrets"/*) ;; *) die "proxy proof file must be under $secrets, outside the data root: $proof_file" ;; esac
|
||||
owner="$(stat -c '%u' -- "$proof_file")" || die "cannot stat proxy proof file"
|
||||
mode="$(stat -c '%a' -- "$proof_file")" || die "cannot stat proxy proof file"
|
||||
[ "$owner" = "$USER_UID" ] || die "proxy proof file must be owned by $USER_NAME (uid $USER_UID): $proof_file"
|
||||
[ "$mode" = 600 ] || [ "$mode" = 400 ] || die "proxy proof file must be mode 0600 or 0400, not $mode: $proof_file"
|
||||
echo "${flags# }"
|
||||
}
|
||||
FLAGS="$(serve_flags)" || exit 1
|
||||
|
||||
# PATH: the node's directory first, then the default list without it. Unlike
|
||||
# scripts/webui-service.sh:303-305 this always puts node's directory first,
|
||||
# so the bus's `exec node` (scripts/mosaic) finds the same node.
|
||||
NODE_DIR="$(dirname -- "$NODE")"
|
||||
UNIT_PATH="$NODE_DIR"
|
||||
for d in /usr/local/bin /usr/bin /bin; do [ "$d" = "$NODE_DIR" ] || UNIT_PATH="$UNIT_PATH:$d"; done
|
||||
|
||||
HARDENING="ProtectSystem=strict
|
||||
ReadWritePaths=$HOME_DIR
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes"
|
||||
CAPS="AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE"
|
||||
# The default instance, as scripts/instance.sh:85-107 renders it: no
|
||||
# MOSAIC_CONFIG line, and the session env under %h, which becomes <home>.
|
||||
SESSION_ENV='%h/.config/mosaic-dev/secrets/claude-session.env'
|
||||
ENV_FILE="-$HOME_DIR/.config/mosaic-dev/secrets/claude-session.env"
|
||||
PM_FLAGS=''
|
||||
[ "$PM" = 0 ] || PM_FLAGS=' --pm'
|
||||
CLAUDE_ENV=''
|
||||
[ "$CLAUDE_SET" = 0 ] || CLAUDE_ENV="Environment=MOSAIC_CLAUDE_BIN=$CLAUDE"
|
||||
|
||||
# One unit from its template. kind is bus or webui.
|
||||
render() {
|
||||
local kind="$1" template="$2" text tokens
|
||||
[ -f "$template" ] && [ ! -L "$template" ] || die "template missing or not a regular file: $template"
|
||||
text="$(sed -e "s|@REPO@|$COPY|g" -e "s|@PATH@|$UNIT_PATH|g" -e "s|@NODE@|$NODE|g" \
|
||||
-e "s|@PM_FLAGS@|$PM_FLAGS|g" -e "s|^@CLAUDE_ENV@\$|$CLAUDE_ENV|" \
|
||||
-e '/^@CONFIG_ENV@$/d' -e "s|@SESSION_ENV@|$SESSION_ENV|g" \
|
||||
-e "s|%h|$HOME_DIR|g" -- "$template")"
|
||||
# Checked before @FLAGS@ goes in, since an allowed user may hold @.
|
||||
tokens="$(printf '%s\n' "$text" | grep -oE '@[A-Z_]+@' || true)"
|
||||
if [ "$kind" = webui ]; then
|
||||
[ "$tokens" = @FLAGS@ ] || die "$template: a token other than one @FLAGS@ is left after the render: $(echo $tokens)"
|
||||
text="${text//@FLAGS@/$FLAGS}"
|
||||
else
|
||||
[ -z "$tokens" ] || die "$template: a token is left after the render: $(echo $tokens)"
|
||||
fi
|
||||
if printf '%s\n' "$text" | grep -q '%h'; then die "$template: %h is left after the render"; fi
|
||||
local add="User=$USER_NAME\nGroup=$GROUP\n${HARDENING//$'\n'/\\n}\n"
|
||||
[ "$kind" = bus ] || add="$add${CAPS//$'\n'/\\n}\n"
|
||||
# The added lines go at the end of [Service], before its trailing blank
|
||||
# lines. In [Install], WantedBy=multi-user.target replaces
|
||||
# WantedBy=default.target in both units, so both start at boot (Q189).
|
||||
printf '%s\n' "$text" | awk -v add="$add" '
|
||||
function blanks_out() { for (; blanks > 0; blanks--) print "" }
|
||||
/^\[.*\]$/ {
|
||||
if (sec == "[Service]") printf "%s", add
|
||||
sec = $0
|
||||
blanks_out(); print; next
|
||||
}
|
||||
$0 == "" { blanks++; next }
|
||||
{ blanks_out() }
|
||||
sec == "[Install]" && $0 == "WantedBy=default.target" { $0 = "WantedBy=multi-user.target" }
|
||||
{ print }
|
||||
END { if (sec == "[Service]") printf "%s", add; blanks_out() }'
|
||||
}
|
||||
|
||||
# The unit's settings as section<TAB>key<TAB>value, one per line, keys
|
||||
# trimmed as systemd trims them. A continued line refuses, so no setting
|
||||
# hides in one.
|
||||
settings() {
|
||||
awk '
|
||||
{ sub(/^[ \t]+/, ""); sub(/[ \t]+$/, "") }
|
||||
$0 == "" || /^[#;]/ { next }
|
||||
/\\$/ { print "CONTINUED"; exit }
|
||||
/^\[.*\]$/ { sec = $0; next }
|
||||
{
|
||||
i = index($0, "="); if (i == 0) { print "NOKEY\t" $0; next }
|
||||
k = substr($0, 1, i - 1); v = substr($0, i + 1)
|
||||
sub(/[ \t]+$/, "", k); sub(/^[ \t]+/, "", v)
|
||||
print sec "\t" k "\t" v
|
||||
}'
|
||||
}
|
||||
|
||||
# Refuses unless one unit's fixed lines hold. what names the unit in messages,
|
||||
# copy is the copy directory it should run from.
|
||||
check_unit() {
|
||||
local kind="$1" text="$2" what="$3" copy="$4" s
|
||||
s="$(printf '%s\n' "$text" | settings)"
|
||||
! printf '%s\n' "$s" | grep -qx CONTINUED || die "$what: a line continues onto the next"
|
||||
! printf '%s\n' "$s" | grep -q '^NOKEY' || die "$what: a line is neither a section, a comment nor a setting"
|
||||
# %h in a system unit is root's home, not <user>'s.
|
||||
! printf '%s\n' "$text" | grep -q '%h' || die "$what: %h is left; a system unit reads it as root's home"
|
||||
local key
|
||||
for key in RestrictNamespaces PrivateUsers SystemCallFilter PermissionsStartOnly; do
|
||||
! printf '%s\n' "$s" | awk -F'\t' -v k="$key" '$2 == k { found = 1 } END { exit !found }' \
|
||||
|| die "$what: refusing $key=, which the hub's units must not hold"
|
||||
done
|
||||
# An Exec*= value whose leading prefix characters hold + or ! runs with
|
||||
# full privileges, whatever User= says.
|
||||
local bad
|
||||
bad="$(printf '%s\n' "$s" | awk -F'\t' '$2 ~ /^Exec/ { p = $3; sub(/[^-@:+!].*$/, "", p); if (p ~ /[+!]/) print $2 "=" $3 }')"
|
||||
[ -z "$bad" ] || die "$what: refusing an Exec line that runs with full privileges: $bad"
|
||||
# The fixed lines, each exactly once, in [Service].
|
||||
one() {
|
||||
local k="$1" v="$2" got
|
||||
got="$(printf '%s\n' "$s" | awk -F'\t' -v k="$k" '$2 == k { print $1 "\t" $3 }')"
|
||||
[ "$got" = "[Service] $v" ] || die "$what: $k= must be exactly once in [Service], as $k=$v"
|
||||
}
|
||||
none() {
|
||||
! printf '%s\n' "$s" | awk -F'\t' -v k="$1" '$2 == k { found = 1 } END { exit !found }' || die "$what: refusing $1=, which this unit must not hold"
|
||||
}
|
||||
one User "$USER_NAME"
|
||||
one Group "$GROUP"
|
||||
one WorkingDirectory "$copy"
|
||||
# Environment= lines: PATH, and MOSAIC_CLAUDE_BIN only with --claude.
|
||||
local envs want_envs="[Service] PATH=$UNIT_PATH"
|
||||
[ "$kind" = webui ] || [ -z "$CLAUDE_ENV" ] || want_envs="$want_envs
|
||||
[Service] MOSAIC_CLAUDE_BIN=$CLAUDE"
|
||||
envs="$(printf '%s\n' "$s" | awk -F'\t' '$2 == "Environment" { print $1 "\t" $3 }')"
|
||||
[ "$envs" = "$want_envs" ] || die "$what: the Environment= lines must be exactly: $(printf '%s' "$want_envs" | cut -f2 | tr '\n' ' ')"
|
||||
one NoNewPrivileges yes
|
||||
one ProtectSystem strict
|
||||
one ReadWritePaths "$HOME_DIR"
|
||||
one ProtectHome yes
|
||||
one PrivateTmp yes
|
||||
local order
|
||||
order="$(printf '%s\n' "$s" | awk -F'\t' '$2 ~ /^(ProtectSystem|ReadWritePaths|ProtectHome|PrivateTmp)$/ { printf "%s ", $2 }')"
|
||||
[ "$order" = "ProtectSystem ReadWritePaths ProtectHome PrivateTmp " ] || die "$what: the hardening lines must be in the order ProtectSystem, ReadWritePaths, ProtectHome, PrivateTmp"
|
||||
local wanted
|
||||
wanted="$(printf '%s\n' "$s" | awk -F'\t' '$2 == "WantedBy" { print $1 "\t" $3 }')"
|
||||
[ "$wanted" = "[Install] multi-user.target" ] || die "$what: WantedBy= must be exactly once, as WantedBy=multi-user.target"
|
||||
if [ "$kind" = bus ]; then
|
||||
one ExecStart "$copy/scripts/mosaic bus start %i$PM_FLAGS"
|
||||
one EnvironmentFile "$ENV_FILE"
|
||||
none AmbientCapabilities
|
||||
none CapabilityBoundingSet
|
||||
else
|
||||
one ExecStart "$NODE $copy/packages/webui/src/cli.mjs serve $FLAGS"
|
||||
none EnvironmentFile
|
||||
one AmbientCapabilities CAP_NET_BIND_SERVICE
|
||||
one CapabilityBoundingSet CAP_NET_BIND_SERVICE
|
||||
fi
|
||||
}
|
||||
|
||||
BUS_TEXT="$(render bus "$BUS_TEMPLATE")" || exit 1
|
||||
WEBUI_TEXT="$(render webui "$WEBUI_TEMPLATE")" || exit 1
|
||||
check_unit bus "$BUS_TEXT" "rendered $BUS_UNIT" "$COPY"
|
||||
check_unit webui "$WEBUI_TEXT" "rendered $WEBUI_UNIT" "$COPY"
|
||||
|
||||
# A re-install: each installed unit passes the same checks, both name one
|
||||
# copy, and each equals the render once its copy id is the new one.
|
||||
[ -d "$UNIT_DIR" ] && [ ! -L "$UNIT_DIR" ] || die "no unit directory $UNIT_DIR"
|
||||
installed_ids=''
|
||||
for pair in "bus:$BUS_UNIT:BUS_TEXT" "webui:$WEBUI_UNIT:WEBUI_TEXT"; do
|
||||
IFS=: read -r kind name var <<<"$pair"
|
||||
path="$UNIT_DIR/$name"
|
||||
[ ! -L "$path" ] || die "refusing: $path is a symlink"
|
||||
[ -e "$path" ] || continue
|
||||
[ -f "$path" ] || die "refusing: $path is not a regular file"
|
||||
text="$(cat -- "$path")"
|
||||
old="$(printf '%s\n' "$text" | settings | awk -F'\t' -v c="$COPIES/" '$2 == "WorkingDirectory" && index($3, c) == 1 { print substr($3, length(c) + 1) }')"
|
||||
[[ "$old" =~ ^[0-9a-f]{40}$ ]] || die "installed $path names no copy under $COPIES"
|
||||
check_unit "$kind" "$text" "installed $path" "$COPIES/$old"
|
||||
[ "$text" = "$(printf '%s\n' "${!var}" | sed "s|$COPY|$COPIES/$old|g")" ] \
|
||||
|| die "installed $path differs from the render in more than the copy id; the node path, serve flags, --pm and --claude must equal the installed ones"
|
||||
installed_ids="$installed_ids $old"
|
||||
done
|
||||
read -r -a ids <<<"$installed_ids"
|
||||
[ "${#ids[@]}" -lt 2 ] || [ "${ids[0]}" = "${ids[1]}" ] || die "the installed units name different copies, ${ids[0]} and ${ids[1]}"
|
||||
|
||||
# `systemctl start` leaves a running unit on the copy it started from, so
|
||||
# note which units run before the write (row 195 owns the restart).
|
||||
BUS_NAME="mosaic-bus@$BUSINESS.service"
|
||||
running=''
|
||||
for name in "$BUS_NAME" "$WEBUI_UNIT"; do
|
||||
if "$SYSTEMCTL" is-active --quiet "$name"; then running="$running $name"; fi
|
||||
done
|
||||
|
||||
# Write each unit to a temp file in the unit directory, then rename.
|
||||
write_unit() {
|
||||
local name="$1" text="$2" tmp
|
||||
tmp="$(mktemp "$UNIT_DIR/.case2-unit.XXXXXX")"
|
||||
printf '%s\n' "$text" >"$tmp"
|
||||
chmod 0644 "$tmp"
|
||||
[ "$(id -u)" != 0 ] || chown root:root "$tmp"
|
||||
mv -fT -- "$tmp" "$UNIT_DIR/$name"
|
||||
}
|
||||
write_unit "$BUS_UNIT" "$BUS_TEXT"
|
||||
write_unit "$WEBUI_UNIT" "$WEBUI_TEXT"
|
||||
# Enable both, then start the bus, then the Console (design 10 step 2c).
|
||||
# serve calls the bus per request, so the units carry no After= or Wants=.
|
||||
"$SYSTEMCTL" daemon-reload || die "systemctl daemon-reload failed; the units are written, nothing is enabled or started"
|
||||
"$SYSTEMCTL" enable "$BUS_NAME" "$WEBUI_UNIT" || die "systemctl enable $BUS_NAME $WEBUI_UNIT failed; nothing is started"
|
||||
"$SYSTEMCTL" start "$BUS_NAME" || die "systemctl start $BUS_NAME failed; the Console is not started"
|
||||
"$SYSTEMCTL" start "$WEBUI_UNIT" || die "systemctl start $WEBUI_UNIT failed; the bus is running"
|
||||
if [ -z "$running" ]; then
|
||||
echo "case2-unit: wrote $UNIT_DIR/$BUS_UNIT and $UNIT_DIR/$WEBUI_UNIT from copy $COPY_ID; enabled and started $BUS_NAME, then $WEBUI_UNIT"
|
||||
exit 0
|
||||
fi
|
||||
echo "case2-unit: wrote $UNIT_DIR/$BUS_UNIT and $UNIT_DIR/$WEBUI_UNIT from copy $COPY_ID and enabled both"
|
||||
for name in "$BUS_NAME" "$WEBUI_UNIT"; do
|
||||
case " $running " in
|
||||
*" $name "*) echo "case2-unit: $name was already running, so it is still on ${ids[0]:+copy }${ids[0]:-the copy it started from}, not copy $COPY_ID" ;;
|
||||
*) echo "case2-unit: started $name on copy $COPY_ID" ;;
|
||||
esac
|
||||
done
|
||||
echo "case2-unit: restart $BUS_NAME and $WEBUI_UNIT together to put both on copy $COPY_ID; until then they may run different copies"
|
||||
+62
-13
@@ -3,21 +3,25 @@
|
||||
# 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.
|
||||
# Row 232: check --units external reads root's system units. copy --bundle makes the copy from a git
|
||||
# bundle on a host with no checkout, and wrapper --create writes the first wrapper on a new host.
|
||||
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
|
||||
ALLOW_WORKTREE=0
|
||||
ALLOW_WORKTREE=0 UNITS=user BUNDLE="" CREATE=0 CLEAN=""
|
||||
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] | copy --bundle FILE SHA [--copies DIR] | wrapper ID [--instance NAME] [--copies DIR] [--bin DIR] [--create] | check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID] [--units user|external]' >&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;;
|
||||
--allow-worktree) [ "$CMD" = copy ] || usage; ALLOW_WORKTREE=1;shift;;
|
||||
--allow-worktree) [ "$CMD" = copy ] && [ -z "$BUNDLE" ] || usage; ALLOW_WORKTREE=1;shift;;
|
||||
--units) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; case "$2" in user|external) UNITS="$2";; *) usage;; esac; shift 2;;
|
||||
--create) [ "$CMD" = wrapper ] || usage; CREATE=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"; }
|
||||
# Closure of scripts/mosaic, bus process/notifier/launcher, Console serve,
|
||||
@@ -44,16 +48,22 @@ reachable() {
|
||||
copy_commit() {
|
||||
[ -d "$REPO/.git" ] || [ "$ALLOW_WORKTREE" = 1 ] || die "refusing: $REPO is not the canonical checkout (its .git is not a directory); run copy from the canonical checkout"
|
||||
[[ "$ID" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] || die "commit must be a commit id or branch name: $ID"
|
||||
local full target tmp
|
||||
local full
|
||||
full="$(git -C "$REPO" rev-parse --verify --quiet "$ID^{commit}")" || die "not a commit: $ID"
|
||||
reachable "$full"
|
||||
copy_tree "$REPO" "$full"
|
||||
}
|
||||
# The copy of commit $2 from repository $1. CLEAN names a bundle's temporary repository, which the
|
||||
# trap removes with the temporary copy.
|
||||
copy_tree() {
|
||||
local full="$2" target tmp
|
||||
target="$COPIES/$full"
|
||||
[ ! -L "$target" ] || die "refusing: copy target is a symlink: $target"
|
||||
if [ -e "$target" ]; then echo "exists: $target"; return; fi
|
||||
mkdir -p "$COPIES"
|
||||
tmp="$(mktemp -d "$COPIES/.$full.XXXXXX")"
|
||||
trap "chmod -R u+w '$tmp' 2>/dev/null || true; rm -rf '$tmp'" EXIT
|
||||
git -C "$REPO" archive --format=tar "$full" -- "${COPY_PATHS[@]}" | tar -xf - -C "$tmp" || die "git archive failed; no copy written"
|
||||
trap "chmod -R u+w '$tmp' 2>/dev/null || true; rm -rf '$tmp'$CLEAN" EXIT
|
||||
git -C "$1" archive --format=tar "$full" -- "${COPY_PATHS[@]}" | tar -xf - -C "$tmp" || die "git archive failed; no copy written"
|
||||
[ -f "$tmp/package.json" ] && [ -f "$tmp/package-lock.json" ] || die 'package.json or package-lock.json missing in temp copy; npm ci not run'
|
||||
(cd "$tmp" && npm ci --no-audit --no-fund) >&2 || die 'npm ci failed; no copy written'
|
||||
find "$tmp" -type f -exec chmod a-w {} +
|
||||
@@ -65,19 +75,53 @@ copy_commit() {
|
||||
trap - EXIT
|
||||
echo "copied: $target"
|
||||
}
|
||||
# Row 232: the copy of SHA from a git bundle, for a host with no refactor checkout. The bundle must
|
||||
# verify, carry refs/heads/refactor, and reach SHA from it, as reachable asks of a checkout. Prints
|
||||
# the RELEASE value at SHA and the bundle's sha256, the release identity on such a host.
|
||||
copy_bundle() {
|
||||
[[ "$ID" =~ ^[0-9a-f]{40}$ ]] || die "--bundle needs a full 40-character commit id: $ID"
|
||||
[ -f "$BUNDLE" ] && [ ! -L "$BUNDLE" ] || die "refusing: bundle is absent, not regular, or a symlink: $BUNDLE"
|
||||
local g sum release heads
|
||||
case "$BUNDLE" in /*) ;; *) BUNDLE="$PWD/$BUNDLE";; esac
|
||||
g="$(mktemp -d)"
|
||||
CLEAN=" '$g'"
|
||||
trap "rm -rf '$g'" EXIT
|
||||
git init -q --bare "$g" || die 'git init failed; no copy written'
|
||||
git -C "$g" bundle verify -q "$BUNDLE" >/dev/null 2>&1 || die "refusing: git bundle verify failed: $BUNDLE"
|
||||
heads="$(git -C "$g" bundle list-heads "$BUNDLE" refs/heads/refactor)" || die "refusing: cannot list the bundle's heads: $BUNDLE"
|
||||
grep -qx '[0-9a-f]\{40\} refs/heads/refactor' <<< "$heads" || die "refusing: the bundle has no refs/heads/refactor: $BUNDLE"
|
||||
# Every ref, so a SHA the bundle carries on another branch reaches the reachability check.
|
||||
git -C "$g" fetch -q "$BUNDLE" 'refs/*:refs/*' 2>/dev/null || die "refusing: cannot fetch from $BUNDLE"
|
||||
git -C "$g" cat-file -e "$ID^{commit}" 2>/dev/null || die "refusing: $ID is not in the bundle"
|
||||
git -C "$g" merge-base --is-ancestor "$ID" refs/heads/refactor || die "refusing: $ID is not reachable from the bundle's refs/heads/refactor"
|
||||
release="$(git -C "$g" show "$ID:RELEASE")" || die "no RELEASE at $ID"
|
||||
sum="$(sha256sum < "$BUNDLE" | cut -d' ' -f1)"
|
||||
copy_tree "$g" "$ID"
|
||||
rm -rf "$g"; trap - EXIT
|
||||
echo "release: $release"
|
||||
echo "bundle sha256: $sum"
|
||||
}
|
||||
# Row 232: --create writes the wrapper only where none is. ln refuses any existing path, a symlink
|
||||
# included, so this is the one check, and a wrapper that appears mid-run is never replaced.
|
||||
place() {
|
||||
if [ "$CREATE" = 1 ]; then ln "$1" "$2" 2>/dev/null || die "refusing: --create and a wrapper exists: $2"; rm -f "$1"
|
||||
else mv -f "$1" "$2"; fi
|
||||
}
|
||||
wrapper() {
|
||||
[ -z "$MOSAIC_UNIT_SUFFIX" ] || { wrapper_instance; return; }
|
||||
plain_path "$BIN" 'bin directory'
|
||||
local root file lease tmp
|
||||
root="$(copy_root)";file="$BIN/mosaic"
|
||||
[ -f "$file" ] && [ ! -L "$file" ] || die "refusing: wrapper is absent, not regular, or a symlink: $file"
|
||||
[ "$(sed -n '2p' "$file")" = "$MARKER" ] || die "refusing: foreign wrapper (missing Q81 marker on line 2): $file"
|
||||
lease='[ "${1:-}" = __lease-capability ] && exec "'"$BIN"'/mosaic-old" "$@"'
|
||||
[ "$(grep -Fxc "$lease" "$file" || true)" = 1 ] || die 'refusing: Q81 lease line missing or ambiguous'
|
||||
if [ "$CREATE" = 0 ]; then
|
||||
[ -f "$file" ] && [ ! -L "$file" ] || die "refusing: wrapper is absent, not regular, or a symlink: $file"
|
||||
[ "$(sed -n '2p' "$file")" = "$MARKER" ] || die "refusing: foreign wrapper (missing Q81 marker on line 2): $file"
|
||||
[ "$(grep -Fxc "$lease" "$file" || true)" = 1 ] || die 'refusing: Q81 lease line missing or ambiguous'
|
||||
fi
|
||||
tmp="$(mktemp "$BIN/.mosaic.XXXXXX")"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
printf '#!/usr/bin/env bash\n%s\n# CLI runs from pinned copy: %s\n# The Q81 lease probe still answers at mosaic-old.\n%s\nexec "%s/scripts/mosaic" "$@"\n' "$MARKER" "$root" "$lease" "$root" > "$tmp"
|
||||
chmod 0755 "$tmp";mv -f "$tmp" "$file";trap - EXIT
|
||||
chmod 0755 "$tmp";place "$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.
|
||||
@@ -93,12 +137,14 @@ wrapper_instance() {
|
||||
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
|
||||
chmod 0755 "$tmp";place "$tmp" "$file";trap - EXIT
|
||||
echo "wrapper: $root"
|
||||
}
|
||||
# --units external reads root's system units, which the stack user can read and not change.
|
||||
units_show() { if [ "$UNITS" = external ]; then systemctl show "$@"; else systemctl --user show "$@"; fi; }
|
||||
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; }
|
||||
out="$(units_show "$1" -p LoadState -p WorkingDirectory -p ExecStart 2>/dev/null)" || { echo 'absent (systemctl read failed)';return; }
|
||||
load="$(sed -n 's/^LoadState=//p' <<< "$out")"; root="$(sed -n 's/^WorkingDirectory=//p' <<< "$out")"
|
||||
exec_start="$(sed -n 's/^ExecStart=//p' <<< "$out")"
|
||||
if [ "$load" != loaded ] || [ -z "$root" ];then echo absent;return;fi
|
||||
@@ -133,7 +179,10 @@ 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;;
|
||||
copy|wrapper) [ $# -gt 0 ] || usage
|
||||
if [ "$CMD" = copy ] && [ "$1" = --bundle ];then [ $# -ge 3 ] && [ -n "$2" ] || usage;BUNDLE="$2";shift 2;fi
|
||||
ID="$1";shift;case "$ID" in -*)usage;;esac;parse "$@"
|
||||
if [ -n "$BUNDLE" ];then copy_bundle;elif [ "$CMD" = copy ];then copy_commit;else wrapper;fi;;
|
||||
check) parse "$@";[ -z "$MOSAIC_UNIT_SUFFIX" ] || [ "$BUSINESS_SET" = 1 ] || usage;check;;
|
||||
-h|--help) usage 0;;
|
||||
*) usage;;esac
|
||||
|
||||
+460
-58
@@ -19,6 +19,13 @@
|
||||
# rehearse (D2, row 163) stops and starts no unit and writes only its own rehearsal directory, but
|
||||
# it reads the live config and a backup of the live store, so Jason or Mos runs it.
|
||||
#
|
||||
# --units external (row 232, #1734; r6 section 4 at agents/darkwing/work/queue-195/DESIGN.md) is
|
||||
# for a host whose units are root's system units. This script then reads those units and never
|
||||
# starts or stops one: root stops them, activate or rollback stages the copy (A3), root installs
|
||||
# and starts them, and confirm gates them and writes the pointer (A5). revert and confirm
|
||||
# --rollback put the old copy back when a step fails (R2, R4), and gate checks a copy root
|
||||
# restarted with no activation open (A2f). Without it, everything runs as before.
|
||||
#
|
||||
# Most functions below run as `f || handler`, and bash ignores set -e inside such a call, so
|
||||
# every step that can fail carries its own `|| return 1`.
|
||||
set -euo pipefail
|
||||
@@ -32,6 +39,7 @@ BIN="$HOME/.npm-global/bin"
|
||||
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
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 SSO=0 R_BACKUP="" R_PORT=""
|
||||
UNITS=user TO_FLAG="" EXPECT_FROM="" SINCE="" ROLLBACK_OLD="" CAUSE=""
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'USAGE'
|
||||
@@ -42,11 +50,18 @@ 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
|
||||
stack-release.sh confirm ID BUSINESS --units external [--rollback OLD --cause A3|A4|A5] [--timeout S]
|
||||
stack-release.sh revert ID BUSINESS --units external
|
||||
stack-release.sh gate ID BUSINESS --units external --since EPOCH [--timeout S]
|
||||
common options: --copies DIR, --bin DIR, --instance NAME, --units user|external
|
||||
--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.
|
||||
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
|
||||
every table in COUNTED.
|
||||
--units external (row 232) reads root's system units and starts or stops none. adopt, activate,
|
||||
rollback, status, confirm, revert and gate take it; rollback then needs --to ID, the previous copy.
|
||||
activate and rollback --plan take --expect-from OLD. --pm, --pm-after-harness, --tester-port, the
|
||||
proof flags, --no-proof, --no-send, --sso and pm-on are not for it.
|
||||
USAGE
|
||||
exit "${1:-4}"
|
||||
}
|
||||
@@ -58,7 +73,27 @@ now_ms() { date -u +%Y-%m-%dT%H:%M:%S.%3NZ; }
|
||||
jline() {
|
||||
node -e 'const a=process.argv.slice(1),o={};for(let i=0;i<a.length;i+=2)o[a[i]]=a[i+1]==="@null"?null:a[i+1];process.stdout.write(JSON.stringify(o)+"\n")' -- "$@"
|
||||
}
|
||||
log_line() { jline at "$(now)" op "$OP" from "${FROM:-@null}" to "${TO:-@null}" backup "${BACKUP:-@null}" activation "${ACT:-@null}" "$@" >> "$LOG"; }
|
||||
log_line() {
|
||||
[ "$UNITS" = user ] || set -- "$@" units external
|
||||
jline at "$(now)" op "$OP" from "${FROM:-@null}" to "${TO:-@null}" backup "${BACKUP:-@null}" activation "${ACT:-@null}" "$@" >> "$LOG"
|
||||
}
|
||||
|
||||
# --- The units backend (row 232) ---
|
||||
|
||||
# Every systemctl, journalctl and systemd-analyze call goes through these three. user is the
|
||||
# systemd user manager, as before. external reads root's system units, which needs no privilege,
|
||||
# and refuses any verb that would change one.
|
||||
sc() {
|
||||
if [ "$UNITS" = user ]; then systemctl --user "$@"; return; fi
|
||||
case "$1" in
|
||||
show|is-active|list-units) systemctl "$@" ;;
|
||||
*) echo "stack-release: --units external never runs systemctl $1; root does" >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
# The bus unit's journal is a user journal only: under external it lands in the system journal,
|
||||
# which the stack's user can't read (r6 section 4), so the external PM check reads launches list.
|
||||
jctl() { [ "$UNITS" = user ] || return 1; journalctl --user "$@"; }
|
||||
analyze() { [ "$UNITS" = user ] || return 1; systemd-analyze --user "$@"; }
|
||||
|
||||
# A refusal before anything stopped: exit 3, and one log line with the code, except for
|
||||
# run-locked (the lock holder may be writing the log) and in --plan, which writes nothing.
|
||||
@@ -83,7 +118,7 @@ parse_opts() {
|
||||
case "$1" in
|
||||
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; shift 2 ;;
|
||||
--bin) [ $# -ge 2 ] || usage; BIN="$2"; shift 2 ;;
|
||||
--timeout) [ "$OP" != status ] && [ "$OP" != adopt ] && [ $# -ge 2 ] && [[ "$2" =~ ^[1-9][0-9]{0,4}$ ]] || usage; TIMEOUT="$2"; shift 2 ;;
|
||||
--timeout) [ "$OP" != status ] && [ "$OP" != adopt ] && [ "$OP" != revert ] && [ $# -ge 2 ] && [[ "$2" =~ ^[1-9][0-9]{0,4}$ ]] || usage; TIMEOUT="$2"; shift 2 ;;
|
||||
--plan) [ "$OP" = activate ] || [ "$OP" = rollback ] || usage; PLAN=1; shift ;;
|
||||
--pm) [ "$OP" = activate ] || usage; WANT_PM=1; shift ;;
|
||||
--pm-after-harness) [ "$OP" = activate ] || usage; AFTER_HARNESS=1; shift ;;
|
||||
@@ -98,9 +133,27 @@ parse_opts() {
|
||||
--backup) [ "$OP" = rehearse ] && [ $# -ge 2 ] && { [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || [[ "$2" =~ ^/[A-Za-z0-9/._+-]*$ ]]; } || usage; R_BACKUP="$2"; shift 2 ;;
|
||||
--port) [ "$OP" = rehearse ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{1,5}$ ]] && [ "$((10#$2))" -ge 1024 ] && [ "$((10#$2))" -le 65535 ] || usage; R_PORT="$((10#$2))"; shift 2 ;;
|
||||
--events) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^(0|[1-9][0-9]*)$ ]] || usage; EVENTS="$2"; shift 2 ;;
|
||||
--units) [ "$OP" != rehearse ] && [ $# -ge 2 ] && { [ "$2" = user ] || [ "$2" = external ]; } || usage; UNITS="$2"; shift 2 ;;
|
||||
--to) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9a-f]{40}$ ]] || usage; TO_FLAG="$2"; shift 2 ;;
|
||||
--expect-from) { [ "$OP" = activate ] || [ "$OP" = rollback ]; } && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9a-f]{40}$ ]] || usage; EXPECT_FROM="$2"; shift 2 ;;
|
||||
--since) [ "$OP" = gate ] && [ $# -ge 2 ] && [[ "$2" =~ ^(0|[1-9][0-9]{0,11})$ ]] || usage; SINCE="$2"; shift 2 ;;
|
||||
--rollback) [ "$OP" = confirm ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9a-f]{40}$ ]] || usage; ROLLBACK_OLD="$2"; shift 2 ;;
|
||||
--cause) [ "$OP" = confirm ] && [ $# -ge 2 ] && [[ "$2" =~ ^A[345]$ ]] || usage; CAUSE="$2"; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
# Row 232: confirm, revert, gate, --to and --expect-from are external only, and an external
|
||||
# rollback names its target. gate needs --since, its start floor (r6 N2), and --expect-from
|
||||
# goes with --plan. The flags that change a unit's render or the PM are root's under external.
|
||||
if [ "$UNITS" = user ]; then
|
||||
[ "$OP" != confirm ] && [ "$OP" != revert ] && [ "$OP" != gate ] && [ -z "$TO_FLAG" ] && [ -z "$EXPECT_FROM" ] || usage
|
||||
else
|
||||
[ "$OP" != rollback ] || [ -n "$TO_FLAG" ] || usage
|
||||
[ "$WANT_PM" = 0 ] && [ -z "$TESTER_PORT" ] && [ -z "$PROOF_HEADER" ] && [ -z "$PROOF_FILE" ] && [ "$NO_PROOF" = 0 ] && [ "$NO_SEND" = 0 ] && [ "$SSO" = 0 ] || usage
|
||||
fi
|
||||
[ "$OP" != gate ] || [ -n "$SINCE" ] || usage
|
||||
[ -z "$EXPECT_FROM" ] || [ "$PLAN" = 1 ] || usage
|
||||
[ "${ROLLBACK_OLD:+1}" = "${CAUSE:+1}" ] || usage
|
||||
[ -z "$EVENTS" ] || [ -n "$RESTORE" ] || usage
|
||||
[ "$OP" != rehearse ] || { [ -n "$R_BACKUP" ] && [ -n "$R_PORT" ]; } || usage
|
||||
# A bad tester port is a usage error; --plan lists it as tester-port-invalid instead (row 180).
|
||||
@@ -187,7 +240,7 @@ process.exit(1);' -- "$LOG" "$1" "$P_COPY"
|
||||
|
||||
# --- Reading the units ---
|
||||
|
||||
show() { systemctl --user show "$1" -p LoadState -p WorkingDirectory -p ExecStart -p Environment -p MainPID -p DropInPaths 2>/dev/null; }
|
||||
show() { sc show "$1" -p LoadState -p WorkingDirectory -p ExecStart -p Environment -p MainPID -p DropInPaths 2>/dev/null; }
|
||||
argv_of() { sed -n 's/^ExecStart=.*argv\[\]=\([^;]*\) ;.*/\1/p' <<< "$1"; }
|
||||
# systemctl show quotes a value that holds a space and escapes with a backslash, and the read -a
|
||||
# split below can't undo either, so such a line is unreadable rather than read wrong (row 166 item 4).
|
||||
@@ -286,12 +339,20 @@ check_target() {
|
||||
# has KIND exits 0 when the copy's KINDS has KIND
|
||||
# unknown prints the business file's plugins keys the copy's KINDS lacks, from a plain JSON read
|
||||
# harness prints plugins.harness as JSON, or `none`, from the copy's loader
|
||||
# launchby prints launch.by, the PM's instance, from a plain JSON read (row 232); it imports
|
||||
# only business.mjs, for the business file's path
|
||||
# A failure exits 1 and prints its first line.
|
||||
PLUGINS_JS='
|
||||
const {readFileSync}=require("node:fs");const {pathToFileURL}=require("node:url");
|
||||
const [mode,copy,id,kind]=process.argv.slice(1);
|
||||
const imp=(p)=>import(pathToFileURL(`${copy}/packages/${p}`).href);
|
||||
(async()=>{
|
||||
if(mode==="launchby"){
|
||||
const {businessFilePath}=await imp("business/src/business.mjs");
|
||||
const by=JSON.parse(readFileSync(businessFilePath(id),"utf8")).launch?.by;
|
||||
if(typeof by!=="string"||!by)throw new Error("the business file has no launch.by");
|
||||
return process.stdout.write(by);
|
||||
}
|
||||
const {KINDS}=await imp("plugins/src/kinds.mjs");
|
||||
if(mode==="has")process.exit(Object.hasOwn(KINDS,kind)?0:1);
|
||||
const b=await imp("business/src/business.mjs");
|
||||
@@ -455,13 +516,13 @@ capture() {
|
||||
|
||||
# Stops the Console, then the bus, and waits for both and the stopped check, within --timeout.
|
||||
stop_both() {
|
||||
systemctl --user stop "$CONSOLE_UNIT" || return 1
|
||||
systemctl --user stop "$BUS_UNIT" || return 1
|
||||
sc stop "$CONSOLE_UNIT" || return 1
|
||||
sc stop "$BUS_UNIT" || return 1
|
||||
local deadline=$((SECONDS + TIMEOUT)) u s down
|
||||
while :; do
|
||||
down=1
|
||||
for u in "$CONSOLE_UNIT" "$BUS_UNIT"; do
|
||||
s="$(systemctl --user is-active "$u" 2>/dev/null || true)"
|
||||
s="$(sc is-active "$u" 2>/dev/null || true)"
|
||||
case "$s" in inactive|failed) ;; *) down=0 ;; esac
|
||||
done
|
||||
if [ "$down" = 1 ] && stopped; then say "stopped both units"; return 0; fi
|
||||
@@ -472,7 +533,7 @@ stop_both() {
|
||||
|
||||
start_both() {
|
||||
START_EPOCH="$(date +%s)" && START_MS="$(date +%s%3N)" || return 1
|
||||
systemctl --user start "$BUS_UNIT" && systemctl --user start "$CONSOLE_UNIT"
|
||||
sc start "$BUS_UNIT" && sc start "$CONSOLE_UNIT"
|
||||
}
|
||||
|
||||
# Step 4: installs copy $1 with pm $2, claude $3 and the Console flags after them. Sets FAIL.
|
||||
@@ -493,9 +554,19 @@ install_stack() {
|
||||
gate_once() {
|
||||
local copy="$COPIES/$1" pm="$2" main out
|
||||
GATE=units-inactive GATE_WHY=""
|
||||
systemctl --user is-active --quiet "$BUS_UNIT" && systemctl --user is-active --quiet "$CONSOLE_UNIT" || return 1
|
||||
sc is-active --quiet "$BUS_UNIT" && sc is-active --quiet "$CONSOLE_UNIT" || return 1
|
||||
if [ "$UNITS" = external ]; then
|
||||
# r6 N4: root started the units, so this start's time is the bus unit's, in whole seconds,
|
||||
# at or after FLOOR. It is a gate code, so polling goes on: Restart=on-failure can bring a
|
||||
# fresh start.
|
||||
GATE=stale-start
|
||||
out="$(sc show --timestamp=unix -p ActiveEnterTimestamp "$BUS_UNIT" 2>/dev/null | sed -n 's/^ActiveEnterTimestamp=@//p')" || return 1
|
||||
[[ "$out" =~ ^[0-9]{1,12}$ ]] || { GATE_WHY="ActiveEnterTimestamp is unreadable"; return 1; }
|
||||
[ "$out" -ge "$FLOOR" ] || { GATE_WHY="the bus started at @$out, before @$FLOOR"; return 1; }
|
||||
START_MS=$((out * 1000))
|
||||
fi
|
||||
GATE=bus-status
|
||||
main="$(systemctl --user show "$BUS_UNIT" -p MainPID | sed -n 's/^MainPID=//p')" || return 1
|
||||
main="$(sc show "$BUS_UNIT" -p MainPID | sed -n 's/^MainPID=//p')" || return 1
|
||||
out="$("$copy/scripts/mosaic" bus status --json 2>/dev/null)" || { GATE_WHY="bus status failed"; return 1; }
|
||||
# Every `bus start` writes boot.at (packages/cli/src/boot-status.mjs), so a value from before
|
||||
# this start means the host on the socket is not the one this run started.
|
||||
@@ -508,13 +579,13 @@ 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 "${INSTANCE_FLAGS[@]}" "${UNITS_FLAGS[@]}" --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; }
|
||||
if [ "$pm" = 1 ]; then
|
||||
GATE=pm-launch-unconfirmed
|
||||
pm_launched "$copy" || return 1
|
||||
if [ "$UNITS" = user ]; then pm_launched "$copy" || return 1; else pm_external "$1" || return 1; fi
|
||||
fi
|
||||
GATE=""
|
||||
}
|
||||
@@ -526,7 +597,7 @@ fetch(`http://127.0.0.1:${process.argv[1]}/healthz`,{signal:AbortSignal.timeout(
|
||||
# because this host's journalctl reads a bare time as local time.
|
||||
pm_launched() {
|
||||
local journal run list
|
||||
journal="$(journalctl --user -u "$BUS_UNIT" --since "@$START_EPOCH" -o cat 2>/dev/null)" || { GATE_WHY="journal read failed"; return 1; }
|
||||
journal="$(jctl -u "$BUS_UNIT" --since "@$START_EPOCH" -o cat 2>/dev/null)" || { GATE_WHY="journal read failed"; return 1; }
|
||||
local -a lines=()
|
||||
mapfile -t lines < <(grep '^launched ' <<< "$journal" || true)
|
||||
[ "${#lines[@]}" -gt 0 ] || { GATE_WHY="no launched line since the start"; return 1; }
|
||||
@@ -540,6 +611,20 @@ process.stdout.write(!s?`run ${run} is not in launches list`:s.business!==b?`run
|
||||
[ -z "$GATE_WHY" ]
|
||||
}
|
||||
|
||||
# r6 section 4: the PM check under --units external, where the bus unit's journal is the system
|
||||
# journal and unreadable here. It passes when exactly one live launches list entry is for the
|
||||
# PM's instance (launch.by, packages/cli/src/launcher.mjs:675), and that entry is for BUSINESS and
|
||||
# started at or after this start. A run the PM starts for another role has another instance.
|
||||
pm_external() {
|
||||
local by list
|
||||
by="$(plugins launchby "$1" 2>&1)" || { GATE_WHY="cannot read launch.by: $by"; return 1; }
|
||||
list="$("$COPIES/$1/scripts/mosaic" launches list --json 2>/dev/null)" || { GATE_WHY="launches list failed"; return 1; }
|
||||
GATE_WHY="$(node -e '
|
||||
const [list,by,b,since]=process.argv.slice(1),l=JSON.parse(list).filter((x)=>x.instance===by&&x.live===true),s=l[0];
|
||||
process.stdout.write(l.length===0?`no live launch of ${by}`:l.length>1?`${l.length} live launches of ${by}`:s.business!==b?`the launch of ${by} is for ${s.business}`:!(Date.parse(s.startedAt)>=Number(since))?`run ${s.run} started before this start`:"");' -- "$list" "$by" "$BUSINESS" "$START_MS" 2>&1)" || { GATE_WHY="launches list unreadable"; return 1; }
|
||||
[ -z "$GATE_WHY" ]
|
||||
}
|
||||
|
||||
# Polls every second until every check passes or --timeout runs out.
|
||||
gate() {
|
||||
local deadline=$((SECONDS + TIMEOUT))
|
||||
@@ -573,7 +658,7 @@ put_back() {
|
||||
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
|
||||
systemctl --user daemon-reload || return 1
|
||||
sc daemon-reload || return 1
|
||||
say "put back the unit files and the wrapper from $RS"
|
||||
}
|
||||
|
||||
@@ -608,8 +693,8 @@ after_fail() {
|
||||
# Stop both units, print both results, log rollback-failed, exit 2. No retry.
|
||||
rollback_failed() {
|
||||
trap - INT TERM HUP
|
||||
systemctl --user stop "$CONSOLE_UNIT" || true
|
||||
systemctl --user stop "$BUS_UNIT" || true
|
||||
sc stop "$CONSOLE_UNIT" || true
|
||||
sc stop "$BUS_UNIT" || true
|
||||
say "rollback-failed: run $1, rollback $2"
|
||||
log_line result rollback-failed code "$1" rollback failed rollbackCode "$2"
|
||||
echo "stack-release: rollback-failed: the run failed with $1 and the rollback with $2; both units are stopped. Rollback set: $RS${BACKUP:+, backup $DATA/backups/stack/$BACKUP}${RESTORED:+, post-attempt store $POST}" >&2
|
||||
@@ -618,10 +703,15 @@ rollback_failed() {
|
||||
|
||||
# --- Refusals (item 3) ---
|
||||
|
||||
lock() {
|
||||
# confirm and revert act on the open activation by design, so they take the lock alone (r6 section 4).
|
||||
lock_only() {
|
||||
mkdir -p "$STATE"
|
||||
exec 9> "$STATE/stack-release.lock"
|
||||
flock -n 9 || refuse run-locked "another stack-release run holds $STATE/stack-release.lock"
|
||||
}
|
||||
|
||||
lock() {
|
||||
lock_only
|
||||
local open
|
||||
open="$(interrupted | paste -sd' ' -)"
|
||||
[ -z "$open" ] || refuse interrupted-run "activation $open started and has no final line; Jason or Mos settles it (status shows it)"
|
||||
@@ -629,7 +719,7 @@ 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="$(sc list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || refuse instance-busy "$SC_NAME list-units failed, so the bus state is unknown"
|
||||
while read -r unit load state rest; do
|
||||
[[ "$unit" == $BUS_GLOB ]] || continue
|
||||
if [ "$unit" = "$BUS_UNIT" ]; then
|
||||
@@ -645,7 +735,7 @@ instance_busy() {
|
||||
drop_in_override() {
|
||||
local paths directory dropdir file content glob
|
||||
local -a dropdirs
|
||||
paths="$(systemd-analyze --user unit-paths 2>/dev/null)" && [ -n "$paths" ] || refuse drop-in-override "systemd unit paths are unknown"
|
||||
paths="$(analyze 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
|
||||
@@ -679,7 +769,8 @@ flags_drift() {
|
||||
if [ "$AFTER_HARNESS" = 1 ]; then
|
||||
[ "$P_PM" = 1 ] || d+=$'\n'" pm: pointer $P_PM, --pm-after-harness needs a pointer with pm 1"
|
||||
else
|
||||
[ "$OP" != activate ] || [ "$WANT_PM" = "$P_PM" ] || d+=$'\n'" pm: pointer $P_PM, --pm given $WANT_PM"
|
||||
# Under --units external the PM is a flag in root's unit, so there is no --pm to compare.
|
||||
[ "$UNITS" != user ] || [ "$OP" != activate ] || [ "$WANT_PM" = "$P_PM" ] || d+=$'\n'" pm: pointer $P_PM, --pm given $WANT_PM"
|
||||
fi
|
||||
[ -z "$d" ] || refuse unit-flags-drift "the units differ from the pointer; settle it first:$d"
|
||||
}
|
||||
@@ -1075,11 +1166,12 @@ 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 "${INSTANCE_FLAGS[@]}" "${UNITS_FLAGS[@]}" --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"
|
||||
[ -n "$U_CLAUDE" ] || refuse no-claude-pin "$BUS_UNIT has no MOSAIC_CLAUDE_BIN"
|
||||
# Under --units external the claude pin is root's render, so adopt records what the unit has.
|
||||
[ "$UNITS" = external ] || [ -n "$U_CLAUDE" ] || refuse no-claude-pin "$BUS_UNIT has no MOSAIC_CLAUDE_BIN"
|
||||
target_schema "$TO" || refuse copy-schema-unknown "cannot read SCHEMA_VERSION from $COPIES/$TO/packages/bus/src/store.mjs"
|
||||
next_activation
|
||||
write_pointer "$TO" "$U_PM" 0 "$U_CLAUDE" "$T_SCHEMA" "${U_CONSOLE[@]}"
|
||||
@@ -1094,12 +1186,12 @@ plan_store() {
|
||||
local src="" why=""
|
||||
S_SCHEMA="" S_FROM="" S_CHANGED=""
|
||||
if [ ! -e "$STORE" ]; then why="there is no store file"
|
||||
elif systemctl --user is-active --quiet "$BUS_UNIT"; then
|
||||
elif sc is-active --quiet "$BUS_UNIT"; then
|
||||
src="the store the running bus holds"
|
||||
S_SCHEMA="$(node -e "$STORE_JS" -- heldversion "$STORE" 2>/dev/null)" || S_SCHEMA=""
|
||||
# A bus that stops during the readOnly open can leave -wal or -shm beside the stopped
|
||||
# store, so the version read can't be planned on (row 166 item 2).
|
||||
systemctl --user is-active --quiet "$BUS_UNIT" || S_CHANGED=1
|
||||
sc is-active --quiet "$BUS_UNIT" || S_CHANGED=1
|
||||
elif store_unclean; then why="the store was not read: $UNCLEAN is present"
|
||||
else
|
||||
src="the stopped store"
|
||||
@@ -1186,15 +1278,19 @@ plan() {
|
||||
echo "backup: the run backs up the stopped store to $DATA/backups/stack/<activation>/ before any migration"
|
||||
fi
|
||||
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"
|
||||
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
|
||||
echo "$PROOF_PLAN"
|
||||
echo "$SEND_PLAN"
|
||||
[ -z "$SSO_PLAN" ] || echo "$SSO_PLAN"
|
||||
echo "Console: scripts/webui-service.sh install $flags--copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
|
||||
if [ "$UNITS" = external ]; then
|
||||
echo "units: external; root installs the units for $TO and starts them, then confirm gates them"
|
||||
else
|
||||
[ "$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"
|
||||
echo "bus: scripts/bus-service.sh install $flags--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"
|
||||
[ -z "$SSO_PLAN" ] || echo "$SSO_PLAN"
|
||||
echo "Console: scripts/webui-service.sh install $flags--copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
|
||||
fi
|
||||
echo "CLI: scripts/stack-copy.sh wrapper $TO $flags--copies $COPIES --bin $BIN"
|
||||
local r
|
||||
for r in "${PLAN_REFUSE[@]}"; do echo "refuses $r"; done
|
||||
@@ -1268,6 +1364,16 @@ 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 [ "$UNITS" = external ]; then
|
||||
ext_pointer_checks
|
||||
if [ "$PLAN" = 1 ]; then check_target; plugin_checks; config_checks; plan; exit 0; fi
|
||||
ext_precheck
|
||||
plugin_checks
|
||||
config_checks
|
||||
new_activation
|
||||
run_external migrate
|
||||
return
|
||||
fi
|
||||
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; config_checks; plan; exit 0; fi
|
||||
precheck
|
||||
plugin_checks
|
||||
@@ -1281,43 +1387,321 @@ do_rollback() {
|
||||
pointer_for_business
|
||||
TO="$(from_log previous)" || refuse no-previous "the log names no earlier copy than $P_COPY"
|
||||
run_pm
|
||||
if [ "$UNITS" = external ]; then
|
||||
# r6: root names the target from the go, and it must be the one rollback reads from the log.
|
||||
[ "$TO_FLAG" = "$TO" ] || refuse to-not-previous "--to $TO_FLAG is not the previous copy; the log names $TO"
|
||||
ext_pointer_checks
|
||||
if [ "$PLAN" = 1 ]; then
|
||||
check_target; plugin_checks; config_checks
|
||||
[ -z "$RESTORE" ] || { next_activation; restore_checks; }
|
||||
plan; exit 0
|
||||
fi
|
||||
ext_precheck
|
||||
plugin_checks
|
||||
config_checks
|
||||
[ "$P_SCHEMA" = "$T_SCHEMA" ] || [ -n "$RESTORE" ] || refuse schema-differs "the store is at $P_SCHEMA and $TO expects $T_SCHEMA; pass --restore ACTIVATION"
|
||||
new_activation
|
||||
[ -z "$RESTORE" ] || restore_checks
|
||||
run_external restore
|
||||
return
|
||||
fi
|
||||
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; config_checks; plan; exit 0; fi
|
||||
precheck
|
||||
plugin_checks
|
||||
config_checks
|
||||
[ "$P_SCHEMA" = "$T_SCHEMA" ] || [ -n "$RESTORE" ] || refuse schema-differs "the store is at $P_SCHEMA and $TO expects $T_SCHEMA; pass --restore ACTIVATION"
|
||||
new_activation
|
||||
if [ -n "$RESTORE" ]; then
|
||||
local o="$DATA/backups/stack/$RESTORE" rc=0 c="" text
|
||||
[ -f "$o/SHA256SUMS" ] && [ "$(cat "$o/DATA_ROOT" 2>/dev/null)" = "$DATA" ] && [ "$(cat "$o/SCHEMA" 2>/dev/null)" = "$T_SCHEMA" ] && [ -f "$o/STAMP" ] \
|
||||
|| refuse restore-mismatch "$o is not a backup of this root at $T_SCHEMA"
|
||||
# Before anything stops. A store the running bus holds is read with readOnly, which adds
|
||||
# no file (the probe); a stopped one falls under the store_unclean rule.
|
||||
if systemctl --user is-active --quiet "$BUS_UNIT"; then
|
||||
c="$(node -e "$STORE_JS" -- heldcount "$STORE" "$(cat "$o/STAMP")" "$COUNTED")" || rc=1
|
||||
# plan_store's recheck (row 166 item 2), before anything stops.
|
||||
systemctl --user is-active --quiet "$BUS_UNIT" || refuse restore-mismatch "the bus stopped while the events after $(cat "$o/STAMP") were counted, so the store state changed; nothing was stopped"
|
||||
else
|
||||
! store_unclean || refuse store-unclean "$UNCLEAN is present; the bus did not close the store cleanly"
|
||||
c="$(store_read count "$(cat "$o/STAMP")" "$COUNTED")" || rc=1
|
||||
fi
|
||||
[ "$rc" = 0 ] || refuse restore-mismatch "cannot count the rows after $(cat "$o/STAMP")"
|
||||
COUNT="${c%%$'\t'*}" text="${c#*$'\t'}"
|
||||
echo "restore: $o; the store now in place moves to $POST and is kept; $text were written after the backup"
|
||||
[ "$COUNT" = 0 ] || [ "$EVENTS" = "$COUNT" ] || refuse post-backup-events "$text were written after the backup; Jason decides, then --events $COUNT"
|
||||
BACKUP="$RESTORE"
|
||||
fi
|
||||
[ -z "$RESTORE" ] || restore_checks
|
||||
run_steps restore
|
||||
}
|
||||
|
||||
# rollback --restore's checks on the backup and the rows written after it, before anything stops.
|
||||
# Sets COUNT and BACKUP; POST comes from new_activation.
|
||||
restore_checks() {
|
||||
local o="$DATA/backups/stack/$RESTORE" rc=0 c="" text
|
||||
[ -f "$o/SHA256SUMS" ] && [ "$(cat "$o/DATA_ROOT" 2>/dev/null)" = "$DATA" ] && [ "$(cat "$o/SCHEMA" 2>/dev/null)" = "$T_SCHEMA" ] && [ -f "$o/STAMP" ] \
|
||||
|| refuse restore-mismatch "$o is not a backup of this root at $T_SCHEMA"
|
||||
# Before anything stops. A store the running bus holds is read with readOnly, which adds
|
||||
# no file (the probe); a stopped one falls under the store_unclean rule.
|
||||
if sc is-active --quiet "$BUS_UNIT"; then
|
||||
c="$(node -e "$STORE_JS" -- heldcount "$STORE" "$(cat "$o/STAMP")" "$COUNTED")" || rc=1
|
||||
# plan_store's recheck (row 166 item 2), before anything stops.
|
||||
sc is-active --quiet "$BUS_UNIT" || refuse restore-mismatch "the bus stopped while the events after $(cat "$o/STAMP") were counted, so the store state changed; nothing was stopped"
|
||||
else
|
||||
! store_unclean || refuse store-unclean "$UNCLEAN is present; the bus did not close the store cleanly"
|
||||
c="$(store_read count "$(cat "$o/STAMP")" "$COUNTED")" || rc=1
|
||||
fi
|
||||
[ "$rc" = 0 ] || refuse restore-mismatch "cannot count the rows after $(cat "$o/STAMP")"
|
||||
COUNT="${c%%$'\t'*}" text="${c#*$'\t'}"
|
||||
echo "restore: $o; the store now in place moves to $POST and is kept; $text were written after the backup"
|
||||
[ "$COUNT" = 0 ] || [ "$EVENTS" = "$COUNT" ] || refuse post-backup-events "$text were written after the backup; Jason decides, then --events $COUNT"
|
||||
BACKUP="$RESTORE"
|
||||
}
|
||||
|
||||
# --- --units external (row 232, r6 section 4) ---
|
||||
|
||||
# After the pointer, for activate and rollback, in --plan and in the run. OLD is the copy root read
|
||||
# from the unit files it installed (r6 A1, N5), so a pointer that names another copy stops the
|
||||
# re-pin before root stops anything. The PM is a flag in root's unit, so --pm-after-harness has
|
||||
# nothing to wait on. The run's own lock refuses an open activation; --plan takes no lock, so it
|
||||
# lists one here.
|
||||
ext_pointer_checks() {
|
||||
[ -z "$EXPECT_FROM" ] || [ "$EXPECT_FROM" = "$P_COPY" ] || check_refuse pointer-differs "the pointer names $P_COPY and root's units name $EXPECT_FROM; Jason or Mos settles it"
|
||||
[ "$AFTER_HARNESS" = 0 ] || check_refuse external-pm "under --units external the PM is a flag in root's bus unit; changing it is a re-install with its own go"
|
||||
[ "$PLAN" = 1 ] || return 0
|
||||
local open
|
||||
open="$(interrupted | paste -sd' ' -)"
|
||||
[ -z "$open" ] || check_refuse interrupted-run "activation $open started and has no final line; Jason or Mos settles it (status shows it)"
|
||||
}
|
||||
|
||||
# A3's checks before `started`. Root has stopped the units and installs them after this run, so
|
||||
# there is no Console flag, port, drop-in or claude pin of this script's to check.
|
||||
ext_precheck() {
|
||||
check_target
|
||||
instance_busy
|
||||
read_units || refuse unit-flags-drift "cannot read $BUS_UNIT or $CONSOLE_UNIT"
|
||||
flags_drift
|
||||
units_down
|
||||
}
|
||||
|
||||
# r6 A3 (N4): each unit reads inactive, or failed as stop_both accepts, and the store is closed,
|
||||
# so nothing below runs against a store a live bus holds.
|
||||
units_down() {
|
||||
local u s
|
||||
for u in "$CONSOLE_UNIT" "$BUS_UNIT"; do
|
||||
s="$(sc is-active "$u" 2>/dev/null || true)"
|
||||
case "$s" in inactive|failed) ;; *) refuse units-running "$u is ${s:-unknown}; root stops the units first" ;; esac
|
||||
done
|
||||
stopped || refuse units-running "the store under $DATA/bus is still open (writer.lock or an open file)"
|
||||
}
|
||||
|
||||
# A3's capture into $RS, which run_external made: the pointer, the wrapper and its mode, and for each
|
||||
# unit its show output and the sha256 of its unit file. It reads root's units and makes no directory.
|
||||
capture_units() {
|
||||
local u frag
|
||||
[ ! -f "$POINTER" ] || cp -p "$POINTER" "$RS/pointer" || return 1
|
||||
[ -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
|
||||
: > "$RS/UNITS.sha256" || return 1
|
||||
for u in "$BUS_UNIT" "$CONSOLE_UNIT"; do
|
||||
show "$u" > "$RS/show-$u.txt" || return 1
|
||||
frag="$(sc show "$u" -p FragmentPath 2>/dev/null | sed -n 's/^FragmentPath=//p')" && [ -n "$frag" ] || { say "capture: no FragmentPath for $u"; return 1; }
|
||||
sha256sum "$frag" >> "$RS/UNITS.sha256" || return 1
|
||||
done
|
||||
sums "$RS" > "$RS/SHA256SUMS" || return 1
|
||||
say "captured the rollback set: $RS"
|
||||
}
|
||||
|
||||
# A failure after the capture: FAILED holds the code, nothing is logged or cleaned up, exit 1.
|
||||
# Root runs R1-R4, and revert is the one cleanup path (r6 A3).
|
||||
ext_fail() {
|
||||
trap - INT TERM HUP
|
||||
{ echo "$1" > "$RS/FAILED"; } 2>/dev/null || true
|
||||
say "failed: $1"
|
||||
echo "stack-release: $OP failed with $1; $ACT stays open for root's revert and confirm --rollback. Rollback set: $RS" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# A3 for target $TO. $1 is the store step: migrate or restore. The marker goes in first, so every
|
||||
# failure after it leaves what revert needs. Writes no pointer and starts nothing.
|
||||
run_external() {
|
||||
log_line result started
|
||||
{ mkdir -p -m 0700 "$STATE/stack-rollback" && mkdir -m 0700 "$RS"; } 2>/dev/null || refuse marker-failed "could not make the rollback set $RS; nothing was changed"
|
||||
{ echo "$TO" > "$RS/AWAITING-UNITS"; } 2>/dev/null || { rm -f "$RS/AWAITING-UNITS"; refuse marker-failed "could not write $RS/AWAITING-UNITS; nothing was changed"; }
|
||||
STEPS="$RS/steps.log"
|
||||
capture_units || { rm -f "$RS/AWAITING-UNITS"; refuse capture-failed "could not write the rollback set $RS; nothing was changed"; }
|
||||
trap 'ext_fail signal' INT TERM HUP
|
||||
if [ -e "$STORE" ]; then
|
||||
if store_unclean; then say "$UNCLEAN is present; the store is left as it is for Jason or Mos"; ext_fail store-unclean; fi
|
||||
STORE_SCHEMA="$(store_read version)" && [ -n "$STORE_SCHEMA" ] || ext_fail schema-unreadable
|
||||
say "stopped store schema: $STORE_SCHEMA"
|
||||
if [ "$1" = migrate ]; then
|
||||
if [ "$STORE_SCHEMA" != "$T_SCHEMA" ]; then
|
||||
in_chain "$STORE_SCHEMA" || ext_fail store-newer
|
||||
[ "$MIGRATE" = 1 ] || ext_fail migration-needs-flag
|
||||
fi
|
||||
backup_store || ext_fail backup
|
||||
echo "$BACKUP" > "$RS/BACKUP" || ext_fail backup
|
||||
if [ "$STORE_SCHEMA" != "$T_SCHEMA" ]; then
|
||||
# MIGRATING and TAKEN before the migration, so revert restores a partial one (r6 B2).
|
||||
: > "$RS/MIGRATING" || ext_fail migrate-failed
|
||||
TAKEN="$ACT"
|
||||
node "$COPIES/$TO/packages/bus/src/migrate-schema.mjs" "$DATA" >> "$STEPS" 2>&1 || ext_fail migrate-failed
|
||||
say "migrated the store to $T_SCHEMA"
|
||||
fi
|
||||
else
|
||||
[ "$STORE_SCHEMA" = "$P_SCHEMA" ] || ext_fail store-differs
|
||||
if [ -n "$RESTORE" ]; then
|
||||
local n
|
||||
n="$(store_read count "$(cat "$DATA/backups/stack/$RESTORE/STAMP")" "$COUNTED")" || ext_fail schema-unreadable
|
||||
[ "${n%%$'\t'*}" = "$COUNT" ] || ext_fail post-backup-events
|
||||
echo "$RESTORE" > "$RS/BACKUP" || ext_fail restore
|
||||
# RESTORED tells confirm --rollback the store was swapped, so it skips OLD's gate
|
||||
# and logs rollbackCode not-attempted (ruling 4, T14b).
|
||||
if ! restore_store "$RESTORE"; then
|
||||
[ -z "$RESTORED" ] || : > "$RS/RESTORED" || true
|
||||
ext_fail restore
|
||||
fi
|
||||
: > "$RS/RESTORED" || ext_fail restore
|
||||
fi
|
||||
fi
|
||||
elif [ -n "$RESTORE" ]; then
|
||||
ext_fail store-differs
|
||||
fi
|
||||
scripts/stack-copy.sh wrapper "$TO" "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" >> "$STEPS" 2>&1 || ext_fail wrapper
|
||||
trap - INT TERM HUP
|
||||
say "staged $TO"
|
||||
echo "stack-release: $OP staged $TO for $BUSINESS as $ACT; root installs and starts the units, then runs confirm $TO $BUSINESS --units external"
|
||||
}
|
||||
|
||||
# r6 "The open activation": exactly one AWAITING-UNITS file, its activation the one the log holds
|
||||
# open, holding the copy root passed. Sets O_ACT. Anything else refuses open-activation, and ACT
|
||||
# stays unset, so the refused line names no activation and the open one stays open.
|
||||
open_activation() {
|
||||
local -a markers open
|
||||
local m found="" seen=""
|
||||
mapfile -t markers < <(compgen -G "$STATE/stack-rollback/*/AWAITING-UNITS" || true)
|
||||
mapfile -t open < <(interrupted)
|
||||
for m in "${markers[@]}"; do
|
||||
seen+=" $(basename "$(dirname "$m")") (copy $(cat "$m" 2>/dev/null || echo unreadable))"
|
||||
done
|
||||
if [ "${#markers[@]}" = 1 ] && [ "${#open[@]}" = 1 ]; then
|
||||
m="$(basename "$(dirname "${markers[0]}")")"
|
||||
[ "$m" != "${open[0]}" ] || [ "$(cat "${markers[0]}" 2>/dev/null)" != "$ID" ] || found="$m"
|
||||
fi
|
||||
[ -n "$found" ] || refuse open-activation "need one AWAITING-UNITS for copy $ID whose activation the log holds open; markers:${seen:- none}; open: ${open[*]:-none}"
|
||||
O_ACT="$found"
|
||||
}
|
||||
|
||||
# The op, from, to and start second of O_ACT's `started` line, one per line.
|
||||
started_of() {
|
||||
node -e '
|
||||
const [file,act]=process.argv.slice(1);
|
||||
let s;
|
||||
for(const l of require("fs").readFileSync(file,"utf8").split("\n")){
|
||||
if(!l.trim())continue;
|
||||
let o;try{o=JSON.parse(l)}catch{continue}
|
||||
if(o.activation===act&&o.result==="started")s=o;
|
||||
}
|
||||
const at=s&&Math.floor(Date.parse(s.at)/1000);
|
||||
if(!s||!["activate","rollback"].includes(s.op)||!Number.isFinite(at))process.exit(1);
|
||||
process.stdout.write([s.op,s.from??"",s.to??"",at].join("\n")+"\n");' -- "$LOG" "$O_ACT"
|
||||
}
|
||||
|
||||
# A5, and R4 with --rollback. The lines it writes carry the open activation's op, from, to and
|
||||
# activation, and the backup A3 recorded, so from_log reads them as a dragon-lin run's. Every check
|
||||
# runs before ACT is set.
|
||||
do_confirm() {
|
||||
lock_only
|
||||
open_activation
|
||||
local s
|
||||
local -a f
|
||||
s="$(started_of)" || refuse open-activation "the log's started line for $O_ACT is unreadable"
|
||||
mapfile -t f <<< "$s"
|
||||
[ "${f[2]}" = "$ID" ] || refuse open-activation "$O_ACT started for ${f[2]}, not $ID"
|
||||
if read_pointer; then [ "$P_BUSINESS" = "$BUSINESS" ] || refuse business-mismatch "the pointer is for $P_BUSINESS"; fi
|
||||
if [ -n "$ROLLBACK_OLD" ]; then
|
||||
[ "$ROLLBACK_OLD" = "${f[1]}" ] || refuse rollback-mismatch "$ROLLBACK_OLD is not the copy $O_ACT started from, ${f[1]:-none}"
|
||||
else
|
||||
[ ! -e "$STATE/stack-rollback/$O_ACT/FAILED" ] || refuse activation-failed "$O_ACT has a FAILED file; root runs revert and confirm --rollback"
|
||||
target_schema "$ID" || refuse copy-schema-unknown "cannot read SCHEMA_VERSION from $COPIES/$ID/packages/bus/src/store.mjs"
|
||||
fi
|
||||
OP="${f[0]}" FROM="${f[1]}" TO="${f[2]}" FLOOR="${f[3]}" ACT="$O_ACT" RS="$STATE/stack-rollback/$O_ACT"
|
||||
STEPS="$RS/steps.log"
|
||||
BACKUP="$(cat "$RS/BACKUP" 2>/dev/null || true)"
|
||||
if [ -z "$ROLLBACK_OLD" ]; then
|
||||
read_units || ext_fail units-unreadable
|
||||
P_CONSOLE=("${U_CONSOLE[@]}")
|
||||
gate "$TO" "$U_PM" || ext_fail "$GATE"
|
||||
write_pointer "$TO" "$U_PM" 0 "$U_CLAUDE" "$T_SCHEMA" "${U_CONSOLE[@]}"
|
||||
log_line result ok
|
||||
rm -f "$RS/AWAITING-UNITS"
|
||||
echo "stack-release: $OP ok: $TO is live for $BUSINESS"
|
||||
return
|
||||
fi
|
||||
local code rcode=""
|
||||
if [ "$CAUSE" = A4 ]; then code=units-start
|
||||
else code="$(head -n 1 "$RS/FAILED" 2>/dev/null || true)"; [ -n "$code" ] || code=unrecorded
|
||||
fi
|
||||
if [ -e "$RS/RESTORED" ]; then rcode=not-attempted
|
||||
elif ! read_units; then rcode=units-unreadable
|
||||
else
|
||||
P_CONSOLE=("${U_CONSOLE[@]}")
|
||||
gate "$ROLLBACK_OLD" "$U_PM" || rcode="$GATE"
|
||||
fi
|
||||
if [ -z "$rcode" ]; then
|
||||
local r=failed
|
||||
[ "$CAUSE" != A5 ] || r=gate-failed
|
||||
log_line result "$r" code "$code" rollback ok
|
||||
rm -f "$RS/AWAITING-UNITS" "$RS/FAILED"
|
||||
echo "stack-release: $code; rolled back to $ROLLBACK_OLD, which passed the gate" >&2
|
||||
exit 1
|
||||
fi
|
||||
say "rollback-failed: run $code, rollback $rcode"
|
||||
log_line result rollback-failed code "$code" rollback failed rollbackCode "$rcode"
|
||||
rm -f "$RS/AWAITING-UNITS" "$RS/FAILED"
|
||||
echo "stack-release: rollback-failed: the run failed with $code and the rollback with $rcode; root stops the units. Rollback set: $RS${BACKUP:+, backup $DATA/backups/stack/$BACKUP}" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
revert_fail() {
|
||||
say "revert failed: $1"
|
||||
echo "stack-release: revert failed: $1; $O_ACT stays open. Rollback set: $RS" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The store already holds backup $1 when every file checks against its SHA256SUMS and the listing
|
||||
# matches, as after a revert that restored it.
|
||||
store_matches() {
|
||||
local o="$DATA/backups/stack/$1"
|
||||
[ -d "$DATA/bus" ] && (cd "$DATA/bus" && sha256sum -c --quiet "$o/SHA256SUMS") >/dev/null 2>&1 &&
|
||||
[ "$(ls -1A "$DATA/bus")" = "$(ls -1A "$o/bus")" ]
|
||||
}
|
||||
|
||||
# R2: puts the wrapper back and, after a migration, the backup taken before it. Each part can run
|
||||
# again, so it serves an A3 that stopped partway. A rollback's --restore swap is confirm's to
|
||||
# report, not revert's to undo. Writes no log line.
|
||||
do_revert() {
|
||||
lock_only
|
||||
open_activation
|
||||
RS="$STATE/stack-rollback/$O_ACT" STEPS="$STATE/stack-rollback/$O_ACT/steps.log"
|
||||
[[ "$O_ACT" =~ ^([0-9]{8}T[0-9]{6}Z)-[0-9a-f]{12}(-[1-9])?$ ]] || revert_fail "$O_ACT is not an activation id"
|
||||
POST="$DATA/bus.post-attempt-${BASH_REMATCH[1]}${BASH_REMATCH[2]}"
|
||||
[ -f "$RS/wrapper" ] && [ -f "$RS/WRAPPER_MODE" ] || revert_fail "the rollback set has no saved wrapper"
|
||||
local tmp
|
||||
tmp="$(mktemp "$BIN/.$WRAPPER_NAME.XXXXXX")" && cp -p "$RS/wrapper" "$tmp" && chmod "$(cat "$RS/WRAPPER_MODE")" "$tmp" && mv -f "$tmp" "$BIN/$WRAPPER_NAME" ||
|
||||
revert_fail "could not put the wrapper back"
|
||||
[ "$(sha256sum < "$BIN/$WRAPPER_NAME" | cut -d' ' -f1)" = "$(sum_of "$RS" wrapper)" ] &&
|
||||
[ "$(stat -c %a "$BIN/$WRAPPER_NAME")" = "$(cat "$RS/WRAPPER_MODE")" ] || revert_fail "the wrapper does not match the rollback set"
|
||||
say "put back the wrapper from $RS"
|
||||
if [ -e "$RS/MIGRATING" ]; then
|
||||
TAKEN="$(cat "$RS/BACKUP" 2>/dev/null || true)"
|
||||
[ -n "$TAKEN" ] || revert_fail "MIGRATING is set and BACKUP names no backup"
|
||||
if store_matches "$TAKEN"; then say "the store already matches backup $TAKEN"
|
||||
else restore_store "$TAKEN" || revert_fail "could not restore backup $TAKEN"
|
||||
fi
|
||||
fi
|
||||
echo "stack-release: revert ok: $O_ACT's wrapper$([ -e "$RS/MIGRATING" ] && echo ' and store are' || echo ' is') back; root installs the old units next"
|
||||
}
|
||||
|
||||
# A2f: gates copy ID with no open activation, on a start at or after --since. Takes no lock and
|
||||
# writes nothing, so it refuses nothing: 0 on a pass, 1 otherwise.
|
||||
do_gate() {
|
||||
FLOOR="$SINCE"
|
||||
read_units || { echo "stack-release: gate: cannot read $BUS_UNIT or $CONSOLE_UNIT" >&2; exit 1; }
|
||||
P_CONSOLE=("${U_CONSOLE[@]}")
|
||||
gate "$ID" "$U_PM" || exit 1
|
||||
}
|
||||
|
||||
# --- pm-on (row 169 item 3) ---
|
||||
|
||||
# Stops the bus alone and waits for it and the stopped check, within --timeout. The Console runs on.
|
||||
stop_bus() {
|
||||
systemctl --user stop "$BUS_UNIT" || return 1
|
||||
sc stop "$BUS_UNIT" || return 1
|
||||
local deadline=$((SECONDS + TIMEOUT)) s
|
||||
while :; do
|
||||
s="$(systemctl --user is-active "$BUS_UNIT" 2>/dev/null || true)"
|
||||
s="$(sc is-active "$BUS_UNIT" 2>/dev/null || true)"
|
||||
case "$s" in inactive|failed) if stopped; then say "stopped $BUS_UNIT"; return 0; fi ;; esac
|
||||
[ "$SECONDS" -lt "$deadline" ] || return 1
|
||||
sleep 1
|
||||
@@ -1326,7 +1710,7 @@ stop_bus() {
|
||||
|
||||
start_bus() {
|
||||
START_EPOCH="$(date +%s)" && START_MS="$(date +%s%3N)" || return 1
|
||||
systemctl --user start "$BUS_UNIT"
|
||||
sc start "$BUS_UNIT"
|
||||
}
|
||||
|
||||
# mos-0848 point 3: put the phase-1 bus unit back (no --pm), restart it and rerun the phase-1 gate.
|
||||
@@ -1348,8 +1732,8 @@ pm_on_fail() {
|
||||
exit 1
|
||||
fi
|
||||
local pwhy="$GATE_WHY"
|
||||
systemctl --user stop "$CONSOLE_UNIT" || true
|
||||
systemctl --user stop "$BUS_UNIT" || true
|
||||
sc stop "$CONSOLE_UNIT" || true
|
||||
sc stop "$BUS_UNIT" || true
|
||||
say "pm-on-down: pm-on $code, phase 1 $pcode"
|
||||
log_line result pm-on-down code "$code" why "${why:-@null}" phase1 "$pcode" phase1Why "${pwhy:-@null}"
|
||||
echo "stack-release: pm-on-down: pm-on failed with $code${why:+ ($why)} and the phase-1 bus with $pcode${pwhy:+ ($pwhy)}; both units are stopped. Rollback set: $RS" >&2
|
||||
@@ -1359,6 +1743,7 @@ pm_on_fail() {
|
||||
# Phase 2: the bus unit alone, on the pointer's copy with --pm, gated with pm_launched.
|
||||
do_pm_on() {
|
||||
lock
|
||||
[ "$UNITS" = user ] || refuse external-pm "under --units external the PM is a flag in root's bus unit; changing it is a re-install with its own go"
|
||||
pointer_for_business
|
||||
[ "$P_PENDING" = 1 ] || refuse not-pending "the pointer is not pending; pm-on follows activate --pm-after-harness"
|
||||
local out
|
||||
@@ -1395,8 +1780,16 @@ do_status() {
|
||||
local open
|
||||
open="$(interrupted | paste -sd' ' -)"
|
||||
[ -z "$open" ] || echo "interrupted run: $open (started, no final line; Jason or Mos settles it)"
|
||||
if [ "$UNITS" = external ]; then
|
||||
local m
|
||||
local -a markers
|
||||
mapfile -t markers < <(compgen -G "$STATE/stack-rollback/*/AWAITING-UNITS" || true)
|
||||
for m in "${markers[@]}"; do
|
||||
echo "awaiting units: $(basename "$(dirname "$m")") (copy $(cat "$m" 2>/dev/null || echo unreadable))"
|
||||
done
|
||||
fi
|
||||
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 "${INSTANCE_FLAGS[@]}" "${UNITS_FLAGS[@]}" --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
|
||||
@@ -1722,11 +2115,16 @@ ID=""
|
||||
case "$OP" in
|
||||
activate|rehearse) [ $# -ge 2 ] || usage; ID="$1"; BUSINESS="$2"; shift 2; [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || usage ;;
|
||||
adopt|pm-on|rollback|status) [ $# -ge 1 ] || usage; BUSINESS="$1"; shift ;;
|
||||
confirm|revert|gate) [ $# -ge 2 ] || usage; ID="$1"; BUSINESS="$2"; shift 2; [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || usage ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage
|
||||
parse_opts "$@"
|
||||
# Row 232: the stack-copy.sh check flags and the systemctl the instance-busy message names.
|
||||
UNITS_FLAGS=()
|
||||
SC_NAME="systemctl --user"
|
||||
if [ "$UNITS" = external ]; then UNITS_FLAGS=(--units external); SC_NAME=systemctl; fi
|
||||
COPIES_DIR="$COPIES"
|
||||
instance_select
|
||||
load_config
|
||||
@@ -1741,6 +2139,7 @@ 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=()
|
||||
BASE_CONSOLE=() SERVE_FLAGS=() SSO_PLAN="" SSO_STEP="" SSO_DROP="" SSO_NO_TARGET=""
|
||||
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
|
||||
O_ACT="" FLOOR=0
|
||||
case "$OP" in
|
||||
adopt) do_adopt ;;
|
||||
activate) do_activate ;;
|
||||
@@ -1748,4 +2147,7 @@ case "$OP" in
|
||||
rollback) do_rollback ;;
|
||||
status) do_status ;;
|
||||
rehearse) do_rehearse ;;
|
||||
confirm) do_confirm ;;
|
||||
revert) do_revert ;;
|
||||
gate) do_gate ;;
|
||||
esac
|
||||
|
||||
Reference in New Issue
Block a user