Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,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,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,74 @@
|
||||
# 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:
|
||||
210 (r4 due 10-21), 237, 194, 234.
|
||||
- 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 c8bc5cf4c)
|
||||
|
||||
`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 | open | 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 | open | Publish busy check (199 N1). |
|
||||
| 234 | open | Machine grant row a and the 3i to 3j schema step. `--plan` must say `migration: 3i -> 3j`. |
|
||||
| PRD 1.7+1.8 | open | `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.
|
||||
- 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, written once both have landed.
|
||||
@@ -1390,3 +1390,6 @@ 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+15
-10
@@ -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 2603 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 | in-review, round 2, 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) | Sage gate 2026-10-21 on 92fac9904 (c8bc5cf4c + 3cf63efb4): mutants 52/53, M22b equivalent (no await broker.mjs:1741 to credentials.mjs:358; coverage moves to row 235, gizmoduck-0088). Gate STOP pkg-bus 497/1: B1 broker.mjs:7 imports business/src/hosts.mjs (not in RUNNER_FILES, nor errors.mjs/util.mjs); project-runtime.test.mjs:12 pin fails; seat sandbox.test.mjs 6/4 vs base 10/0. r4 needed (sage-1617). |
|
||||
| 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,17 @@ 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 | — |
|
||||
| 232 | Installer I1: stack-release external units and stack-copy bundle copies (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1734 | in-review, round 1, request posted | — | 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 | briefed | 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 | — |
|
||||
| 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 | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+2261
-53
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
|
||||
|
||||
|
||||
@@ -1035,6 +1035,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 });
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user