Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84f1a91e6e | ||
|
|
a0ffabc3f9 |
@@ -5174,6 +5174,3 @@ 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.
|
||||
2026-10-21T04:50:44Z | Darkwing | row 233 #1735 installer I3 build, round 1 in review | Before: root had no upgrade driver for root-owned units and no host run sheet; stack-release.sh had no rehearse --backup last and no external plan refusals. After: scripts/stack-upgrade.sh upgrade and rollback (r6 A1-A5, A2f, R1-R4) with 20 tests, docs/guides/host-run-sheet.md, TOOLS.md lines, and on Sage's rulings stack-release.sh --backup last, BACKUP_STORE and the --plan refusals open-activation and migrate-required (3 tests). Base e65a3ab4d (row 232's landing). Gate on Sage's slot (sage-1704) on d99166ade: cli 616/0, every scripts/test-*.sh green, 1201/0 summed by awk; m1-m8 8/8 killed. Correction: the process sweep after the gate found an orphaned sleep 60 from the lock test's flock holder (N1); 08db6ac3f fixes it test only, Sage ruled the gate stands (sage-1707), rerun c0 20/0 and m1 killed with 0 left (sage-1708). Tip 1a1dc80ca (pushed, seat branch only). Manifest 464ce9eb (7 paths), queue revs 2644 and 2647, request comment 29901, reviewer Filbert. Packet agents/darkwing/work/queue-233/BUILD.md.
|
||||
2026-10-21T06:37:41Z | Darkwing | row 240 #1746 installer I4 build, round 1 in review | Before: root had no host check for the bwrap AppArmor profile, its pins or a boot unit, and stack-upgrade.sh exited 1 args-failed when the args move after A5 failed, while exit 1 means the old copy runs. After: scripts/case1-hostcheck.sh (seven items, --check, --enforce, --upstream), scripts/case1-pins.sh, mosaic-hostcheck.service and 16 tests; the args move failure exits 5 args-failed, documented in TOOLS.md and run sheet section 6 and tested; run sheet section 3 puts the pinned npm on PATH and names the bin/claude.exe --claude path; sections 8 and 10 have precheck lines. Verification: slot run r1 on 47ec9fe2f failed at the singles (the new N1 test's after hook threw ENOENT after the fixture cleanup removed the root); b45852daa drops the hook; r2 on b45852daa green: singles 16/0 and 21/0, c0 plus M1-M8 8/8 killed, cli 639/0, scripts 585/0. Candidate darkwing/row240-r3 ad1b23ab2 on c771a36f4, manifest 573ece95 (9 paths), revs 2694 and 2695, comment 29943.
|
||||
|
||||
+3
-7
@@ -86,8 +86,7 @@ The layers each adapter relies on, and what they don't cover, are in
|
||||
### A role's skills (row 64)
|
||||
|
||||
A managed session's skills reach both adapters as text in the bundle's
|
||||
`prompt.md`, which they pass as a file: `pi` with `--system-prompt <path>`
|
||||
and `claude` with `--system-prompt-file <path>` (row 213). Neither adapter loads a
|
||||
`prompt.md`, which they pass with `--system-prompt`. Neither adapter loads a
|
||||
skill of its own: `pi` keeps `--no-skills`, since role sessions never get
|
||||
`MOSAIC_SKILLS`, and `claude` keeps `--disable-slash-commands` and
|
||||
`--restricted`. Sage chose this on 2026-10-19 from a probe, recorded in
|
||||
@@ -105,8 +104,5 @@ skill of its own: `pi` keeps `--no-skills`, since role sessions never get
|
||||
|
||||
The cost is prompt size: a role's skill text is in every turn, where the
|
||||
other routes load a skill when it's used. The bundle caps a role's skills at
|
||||
64 KiB together. The cap dates from when `claude` passed `prompt.md` as one
|
||||
argument, which Linux caps at 128 KiB; row 213 passes the file instead, so no
|
||||
local user reads the prompt in `/proc/<pid>/cmdline`. Pi 0.85.1 uses the path
|
||||
string itself as the prompt when the file is missing or can't be read, so the
|
||||
pi adapter exits 2 unless the path is a readable regular file.
|
||||
64 KiB together, since `claude` passes `prompt.md` as one argument and Linux
|
||||
caps one argument at 128 KiB.
|
||||
|
||||
@@ -27,11 +27,6 @@ need MOSAIC_CLAUDE_MCP_CONFIG
|
||||
for f in "$MOSAIC_SYSTEM_PROMPT_FILE" "$MOSAIC_CLAUDE_SETTINGS" "$MOSAIC_CLAUDE_MCP_CONFIG"; do
|
||||
[ -r "$f" ] || { echo "claude adapter: not readable: $f" >&2; exit 2; }
|
||||
done
|
||||
PROMPT_FILE=$MOSAIC_SYSTEM_PROMPT_FILE
|
||||
case "$PROMPT_FILE" in
|
||||
/*) ;;
|
||||
*) PROMPT_FILE="$(pwd)/$PROMPT_FILE" ;;
|
||||
esac
|
||||
[ "${MOSAIC_INTERACTIVE:-}" != "1" ] || { echo "claude adapter: interactive mode isn't supported" >&2; exit 2; }
|
||||
CLAUDE=claude
|
||||
if [ -n "${MOSAIC_CLAUDE_BIN:-}" ]; then
|
||||
@@ -61,10 +56,7 @@ fi
|
||||
# Flags:
|
||||
# -p one-shot: print the answer and exit
|
||||
# --output-format text stdout is the answer only
|
||||
# --system-prompt-file the generated prompt replaces the default, given
|
||||
# as a file so no local user reads it in
|
||||
# /proc/<pid>/cmdline (row 213); absolute, since
|
||||
# the cd above changed the working directory
|
||||
# --system-prompt the generated prompt replaces the default
|
||||
# --effort the instance's thinking var (row 65; 2.1.302 takes
|
||||
# low, medium, high, xhigh, max)
|
||||
# --restricted no user/project/local settings files; --settings
|
||||
@@ -84,9 +76,10 @@ fi
|
||||
# text in the system prompt (row 64)
|
||||
ALLOWED=mcp__mosaic
|
||||
[ -z "${MOSAIC_TOOLS:-}" ] || ALLOWED="$MOSAIC_TOOLS,mcp__mosaic"
|
||||
PROMPT_CONTENT="$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")"
|
||||
"$CLAUDE" -p "$MOSAIC_REQUEST" \
|
||||
--output-format text \
|
||||
--system-prompt-file "$PROMPT_FILE" \
|
||||
--system-prompt "$PROMPT_CONTENT" \
|
||||
--model "$MOSAIC_MODEL" \
|
||||
--effort "$MOSAIC_THINKING" \
|
||||
--restricted \
|
||||
|
||||
+4
-14
@@ -8,17 +8,7 @@
|
||||
set -eu
|
||||
|
||||
[ -n "${MOSAIC_SYSTEM_PROMPT_FILE:-}" ] || { echo "pi adapter: MOSAIC_SYSTEM_PROMPT_FILE is required" >&2; exit 2; }
|
||||
# Row 213 (C2-08): pi gets the prompt as a path, so no local user reads it in /proc/<pid>/cmdline.
|
||||
# Pi 0.85.1 reads --system-prompt as a file only when the path exists and reads, and otherwise
|
||||
# uses the path string itself as the prompt, without a warning for a missing file
|
||||
# (dist/core/resource-loader.js resolvePromptInput). So the path must be a regular readable
|
||||
# file here, and is made absolute before the cd below, since pi resolves it from its own cwd.
|
||||
PROMPT_FILE=$MOSAIC_SYSTEM_PROMPT_FILE
|
||||
case "$PROMPT_FILE" in
|
||||
/*) ;;
|
||||
*) PROMPT_FILE="$(pwd)/$PROMPT_FILE" ;;
|
||||
esac
|
||||
[ -f "$PROMPT_FILE" ] && [ -r "$PROMPT_FILE" ] || { echo "pi adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
|
||||
[ -r "$MOSAIC_SYSTEM_PROMPT_FILE" ] || { echo "pi adapter: system prompt not readable: $MOSAIC_SYSTEM_PROMPT_FILE" >&2; exit 2; }
|
||||
# MOSAIC_AGENT_NAME is optional in headless mode (identity section is then
|
||||
# omitted); interactive launches always set it via scripts/agent.sh.
|
||||
|
||||
@@ -104,8 +94,7 @@ fi
|
||||
# All flags documented in the pi package README (CLI Reference):
|
||||
# -p/--print one-shot mode: print the response and exit (omitted in
|
||||
# interactive TUI mode)
|
||||
# --system-prompt replace the default prompt with the generated one, given
|
||||
# as the file's absolute path (checked above), never its text
|
||||
# --system-prompt replace the default prompt with the generated one
|
||||
# --no-* no ambient context/skills/extensions/templates/themes
|
||||
# EXT_FLAGS the explicitly provided extensions only (-e)
|
||||
# SESSION_FLAGS ephemeral | persistent | forked (per env)
|
||||
@@ -114,6 +103,7 @@ fi
|
||||
# --offline no startup network operations (update checks/telemetry)
|
||||
# --no-approve ignore project-local .pi/ files (settings, SYSTEM.md)
|
||||
# whatever trust is saved for the workspace
|
||||
PROMPT_CONTENT="$(cat "$MOSAIC_SYSTEM_PROMPT_FILE")"
|
||||
set -- \
|
||||
--offline \
|
||||
--no-approve \
|
||||
@@ -128,7 +118,7 @@ set -- \
|
||||
--provider "$PI_PROVIDER" \
|
||||
--model "$PI_MODEL" \
|
||||
$THINKING_FLAG \
|
||||
--system-prompt "$PROMPT_FILE"
|
||||
--system-prompt "$PROMPT_CONTENT"
|
||||
# One-shot mode appends -p and the request (both safely quoted);
|
||||
# interactive mode appends nothing - clean TUI.
|
||||
[ "$INTERACTIVE" = "1" ] || set -- "$@" -p "$MOSAIC_REQUEST"
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
# Row 195 build: `scripts/case1-accept.sh`
|
||||
|
||||
Row 195 (#1693), the Case 1 install on 10.1.1.53. Design r7 at 5425ea2f3, approved by Duckworth
|
||||
(duckworth-0087, `agents/duckworth/work/row195/REVIEW-r7.md` at 58d930f89; Gitea 29962). Sage's go to build
|
||||
is sage-1817.
|
||||
|
||||
## What this commit holds
|
||||
|
||||
- `scripts/case1-accept.sh` (new, 100755): section 5's acceptance, F1-F11 and P1-P11.
|
||||
- `packages/cli/tests/case1-accept.test.mjs` (new): section 5's cases.
|
||||
- `docs/TOOLS.md`: the `case1-accept.sh` entry after the hostcheck entry.
|
||||
- This file.
|
||||
- `DESIGN.md`, doc-only fixes Sage named in sage-1817, with no new design round:
|
||||
- Duckworth's N1. Section 1's human-id row cited `broker.mjs:245`; the check is `broker.mjs:321`.
|
||||
- Row 226's state at :21 and :326. It said "unclaimed"; 226 is assigned to Gosalyn, briefed and not
|
||||
started (sage-1814).
|
||||
- :340 said I'd send the section 6 items to Gosalyn once 226 was claimed. I sent them on 2026-10-21
|
||||
and Gosalyn acknowledged (gosalyn-0085), so the line now says that.
|
||||
|
||||
Nothing else changes. No `case2-*.sh`, `stack-upgrade.sh`, `stack-release.sh`, `case1-hostcheck.sh` or run
|
||||
sheet edit, and nothing touches .53 (sage-1809).
|
||||
|
||||
## Deviations from section 5, and choices it left open
|
||||
|
||||
1. P4's mark is the single token `accepted Q182+Q183`. Section 5 lists `accepted Q182` and `accepted
|
||||
Q183` as marks and gives P4 both. One line can carry one mark, and P4 is the only Q182 path, so the
|
||||
count list has `accepted Q182+Q183` in place of `accepted Q182`.
|
||||
2. The test seam replaces `getent`. Under `--root`, the script reads `DIR/etc/passwd` and `DIR/etc/group`
|
||||
with awk. Without it, the same helper calls `getent`. The groups of a user are its primary gid, then
|
||||
each group whose member list names it, which is what `id -G` prints. So there is no fake `id`, and
|
||||
the script never calls `id` for another user.
|
||||
3. P10 reads the port floor from `/proc/sys/net/ipv4/ip_unprivileged_port_start` rather than calling
|
||||
`sysctl`, so the seam covers it the way hostcheck's item 7 is covered. There is no fake `sysctl`.
|
||||
4. There is no fake `bwrap`, `claude`, `node` or `hostcheck.sh` on PATH. The script runs each by the
|
||||
absolute path the args file or the install names, so the test puts stubs at those paths under
|
||||
`--root`, and the fake `sudo` runs the stack's user's commands from the tree.
|
||||
5. The args file must say `--profile factory` and name `--business`. Both are new refusals beyond
|
||||
`stack-upgrade.sh`'s rules. A library file, or one for another business, would make F4 and F7
|
||||
check the wrong thing. After `--`, `--port` and `--tester-port` must be numeric. The args file is the
|
||||
only source of the ports.
|
||||
6. F8, F9 and F11 read file metadata as root: `stat`, `-L` and `find -maxdepth 1 -name '*.json'`. None
|
||||
opens the credential. F9 parses `managed-settings.json` with the pinned node, run by root: that node
|
||||
and that file are both root's own, installed by ops-01.
|
||||
7. F7 reads `launch.by` from `$HOME/.config/mosaic-dev/businesses/<id>.json` as the stack's user. That
|
||||
path is `businessFilePath(id)` under the default config, which is what the unit uses. It doesn't load
|
||||
the copy's `business.mjs` as `stack-release.sh`'s `plugins launchby` does. The user runs the whole
|
||||
check and prints one line, and root accepts only `ok <instance> run <run>` (sage-1653: root doesn't
|
||||
parse the user's output beyond a fixed pattern).
|
||||
8. F5 names a process claude when its argv0's basename or its `comm` starts with `claude`. The window
|
||||
confirms this against a real role session. If the bwrap'd claude shows another name, F5 fails closed
|
||||
and the fix is this match, not the label check.
|
||||
9. F3 and F5 run together. `talk` goes out in the background under `timeout 330`, and the `/proc` scan
|
||||
repeats every half second until it finds a labelled pair or `talk` exits. F3's line prints first, then
|
||||
F4, then F5, so the output keeps id order. F3 passes on exit 0 with the `sent` line and a reply line,
|
||||
both counted on stdout only (`cli.mjs:395` and the `formatMessage` write); talk's error goes to stderr.
|
||||
12. `--claude` must match `^/[A-Za-z0-9/._+@-]+$`. The `@` is for `node_modules/@anthropic-ai/...`;
|
||||
`stack-upgrade.sh` doesn't check `--claude` at all.
|
||||
10. F1 requires all four uids in `/proc/<MainPID>/status` (real, effective, saved, filesystem) to be the
|
||||
user's.
|
||||
11. Evidence text the stack's user controls (talk's last line, the precheck's last line, F6's and F10's
|
||||
`--version`) is cut to printable ASCII, one line, 200 bytes.
|
||||
|
||||
## Tests
|
||||
|
||||
`packages/cli/tests/case1-accept.test.mjs`, 22 tests. Every case builds a scratch host with every F check
|
||||
passing and every P check closed or accepted, then changes one thing:
|
||||
- all pass: exit 0, ids in order, each P mark, the count lines match the check lines, and the stand-in
|
||||
credential's value appears nowhere;
|
||||
- each F check failing alone exits 1 with only its line failing;
|
||||
- F3 on exit 0 with only the `sent` line, and its evidence taking talk's stderr;
|
||||
- F1 on a failed unit and on `User=root`; F2 on healthz failing and on another pid holding the port;
|
||||
- F5 on `unpriv_bwrap (enforce)`, `bwrap//&unpriv_bwrap (complain)`, a trailing space on either label,
|
||||
no bwrap of the user's, and passing on `comm` alone;
|
||||
- F6 on a stub that prints nothing and on a path outside `claude-<pin>`;
|
||||
- F7 on two live PM launches, a live one for another business, and no `launch.by`;
|
||||
- F8 on 0640, on another owner and on a symlink, never printing the credential;
|
||||
- F9 on a group-writable mode, another owner and a number for the flag;
|
||||
- F11 on one `*.json` under `discord/`, and passing on an empty directory;
|
||||
- each P check's unnamed mark, with the line count each fault makes: P6 one line per member, P9 one per
|
||||
missing item; P2, P5, P10 and P11 in more than one way;
|
||||
- every P check unnamed at once still exits 0;
|
||||
- nine bad args files refuse with exit 2 before any command runs, each with its own refusal text;
|
||||
- usage errors, and a user's run without `--root`, exit 2;
|
||||
- every in-instance call goes through `sudo -u mosaic -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin`,
|
||||
read from the fake `sudo`'s argv, and root's other `sudo` calls are exactly `-l -U mosaic`,
|
||||
`-l -U localadmin` and the jarvis bwrap probe;
|
||||
- the host check records `--check` and never `--enforce`.
|
||||
|
||||
## Mutants
|
||||
|
||||
Section 5's twelve, each one exact replacement in `scripts/case1-accept.sh`, run by
|
||||
`/mnt/storage/scratch/darkwing-195-out/mutants.py` in Sage's slot (sage-1865). All twelve were killed.
|
||||
After each one the script's bytes were restored, and the final sha256 matched (`d434f15ff638...`). The
|
||||
killers below are the tests that failed in each mutant's saved log.
|
||||
|
||||
| # | Mutant | Killed by |
|
||||
|---|---|---|
|
||||
| 1 | F1 passes on a MainPID owned by root | each F alone |
|
||||
| 2 | F2 accepts a listener on 0.0.0.0 | each F alone |
|
||||
| 3 | F3 passes without a reply | each F alone; F3 exit 0 with no reply line |
|
||||
| 4 | F5 matches a substring | F5 labels (the trailing space) |
|
||||
| 5 | F6 skips the `--version` check | each F alone; F6 stub and path |
|
||||
| 6 | F8 skips the owner check | F8 0640, owner and symlink |
|
||||
| 7 | F11 ignores `discord/` | each F alone; F11 `*.json` |
|
||||
| 8 | P1 closes on any output | P unnamed; every P unnamed |
|
||||
| 9 | P5 maps to `closed` with jarvis in docker | P unnamed; every P unnamed; P2 and P5 names docker |
|
||||
| 10 | P9 drops the jarvis item | P unnamed; P9 per item |
|
||||
| 11 | P11 accepts any `Before=` | P unnamed; every P unnamed |
|
||||
| 12 | a P check's `unnamed` sets the exit to 1 | P unnamed; every P unnamed; P10 and P11; P2 and P5; P9 per item |
|
||||
|
||||
Mutant 1 survived the F1 variants test (a failed unit, `User=root`), because those faults fail F1 on the
|
||||
unit's state and `User=`, not on the uid line. Each F alone covers the uid line.
|
||||
|
||||
## Pre-slot read review
|
||||
|
||||
Since every test run is slot work, a read-only subagent traced each case through the script before the
|
||||
first slot run, executing nothing. It found four defects, all fixed in this commit:
|
||||
- The `--claude` regex had no `@`, so every run refused the args file, the real .53 one included.
|
||||
- The fake `stat` logged the args-file call, so the bad-args test's "no command ran" check would fail.
|
||||
- The P5 and P6 faults each rewrote `etc/group` from the constant, so applying both lost P5's change.
|
||||
- F3 counted stderr into its line count, so a mutant dropping the exit check survived. F3 now counts
|
||||
stdout only, and a `sent`-only case covers the line count.
|
||||
|
||||
## Run environment
|
||||
|
||||
The build commit ddf7dce6c sits on base 4ced5e63d (origin/refactor when it was pushed). It was first built
|
||||
on c515273db as 4a51d392c. Row 238 then changed `docs/TOOLS.md`, so I cherry-picked the three commits onto
|
||||
4ced5e63d as `darkwing/row195-b`. `git range-diff` shows all three identical. node v26.8.1 on dragon-lin,
|
||||
every run behind my busy check and Sage's, chained with `&&`.
|
||||
|
||||
- Step 1, sage-1829 (07:41:54Z to 07:42:05Z), TMPDIR `/tmp/dw195`:
|
||||
`node --test packages/cli/tests/case1-accept.test.mjs`, 22 pass, 0 fail, 11.0 s.
|
||||
- Step 2, sage-1865 (08:34:27Z to 08:36:21Z), TMPDIR `/mnt/storage/scratch/tmp/r195`: the twelve mutants
|
||||
above, all killed.
|
||||
- Step 3, the gate (08:36:21Z to 08:47:06Z), same TMPDIR, `COMPOSE_PROJECT_NAME=darkwing-r195`:
|
||||
|
||||
| Suite | Result |
|
||||
|---|---|
|
||||
| packages/cli (`node --test tests/*.test.mjs`) | 662 pass, 0 fail, 0 skipped |
|
||||
| test-auth.sh | 15 passed, 0 failed |
|
||||
| test-conductor.sh | 17 passed, 0 failed |
|
||||
| test-config.sh | 31 passed, 0 failed |
|
||||
| test-discord.sh | 78 passed, 0 failed |
|
||||
| test-extension-package.sh | 18 passed, 0 failed |
|
||||
| test-foundation.sh | 44 passed, 0 failed |
|
||||
| test-land-work.sh | 86 passed, 0 failed |
|
||||
| test-queue.sh | 156 node pass, 0 fail; 28 passed, 0 failed |
|
||||
| test-release.sh | 14 passed, 0 failed |
|
||||
| test-task.sh | 98 passed, 0 failed |
|
||||
|
||||
Summed: 818 node tests passed with 0 failed, and 429 selftests passed with 0 failed. The compose network
|
||||
was removed afterwards, and the sweep found no process left in the worktree, the output directory or the
|
||||
TMPDIR. This file's results are a doc-only commit on top of the build, so nothing that ran has changed.
|
||||
|
||||
## Correction to deviation 6 (follow-up, 2026-10-21)
|
||||
|
||||
Deviation 6 says the node F9 runs is root's own. At 37171f65b the script didn't check that. F9 ran
|
||||
`--node` as root before F10's path check, so the header's "root itself only reads" was false
|
||||
(Duckworth's N1, `agents/duckworth/work/row195/REVIEW-m1.md` at 6afcee2a5). A host installed as designed
|
||||
was safe, because `case2-unit.sh` refused a non-root-owned `--node`. Row 245 carries the fix and its
|
||||
tests: `agents/darkwing/work/queue-245/BUILD.md`.
|
||||
@@ -1,388 +0,0 @@
|
||||
# Row 195 design: the Case 1 install on 10.1.1.53 (r7)
|
||||
|
||||
Darkwing, 2026-10-21. Brief: `docs/plans/2026-10-20_hub-own-uid.md`, blob 45be7f09afc1, section "Brief: the
|
||||
Case 1 install on 10.1.1.53 (row 195)". Gitea #1693. Reviewer Duckworth, gate owner Sage. Branch
|
||||
`darkwing/row195-r7` on c515273db. r6 (b7239bb66 on `darkwing/row195`, approved duckworth-0055) is the base,
|
||||
carried unchanged as 7398b7dd7. The r6 text is in that commit and in b7239bb66.
|
||||
|
||||
## Changes from r6
|
||||
|
||||
Q187 (mos-1108) made install and upgrade product commands, and mos-1118 split r6 into the installer rows
|
||||
(`docs/plans/2026-10-20_installer-rows.md`). That brief says row 195 "shrinks to the factory profile's
|
||||
values and the Case 1 acceptance". So r7 is mostly a cut. What r6 designed now ships elsewhere:
|
||||
|
||||
| r6 piece | Now | State |
|
||||
|---|---|---|
|
||||
| `stack-release.sh --units external`: `confirm`, `revert`, `gate`, `rollback --to`, external `adopt` and `status`; `stack-copy.sh check --units external` and bundle copies | row 232, I1 | landed |
|
||||
| `case1-repin.sh` (A1-A5, A2f, R1-R4) | `scripts/stack-upgrade.sh`, row 233, I3 | landed |
|
||||
| the host steps 2a, 2a', 2a'' and the window's root steps | `docs/guides/host-run-sheet.md` sections 1-10, row 233 | landed |
|
||||
| `case1-hostcheck.sh`, `case1-pins.sh`, `mosaic-hostcheck.service` (the drift stop, r6 B2) | row 240, I4 | landed c515273db |
|
||||
| the unit render, `--profile factory`, `--pm`, `--claude`, the factory serve flags, the precheck | row 230, C2-13a, I2 | landed |
|
||||
| the install as `mosaic`: bootstrap, business file, copy | row 226, C2-13b, I2, `scripts/case2-install.sh` | assigned to Gosalyn, briefed, not started |
|
||||
| `docs/guides/case1-install.md` | the host run sheet, plus section 6 of this file | |
|
||||
|
||||
Other changes:
|
||||
|
||||
- Claude moves under `/opt` (sage-1522, run sheet section 3). `--claude` is
|
||||
`/opt/mosaic-stack/claude-<pin>/node_modules/@anthropic-ai/claude-code/bin/claude.exe`, root's, and root
|
||||
installs and upgrades it. r6's `/srv/mosaic/.local/bin/claude` is gone. F6 reads the new path.
|
||||
- The args file is `/etc/mosaic-stack/dev.args` (run sheet section 7), not `case1-unit.args`. It holds
|
||||
`--copy` too, and `stack-upgrade.sh` rewrites only that line, after confirm passes.
|
||||
- Both units are `WantedBy=multi-user.target` and start at boot (Q189, mos-1122). r6's "the Console has no
|
||||
`[Install]`, root starts it by hand after a reboot" no longer holds.
|
||||
- The business id is stated, with the DEFERRED 240 N1 reasoning (sage-1809, sage-1813). Section 7.
|
||||
- The window can't run until row 226 lands. Sage added 226 to row 195's after list at rev 2718
|
||||
(sage-1813). Section 6 lists what row 195 needs from 226's install.
|
||||
- The acceptance runs as root through ops-01, not as `localadmin`. `aa-status`, `sudo -l -U`,
|
||||
`systemd-run --uid` and the hostcheck all need root. It gains four functional checks for items the run
|
||||
sheet marks "precheck line missing" (F8-F11) and one host line (P11). Section 5.
|
||||
- The Discord row in section 2 changed. The precheck's "no Discord binding" line is C2-13b's and is
|
||||
library-only, so for the factory it's F11 in the acceptance.
|
||||
|
||||
What row 195 still builds: `scripts/case1-accept.sh` and its test (section 5). The rest of this file is
|
||||
inputs, the window order and the record the reviewer checks.
|
||||
|
||||
## 0. What I checked on the host
|
||||
|
||||
Read only, as `localadmin` over ssh, 2026-10-20 between 20:40Z and 21:51Z. Nothing in r7 touched .53
|
||||
(sage-1809). The window's first step re-reads these, since they can drift in a day: unattended-upgrades
|
||||
runs daily.
|
||||
|
||||
| Item | 10.1.1.53 on 2026-10-20 | Needed |
|
||||
|---|---|---|
|
||||
| OS | Ubuntu 24.04.5, kernel 6.8.0-124, x86_64, 12 cores, 94 GiB, 217 GiB free on `/` | fits |
|
||||
| `mosaic` user, group, `/srv/mosaic` | absent | run sheet section 1 |
|
||||
| node | `/usr/local/bin/node` v22.22.2, root-owned | 24 or later, under `/opt` (run sheet section 2) |
|
||||
| claude | not on localadmin's PATH | under `/opt` (run sheet section 3) |
|
||||
| bwrap | absent; apt candidate `bubblewrap 0.9.0-1ubuntu0.3` | run sheet section 4 |
|
||||
| user namespaces | `kernel.apparmor_restrict_unprivileged_userns = 1`, apparmor 4.0.1, no bwrap profile loaded | run sheet section 4 |
|
||||
| profiles that name bwrap | none (`grep -rlw bwrap /etc/apparmor.d` exits 1) | hostcheck item 3 |
|
||||
| `fs.protected_hardlinks` | 1 | hostcheck item 6 |
|
||||
| `net.ipv4.ip_unprivileged_port_start` | 1024 | run sheet section 5 |
|
||||
| listeners below 1024 | 22 (sshd) and 53 (the resolver); 732 is free | the Console's port |
|
||||
| ports 7330-7333 | free | 7333 is the tester port |
|
||||
| `localadmin` | NOPASSWD sudo (#423 step 1, mos-1066, mos-1077) | ops-01's root path |
|
||||
| `jarvis` | groups sudo, docker, lxd, adm | #423 step 4 removes them (P5) |
|
||||
| unattended-upgrades | on, with the security pocket, which carries bubblewrap | the drift stop (row 240) |
|
||||
| journald | `SplitMode=uid` | the PM check reads `launches list`, not the journal (row 232) |
|
||||
|
||||
## 1. The factory profile's values
|
||||
|
||||
Instance `dev`, row 190's default (mos-1118 choice 4). `~` is `mosaic`'s home.
|
||||
|
||||
| Input | Value | Who gives it |
|
||||
|---|---|---|
|
||||
| user | `mosaic`, primary group `mosaic` with no other member | fixed |
|
||||
| home | `/srv/mosaic`, 0700 | fixed |
|
||||
| config dir | `/srv/mosaic/.config/mosaic-dev` | row 190 |
|
||||
| data root | `/srv/mosaic/.mosaic-dev`, marker `{"instanceVersion":1,"name":"dev"}` | row 190 |
|
||||
| business | `mosaic-stack`, file `<configDir>/businesses/mosaic-stack.json` | fixed (section 7) |
|
||||
| profile | `factory` for `case2-install.sh`, `case2-unit.sh` and `case2-precheck.sh` | fixed |
|
||||
| roles | the factory set from `roles/`, with a chief that has a harness var | the go names the set (section 2) |
|
||||
| human id | Jason's, not `tester` (`broker.mjs:321`) | the go |
|
||||
| model credential | `<configDir>/secrets/claude-session.env`, `mosaic` 0600, one line `CLAUDE_CODE_OAUTH_TOKEN=...` | Jason places it (section 3) |
|
||||
| token slot path | none | section 2 |
|
||||
| node | `/opt/mosaic-stack/node-<v>/bin/node`, `<v>` 24 or later | the go names `<v>` and its sha256 |
|
||||
| claude | `/opt/mosaic-stack/claude-<pin>/node_modules/@anthropic-ai/claude-code/bin/claude.exe`, `<pin>` 2.1.304 unless the go names a newer one | the go |
|
||||
| PM | on (`--pm`) | fixed |
|
||||
| Console | `--port 732` on 127.0.0.1, with the unit's `CAP_NET_BIND_SERVICE` | fixed |
|
||||
| tester | `--tester-port 7333` on 127.0.0.1 (P10) | fixed |
|
||||
| board | none | fixed |
|
||||
| release | a git bundle of `refs/heads/refactor` and the 40-hex commit (run sheet section 6) | the go names both and the bundle's sha256 |
|
||||
|
||||
The args file. `/etc/mosaic-stack/dev.args`, `root:root` 0644, one argument per line (run sheet section 7):
|
||||
|
||||
```
|
||||
--profile
|
||||
factory
|
||||
--user
|
||||
mosaic
|
||||
--home
|
||||
/srv/mosaic
|
||||
--business
|
||||
mosaic-stack
|
||||
--copy
|
||||
<40-hex commit>
|
||||
--node
|
||||
/opt/mosaic-stack/node-<v>/bin/node
|
||||
--claude
|
||||
/opt/mosaic-stack/claude-<pin>/node_modules/@anthropic-ai/claude-code/bin/claude.exe
|
||||
--pm
|
||||
--
|
||||
--port
|
||||
732
|
||||
--business
|
||||
mosaic-stack
|
||||
--tester-port
|
||||
7333
|
||||
```
|
||||
|
||||
`case2-unit.sh` takes exactly these before `--` (`scripts/case2-unit.sh:54`), and `stack-upgrade.sh`
|
||||
refuses `args-unreadable` on anything else (`scripts/stack-upgrade.sh:126-148`). The factory profile
|
||||
refuses the preview trio, `--send` and the proof pair (`case2-unit.sh:169`). .53 has no proxy, and Jason
|
||||
reaches the Console with `ssh -L` (sage-1451 Q3). Changing a serve flag is a re-install with its own go,
|
||||
not an upgrade.
|
||||
|
||||
Why the port is 732 (r6, mos-1101 item 5). Role sessions share the host's network namespace
|
||||
(`packages/seat/src/sandbox.mjs:67`), so while the Console is down a session could bind a port above the
|
||||
floor and answer in its place. Below 1024 only the Console unit can bind, through its capability. The
|
||||
tester port can't go below 1024 (`packages/webui/src/cli.mjs:33`), so P10 records it.
|
||||
|
||||
PATH for `mosaic`'s steps. `sudo -u mosaic -H` resets PATH to sudo's `secure_path`, which finds
|
||||
`/usr/local/bin/node`, v22. So every `mosaic` step runs as
|
||||
`sudo -u mosaic -H env PATH=/opt/mosaic-stack/node-<v>/bin:/usr/local/bin:/usr/bin:/bin ...`, the list
|
||||
`case2-unit.sh` renders as the units' PATH. `stack-upgrade.sh` builds it from the args file's `--node`.
|
||||
|
||||
## 2. Where Case 1 differs from Case 2
|
||||
|
||||
| Case 2 (library) | Case 1 (factory) | Reason |
|
||||
|---|---|---|
|
||||
| business file with the library mapping | `mosaic-stack` with no `library` key. The precheck refuses a library project under `factory` (`profile-library-business`, `case2-precheck.sh:127-128`) | the factory has no books repo |
|
||||
| `bean`, `chet`, persona vars and files, specialists | the factory roles from `roles/`, with a chief that has a harness var | the go names the set. I haven't read Jason's live business file and won't. The acceptance needs only the chief (F3); a chief with no harness var answers `no-route`. The human adds roles later with `instances.set`, no new row |
|
||||
| token slot `library-gitea` | none | the factory's per-agent git identity is row 199's credential store (Megavolt, #1697). Until it lands, git work stays on dragon-lin (Q182) |
|
||||
| `bus.tester` off | on; the precheck refuses it off under `factory` (`case2-precheck.sh:135`) | the acceptance and the Q173 test system need the tester |
|
||||
| persona modes and digests, `library.sync`, `library.status` | skipped | no persona files, no library (C2-13b brief, `factory` profile) |
|
||||
| `ZAI_API_KEY` in `claude-session.env` | not checked; the file holds `CLAUDE_CODE_OAUTH_TOKEN` | no instance has provider `zai` (run sheet section 9) |
|
||||
| no Discord binding (C2-13b, library only) | none either. F11 checks that `<dataRoot>/discord/` holds no binding | the factory's connector stays on dragon-lin. Two connectors on one bot token would split its events |
|
||||
| the claude marker prompt | runs, since the chief's harness is claude-code | |
|
||||
| serve flags: preview trio, `--send`, proof pair | `--port 732`, `--business mosaic-stack`, `--tester-port 7333` | no proxy on .53 |
|
||||
| bus: no `--pm`, no `MOSAIC_CLAUDE_BIN` | `--pm`, and `Environment=MOSAIC_CLAUDE_BIN=<--claude>` | the PM dispatches the factory's work; without the path the bus finds no claude on the unit's PATH |
|
||||
| Console proof file (run sheet section 9, .54) | none | no proof pair |
|
||||
|
||||
All of these are inputs to the landed scripts. Row 230 implemented the four factory items r6 asked for
|
||||
(serve flags, `--pm`, `--claude`, row 190's tokens), and nothing in them hardcodes a Case 1 value.
|
||||
|
||||
## 3. Model access
|
||||
|
||||
Q185: Case 1 uses the account the factory uses today, the Claude subscription behind dragon-lin's
|
||||
`claude-session.env`. No script copies that file and none reads its value. In the window, Jason, or the
|
||||
person he names under the Q173 secrets rule, makes a credential for .53 from that account and writes it
|
||||
to `/srv/mosaic/.config/mosaic-dev/secrets/claude-session.env` as `mosaic`, mode 0600 (run sheet
|
||||
section 9). Nobody prints it. The check is `stat -c '%U %a %F' PATH` only (F8).
|
||||
|
||||
The bus unit reads it through `EnvironmentFile=-` (`case2-unit.sh:243`). The leading `-` means a missing
|
||||
file doesn't stop the bus, so the chief's first launch would fail instead. The window places the file
|
||||
before the units start (section 6, W9), and the precheck's claude marker prompt then proves it works
|
||||
without printing it.
|
||||
|
||||
Quota: the factory on dragon-lin and on .53 share one subscription. Q185's "use existing" accepts that.
|
||||
The pi harness stays off on .53 until P19 #383 settles (mos-0865). The business file names claude only.
|
||||
|
||||
## 4. Upgrade, rollback and backup on .53
|
||||
|
||||
All three are `scripts/stack-upgrade.sh` (row 233), run as root by ops-01 from `/root/mosaic-stack` at the
|
||||
reviewed commit. Usage, exits and the step log are in `docs/TOOLS.md`, "Root's upgrade driver", and run
|
||||
sheet section 6.
|
||||
|
||||
| Action | Command | Who | Go |
|
||||
|---|---|---|---|
|
||||
| upgrade | `stack-upgrade.sh upgrade --instance dev --bundle /srv/mosaic/incoming/<sha>.bundle --sha <sha>` after run sheet section 6's per-upgrade lines | ops-01 | Mos per run; `--migrate` also needs Jason present (decision 96) |
|
||||
| rollback | `stack-upgrade.sh rollback --instance dev --to <copy> [--restore <ACT> [--events N]]` | ops-01 | Mos per run. Mos or Sage picks `<copy>` from `stack-release.sh status mosaic-stack --units external`, read as `mosaic`. The plan refuses unless it is the previous distinct copy |
|
||||
| backup of the store | A3's `backup_store` into `<dataRoot>/backups/stack/<ACT>`, inside every upgrade | the driver | the upgrade's |
|
||||
| rehearsal | `stack-release.sh rehearse` against the log's newest backup, before anything stops, on `--rehearse-port`, default 7339, which section 0 found free | the driver | the upgrade's |
|
||||
| backup of the VM | a PBS backup of VM 1008 before the install window and before any `--migrate` | ops-01 | the window's go (Q182, Q183: agents keep PBS) |
|
||||
|
||||
Three .53 specifics:
|
||||
|
||||
1. The first upgrade after install needs `--no-rehearsal` in its go. The install's adopt takes no backup,
|
||||
so the log records none, the rehearsal exits 3 (`no-backup`), and the driver goes on only with the
|
||||
flag (`stack-upgrade.sh:323-324`). Every later upgrade refuses the flag (`rehearsal-required`).
|
||||
2. Exit 5 (`args-failed`): the new stack is live and confirmed, but `dev.args` still names the old copy.
|
||||
ops-01 moves the file the result line names into place with `mv -f`, and nothing else (run sheet
|
||||
section 6).
|
||||
3. Exit 2 leaves both units stopped and the activation open. ops-01 sends Mos the exit and the last
|
||||
lines of `/var/log/mosaic-stack/upgrade-dev.log`, and Jason or Mos settles it with `confirm` or `revert`
|
||||
as on dragon-lin. A unit that doesn't stop within `--timeout` ends here on purpose (A2f's
|
||||
`stale-start`, duckworth-0055 N1, now in run sheet section 6).
|
||||
|
||||
The store backup doesn't hold the config dir or the credential. PBS backups of VM 1008 hold the whole disk,
|
||||
the credential included (P4). A restore swaps only the store and leaves `claude-session.env` in place.
|
||||
|
||||
Re-install, for a serve flag or role change, is not an upgrade. It needs its own go and runs: stop the
|
||||
Console, then the bus; rewrite `dev.args` with the same `--copy`; `case2-unit.sh` with it; both units start
|
||||
again from the render. Row 230 N1 (webby-0046) holds here: a re-render with the units running leaves each on
|
||||
its old copy, so ops-01 stops both first. `stack-upgrade.sh` already stops both before it renders.
|
||||
|
||||
## 5. Acceptance: `scripts/case1-accept.sh`
|
||||
|
||||
New in this row. ops-01 runs it as root on .53, from `/root/mosaic-stack`:
|
||||
|
||||
```
|
||||
case1-accept.sh --business mosaic-stack --args /etc/mosaic-stack/dev.args --claude-pin <pin> --node-pin <v>
|
||||
```
|
||||
|
||||
It reads the user, home, node, claude and ports from the args file and refuses (exit 2) when that file
|
||||
fails `stack-upgrade.sh`'s rules. Every check inside the instance runs as
|
||||
`sudo -u mosaic -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin`. It changes nothing on the host
|
||||
except what F3's message writes to the store. It prints one line per check, `<id> <path> <mark>
|
||||
<evidence>`, then a count of each mark. Marks: `closed`, `accepted Q182`, `accepted Q183`, `accepted
|
||||
mos-1096`, `accepted mos-1101`, and `unnamed`, which means "not named in Q182, Q183 or a Mos ruling".
|
||||
It exits 1 when any F check fails and 0 otherwise. A P check never changes the exit. Each `unnamed`
|
||||
line goes to Mos, who takes it to Jason (R8 N1). Duckworth reads the whole output.
|
||||
|
||||
It prints no value from `claude-session.env` and never opens it.
|
||||
|
||||
Functional checks. Any failure fails the row:
|
||||
|
||||
| Id | Check | How |
|
||||
|---|---|---|
|
||||
| F1 | the units run as `mosaic` | for `[email protected]` and `mosaic-webui.service`: active, `User=mosaic`, and the uid in `/proc/<MainPID>/status` is `mosaic`'s |
|
||||
| F2 | the Console answers on 127.0.0.1:732 only | `curl -fsS http://127.0.0.1:732/healthz` succeeds, and `ss -Hltnp` shows 732 bound to 127.0.0.1 only, by the Console's MainPID. Jason reaches it with `ssh -L 7332:127.0.0.1:732 [email protected]`. A LAN listener waits for Q175 SSO (sage-1451 Q3) |
|
||||
| F3 | one tester message gets a reply | as `mosaic`: `mosaic talk chief <marker> --business mosaic-stack --tester --wait 300`. A stored reply to the marked message passes. The inbox shows decisions only, so it isn't used |
|
||||
| F4 | the precheck passes | `case2-precheck.sh --business mosaic-stack --profile factory` as `mosaic` exits 0 |
|
||||
| F5 | a role session runs under bwrap with the profile | while F3's reply is pending: a `/usr/bin/bwrap` process owned by `mosaic` has a claude child. Its `/proc/<pid>/attr/current` equals `bwrap (enforce)` and the child's equals `bwrap//&unpriv_bwrap (enforce)`, exact strings after the trailing newline is cut |
|
||||
| F6 | claude is the pin | the args file's `--claude` path sits under `/opt/mosaic-stack/claude-<pin>/`, and its `--version` as `mosaic` prints `<pin> (Claude Code)` |
|
||||
| F7 | the PM run is live | `launches list --json` as `mosaic` has exactly one live entry for the PM's instance (`launch.by`), the check `confirm` runs |
|
||||
| F8 | the credential's file | `stat -c '%U %a %F'` on `claude-session.env` reads `mosaic 600 regular file`, and it isn't a symlink. Run sheet section 9 marks this "precheck line missing" |
|
||||
| F9 | the autoupdater is off | `/etc/claude-code/managed-settings.json` is `root:root` 0644 and its `env.DISABLE_AUTOUPDATER` is `"1"`. Run sheet section 3 marks this "precheck line missing" |
|
||||
| F10 | node is the pin | the args file's `--node` is `/opt/mosaic-stack/node-<v>/bin/node` with `<v>` the `--node-pin`, and its `--version` prints `<v>`. Run sheet section 2 marks this "precheck line missing" |
|
||||
| F11 | no Discord binding | `/srv/mosaic/.mosaic-dev/discord/` is absent or holds no `*.json` (section 2) |
|
||||
|
||||
Host paths. These report and never fail the row:
|
||||
|
||||
| Id | Path | Mark | Evidence |
|
||||
|---|---|---|---|
|
||||
| P1 | `mosaic` has sudo | closed only when `sudo -l -U mosaic` prints "is not allowed to run sudo"; any other output is unnamed | the `sudo -l` result |
|
||||
| P2 | `mosaic` has supplementary groups or a key | closed when `id -G mosaic` is one entry and `~mosaic/.ssh/authorized_keys` is absent or empty | `id`, file state |
|
||||
| P3 | `localadmin` NOPASSWD sudo, reached with the dragon-lin key | accepted Q183 | `sudo -l -U localadmin` |
|
||||
| P4 | root on pve over VM 1008, and PBS backups that hold `/srv/mosaic` with the credential | accepted Q182 and Q183 | a fixed line; the script can't see pve |
|
||||
| P5 | `jarvis`, which runs Hermes, reaches `/srv/mosaic` through sudo, docker or lxd | closed when `jarvis` is in none of the three; unnamed otherwise. #423 step 4 removes all four groups before the install (mos-1091 4c) | `id jarvis` |
|
||||
| P6 | other members of sudo, docker, lxd | closed when sudo holds only `localadmin` and docker and lxd are empty; each other member is its own unnamed line | `getent group` lines |
|
||||
| P7 | a file under `/etc/sudoers.d` naming `mosaic` | closed when none | file names |
|
||||
| P8 | the Console or tester reachable from the LAN | closed when 732 and 7333 are bound to 127.0.0.1 only, each by the Console's MainPID, and nothing listens on 7331 | listener table |
|
||||
| P9 | a user namespace through `/usr/bin/bwrap` | accepted mos-1096 only while all eight hold, unnamed otherwise: hostcheck items 1 to 6, read from `/usr/local/lib/mosaic-stack/hostcheck.sh --check`'s own `ok N` lines (item 7 is P10's floor); `sudo -u jarvis /usr/bin/bwrap --version` fails with "Permission denied"; `getent group mosaic` lists no member and no other account has its gid. F5 adds the label of a real role session | the hostcheck lines and each item |
|
||||
| P10 | a session binds the tester port 7333 while the Console is down | accepted mos-1101 while the port floor is above 732 and 7333 is held by the Console's MainPID | the floor, the owner pid |
|
||||
| P11 | the boot check runs before the bus | closed when `mosaic-hostcheck.service` is enabled and `systemctl show -p Before` on it names `mosaic-bus@<business>.service` for the args file's business; unnamed otherwise (section 7) | the `Before=` value |
|
||||
|
||||
P9 reads hostcheck's output, which root's own script prints, by matching `^ok [1-6] ` lines. It runs `--check` only,
|
||||
never `--enforce`, so the acceptance can't close bwrap.
|
||||
|
||||
Tests, in `packages/cli/tests/case1-accept.test.mjs`. The script takes a test seam, `--root DIR`, for
|
||||
`/proc`, `/etc`, `/srv/mosaic` and `getent`, and refuses it when run as root, as the hostcheck does. Fake
|
||||
`systemctl`, `curl`, `ss`, `sudo`, `bwrap`, `sysctl`, `stat`, `claude`, `node` and `hostcheck.sh` on PATH.
|
||||
Nothing reaches .53 or dragon-lin's live state, and no test reads a real credential. Cases:
|
||||
- every check passing exits 0, with the counts matching the lines;
|
||||
- each F check failing alone exits 1 with its line;
|
||||
- F5 fails on `unpriv_bwrap (enforce)`, `bwrap//&unpriv_bwrap (complain)` and a trailing space;
|
||||
- F6 fails on a stub `claude.exe` that prints nothing and on a path outside `claude-<pin>`;
|
||||
- F8 fails on mode 0640, on another owner and on a symlink, and the stand-in credential's value never
|
||||
appears in the output;
|
||||
- F11 fails on one `*.json` under `discord/`;
|
||||
- each P check in each of its marks: P1 with output that grants nothing but lacks the refusal text
|
||||
(unnamed), P9 unnamed once for each item missing, P10 unnamed with the floor at 700 and with 7333 held
|
||||
by another pid, P11 unnamed with `Before=` naming another business;
|
||||
- every P check `unnamed` still exits 0;
|
||||
- a bad args file refuses with exit 2 before any check;
|
||||
- every in-instance call goes through `sudo -u mosaic -H env PATH=<node dir>:...`, read from the fake
|
||||
`sudo`'s recorded argv;
|
||||
- the hostcheck fake records `--check` and never `--enforce`.
|
||||
|
||||
Mutants, each killed by a named case:
|
||||
1. F1 passes on a MainPID owned by root.
|
||||
2. F2 accepts a listener on 0.0.0.0.
|
||||
3. F3 passes without a reply.
|
||||
4. F5 matches a substring.
|
||||
5. F6 skips the `--version` check.
|
||||
6. F8 skips the owner check.
|
||||
7. F11 ignores `discord/`.
|
||||
8. P1 closes on any output.
|
||||
9. P5 maps to `closed` with `jarvis` in docker.
|
||||
10. P9 drops the jarvis item.
|
||||
11. P11 accepts any `Before=`.
|
||||
12. A P check's `unnamed` sets the exit to 1.
|
||||
|
||||
## 6. The .53 window
|
||||
|
||||
Nothing in this row touches .53 (sage-1809). Every root step below is ops-01's, over
|
||||
`ssh [email protected]` with `sudo`, and waits on a Jason decision (mos-1086). The window needs:
|
||||
- row 226 landed;
|
||||
- Jason's or Mos's go naming the commit, the bundle's sha256, the node and claude pins, the role set and
|
||||
Jason's human id;
|
||||
- a backup first;
|
||||
- a STATE report after.
|
||||
|
||||
| # | Step | Runs as | Source |
|
||||
|---|---|---|---|
|
||||
| W0 | re-read section 0's items; stop on any change and report it | `localadmin`, read only | section 0 |
|
||||
| W1 | PBS backup of VM 1008 | ops-01 on pve | section 4 |
|
||||
| W2 | the stack's user and home | root | run sheet 1 |
|
||||
| W3 | node under `/opt` | root | run sheet 2 |
|
||||
| W4 | claude under `/opt`, managed settings | root | run sheet 3 |
|
||||
| W5 | bubblewrap, the profile, the statoverride, the no-new-privs test, the drift stop; `hostcheck.sh --check` exits 0 with item 7 at 1023 | root | run sheet 4 |
|
||||
| W6 | the port floor (read) | root | run sheet 5 |
|
||||
| W7 | the bundle, root's checkout at `<sha>`, `mosaic`'s clone | root, then `sudo -u mosaic -H` | run sheet 6 |
|
||||
| W8 | `case2-install.sh --profile factory` with section 1's inputs: bootstrap, the business file, roles, human id, the copy | `sudo -u mosaic -H env PATH=...` | row 226 |
|
||||
| W9 | place `claude-session.env` | Jason, or who he names | run sheet 9, section 3 |
|
||||
| W10 | write `dev.args` (section 1); `case2-unit.sh` from it, which enables both units and starts the bus, then the Console | root | run sheet 7, 10 |
|
||||
| W11 | `case2-precheck.sh --business mosaic-stack --profile factory` exits 0; `hostcheck.sh --check` exits 0 with item 7 at 732 | `mosaic`; root | row 230, 240 |
|
||||
| W12 | `stack-release.sh adopt mosaic-stack --units external`, unless row 226's install does it | `sudo -u mosaic -H` | row 232 |
|
||||
| W13 | `case1-accept.sh` (section 5); the output to Duckworth, then Mos confirms | root | this row |
|
||||
| W14 | STATE report to Mos | ops-01 | brief |
|
||||
|
||||
Install rollback, any step before W13 passes. Stop and disable both units, remove their files and
|
||||
`dev.args`, then the run sheet's rollbacks for sections 4, 3 and 2 in that order. Removing the user and
|
||||
home (`userdel -r mosaic`) needs its own Mos go, since it deletes the home. W1's backup covers the rest.
|
||||
|
||||
What row 195 needs from row 226. Row 226 is assigned to Gosalyn, briefed and not started, so I'm listing what the window
|
||||
needs from its design, not designing it:
|
||||
|
||||
1. `--profile factory` writes the business file with `bus.tester` on and no `library` key, and the roles
|
||||
the go names.
|
||||
2. It writes no token slot and no persona files (its brief says so).
|
||||
3. It runs before the unit render, since the copy must exist for `case2-unit.sh --copy`, and it starts no
|
||||
unit.
|
||||
4. Who writes `dev.args`. The C2-13b brief says the install doesn't write unit files, and the run sheet
|
||||
says "I2's install writes it". `mosaic` can't write `/etc/mosaic-stack`. So either root's part of I2
|
||||
writes it, or ops-01 writes it from section 1 at W10. Either works for row 195. The file's content is
|
||||
fixed above.
|
||||
5. Whether it runs `adopt`. If it doesn't, W12 does.
|
||||
|
||||
I sent these to Gosalyn on 2026-10-21 (gosalyn-0085 acknowledged), and the run sheet takes whichever answer 226 gives.
|
||||
|
||||
## 7. The business id
|
||||
|
||||
.53's business id is `mosaic-stack` (sage-1813). It appears in:
|
||||
- the args file, twice;
|
||||
- the bus unit `[email protected]`;
|
||||
- the business file name;
|
||||
- `case1-accept.sh --business`.
|
||||
|
||||
The landed boot unit `packages/cli/systemd/mosaic-hostcheck.service` has
|
||||
`[email protected]`. So at boot the drift stop runs before .53's bus, and DEFERRED 240
|
||||
N1 (da3051ccc: `After=mosaic-hostcheck.service` in `mosaic-bus.service.in`) doesn't bind this row.
|
||||
|
||||
If a go ever names another id for .53, the window stops before W8, and the N1 fix lands first as its own
|
||||
reviewed row. P11 is the check on the host. It reads `unnamed` when the boot unit's `Before=` doesn't
|
||||
name the installed bus.
|
||||
|
||||
DEFERRED 240 N2 (the apt hook goes in before the first `--check`) is a run sheet order note. This row
|
||||
doesn't edit the run sheet, so it stays deferred.
|
||||
|
||||
## 8. Rulings this design rests on
|
||||
|
||||
- sage-1451: Q1 system units; Q3 the Console on 127.0.0.1 with `ssh -L`; Q4 a rebase goes to a new branch
|
||||
name (#426(a), mos-1086).
|
||||
- mos-1091, mos-1096, mos-1098, mos-1101: the bwrap path, its profile and P9; the port squat and P10; .53
|
||||
runs the PM. Their r6 sections stand, and rows 232, 233, 240 and 230 implemented them.
|
||||
- mos-1108 (Q187) and mos-1118: the installer rows and the scope cut. mos-1122 (Q189): both units start at
|
||||
boot.
|
||||
- sage-1522: claude under `/opt`, root-owned.
|
||||
- sage-1809: no step on .53, root steps named and left to ops-01, the business id stated.
|
||||
- sage-1813: 226 in the after list (rev 2718); the id is `mosaic-stack` and 240 N1 doesn't apply.
|
||||
- Q185: the factory's model account. Q173: credentials placed by Jason or his named person.
|
||||
|
||||
## 9. Manifest (planned)
|
||||
|
||||
- `scripts/case1-accept.sh` (new), mode 100755.
|
||||
- `packages/cli/tests/case1-accept.test.mjs` (new). Section 5's cases and mutants.
|
||||
- `docs/TOOLS.md`: a `case1-accept.sh` entry with usage, marks and exits, beside the hostcheck entry.
|
||||
- `agents/darkwing/work/queue-195/BUILD.md` (new): run environment, the mutant results, deviations.
|
||||
- No change to `case2-*.sh`, `stack-upgrade.sh`, `stack-release.sh`, `case1-hostcheck.sh` or the run
|
||||
sheet. Two follow-ups stay out of this row:
|
||||
- row 230 r2 N1. The "still on copy" line reads the id from the unit file. On .53 neither the install
|
||||
nor `stack-upgrade.sh` reaches that line, since both start from stopped units.
|
||||
- DEFERRED 240 N2.
|
||||
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, dragon-lin's units or `~/.mosaic`.
|
||||
|
||||
Gate: Duckworth approves this design, then the manifest; the cli suite and every `scripts/test-*.sh`
|
||||
green on Sage's gate; section 5's twelve mutants killed in a slot Sage grants.
|
||||
@@ -254,34 +254,3 @@ checked. The script stops on any restore mismatch, and it ran to its end.
|
||||
|
||||
17 of 17 killed, 01:22:16 to 01:24:32Z. I read the logs for m01 and m09, two of the quick kills: each failed on
|
||||
its named test's assertion (m09 on `AWAITING-UNITS` in sequence 2), not on a load error.
|
||||
|
||||
## Round 2: rebase onto 8fede5fc6 (sage-1673)
|
||||
|
||||
Rebase only. Before the landing gate, a check of BASE..HEAD on the manifest paths found that row 230
|
||||
(c8bc5cf4c) had changed one of them after the round 1 base, cb5fdb064. Sage ruled a re-round with a
|
||||
scoped review, the same shape as row 210 round 3.
|
||||
|
||||
Branch `darkwing/row232-r2`: the two round 1 commits, a0ffabc3f and 84f1a91e6, cherry-picked onto
|
||||
8fede5fc6 with no conflict, then this section. No code or test changed.
|
||||
|
||||
- `git merge-tree --write-tree origin/refactor darkwing/row232-r1` (refactor at 8fede5fc6) gives tree
|
||||
837bde79236c with no conflict. The cherry-picked tip before this section has the same tree.
|
||||
- `packages/cli/README.md` is a clean 3-way merge. Row 230's hunk is at lines 710-717 (the session credential
|
||||
paragraph); this row's is a new paragraph at 773. With `git patch-id --stable`, the README diff from r1 to
|
||||
r2 is 02b679e2, the same as row 230's README diff `cb5fdb064..8fede5fc6`. This row's README diff is
|
||||
c395ecd2 on both bases (`cb5fdb064..r1` and `8fede5fc6..r2`).
|
||||
- The seven code, test and doc paths other than the README hash the same as in manifest 7a3b4ea1, which
|
||||
Filbert approved in round 1: `docs/TOOLS.md`, `packages/cli/tests/stack-copy.test.mjs`,
|
||||
`packages/cli/tests/stack-release-external.test.mjs`, `packages/cli/tests/stack-release-fixture.mjs`,
|
||||
`packages/cli/tests/stack-release.test.mjs`, `scripts/stack-copy.sh`, `scripts/stack-release.sh`.
|
||||
This file also matched until this section was appended. Its other lines are unchanged.
|
||||
- The one test that reads the README, `host.test.mjs:855`, looks for the unit's `EnvironmentFile=` line.
|
||||
That line is unchanged context in row 230's hunk.
|
||||
- No row 230 test reads `stack-release.sh` or `stack-copy.sh`, and no test of this row reads a row 230 file.
|
||||
|
||||
Nothing was run for this round. The landing gate runs on the r2 tip on Sage's slot.
|
||||
|
||||
Follow-up, doc-only (sage-1673): `scripts/case2-unit.sh:108` and `scripts/case2-precheck.sh:157` cite
|
||||
`scripts/stack-copy.sh:8` and `:22` for `COPIES` and `copy_root`. With this row those lines are `:10` and
|
||||
`:26`. Only comments are affected and no test reads them. Row 233 doesn't touch those scripts, so the fix
|
||||
goes on the #1732 carry list.
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
# Row 233 (I3): root's upgrade driver and the host run sheet
|
||||
|
||||
Brief: `docs/plans/2026-10-20_installer-rows.md` section I3, blob `e61291bcf1c6` (commit a0e3d7cfd), re-pinned
|
||||
by Sage (sage-1685, rev 2631) from `f35e7e57daee` (ad6f0be7a). The later blob only appends I4; the I3 text
|
||||
is the same. Gitea #1735, Vikunja P19
|
||||
#432. Owner Darkwing, reviewer Filbert, gate owner Sage. Design input: row 195 DESIGN r6,
|
||||
`agents/darkwing/work/queue-195/DESIGN.md` at b7239bb66, sections 2, 4 and 7. Where the brief and r6
|
||||
differ, the brief wins. Sage's rulings during the build: sage-1650 and sage-1652 (`--backup last`, the
|
||||
`--no-rehearsal` mapping, `--rehearse-port`, hostcheck), and sage-1653 (`from_log newest`, the two external
|
||||
plan refusals, the `BACKUP_STORE` line).
|
||||
|
||||
## Files
|
||||
|
||||
Base: e65a3ab4d, row 232's landing, on `darkwing/row233-r1`. The build started on ea1e85c7b
|
||||
(`darkwing/row233`, row 232's candidate e6ca230d2 and its BUILD.md) before row 232 landed. The three row 232
|
||||
files this row changes are identical on the two bases, so the uncommitted diff moved across unchanged.
|
||||
|
||||
- `scripts/stack-upgrade.sh` (new): `upgrade` and `rollback`, root's side of r6 A1-A5, A2f and R1-R4.
|
||||
- `packages/cli/tests/stack-upgrade.test.mjs` (new, 20 tests).
|
||||
- `docs/guides/host-run-sheet.md` (new).
|
||||
- `docs/TOOLS.md`: a new subsection "Root's upgrade driver (`scripts/stack-upgrade.sh`, row 233)", plus the
|
||||
row 232 lines below.
|
||||
- Row 232 files, changed on Sage's rulings (the brief's files list doesn't name them):
|
||||
- `scripts/stack-release.sh`:
|
||||
- `rehearse --backup last` takes the newest backup the log records (`from_log newest`) and prints
|
||||
`PASS backup-last`. With none it fails `no-backup`, exits 3 and makes no rehearsal directory.
|
||||
A newest backup that isn't an activation id aborts `backup-unreadable`, exit 1.
|
||||
- The rehearsal prints `BACKUP_STORE <activation id> schema <version>` before its migration step.
|
||||
- `--plan` under `--units external` refuses `open-activation` when any rollback set holds
|
||||
`AWAITING-UNITS`, and `migrate-required` for a migrating target without `--migrate`. Root reads
|
||||
only the plan's exit, so these two have to be refusals there. User mode prints the same bytes.
|
||||
- `packages/cli/tests/stack-release.test.mjs`: three new `row 233:` tests for `--backup last`.
|
||||
- `docs/TOOLS.md`: `--backup ACTIVATION|PATH|last` in the rehearse usage, a paragraph on `last`, and a
|
||||
paragraph on the two external plan refusals.
|
||||
- This file.
|
||||
|
||||
## The brief, item by item
|
||||
|
||||
| Brief item | Where |
|
||||
|---|---|
|
||||
| Root runs from a root-owned checkout, never a copy | `head-mismatch` (A1); TOOLS.md; run sheet section 6 |
|
||||
| I2's render with `/etc/mosaic-stack/<instance>.args`, `--copy` the one changed value | `read_args`, `args_with`; the args test |
|
||||
| The args file changes only after a confirmed run | `prepare_args` before A2, `mv` after A5; sequences 1 and 7, m6 |
|
||||
| `copy --bundle` as the stack's user before A1 | the `fetch`, `ff`, `copy` steps; sequence 1's sudo keys |
|
||||
| A rehearsal before A2, a failed one refuses with nothing stopped | `rehearse`; three rehearsal tests, m4 |
|
||||
| The root lock, held to the end, rollback included | `flock -n 9`; the lock test and sequence 3's probe, m1, m2 |
|
||||
| A1 refuses `open-activation` | stack-release.sh's external plan; the AWAITING-UNITS test, m3 |
|
||||
| `migrate-required` without `--migrate` | stack-release.sh's external plan; its test, m5 |
|
||||
| The step log, 0640, one line per step | `logl`, `step`; every test checks lines against `STEP` |
|
||||
| Exit codes 0-3 | `finish`, `refuse`; exit 4 is usage |
|
||||
| duckworth-0055's notes | the next section |
|
||||
| Root parses no stack user output beyond exits | the step log test; the one exception is sage-1653's `BACKUP_STORE` |
|
||||
| The host run sheet | `docs/guides/host-run-sheet.md` |
|
||||
|
||||
## duckworth-0055's notes on r6 (brief line 169)
|
||||
|
||||
- N1, A2's stop timeout under `gate --since`. Row 232's BUILD.md left two pieces to I3. The run sheet's
|
||||
A2f line is in section 6 ("When a run doesn't exit 0", exit 2). The test is `N1: a bus that outlives
|
||||
A2's timeout keeps its old start`: a bus that ignores `stop` keeps its old `ActiveEnterTimestamp`,
|
||||
`wait-down` logs `rc=1 ... state: active`, A2f restarts and gates, the gate fails `stale-start`, and the
|
||||
driver stops both units and exits 2. Sequence 4 is the other half: an A3 exit 3 goes to A2f and exits
|
||||
1 with the old copy's gate passing.
|
||||
- N2, the claude path under `/opt/mosaic-stack`. Run sheet section 3 installs it there as root with
|
||||
managed settings. The args file's `--claude` carries it, and `case2-unit.sh` already refuses a binary
|
||||
that isn't root's or sits under the home. The driver passes the args file through and adds no claude
|
||||
check of its own. F6 is I2's.
|
||||
|
||||
## Sage's rulings during the build
|
||||
|
||||
- sage-1650 and sage-1652. Root always runs the rehearsal and maps its exit:
|
||||
- 0 goes on, or refuses `rehearsal-required` with `--no-rehearsal`.
|
||||
- 3 refuses `no-backup`, or with `--no-rehearsal` logs `rehearsal-skipped` and goes on.
|
||||
- Anything else refuses `rehearsal-failed`.
|
||||
|
||||
`--rehearse-port N`, default 7339. Hostcheck: a missing file or a nonzero `--check` refuses
|
||||
`profile-drift`.
|
||||
- sage-1653. `from_log newest`, and the external plan refusals `open-activation` and `migrate-required`.
|
||||
Residual: the `BACKUP_STORE` id and schema land in root's step log, but nothing ties that backup to the
|
||||
one the activation's own backup step takes later. A stopped backup before the upgrade would close that
|
||||
gap; it's a later row, not this one.
|
||||
|
||||
## Tests
|
||||
|
||||
`stack-upgrade.test.mjs` runs the driver as the test's user against a scratch root, with the test seams
|
||||
`STACK_UPGRADE_*`. The stand-ins are a fake `sudo` (logs its argv, checks the driver's form, then runs the
|
||||
real command as the test's user, r6's B1 pattern), a fake `case2-unit.sh` render that installs and starts
|
||||
the fixture units, a fake `systemctl` for root's system units, and a shell `hostcheck.sh`. The stack's
|
||||
user's steps run the real `stack-release.sh` and `stack-copy.sh` from a clone made from a real bundle. The
|
||||
units, copies and store are `stack-release-fixture.mjs`'s.
|
||||
|
||||
| Test | Covers |
|
||||
|---|---|
|
||||
| sequence 1 | confirm fails healthz, rollback with `--cause A5`, exit 1; every sudo call in the driver's form |
|
||||
| sequence 2 | `activate --migrate` fails at the wrapper, store restored, `--cause A3`, exit 1 |
|
||||
| sequence 3 | render fails, `--cause A4`; a probe run during `render-old` refuses `upgrade-running` |
|
||||
| sequence 4 | stage exits 3, A2f restarts and gates the old units, exit 1 |
|
||||
| N1 | a unit that won't stop: A2f's gate fails `stale-start`, exit 2, units stopped |
|
||||
| sequence 5 | confirm of the wrong copy refused, `--cause A5`, exit 1 |
|
||||
| sequence 6 | revert fails, exit 2; the next run refuses `interrupted-run` at its plan |
|
||||
| sequence 7 | two upgrades and a `rollback --to` with `--restore`; the args file after each confirm; `to-not-previous` |
|
||||
| sequence 8 | a migration that writes and fails, store back byte for byte, exit 1 |
|
||||
| lock | a second run while the lock is held: one log line, no call |
|
||||
| open-activation | any `AWAITING-UNITS` refuses at A1, nothing stopped |
|
||||
| migrate-required | refused without `--migrate`, no rehearsal, nothing stopped |
|
||||
| rehearsal-failed | the rehearsal's argv, exit 3, nothing stopped |
|
||||
| `BACKUP_STORE` | the line after a match; four malformed lines log `BACKUP_STORE none` |
|
||||
| no-backup | the real rehearsal with no backup refuses; `--no-rehearsal` logs the skip and goes on |
|
||||
| backup-last | the real rehearsal names the newest backup; `--no-rehearsal` with one refuses `rehearsal-required` |
|
||||
| args file | group write, other write, two `--copy`, an unknown flag, a symlink, `args-differ` |
|
||||
| hostcheck | nonzero `--check` and a missing file refuse `profile-drift` before the plan |
|
||||
| head-mismatch | checkout not at `--sha`; a commit the clone can't fast-forward to refuses `fetch-failed` |
|
||||
| usage | twelve bad argvs exit 4 with no log and no lock |
|
||||
|
||||
The step log check (`STEP`) runs in every test that reads the log: each line must be a step line or the
|
||||
result line, and none may hold the stack user's output (`stack-release:`, `PASS `, `FAIL `, `REHEARSAL`,
|
||||
the fake rehearsal's text).
|
||||
|
||||
## Mutants
|
||||
|
||||
`~/darkwing-scratch/r233/mutants.sh`, not committed because it edits the worktree. It has the brief's six
|
||||
mutants and two for sage-1650's mapping. The method is row 232's: a checked backup, exact replacements, a
|
||||
syntax check, the named killers, then the file put back with its sha256 checked. One control runs first:
|
||||
the whole `stack-upgrade.test.mjs` with no change. `DRY=1` matched each replacement once on the candidate
|
||||
(2026-10-21).
|
||||
|
||||
| Mutant | File | Killers |
|
||||
|---|---|---|
|
||||
| m1 drop the flock | stack-upgrade.sh | `a second run while the lock is held`, `sequence 3:` |
|
||||
| m2 release the lock when the rollback starts | stack-upgrade.sh | `sequence 3:` |
|
||||
| m3 skip A1's open-activation check | stack-release.sh | `A1 refuses open-activation` |
|
||||
| m4 skip the rehearsal | stack-upgrade.sh | `the rehearsal runs after the plan`, `sequence 1:` |
|
||||
| m5 accept a migrating target without `--migrate` | stack-release.sh | `migrate-required` |
|
||||
| m6 write the args file before confirm | stack-upgrade.sh | `sequence 7:`, `sequence 1:` |
|
||||
| m7 `--no-rehearsal` accepted with a backup | stack-upgrade.sh | `refuses rehearsal-required` |
|
||||
| m8 drop the `rehearsal-skipped` line | stack-upgrade.sh | `a real rehearsal with no backup` |
|
||||
|
||||
## Deviations
|
||||
|
||||
D1. The brief has A1 refuse `open-activation` for an open `AWAITING-UNITS` or a non-empty `interrupted`
|
||||
set. The interrupted case refuses with row 232's existing code, `interrupted-run`; only `AWAITING-UNITS`
|
||||
gives `open-activation`. Both reach root as `plan-refused`, exit 3 (sequence 6, the open-activation test).
|
||||
|
||||
D2. Root copies one line of the stack user's output into its log, `BACKUP_STORE`, on sage-1653. It
|
||||
matches a fixed pattern first, takes the last match, logs `BACKUP_STORE none` otherwise, and decides
|
||||
nothing on it.
|
||||
|
||||
D3. The stack user's steps carry no `--instance`. The driver takes `--instance dev` only, row 190's default
|
||||
instance, which the stack user's commands select with no flag. A named instance's units are a later row.
|
||||
|
||||
D4. Most tests fake the rehearsal in the fake `sudo`: the fixture's stub copies can't pass a real one. Two
|
||||
tests run the real rehearsal (`faults/real-rehearse`): no-backup and backup-last. `stack-release.test.mjs`
|
||||
covers `--backup last` itself.
|
||||
|
||||
D5. Any nonzero `--plan` exit refuses `plan-refused`, exit 3, whatever the code. The stack user's output
|
||||
above it names the refusal; root doesn't parse it.
|
||||
|
||||
D6. `rollback --restore ACT` is accepted without `--events`, as `stack-release.sh rollback` accepts it.
|
||||
The brief's usage pairs them. `--events` without `--restore` is a usage error.
|
||||
|
||||
D7. The refusal of any `STACK_UPGRADE_*` seam when run as root has no test, because the tests don't run
|
||||
as root. It is three lines at the top of the script.
|
||||
|
||||
D8. The fake `sudo` keeps the test's environment, where real `sudo` resets it. The driver passes
|
||||
everything the stack user needs through `env PATH=...` and `-H`, and the fake checks that form.
|
||||
|
||||
D9. Exit 3 means nothing stopped, not nothing changed. A refusal after `fetch` leaves the stack user's
|
||||
clone fast-forwarded and the new copy made. Both are the user's own state, and the next run reuses them.
|
||||
TOOLS.md and the run sheet say so.
|
||||
|
||||
D10. The brief's usage has neither `--no-rehearsal` nor `--rehearse-port`. Both come from sage-1650 and
|
||||
sage-1652.
|
||||
|
||||
## Decisions
|
||||
|
||||
- `rollback` has no HEAD check. Its target is an older copy than root's checkout, which already exists.
|
||||
It fetches, copies and rehearses nothing (sequence 7).
|
||||
- The new args file is written next to the old one (`.dev.args.XXXXXX`, same mode) before A2, so a full
|
||||
`/etc` refuses before anything stops. It moves into place after A5. A2f and the rollback remove it; a failed move keeps it and names it.
|
||||
- Every stack user step gets `--copies` explicitly, so the copies directory comes from the args file's
|
||||
`--home`, never from the user's environment.
|
||||
- The stack's user does the fetch, the fast-forward and the copy, so root never runs git on a tree that
|
||||
user can write.
|
||||
- A4 is the render, and `case2-unit.sh` starts the bus, then the Console. The forward path has no
|
||||
`systemctl start` of its own; only A2f does.
|
||||
|
||||
## Run environment
|
||||
|
||||
Worktree `/mnt/storage/scratch/darkwing-r233`, `TMPDIR=/mnt/storage/scratch/tmp/r233`, node from PATH. One
|
||||
whole `stack-upgrade.test.mjs` run takes about 56 s.
|
||||
|
||||
## Results
|
||||
|
||||
### Singles, run 1, 2026-10-21, Sage's slot (sage-1689)
|
||||
|
||||
`~/darkwing-scratch/r233/singles.sh` on `darkwing/row233-r1` (base e65a3ab4d, uncommitted tree), 03:48:20Z
|
||||
to 03:59:00Z. These are the test files that read a changed script. `stack-instances.test.mjs` reads
|
||||
`stack-release.sh`, so it joined the three named in the plan.
|
||||
|
||||
| File | Result |
|
||||
|---|---|
|
||||
| `stack-upgrade.test.mjs` | 20 pass, 0 fail |
|
||||
| `stack-release.test.mjs` | 194 pass, 1 fail |
|
||||
| `stack-release-external.test.mjs` | 25 pass, 0 fail |
|
||||
| `stack-instances.test.mjs` | 23 pass, 0 fail |
|
||||
|
||||
The failure was my test, not the script. "row 233: --backup last takes the newest backup..." compares
|
||||
`untouched(e)` before and after the run, and `untouched` includes `existsSync(e.log)`. The test took its
|
||||
before snapshot ahead of `rlog()`, which writes the log, so that flag went from false to true. Every hash in
|
||||
the snapshot matched. The fix takes the snapshot after `rlog()`. No other row 233 test calls `untouched`.
|
||||
|
||||
### Singles, run 2
|
||||
|
||||
`~/darkwing-scratch/r233/rerun.sh` on aacfa2d7a, Sage's slot (sage-1695), 04:13:05Z to 04:21:28Z:
|
||||
`stack-release.test.mjs` whole, 195 pass, 0 fail, 0 skipped. The fixed test passed. The other three files
|
||||
did not change after run 1. The gate and the mutants run next, in Sage's slot.
|
||||
|
||||
### Gate and mutants, 2026-10-21, Sage's slot (sage-1704)
|
||||
|
||||
`~/darkwing-scratch/r233/gate.sh` then `mutants.sh`, chained behind `busy.sh`, on d99166ade (code
|
||||
aacfa2d7a; d99166ade changes only this file). Counts are an awk sum over `gate-r1.out`.
|
||||
|
||||
| Part | Time (UTC) | Result |
|
||||
|---|---|---|
|
||||
| cli suite (`node --test tests/*.test.mjs`) | 04:29:01-04:37:27 | 616 pass, 0 fail, 0 skipped |
|
||||
| the 10 `scripts/test-*.sh` | 04:37:27-04:39:38 | selftests 429 passed, 0 failed; `test-queue`'s node part 156 pass, 0 fail |
|
||||
| gate total | | 1201 pass, 0 fail, 11 suites, `GATE_PASSED` |
|
||||
| c0 control | 04:39:38-04:40:35 | exit 0 |
|
||||
| m1-m8 | 04:40:35-04:41:25 | 8 of 8 killed, every one exit 1 |
|
||||
|
||||
Every restore passed its sha256 check, and `git diff` was clean at d99166ade afterwards. The EXIT trap
|
||||
removed the `darkwing-r233_default` network.
|
||||
|
||||
N1, found in the process sweep after the run: a `sleep 60` was left with its cwd in `packages/cli`.
|
||||
`stack-upgrade.test.mjs:312` spawns `flock LOCK sleep 60`, and its `t.after` SIGKILLs only the flock, so
|
||||
flock's child outlives the test by up to 60 s. It exited on its own. Row 232's T20
|
||||
(`stack-release.test.mjs:370`) spawns its holder `detached` and kills the group with `process.kill(-pid)`.
|
||||
The fix is the same two lines. That test is one of m1's killers.
|
||||
|
||||
Sage ruled it a fix now (sage-1707), and 08db6ac3f makes it, test only. After the gate's d99166ade
|
||||
the only change outside this file is that test:
|
||||
|
||||
```
|
||||
$ git diff --stat d99166ade 08db6ac3f
|
||||
packages/cli/tests/stack-upgrade.test.mjs | 4 ++--
|
||||
1 file changed, 2 insertions(+), 2 deletions(-)
|
||||
```
|
||||
|
||||
The gate stands on Sage's ruling. The rerun is the whole `stack-upgrade.test.mjs` singly plus m1, in
|
||||
Sage's slot, with a sweep for a leftover `sleep` afterwards.
|
||||
@@ -1,173 +0,0 @@
|
||||
# Row 240 (I4): root's host check, its pins and the boot unit
|
||||
|
||||
Brief: `docs/plans/2026-10-20_installer-rows.md` section I4, blob `4a5a65020f00` (commit b00d7d94d, rev 2665,
|
||||
the pin row 240 carries; the I3 carry items came in with it). Sage split it out of I3 (sage-1650). Gitea #1746, Vikunja P19 #432. Owner Darkwing, reviewer Filbert, gate owner Sage.
|
||||
Design: row 195 DESIGN r6 B2, `agents/darkwing/work/queue-195/DESIGN.md` at b7239bb66, :262-303 (the script,
|
||||
the three callers) and :839-860 (files, test cases, mutants). Where the brief and r6 differ, the brief wins.
|
||||
|
||||
## Files
|
||||
|
||||
Drafted on `darkwing/row240-r1` from d99166ade (row 233's candidate) while row 233's gate ran, then applied
|
||||
as one patch onto row 233's landing, 57f59e0c3, on `darkwing/row240-r2` (sage-1753). It applied cleanly; between
|
||||
the two bases the only change on this row's paths is 233's N1 test fix in `stack-upgrade.test.mjs`. Row 234
|
||||
then landed as c771a36f4 on 57f59e0c3. Sage preferred that the gate run with it in the tree (sage-1773), so both
|
||||
commits were picked onto c771a36f4 as `darkwing/row240-r3`. That was a clean pick: the 42 paths changed from
|
||||
57f59e0c3 to c771a36f4 share none with this row's 9.
|
||||
|
||||
- `scripts/case1-pins.sh` (new, 0644): `PIN_PROFILES_VERSION`, `PIN_PROFILE_SHA256`, `PIN_BUBBLEWRAP_VERSION`,
|
||||
r6 section 0's values.
|
||||
- `scripts/case1-hostcheck.sh` (new, 0755): the seven items and `--check`, `--enforce`, `--upstream`.
|
||||
- `packages/cli/systemd/mosaic-hostcheck.service` (new): the boot oneshot.
|
||||
- `packages/cli/tests/case1-hostcheck.test.mjs` (new, 16 tests). The cli suite's `tests/*.test.mjs` glob picks
|
||||
it up.
|
||||
- `docs/guides/host-run-sheet.md` section 4: the install lines that replace "Install them from row 240's run
|
||||
sheet lines once it lands", plus rollback, drift recovery and `--upstream` before a re-pin window. Sections
|
||||
3, 6, 8 and 10 for the I3 carry items below.
|
||||
- `scripts/stack-upgrade.sh` and `packages/cli/tests/stack-upgrade.test.mjs`: I3 N1 below.
|
||||
- `docs/TOOLS.md`: a new subsection "Root's host check (`scripts/case1-hostcheck.sh`, row 240)" after the
|
||||
upgrade driver's.
|
||||
|
||||
Brief line 257 said I3's `stack-upgrade.sh` doesn't change. Sage's ruling sage-1727 widened the row to it for
|
||||
N1 alone. Its default `STACK_UPGRADE_HOSTCHECK=/usr/local/lib/mosaic-stack/hostcheck.sh` is where the run
|
||||
sheet installs this script.
|
||||
|
||||
## Carried from I3
|
||||
|
||||
Filbert's row 233 approve (Gitea 29922) left five notes. Sage ruled N1, N4 and N5 into this row and N2 and N3
|
||||
to DEFERRED.md (sage-1727).
|
||||
|
||||
| Note | Change |
|
||||
|---|---|
|
||||
| N1: `stack-upgrade.sh` exits 1 `args-failed` when the args move after A5 fails, but exit 1 means the old copy runs | The move's failure exits 5 `args-failed`. The header, TOOLS.md's step 5 and exit line, and run sheet section 6's exit list name exit 5 and what root does: move the new file the result line names into place. Test `row 240: I3 N1: a failed args move after A5 exits 5 ...` makes `/etc/mosaic-stack` read-only at confirm and checks exit 5, the step names, `args rc=1`, the result line, both units on the new copy, the old args file's bytes and the new file beside it. The step log regex takes `exit=5`. Mutant M8. |
|
||||
| N4: section 3's `npm install` has no npm on root's PATH, and the `--claude` path is unnamed while `node_modules/.bin/claude` is a symlink `case2-unit.sh:140` refuses | The install line runs with section 2's pinned node first on PATH (the postinstall runs `node` too). The `--claude` path is `node_modules/@anthropic-ai/claude-code/bin/claude.exe`, and a `--version` line catches the postinstall's 500-byte stub. |
|
||||
| N5: sections 8 and 10 lack precheck lines | Each gets "Precheck line: none" and its missing line. |
|
||||
|
||||
Exit 5 is new rather than 2 because 2 means both units are stopped, and here both run. Exit 4 stays usage.
|
||||
|
||||
The `claude.exe` path comes from the 2.1.304 package itself: `npm view` gives `bin.claude` as `bin/claude.exe`,
|
||||
and its `install.cjs` hardlinks the platform binary over that placeholder, or copies it across devices, then
|
||||
sets 0755. I downloaded the tarball into `/mnt/storage/scratch/tmp/r240/cc304` and read it; nothing ran. The
|
||||
run sheet says a newer pin needs the same check.
|
||||
|
||||
## The brief, item by item
|
||||
|
||||
| Brief line | Where |
|
||||
|---|---|
|
||||
| 247 `case1-pins.sh` holds the three pins | `scripts/case1-pins.sh`; the pins test checks the values against r6's |
|
||||
| 248-249 seven items, `--check` exits 0 or 1 | `items()` and the final dispatch; the check tests |
|
||||
| 250-251 `--enforce`: `apparmor_parser -R`, `root:root 0700`, `DRIFT`, a log line, exit 0 | `enforce()` and `stop_bwrap()`; the four enforce tests |
|
||||
| 252 `--upstream` compares the current deb to the pin | `upstream()`; the upstream test |
|
||||
| 253 the boot oneshot | `mosaic-hostcheck.service`; the boot unit test |
|
||||
| 254-255 fakes for every host tool, r6's seven case groups, seven mutants | the test file; Tests and Mutants below |
|
||||
| 257 the I3 run sheet item points here | run sheet section 4 |
|
||||
|
||||
## Tests
|
||||
|
||||
Fakes on PATH, each logging its argv to `$HC/calls.log`: `aa-status`, `dpkg`, `dpkg-statoverride`,
|
||||
`apparmor_parser`, `systemd-run`, `stat`, `chown`, `chmod`, `apt-get`, `dpkg-deb`. `host(t)` builds a scratch
|
||||
root (`/etc`, `/proc/sys`, `/usr/bin/bwrap`, `/var/log`) and a lib dir with a copy of the script and fixture
|
||||
pins that hold the fixture profile's sha256. Every run passes `--root`.
|
||||
|
||||
r6's case groups (:843-856) against the tests:
|
||||
|
||||
| r6 case | Tests |
|
||||
|---|---|
|
||||
| `--check` exits 0 with all seven, 1 with each failing alone, its line naming it | `check: exits 0 ...`, `check: exits 1 with each item failing alone ...`, plus one test per item for the item's edge cases |
|
||||
| item 3: a second file naming `bwrap` as a word, `/{usr/,}bin/bwrap` included; passes with the profile alone | `check: item 3 ...` |
|
||||
| item 4: `unpriv_bwrap (enforce)` or a trailing space fails; the argv carries `-p NoNewPrivileges=yes` and the 2a' properties | `check: item 4 ...` checks the whole argv |
|
||||
| item 7: the floor at 732 fails | `check: item 6 ...; item 7 ...` (733 passes as the control) |
|
||||
| `--enforce` exits 0 on a pass and every failure; unload, close, `DRIFT`, one log line; `DRIFT` present with every item passing still closes | `enforce: a pass ...`, `enforce: each failure ...`, `enforce: with DRIFT present ...` |
|
||||
| `--enforce` never calls `dpkg-statoverride` with `--update`, `--add` or `--remove` | `enforce: no unload ...; never writes the statoverride` (every call is `--list /usr/bin/bwrap`) |
|
||||
| a bubblewrap version other than the pin is a line, not a failure | `check: a bubblewrap version other than the install pin ...` |
|
||||
| `--upstream` against a differing deb prints the difference, exits 0 | `upstream: ...` (also: a match, a deb with no profile, a failed download) |
|
||||
| the unit: `Type=oneshot`, `After=apparmor.service`, `[email protected]`, runs `--enforce` | `boot unit: ...` |
|
||||
|
||||
The last test also checks that the repo script reads `scripts/case1-pins.sh` beside it, that bad usage exits
|
||||
2, and that a non-root run without `--root` refuses.
|
||||
|
||||
Singles are the two files that run a changed script: `case1-hostcheck.test.mjs` and `stack-upgrade.test.mjs`.
|
||||
Sage asked (sage-1759) whether `stack-release.test.mjs` or `stack-release-external.test.mjs`, which share
|
||||
`stack-release-fixture.mjs`, run `stack-upgrade.sh` or `case1-pins.sh`. They don't. `grep -cF` for
|
||||
`stack-upgrade`, `case1-pins`, `case1-hostcheck` and `hostcheck` gives 0 in all three files; the control
|
||||
`stack-release.sh` gives 2 in the fixture and 8 in `stack-release.test.mjs`. `scripts/stack-release.sh` names
|
||||
`stack-upgrade.sh` once, in a comment (:2061). The gate's cli suite runs both files anyway.
|
||||
|
||||
## Mutants
|
||||
|
||||
r6's seven (:858-860) and one for I3 N1, scripted in `~/darkwing-scratch/r240/mutants.sh` with r233's method: a checked backup,
|
||||
an exact replacement that must match once, `bash -n`, the named killer by `--test-name-pattern`, the restore
|
||||
and a sha256 check. A dry run matched all eight once each. Results below.
|
||||
|
||||
| Mutant | Change | Killer |
|
||||
|---|---|---|
|
||||
| M1 | `--enforce` exits 1 after the stop | `enforce: each failure exits 0 ...` |
|
||||
| M2 | `[ ! -e "$DRIFT" ] \|\| had=1` becomes `true \|\| had=1` | `enforce: with DRIFT present ...` |
|
||||
| M3 | item 1's `dpkg -S` becomes `owner=""; rc=1` | `check: item 1 fails when a package owns ...` |
|
||||
| M4 | item 3's `grep -rlw bwrap` becomes `grep -rlF /usr/bin/bwrap` | `check: item 3 ...` |
|
||||
| M5 | item 4's `[ "$label" = "$LABEL" ]` becomes a substring match | `check: item 4 ...` |
|
||||
| M6 | item 4 drops `-p NoNewPrivileges=yes` | `check: item 4 ...` (argv) |
|
||||
| M7 | item 7's `-gt` becomes `-ge` | `check: item 6 ...; item 7 fails with the floor at 732 ...` |
|
||||
| M8 | `stack-upgrade.sh`'s `finish 5 args-failed` becomes `finish 1 args-failed` (I3 N1) | `row 240: I3 N1: a failed args move ...` |
|
||||
|
||||
## Deviations
|
||||
|
||||
- Repo name `scripts/case1-hostcheck.sh` (r6), installed as `/usr/local/lib/mosaic-stack/hostcheck.sh`, the
|
||||
name in brief line 239 and I3's `stack-upgrade.sh` default. r6's apt hook line says `case1-hostcheck.sh`;
|
||||
the run sheet's hook uses the installed name.
|
||||
- `stat` is a fake too. r6 lists seven fakes; a test can't make a root-owned file, so item 5's `stat` reads a
|
||||
fixture.
|
||||
- Item 7 with no Console unit installed reads the port as 1023 and says so ("1023, because no Console unit
|
||||
is installed"). Until I2 installs the unit the floor must be above 1023. A unit with no `--port` fails.
|
||||
- Item 4 runs under `timeout 60`. A hung `systemd-run` would otherwise hang the apt hook and the boot unit.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Fail closed where r6 is silent:
|
||||
- pins with no 64-hex sha fail item 1;
|
||||
- `dpkg -S` exit 1 (no owner) passes, exit 0 fails with the owner, any other exit fails with the code;
|
||||
- a failed `aa-status` fails item 2 and counts as loaded, so `--enforce` tries the unload.
|
||||
- `--root DIR` is the test seam (r6 :840). Root refuses it; a non-root user needs it. Both exit 2. Root runs
|
||||
with `PATH=/usr/sbin:/usr/bin:/sbin:/bin`.
|
||||
- `DRIFT` is append-only (`<stamp> <failed line>`), and `--enforce` never removes it. The log line is written
|
||||
only when the stop runs: `<stamp> enforce stop items=<ids or none> drift-before=<0|1> unload=<rc=N|no-file|skipped> chown=N chmod=N`.
|
||||
- `--upstream` exits 1 when the download or the unpack fails, since it then compared nothing. A deb without
|
||||
the profile file is a difference (exit 0), per r6 :302-303.
|
||||
- The bubblewrap note reads the version from `dpkg -s bubblewrap`.
|
||||
- The run sheet's recovery steps (`chown root:mosaic`, `chmod 0750`, `apparmor_parser -r`, then `--check`
|
||||
before root removes `DRIFT`) are mine. r6 says only that root removes `DRIFT` in a reviewed step.
|
||||
|
||||
## Run environment
|
||||
|
||||
Worktree `/mnt/storage/scratch/darkwing-r240b`, branch `darkwing/row240-r3` on c771a36f4, with `node_modules`
|
||||
linked to the canonical checkout's. Node v26.8.1, kernel 7.2.2-1-cachyos, 16 CPUs, TMPDIR
|
||||
`/mnt/storage/scratch/tmp/r240`, compose project `darkwing-r240b`. Sage's slot (sage-1773, 06:16Z). Each run is
|
||||
one chain behind `busy.sh`, launched from `/`: `~/darkwing-scratch/r240/run-all.sh` checks the HEAD pin and a
|
||||
clean tree, runs the singles, then the mutants (8 killed, none surviving, the tree clean after), then the gate
|
||||
(the cli suite and every `scripts/test-*.sh`), each stopping the chain on failure, then sweeps `/proc` cwd links
|
||||
for anything left in the r240 trees.
|
||||
|
||||
## Results
|
||||
|
||||
Run r1, 06:17:10Z on 47ec9fe2f, failed at the singles. `case1-hostcheck.test.mjs` passed 16/0/0, and
|
||||
`stack-upgrade.test.mjs` went 20 pass, 1 fail. The failure was the N1 test's own `t.after` hook, which ran
|
||||
`chmodSync(e.etc, 0o755)` after the fixture's cleanup had already removed the scratch root, so it threw ENOENT.
|
||||
Every assertion in the test body had passed, exit 5 included. The fixture's cleanup runs `chmod -R u+w` on the
|
||||
root before `rmSync`, so the 0555 dir never needed the hook. b45852daa drops it and changes nothing else. The
|
||||
mutants and the gate didn't run. The sweep found nothing left. Sage counted r1 as this slot's one failure
|
||||
(sage-1788).
|
||||
|
||||
Run r2, 06:20:41Z to 06:33:28Z on b45852daa, passed. The counts come from awk over the .out files
|
||||
(`~/darkwing-scratch/r233/sum.sh`):
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| `case1-hostcheck.test.mjs` | 16/0/0 |
|
||||
| `stack-upgrade.test.mjs` | 21/0/0 |
|
||||
| c0 control | exit 0 |
|
||||
| M1-M8 | 8/8 killed, none survived, every restore's sha256 matched, tree clean after |
|
||||
| `packages/cli` suite | 639/0/0 |
|
||||
| `scripts/test-*.sh` | auth 15, conductor 17, config 31, discord 78, extension-package 18, foundation 44, land-work 86, queue 156 node + 28 selftest, release 14, task 98; 585 pass, 0 fail |
|
||||
| Gate total | 11 suites, 1224 pass, 0 fail |
|
||||
| Sweep | nothing left in the r240 trees; the compose network was removed |
|
||||
|
||||
This commit adds only these BUILD.md lines. No test reads BUILD.md.
|
||||
@@ -1,91 +0,0 @@
|
||||
# Row 245 build: Duckworth's N1-N3 on `scripts/case1-accept.sh` (row 195 follow-up)
|
||||
|
||||
Duckworth approved row 195's manifest round 1 (duckworth-0088, `agents/duckworth/work/row195/REVIEW-m1.md`
|
||||
at 6afcee2a5) with three notes that don't block. Row 195's code landed as b65014647, and the notes are in
|
||||
`docs/plans/DEFERRED.md` (d6cff0cdd). Row 245 carries them: brief `docs/plans/2026-10-20_hub-own-uid.md` §
|
||||
"Row 195 follow-up" at 62ddc96cc, owner Darkwing, reviewer Duckworth, gate owner Sage (sage-1923).
|
||||
|
||||
The branch is `darkwing/row245`, on 62ddc96cc. Its first commit is a cherry-pick of `darkwing/row195-n1`
|
||||
1e3c4767c, which was written on 37171f65b and never run. The script, test and TOOLS.md blobs are the same
|
||||
as in 1e3c4767c, and none of those paths changed between 37171f65b and 62ddc96cc.
|
||||
|
||||
## What changes
|
||||
|
||||
N1. F9 ran `$ROOT$NODE` as root (`case1-accept.sh:298` at 37171f65b) before F10 checked that `--node` is
|
||||
the pinned path. Duckworth offered two fixes: F9's parse through `AS_USER`, or F10's path check first. I
|
||||
took the path check, plus one more:
|
||||
- F9 now runs node only when `--node` equals `/opt/mosaic-stack/node-<--node-pin>/bin/node`.
|
||||
- Then `not_root_only` walks from that file up to `/`. Each entry must be the running user's (root's on the
|
||||
host) with no group or other write, the rule `case2-unit.sh` `root_only` applied when it rendered the
|
||||
units. A symlink fails it, because its mode is 777. The first entry that breaks the rule goes in the
|
||||
evidence.
|
||||
|
||||
I didn't run the parse through `AS_USER`. That would hand a check about a root file to the stack's user,
|
||||
and a process running as `mosaic` shares that node's uid. Whether it could change what node prints then
|
||||
turns on the host's ptrace setting, and the root-only check doesn't depend on that. Sage agreed in
|
||||
sage-1877. Rechecking ownership costs one loop and doesn't depend on
|
||||
how the host was installed.
|
||||
|
||||
N2. The header (:9-15) and the `docs/TOOLS.md` entry now list what root runs: `systemctl show` and
|
||||
`is-enabled` (F1, P11), `curl` on the loopback healthz (F2), `sudo -l` (P1, P3), `sudo -u jarvis bwrap
|
||||
--version` and `hostcheck.sh --check` (P9), and node for F9 under the rule above.
|
||||
|
||||
N3. F6 matched `--claude` by prefix, so `/opt/mosaic-stack/claude-<pin>/../claude-2.0.9/...` passed the
|
||||
path check. F6 now fails on any `..` segment, before it runs `--version`. F10 compares for equality, so
|
||||
it needed no change.
|
||||
|
||||
Row 195's BUILD.md gets a correction entry for deviation 6. Row 195's DESIGN.md is unchanged. Its "either
|
||||
works" wording at `DESIGN.md:336-338` is superseded by gosalyn-0087 (root writes `dev.args` at W10), and
|
||||
it stays in place: an approved design isn't edited (sage-1877).
|
||||
|
||||
## Tests
|
||||
|
||||
`packages/cli/tests/case1-accept.test.mjs`:
|
||||
- The node stub touches `node-as-root` in the state directory when something runs it outside the fake
|
||||
`sudo` (which sets HOME to the stack's home). `host()` sets the node file's directories and the scratch
|
||||
root to 0755, so the umask can't fail F9.
|
||||
- "every check passing" also asserts that root ran the pinned node.
|
||||
- New: "F9 won't run --node as root off the pin path or with a writable directory above it". Another node
|
||||
path fails F9 and F10, with no root run. A group-writable `bin/` and a symlinked node each fail F9
|
||||
alone, with the bad path in the evidence and no root run.
|
||||
- The F6 test gains a `..` case: a path under `claude-<pin>/` by prefix that names 2.0.9's binary, which
|
||||
prints the pin. F6 alone fails, with "has a .. segment".
|
||||
|
||||
That makes 23 tests. All 23 pass on Sage's slot (results below).
|
||||
|
||||
## Mutants
|
||||
|
||||
The twelve from row 195 still apply, unchanged. These four are new, each one exact replacement:
|
||||
|
||||
| # | Mutant | Expected killer |
|
||||
|---|---|---|
|
||||
| 13 | F9 runs any `--node` (the pin check becomes `true`) | F9 won't run (another node) |
|
||||
| 14 | F9 skips the ownership walk (`bad=""`) | F9 won't run (writable `bin/`, symlink) |
|
||||
| 15 | `not_root_only` checks only the file, not the directories above it | F9 won't run (writable `bin/`) |
|
||||
| 16 | F6 accepts a `..` segment | F6 stub, path and `..` |
|
||||
|
||||
The brief's gate asks for a mutant for each of N1 and N3. Mutants 13-15 are N1's, and 16 is N3's.
|
||||
|
||||
## Slot plan
|
||||
|
||||
Same shape as row 195's steps, on this row's tip: the single test file, then the sixteen mutants, then
|
||||
the gate (the cli suite and every `scripts/test-*.sh`), each behind the busy checks and chained with
|
||||
`&&`. The scripts are in `/mnt/storage/scratch/darkwing-245-out/`.
|
||||
|
||||
## Slot results (grant sage-1961, 2026-10-21)
|
||||
|
||||
All three steps ran on 0590a29fc with node v26.8.1, launched from `/`. Both busy checks were CLEAR
|
||||
before each step.
|
||||
|
||||
- Singles, 11:27:46-11:27:59Z: `case1-accept.test.mjs` passed 23, failed 0.
|
||||
- Mutants, 11:28:10-11:31:20Z: all 16 killed, each with exit 1. The runner restored the script, and
|
||||
its sha256 875bb5b7 matches the committed blob. Rows 1-12 died as in row 195. 13, 14 and 15 died on
|
||||
"F9 won't run --node as root off the pin path or with a writable directory above it". 16 died on
|
||||
"F6 fails on a stub that prints nothing, on a path outside claude-<pin> and on a .. segment".
|
||||
- Gate, 11:31:20-11:42:51Z: the cli suite passed 664, failed 0, skipped 0. Every `scripts/test-*.sh`
|
||||
exited 0: auth 15, conductor 17, config 31, discord 78, extension-package 18, foundation 44,
|
||||
land-work 86, queue 28 (its node part 156/0), release 14, task 98. That's 429 selftests, none failed.
|
||||
- Afterwards no process had its cwd in the worktree, the out dir or TMPDIR, and the
|
||||
`darkwing-r245_default` network was gone.
|
||||
|
||||
Logs: `/mnt/storage/scratch/darkwing-245-out/` (`step1.log`, `mut.out`, `mut/m1-16.log`, `gate.out`).
|
||||
@@ -1,310 +0,0 @@
|
||||
# Row 234 (#436a row a, Gitea #1740): host enrollment, store records and bus schema 3i to 3j
|
||||
|
||||
Gizmoduck, 2026-10-21. Reviewer Duckworth, gate owner Sage. Lands by 10-25, before the .53 and .54 installs
|
||||
(mos-1134).
|
||||
|
||||
- Brief: `docs/plans/2026-10-20_host-credentials.md`, section "Machine grant row a: enrollment, store records
|
||||
and migration 3i-3j", blob 64451be5 (queue rev 2617).
|
||||
- Design: `agents/gizmoduck/work/machine-grant/DESIGN.md` r6, f9039682e on `gizmoduck/machine-grant-design`,
|
||||
approved by Duckworth (duckworth-0064, REVIEW-r6.md at 8ea792256). Not reopened here. Cites "r6:" are lines
|
||||
of that file.
|
||||
- Grant event rule: #436b section 7, fa5658941 on `megavolt/row199`,
|
||||
`agents/megavolt/work/436b/DESIGN.md:431-490`, file sha256
|
||||
96510b83778e79a71b33d0d5f6fd0c806dfe74f6de315ff4a9f1fbe5620f6c69, approved by Duckworth (duckworth-0067).
|
||||
- Base: origin/refactor 8fede5fc6, with rows 199 (cb5fdb064) and 210 (f5753c0e8) landed. Branch
|
||||
`gizmoduck/row234`. Code cites are `path:line` at 8fede5fc6 unless they say otherwise.
|
||||
|
||||
## Names: r6 against section 7
|
||||
|
||||
r6's `grant.*` and `host.enroll.*` names are `body.action` values on `action.allowed` events, not event
|
||||
kinds. The `events` kind CHECK isn't rebuilt. Four r6 names move under `grant.`:
|
||||
|
||||
| r6 | This row |
|
||||
|---|---|
|
||||
| `host.enroll.step` | `grant.host.enroll.step` |
|
||||
| `host.enrolled` | `grant.host.enrolled` |
|
||||
| `host.checked` | `grant.host.checked` |
|
||||
| `host.removed` | `grant.host.removed` |
|
||||
|
||||
Only the consume record carries `decision`: `{operation: 'grant.host-enroll', decision}`. Every other
|
||||
guarded body names the decision `grantDecision`, so r6's `decision` field in step, enrolled and failed bodies
|
||||
is `grantDecision` here. The actor of every guarded event is `actor_role` NULL with `actor_run` 'grant',
|
||||
and `body.by` names the caller the broker checked.
|
||||
|
||||
## Split (r6 section 12 Q-S4)
|
||||
|
||||
Row a builds:
|
||||
|
||||
1. **Schema 3i to 3j.** `migrations/3i-3j.sql` adds `hosts` and `host_grants` (r6 S1, primary keys
|
||||
(business, host) and (business, agent, host)) and the guard trigger: an `action.allowed` whose
|
||||
`body.action` or `body.operation` GLOBs `'grant.*'` aborts unless the actor is (NULL, 'grant').
|
||||
`SCHEMA_VERSION` 3j, a migration test with a step that fails partway.
|
||||
2. **Grant event writer.** A private session-less broker writer for guarded events. `#event` doesn't change.
|
||||
`recordEvent` refuses `reserved-event` for the guarded namespace.
|
||||
3. **The `grant` raise frame.** `decision.raise` takes `grant` only with `credential.mint`, only from the chief
|
||||
(`grant-chief-only`), and in row a only with `method: 'host-enroll'`. Any other method refuses
|
||||
`invalid-request` until row b adds `host`. E1's checks, the `ssh-keyscan` set compare before the raise
|
||||
transaction, and the rendered block.
|
||||
4. **Enrollment executor.** `#consumeGrant`, the start on the human's cli yes (X1), E2 steps 1 to 4, the
|
||||
installer key at `<configDir>/secrets/hosts/<business>/<hostId>/installer`, the failure cleanup, and the
|
||||
start reconcile for enrollments: the `registry` step undo, and a resolved-yes unspent enrollment ending
|
||||
`grant.failed interrupted` (X3).
|
||||
5. **`hosts.check` and `hosts.remove`** (E4), with `mosaic hosts status|check|remove`.
|
||||
6. **`tools/host/`**: `mosaic-keys`, `mosaic-keys-write`, `mosaic-keys.sudoers`, `mosaic-keys.sshd.conf` (E3).
|
||||
7. **`host.run` on the store** (X4 first and third bullets): it reads the `hosts` and `host_grants` records
|
||||
and the Approve record check, and at start the runtime gives `Credentials` a reference per `host_grants`
|
||||
record. The loader refuses `hosts` at the top level and in a role (S1). Row 210's tests move to store
|
||||
fixtures.
|
||||
8. **Reader filter** (duckworth-0067 N1): every grant-evidence reader in this row (status, the start
|
||||
reconcile, `openGrantDecisions`) filters `kind = 'action.allowed' AND actor_role IS NULL AND actor_run =
|
||||
'grant'`, with a test that an `action.refused` look-alike and a session-written one are ignored.
|
||||
`hosts.remove`'s `hostLineLeft` reader is one of them (duckworth-0079 M4).
|
||||
|
||||
Row b keeps the `host` method's raise binding (R4), the grant executor (X2), the grant rows of X3, X5,
|
||||
`Credentials.register`/`claim`/`abortClaim`, revoke (section 6) and `sweep`. So row a has no way to make a
|
||||
`host_grants` record outside a test fixture, and from its landing to row b's no agent holds a host grant
|
||||
(r6 N9, the run sheet strips row 210's keys).
|
||||
|
||||
Tests in row a (r6 section 10): G1 and G5 for the `host-enroll` raise, G6 and G10 for the enrollment
|
||||
executor, G11's enrollment cases, G14, G15, G16, G17, G18, G20, G21 (its `host-line-left` case reads an
|
||||
event fixture until row b writes `hostLineLeft`), G25, the migration test, the trigger cases, the reader
|
||||
filter test, and row 210's T-tests on store fixtures. Row b: G2 to G4, G7 to G9, G11's grant cases, G12,
|
||||
G13, G19, G22 to G24, G26 to G28.
|
||||
|
||||
## Early check items (duckworth-0079, REVIEW-build-split.md 10672772e on `duckworth/reviews`)
|
||||
|
||||
Moves into row a:
|
||||
- M1. G4 for the `host-enroll` block (r6:240-242): a question can't forge the block, and the 4096-character
|
||||
limit holds with the block appended. G4 for the `host` block stays in row b.
|
||||
- M2. The tag function (r6:265-266), because E2 step 2 tags the installer line. G24's first sentence moves
|
||||
here: names of 64 characters with `.` and `_` give distinct tags of the fixed length. The `sweep` half
|
||||
stays in row b.
|
||||
- M3. A store-fixture test that `host.run` refuses `host-not-granted` for no record, an unresolved decision,
|
||||
a decision resolved by an arbiter, the tester or an agent or not via cli, a consume record with operation
|
||||
`grant.host-enroll` in place of `grant.host`, and an unspent decision (X4 first bullet, r6:593-596). A
|
||||
restart test that a `host_grants` fixture gives `Credentials` a reference (X4 third bullet).
|
||||
|
||||
G21's `host-line-left` case:
|
||||
- F1. The fixture goes through this row's private grant writer in the real shape: `action.allowed`, actor
|
||||
(NULL, 'grant'), `body.action` 'grant.failed', `body.host`, `body.hostLineLeft`. No raw SQL around the
|
||||
trigger.
|
||||
- F2. `hosts.remove` reads `body.hostLineLeft` from any guarded body, not only `grant.failed`.
|
||||
- F3. Decided: a guarded body that carries `hostLineLeft` also carries `host`. Row b adds `host` to
|
||||
`grant.revoke.closed`, so its body is `{tag, host, hostLineLeft}` (r6:716 has `{tag, hostLineLeft}`).
|
||||
The reader matches on `body.host`, and doesn't map tags back to hosts through `grant.step`. A direct
|
||||
field is one equality in SQL, and a tag lookup would add a second reader that the N1 filter also has to
|
||||
cover.
|
||||
- F4. Row b carry: "no later `sweep` cleared it" (r6:403) needs `grant.sweep`, which only row b writes. Row
|
||||
b adds the sweep clear to `hosts.remove` with a G21 case, and its G8 or G13 shows `hosts.remove`
|
||||
refusing after a real `hostLineLeft` write, not only after the fixture. Until then row a's check is "any
|
||||
guarded `hostLineLeft` for this host refuses".
|
||||
|
||||
## Mutants (r6:838-883, numbered here)
|
||||
|
||||
Row a, killed on this row's tree:
|
||||
|
||||
| # | Mutant |
|
||||
|---|---|
|
||||
| A1 | drop the chief check |
|
||||
| A2 | start the executor on a `credential.mint` with no `grant` |
|
||||
| A3 | start it on an arbiter resolve |
|
||||
| A3b | start it on a `cli` yes by someone other than the human (duckworth-0083 N8) |
|
||||
| A4 | start it on `no` |
|
||||
| A5 | skip the keyscan compare |
|
||||
| A6 | compare only one key |
|
||||
| A7 | write the private key into the event body |
|
||||
| A8 | drop the "not yet enrolled" check in E2 step 1 |
|
||||
| A9 | drop the `hosts` primary key |
|
||||
| A10 | skip the start check for a resolved, unspent enrollment decision |
|
||||
| A11 | `host.run` reads the business file instead of the grant record |
|
||||
| A12 | build the tag without the business |
|
||||
| A13 | drop `from=` in the writer |
|
||||
| A14 | let the writer touch a line outside the tag pattern |
|
||||
| A15 | drop `O_NOFOLLOW` on the user file |
|
||||
| A16 | drop one group from the writer's refused list |
|
||||
| A17 | drop the user pattern check before the path use |
|
||||
| A18 | treat a `privileged` entry as enough without `users` |
|
||||
| A19 | let the writer accept a sudo user not in `privileged` |
|
||||
| A20 | trigger: drop the `body.operation` arm, so the consume record passes from a session |
|
||||
| A21 | trigger: accept actor_role NULL with any actor_run |
|
||||
| A22 | a grant reader drops the `kind = 'action.allowed'` or actor filter. The runner splits it: A22a drops the kind, and G21's `action.refused` look-alikes kill it. A22b drops the actor, which the trigger can't stand in for, because 3i-3j adds the trigger without checking old rows. Its killer is the grant-migration test "A22b, sage-1703": a session-written `grant.host.removed` with `hostLineLeft` and a `grant.host-enroll` row from 3i, migrated to 3j, where `hosts.remove` still removes the host and `reconcileGrants` ends nothing |
|
||||
| A23 | `host.run` skips the Approve record check (any `host_grants` record is enough) |
|
||||
|
||||
Row b keeps the rest of r6's list: the `ready` check, the three hash compares, the definition check, the
|
||||
install and record order, `remove` on a verify failure, both start sweeps, the reconcile's remote `remove`,
|
||||
the `grant.step` before install, revoke leaving the record, `register` before `issue`, the abort check and
|
||||
the `await` before `issue`, revoke and an unreferenced claim, `sweep` and any `mosaic-` line, revoke's host
|
||||
step order, claim before consume, the resolved unspent grant decision, an async `commit`, `hosts.revoke`
|
||||
through `credential-verbs.mjs`, the revoke marker reconcile, the upsert, the undo past `grant.revoked`, the
|
||||
abort after step 5 and a missing `bus.tester` as dev.
|
||||
|
||||
## Broker functions this row touches (sage-1677)
|
||||
|
||||
For row 236 (Megavolt), which rebuilds on this landing. Old lines are `packages/bus/src/broker.mjs` at
|
||||
8fede5fc6, new lines are at the last commit that changed this file (they moved by 10 after `runEnrollment`
|
||||
went in). The planned list is kept in this file's history (827b89ea6).
|
||||
|
||||
Changed:
|
||||
- `constructor` (:279): one more option, `hostGrant`, the object `host-grant.mjs` builds.
|
||||
- `recordEvent` (old :1243-1251, new :1606): one more refusal. An `action.allowed` whose `body.action` or
|
||||
`body.operation` is a `grant.*` name refuses `reserved-event`.
|
||||
- `request()` (old :1362, new :1726): two routes ahead of the sync path, a `decision.raise` with `grant` to
|
||||
`#raiseGrant` and the `hosts.*` verbs to `#requestHosts`. Both are async.
|
||||
- `requestHost` (old :1712-1795, new :2079): the business-file lookups (old :1730-1736) become one
|
||||
`#hostAccess` call, in the same transaction as `#agent`. `commands` and `entry` come from it.
|
||||
- `#dispatch` (old :1796, new :2160) gets a fifth parameter, `enroll`, which only `#raiseGrant` passes.
|
||||
- `decision.raise` (new :2244): `grant` joins the allowed keys, and `grant` without `enroll` (or the
|
||||
reverse) refuses `invalid-request`. The `host.sudo` block reads `#hostAccess` in place of `b.hosts`
|
||||
and the role's `hosts`, and an undefined `target` still refuses `host-unknown`. `validArgv` and
|
||||
`validStdin` now run after the access check, so a malformed argv for an ungranted host refuses
|
||||
`host-not-granted` first. The enrollment block is appended to the question before `string(question)`,
|
||||
so the 4096 cap counts it (M1). The decision's raise body carries `grant: enroll`.
|
||||
- `decision.resolve` (new :2409): when `op` is `resolved`, `#approvedGrant` reads the decision, and a
|
||||
`host-enroll` grant starts `#runEnrollment` from `setImmediate`, after the transaction commits. The
|
||||
result adds `grant: 'started'`.
|
||||
|
||||
New, all private but two:
|
||||
- `#grantEvent` (:657), the writer of guarded events. It runs the secret check, then `writeGrantEvent`.
|
||||
- `#approvedGrant` (:667), `#consumeGrant` (:678). `#consumeGrant` sits after `#approvedGrant`, not next
|
||||
to `#consumeAuthority` as planned.
|
||||
- `#hostRecord` (:685), `#hostAccess` (:692), `#grantNotice` (:708), `#enrollFailed` (:736),
|
||||
`#runEnrollment` (:750), `#openGrantDecisions` (:823).
|
||||
- `runEnrollment` (:745), public, a trusted entry to `#runEnrollment` for G10. No verb reaches it.
|
||||
- `reconcileGrants` (:833), public, which `runtime.mjs` calls once at start.
|
||||
- `#raiseGrant` (:866), `#hostsCaller` (:886), `#requestHosts` (:900), `#hostLinesLeft` (:964).
|
||||
|
||||
Module level, new (:184-230): `GRANT_RUN`, `grantName`, `GRANT_EVIDENCE` (the N1 filter as SQL),
|
||||
`registrySha256`, `writeGrantEvent` (exported, the one writer of guarded events, which test fixtures use
|
||||
for F1), `HOSTS_VERBS`, `fingerprintOf`, `INSTALLER_COMMENT`.
|
||||
|
||||
Unchanged: `#event`, `#spent`, `#consumeAuthority`.
|
||||
|
||||
Other `src` files:
|
||||
- `bus/src/host-grant.mjs`, new: the keyscan, the installer key pair, and `hosts.check`'s `list`.
|
||||
- `bus/src/runtime.mjs`: builds `hostGrant`, calls `reconcileGrants` before the server starts, and adds
|
||||
the `host_grants` references before `new Credentials` (X4 third bullet).
|
||||
- `bus/src/host-run.mjs`: `runHost` takes `command`, sent as is in place of the sudo argv, for the
|
||||
installer's `list`.
|
||||
- `bus/src/server.mjs`: the `hosts.*` verbs and a raise with `grant` go down the async path.
|
||||
- `bus/src/store.mjs`: the 3i-3j step, `SCHEMA_VERSION` 3j. `bus/src/credentials.mjs` exports
|
||||
`privateDir`.
|
||||
- `business/src/business.mjs` and `business/src/hosts.mjs`: the loader refuses `hosts` (S1).
|
||||
`checkHosts` and `checkHostGrants` are gone.
|
||||
- `cli/src/hosts.mjs`, new, routed from `cli.mjs` and `scripts/mosaic`.
|
||||
|
||||
## Decisions taken in the build
|
||||
|
||||
- **`#hostAccess` binds the agent and the host.** X4 says the Approve record check passes when the
|
||||
decision is an approved `host` grant with a `grant.host` consume. The check also requires the grant's
|
||||
`agent` to equal the session's role and its `host` to equal the requested host. Without that, an
|
||||
approved grant for one agent and host would vouch for any `host_grants` row that names its decision.
|
||||
Cost: none for row b, whose executor writes the row from the same grant.
|
||||
- **Store fixtures are written the real way.** `bus/tests/host-fixtures.mjs` inserts the decision rows by
|
||||
SQL, but every guarded event goes through `writeGrantEvent`, so the trigger sees it (F1). An enrollment
|
||||
fixture writes `grant.host.enrolled`, or the start reconcile reads it as interrupted.
|
||||
- **The resolver test for host kinds is gone.** `business/tests/hosts.test.mjs` lost its D5 case, because
|
||||
the loader now refuses `host.<id>` credential refs. The `isHostKind` filter in `business/src/resolve.mjs`
|
||||
can't be reached now. I left it in, since row b may want it back, and it's one line.
|
||||
- **Store before Credentials.** The runtime already opened the store before `Credentials`. The
|
||||
`host_grants` references depend on that order, and the comment there says so.
|
||||
- **CLI.** `mosaic hosts` prints tab lines, and `--json` prints the bus rows. Usage errors exit 4 and bus
|
||||
refusals exit 2, as `mosaic credentials` does. There is no enroll command: enrollment is the chief's raise.
|
||||
- **Host tools tests live in the bus suite.** `bus/tests/host-tools.test.mjs` loads the writer by path
|
||||
through a Python harness under `python3 -B -I`. The harness passes its own key dir, config dir, owner
|
||||
uid and user, group and sudo lookups, and runs `mosaic-keys` from a copy with stub `sudo` and `logger`.
|
||||
The gate runs the bus suite, so G16 and G25 run there. The real `pwd`, `grp` and `sudo -l` paths are
|
||||
checked on the #433 VM, not here. The test also runs `visudo -c` on the sudoers file and `sshd -T` on
|
||||
the drop-in.
|
||||
- **Writer details the design left open.** The `from` file must hold exactly one value matching
|
||||
`[0-9A-Za-z.:/*?!_-]+(,[0-9A-Za-z.:/*?!_-]+)*`, so it can't close the quote. `users`, `privileged` and `from` must be
|
||||
regular, owned by the owner uid and not group or world writable, and are opened without following a
|
||||
symlink. Lines starting with `#` are comments. A refusal exits 1 with its code on stderr and in syslog.
|
||||
`add` of a tag that is already there exits 0 and writes nothing.
|
||||
- **`runEnrollment` is public for G10 (duckworth-0081 B1).** G10 needs a second start for a spent
|
||||
decision, and the resolve path can't give one: a second resolve refuses `decision-closed`
|
||||
(broker.mjs:2416) before the executor runs. So
|
||||
`#runEnrollment` returns its code (`enrolled`, `host-enrolled` or the refusal), and a public
|
||||
`runEnrollment` calls it, as `reconcileGrants` is public. The test checks a spent
|
||||
decision gives `decision-consumed`, and an open or agent-resolved one gives `not-authorized`, with
|
||||
nothing written. That second case also reaches the `via` and `by` checks in `#approvedGrant`, so A3 is
|
||||
no longer vacuous (N1).
|
||||
Who can call it (sage-1692): only code holding the `Broker` object, which is the bus process and the
|
||||
tests. The one `new Broker` in `src` is `runtime.mjs:126`, and no `src` file calls `runEnrollment`;
|
||||
the resolve path calls the private `#runEnrollment` from `setImmediate` (broker.mjs:2445). A seat
|
||||
holds no `Broker`. It reaches the bus only over `broker.sock`, where `server.mjs` calls a fixed set of
|
||||
methods by name (`request` at :134 and :140, `identity`, `disconnect`). `request()` routes on the verb
|
||||
through the `#dispatch` switch, and neither file looks a method up by name (no `this[` in either), so no
|
||||
verb string can reach `runEnrollment`.
|
||||
- **A `sudo -l` that times out refuses `user-sudo` (N2).** `_sudo` returns an empty answer on
|
||||
`TimeoutExpired`, which isn't the expected line, so `main` logs the refusal and exits 1.
|
||||
- **Keyscan comment (N3).** A scan that can't start refuses `host-failed`. One that runs and finds no key
|
||||
returns no lines, and the raise refuses `host-key-mismatch`. The comment in `host-grant.mjs` now says so.
|
||||
- **No tag on in-run `grant.failed` (N4).** r6:578 tags only the `interrupted` one, so the code follows it.
|
||||
- **Run sheet (N5).** The writer refuses `config-privileged` when the file is missing, so each host needs an
|
||||
empty, root-owned `/etc/mosaic-keys/privileged` (0644) before the first enrollment.
|
||||
|
||||
## Mutant results (Sage's slot, 2026-10-21)
|
||||
|
||||
All 25 killed: A1 to A23, with A3b added for duckworth-0083 N8 and A22 split into A22a and A22b. The
|
||||
runner is `mutants.py` outside the repo. Each mutant gets its own session, a 420 s limit with a group kill,
|
||||
and a byte restore checked against git status. None hung. Run 1 (04:24Z, on 95f966b1c) killed A1 to A22a
|
||||
and stopped at A22b, which survived. Run 2 (04:43Z, on 097892741) killed A22b and A23.
|
||||
|
||||
- Each kill comes from a named assertion in the mutant's own test files, except A9. Dropping the `hosts`
|
||||
primary key leaves `host_grants`' foreign key with no parent key, so the store refuses to open
|
||||
("foreign key mismatch") and 28 of 29 tests fail at setup. That's a structural kill, not the S1 test.
|
||||
The schema can't exist without the key (sage-1709).
|
||||
- A22b drops the actor filter from `GRANT_EVIDENCE`. The trigger can't stand in for it, because a 3i store
|
||||
can hold a session-written grant row that the 3i-3j step keeps. The grant-migration test "A22b,
|
||||
sage-1703" kills it (see the A22 row above).
|
||||
|
||||
Follow-up, not in row a: G18's control calls `runHost` directly, so nothing runs `hg.check` end to end
|
||||
with a good pin (duckworth-0083 N9). The #433 VM run covers it.
|
||||
|
||||
## Status at 097892741
|
||||
|
||||
Done: every Split item, 1 to 8, duckworth-0081 B1 to B3, duckworth-0083 N8, and all 25 mutants killed.
|
||||
|
||||
Single files in the slot, all exit 0, 178 pass and 0 fail by an awk sum over 23 logs: bus grant-enroll
|
||||
24, host-run 24, grant-migration 6, host-tools 6, account-migration 3, chief-of-staff 18,
|
||||
comment-migration 5, hard-waits 13, name-migration 1, project-runtime 3, spec-migration 1,
|
||||
thread-migration 3, usage-migration 1, project-files 8, spec-socket 6, spec-review 12, land 17, spec 4;
|
||||
harness spec-tools 2, raise-domain 2; business hosts 4; cli hosts 5; seat sandbox 10.
|
||||
|
||||
Left: Sage runs the whole bus, business and cli suites and every `scripts/test-*.sh` at the landing gate,
|
||||
after Duckworth approves.
|
||||
|
||||
## Round 3 (sage-1742): two tests that count the schema
|
||||
|
||||
The landing gate failed `packages/bus/tests/schema.test.mjs:522`. It counts the tables and triggers that
|
||||
`schema.sql` creates, and row a adds two tables and a trigger, so the counts go from 16 to 18 tables and
|
||||
from 70 to 71 triggers. My singles didn't run it because I took it for a test of other rows' tables.
|
||||
|
||||
I grepped `packages/*/tests/*` for `sqlite_master` and `schema.sql`, with `schema.test.mjs:517` as the
|
||||
positive control. That turned up one more test that would fail: T46 in
|
||||
`packages/cli/tests/stack-release.test.mjs:910`. It puts every schema table in exactly one of four lists, and
|
||||
hosts and host_grants were in none. I'd left the stack-release tests out of the r2 singles on the claim that
|
||||
they don't load the bus, but T46 reads `schema.sql`. Both tables go in its untimed list (Sage's ruling,
|
||||
sage-1745: the message_threads pattern). Neither has an `at` column. Row a writes hosts at five lines,
|
||||
and each runs in one store transaction with a `grant.*` events row, which the restore guard counts.
|
||||
`#grantEvent` (broker.mjs:657) writes that row through `writeGrantEvent` (:201, INSERT INTO events at
|
||||
:212).
|
||||
|
||||
- :737 DELETE, in `#enrollFailed`, with grant.failed at :738. Both callers wrap it in a transaction,
|
||||
:816 and :849.
|
||||
- :763 INSERT, in the step 1 transaction (:754), with grant.host.enroll.step at :761.
|
||||
- :783 UPDATE installer_fingerprint, in the step 4 transaction (:780), with grant.host.enrolled at :784.
|
||||
- :932 DELETE, in the hosts.remove transaction (:927), with grant.host.removed at :933.
|
||||
- :950 UPDATE state ready, in the hosts.check transaction (:944), with grant.host.checked at :951.
|
||||
|
||||
Row a writes no host_grants rows. It only reads them (broker.mjs:695, :930, runtime.mjs:103). Row b adds
|
||||
the writes, and each must share a transaction with a `grant.*` event the same way. That goes on row
|
||||
235's carry list. The other hits pin an older schema
|
||||
(account, comment, name, spec, thread and usage migrations, chief-of-staff 3c) or count only their own
|
||||
tables (state.test.mjs:106 reads every table but pins no list, threads.test.mjs:129).
|
||||
|
||||
hosts and host_grants are mutable by design, so the refused-labels loop above `schema.test.mjs:517` gets no
|
||||
entry for them. hosts.check updates a host to ready. hosts.remove and a failed enrollment delete the
|
||||
record. Row b updates and deletes grant records. Neither table has append-only triggers. The one new
|
||||
trigger, events_grant_actor, guards events. grant-migration.test.mjs tests it, and it killed mutants
|
||||
A20 and A21.
|
||||
@@ -1,598 +0,0 @@
|
||||
# Row 235 build: machine grant row b (#436, Gitea #1741)
|
||||
|
||||
Brief: `docs/plans/2026-10-20_host-credentials.md`, "Machine grant row b: raise binding, executor,
|
||||
reconcile and revoke". Design r6 f9039682e. Built on row 234's landing c771a36f4 (sage-1775), branch
|
||||
gizmoduck/row235-b. Line numbers below were read on b0320d395, whose src files are byte-identical to the landing's.
|
||||
|
||||
## Squash onto 4ced5e63d (sage-1812, sage-1831)
|
||||
|
||||
Steps 1-6 were six local commits on c771a36f4 (gizmoduck/row235-b, tip f9fff4300). Sage named
|
||||
4ced5e63d as the base, so this branch, gizmoduck/row235-c, holds one commit on it: the diff
|
||||
c771a36f4..f9fff4300 applied with `git apply --index`. N10 is in that commit. N12 needs no change,
|
||||
because row b doesn't touch schema.sql.
|
||||
|
||||
Rows 240, 228 and 238 landed between c771a36f4 and 4ced5e63d and changed 18 paths. One of them is
|
||||
also mine:
|
||||
|
||||
- `packages/cli/README.md`. Row 228 (86249ac55) changed the session credential paragraphs at about
|
||||
lines 432 and 706. My hunk adds three lines on `hosts revoke` and `hosts sweep` at about line 1052.
|
||||
The two don't overlap, so the blob here is row 228's blob plus my three lines, with no landed line
|
||||
reverted. cli tests/host.test.mjs:855 reads this README, and run 1's cli suite covers it.
|
||||
|
||||
The other 12 paths are byte-identical to f9fff4300. The line numbers in the step notes below were read
|
||||
on the old base.
|
||||
|
||||
## Run 1 (sage-1831, sage-1833, sage-1838)
|
||||
|
||||
Run 1 on 4d4bb4af6, 07:45-07:54Z, failed: 1351 tests, 1285 pass, 66 fail. The 66 are 33 tests counted
|
||||
twice, once in the files run and once in the bus suite. The cli suite, the cli hosts test, seat
|
||||
sandbox.test and bus project-runtime.test passed. Two defects, both mine:
|
||||
|
||||
- grant-executor.test.mjs, 32 tests. setup() never made `dir/data`, and `new Store` lstats its root
|
||||
(store.mjs:137), so every test stopped at setup with ENOENT. setup() now makes it, mode 0700.
|
||||
- credential-claim.test.mjs:76 (X4), 1 test. A register after a revoke left the key in `#revoked`, so
|
||||
status() said `revoked` for a new grant's reference that has no token yet. register now clears
|
||||
`#revoked`, as unregister does. `#revoked` feeds only status(), and issue clears it as well.
|
||||
|
||||
Both fixes are amended into the one commit. The rerun covers the five bus files, the bus suite, and
|
||||
cli credentials.test.mjs and verbs.test.mjs, which read credential state strings.
|
||||
|
||||
## Run 1 rerun (sage-1842, sage-1843)
|
||||
|
||||
The rerun on ac7c45975, 08:06-08:07Z, failed: 701 tests, 695 pass, 6 fail, which are 3 grant-executor
|
||||
tests counted in both the files run and the bus suite. All three were test defects that run 1's setup
|
||||
failure had hidden. No src file changed for them:
|
||||
|
||||
- G10. The two fixture decisions were built after `before`, and grantDecision (host-fixtures.mjs)
|
||||
INSERTs one events row each, so the count read 13 for 11. The test now builds them first, asserts
|
||||
they added exactly 2 rows, and then pins the executor's 0 against that count.
|
||||
- G27. setup's t.after removes the scratch dir before a hook the test adds later runs, so the test's
|
||||
chmod hook hit ENOENT after the body had passed. The body now restores the mode in a try/finally.
|
||||
- G22. The slow run's promise had no handler until `refusesWith`, four lines on. The revoke rejected it
|
||||
first, and node failed the test on the unhandled rejection. The handler now goes on at creation.
|
||||
|
||||
The fix is one more amend of the single commit. The rerun is grant-executor.test.mjs whole plus the bus
|
||||
suite.
|
||||
|
||||
Rerun 1c on 5df4b52c5, 08:15Z, was green: 623 of 623, grant-executor 34 of 34 and the bus suite 589 of 589.
|
||||
|
||||
## Mutants B1-B37 (sage-1894, sage-1899, sage-1904)
|
||||
|
||||
Three chunks ran on 5df4b52c5, 09:22-09:37Z. Restores matched HEAD after each. 33 were killed and four
|
||||
survived. Two of the misses were mine, in the file lists:
|
||||
|
||||
- B2 and B23 listed only grant-executor, and that file never makes the raise either mutant changes.
|
||||
grant-enroll.test.mjs:227 (A2, A4) resolves a plain `credential.mint` yes and a host grant no, and
|
||||
asserts no grant events for either. mutants.py now lists grant-enroll and grant-executor for both.
|
||||
B2 then ran alone and A2 killed it. B23 runs again with this round's tests.
|
||||
|
||||
The other two survivors needed tests, and Sage ruled on all four (sage-1904):
|
||||
|
||||
- B16 drops the abort check after `verify`. The check after `step('commit')` still ends the grant
|
||||
revoked, so G23 X5's asserts held. The run then logs a commit step for a grant that never committed.
|
||||
G23 X5 now asserts that its only `grant.step` is `install`.
|
||||
- B17 adds `await null` after the commit check. Sage accepted it as equivalent. `await null` yields
|
||||
only to microtasks. Every revoke starts in a macrotask of its own (a socket request, or a test's
|
||||
call), so none can land in that gap. There is no test for it.
|
||||
- B27 makes issue's commit callback async. issue() doesn't await it, so the executor returns `applied`
|
||||
before the INSERT runs. The two differ only when the commit transaction throws, and a real store can
|
||||
throw there with a full disk, an I/O error or a busy timeout. Under the mutant that throw becomes an
|
||||
unhandled rejection after the key file is written, and the catch's undo never runs. The new G8 test
|
||||
makes the store's `run` throw SQLITE_FULL once on the host_grants INSERT. It asserts host-failed, no
|
||||
record, key file, reference or line, and the steps install, commit, failed. The store object comes
|
||||
from the test's setup, so src gains no seam.
|
||||
|
||||
These two test changes went in as a new commit on gizmoduck/row235-d (8b13d0ca0). That branch then
|
||||
moved onto row 213's landing as gizmoduck/row235-e, below. B16, B23 and B27 run there, after the touched
|
||||
test files run whole.
|
||||
|
||||
## Rebase onto 62ddc96cc (sage-1922)
|
||||
|
||||
Row 213 landed as 361ba2387, and refactor's tip is 62ddc96cc, with only docs past it. gizmoduck/row235-e
|
||||
starts at 62ddc96cc and cherry-picks 8b13d0ca0's one commit. The patch-id is unchanged (c062b73fa9f1).
|
||||
row235-c and row235-d stay as they were.
|
||||
|
||||
Between 4ced5e63d and 62ddc96cc, 361ba2387 is the only commit that touches a file of mine. The one
|
||||
drifted path is `packages/bus/src/broker.mjs`:
|
||||
|
||||
- 213 adds `CREATIVE_DEFINITIONS` to the vocabulary import at :6, and refuses `decision.raise` from a
|
||||
creative role with `decision-not-allowed` at :2176. Both hunks stay as landed.
|
||||
- My 31 removed broker lines read the same before and after the cherry-pick, so no landed line is
|
||||
reverted.
|
||||
|
||||
`packages/cli/README.md` isn't in 361ba2387. Every other path of mine is byte-identical to 8b13d0ca0.
|
||||
Line numbers in the section below were read on the row235-e working tree; earlier sections keep theirs.
|
||||
|
||||
## Creative roles get no host grant (mos-1163, sage-1905, sage-1908, sage-1916)
|
||||
|
||||
Brief blob b16e236188f6 (4364dddb0, rev 2784) requires two refusals: the writer, at the raise and at the
|
||||
step 1 recheck, and host.run at spend time, with the record kept. Webby's N2 on row 213 r4 asks whether
|
||||
a creative role can spend a decision another role raised.
|
||||
|
||||
The spend answer. Mos confirmed it read-only (mos-1164, sage-1916) on 8b13d0ca0 and 01e1fe512; the
|
||||
row235-e line follows each:
|
||||
|
||||
- Only the chief raises a host grant. `#raiseGrant` refuses `grant-chief-only` for any other
|
||||
definition: :1232 on 8b13d0ca0, :870 on 01e1fe512, :1238 here. The hosts verbs check the same at
|
||||
:1307 on 8b13d0ca0, :896 on 01e1fe512, :1313 here. `chief-of-staff` isn't in `CREATIVE_DEFINITIONS`.
|
||||
- No agent spends one. `#consumeGrant` (:697) is called for a host grant only from `#runHostGrant`
|
||||
(:902) and from the restart reconcile (:1106), which ends it interrupted. `#runHostGrant` starts from
|
||||
the resolve hook (:2881) only when `#approvedGrant` returns a grant, and that needs a close by the
|
||||
business's human, via cli, with the raise's approval choice (:691). An agent's resolve records via
|
||||
`broker`, so it starts nothing (grant-enroll A3). The public `runHostGrant` (:891) is a trusted API
|
||||
that no verb reaches.
|
||||
|
||||
So a creative role can't raise, approve or spend a host grant. The gap was a different one: the chief
|
||||
could raise a grant whose `agent` is a creative role, and the human could approve it. The only check on
|
||||
that agent was `#hostDefinition`, which refuses `host-not-declared` (:864) unless the definition's
|
||||
`credentials` names `host`. Row 213's `checkCreative` (business/src/role.mjs:143) limits a creative
|
||||
role's tools, authority and messaging, not its `credentials`, so a creative role file naming `host`
|
||||
passed both the raise and the step 1 recheck.
|
||||
|
||||
As built:
|
||||
|
||||
- Writer. `#hostDefinition` refuses `host-creative-role` (:862) before the `host-not-declared` check.
|
||||
The raise binding (:1266) and the step 1 recheck (:879) both call it, so a grant raised before its
|
||||
agent's instance moved to a creative definition ends at step 1, spent, with no remote step.
|
||||
- Spend. `#hostAccess` refuses `host-creative-role` (:714) when the agent's current definition is
|
||||
creative, before it reads the record. Both callers go through it: host.run (:2543) and the host.sudo
|
||||
raise (:2778). The record stays, because revoke still owns the cleanup (sage-1908). This goes past
|
||||
the brief's writer line in the same direction; Sage tells Mos.
|
||||
|
||||
Tests:
|
||||
|
||||
- grant-host.test.mjs:115. A `muse` role on a `research` definition that declares host. The raise
|
||||
refuses `host-creative-role` and adds no decision.
|
||||
- grant-executor.test.mjs:947. The restart moves `ops` to a creative definition that declares host,
|
||||
then a fixture grant runs. It ends `host-creative-role` at step 1, the decision is spent, and there is
|
||||
no remote request and no record. The fixture comes after the restart, because the restart's reconcile
|
||||
ends any resolved, unspent host grant as interrupted.
|
||||
- grant-executor.test.mjs:958. An applied grant, then a restart with `ops` creative, then host.run
|
||||
refuses `host-creative-role`. No remote request is added, sudo.log has no `systemctl`, and the record
|
||||
is still there.
|
||||
|
||||
The broker clones `businesses` when it's built, so a definition change needs a new Broker. The test's
|
||||
`restart()` gains a `business` parameter, default unchanged, for that.
|
||||
|
||||
Mutants: B38 removes the `#hostDefinition` line, run against grant-host and grant-executor. B39 removes
|
||||
the `#hostAccess` line, run against grant-executor.
|
||||
|
||||
## Run 2 (sage-1928)
|
||||
|
||||
Run 2 on 534f2254a, 10:12:52-10:14:51Z, was green. grant-host.test.mjs 9 of 9, grant-executor.test.mjs
|
||||
37 of 37 and grant-enroll.test.mjs 24 of 24, each run whole. grant-enroll is unchanged, but it drives the
|
||||
broker, and broker.mjs changed. Then the five mutants, all killed, with the restore matching HEAD:
|
||||
|
||||
| Id | Killed by |
|
||||
|---|---|
|
||||
| B16 | G23 (X5), the revoke as verify returns |
|
||||
| B23 | A2, A4 |
|
||||
| B27 | G8, the store fault in the commit transaction |
|
||||
| B38 | both mos-1163 tests |
|
||||
| B39 | the sage-1908 test |
|
||||
|
||||
## Mutant results, B1-B39
|
||||
|
||||
| Run | Head | Killed | Survived |
|
||||
|---|---|---|---|
|
||||
| B1-B13, 09:22Z | 5df4b52c5 | 12 | B2 (file list) |
|
||||
| B2 alone, B14-B37, 09:28-09:37Z | 5df4b52c5 | 21 | B16, B17, B23, B27 |
|
||||
| B16 B23 B27 B38 B39, 10:13Z | 534f2254a | 5 | none |
|
||||
|
||||
38 of 39 are killed. B17 is equivalent (sage-1904), for the macrotask reason in the B1-B37 section.
|
||||
B1-B37 ran on 5df4b52c5, before the move onto row 213's landing, and weren't rerun on 534f2254a.
|
||||
`--check` matches all 39 anchors once on 534f2254a.
|
||||
|
||||
## Carry list
|
||||
|
||||
- N10 (duckworth-0084, row 234 r1): `cli/src/hosts.mjs:30-31` reads `shape[verb]` off a plain object,
|
||||
so `mosaic hosts constructor` passes usage and the bus refuses it with exit 2 instead of 4. Row b adds
|
||||
`hosts revoke` and `hosts sweep` to the same table (design r6 :562, :680), so the fix goes in with
|
||||
them: `Object.hasOwn(shape, verb)` and a usage test for an `Object.prototype` name (sage-1726).
|
||||
- Untimed tables (sage-1745, row 234 r3): stack-release T46 lists hosts and host_grants as untimed
|
||||
because every write to them shares one store transaction with a `grant.*` events row. Row a writes only
|
||||
hosts. Every host_grants INSERT, UPDATE and DELETE row b adds must do the same, and BUILD.md lists each
|
||||
with its event line. A write without one is a stop to raise with Sage.
|
||||
- N12 (duckworth-0086, row 234 r3, doc-only): row 234's round 3 list of other schema hits leaves out
|
||||
`packages/cli/tests/stack-release-fixture.mjs:214` and `scripts/test-land-work.sh:254`. Both look up
|
||||
one table by name and need no change. If row b changes the schema, its grep list names them.
|
||||
|
||||
## What row a already gives row b (b0320d395)
|
||||
|
||||
- `#approvedGrant`, `#consumeGrant(business, decision, method)` and `GRANT_EVIDENCE` (broker.mjs:667-690).
|
||||
Row b calls `#consumeGrant` with `'host'`.
|
||||
- `#openGrantDecisions(business, method)` and `reconcileGrants()` (broker.mjs:823-860), which today handle
|
||||
`host-enroll` only. Row b adds the `host` method, the `grant.step` undo table (r6 X3) and the
|
||||
revoke-marker reconcile (r6 section 6, N4).
|
||||
- The resolve hook at broker.mjs:2444-2445 starts `#runEnrollment` for `host-enroll`. Row b adds the same
|
||||
start for `host`, under the same human, via cli and choice checks.
|
||||
- `host.run` already reads the store grant record and checks the Approve record (broker.mjs:691-703).
|
||||
- The raise check `grant-chief-only` (broker.mjs:870, :896). `host-grant.mjs:55` refuses every method but
|
||||
`host-enroll`, and row b opens it to `host`.
|
||||
- `Credentials` has `#inflight` with the `host-busy` claim inside `runHost` (credentials.mjs:118, :355).
|
||||
It has no `claim`, `abortClaim`, `register` or `unregister`. Row b adds those (r6 X4, X5).
|
||||
|
||||
## Plan, in build order
|
||||
|
||||
1. Raise binding (r6 R1-R4): the `grant` argument for `method: 'host'`, `host-not-declared`,
|
||||
`host-not-ready`, the 210 D6 argv rules, `rotateBy`, the rendered block and the 4096 cap, and the raise
|
||||
body hashes. Tests G1-G5, G19.
|
||||
2. `Credentials.claim`, `release`, `abortClaim`, `register`, `unregister`, with the shared reference check.
|
||||
3. The executor in `bus/src/host-grants.mjs`, steps 1-6 (r6 X2), with failure undo (X3) and the abort rule
|
||||
(X5, C1). Tests G7-G10, G15, G23, G24.
|
||||
4. Restart reconcile for `grant.step`, unspent `host` decisions, and the start sweep of `host.<id>` key
|
||||
files with no record (r6 X3, N1). Tests G11, G27, G28.
|
||||
5. `hosts.revoke` and `hosts.sweep` verbs, plus the CLI `revoke` and `sweep` with N10. Tests G12, G13,
|
||||
G26, G27.
|
||||
6. G22 end to end on one bus. Then the mutant list from r6 section 10 for row b, numbered B1 onward in
|
||||
this file, with the upsert mutant and the abort-after-commit mutant included.
|
||||
|
||||
There is no schema step. If one turns out to be needed, I stop and ask Sage to re-brief first.
|
||||
|
||||
## Step 1 notes (raise binding, read on b0320d395)
|
||||
|
||||
Where the code goes:
|
||||
|
||||
- `#raiseGrant` (broker.mjs:866-885) runs the checks and keyscan for `host-enroll` today, and every raise
|
||||
with `grant` reaches it (:1738-1739). Row b branches on `a.grant?.method`. `host` takes no keyscan, so
|
||||
its checks can all run inside the one raise transaction, as R4 asks.
|
||||
- `enrollCheck` (host-grant.mjs:50-69) refuses every method but `host-enroll` (:55). Row b adds a sibling
|
||||
`hostGrantCheck` for the R4 shape `{method, agent, host, commands, rotateBy}` and keeps `enrollCheck`
|
||||
as it is, so the 234 tests don't move.
|
||||
- The raise body is at broker.mjs:2270, :2345-2353 and :2391. `enroll` becomes a generic `grant`
|
||||
binding. The question block for `host` follows the `host.sudo` block (:2337-2341): agent, host id,
|
||||
address, user, sudo, each prefix JSON-quoted, rotate date, and new key or changed commands. The
|
||||
4096 cap comes from `string(question)` (:2354).
|
||||
- The argv rules come from `checkCommands` (business/src/hosts.mjs:67-87): no wildcard, no empty prefix,
|
||||
no glob characters, a bare name or an absolute path, and `refusedCommand` for shells and runners. It
|
||||
throws `refuse(...)` with a message, so the bus maps any throw to `invalid-request`, as `enrollCheck`
|
||||
maps `checkAddress` (host-grant.mjs:57-61).
|
||||
- `host-not-declared`: the agent's definition must list `{"service": "host"}`. business.mjs:126-129 reads
|
||||
`definition.credentials` and filters out `HOST_SERVICE` (vocabulary.mjs:113). The bus needs the
|
||||
definition's service list. Read how the running business exposes the definition before choosing.
|
||||
- Both lookups are answered. `loadBusiness` returns `definitions`, each role file's validated document
|
||||
(business/src/business.mjs:114-124, :159, returned at :267). `busBusiness` clones the whole business
|
||||
(bus/src/business.mjs:7), so the broker has `b.definitions`. `#definitions` already reads it
|
||||
(broker.mjs:1027-1029), and the comment there says the broker doesn't read `roles/` (sage-0590).
|
||||
- `host-not-declared` checks `b.definitions[b.roles[agent].definition].credentials` for
|
||||
`{service: 'host'}`. role.mjs:118 normalizes a host entry to exactly that.
|
||||
- Deviation from R4: `definitionSha256` is sha256 over `canonicalJson` of that validated definition,
|
||||
not over the file's bytes. The broker doesn't read `roles/`. A whitespace-only edit to the file no
|
||||
longer voids an approval, but any change to a field the loader keeps still does. The bus sees a
|
||||
definition change only after a business reload or restart, the same as every other role field.
|
||||
This goes in the round 1 note.
|
||||
|
||||
Assumption, accepted for round 1 by Sage (sage-1782), for Duckworth to check in review: design R4 says `rotateBy` is "a date inside 210's rotation window", but 210
|
||||
defines no window in code. It checks only that the value is a real calendar day (bus
|
||||
credentials.mjs:16-20, issue at :245). The business load calls a past `rotateBy` a problem
|
||||
(business/src/credentials.mjs:114). So the raise takes a real day strictly after today, UTC, and refuses
|
||||
`invalid-request` otherwise. There is no upper bound, because no requirement states one and 210
|
||||
checks none.
|
||||
|
||||
## Step 1 as built (not run yet; no slot)
|
||||
|
||||
- `hostGrantCheck(grant, today)` in host-grant.mjs takes exactly `method`, `agent`, `host`, `commands` and
|
||||
`rotateBy`. `agent` must match ID_PATTERN and `host` HOST_ID. `commands` goes through `checkCommands`,
|
||||
and any throw becomes `invalid-request`. `rotateBy` must pass 210's `day` (now exported from bus
|
||||
credentials.mjs) and be later than the bus clock's UTC day.
|
||||
- `#raiseGrant` branches on `a.grant?.method === 'host'` after the chief check and before `enrollCheck`.
|
||||
So a host-enroll shape with `method: 'host'` still refuses `invalid-request`, and the 234 G1 list holds.
|
||||
- `#hostGrantBinding` runs inside the raise transaction. It refuses `unknown-role` for an agent that
|
||||
isn't a role instance (the code bindLaunch uses, broker.mjs:522). It refuses `host-not-declared`, and
|
||||
`host-unknown` for a host with no record, as `#hostAccess` does. A record in `pending-install` refuses
|
||||
`host-not-ready`. R4 names only `host-not-ready`. I picked `host-unknown` for a missing record so
|
||||
"not enrolled" and "enrolled but not checked" stay apart.
|
||||
- `commandsSha256` is sha256 over `JSON.stringify(commands)`. For arrays of strings this equals
|
||||
canonicalJson, and step 3's executor recomputes it the same way.
|
||||
- `#dispatch` takes `binding = {body, block}` in place of `enroll`. The enrollment block moved into
|
||||
`enrollBinding` at module level with its text unchanged, so the 234 G4 test reads the same question.
|
||||
- No new import in broker.mjs, so RUNNER_FILES doesn't change. host-grant.mjs now imports
|
||||
business/src/util.mjs for canonicalJson. Nothing in the sandbox loads host-grant.mjs. Before in-review
|
||||
I still run seat sandbox.test.mjs and bus project-runtime.test.mjs whole.
|
||||
- The new-key line reads host_grants (a SELECT). Step 1 writes no host_grants row.
|
||||
- Tests: bus/tests/grant-host.test.mjs, G1 (host), G2, G3, rotateBy, G19, R4/G5 body hashes, G4 block.
|
||||
The G5 tester-thread case: a thread run is always THREAD_DEFINITION, never chief-of-staff, so
|
||||
`grant-chief-only` refuses it first. The test covers the tester's own cap (`tester-refused`).
|
||||
|
||||
## Step 2 as built (not run yet)
|
||||
|
||||
- The constructor's per-reference checks moved into `#checkRef`, in the same order, and `register` calls
|
||||
it (r6 X4). `register` reads no file. A key file that a failed revoke unlink left at the path stays
|
||||
unused until the start sweep (step 4) removes it. `register` refuses `credential-reference` while the
|
||||
entry holds a live token. An entry with no token, revoked or never issued, is replaced, so a new grant
|
||||
after a revoke registers again.
|
||||
- `unregister` drops an entry with no token and clears its revoked mark. A live token refuses.
|
||||
- `claim` refuses `host-busy` when the slot is held, by a claim or a `host.run`, and returns the signal.
|
||||
It needs no reference, because the executor claims at step 1 and registers at step 5.
|
||||
`release(instance, service, signal)` frees the slot only for its own signal. I added the signal
|
||||
argument so a late release can't free a later claim.
|
||||
- `abortClaim` aborts whatever holds the slot and returns `{pending}`. An aborted claim keeps the slot
|
||||
until the executor releases it in its `finally` (X2).
|
||||
- Tests: bus/tests/credential-claim.test.mjs. The D5 `use(` allowlist is unchanged.
|
||||
|
||||
## Step 3 as built (not run yet)
|
||||
|
||||
Where the code goes. The remote steps are in host-grant.mjs (`grantKey`, `install`, `verify`, `remove`)
|
||||
and the orchestration is `#runHostGrant` in broker.mjs, next to `#runEnrollment`. BUILD.md's plan said a
|
||||
new `bus/src/host-grants.mjs`. I kept row 234's split instead: the broker holds every store write and
|
||||
host-grant.mjs holds the network and key files. A new broker import would also change what the sandbox
|
||||
must mount (RUNNER_FILES), and this way broker.mjs gains none.
|
||||
|
||||
The broker gets `hostKeys`, made by `grantKeys({credentials, storeRoot})` in host-grant.mjs. It wraps
|
||||
only `claim`, `release`, `abortClaim`, `register`, `unregister`, `issue` and `revoke`, keyed by
|
||||
(business, agent, hostId). The broker still never gets `use()`. runtime.mjs builds it and passes it in.
|
||||
`hostReference` gives the store reference, and the runtime's start load uses the same function.
|
||||
|
||||
Every host_grants write in step 3, with its event (sage-1745):
|
||||
|
||||
| Write | Where | Event in the same transaction |
|
||||
|---|---|---|
|
||||
| INSERT, a new key | the `commit` callback that `issue` calls | `grant.applied` |
|
||||
| UPDATE commands and decision, a commands change | step 5's transaction | `grant.applied` |
|
||||
|
||||
No other step 3 code writes host_grants.
|
||||
|
||||
Deviations from r6, for the round 1 note:
|
||||
|
||||
- `grant.applied` commits with the record, inside `commit`, not after `issue` at step 6. The untimed-table
|
||||
rule needs an event in the record's transaction. `grant.step commit` can't be that event, because X2
|
||||
writes it before the key file. So X3's "5 after commit" row has nothing to undo in the executor: once the
|
||||
record commits, `grant.applied` has too. What's left in `issue` after `commit` is the memory fill, which
|
||||
writes no file and no store row. The executor sets `committed` after the transaction returns and from
|
||||
then ignores an abort (C1). The restart reconcile (step 4) keeps the commit row, because a crash can fall
|
||||
between the key file write and `commit`.
|
||||
- Event bodies name the decision as `grantDecision`, as row 234's enrollment events do. The consume record
|
||||
is the only grant body with `decision`, so `#spent` counts it and nothing else.
|
||||
- For a commands change, `grant.applied` has `fingerprint: null`. The executor holds no key on that path.
|
||||
The record's `rotate_by` stays. X2 names only commands and the decision id.
|
||||
- The keygen dir goes as soon as `grantKey` has read the key back into memory, not in step 5's `finally`.
|
||||
- Step 4 runs `true` as the remote command as is, not through the sudo argv, since a user with no sudo
|
||||
must pass it too.
|
||||
- `hostGrant()` takes `installerUser`, `mosaic-installer` by default. Only the tests change it, because
|
||||
the scratch sshd logs in only as the test's own user.
|
||||
- A commands-change UPDATE that changes no row ends `host-changed`. Only a revoke deletes a live record,
|
||||
and a revoke aborts the claim first, so this is a guard.
|
||||
|
||||
Step 1 codes, after the spend and in its transaction: `host-not-configured`, `unknown-role`,
|
||||
`host-not-declared`, `decision-mismatch` (definition, registry or commands hash), `host-unknown`,
|
||||
`host-not-ready`, `revoked` (a `grant.revoked` of the agent and host after the raise). The definition
|
||||
check moved into `#hostDefinition`, which the raise binding uses too.
|
||||
|
||||
Tests: bus/tests/grant-executor.test.mjs, a scratch sshd on 127.0.0.1 with stub `list`, `add` and
|
||||
`remove` that edit a second authorized-keys file. G7 (new key, and a commands change), G8 (install, verify,
|
||||
a failed remove, step 5 before commit, a failed step 1 check), G9, G10, G15, G23 (a revoke during install
|
||||
through `abortClaim`, a `grant.revoked` before start, and a held slot), G24's tag part. G23's held slot
|
||||
uses `claim` in place of a running `host.run`. Both hold the same `#inflight` entry. The `revoke` verb
|
||||
comes in step 5, so G23 here writes `grant.revoked` through the fixture writer and aborts through
|
||||
`abortClaim`. Step 5 adds the verb's own path.
|
||||
|
||||
## Step 4 as built (not run yet)
|
||||
|
||||
The restart reconcile has three parts, in start order:
|
||||
|
||||
1. `sweepHostKeys` (host-grant.mjs), in runtime.mjs right before `Credentials` loads. In each loaded
|
||||
business's agent store it unlinks every `host.<id>` file that has no `host_grants` record, and logs
|
||||
one `hosts: {"event":"host-key-swept",...}` line per file to stderr. A store dir that isn't private
|
||||
refuses `credential-file`, and so does the start. A name that isn't `host.` plus a valid host id
|
||||
stays, so ssh-keygen's `.pub` stays too. A business the bus didn't load is left alone.
|
||||
2. `reconcileGrants`, where row 234 already calls it, after `Credentials` loads and before the socket
|
||||
opens. For a `host` grant it ends a spent decision that has no `grant.applied` and no `grant.failed`.
|
||||
It also spends and ends an unspent one the human resolved yes. Each ends `grant.failed interrupted`,
|
||||
with the tag and a notice. When the last step is `install`, or `commit` with `previous: null`, a line
|
||||
may be left. For those, it claims the (agent, `host.<id>`) slot and queues the decision for part 3.
|
||||
3. `reconcileHosts`, which runtime.mjs calls once `serve` returns, with no await, so a start doesn't
|
||||
wait on the network. Each queued decision runs `remove <user> <tag>` through the installer, as the
|
||||
step's `user`. Then it ends `grant.failed interrupted`, with `hostLineLeft` if the remove failed or
|
||||
there's no host record, and releases the claim. The claim makes a new grant for the same agent and
|
||||
host refuse `host-busy` while the remove runs. Otherwise its new line, with the same tag, could be
|
||||
removed.
|
||||
|
||||
Deviations from r6's restart table, for the round 1 note:
|
||||
|
||||
- No record write on restart. Step 3 commits the record with `grant.applied` in one transaction. So a
|
||||
decision with no `grant.applied` has no record. For a new key, the record undo has nothing to delete.
|
||||
For a commands change, the "back to `previous`" undo has nothing to put back. What's left of the
|
||||
`commit, new key` row is the key file, written before `commit`, and the start sweep unlinks it.
|
||||
The `commit, commands change` row ends interrupted with nothing undone. G28's "a revoke before
|
||||
`grant.applied`" can't happen, because they commit together. Step 5 tests G28 as a change, then a
|
||||
revoke, then a restart.
|
||||
- The local undo of row 234's enrollments stays after `Credentials` loads, where row 234 put it. That
|
||||
undo touches only the installer key and the host record, and `Credentials` reads neither.
|
||||
- The host grant part writes no `host_grants` row, so the untimed-table rule (sage-1745) adds nothing
|
||||
here.
|
||||
|
||||
Fixture change: `hostGrantRecord` (tests/host-fixtures.mjs) writes `grant.applied` after a `grant.host`
|
||||
spend, as the executor always does. Without it, a start over a fixture record ends that record's
|
||||
decision `interrupted`. Row 234's M3 start test (host-run.test.mjs:602) does that start. No test counts
|
||||
the fixture's grant events (checked by grep across the bus tests).
|
||||
|
||||
Tests in grant-executor.test.mjs, through a `restart` helper that follows runtime.mjs's order on the
|
||||
same store:
|
||||
|
||||
- G11 after `install`: the line goes, the slot is held between the two parts, and the grant ends
|
||||
interrupted. A second start adds nothing, and `runHostGrant` refuses `decision-consumed`.
|
||||
- G11 after a new key's `commit` with the key file written: no key file, record, reference or line.
|
||||
- G11 after a change's `commit`: the record, key and line are unchanged, and the host gets no request.
|
||||
- G11 (B2) before step 1, spent and unspent: each ends interrupted with a notice, and the unspent one
|
||||
is spent exactly once.
|
||||
- A failed remote undo, and a missing host record: each ends with `hostLineLeft` and the sweep hint.
|
||||
- A finished grant: the reconcile adds nothing.
|
||||
- N1 (G27's sweep part): only files with no record in loaded businesses go, and a dir that isn't private
|
||||
refuses.
|
||||
|
||||
The revoke-marker part of the reconcile (section 6, N4) comes with the revoke verb in step 5.
|
||||
|
||||
## Step 5 as built (not run yet)
|
||||
|
||||
Two new bus verbs, `hosts.revoke` and `hosts.sweep`, join `HOSTS_VERBS`. The CLI gets
|
||||
`mosaic hosts revoke AGENT HOST --reason R` and `mosaic hosts sweep HOST`.
|
||||
|
||||
`hosts.revoke {agent, host, reason}` follows r6 section 6:
|
||||
|
||||
1. One transaction deletes the `host_grants` record and writes `grant.revoked` with a new `revoke` id
|
||||
(a UUID), the computed tag, `hostLine: "pending"`, `granted`, `reason` and `by`. With no record it
|
||||
still writes the marker, with `granted: false`, so a stale line can be named (N4). A host with no
|
||||
record refuses `host-unknown` and writes nothing.
|
||||
2. With no await between, `Credentials.revoke` drops the key from memory, aborts a `host.run` in flight
|
||||
and unlinks the file. With no reference (`credential-unknown`), an executor holds only a claim, so
|
||||
`abortClaim` stops it. Any other throw, such as an unlink that fails, comes back as `keyFile: "left"`.
|
||||
The record is already gone, so the next start's sweep unlinks that file.
|
||||
3. `remove <user> <tag>` through the installer, as the host record's user (the executor's user too).
|
||||
Then `grant.revoke.closed` with the same `revoke` id and `hostLine: "removed"`, or `hostLineLeft`.
|
||||
|
||||
Callers (G26): the human, and the assistant of the session's business, checked with `#threaded`. The
|
||||
tester can call it when `bus.tester` is exactly `true`, read again at the call. Any other agent gets
|
||||
`revoke-refused`, a code new in this row. A tester session can't hold a business whose `bus.tester`
|
||||
isn't `true`, since `bindTester` refuses `tester-off`, and the broker's businesses don't change while
|
||||
it runs. So `tester-dev-only` can't be reached from a test. The check stays for N8, in case a later
|
||||
row reloads businesses.
|
||||
|
||||
`hosts.sweep {host}` is the human's or the tester's; an agent gets `human-required`. It runs
|
||||
`list <user>`, then removes each listed tag that this business recorded for the host in a `grant.step`
|
||||
or `grant.revoked` and that no live grant holds. Another business's line and an unrecorded `mosaic-`
|
||||
line stay (G24's sweep part). It writes `grant.host.swept {host, removed, left, by}`, where `left` is
|
||||
each earlier left tag still listed and not removed. `#hostLinesLeft` now starts from the last
|
||||
`grant.host.swept` and adds only later `hostLineLeft` bodies (F4).
|
||||
|
||||
The restart reconcile adds the marker part: `reconcileGrants` collects each `grant.revoked` with
|
||||
`hostLine: "pending"`, a text `revoke` id and no `grant.revoke.closed` of that id. `reconcileHosts`
|
||||
runs step 3 for each. A `grant.revoked` with no `revoke` id, as G23's fixtures write, isn't reopened.
|
||||
`hosts.status` adds `revoking`, the tags of the open markers on the host.
|
||||
|
||||
Other changes:
|
||||
|
||||
- N10: the CLI checks the verb with `Object.hasOwn`, so `constructor`, `toString` or `__proto__` is a
|
||||
usage error.
|
||||
- `--reason` is required with `revoke` and refused with any other verb.
|
||||
- The reason goes through the broker's `string(x, 500)`, as `credentials.revoke`'s does, so a reason of
|
||||
only spaces is accepted there too.
|
||||
- Correction to step 4: the first G11 test expected `list` then `remove`. `reconcileHosts` sends only
|
||||
`remove`, so the test now expects that.
|
||||
|
||||
Residuals for the round 1 note:
|
||||
|
||||
- A new grant whose line goes in while a revoke's step 3 or a sweep runs can lose that line, if it has
|
||||
the revoked tag. Revoke holds no claim during step 3. If the remove lands after the new grant's
|
||||
verify, the grant is recorded but its line is gone, so its logins fail. I didn't add a claim, because
|
||||
a revoke must not wait on a slot.
|
||||
- The enrollment's installer tag is never in the sweep's set, so a `hostLineLeft` naming it stays
|
||||
until the human removes it by hand.
|
||||
|
||||
Tests in grant-executor.test.mjs:
|
||||
|
||||
- G12: the human's revoke of a live grant, and the bodies of both markers. A restart adds nothing.
|
||||
- G12 (N4): a revoke with no record removes the computed tag's line. Bad args refuse
|
||||
`invalid-request` and an unknown host `host-unknown`.
|
||||
- G13: a revoke during `install` aborts the executor, which ends `revoked`. A raise before a revoke
|
||||
and started after it ends `revoked` at step 1.
|
||||
- G26: who may revoke and who may sweep.
|
||||
- G27: a restart after step 1 (the sweep takes the key file, then step 3 runs), after step 2 with the
|
||||
host down (`hostLineLeft`, then a sweep clears it), and a file a revoke couldn't unlink.
|
||||
- G28: a commands change, then a revoke, then a restart.
|
||||
- G24 (B4): the sweep's tag set, a failing `list`, and the left list it records.
|
||||
|
||||
The CLI tests in packages/cli/tests/hosts.test.mjs cover revoke and sweep, the N10 usage cases and the
|
||||
`revoking` field in a status line.
|
||||
|
||||
## Step 6 as built (not run yet)
|
||||
|
||||
New tests in grant-executor.test.mjs:
|
||||
|
||||
- G22 (B1), one bus with no restart. The chief enrolls web1, the human approves, and `hosts.check`
|
||||
marks it ready. A grant of `echo` follows, then a `host.run` of `echo hi` that prints `hi`. A
|
||||
`sleep` raise refuses `command-not-granted`, and after a commands change to `echo` and `sleep` the
|
||||
next `host.run` of `sleep 30` starts. A revoke kills that run with `credential-unavailable`. The next
|
||||
call refuses `host-not-granted`.
|
||||
- G12: the local steps finish before the host step. With the host's `remove` held, the record and key
|
||||
file are already gone and `hosts.status` shows the tag under `revoking`.
|
||||
- G13: a revoke whose host `remove` fails returns `hostLineLeft`, drops the record and key, and closes
|
||||
the marker, so a restart adds nothing.
|
||||
- G23 (X5): a revoke right after the executor's spend reaches its claim and the grant ends `revoked`.
|
||||
- G23 (X5): a revoke that lands as `verify` returns ends `revoked` with no record, key file or line.
|
||||
Only the abort check after `verify` catches it.
|
||||
|
||||
The fixture gains a `sudo` stub (logs to sudo.log, then runs the command as the test's user), a
|
||||
`hold-remove` flag, `opts.enroll` (no host record and no installer key) and `opts.wrap` (changes the
|
||||
host API the broker gets). `hostGrant` gets a fake `scan` that returns the scratch sshd's host key.
|
||||
|
||||
G22 deviations, for the round 1 note:
|
||||
|
||||
- `checkAddress` (business/src/hosts.mjs:62) refuses this machine's address, so the test enrolls
|
||||
192.0.2.10 with the fake keyscan. It then points the record at the scratch sshd with an SQL update
|
||||
and adds the enrolled installer key to the sshd's authorized keys. Enrollment, check, grant, run and
|
||||
revoke all go through the real code after that.
|
||||
- r6 B1 says the next `host.run` after a revoke refuses `credential-unavailable`. Here it refuses
|
||||
`host-not-granted`, because step 1 deletes the record and `requestHost` checks the record
|
||||
(`#hostAccess`) before it reaches the key. The call in flight does refuse `credential-unavailable`.
|
||||
|
||||
### Mutants
|
||||
|
||||
`/mnt/storage/scratch/gizmo-235-out/mutants.py` holds each mutant as anchored edits. `--check` applies
|
||||
them in memory and runs no test. All 39 anchors match once. Each mutant runs its named test files.
|
||||
|
||||
From r6 section 10, as they apply to row b:
|
||||
|
||||
| Id | Mutant | Test file |
|
||||
|---|---|---|
|
||||
| B1 | drop the chief check on a host grant raise | grant-host (G1) |
|
||||
| B2 | start the executor on a `credential.mint` with no `grant` | grant-enroll (A2), grant-executor |
|
||||
| B3 | skip the `ready` check at the raise | grant-host (G19) |
|
||||
| B4 | skip the `ready` check at step 1 | grant-executor |
|
||||
| B5 | skip the definition hash compare | grant-executor (G9) |
|
||||
| B6 | drop the definition check (`host-not-declared`) | grant-host (G2), grant-executor (G9) |
|
||||
| B7 | skip the commands hash compare | grant-executor (G9) |
|
||||
| B8 | skip the host record hash compare | grant-executor (G9) |
|
||||
| B9 | write the grant record before install | grant-executor (G8) |
|
||||
| B10 | skip `remove` on a verify failure | grant-executor (G8) |
|
||||
| B11 | skip the start sweep of key files with no record | grant-executor (N1, G27) |
|
||||
| B12 | skip the reconcile's remote `remove` | grant-executor (G11) |
|
||||
| B13 | skip the `grant.step` before install | grant-executor (G11) |
|
||||
| B14 | revoke leaves the grant record | grant-executor (G12) |
|
||||
| B15 | skip `register` before `issue` | grant-executor (G7) |
|
||||
| B16 | drop the abort check after `verify`, the last one before `issue` | grant-executor (G23 X5) |
|
||||
| B17 | add an `await` between the abort check and `issue` | grant-executor |
|
||||
| B18 | revoke ignores a claim with no reference | grant-executor (G13, G23 X5) |
|
||||
| B19 | build the tag without the business | grant-executor (G24) |
|
||||
| B20 | `sweep` removes any `mosaic-` line | grant-executor (G24 B4) |
|
||||
| B21 | run revoke's host step before the local steps | grant-executor (G12) |
|
||||
| B22 | start the executor on an arbiter resolve | grant-executor (G10) |
|
||||
| B23 | start it on `no` | grant-enroll (A2, A4), grant-executor |
|
||||
| B24 | write the private key into the event body | grant-executor (G15) |
|
||||
| B25 | put an `await` between the spend and the claim | grant-executor (G23 X5) |
|
||||
| B26 | skip the start check for a resolved, unspent grant decision | grant-executor (G11 B2) |
|
||||
| B27 | make `commit` async | grant-executor (G8) |
|
||||
| B28 | route `hosts.revoke` to `Credentials.revoke` alone, as `credential-verbs.mjs` would | grant-executor (G12, G27) |
|
||||
| B29 | skip the revoke marker reconcile | grant-executor (G27) |
|
||||
|
||||
Row 235's own:
|
||||
|
||||
| Id | Mutant | Test file |
|
||||
|---|---|---|
|
||||
| B30 | skip the `grant.revoked` check at step 1 | grant-executor (G13, G23) |
|
||||
| B31 | the sweep set keeps a live grant's tag | grant-executor (G24 B4) |
|
||||
| B32 | reopen a `grant.revoked` with no revoke id | grant-executor (G27) |
|
||||
| B33 | step 3 reports `removed` when the host remove fails | grant-executor (G13, G27) |
|
||||
| B34 | any agent revokes, not just the assistant | grant-executor (G26) |
|
||||
| B35 | `hostLineLeft` ignores the last sweep | grant-executor (G24 B4, G27) |
|
||||
| B36 | a stale `release` frees a later claim | credential-claim (X5), grant-executor |
|
||||
| B37 | `register` replaces an entry with a live token | credential-claim (X4) |
|
||||
| B38 | drop the creative check in `#hostDefinition` | grant-host (mos-1163), grant-executor (mos-1163) |
|
||||
| B39 | drop the creative check in `#hostAccess` | grant-executor (sage-1908) |
|
||||
|
||||
B17 may survive. `await null` yields only to microtasks, and every revoke starts in a macrotask of its
|
||||
own (a socket request, or a test's call), so none can land in that gap. If it survives I'll report it
|
||||
as equivalent under this build, not add a test that fakes a microtask revoke.
|
||||
|
||||
Section 10 items not run as row b mutants, and why:
|
||||
|
||||
- The upsert mutant ("let the undo to `previous` insert a record") and "run past a newer
|
||||
`grant.revoked`". Row 235 has no undo to `previous`. A commands change commits the UPDATE and
|
||||
`grant.applied` in one transaction (sage-1745), so a restart after it leaves the record as it is
|
||||
(G11). The UPDATE can't meet a missing record either: step 1 reads the live record, and nothing
|
||||
awaits between there and the UPDATE. An upsert there would be equivalent.
|
||||
- "let the executor act on an abort after step 5 commits". The record and `grant.applied` commit
|
||||
together, and no abort check follows the commit, so no code path reaches the `if (committed)` guard
|
||||
with an abort. Removing the guard is equivalent. C1's case is covered by G28 with a later revoke.
|
||||
- "treat a missing `bus.tester` as dev". The tester check can't be reached from a test (step 5 notes).
|
||||
- Row a's: the keyscan compare, the writer items, the "not yet enrolled" check, the `hosts` primary key
|
||||
and `host.run` reading the business file. Row 234 ran those as A5, A6, A8, A9, A11 and A13-A19.
|
||||
@@ -1,98 +0,0 @@
|
||||
# Row 249 build notes (Gizmoduck)
|
||||
|
||||
Gitea #1751, Vikunja P19 445 under #436. Brief: docs/plans/2026-10-20_host-credentials.md, section
|
||||
"Machine grant row b follow-up: the undo can't prove the remote add has ended" (6de1ebc62). Reviewer
|
||||
Duckworth, gate owner Sage. Branch gizmoduck/row249-on-8a04, on row 248's landing 8a04f4aa4 (sage-2012).
|
||||
No file under packages/bus/src changed between 6d5545d44 and 8a04f4aa4, so the brief's src line numbers
|
||||
hold on the base. Line numbers below are on this branch.
|
||||
|
||||
## Correction to the brief
|
||||
|
||||
The brief, copying gizmoduck-0139, puts the sweep's host-unknown refusal at broker.mjs:1350. On
|
||||
6d5545d44 it is :1349. On this branch it is :1354.
|
||||
|
||||
## The change
|
||||
|
||||
`#runHostGrant` in packages/bus/src/broker.mjs:
|
||||
|
||||
- :943 adds `installed = false` next to `installing`.
|
||||
- :956 sets `installed = true` on the line after `await this.#hostGrant.install(...)`, before the
|
||||
`stop()` that follows it. An abort that lands after install returned still ends revoked, with no hint,
|
||||
because install returning means every request it sent has exited.
|
||||
- :1025, in the catch, inside `if (installing)` and after the remove: `if (!installed) extra =
|
||||
{ hostLineLeft: tag };`. The remove still runs first and its result doesn't matter. A remove that
|
||||
throws sets the same hint as before (:1021).
|
||||
|
||||
The rule keys on install not returning, not on `signal.aborted`. That covers an abort (host-run.mjs:136,
|
||||
SIGKILL to the local group), a timeout (host-run.mjs:131-134, SIGTERM, then SIGKILL after `graceMs`) and
|
||||
a dropped connection, which fails the request with no signal at all. `#hostGrantFailed` (:845) already
|
||||
adds "A line tagged X may be left on the host. Run: mosaic hosts sweep <host>" when `hostLineLeft` is
|
||||
set (:852), so the message needed no change.
|
||||
|
||||
False hints. install is one call that runs `list`, maybe `remove`, then `add` (host-grant.mjs:329-333).
|
||||
Any failure inside it now records the hint, including a `list` that exits 1 before any add was sent, and
|
||||
an abort that lands before the first ssh starts (the G23 X5 case at "a revoke right after the spend").
|
||||
The brief accepts this for an add that exits nonzero. The other cases are the same kind: the line isn't
|
||||
on the host, and a sweep that doesn't list the tag drops the hint (G24, "a left tag no longer on the
|
||||
host", broker.mjs:1358). Narrowing it would need install to report which request was in flight, which is
|
||||
a change to host-grant.mjs's API that the brief doesn't ask for.
|
||||
|
||||
## Row a's enroll
|
||||
|
||||
The race can't happen there, because enrollment sends nothing to the host. `#runEnrollment`
|
||||
(broker.mjs:772-842) writes the record, calls `this.#hostGrant.enroll(business, host)` (:800) and
|
||||
commits the fingerprint. `enroll` (host-grant.mjs:247-286) runs ssh-keygen locally and writes the
|
||||
private half to its file. It opens no ssh session. The installer line goes on the host by hand: the
|
||||
notice tells ops-01 to write it, then to run `mosaic hosts check`. The undo at :835 is `unenroll`, which
|
||||
deletes the local key file. There is no remote add to outlive a kill, and no remote undo to race with.
|
||||
|
||||
## Tests
|
||||
|
||||
packages/bus/tests/grant-executor.test.mjs:
|
||||
|
||||
- `lateAdd` (:451) is the shared tail. After the grant ends it asserts the code, `hostLineLeft` on
|
||||
grant.failed, the sweep line in the notice, and that the undo's remove was the last request. Then it
|
||||
writes `release`, so the held add writes its line and exits 0, and waits for the line in `granted`. It
|
||||
checks there's no record, `hosts.status` lists the tag in `hostLineLeft`, `hosts.remove` refuses
|
||||
host-line-left, `hosts.sweep` returns `removed: [tag]` and the line is gone, then `hosts.remove`
|
||||
succeeds.
|
||||
- :468, the abort case. `hold-add`, the human approves, the add holds, `abortClaim` aborts the
|
||||
executor's claim. Ends revoked.
|
||||
- :480, the timeout case. A 30 s wait is too long for the file, and making the timeout an option would
|
||||
add a src seam, so the test wraps `install` through `opts.wrap` instead. The wrapper passes install its
|
||||
own AbortController signal, waits for `holding`, aborts it, which SIGKILLs the local ssh group as the
|
||||
timer does, then throws as a timed-out request does. The executor's signal isn't aborted, so it ends
|
||||
host-failed. Nothing under src changes for it.
|
||||
- G8 (:283) changes. fail-list and fail-add now carry `hostLineLeft` and the sweep line. noop-add fails at
|
||||
verify, after install returned, so it keeps no hint and the old message.
|
||||
- The comment at :436 (:432 at 6d5545d44) now says `skip` makes the held add exit without writing and that a
|
||||
killed session's add can still write. G13 has no such comment.
|
||||
|
||||
Both new tests write `release`, so their held stub exits before `t.after` removes the dir. Row 248's dir
|
||||
check (:67, :72) is the backstop.
|
||||
|
||||
## Mutant
|
||||
|
||||
/mnt/storage/scratch/gizmo-235-out/mutants-249.py, on row 235's runner. C1 removes the :1025 line and
|
||||
runs grant-executor.test.mjs. Both new tests and G8 should fail under it. `--check` matches its anchor
|
||||
once.
|
||||
|
||||
## Runs
|
||||
|
||||
On Sage's grant sage-2015, at 8457af076 (7cee1a1ab plus this file), TMPDIR /mnt/storage/scratch/g249t:
|
||||
|
||||
| Run | Window | Result |
|
||||
|---|---|---|
|
||||
| grant-executor.test.mjs whole | 13:29:01-13:29:21Z | 39 of 39 pass |
|
||||
| C1 | 13:29:21-13:29:40Z | killed: both row 249 tests and G8 fail on `hostLineLeft` undefined |
|
||||
|
||||
After C1 the worktree was clean and broker.mjs matched HEAD. After the runs, no node, sshd or sh process had a
|
||||
cwd under the worktree or g249t, and no `fake/add` or `fake/remove` stub was running. The bus suite runs on
|
||||
Sage's gate.
|
||||
|
||||
## Rebase onto 8a04f4aa4 (sage-2012)
|
||||
|
||||
The first commit, 02bfd184b, sat on bc579f7a8 as gizmoduck/row249, which was never pushed. Row 248 landed
|
||||
as 8a04f4aa4, changing grant-executor.test.mjs at :54, :67 and :72. gizmoduck/row249-on-8a04 starts at
|
||||
8a04f4aa4 and cherry-picks 02bfd184b with no conflict. 248's lines stay as landed. The patch-id is the
|
||||
same on both (c33823941e61). Test line numbers moved down by one, and this file was updated for that.
|
||||
@@ -1,53 +0,0 @@
|
||||
# Row 212 build notes, C2-01 packages/library (#1713, Vikunja #416)
|
||||
|
||||
## Rounds
|
||||
|
||||
- Round 1: bdf18272a on gosalyn/c2-01, base 32b54ac3b. Webby approved it in Gitea comment 29704.
|
||||
- Round 2: d3c7bbf839d9c3bf50f154b18e7e4472fbd005ed on gosalyn/c2-01-r2, rebased onto 9b5362018 after row 232 drifted three paths (sage-1714). Webby approved it at merge scope, rev 2666 against comment 29921 (webby-0052).
|
||||
- Round 3: this commit on gosalyn/c2-01-r3, one commit on the row 234 landing c771a36f4 (sage-1736, sage-1779, mos-1160). Row 234 changed scripts/mosaic, so round 3 review covers that one file.
|
||||
|
||||
## Round 2 merge
|
||||
|
||||
range-diff 32b54ac3b..bdf18272a against 9b5362018..d3c7bbf83 shows one change, the context line where upstream added `credentials` to the scripts/mosaic case line. Patch-id moved from 8f80dfa3 to 175f45c6 for that reason only. All three drifted hunks keep both sides:
|
||||
|
||||
- scripts/stack-copy.sh COPY_PATHS reads `packages/queue/src packages/library/src`. Row 232's bundle, --create, --units and instance code is unchanged.
|
||||
- scripts/mosaic runs the `library survey` exec block before the case line, and the case line keeps `credentials`.
|
||||
- packages/cli/tests/stack-copy.test.mjs adds `packages/library/src/index.mjs` to the copy file list.
|
||||
|
||||
## Round 2 singles, evidence for the 19 paths other than scripts/mosaic
|
||||
|
||||
Run on d3c7bbf83 in Sage's slot (sage-1723) from 2026-10-21T05:00:09Z to 05:11:29Z. The HEAD and a clean tree were checked first. The counts are an awk sum over the 23 logs: 481 passed, 0 failed, 0 skipped, 0 cancelled, and every file exited rc=0.
|
||||
|
||||
| Package | File | Pass |
|
||||
|---|---|---|
|
||||
| library | links | 6 |
|
||||
| library | mapping | 7 |
|
||||
| library | reader | 11 |
|
||||
| library | render | 11 |
|
||||
| library | survey | 4 |
|
||||
| cli | app | 30 |
|
||||
| cli | case2-unit | 30 |
|
||||
| cli | credentials | 5 |
|
||||
| cli | host | 42 |
|
||||
| cli | instances | 5 |
|
||||
| cli | project | 4 |
|
||||
| cli | stack-copy | 16 |
|
||||
| cli | stack-instances | 23 |
|
||||
| cli | stack-release-external | 25 |
|
||||
| cli | stack-release | 192 |
|
||||
| cli | tester-reads | 6 |
|
||||
| cli | tester-threads | 5 |
|
||||
| cli | usage | 2 |
|
||||
| queue | dispatch | 4 |
|
||||
| runs | task-cli | 9 |
|
||||
| webui | pinned-copy | 10 |
|
||||
| webui | service | 31 |
|
||||
| webui | views | 3 |
|
||||
|
||||
The file list comes from the library tests plus every test that names scripts/mosaic or stack-copy.sh, by basename and by join() path. No 64-hex pin in the tests names a merged path.
|
||||
|
||||
## Round 3 scripts/mosaic
|
||||
|
||||
The only path both row 234 and this row change is scripts/mosaic. Row 234 added `hosts` to the cli case line and changed nothing else in the file. Round 3 takes the landed blob cce1fdee4 and adds this row's six-line `library survey` block before the case line by hand, so `hosts` stays. The mode stays 100755. The other 19 paths are byte-identical to d3c7bbf83. Against d3c7bbf83 the only difference is the `hosts` context line.
|
||||
|
||||
Round 3 runs only the tests that read scripts/mosaic, in Sage's slot. Their counts go to Sage in T3 and onto #1713, since this file can't hold them and stay one commit. Those tests are library survey, cli app, case2-unit, credentials, hosts, host, instances, project, stack-copy, stack-release, stack-release-external, stack-upgrade, tester-reads, tester-threads and usage, queue dispatch, runs task-cli, webui pinned-copy, service and views, plus scripts/test-queue.sh. hosts.test.mjs is new with row 234.
|
||||
@@ -1,158 +0,0 @@
|
||||
# Row 221 build notes, C2-20 Bean and Chet persona import (#1722, Vikunja #416)
|
||||
|
||||
Brief: docs/plans/2026-10-20_case2-rows.md "C2-20", blob 16847e94ae17 (sage-1834). Claimed rev 2736.
|
||||
Rulings: sage-1836 (fence, pairing) and sage-1840 (YAML parser, handoff).
|
||||
|
||||
## Base
|
||||
|
||||
Built on Morgana's row 213 r4 tip 2b3e67e36 (origin/morgana/row213-r4), since the brief says "After:
|
||||
C2-08" and 221 exports 213's fence check. Before review it goes onto 213's landing under a new branch
|
||||
name, and its tests run again there.
|
||||
|
||||
## What changed
|
||||
|
||||
- `scripts/case2-persona-import.mjs`, new. Verbs `stage` and `install`, usage in its header.
|
||||
- `packages/cli/tests/case2-persona-import.test.mjs`, new, and `packages/cli/tests/fixtures/case2-persona-import/`,
|
||||
a fixture Hermes home plus `trace.mjs`, the read trace.
|
||||
- `packages/harness/src/bundle.mjs`: the three text rules in `readPersona` move into an exported
|
||||
`personaTextFault(text)`, which returns null or the reason, and `readPersona` calls it. Nothing it
|
||||
checks changes and the messages are byte for byte the same, so 213's creative tests hold. There is
|
||||
one copy of the rules and the import calls it (sage-1836).
|
||||
- `package.json` and `package-lock.json`: `yaml` 2.9.0 becomes an exact direct dependency (sage-1840 Q3).
|
||||
|
||||
## Rulings recorded
|
||||
|
||||
Profile to name (sage-1836, answers DESIGN.md:1322 as an implementation ruling, not a PRD change).
|
||||
Hermes at df5700ebe has no field holding a profile's own name. `profile.yaml` has only `description`
|
||||
and `description_auto` (hermes_cli/profiles.py:798-850), and `agent_name` at cli.py:3494 is skin
|
||||
branding with the default "Hermes Agent". So the human passes the pairing as `--pair default=Bean
|
||||
--pair chet=Chet`. The script checks both profile directories exist, each name is exactly Bean or Chet
|
||||
and the two differ, then prints the pairing with each SOUL.md's byte count and sha256 before it writes.
|
||||
The brief's "confirm profile to name" is met by the human supplying and confirming the pairing.
|
||||
|
||||
The persona is `<profile dir>/SOUL.md`, where Hermes loads it (agent/prompt_builder.py:1829-1855).
|
||||
The default profile's directory is the Hermes root, and others are `profiles/<name>`, with names
|
||||
checked against hermes_cli/profiles.py:37. A missing or blank SOUL.md is "no persona", as in Hermes.
|
||||
The file is staged byte for byte. Hermes strips it and runs its own scan before use, but this import
|
||||
copies her text unchanged.
|
||||
|
||||
Handoff between the steps (sage-1840 Q4). `stage` writes 0600 files owned by whoever ran it. `install`
|
||||
runs as `<user>` and refuses a staging file that isn't its own or has a group or other bit. The script
|
||||
changes no owner. It prints the root step, `chown -R <user>: <staging>`, as an instruction. **Item for
|
||||
Jason's go:** who runs stage on `<host>`, and the root chown between the two steps.
|
||||
|
||||
## Word list
|
||||
|
||||
69 tool names: every literal `name="..."` in a `registry.register(` call under hermes-agent's
|
||||
`tools/*.py` at df5700ebe, plus Hermes and Discord. Two more registrations, in tools/mcp_tool.py:4997
|
||||
and :5034, and plugin tools (hermes_cli/plugins.py:430) get their names at run time, so a fixed list
|
||||
can't hold them. Matching is whole word, any case. `memory`, `terminal`, `todo`, `process`, `patch` and
|
||||
`clarify` are ordinary words, so a persona line using them in prose counts, and the count leans high.
|
||||
It is a hint for Jason and changes nothing in the import.
|
||||
|
||||
## Channel prompts
|
||||
|
||||
Hermes takes a platform's block from `<platform>` at the top level of config.yaml, else from
|
||||
`platforms.<platform>` or `gateway.platforms.<platform>` (gateway/config.py:1159-1178), and its
|
||||
prompts from `channel_prompts` (:1233). The script lists all three by platform, channel id and byte
|
||||
count, and says "not text" for a value that isn't a string. The dry run doesn't read config.yaml.
|
||||
|
||||
yaml parses with `intAsBigInt`, so an unquoted 18 or 19 digit Discord id prints exactly instead of
|
||||
rounding as a double, and with `uniqueKeys: false`, so a repeated key keeps the last value as Python's
|
||||
`safe_load` does. `logLevel: "silent"` stops yaml's warnings, which quote config text. yaml reads YAML
|
||||
1.2 and Hermes's PyYAML reads 1.1. They differ on scalars such as `yes` and `0123`, but neither
|
||||
changes a channel prompt's key or its byte count for a quoted or decimal id.
|
||||
|
||||
## yaml dependency (sage-1840 Q3)
|
||||
|
||||
Made with `npm install --save-exact [email protected] --package-lock-only --ignore-scripts` at the worktree
|
||||
root. `git config core.hooksPath` was unset before and after. npm moved nothing else:
|
||||
|
||||
```
|
||||
package-lock.json | 3 ++-
|
||||
package.json | 3 ++-
|
||||
2 files changed, 4 insertions(+), 2 deletions(-)
|
||||
```
|
||||
|
||||
```
|
||||
@@ -10,7 +10,8 @@
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
- "oauth4webapi": "3.8.8"
|
||||
+ "oauth4webapi": "3.8.8",
|
||||
+ "yaml": "2.9.0"
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
node_modules/yaml is 2.9.0, from the pinned pi packages (package-lock.json:502 and :556).
|
||||
|
||||
Where it runs. `scripts/case2-persona-import.mjs` isn't in stack-copy's COPY_PATHS, and a copy carries
|
||||
no node_modules of its own beyond its `npm ci`. So the script runs from a checkout at the reviewed
|
||||
commit after `npm ci`, where `import "yaml"` resolves through the root package.json and
|
||||
`../packages/harness/src/bundle.mjs` is beside it. Whoever runs `stage` needs read access to that
|
||||
checkout and to her Hermes home, which is part of the item for Jason's go above.
|
||||
|
||||
## Tests
|
||||
|
||||
Round 1, on 213 r4 2b3e67e36, slot grants sage-1851 and sage-1863. One `node --test` per file.
|
||||
|
||||
Run 1 started at 08:19:44Z. A 600 s tool timeout on my wrapper killed it at about 08:29:45Z, inside
|
||||
stack-release-external, after stack-release took 494 s. One test in this row's file failed in run 1.
|
||||
Its filter `includes("per-channel prompt")` also matched the "profile chet: no per-channel prompts"
|
||||
line that the next assert checks on its own. That was a test defect, and the script's output was
|
||||
right. The filter is now `includes(": per-channel prompt ")`. The kill orphaned one fixture
|
||||
holder.cjs, which I killed, and I removed its tmp dirs. Sage counts this as one failure under mos-0823.
|
||||
|
||||
The rerun, 08:31:11Z to 08:33:31Z, covered this row's file, stack-release-external, stack-upgrade
|
||||
and the fixture dry run. The 15 files that run 1 finished green stand on code the fix didn't touch. Their counts come from run 1.
|
||||
|
||||
| File | pass | fail |
|
||||
|---|---|---|
|
||||
| packages/business/tests/launch-settings.test.mjs | 16 | 0 |
|
||||
| packages/cli/tests/stack-copy.test.mjs | 16 | 0 |
|
||||
| packages/cli/tests/stack-release.test.mjs | 195 | 0 |
|
||||
| packages/conversation/tests/cohort.test.mjs | 37 | 0 |
|
||||
| packages/harness/tests/bundle.test.mjs | 22 | 0 |
|
||||
| packages/harness/tests/claude-gate.test.mjs | 3 | 0 |
|
||||
| packages/harness/tests/claude-session.test.mjs | 8 | 0 |
|
||||
| packages/harness/tests/creative.test.mjs | 7 | 0 |
|
||||
| packages/harness/tests/mcp-server.test.mjs | 5 | 0 |
|
||||
| packages/harness/tests/pi-session.test.mjs | 8 | 0 |
|
||||
| packages/harness/tests/skills.test.mjs | 10 | 0 |
|
||||
| packages/plugins/tests/harness.test.mjs | 9 | 0 |
|
||||
| packages/seat/tests/sandbox.test.mjs | 10 | 0 |
|
||||
| packages/webui/tests/editor.test.mjs | 5 | 0 |
|
||||
| packages/webui/tests/pinned-copy.test.mjs | 10 | 0 |
|
||||
| packages/cli/tests/case2-persona-import.test.mjs | 11 | 0 |
|
||||
| packages/cli/tests/stack-release-external.test.mjs | 25 | 0 |
|
||||
| packages/cli/tests/stack-upgrade.test.mjs | 20 | 0 |
|
||||
|
||||
Total over the 18 files: 417 pass, 0 fail, 0 cancelled, summed by awk.
|
||||
|
||||
The dry run on the fixture, `stage --hermes-root packages/cli/tests/fixtures/case2-persona-import/hermes
|
||||
--pair default=Bean --pair chet=Chet --dry-run`, exited 0 and printed:
|
||||
|
||||
```
|
||||
files 2
|
||||
profile default: lines 3, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0
|
||||
profile chet: lines 2, lines starting "# " or "## " 0, lines naming a Hermes tool or channel 0
|
||||
```
|
||||
|
||||
## Rebase onto 213's landing (sage-1925)
|
||||
|
||||
213 landed as 361ba2387, with the same `git patch-id --stable` as r4 2b3e67e36, f313e69b6f7e. The
|
||||
refactor tip was 62ddc96cc. I cherry-picked 2d7a99aee onto it under the new branch gosalyn/c2-20-r2 as
|
||||
48f044ccc. The patch-id is eb094ae783e9 before and after. None of the 22 paths changed between 2b3e67e36
|
||||
and 62ddc96cc. The fence export from sage-1836 Q1 was already in the commit: `personaTextFault` from
|
||||
packages/harness/src/bundle.mjs, called by the script. I checked the test list again on the new base by
|
||||
grepping tests and scripts/test-*.sh for each non-test path's basename. The new hits name other packages'
|
||||
package.json files or write lockfiles in temp dirs, so the 18 files stand. 231 landed after that as
|
||||
29ce1e360. It touches only packages/library and the queue-231 packet, none of these 22 paths.
|
||||
|
||||
Rerun on 48f044ccc, slot grant sage-1932, 10:18:09Z to 10:30:14Z, after a busy check. All 18 files ran
|
||||
whole, one `node --test` each, every rc 0: 419 pass, 0 fail, 0 cancelled, summed by awk. That's two more
|
||||
than the 417 above, both from the base. stack-release went from 195 to 196 and stack-upgrade from 20 to 21,
|
||||
and rows 238 and 240 changed those test files. Every other file has the count in the table above. The dry
|
||||
run printed the same three lines, rc 0.
|
||||
@@ -1,104 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,96 +0,0 @@
|
||||
# Row 231 build notes, C2-01b survey escapes, list item lines and attachment embeds (#1733)
|
||||
|
||||
Brief: docs/plans/2026-10-20_case2-rows.md "C2-01b survey escapes, list item lines and attachment embeds",
|
||||
blob 16847e94ae17. These are Webby's row 212 notes N1 to N3 (webby-0041), split out by sage-1517.
|
||||
|
||||
## Base
|
||||
|
||||
First built as 777f7358a on 212's first round bdf18272a. 212 landed as ee7b2f137 (sage-1886), so the
|
||||
commit was cherry-picked onto refactor fefd54e13 under the new branch gosalyn/c2-01b-r2 as 3647a5a93.
|
||||
`git patch-id --stable` is 45911915a9ee before and after. packages/library is the same in bdf18272a
|
||||
and in 212's landed round a2c72ac95, so nothing the commit touches moved under it.
|
||||
|
||||
## What changed
|
||||
|
||||
- N1, `packages/library/src/survey.mjs`: JSON strings escape C0 controls but leave DEL, C1 and bidi
|
||||
controls raw. The new `quote()` escapes those too, and folder names print through it.
|
||||
- N2, `packages/library/src/reader.mjs`: front matter parsing records each block list item's line, and
|
||||
a link in a block list carries its item's line instead of its key's.
|
||||
- N3, `links.mjs` exports `isAttachment` through `index.mjs`. A wikilink or embed whose target's last
|
||||
segment ends in an extension other than `.md` is an attachment. The reader neither resolves nor
|
||||
indexes it, and it raises no problem. The survey prints a count of body links to attachments.
|
||||
- `packages/library/README.md` describes all three, and the links, reader and survey tests cover them.
|
||||
|
||||
## Outside readers
|
||||
|
||||
`packages/cli/tests/stack-copy.test.mjs:38` checks only that `packages/library/src/index.mjs` exists, and
|
||||
`scripts/mosaic:41` execs `survey.mjs`. No test pins a hash of a library file.
|
||||
|
||||
## Tests
|
||||
|
||||
On 3647a5a93, slot grant sage-1889, 09:20:01Z to 09:20:14Z, after a busy check. One `node --test` per
|
||||
file, every rc 0.
|
||||
|
||||
| File | pass | fail |
|
||||
|---|---|---|
|
||||
| packages/library/tests/links.test.mjs | 7 | 0 |
|
||||
| packages/library/tests/mapping.test.mjs | 7 | 0 |
|
||||
| packages/library/tests/reader.test.mjs | 13 | 0 |
|
||||
| packages/library/tests/render.test.mjs | 11 | 0 |
|
||||
| packages/library/tests/survey.test.mjs | 6 | 0 |
|
||||
| packages/cli/tests/stack-copy.test.mjs | 16 | 0 |
|
||||
|
||||
Total: 60 pass, 0 fail, 0 cancelled, summed by awk.
|
||||
|
||||
## Round 2, after webby-0070 (CHANGES) and sage-1900
|
||||
|
||||
Webby's round 1 review is agents/webby/work/queue-231/REVIEW-r1.md at 387f04dfd, rev 2774.
|
||||
|
||||
Ruling sage-1900, recorded here as asked. B2: the brief stays as approved (sage-1517, case2-rows.md
|
||||
C2-01b N3). Only a `![[x.ext]]` embed can be an attachment. Plain wikilinks and front matter values keep
|
||||
resolving, and an unresolved one is R4. README's line "A link to a `.canvas` file or an attachment is
|
||||
R4" is back as it was at fefd54e13. B1: resolve first. An embed is an attachment only when it resolves
|
||||
to nothing and its target has an extension other than `.md`. So `![[Dr.Quinn]]` with Dr.Quinn.md
|
||||
indexed resolves, `![[cover.png]]` gives no problem, `![[missing-note]]` is R4 and `![[note.md]]`
|
||||
resolves. My own design ruling after r11 (28213ac27) also made plain links attachments. The brief
|
||||
narrowed that, and the brief governs this row.
|
||||
|
||||
Changes:
|
||||
|
||||
- `links.mjs`: `isAttachment` is true only for a body embed, `link.embed === true && !link.field`,
|
||||
with the extension test as before. Its comment says it goes by name, so the reader asks it only
|
||||
after resolving.
|
||||
- `reader.mjs`: every link resolves first. An unresolved one is R4 unless `isAttachment` holds.
|
||||
- `survey.mjs`: `quote()` also escapes U+061C (Webby N1). The attachment count now covers body embeds
|
||||
only, since it uses `isAttachment`.
|
||||
- Webby N2, my choice of Sage's two options: the survey keeps a count by name and the README says so,
|
||||
"the count of body embeds whose target has an extension other than `.md`", and that `![[Dr.Quinn]]`
|
||||
counts there even when Dr.Quinn.md exists. Checking against the survey's path list would mean a second
|
||||
resolver in the survey, for a count that only guides the mapping.
|
||||
- Tests. links: embeds only, with `[[cover.png]]`, `[[Board.canvas]]`, `[[Dr.Quinn]]`, a front matter
|
||||
value and a markdown link all false, and `![[Dr.Quinn]]` true by name. reader: the Shore case now
|
||||
gives R4 for the front matter `"![[portrait.JPG]]"` (line 3), for `[[Board.canvas]]` (line 5) and for
|
||||
`![[missing-note]]` (line 6). The new Dr.Quinn case has Dr.Quinn.md indexed and resolves `[[Dr.Quinn]]` in
|
||||
a block list and in the body, plus `![[Dr.Quinn]]`, with no problem. survey: a U+061C folder name
|
||||
prints escaped, and the attachment count is 1, since `[[map.PDF]]` is a plain link.
|
||||
|
||||
Round 2 tests, one `node --test` per file, each run after a busy check.
|
||||
|
||||
Run 1, slot grant sage-1903, 09:39:32Z to 09:39:46Z: 60 pass, 1 fail. The new Dr.Quinn reader test failed
|
||||
on order alone. All three links resolved to Dr.Quinn.md with the right field, line and embed values, and
|
||||
there was no problem. The reader lists body links before front matter links, and my expected array put
|
||||
the `relationships` link first. I stopped and reported it (gosalyn-0098).
|
||||
|
||||
Run 2, after the test-only fix sage-1907 approved, which moves that entry to the end of the expected array,
|
||||
09:43:42Z to 09:43:56Z. Every rc 0.
|
||||
|
||||
| File | pass | fail |
|
||||
|---|---|---|
|
||||
| packages/library/tests/links.test.mjs | 7 | 0 |
|
||||
| packages/library/tests/mapping.test.mjs | 7 | 0 |
|
||||
| packages/library/tests/reader.test.mjs | 14 | 0 |
|
||||
| packages/library/tests/render.test.mjs | 11 | 0 |
|
||||
| packages/library/tests/survey.test.mjs | 6 | 0 |
|
||||
| packages/cli/tests/stack-copy.test.mjs | 16 | 0 |
|
||||
|
||||
Total: 61 pass, 0 fail, 0 cancelled, summed by awk.
|
||||
@@ -1,87 +0,0 @@
|
||||
# Row 247 build notes, C2-20b persona import follow-up (#1749)
|
||||
|
||||
Brief: docs/plans/2026-10-20_case2-rows.md "C2-20b persona import follow-up: printed keys, the dry run
|
||||
fence check and the mkdir race", landed in 335bb3e30. These are Webby's row 221 notes N1, N2 and N4
|
||||
(webby-0073, REVIEW-r1 a0a00630e on webby/reviews). N3 is in DEFERRED (sage-1941).
|
||||
|
||||
## Base
|
||||
|
||||
Branch gosalyn/c2-20b from 335bb3e30, which is row 221's landing 300766450 plus the brief.
|
||||
|
||||
## What changed
|
||||
|
||||
- N1, `scripts/case2-persona-import.mjs`: the per-channel prompt line prints the platform key, with its
|
||||
fixed `platforms.` or `gateway.platforms.` prefix, and the channel key through `JSON.stringify`. A key
|
||||
holding a newline prints as `\n` inside one quoted line. The profile name on that line is not config
|
||||
text: `pairing` already holds it to `PROFILE_ID`.
|
||||
- N2: the dry run calls `personaTextFault` on each persona after its count line. A fault prints as
|
||||
`profile P: persona <reason>, so C2-08's fence would refuse it`, where the reason is the fixed string
|
||||
`personaTextFault` returns, never persona text or a line number. With any fault the run refuses, exit
|
||||
1, and writes nothing, as stage would. The header comment no longer says a fault is counted, not
|
||||
refused, and now says a SOUL.md over four times the fence in bytes refuses before any count, which is
|
||||
what `soul()` already did (Webby's second point under N2).
|
||||
- N4: `privateDir` catches an EEXIST from its mkdir and refuses with `<path> was made by something else
|
||||
between the check and mkdir`. Any other mkdir error is thrown as before, since the brief names only
|
||||
EEXIST.
|
||||
- Tests, `packages/cli/tests/case2-persona-import.test.mjs`:
|
||||
- The per-channel prompt test expects the quoted keys.
|
||||
- New: a chet config.yaml with a newline in the platform key and in the channel key prints one quoted
|
||||
line, and no output line starts with the forged text.
|
||||
- The dry run heading test now expects the fence fault line, exit 1 and the refusal, still with
|
||||
nothing written. Its persona has two heading lines, which the fence refuses.
|
||||
- New: a persona of 8001 two-byte characters, 16002 bytes and under the 32000 byte cap, makes the dry
|
||||
run print the over 8000 characters fault, write nothing and exit 1. At exactly 8000 it exits 0.
|
||||
- New: the mkdir race refuses by name at stage, for the staging directory, and at install, for
|
||||
`personas/<business>`. Each refused directory is left empty.
|
||||
- New fixture `packages/cli/tests/fixtures/case2-persona-import/mkdir-race.mjs`, an `--import` preload
|
||||
like `trace.mjs`. It makes the directory named by `$PERSONA_MKDIR_RACE` just before the script's own
|
||||
`mkdirSync` of that path, so the script meets a real EEXIST from the kernel, not a thrown stub.
|
||||
|
||||
## Known limit of N1
|
||||
|
||||
`JSON.stringify` escapes C0 controls, `"` and `\`, but leaves DEL, C1, U+2028, U+2029 and the bidi
|
||||
controls raw. None of those ends a line on a terminal, so the forged line N1 names can't happen. A bidi
|
||||
control could still reorder how the rest of that one line displays. The brief asks for `JSON.stringify`,
|
||||
and Sage kept it in sage-1964 because the keys come from Jason's own Hermes config. Note for Webby: row
|
||||
231's `quote()` in packages/library covers the wider set, and this row doesn't import it. If you want it,
|
||||
ask for it in review.
|
||||
|
||||
## Mutants
|
||||
|
||||
One per fix, plus a second for N1 so each of its two keys has its own. Each replaces one exact string
|
||||
in the script, runs the test file, then restores the script from a copy and checks its sha256.
|
||||
|
||||
| Mutant | Fix | Change |
|
||||
|---|---|---|
|
||||
| M1 | N1 | the channel key prints raw |
|
||||
| M2 | N1 | the platform key prints raw |
|
||||
| M3 | N2 | the dry run's refusal after a fault is skipped |
|
||||
| M4 | N4 | privateDir's EEXIST branch never matches, so EEXIST is thrown raw |
|
||||
|
||||
## Outside readers
|
||||
|
||||
`git grep` over packages/*/tests/* and scripts/test-*.sh finds `case2-persona-import` only in its own
|
||||
test file and in two files of its fixture tree, README.md and trace.mjs, which name the test file in a
|
||||
comment. No test pins a hash of the script.
|
||||
|
||||
## Tests
|
||||
|
||||
Two slot runs, `busy.sh && run.sh` in /mnt/storage/scratch/gosalyn-r247, on the test file alone.
|
||||
|
||||
Run 1, grant sage-1969, 11:47:45Z to 11:48:00Z, was red: 14 tests, 12 pass, 2 fail. The fault was in the
|
||||
script. Adding `JSON.stringify` to the per-channel line dropped the space after the comma, so it printed
|
||||
`"-1001",29 bytes`. The two per-channel tests caught it. run.sh also went on to the mutants with a red
|
||||
base, so their counts meant nothing. Sage approved both fixes in sage-1971: the space goes back, and run.sh
|
||||
now exits 3 before the mutants unless the base is rc=0 with fail 0.
|
||||
|
||||
Run 2, grant sage-1971, 11:49:04Z to 11:49:22Z, was green: 14 tests, 14 pass, 0 fail, rc=0.
|
||||
|
||||
| Mutant | Line | rc | Failed | Killed by |
|
||||
|---|---|---|---|---|
|
||||
| M1 | 237 | 1 | 2 | the per-channel prompt test and the newline key test |
|
||||
| M2 | 237 | 1 | 2 | the same two |
|
||||
| M3 | 238 | 1 | 2 | the dry run fence test and the dry run heading test |
|
||||
| M4 | 192 | 1 | 1 | the mkdir race test |
|
||||
|
||||
After the run the script's sha256 was 7031d2c0dc9bd22bc748b364a35b461fc01cb98b795032a2e0ec436f3188d4e4, the
|
||||
same as the copy taken before it. The /proc scan found no process in the worktree or the scratch dir.
|
||||
@@ -1,75 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,71 +0,0 @@
|
||||
# Row 241: one decision lands once (#1747, P19 441)
|
||||
|
||||
Megavolt, 2026-10-21 (from `date -u`). Brief: `docs/plans/2026-10-20_agent-credentials.md`
|
||||
§ "Brief: row 237 follow-up, one decision lands once, with tests at the broker", c25785d3d. Source:
|
||||
row 237 residual 3, megavolt-0068, megavolt-0069 and sage-1651. Base: origin/refactor f5753c0e8, row 210's landing.
|
||||
Reviewer Duckworth, gate owner Sage. Tests only; no source changes.
|
||||
|
||||
## Why landing has no row 237 race
|
||||
|
||||
Row 237 fixed publish: two tickets on one decision both pushed, because the decision is spent only
|
||||
after the push, and a push to a remote fork can't be taken back. Landing has the same ticket shape
|
||||
(`checkLand` opens a ticket, `recordLand` spends the decision), but three guards keep one decision
|
||||
from landing twice:
|
||||
|
||||
1. **land-busy, the outer guard.** `doLand` in `packages/cli/src/git-socket.mjs:351` refuses
|
||||
`land-busy` while another landing runs. The slot is taken before the first await, and freed only
|
||||
when the landing ends. There is one git socket per launcher, and one host per dataRoot (host.lock).
|
||||
So through the socket two landings never overlap, and a later one stops at `checkLand` with
|
||||
`decision-consumed`. Even past that, applying the same reviewed patch to the new main fails
|
||||
`base-conflict` in `prepareLand`.
|
||||
2. **The CAS on main.** `finishLand` (`packages/seat/src/working.mjs:391`) moves main with
|
||||
`update-ref main <commit> <old>`, so a landing prepared on a tip that has since moved refuses
|
||||
`base-moved` and writes nothing.
|
||||
3. **The recordLand recheck.** `finishLand` calls `record()`, which is `recordLand`, under the same
|
||||
repository lock. `recordLand` runs `#landFacts` again (`packages/bus/src/broker.mjs:963`), which
|
||||
refuses `decision-consumed` once the decision is spent. When `record()` throws, `finishLand` moves
|
||||
main back.
|
||||
|
||||
Guards 2 and 3 can't be reached end to end, because guard 1 stops a second landing first. So they are
|
||||
pinned below the socket, and no test bypasses land-busy.
|
||||
|
||||
### The unrecorded-commit window
|
||||
|
||||
Between the `update-ref` and the end of `record()`, main names the squash, which isn't recorded yet.
|
||||
That window is under the repository's lock, so no other push, review or landing in this host sees
|
||||
it. If `record()` refuses, main moves back. If moving back fails, `finishLand` refuses
|
||||
`rollback-failed` and main keeps naming an unrecorded commit (tested at `working.test.mjs:555`, the
|
||||
main.lock case). That is existing behaviour, unchanged here.
|
||||
|
||||
## The test
|
||||
|
||||
`packages/bus/tests/land.test.mjs:312`. Two tickets pass `checkLand` on one decision before either
|
||||
records. `recordLand` on the first lands. `recordLand` on the second refuses `decision-consumed`
|
||||
(:319), and leaves one merge `action.allowed`, one RESULT and no `action.refused`. `landRefused`, as
|
||||
the socket would call it, then files the refusal and ends the ticket.
|
||||
|
||||
**The assert that pins the recordLand recheck is :319.** The `#spent` line is shared with
|
||||
`checkLand`, so dropping it also fails :144 (a `checkLand` after the decision is spent). Only :319
|
||||
shows the recheck at record time.
|
||||
|
||||
Single file on this branch: land.test.mjs 18/18.
|
||||
Whole suites on this branch before in-review: bus 500/500, seat 164/164, cli 565/565 (02:52 to 03:01Z).
|
||||
|
||||
## Mutants
|
||||
|
||||
| Mutant | Change | Killed by |
|
||||
|---|---|---|
|
||||
| M1 | drop the `#spent` line in `#landFacts` (broker.mjs:963) | land.test.mjs: :312 at :319, and :94 at :144 (16/2) |
|
||||
| M2 | drop `land.old` from the `update-ref` in `finishLand` (working.mjs:391) | working.test.mjs:555 at :567, the `base-moved` assert (25/1) |
|
||||
| M3 | drop the `land-busy` throw (git-socket.mjs:351) | git-socket.test.mjs:812 at :825, the `land-busy` assert (23/1) |
|
||||
|
||||
Each run was one test file on base f5753c0e8 (row 210's landing, which moved the `#spent` line from
|
||||
:945 to :963), 2026-10-21 02:51Z. Each assert failed with "Missing expected exception" or "rejection".
|
||||
`cmp` confirmed each source matched its saved copy after the restore. M2 is killed by the existing
|
||||
working.test:555, so per the brief working.test.mjs isn't changed. Logs: `/mnt/storage/scratch/megavolt-r241/`
|
||||
(m1.log to m3.log, with each diff).
|
||||
|
||||
## Gate
|
||||
|
||||
Per the brief: Duckworth approves the manifest; the bus, seat and cli suites and every
|
||||
`scripts/test-*.sh` are green on Sage's gate; the three mutants are killed.
|
||||
@@ -1,108 +0,0 @@
|
||||
# Row 242 build: a retry after publish-timeout pushes once
|
||||
|
||||
Row 242, #1748, P19 440. Brief: docs/plans/2026-10-20_agent-credentials.md, "Brief: row 237
|
||||
follow-up N1, a retry after publish-timeout pushes once" (9d165be8c). Base: origin/refactor
|
||||
8fede5fc6 (row 210 landed f5753c0e8).
|
||||
|
||||
## The defect
|
||||
|
||||
The git socket polls publishResult until publishDeadlineMs, then refuses publish-timeout
|
||||
(packages/cli/src/git-socket.mjs:433) while the bus process's push (runtime publishPush) can
|
||||
still be running. The socket then calls publishRefused, which went straight to
|
||||
broker.publishRefused: it filed the refusal and deleted the ticket. checkPublish no longer saw an
|
||||
open ticket on the decision, so a retry got a new ticket and pushed a second ref while the first
|
||||
push was still on its way to the fork.
|
||||
|
||||
## Fix chosen: hold the decision until the push ends, then record a push that reached the fork
|
||||
|
||||
packages/bus/src/runtime.mjs, `publishRefused` and `settle`:
|
||||
|
||||
- If the bus has no push for the ticket, publishRefused goes to the broker as before.
|
||||
- If the push is still running, the refusal code is held on the entry and the ticket stays open.
|
||||
checkPublish refuses another publish on the decision with publish-busy (row 237's check),
|
||||
the same as when the socket is still polling.
|
||||
- When the push ends, `settle` runs:
|
||||
- A push that reached the fork is recorded with broker.recordPublish, which spends the
|
||||
decision. The bus logs `pushed after the git socket refused <code>; recorded`.
|
||||
- A push that failed is filed as publishRefused with the socket's code.
|
||||
- If recordPublish refuses with a BusError, that code is filed instead.
|
||||
- If the push has already ended when the refusal arrives, it's settled at once. The sage-1440 test
|
||||
relies on this: its push fails fast, so publish-timeout is filed once, immediately, as before.
|
||||
|
||||
Why hold rather than kill. Killing the git client doesn't undo a push the fork has already
|
||||
received: receive-pack can update the ref after the client dies. After a kill the bus would have
|
||||
to ls-remote the fork to learn what landed, and the result could still race the server. Holding
|
||||
waits for the push's own outcome. Each git run in publishRemote already has its own limit
|
||||
(publishRemote defaults to checkout.mjs GIT_LIMITS), so a hold is bounded. Holding also reuses the existing publish-busy
|
||||
state, and the refusal text is unchanged.
|
||||
|
||||
Why record and not only hold. If the hold just filed publish-timeout once the push ended, the
|
||||
ticket would close and the decision would be free again with a ref on the fork. A retry would then
|
||||
push a second ref. Recording the late push spends the decision, so the retry gets
|
||||
decision-consumed.
|
||||
|
||||
The publish-timeout refusal text is unchanged (brief condition).
|
||||
|
||||
## Test
|
||||
|
||||
packages/cli/tests/publish-socket.test.mjs:302, "row 242: a retry after publish-timeout refuses
|
||||
publish-busy while the bus process's push runs; the late push is recorded and the decision pushes
|
||||
once".
|
||||
|
||||
- publishDeadlineMs 150 and a store token. The fork's pre-receive hook writes `held`, then loops
|
||||
until the test writes `release`.
|
||||
- The first publish gives publish-timeout. The test waits for `held`.
|
||||
- A retry to branch `other` gives publish-busy (:322). Refused events: ["publish-busy"]. No
|
||||
git.push.remote allowed event.
|
||||
- The test writes `release` and waits until there is exactly one allowed event, with ref
|
||||
refs/heads/coder/feat and the work commit.
|
||||
- The next retry gives decision-consumed (:330).
|
||||
- Exactly one git-receive-pack request reached the fake Gitea, and the fork has only
|
||||
refs/heads/coder/feat. Refused events: ["publish-busy", "decision-consumed"]. No TOKEN appears in
|
||||
events or logs.
|
||||
|
||||
Fail fast (sage-1681): the body is wrapped in try/finally, and the finally writes `release`. Under
|
||||
the first mutant run, the assert at the retry failed and the hook was never released. setup's
|
||||
teardown waits for the fake Gitea, which waited for the held push, so the file hung until I stopped
|
||||
it. Now a failed assert ends the hook and the run. Each wait is bounded at 10 s with an assert.fail.
|
||||
|
||||
## Mutants
|
||||
|
||||
Script: /mnt/storage/scratch/megavolt-r242/mutants.sh. Each mutant: save, one-line sed, run
|
||||
publish-socket.test.mjs whole, restore, cmp.
|
||||
|
||||
| Mutant | Change | Expected kill |
|
||||
|---|---|---|
|
||||
| M1 | export `publishRefused: (ticket, args) => broker.publishRefused(ticket, args)` again (removes the fix) | :302 at :322: the retry gets a new ticket and its own push, so publish-timeout, not publish-busy (seen in the stopped 03:06Z run) |
|
||||
| M2 | `settle` files publishRefused without trying recordPublish (hold only) | :302 at the wait for one allowed event, then :330 |
|
||||
|
||||
Results (Sage's slot, sage-1686, 2026-10-21T03:35:00Z to 03:35:15Z): both killed, both restored
|
||||
(cmp ok).
|
||||
|
||||
- M1: publish-socket 8/1, the row 242 test fails at :322 in 0.5 s.
|
||||
- M2: publish-socket 8/1, the row 242 test fails at :327, the wait for one allowed event, through
|
||||
the assert.fail inside `until` (:311) after its 10 s bound, in 10.3 s.
|
||||
|
||||
## Suites
|
||||
|
||||
On Sage's slot (sage-1686), on base 8fede5fc6 plus this row:
|
||||
|
||||
- bus `node --test tests/*.test.mjs`: 499/499, 03:35:20Z to 03:35:34Z. This is one fewer than
|
||||
Sage's 241 gate (500) because the base predates row 241's land.test case.
|
||||
- cli `node --test tests/*.test.mjs`: 566/566, 03:35:34Z to 03:43:47Z. That's 565 plus the
|
||||
row 242 test.
|
||||
|
||||
Singles before the slot: publish-socket.test.mjs 9/9, bus publish.test.mjs 13/13. The seat suite
|
||||
and scripts/test-*.sh are left for Sage's landing gate.
|
||||
|
||||
## Residuals
|
||||
|
||||
- The agent was told publish-timeout, but the push is recorded later and the run isn't
|
||||
notified. The allowed event and the bus log line are the only trace. A later publish on the
|
||||
decision gets decision-consumed, which tells the agent the decision was used. Whether the run
|
||||
should get a RESULT for a late record is a follow-up question for Sage.
|
||||
- A late push whose recordPublish refuses (Duckworth N2) is filed with that code: the ref stays on
|
||||
the fork and the decision is not spent, the same residual as an in-band push whose record
|
||||
refuses.
|
||||
- The hold lives in the bus process's memory, like the broker's open tickets. A bus restart during
|
||||
a hold drops both, the same as today for any publish in flight.
|
||||
@@ -1,252 +0,0 @@
|
||||
# Row 213 (C2-08) build: creative roles, persona file and block, M1, M2, M6, M10, prompt by file
|
||||
|
||||
Morgana (Opus 5.5), 2026-10-20. Queue row 213, Gitea #1714. Brief: `docs/plans/2026-10-20_case2-rows.md`
|
||||
§ C2-08 (on `refactor` from 32b54ac3b). Design: Gosalyn's Case 2 design r10, sections 4 and 5.
|
||||
Reviewer Webby, gate owner Sage. Base: `base.txt` (088087ac9). Manifest: `candidate-manifest.sha256`.
|
||||
D1 and D2 approved as deviations in sage-1492.
|
||||
|
||||
## What it is
|
||||
|
||||
1. **Six creative roles** (`roles/<name>.json` and `.md`, roleVersion 2): companion and research get
|
||||
read and web, with network api-only. ideas, proofreader, fact-checker and continuity get read only, with
|
||||
network none. All six get withinRole `["message.send"]`, no credentials, and messaging that sends to and
|
||||
receives from `human` only. `vocabulary.mjs` names them in `CREATIVE_DEFINITIONS`, plus `CREATIVE_KINDS`,
|
||||
`CREATIVE_TOOLS` and `THREAD_DEFINITIONS`. `END_PROTECTED` becomes `THREAD_DEFINITIONS`.
|
||||
2. **M1** (`role.mjs` `checkCreative`): a creative role file is refused if it names a tool outside
|
||||
`CREATIVE_TOOLS`, holds any authority but withinRole `message.send`, lacks `messaging`, names anyone but
|
||||
`human` in messaging, or is version 1.
|
||||
3. **M2**: since the role files name no write, edit or bash tool, the existing gate refuses write, edit and
|
||||
bash on pi and Write, Edit and Bash on claude-code for each creative role. No gate code changed. The
|
||||
test pins it.
|
||||
4. **M6**: `tools.mjs` leaves `raise_decision` out for a creative definition. `broker.mjs` refuses
|
||||
`decision.raise` from a creative session with `decision-not-allowed`, after `#secretCheck` and before
|
||||
the spec-cited path, so a cited raise is refused too.
|
||||
5. **M10**: a creative instance belongs to the human.
|
||||
- `launch-settings.mjs` `creativeHumanOnly` gives `organization-creative-human-only` to any other actor.
|
||||
It covers: a `models` key that a creative instance's `model` or `fallback.model` var names (changed or
|
||||
removed), `setRoutes` for the instance, and the `instances` key. The `instances` key is refused for
|
||||
every actor with `organization-creative-instance`, because the model changes through `instances.set`
|
||||
or `routes.set`.
|
||||
- `instance-settings.mjs`: add, change and redefine to or from a creative definition are the human's.
|
||||
A creative definition is `END_PROTECTED`, so the new `closed()` lets this writer still add and change
|
||||
one. `accepted` and `protected` use `closed()` too, so the human's Console form offers the creative
|
||||
definitions and marks those instances as editable.
|
||||
6. **Persona** (design 4): vars `persona.name` (one line, max 40) and `persona.sha256` (64 lowercase hex),
|
||||
agent layer. The file lives at `<configDir>/personas/<business>/<instance>.md`.
|
||||
- `bundle.mjs` `readPersona` opens it with O_NOFOLLOW and refuses with `persona-invalid` when the file
|
||||
is missing, a symlink, not a regular file, owned by another uid, carries group or other mode bits, is
|
||||
over 8000 characters, is not UTF-8, or holds `</persona-file>` or a line starting `# ` or `## `.
|
||||
- It refuses with `persona-mismatch` when the bytes don't match the digest.
|
||||
- A file present with no `persona.sha256` var is ignored. A digest with no `personaDir` refuses.
|
||||
- The block sits between the contract and `## This session`, wrapped in
|
||||
`<persona-file instance=... sha256=...>`.
|
||||
7. **Web**: `webPlan` refuses `web-source-not-allowed` when a creative definition is on
|
||||
`harness-builtin`, so every creative fetch goes through the broker.
|
||||
8. **Prompt by file**: claude passes `--system-prompt-file <path>`, and pi passes `--system-prompt <path>`.
|
||||
Both make the path absolute. The prompt text no longer appears in `/proc/<pid>/cmdline`.
|
||||
`adapters/README.md` says so.
|
||||
|
||||
## Refusal codes
|
||||
|
||||
`persona-invalid`, `persona-mismatch`, `web-source-not-allowed`, `organization-creative-human-only` and
|
||||
`organization-creative-instance` are new. `decision-not-allowed` is named in the brief but is also new in
|
||||
code. My morgana-0021 message called it an existing code, which was wrong.
|
||||
|
||||
## Deviations (sage-1492)
|
||||
|
||||
- **D1** `packages/business/src/business.mjs`: `checkPersonaVars`, plus one call line at the end of
|
||||
`checkRoles`'s per-instance block. The persona vars are accepted only on an instance of a creative
|
||||
definition, and only both together. Without D1, any instance's agent layer could carry a persona
|
||||
digest, and the bundle would read a persona file for a pm or chief.
|
||||
Test: `business.test.mjs` "persona vars: agent layer, creative definition only, both or neither". It
|
||||
covers the business layer, pm and chief, one key alone and malformed values. `instance-settings.test.mjs`
|
||||
also covers it: changing a persona-carrying companion to security-reviewer is refused.
|
||||
Mutants: D1a, D1b, D1c.
|
||||
- **D2** `packages/cli/src/launcher.mjs`: one line, `personaDir: join(configDir(env), "personas"),` in the
|
||||
`buildBundle` call. Without it a creative instance with a persona can never launch.
|
||||
Test: `launcher.test.mjs` "row 213 a creative instance's persona is read from <configDir>/personas: off
|
||||
its digest the launch fails with the mismatch as its reason". Mutant: D2.
|
||||
|
||||
- **Adapter tests' file** (webby-0040 N1). Brief line 68 puts the adapter tests in `claude-session.test.mjs`
|
||||
and `pi-session.test.mjs`. They are in `harness/tests/creative.test.mjs` instead, next to the other row 213
|
||||
harness tests, so the row's adapter cases sit in one file and the two session test files stay unchanged.
|
||||
Same cases: argv holds the absolute prompt path and no marker, and pi refuses a missing, unreadable or
|
||||
directory prompt.
|
||||
|
||||
The checkRoles change sits in its own block and the launcher change is one line, so the rebase over rows
|
||||
194 and 199 (both touch `business.mjs`, and 199 touches `launcher.mjs`) stays small.
|
||||
|
||||
## Pinned-test edits and the pi tightening (sage-1492, named here)
|
||||
|
||||
1. `packages/bus/tests/project-runtime.test.mjs:26`: the pinned broker import line gains
|
||||
`CREATIVE_DEFINITIONS` (M6 reads it).
|
||||
2. `packages/harness/tests/skills.test.mjs:170`: the test checked the prompt text in argv after
|
||||
`--system-prompt`. Each adapter now passes a path, so the test looks up the flag for each harness,
|
||||
checks that the next argument is the bundle's prompt file, and reads the MARKED and chief checks from
|
||||
that file.
|
||||
3. `adapters/pi/adapter.sh`: `[ -f "$PROMPT_FILE" ] && [ -r "$PROMPT_FILE" ]`, exit 2 otherwise. Pi 0.85.1
|
||||
uses the path string as the prompt when it can't read the file. Test: `harness/tests/creative.test.mjs`
|
||||
"pi refuses a missing, unreadable or directory prompt" (pi never runs). Mutant: A1.
|
||||
|
||||
One more pinned assertion changed: `business/tests/assistant.test.mjs` REQ-HARN-2 expected only the
|
||||
assistant to hold `web`. Companion and research now hold it too.
|
||||
|
||||
## Files
|
||||
|
||||
Source: `packages/business/src/{vocabulary,role,vars,business,launch-settings,instance-settings}.mjs`,
|
||||
`packages/harness/src/{bundle,tools}.mjs`, `packages/bus/src/broker.mjs`, `packages/cli/src/launcher.mjs`,
|
||||
`adapters/{pi,claude}/adapter.sh`, `adapters/README.md`, `roles/{companion,research,ideas,proofreader,
|
||||
fact-checker,continuity}.{json,md}`.
|
||||
|
||||
Tests changed: `business/tests/{role,business,launch-settings,instance-settings,assistant}.test.mjs`,
|
||||
`harness/tests/skills.test.mjs`, `bus/tests/project-runtime.test.mjs`, `cli/tests/launcher.test.mjs`.
|
||||
New: `harness/tests/creative.test.mjs` (7), `bus/tests/creative.test.mjs` (3).
|
||||
|
||||
I grepped `packages/*/tests` and `scripts/` for each changed source path next to a 64-hex string. The hits
|
||||
are `thread-migration.test.mjs` (a DDL digest, not a pin on `broker.mjs`; 3/3 pass) and discord's own
|
||||
`tools.mjs`. No test pins a changed file.
|
||||
|
||||
## Evidence
|
||||
|
||||
Each file alone from its package with `node --test --test-reporter=tap <file>`, 2026-10-20, all fail 0:
|
||||
|
||||
- Touched: business role 17, business 17, launch-settings 16, instance-settings 12, assistant 11; harness
|
||||
creative 7, skills 10, bundle 22; bus creative 3, project-runtime 3; cli launcher 30. Round 2 reran
|
||||
bus creative, instance-settings, business, launch-settings, role, harness creative and bundle, bus
|
||||
tester-writes 8 and chief-of-staff 18, and the vars readers business vars 7, discord context 7 and git 12.
|
||||
- Other files that read a changed path: business hard-waits, record-grants, token-limit-settings,
|
||||
workflow-roles, chief-of-staff, project-repo, plugin-settings; harness bundle 22, tools 22,
|
||||
claude-session 8, pi-session 8, raise-domain, spec-tools, claude-gate, mcp-server, gate, runner 34,
|
||||
git-tools; bus hard-waits, launch-notice, tester-writes, business, broker 16, workflow-roles, web 19,
|
||||
task-page, thread-migration 3; discord git, web, setspark, engine, tools; plugins harness, loader; seat
|
||||
working, sandbox, session; cli launch-limit-checks, limits, git-socket, launch-account, thread-launch,
|
||||
launch-limits, launch-routes, instances, stack-copy, stack-release 174; webui limits-browser, editor,
|
||||
settings-form, tester-browser.
|
||||
|
||||
Suites, `scripts/test-*.sh` and the mutants are not run yet. They need Sage's slot.
|
||||
|
||||
Drift: `git diff --stat 088087ac9 305857c8f` over the 35 manifest paths is empty (2026-10-20, sage-1501).
|
||||
Row 192's async `scripts/mosaic-config.mjs` and `webui-service.sh` are outside the manifest. The broker's
|
||||
new import comes from `vocabulary.mjs`, which it already imported, so `RUNNER_FILES` doesn't change.
|
||||
|
||||
Mutants: `mutants.py` in this directory, 31 mutants over 11 files. It backs up each file to scratch first,
|
||||
aborts on a bad backup or any anchor count other than one (all 31 checked as unique without editing), runs
|
||||
the named test files per mutant, restores each file after its mutant and again in `finally`. Log:
|
||||
`mutants.log`, written on the run. The brief's `models` mutant is L1.
|
||||
|
||||
| Mutant | File | Tests |
|
||||
|---|---|---|
|
||||
| D1a/b/c persona vars check (D1) | business.mjs | business, instance-settings |
|
||||
| D2 no personaDir (D2) | launcher.mjs | launcher |
|
||||
| L1 models key by anyone (the `models` mutant) | launch-settings.mjs | launch-settings |
|
||||
| L2 fallback.model not checked | launch-settings.mjs | launch-settings |
|
||||
| L3 instances key allowed | launch-settings.mjs | launch-settings |
|
||||
| L4 setRoutes by anyone | launch-settings.mjs | launch-settings |
|
||||
| L5 creativeHumanOnly never refuses | launch-settings.mjs | launch-settings, instance-settings |
|
||||
| I1/I2/I3 add, change, redefine by anyone | instance-settings.mjs | instance-settings |
|
||||
| R1/R2/R3 checkCreative off, wider tools, non-human messaging | role.mjs | role |
|
||||
| B1-B8 mode bits, digest, headings, symlink, char cap, block, no dir, builtin web | bundle.mjs | harness creative |
|
||||
| T1 raise_decision offered | tools.mjs | harness creative |
|
||||
| K1 broker raise check off | broker.mjs | bus creative |
|
||||
| K2/K3 chief or tester session reaches the writer as the human (round 2, B1) | broker.mjs | bus creative |
|
||||
| V1 persona.name line separators accepted (round 2, N3) | vars.mjs | business |
|
||||
| A1 pi without -f; A2/A3 relative path kept | adapters | harness creative, skills |
|
||||
|
||||
## Residuals
|
||||
|
||||
- `ID_PATTERN` allows dots, so a human id could read like `<definition>.<instance>`. That is only cosmetic:
|
||||
`creativeHumanOnly` compares the actor with `document.human`.
|
||||
- TOCTOU between pi's `[ -f ] && [ -r ]` and pi's own read of the bundle's prompt file.
|
||||
- The other-uid owner check in `readPersona` can't be tested without root.
|
||||
- O_NOFOLLOW covers only the last path component, so a symlinked `personas/` or `personas/<business>/` is
|
||||
followed (webby-0040 N2). The digest still refuses any bytes but the pinned ones. A comment in
|
||||
`bundle.mjs` says so.
|
||||
- The claude adapter checks the prompt file with `[ -r ]` only, and pi checks `-f` and `-r`. The brief asks
|
||||
for pi's check (webby-0040 N4).
|
||||
- **Launch-failed mapping.** A BundleError thrown from `buildBundle` inside `start()` becomes the Refusal
|
||||
`launch-failed`, with the code's message in the log's `why`. That holds for the persona codes and for
|
||||
webPlan's `web-source-not-allowed`. By then the run dir `launches/<business>/<run>/` exists with only
|
||||
`session/`, and no bundle is written. Only `check()`'s readSkills preflight surfaces bundle codes. The
|
||||
D2 test pins both facts. I kept the launcher change to one line, as sage-1492 asked.
|
||||
|
||||
## Follow-ups (not in this row)
|
||||
|
||||
- A persona preflight in launcher `check()`, so a bad persona refuses with its own code before a run dir
|
||||
exists. Sage agreed it as a follow-up (sage-1501) and files it after this row lands.
|
||||
- The webui `bus.mjs` code-to-HTTP map lacks the new codes.
|
||||
- webby-0053 N1: `checkCreative` doesn't refuse `credentials`, so a later role-file commit could give a
|
||||
creative instance a gitea credential. All six creative role files have `credentials: []` today. A
|
||||
one-line refusal and a `role.test.mjs` case close it.
|
||||
- The cli `instances.mjs` message wording for the creative refusals.
|
||||
|
||||
## Round 2 (webby-0040, round 1 CHANGES on bb5f4600)
|
||||
|
||||
- **B1**: `bus/tests/creative.test.mjs` adds "M10 through the broker". It runs on a real organization file
|
||||
and writer behind `startBroker`, with a claimed chief session, a bound tester session and the human.
|
||||
- The chief's `organization.set` of bean's `launch.models` key is refused with
|
||||
`organization-creative-human-only`. Its `instances` key is refused with `organization-creative-instance`.
|
||||
- The tester's `organization.set` and `limits.set` are refused `tester-refused` (N5: the broker refuses
|
||||
first). Its `instances.set` (change bean, add an ideas instance) and `routes.set` for bean reach the
|
||||
writer as `tester` and are refused with `organization-creative-human-only`.
|
||||
- The file bytes are unchanged and no committed log entry exists after all of these.
|
||||
- The human's `limits.set` of the same change lands, and the one committed entry names `jason`.
|
||||
- Mutants K2 and K3 change the broker's actor naming for a role or tester session.
|
||||
- **B2**: `instance-settings.test.mjs` asserts the mode of `writer.logFile("acme")` (0600) and that it sits
|
||||
in the history dir. It lives at `.history/acme/changes.jsonl`, so the round 1 loop over that dir already
|
||||
covered it, but no line named it.
|
||||
- **N1**: listed under Deviations. **N2**: comment and residual. **N3**: `persona.name` now refuses U+2028
|
||||
and U+2029, with two values added to the business test and mutant V1. **N4**: noted under residuals.
|
||||
|
||||
- Drift: `git diff --stat 088087ac9 f41729178` over the 35 manifest paths is empty. Round 2 manifest
|
||||
digest b5ff0ac0e754cb1e5891417f4ab1da9597f5cf49fb2fe2d5f0fbf066f821c43a.
|
||||
|
||||
## Round 3 (sage-1715): rebase onto 9b5362018
|
||||
|
||||
Rows 199 (cb5fdb064), 237 (d7d943199) and 210 (f5753c0e8) landed on six manifest paths after the round 2
|
||||
approval. Round 3 rebases the three commits onto origin/refactor 9b5362018 on a new branch,
|
||||
`morgana/row213-r3`. The round 2 branch stays as it was. Nothing else changed.
|
||||
|
||||
- Five files conflicted. Each resolution keeps both sides:
|
||||
- `bus/src/broker.mjs` and `bus/tests/project-runtime.test.mjs`: the vocabulary import keeps rows 199
|
||||
and 210's names (forkProblem, forkUrl, credentialKind, validArgv, commandAllowed) and adds
|
||||
CREATIVE_DEFINITIONS last. The pinned import line in the test carries the same list. RUNNER_FILES
|
||||
is unchanged, since the import is still the vocabulary file only.
|
||||
- `business/src/business.mjs`: one vocabulary import with row 199's names plus CREATIVE_DEFINITIONS.
|
||||
The `checkRoles(roles, file, rolesDir, business, hosts)` signature is row 199's. `checkPersonaVars`
|
||||
runs after row 199's identity and publish checks.
|
||||
- `business/src/role.mjs`: the import keeps row 210's HOST_SERVICE and adds CREATIVE_DEFINITIONS and
|
||||
CREATIVE_TOOLS.
|
||||
- `business/src/vocabulary.mjs`: the creative constants and `END_PROTECTED = THREAD_DEFINITIONS` come
|
||||
before row 199's Gitea URL parse, which is kept whole.
|
||||
- `cli/src/launcher.mjs` merged without a conflict.
|
||||
- `git range-diff 088087ac9..aee32d185 9b5362018..morgana/row213-r3`: commits 2 and 3 are `=`. Commit 1
|
||||
differs only in the conflict hunks above.
|
||||
- None of the new base's added lines name END_PROTECTED, THREAD_DEFINITION, CREATIVE_* or the creative
|
||||
refusal codes, apart from docs. All 31 mutant anchors still count 1. The hash-pin grep finds no
|
||||
64-hex pin on a manifest path.
|
||||
- The manifest is rebuilt from the round 3 tree. It has the same 35 paths, and six digests changed: the
|
||||
six drifted files. `base.txt` is 9b5362018.
|
||||
- Singles of every touched test file, plus the seat sandbox and bus project-runtime tests (RUNNER_FILES
|
||||
rule), wait on Sage's slot. They are recorded here when run.
|
||||
|
||||
## Round 4 (sage-1778, mos-1160): one commit on row 234's landing c771a36f4
|
||||
|
||||
Row 234 landed as c771a36f4 after the round 3 approval (webby-0053). Round 4 is one commit on c771a36f4,
|
||||
on the new branch `morgana/row213-r4`. It is the round 3 change applied onto the landed blobs. The round 3
|
||||
branch stays as it was.
|
||||
|
||||
- Overlap, `git diff --name-only 9b5362018 c771a36f4` against the round 3 paths: `business/src/business.mjs`,
|
||||
`bus/src/broker.mjs` and `bus/tests/project-runtime.test.mjs`. Each starts from the landed blob, and the
|
||||
row 213 edits are merged by hand. No landed hunk is reverted (sage-1760).
|
||||
- `broker.mjs` and the pinned `project-runtime` import line: row 234's list with `HOST_ID`, then
|
||||
`CREATIVE_DEFINITIONS`. The test keeps both rows' comments. The import still names only
|
||||
`vocabulary.mjs`, so RUNNER_FILES is unchanged.
|
||||
- `business.mjs`: row 234's import (no `HOST_PREFIX`, no `hosts.mjs`) plus `CREATIVE_DEFINITIONS`.
|
||||
`checkPersonaVars` sits before row 234's `HOSTS_MOVED` and its four-argument `checkRoles`, and still
|
||||
runs after the identity and publish checks.
|
||||
- Check: the removed and added non-packet lines of `diff c771a36f4..r4` and `diff 9b5362018..2d2ad2760`
|
||||
differ only in those three import lines. All 31 mutant anchors count 1.
|
||||
- Row 234's hosts verbs: `#hostsCaller` admits the human, the tester or the chief definition
|
||||
(`grant-chief-only`), so a creative role can't reach them and M1 stays as narrow.
|
||||
- The manifest has the same 35 paths. The three overlap files changed digest. `base.txt` is c771a36f4.
|
||||
- Singles and the 31 mutants wait on Sage's slot. They are recorded here when run.
|
||||
@@ -1 +0,0 @@
|
||||
c771a36f4bd0b6c359282425c668996a90072f93
|
||||
@@ -1,35 +0,0 @@
|
||||
37cc5d74636465c592c9eb8f3d9b66c2632299be922444737b32d3d3be43c239 adapters/claude/adapter.sh
|
||||
1e0487ab682ff97e974a9fed4765994f70ee362058bd0aa6aee32c2a7891e0d2 adapters/pi/adapter.sh
|
||||
2cb62d90f4f52f17ca606e57d4714b872520d0224cd94fd116308becee864b71 adapters/README.md
|
||||
ee5521840784d9ff3603c22cc6b5f090ac062e8575946a071972fedc089724d1 packages/business/src/business.mjs
|
||||
a93b4efe213750b35730d6391594f3e26ffe667040a5d7baf5262b83341164b1 packages/business/src/instance-settings.mjs
|
||||
3e957579bcfdf5f88e60f7235b7ead4fb27cb455e34b7588f4e22b8ab5542bec packages/business/src/launch-settings.mjs
|
||||
6b84018c202ff08e59b4e946556703d199fe75ef97e9e6ac4a390c89705ac796 packages/business/src/role.mjs
|
||||
f6b15ee8dad2d985c0ae66b1f106ca46cb56ec86ac8cd5dec8a05c887596a421 packages/business/src/vars.mjs
|
||||
814ba93ca7403ad77ea2e7fb7bc21c2b40846bf8a3c2eae53b32446278af061c packages/business/src/vocabulary.mjs
|
||||
b67917c5c7bb270b82f1cbd97071a195b79a57c9a343acbc5feae2a877fb9e8f packages/business/tests/assistant.test.mjs
|
||||
b4606af3288624475e23ca506f7dea084a1e606efce4b1556d132b03230c2be1 packages/business/tests/business.test.mjs
|
||||
2b80070db1e1d72b82a32cec75711c59a9383188b5a659854fd0ba8ae2dc0f5b packages/business/tests/instance-settings.test.mjs
|
||||
647b4835836566070c7b859b2bbcaf3fd748030fe88c8f20e990f5038ec65e88 packages/business/tests/launch-settings.test.mjs
|
||||
0c9f082d8c05582382dc646c74aa60a46b89804717f828db9fadde51f2e258a8 packages/business/tests/role.test.mjs
|
||||
15f530d49eaf25167113f84b743d7057c17a273e56731d8937bad26196325bfc packages/bus/src/broker.mjs
|
||||
21946cdecab263ae65791977eb4599594340c8e049b3c216e3181610638f0952 packages/bus/tests/creative.test.mjs
|
||||
4b1c6688beba124492cf130247a2ff93fdd6d18594990cde84709139964d0b5b packages/bus/tests/project-runtime.test.mjs
|
||||
a968a19d1b32416aecb2488c7fb9768db7d5160374f1b790166380d41acea894 packages/cli/src/launcher.mjs
|
||||
7933f256e5f097999ff36cc7e48b0c417385c6c3727919f38437bd501aecc438 packages/cli/tests/launcher.test.mjs
|
||||
2c1aad8d8be1a03be12eca93b4da36c656236ad38d09bfdc314e42a68240afc3 packages/harness/src/bundle.mjs
|
||||
22f9e3c1dce25e5eb6713306ccdf22dc77a4ccf0c99e9186f2e16602f58aeb49 packages/harness/src/tools.mjs
|
||||
305be35111c432a663d5671a4dac9bfe5bbb1a9b34ace77087e12c6f687602a1 packages/harness/tests/creative.test.mjs
|
||||
3d68294493e41db3b647cd71cd94436c28fc531c64a8b1589be7644c3bf59f94 packages/harness/tests/skills.test.mjs
|
||||
9425a319ec8966712fc38027bc62f7fbf52a216e30b9520cbd877386990c92d6 roles/companion.json
|
||||
2533b3ec4dfa78cd81b254c167865b984a6edf6b00454911363012ab5ac1e4c1 roles/companion.md
|
||||
cb2512590330efe4514c88eb31d09e3e3b159943803e462a224b77010c249de7 roles/continuity.json
|
||||
193c82fa803a85ee4c4b2f946ce2d69e94a192c00c5093d4db91cc274a5d11ed roles/continuity.md
|
||||
7a59218d903b26174c51f0118a347b0b14e187e2d0c229ca43c98dad357f3435 roles/fact-checker.json
|
||||
8895fbe1d7961bbb98b272cd888250d6ef44f74986948032719b44dc3dc56579 roles/fact-checker.md
|
||||
3a932eefe3af967d046d5e241d59f5b2700bd5ad4ef236d6a937011500200b5f roles/ideas.json
|
||||
8ce05e198cefb29f2aea53a25a2cd6599782b34c7a0815a2b6d26272c0fdae50 roles/ideas.md
|
||||
cb117d44e48f288048a4e697ee237b445b8fcea21bb95f9723e91b17ab542a3f roles/proofreader.json
|
||||
a2214ab481ef27d24c20da9e5e7bbc86c6ea769cacc8a163330813e75a20ec15 roles/proofreader.md
|
||||
95f5fc2bd2a0593177c21ca1898317c671ad04334e5c561c29917fe2ceae2c51 roles/research.json
|
||||
7a602b0efe44669028e52fc6aca83f00bd3dd32a105d0729538f2ed357f6ecd3 roles/research.md
|
||||
@@ -1,91 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Row 213 (C2-08) mutants across the changed sources. Run from the row213 worktree root, in Sage's slot.
|
||||
# Each mutant changes one anchor (count must be 1), runs the named test files alone, then restores.
|
||||
import os, shutil, subprocess, sys
|
||||
BAK = "/mnt/storage/scratch/morgana-r213/mutant-backups" # any scratch path outside the tree
|
||||
BIZ = "packages/business/src/business.mjs"
|
||||
LS = "packages/business/src/launch-settings.mjs"
|
||||
IS = "packages/business/src/instance-settings.mjs"
|
||||
ROLE = "packages/business/src/role.mjs"
|
||||
BUNDLE = "packages/harness/src/bundle.mjs"
|
||||
TOOLS = "packages/harness/src/tools.mjs"
|
||||
BROKER = "packages/bus/src/broker.mjs"
|
||||
LAUNCHER = "packages/cli/src/launcher.mjs"
|
||||
PI = "adapters/pi/adapter.sh"
|
||||
CLAUDE = "adapters/claude/adapter.sh"
|
||||
T_BIZ = [("business", "tests/business.test.mjs")]
|
||||
T_LS = [("business", "tests/launch-settings.test.mjs")]
|
||||
T_IS = [("business", "tests/instance-settings.test.mjs")]
|
||||
T_ROLE = [("business", "tests/role.test.mjs")]
|
||||
T_HC = [("harness", "tests/creative.test.mjs")]
|
||||
T_BUS = [("bus", "tests/creative.test.mjs")]
|
||||
T_LAUNCH = [("cli", "tests/launcher.test.mjs")]
|
||||
T_ADAPT = [("harness", "tests/creative.test.mjs"), ("harness", "tests/skills.test.mjs")]
|
||||
ABS = ' *) PROMPT_FILE="$(pwd)/$PROMPT_FILE" ;;'
|
||||
MUTANTS = [
|
||||
# D1 (deviation): persona vars only on a creative definition, both or neither.
|
||||
("D1a persona vars check not called", BIZ, " checkPersonaVars(out[instance].vars, entry.definition, at);\n", "", T_BIZ + T_IS),
|
||||
("D1b any definition may carry persona vars", BIZ, " if (!CREATIVE_DEFINITIONS.includes(definition)) refuse(", " if (false) refuse(", T_BIZ + T_IS),
|
||||
("D1c one persona var alone accepted", BIZ, " if (set.length === 1) refuse(", " if (false) refuse(", T_BIZ),
|
||||
# D2 (deviation): the launcher hands buildBundle the persona directory.
|
||||
("D2 launcher passes no persona directory", LAUNCHER, ' personaDir: join(configDir(env), "personas"),', " personaDir: null,", T_LAUNCH),
|
||||
# M10: the human's settings.
|
||||
("L1 models-key change by anyone", LS, " if (was !== is) creativeHumanOnly(", " if (false) creativeHumanOnly(", T_LS),
|
||||
("L2 fallback.model key not checked", LS, ' for (const key of [vars.model, vars["fallback.model"]]) {', " for (const key of [vars.model]) {", T_LS),
|
||||
("L3 instances key allowed for a creative instance", LS, " if (CREATIVE_DEFINITIONS.includes(next.roles[instance].definition)) {\n throw new OrganizationError(\"organization-creative-instance\"", " if (false) {\n throw new OrganizationError(\"organization-creative-instance\"", T_LS),
|
||||
("L4 setRoutes by anyone", LS, " creativeHumanOnly(old.document, actor, instance, next.roles[instance].definition);", "", T_LS),
|
||||
("L5 creativeHumanOnly never refuses", LS, " if (CREATIVE_DEFINITIONS.includes(definition) && actor !== document.human) {", " if (false) {", T_LS + T_IS),
|
||||
("I1 add of a creative instance by anyone", IS, " creativeHumanOnly(old, actor, instance, args.definition);\n definitionFor(rolesDir, args.definition);\n next.roles[instance] =", " definitionFor(rolesDir, args.definition);\n next.roles[instance] =", T_IS),
|
||||
("I2 change of a creative instance by anyone", IS, " creativeHumanOnly(old, actor, instance, entry.definition);\n", "", T_IS),
|
||||
("I3 redefine to creative by anyone", IS, " creativeHumanOnly(old, actor, instance, args.definition);\n", "", T_IS),
|
||||
# M1: role files.
|
||||
("R1 checkCreative not called", ROLE, "CREATIVE_DEFINITIONS.includes(role.name) ? checkCreative(role) : role", "role", T_ROLE),
|
||||
("R2 wider tools accepted", ROLE, " if (wider.length > 0) refuse(", " if (false) refuse(", T_ROLE),
|
||||
("R3 non-human messaging accepted", ROLE, ' if (role.messaging[key].some((entry) => entry !== "human")) {', " if (false) {", T_ROLE),
|
||||
# Persona file and block.
|
||||
("B1 group/other mode bits accepted", BUNDLE, " if (stat.mode & 0o077) throw", " if (false) throw", T_HC),
|
||||
("B2 digest not checked", BUNDLE, " if (sha256(data) !== digest) {", " if (false) {", T_HC),
|
||||
("B3 headings and end tag accepted", BUNDLE, " if (text.includes(PERSONA_END) || /^##? /m.test(text)) {", " if (false) {", T_HC),
|
||||
("B4 symlink followed", BUNDLE, "constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK", "constants.O_RDONLY | constants.O_NONBLOCK", T_HC),
|
||||
("B5 character cap off", BUNDLE, " if ([...text].length > PERSONA_CHARS) throw", " if (false) throw", T_HC),
|
||||
("B6 persona block dropped", BUNDLE, " ...personaSection(resolved, persona),\n", "", T_HC),
|
||||
("B7 no personaDir with a digest accepted", BUNDLE, " if (digest === undefined) return null;\n throw new BundleError(", " return null;\n throw new BundleError(", T_HC),
|
||||
("B8 creative role on harness-builtin web", BUNDLE, ' if (granted && source === "harness-builtin" && CREATIVE_DEFINITIONS.includes(resolved.definition)) {', " if (false) {", T_HC),
|
||||
# M6.
|
||||
("T1 raise_decision offered to creative roles", TOOLS, ' if (!CREATIVE_DEFINITIONS.includes(resolved.definition)) add("raise_decision"', ' add("raise_decision"', T_HC),
|
||||
("K1 broker creative raise check off", BROKER, " if (request.verb === 'decision.raise' && !s.human && !s.tester &&\n CREATIVE_DEFINITIONS", " if (false &&\n CREATIVE_DEFINITIONS", T_BUS),
|
||||
# B1 (webby-0040): the broker names the writer's actor from the session.
|
||||
("K2 chief session reaches the writer as the human", BROKER, 'actor = this.#business(s.business).roles[s.role].definition + "." + s.role;', 'actor = "jason";', T_BUS),
|
||||
("K3 tester session reaches the writer as the human", BROKER, 'let actor = s.tester ? "tester" : s.human;', 'let actor = s.tester ? "jason" : s.human;', T_BUS),
|
||||
("V1 persona.name line separators accepted (N3)", "packages/business/src/vars.mjs", "\\u007f\\u2028\\u2029]", "\\u007f]", T_BIZ),
|
||||
# Prompt by file (adapters).
|
||||
("A1 pi prompt check without -f", PI, '[ -f "$PROMPT_FILE" ] && [ -r "$PROMPT_FILE" ] ||', '[ -r "$PROMPT_FILE" ] ||', T_ADAPT),
|
||||
("A2 pi relative prompt path kept", PI, ABS, " *) ;;", T_ADAPT),
|
||||
("A3 claude relative prompt path kept", CLAUDE, ABS, " *) ;;", T_ADAPT),
|
||||
]
|
||||
os.makedirs(BAK, exist_ok=True)
|
||||
files = sorted({m[1] for m in MUTANTS})
|
||||
orig = {}
|
||||
for f in files:
|
||||
bak = os.path.join(BAK, f.replace("/", "__"))
|
||||
shutil.copyfile(f, bak)
|
||||
orig[f] = open(bak).read()
|
||||
if orig[f] != open(f).read(): sys.exit(f"backup mismatch for {f}, abort")
|
||||
for name, f, a, b, _ in MUTANTS:
|
||||
if orig[f].count(a) != 1: sys.exit(f"{name}: anchor count {orig[f].count(a)}, abort")
|
||||
killed = 0
|
||||
try:
|
||||
for name, f, a, b, tests in MUTANTS:
|
||||
open(f, "w").write(orig[f].replace(a, b))
|
||||
failing = []
|
||||
for pkg, t in tests:
|
||||
p = subprocess.run(["node", "--test", "--test-reporter=tap", t], cwd=f"packages/{pkg}", capture_output=True, text=True, timeout=600)
|
||||
failing += [f"{pkg}/{t.split('/')[-1]}: {l[7:]}" for l in p.stdout.splitlines() if l.startswith("not ok")]
|
||||
open(f, "w").write(orig[f])
|
||||
status = "KILLED" if failing else "SURVIVED"
|
||||
killed += bool(failing)
|
||||
print(f"{status} {name}", flush=True)
|
||||
for line in failing: print(f" {line}", flush=True)
|
||||
finally:
|
||||
for f in files: open(f, "w").write(orig[f])
|
||||
print(f"killed {killed}/{len(MUTANTS)}; restored: {all(open(f).read() == orig[f] for f in files)}")
|
||||
@@ -1,8 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,149 +0,0 @@
|
||||
# Row 218 draft notes: claims and sources
|
||||
|
||||
Drafts for `docs/case2/SETUP-SHEET.md` and `docs/case2/SURVEY-RUN.md`. Repo base: `refactor` at
|
||||
947101d8a, which holds 29ce1e360 (row 231). "Design" means
|
||||
`git show 75fb3e10e:agents/gosalyn/work/case2/DESIGN.md`, cited as `D:<line>`. "Brief" means
|
||||
`docs/plans/2026-10-20_case2-rows.md`. Code paths are relative to the repo root. Nothing was run
|
||||
except reads, greps and `git merge-base --is-ancestor 29ce1e360 HEAD`.
|
||||
|
||||
## SETUP-SHEET.md
|
||||
|
||||
| Claim | Source |
|
||||
|---|---|
|
||||
| She keeps writing in Obsidian, obsidian-git pushes to git.mosaicstack.dev, nothing changes on her machine, nothing commits to her repo | D:939-940 (7.1) |
|
||||
| Agents read what she pushed; a push reaches them in about a minute | D:1006-1007 (7.5); D:972-973 (60-second sync timer) |
|
||||
| Agents read `.md` notes inside book folders only | D:457-461 (1.2); `packages/library/src/scope.mjs:9-19` |
|
||||
| Not read: top-level notes, `.canvas`, `.base`, attachments, anything outside a book folder | D:461 |
|
||||
| Other repos under `~/src` are not read | D:489 (1.3), D:494-497 (N4) |
|
||||
| Agents can't write to her repo: canon, playground, anything | D:389 (D3); D:1008 |
|
||||
| Folder roles live in the `projects` entry of the business file, hand-edited, agents can't change it | D:409-429, D:443-451 (1.1, M9) |
|
||||
| Jason asks her to confirm the mapping first | D:431; D:1324-1328 (10 step 7) |
|
||||
| A folder named in `playground` is her playground | Brief:22; D:434 |
|
||||
| Each playground prefix sits inside one book folder | D:434-436; `packages/library/src/mapping.mjs:77-80` |
|
||||
| One per book, e.g. `<book>/playground/`; until then everything mapped is canon | D:438-439 |
|
||||
| Agents search canon unless they ask for the playground | D:463 |
|
||||
| Playground may back an idea or question, not a fact, finding or correction; refused before she sees it | D:858 (`playground-cited-as-canon`); D:378-382, D:835-839 (guard runs before store) |
|
||||
| Only the folder decides; a tag or property can't change scope | D:388 (D2); D:440-441; `scope.mjs:1` |
|
||||
| Graph colour group via Groups and a `path:` query, her own vault setting, changes nothing for agents | Brief:23; D:941-943. Obsidian UI steps from the task prompt, not the design |
|
||||
| Path starts with the books repo folder because the vault is `~/src` | D:939 |
|
||||
| N2: moving a note into a book folder makes it canon, agents may cite it as fact | D:944-945; D:259 |
|
||||
| Rough drafts outside the playground are canon | Inference from D:460 (scope rule) |
|
||||
| N4: Obsidian resolves short links across `~/src`, the reader sees one repo, the two can differ, the reader can't tell | D:494-497; D:262-263 |
|
||||
| A name only in another repo leads nowhere for agents | D:489 (R4); `packages/library/src/links.mjs:61-87` |
|
||||
| A full-path link resolves the same in both | D:497-498. Hub side: `links.mjs:77-78` (`vault-prefix` step) |
|
||||
| "New link format: Absolute path in vault" setting | Obsidian UI knowledge. Not in design or code. Reviewer may strike it |
|
||||
| `appears_in` and `relationships` links are read as links | D:489-490; `packages/library/src/reader.mjs:15`, `:153-162` |
|
||||
| Quoted flow list `["[[A]]", "[[B]]"]` parses | `reader.mjs:81-96` (flow), `:66-73` (quoted scalar), `:159` |
|
||||
| Block list with quoted items parses | `reader.mjs:117-122`, `:66-73` |
|
||||
| Unquoted `[[[A]], [[B]]]` is R3 | Brief:28-29; `reader.mjs:87` (bracket inside a flow list stops parsing), `:137` |
|
||||
| R3 effect: whole front matter unread, note still indexed by path, name and body, listed as a problem | D:480; `reader.mjs:103-105`, `:193`; `packages/library/README.md:69` |
|
||||
| Problems show in Mosaic's status | D:473-474; D:999-1000 (7.4 status line) |
|
||||
| A value without `[[ ]]` is a name | D:489-490; `reader.mjs:159` |
|
||||
| Bean and Chet keep names and personalities | D:660-661 (Q180) |
|
||||
| Hermes: free text and story text. Mosaic: short cited items, a prose request gets a question or idea | D:778-781 (design 4) |
|
||||
| Bean and Chet's item kinds: idea, question, finding, fact; web cites allowed | D:649 (`companion`); D:860 (`citation-unfetched`) |
|
||||
| Limits 10 items, 5 ideas of 200, others 300 | D:819 (M5) |
|
||||
| She or Jason can ask for lower limits | D:1329-1330 (10 step 8); D:831 |
|
||||
| "No cited answer" notice | D:870-871, D:996-997 |
|
||||
| One item can't mix two worlds | D:859 (`citation-cross-universe`) |
|
||||
| She talks to them in the Console; Hermes keeps running until she moves over; stopping it is her choice | D:381-382; D:1331 |
|
||||
| Five more helpers; she chooses the start set and can add one later | D:647-654; D:783-784; D:1327-1328 |
|
||||
| What Jason will ask | D:1325-1327 (10 step 7) |
|
||||
|
||||
## SURVEY-RUN.md
|
||||
|
||||
| Claim | Source |
|
||||
|---|---|
|
||||
| Runs on `<host>` 10.1.1.54 under design 10 step 7, after step 4 | D:1324; D:1266 (host) |
|
||||
| Prints names and counts, never note text, property values, file names or link targets | `packages/library/src/survey.mjs:1-6`; README:118-125 |
|
||||
| Sending to Melanie is Jason's call | Brief:36; D:1324-1328 |
|
||||
| Token slot `library-gitea` is step 4 | D:1314-1316 |
|
||||
| Run as `sudo -u mosaic -H` from `localadmin`; `mosaic` has no login shell | D:1195-1197; D:1281-1282 |
|
||||
| Copy at `/srv/mosaic/.local/share/mosaic-stack/copies/<id>`, `<id>` = commit in Jason's go | D:1286-1292 (2c); `docs/TOOLS.md:417`; home `/srv/mosaic` per D:1314-1315 |
|
||||
| The copy carries `packages/library/src` | `scripts/stack-copy.sh:36` |
|
||||
| Row 231 adds the control and bidi escapes | `git log` 29ce1e360; `survey.mjs:78-82`; README:121-122 |
|
||||
| `<node dir>` is the `--node` directory of step 2c; `scripts/mosaic` execs `node` from PATH | D:1290-1291; `scripts/mosaic:41` |
|
||||
| Dispatch: `mosaic library survey` goes to `survey.mjs` with `survey <args>` | `scripts/mosaic:37-41` (one `shift` leaves `survey` as argv[0]); `survey.mjs:102-103` |
|
||||
| Command and flags: `<repo>` plus optional `--mapping <file>` once; anything starting with `-` refuses | `survey.mjs:16`, `:104-110` |
|
||||
| No fetch; reads `HEAD` | `survey.mjs:5-6`, `:27`; `reader.mjs:22-24` |
|
||||
| Top folder only: ceiling is the path's parent | `packages/project/src/git.mjs:12`, `:62` |
|
||||
| Owner must be `mosaic`: git gets no global config | `packages/seat/src/checkout.mjs:79-89` (`GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`). The dubious-ownership refusal is git's own behaviour |
|
||||
| Bus mirror path `<dataRoot>/projects/<business>/<project>` | Brief:300; `packages/project/src/paths.mjs:34-40` |
|
||||
| C2-04 and C2-03a not landed | `grep` of `packages/*/src` finds no library entry handling or `refs/heads/served`; `docs/guides/host-run-sheet.md:269` says "not landed" |
|
||||
| Served ref `refs/heads/served` | D:963 (7.2); Brief:676 |
|
||||
| Token kept out of URL, config, argv, logs | D:955-956 (7.2) |
|
||||
| Mapping file is the entry alone; `remote`, `branch` required | `survey.mjs:114-116`; `mapping.mjs:38-47` |
|
||||
| Output lines and fields | `survey.mjs:83-97`; data from `:26-76` |
|
||||
| Folder lines list folders that directly hold an entry, full path | `survey.mjs:30-35` |
|
||||
| Key names: short identifiers print, others as `(other key)` | `survey.mjs:17-20`, `:47` |
|
||||
| `unresolved` equals the R4 count | `survey.mjs:72` |
|
||||
| Scope counts cover all tree entries | `survey.mjs:66` |
|
||||
| Refusal exit codes 0, 2, 3, 4 | `survey.mjs:99-124`; README:127-129 |
|
||||
| Mapping checked before repo | `survey.mjs:112-121` |
|
||||
| stderr format `library survey: <message>` | `survey.mjs:101`; `problems.mjs:3-5` |
|
||||
| `(project-git)` suffix on git failures | `reader.mjs:18-20`; `paths.mjs:4-6` |
|
||||
| `library-playground-outside-book` message | `mapping.mjs:79-80` |
|
||||
| 100-second git timeout | `checkout.mjs:49`, `:52`; `git.mjs:68` |
|
||||
| Exit 127 when `node` is missing | `scripts/mosaic:18`, `:41` (bash `exec` failure). Not run |
|
||||
| Other errors rethrow as a stack trace | `survey.mjs:117`, `:123`, `:128-131` |
|
||||
| Don't work around a refusal | AGENTS.md invariant 6 |
|
||||
|
||||
## Code and design disagreements
|
||||
|
||||
1. Design 1.1 shows the mapping nested in `"projects": {"books": {...}}` (D:412-429). The survey's
|
||||
`--mapping` file must be the inner entry alone (`survey.mjs:114-116`). The run sheet follows the code.
|
||||
2. Design 1.3 calls `vaultPrefix` part of the mapping (D:418). The code makes it optional
|
||||
(`mapping.mjs:48`, README:30). Without it, a full vault path link, the N4 advice, resolves to
|
||||
nothing in the hub (`links.mjs:73-87`: `root` fails, `vault-prefix` is skipped, `shortest` refuses a
|
||||
`/`). The setup sheet's full-path advice holds only when Jason sets `vaultPrefix` to her books repo
|
||||
folder name.
|
||||
3. The task prompt calls the unquoted form "refused". R3 is not a refusal. It is a reported problem,
|
||||
and the note stays indexed (D:473, D:480). The sheet says what happens instead.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Where `<repo>` comes from on .54. The survey reads a local repo's `HEAD` and never fetches. The bus
|
||||
mirror needs C2-04 and C2-03a, neither landed. C2-03a moves `refs/heads/served`, not `HEAD`, so the
|
||||
mirror's `HEAD` may name no commit and the survey would refuse with exit 2. The sync also needs a
|
||||
checked mapping with at least one book (`mapping.mjs:59`, `:70`), and the mapping's values are
|
||||
meant to come from this survey (D:431). Sage needs to rule: a placeholder mapping for the first
|
||||
sync, a `HEAD` the sync sets, a `--commit` flag on the survey, or another source.
|
||||
2. The brief says Obsidian's property editor writes `appears_in: ["[[A]]", "[[B]]"]` (Brief:28-29,
|
||||
webby-0041 N4). I didn't read webby-0041 or check Obsidian's output. Both the flow and the block
|
||||
quoted forms parse, so the sheet shows both and says only that the editor adds the quotes.
|
||||
3. Whether `/srv/mosaic/.npm-global/bin/mosaic` exists on .54. The run sheet uses the copy's
|
||||
`scripts/mosaic` by full path to avoid depending on a wrapper.
|
||||
|
||||
## Sage's rulings on the open questions (2026-10-21)
|
||||
|
||||
1. Survey source: the bus's bare mirror after the first `library.sync`, the only copy on the host
|
||||
(design 2.1). The C2-03a brief now requires the mirror's `HEAD` to be a symbolic ref to
|
||||
`refs/heads/served`, with a test (5c6f73ed7), so the survey reads the served commit with no ref flag.
|
||||
2. Property list forms: the sheet shows both quoted forms, since both parse. Webby checks the claim
|
||||
that Obsidian's property editor writes the quoted form.
|
||||
3. The "New link format: Absolute path in vault" tip stays. It is an Obsidian setting, not a design
|
||||
claim, and Webby may strike it.
|
||||
4. The run sheet calls the copy's `scripts/mosaic` by full path. That holds whether or not a wrapper
|
||||
is on `PATH`.
|
||||
5. `vaultPrefix`: the run sheet's step 4 tells Jason to set it, because the setup sheet's full-path
|
||||
link advice depends on it.
|
||||
|
||||
## Round 2 (2026-10-21), from Webby's round 1 notes (webby-0075, rev 2848)
|
||||
|
||||
- N1, by Mos's ruling mos-1165 (fix before landing): SETUP-SHEET.md now gives the 1,200 character
|
||||
total of item text per reply (design M5, step 8). The word "Today" is gone. No code enforces these
|
||||
caps until C2-06 lands, so my ruling is that the sheet goes to Melanie only after C2-06 lands, and
|
||||
then only on Jason's go (#419). The wording stays as is, because by then it holds.
|
||||
- N2: SURVEY-RUN.md says tab, newline, carriage return, backspace and form feed print as `\t`, `\n`,
|
||||
`\r`, `\b` and `\f`, and other control and bidi characters as `\uXXXX` (survey.mjs quote()).
|
||||
- N3: SURVEY-RUN.md has a refusal row for `library-commit:` (reader.mjs listTree). survey.mjs turns
|
||||
every LibraryError into exit 2 with its message.
|
||||
- N4: SURVEY-RUN.md's new step 2 checks `command -v node` under the same sudo and PATH before the
|
||||
survey runs.
|
||||
|
||||
## Round 3 (2026-10-21), from Webby's round 2 note N5 (webby-0077, rev 2856)
|
||||
|
||||
- N5: the library-commit row no longer says a moved `HEAD` causes it. The survey reads `HEAD` once,
|
||||
and listTree raises library-commit only for an id that isn't 40 hex or an object that isn't a
|
||||
commit (reader.mjs:29, :31). In practice that means a SHA-256 repository, so a rerun doesn't help.
|
||||
The row now says "for example in a SHA-256 repository" and "Report to Sage".
|
||||
@@ -196,37 +196,3 @@ 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).
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
// 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);
|
||||
@@ -1,53 +0,0 @@
|
||||
// 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);
|
||||
@@ -1,66 +0,0 @@
|
||||
// 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));
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
# 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: `c771a36f4bd0b6c359282425c668996a90072f93`, the row 234 landing (2026-10-21 06:16Z), unless Mos rules a
|
||||
later code landing. 194 is the open candidate. 210, 237, 232, 241, 242, 233 and 234 have landed.
|
||||
- REPO, `P`, `WIN=agents/sage/work/window-repin-<NEW12>/`, `SNAP`: as in `repin-4429a104f.md`, with the
|
||||
sha checks at P0 and before S1, S2 and any D3.
|
||||
|
||||
## What NEW carries over OLD (refactor c771a36f4)
|
||||
|
||||
`git log --first-parent 4429a104f..HEAD`, path-limited to packages, scripts, roles, contracts, adapters, skills
|
||||
and the package files:
|
||||
|
||||
| Row | Commit | Live effect to check |
|
||||
|---|---|---|
|
||||
| 201 | afad4993f | business: access.json loader. Check: the live business has no access.json, and load stays LOAD_OK (P1). |
|
||||
| 204 | d0bbc5c1c | business: 193a viewer level. Same check. |
|
||||
| 190 | 00d4cc1f6 | Several instances per host. Touches `stack-release.sh`, `webui-service.sh`, `bus-service.sh`, `stack-copy.sh`. The default instance must render the same units: P2 diffs the rendered units OLD vs NEW for `mosaic-stack`, and any difference stops the window until it's explained. |
|
||||
| 200 | 9e1085816 | Tool: `activate --sso`. Off here. `--plan` must show no `--sso` line. |
|
||||
| 199 | cb5fdb064 | Credential store per agent. Check: bus start with no store entries for the live agents changes nothing, and the chief's and pm's launches still pass. The P3 rehearsal covers start. |
|
||||
| N3 | 390ea5261 | README only. |
|
||||
| 230 | c8bc5cf4c | Case 2 scripts, tools only. Not run live. |
|
||||
| 210 | f5753c0e8 | Host access. The live business has no `hosts`, so nothing is granted. Bus start runs the host-run sweep (M34d). P3 must show it removes nothing. |
|
||||
| 237 | d7d943199 | Publish busy check (199 N1). |
|
||||
| 232 | e65a3ab4d | Installer I1: `stack-release.sh --units external` and `stack-copy.sh copy --bundle`, `wrapper --create`. Both are opt-in, and this window uses neither. The default units path must render the same units, so the P2 unit diff covers it with row 190. P0 checks that no window command line carries `--units external`. |
|
||||
| 241 | 5d4a61776 | Test only (bus land.test.mjs). No live effect. |
|
||||
| 242 | 5fb736b92 | Bus publish hold: after publish-timeout the bus holds the decision until its push ends, then records a late push. The live business has no publish in flight at W1, and P3 shows none open. Its follow-up fc1cb856b is comments only (git-socket.mjs). |
|
||||
| 233 | 57f59e0c3 | Installer I3: new tool `scripts/stack-upgrade.sh` and a `stack-release.sh` change. This window doesn't run `stack-upgrade.sh`. The P2 unit diff covers the `stack-release.sh` change with rows 190 and 232. |
|
||||
| 234 | c771a36f4 | Machine grant row a and the 3i to 3j schema step (new tables `hosts` and `host_grants`). `--plan` must say `migration: 3i -> 3j`. |
|
||||
| PRD 1.7+1.8 | 0adf8ddfe | `docs/prd/mosaic-stack.md` and `packages/tasks/requirements.json` move to version 1.8 (mos-1154, mos-1156, DECISIONS Q192). The live pm cites 1.6 until this window. Check after: the pm's requirements read answers version 1.8. |
|
||||
|
||||
Service script drift OLD to HEAD is 415+/91- over the four scripts, mostly row 190. P5 holds them at NEW.
|
||||
|
||||
## Backups and the migration
|
||||
|
||||
- W5: the stopped store backup that every activate takes (row 185), with SHA256SUMS, SCHEMA 3i and STAMP,
|
||||
plus the rollback set. Before `--migrate` writes, I check that SCHEMA reads 3i and the backup SHA256SUMS hash.
|
||||
- W0b: the business file copy, as in the last window.
|
||||
- Rollback after a migration isn't the plain rollback. OLD is 3i and the store is 3j, so going back needs
|
||||
`--restore <W5 activation>`, and that loses the events written after W5. The row 181 restore guard counts
|
||||
11 tables, and the untimed ones need the D3 snapshot. Row 234 adds `hosts` and `host_grants` to the untimed
|
||||
list (T46), so at 3j the D3 snapshot covers them too. Draft 1 recounts the list from NEW. Mos decides any restore past W5, as before.
|
||||
- Grant look-alikes (row 234 A22b, gizmoduck-0107). `3i-3j.sql` adds `events_grant_actor` but doesn't check
|
||||
rows already in the store, so after the migration only the actor filter in `GRANT_EVIDENCE` (actor_role NULL,
|
||||
actor_run 'grant') ignores an old session-written row. P3, and again just before `--migrate`: on the
|
||||
backup copy, count the `action.allowed` events whose body.action or body.operation starts with `grant.`, or
|
||||
whose body has `hostLineLeft`. Expect 0, since no grant code has run live. Any nonzero count stops the window
|
||||
until I've read those rows.
|
||||
- P3 is the rehearsal with `--migrate` on a copy of the live backup. It must show the 3j step and the row
|
||||
counts before and after.
|
||||
|
||||
## Row 42 (S7) interaction
|
||||
|
||||
Row 42's pieces may be in flight at window start (sage-1622, mos-1148 C1 and C2). W1 adds: list open pm tasks, and either pm
|
||||
finishes or parks them before W5, or I record which piece crosses the window. No restore while a piece has
|
||||
history unless the window fails.
|
||||
|
||||
## Open before draft 1
|
||||
|
||||
- Q1, ruled by mos-1161: draft 1 doesn't wait for 194. NEW is the latest code landing when I cut draft 1. If
|
||||
194 has landed with a green full gate (15 packages, ten script suites), NEW is the 194 landing and P3 also runs
|
||||
the lifecycle verbs once on the backup copy and gets one pm reply under the new roles/chief-of-staff files. A
|
||||
P3 failure that traces to 194 drops NEW back to c771a36f4 before anything touches live, and the window goes on.
|
||||
Otherwise NEW is c771a36f4 and 194 rides a later window.
|
||||
- Q2, settled by mos-1148: phase 2 is its own window on 10-23. Draft 1 takes OLD and the Console flags,
|
||||
`--sso` included, from `status` after that window.
|
||||
- Q3: who operates. If Darkwing is free, Darkwing operates and I check. Otherwise I operate as in the last
|
||||
window.
|
||||
- The P-step evidence list for rows 199 and 210 at bus start. Both have landed (cb5fdb064, f5753c0e8), so
|
||||
it's due in draft 1. I take each log line and exit code from a P3 rehearsal run, not from the code.
|
||||
- Rows 241 and 242 (bus): P3 shows no publish or land decision open before W5.
|
||||
@@ -1390,32 +1390,3 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-20T23:21:32Z | Dewey (Opus 5.5, 84fb346c) | row 66 (#1559) round 3 review, rebase of r2 onto c40d846cb (sage/row66-r3 2369c00e3, manifest 42e3876ca168) | APPROVE 29771, rev 2529: patch-id 6d085621 equals r2, range-diff =, 23 manifest hashes match, no drift past the base, four drifted files sit beside rows 190/192 cleanly, no hash pins; singles are Sage's (not rerun). Also #436b Q-B4 answered by mos-1147, verdict addendum
|
||||
2026-10-20T23:31:31Z | Dewey (Opus 5.5, 84fb346c) | #436b design r3 review (megavolt-0050, DESIGN.md fa5658941) | CHANGES: restart reconcile spends step 0 refusals (#436a r6 X3 conflict), settle unknown id keeps unverified token in use; N7 secretCheck; B1-B3, N1-N6, D12 closed; verdict design-r3-verdict.md 4c87d859; dewey-0759 Megavolt, dewey-0760 Sage |
|
||||
2026-10-20T23:32:42Z | Sage (Claude Opus 5.5, 1ef1e4f8) | machine grant rows, slot relay, queue sweep | rows 234 (#1740, 3i-3j) and 235 (#1741) added, row 206 moved to 3j-3k after 234 (f519fb1d2); row 199 r1 request as jarvis 29778 (manifest 35f900fd rebuilt from git objects); #436b r3 item 1 ruled (b), sage-1578; queue-commit 1c46d0abf revs 2456-2539; slot Megavolt to Gosalyn row 230
|
||||
2026-10-21T01:28:05Z | Darkwing (Opus 5.5, 7525c8e4) | row 232 I1 build (sage-1558, sage-1561 ruling a) | rebased onto cb5fdb064 (row 200), gate green and 17/17 mutants killed on Sage's slot; in review round 1, manifest 7a3b4ea1 at 84f1a91e6 on darkwing/row232-r1, comment 29825, revs 2595-2596; Filbert woken
|
||||
2026-10-21T01:34:13Z | Filbert (Opus 5.5, 048f3c63) | row 232 installer I1 review r1 (#1734, manifest 7a3b4ea133cc) | APPROVE, Gitea 29829, queue rev 2600 (left for Sage's queue-commit); static review, gate and mutants are Darkwing's receipts; 3 non-blocking notes
|
||||
2026-10-21T01:49:55Z | Sage (Opus 5.5, 1ef1e4f8) | row 237 landed d7d943199 (#1743, gate green, M1 killed); PRD 1.7+1.8 landed 0adf8ddfe (Honker 2884ee496, Duckworth approve bf52628f9, tasks 97/0 bus 478/0 on the tip, DECISIONS Q192); rows 239 (#1745) and 240 (#1746) added; 233 briefed; queue revs 2540-2603 committed 584e2bd82 | landed, push next
|
||||
2026-10-21T02:51:46Z | Sage (Opus 5.5, 1ef1e4f8) | row 210 landing (r5 0485384e9, Duckworth r3 29851) | landed f5753c0e8, patch-id 723b1c83 = r5 diff, manifest 688d38b9 OK; gate 2796/0 packages, 429/0 scripts; mutants 52/53 (M22b equivalent); #1707 closed (29853), Vikunja 424 comment 429; queue revs 2604-2617 c084d8d42; row 234 briefed; Megavolt has the slot for 241 (sage-1668)
|
||||
2026-10-21T03:01:43Z | Filbert (Opus 5.5, 048f3c63) | row 232 installer I1 review r2, rebase scope (#1734, manifest 7af3c94308e0, da2da2f6c on 8fede5fc6) | APPROVE, Gitea 29865, queue rev 2622 (left for Sage's queue-commit); README = predicted 3-way blob 89a95b32, 7 blobs same as r1, BUILD.md +31/-0; corrected my r1 reader claim (host.test.mjs:855 reads cli README)
|
||||
2026-10-21T04:14:49Z | Sage (Opus 5.5, 1ef1e4f8) | lead: rows 241 and 242 landed, slot order | row 241 landed 5d4a61776 (#1747 closed 29883, M1-M3 killed, gate 2797/0 + 429/0); row 242 landed 5fb736b92 (#1748 closed 29894, Duckworth approve 29889, gate 2826/0 + 429/0); late-record notice ruled a follow-up (Vikunja 440 c432); row 233 brief re-pinned rev 2631; carry items on #1744 (29885) and #1734 (29886); queue revs 2628-2638 committed 96bac588f
|
||||
2026-10-21T04:50:44Z | Darkwing (Opus 5.5, 7525c8e4) | row 233 I3 gate, mutants and review request (sage-1704, sage-1707, sage-1708) | gate green on d99166ade (1201/0, cli 616/0) and m1-m8 8/8 killed; N1 flock-holder orphan fixed test-only in 08db6ac3f, rerun c0 20/0 + m1 killed, sweep 0 left; in review round 1, manifest 464ce9eb at 1a1dc80ca on darkwing/row233-r1, comment 29901, revs 2644 and 2647 (lock race resolved); Filbert woken
|
||||
2026-10-21T04:58:01Z | Filbert (Opus 5.5, 048f3c63) | row 233 (I3, #1735) review round 1 on manifest 464ce9eb177a (1a1dc80ca) | APPROVE, Gitea 29922, rev 2659 (left for Sage); static, no slot; 5 non-blocking notes N1-N5 (args-failed exit 1, REPO ownership, signals, run sheet npm/claude path, sections 8/10 precheck)
|
||||
2026-10-21T05:47:45Z | Sage (Opus 5.5, 1ef1e4f8) | rows 233+234 landing gate, row 233 landing, 240 brief, drift sweep (sage-1742..1758) | combined gate stopped on two row 234 failures (bus schema.test.mjs:522 counts, cli stack-release T46 restore-guard lists; mos-0823); 233 landed alone 57f59e0c3 (patch-id b16ac924, verify-commit ok, cli 617/0, scripts 585/0), pushed with b00d7d94d, 5465fd52d and riders 642a9b87d, 16019b80e; #1735 closed 29931, done rev 2675, Vikunja 432 comment 436; 240 briefed rev 2676; 234 r3 c783d5e39 rebuilt on the landing in Gizmoduck's slot; 197 and 238 drifted, rebase sent (sage-1754, 1755, 1757 stash fix)
|
||||
2026-10-21T06:20:30Z | Sage (Opus 5.5, 1ef1e4f8) | row 234 r3 landing gate (2890/0 packages, 585/0 scripts) and landing c771a36f4, pushed 06:16Z; #1740 closed 29938, Vikunja 436 c437; 235 briefed rev 2686, rotateBy ruling; 238/228/197 rebuilt on 234, slot order Darkwing 240 then 238, 228, 197; 10-24 draft NEW c771a36f4 (sage-1766..1787) | landed
|
||||
2026-10-21T06:37:41Z | Darkwing (Opus 5.5, 7525c8e4) | row 240 I4 build, slot run and review request (sage-1759, sage-1773, sage-1788, sage-1795) | r1 on 47ec9fe2f stopped at singles (stack-upgrade 20/1: the N1 test's own after hook threw ENOENT once the fixture removed the root), b45852daa drops it; r2 green: singles 37/0, M1-M8 8/8 killed, cli 639/0, scripts 585/0; darkwing/row240-r3 pushed at ad1b23ab2 on c771a36f4, manifest 573ece95 (9 paths), in-review revs 2694 and 2695, request comment 29943 on #1746, Filbert woken | in review
|
||||
2026-10-21T06:41:13Z | Filbert (Opus 5.5, 048f3c63) | row 240 (I4, #1746) round 1 review on manifest 573ece95 (ad1b23ab2) | approve, Gitea 29945, rev 2696; 233 carries N1/N4/N5 present; notes N1 boot unit Before= names one business, N2 doc-only, N3 info
|
||||
2026-10-21T07:15:48Z | Darkwing (Opus 5.5, 7525c8e4) | row 195 (#1693) DESIGN r7, Case 1 install on .53 after the installer split | darkwing/row195-r7 5425ea2f3 pushed (r6 carried as 7398b7dd7 on c515273db), DESIGN.md blob 17dfbad95ba5; business id mosaic-stack named in §7, so DEFERRED 240 N1 doesn't apply (sage-1813); root steps left to ops-01, nothing run on .53 (sage-1809); Duckworth review requested 07:14Z, Gitea 29960; row 226 items (dev.args writer, adopt) sent to Gosalyn and copied to Sage per sage-1814; r7 :21 and :326 still say 226 is unclaimed, to fix next revision
|
||||
2026-10-21T07:29:11Z | Sage (Opus 5.5, 1ef1e4f8) | row 240 landing gate (cli 639/0, scripts 585/0) and landing c515273db with DEFERRED da3051ccc; 238 r3/r4 and 228 r3 requests posted as jarvis; combined 228+238 gate (seat 165/0, cli 640/0, scripts 585/0, 238 mutant killed), landings 86249ac55 and d49b1f2bd, DEFERRED e7078e6a8; #1746, #1729, #1744 closed; 197 r2 request posted (29961), r5 rebuild on 238; 195 after 226, Duckworth r7 note posted (29962); slots Herb 197, Gosalyn 212 (sage-1788..1826) | landed
|
||||
2026-10-21T08:29:39Z | Filbert (Opus 5.5, 048f3c63) | row 215 (C2-04) manifest review, herb/row215 6a508804f | CHANGES REQUESTED, static: B1 cli config.mjs:61 trackerFor projectFilePath skip, B2 refuse publish naming a library entry, B3 sync-to-read window in the shared state contract; N1-N3; sent to Herb and Sage 08:29Z
|
||||
2026-10-21T08:37:47Z | Filbert (Opus 5.5, 048f3c63) | row 215 (C2-04) manifest r2 review, herb/row215 2522025af | APPROVE, static: B1-B3 and N1-N2 resolved per sage-1860/1862; build notes on lock file creation, mirror metadata allowlist and flock dependency; sent to Herb and Sage 08:37Z
|
||||
2026-10-21T08:52:06Z | Darkwing (Opus 5.5, 7525c8e4) | row 195 (#1693) build and manifest round 1 in review (rev 2756, request comment 29985, results 29987) | darkwing/row195-b 37171f65b on 4ced5e63d (ddf7dce6c build: scripts/case1-accept.sh, case1-accept.test.mjs, TOOLS.md entry, DESIGN r7 doc fixes per sage-1817; 37171f65b BUILD.md results); slot: single 22/22 (sage-1829), 12/12 mutants killed and gate cli 662/0 plus scripts 429/0 selftests (sage-1865), green per sage-1870; Duckworth reviews (sage-1872); Gosalyn 0087 Q4/Q5 match W10/W12; flock goes to DEFERRED with 215 (sage-1871); nothing touched .53
|
||||
2026-10-21T09:20:46Z | Sage (Opus 5.5, 1ef1e4f8) | 212+197 combined landing gate (node 981/0, selftests 429/0, 08:51-09:03Z); 197 lane proof stopped after two failures (try 1 lane-image base inspect, N7; try 3 solo-a process.test.mjs:82 under umask 077), recovered cf48efccf083; mos-1162: row 212 landed alone as ee7b2f137 (paths match a2c72ac95, patch-id ea84d3e35470), #1713 closed 29996, revs 2759-2760; DEFERRED N7 plus brief docs/plans/2026-10-21_gate-env-rows.md at fefd54e13, rows 243 (chmod) and 244 (fixture PI_AUTH_FILE) revs 2761-2762; row 66 rebuilt as sage/row66-r4b 49ad61963 on fefd54e13 (manifest 99cab8275af4, not yet requested); slots: Herb 215 focused 26/0, Gosalyn 231 singles 60/0, Herb 197 r6 focused granted; kept root ga-8I3iZc removed (sage-1827..1893) | in progress
|
||||
2026-10-21T10:12:45Z | Darkwing (Opus 5.5, 7525c8e4) | row 245 (#1693) built, slot asked (darkwing-0105) | claim rev 2795; darkwing/row245 0590a29fc pushed, on 62ddc96cc: 5273de0b7 cherry-pick of 1e3c4767c (case1-accept.sh, test, TOOLS.md blobs identical) and 0590a29fc notes to queue-245/BUILD.md (DESIGN.md:336-338 superseded by gosalyn-0087, unedited, sage-1877); slot scripts in /mnt/storage/scratch/darkwing-245-out pinned to 0590a29fc, nothing run; told Sage the untracked queue-195 DESIGN.md was r2 (fcbb4aa9b = 818b79696), nothing lost; flagged 245 and 246 both closing #1693
|
||||
2026-10-21T11:28:49Z | Sage (Opus 5.5, 1ef1e4f8) | 235 landing gate (bus 596/0, business 200/0, cli 666/0, scripts green) and landing b1fece6f0, #1741 closed 30026; 197 r6 D5 proof stopped at solo-a script-release (Compose run has no --no-build, reproduced in lane env), #1695 30031; 221 landed before 197 r7 (sage-1954): gate 11:07-11:19Z (test 11/0, harness 140/0, cli 677/0, scripts green), 300766450, #1722 closed 30033, revs 2812-2813; C2-20b brief and DEFERRED N3 in 335bb3e30, row 247 #1749 briefed; 197 r7 9c3166915 focused run on my slot (red 9/2, green 11/0, mutant killed), request rev 2819 (30036), proof candidates A 2be6d07fa and B cceb258ff ready; slots Herb sage-1956, Darkwing 245 sage-1961 (sage-1894..1962) | in progress
|
||||
2026-10-21T11:50:41Z | Darkwing (Opus 5.5, 7525c8e4) | row 245 (#1693) slot green, round 1 in review (rev 2842, request comment 30040) | grant sage-1961 on 0590a29fc: singles 23/0, mutants 16/16 killed (13-15 N1, 16 N3), gate cli 664/0 and every scripts/test-*.sh green (429/0), /proc scan empty; results commit ef8b4439e pushed on darkwing/row245; manifest ~/darkwing-scratch/r245/manifest-ef8b4439e*.sha256, 5 paths; slot handed back (darkwing-0107); Duckworth woken (darkwing-0108)
|
||||
2026-10-21T13:43:38Z | Filbert (Opus 5.5, 048f3c63) | row 215 C2-04 candidate review r1 (herb-0083/0085, sage-2019) | APPROVE on 8d5695d7 tip 14a03d10f, static, no tests; #1716 c30071, queue rev 2897; N1-N4 non-blocking
|
||||
2026-10-20T23:34:58Z | Dewey (Opus 5.5, 84fb346c) | #436b design r4 review (megavolt-0055, DESIGN.md 3e66f5acf) | CHANGES: revoke on an open rotation deletes one of two live tokens; N8 refuse a new grant while held; items 1 (sage-1578 b), 2, N7, duckworth N1 settled; verdict design-r4-verdict.md 19fdb59a; dewey-0761 Megavolt, dewey-0762 Sage
|
||||
2026-10-20T23:35:58Z | Dewey (Opus 5.5, 84fb346c) | #436b design r5 review (megavolt-0057, DESIGN.md 1be535aca) | APPROVE; build carries N9 (settle row skips a revoked rotation, 404 delete is done) and N10 (serviceLeft list); verdict design-r5-verdict.md de52be1b; dewey-0763 Megavolt, dewey-0764 Sage
|
||||
2026-10-20T23:41:36Z | Dewey (Opus 5.5, 84fb346c) | row 222 askpass vs 199 helper (megavolt-0061, design 7.2 8614ba87c) | accepted (a) http.followRedirects=false and (b) host/user-checked helper, plus exact prompt match, credential.helper reset or mirror config check, tests and mutants listed; dewey-0765 Megavolt, -0766 Gosalyn, -0767 Sage
|
||||
2026-10-21T05:03:38Z | researcher | row 228 (#1729) round 2 in review (revs 2667, 2668) | round 1 4fa2ed1e3 (Webby 29677) drifted on packages/cli/README.md; rebased onto 9b5362018 as researcher/row228-r2 (3a2b547c3 + 5f2aad593 carrying the N1 trim), pushed; manifest ab40b92f (3 files); slot sage-1720: singles 94/0 (13+10+42+29), mutant M1 killed, restore sha OK; busy-check copy used for the 16-hour launchpad-test-dex container (Sage ok, sage-1729); request failed (no token), Sage posts as jarvis; packet agents/researcher/work/queue-228/r2
|
||||
2026-10-21T06:47:54Z | researcher | row 228 (#1729) round 3 in review (rev 2702; start rev 2700) | rebase of r2 onto the 234 landing c771a36f4 as researcher/row228-r3 cf18c42b9 (one commit, pushed) with Webby's round 2 N1 (:711 and :718); manifest 82cf98c6 (3 files); slot sage-1796: session.test 13 + host.test 42 = 55/0, manifest OK before and after, busy228.sh copy named in the clear; r2 94/0 and M1 killed stand (seat blobs unchanged); request attempt left in requesting state (lock held by sage-238-review-2b when the outcome rev was written, exit 3; transport said failed pre-send, no token), Sage resolves and posts as jarvis; packet agents/researcher/work/queue-228/r3
|
||||
2026-10-21T07:29:23Z | researcher | row 228 (#1729) landed as 86249ac55 (pushed 07:25Z by Sage) | patch-id b6ffe31feb0d matches my r3 candidate cf18c42b9 (checked here against origin/refactor); Sage combined gate with 238: seat 165/0, cli 640/0, scripts 585/0; queue note rev 2725, done rev 2727, #1729 closed comment 29966 (sage-1824); q228r2 worktree removed, branches researcher/row228, -r2 and -r3 stay on origin and locally; packets agents/researcher/work/queue-228 r1 r2 r3 uncommitted
|
||||
2026-10-21T15:19:14Z | Sage (Opus 5.5, 1ef1e4f8) | MVP push 10-21 afternoon: rows 249, 197 proof 3/4, 222 singles, 66 r4d, queue-commit | 249 landed 9de5a57ab (+7b9737953 DEFERRED N1, #1751 closed, Vikunja 445 c442); 197 proof 3 rc=1 on a driver defect, Herb's fix a5b636750 to Webby, proof 4 set up in sage-r197p4; 222 singles 34/34 and 5 mutants killed (sage-2028); 66 round 4 in review (82c830779, c30083/30084); queue revs 2642-2904 committed 0636721e6
|
||||
|
||||
+3
-154
@@ -599,8 +599,7 @@ error. Before anything stops, a run whose Console flags hold `--sso` refuses
|
||||
`sso-target` for a target Console without it, `sso-config` when the target's own
|
||||
`checkConsole` refuses the config's `console` key (or there is none, in which
|
||||
case, from another copy, it names the first step: activate the target without
|
||||
`--sso`, then write `console.sso`; on a named instance both commands carry
|
||||
`--instance NAME`), and `sso-slot` for each provider's slot file
|
||||
`--sso`, then write `console.sso`), and `sso-slot` for each provider's slot file
|
||||
that is missing, a symlink, not a regular file, not owned by this uid, has group
|
||||
or other bits, resolves inside the repository or the copies, or is empty. It
|
||||
uses `lstat`, `stat` and `realpath` on the slot and never reads it. `--plan`
|
||||
@@ -670,7 +669,7 @@ pinned copy](../packages/cli/README.md#one-pinned-copy-row-85-1579)).
|
||||
#### Rehearsal on a scratch root (D2, row 163)
|
||||
|
||||
```
|
||||
scripts/stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH|last --port N [--timeout S]
|
||||
scripts/stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]
|
||||
```
|
||||
|
||||
`rehearse` starts the target copy's bus host and Console on a copy of a
|
||||
@@ -681,11 +680,7 @@ runs it against live data. It stops and starts no unit and writes no pointer
|
||||
or log line.
|
||||
|
||||
`--backup` takes an activation id under `<dataRoot>/backups/stack/` or an
|
||||
absolute path. `--backup last` (row 233) takes the newest backup the
|
||||
activation log records and prints `PASS backup-last` naming it. With none in
|
||||
the log it fails `no-backup`, exits 3 and makes no directory. After the
|
||||
backup's checks it prints `BACKUP_STORE <activation id> schema <version>`.
|
||||
`--port` is the scratch Console's loopback port, 1024 to
|
||||
absolute path. `--port` is the scratch Console's loopback port, 1024 to
|
||||
65535. The run refuses `port-reserved` for 7332, 7333 and the pointer's
|
||||
`--port` and `--tester-port`, and `port-in-use` for a port something holds;
|
||||
it never picks another port. `--timeout` (default 60 seconds) bounds the
|
||||
@@ -784,152 +779,6 @@ instance (the business's `launch.by`), for this business, whose run started at
|
||||
or after the floor. `status` adds an
|
||||
`awaiting units:` line for an open activation.
|
||||
|
||||
Under `--units external`, `activate --plan` also refuses `open-activation`
|
||||
while any rollback set holds `AWAITING-UNITS`, and `migrate-required` when
|
||||
the target migrates the store and the run lacks `--migrate` (row 233). Under
|
||||
`--units user` the plan's bytes are unchanged.
|
||||
|
||||
#### Root's upgrade driver (`scripts/stack-upgrade.sh`, row 233)
|
||||
|
||||
```
|
||||
scripts/stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal]
|
||||
[--rehearse-port N] [--timeout S]
|
||||
scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
|
||||
```
|
||||
|
||||
Root runs it from a root-owned checkout of the reviewed commit, never from a
|
||||
copy, because the stack's user can write the copies. Each run needs Mos's go,
|
||||
and `--migrate` needs Jason present (decision 96). The host must be prepared
|
||||
first (`docs/guides/host-run-sheet.md`). Only the default instance, `dev`, is
|
||||
accepted; a named instance's units are a later row.
|
||||
|
||||
The unit values come from `/etc/mosaic-stack/dev.args`, which I2's install
|
||||
writes: one `case2-unit.sh` argument per line, the serve flags after `--`,
|
||||
owned by root with no group or other write, exactly one `--copy`, and that
|
||||
copy the one both installed units run. Otherwise the run refuses
|
||||
`args-unreadable` or `args-differ`. The driver writes the new copy id into
|
||||
that file only after `confirm` passes.
|
||||
|
||||
`upgrade`, in order. Every stack user step runs as
|
||||
`sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from the
|
||||
user's clone `<home>/src/mosaic-stack`:
|
||||
|
||||
1. Takes `flock -n` on `/run/mosaic-stack/upgrade-dev.lock` and holds it to
|
||||
the end, rollback included. A second run refuses `upgrade-running`, and
|
||||
a lock it can't open refuses `lock-failed`.
|
||||
2. Fetches the bundle's `refactor` into the clone and fast-forwards it to
|
||||
SHA (`fetch-failed`), then `stack-copy.sh copy --bundle FILE SHA`
|
||||
(`copy-failed`).
|
||||
3. A1: this checkout's HEAD is SHA (`head-mismatch`), the copy exists
|
||||
(`copy-missing`), `/usr/local/lib/mosaic-stack/hostcheck.sh --check`
|
||||
exits 0 (`profile-drift`, row 240), both units name one copy
|
||||
(`units-unreadable`, `units-disagree`) and it is the args file's
|
||||
(`args-differ`), and
|
||||
`activate SHA BUSINESS --units external --plan --expect-from OLD` exits 0
|
||||
(`plan-refused`, which covers `open-activation`, `migrate-required` and
|
||||
`pointer-differs`).
|
||||
4. The rehearsal, `rehearse SHA BUSINESS --backup last --port 7339`
|
||||
(`--rehearse-port`). Exit 0 goes on. Exit 3 means no backup in the log
|
||||
and goes on only with `--no-rehearsal`, logging `rehearsal-skipped`;
|
||||
without the flag it refuses `no-backup`. Exit 0 with `--no-rehearsal`
|
||||
refuses `rehearsal-required`; any other exit refuses `rehearsal-failed`.
|
||||
5. A2 stops the Console, then the bus, and waits for both to read inactive
|
||||
or failed. A3 is `activate --units external`, A4 the render with the new
|
||||
`--copy` (which also starts the bus, then the Console), A5 `confirm`.
|
||||
The driver writes the new args file beside the old one before A2
|
||||
(`args-failed`, exit 3) and moves it into place after A5. A failed move
|
||||
exits 5 `args-failed`: the new copy is live and confirmed, the args file
|
||||
still names the old one, and root moves the new file (the result line
|
||||
names it) into place by hand.
|
||||
|
||||
A failure in A2 or a stage that exits 3 starts the old units again and gates
|
||||
them (A2f). A later failure stops both units, runs `revert`, renders the old
|
||||
copy and runs `confirm --rollback OLD --cause A3|A4|A5`. `rollback` is the
|
||||
same from A1 on, with `rollback BUSINESS --to ID`, no fetch, no copy and no
|
||||
rehearsal; `--restore` and `--events` pass through.
|
||||
|
||||
Root reads no output the stack user wrote except exit codes and the
|
||||
rehearsal's `BACKUP_STORE` line, which it copies into its log after a match
|
||||
against a fixed pattern (`BACKUP_STORE none` otherwise). The step log
|
||||
`/var/log/mosaic-stack/upgrade-dev.log`, 0640, gets one
|
||||
`<time> <step> rc=N argv: ...` line per step and a last
|
||||
`<time> result exit=N <code> <text>` line.
|
||||
|
||||
Exit 0 ok, 1 failed with the old stack running and gated, 2 rollback-failed
|
||||
with both units stopped, 3 refused with nothing stopped, 4 usage, 5
|
||||
args-failed with the new stack live and confirmed. A refusal
|
||||
after step 2 leaves the user's clone fast-forwarded and the new copy made. After exit
|
||||
2 the activation stays open, so the next run's plan refuses until root
|
||||
finishes it.
|
||||
|
||||
#### Root's host check (`scripts/case1-hostcheck.sh`, row 240)
|
||||
|
||||
```
|
||||
/usr/local/lib/mosaic-stack/hostcheck.sh --check | --enforce | --upstream
|
||||
```
|
||||
|
||||
Installed from `scripts/case1-hostcheck.sh` as `hostcheck.sh`, with
|
||||
`scripts/case1-pins.sh` beside it (`docs/guides/host-run-sheet.md` section 4).
|
||||
The pins are the profile's sha256, the `apparmor-profiles` version it came
|
||||
from and the bubblewrap install pin; changing one is a reviewed commit.
|
||||
Seven items, one `ok N` or `FAIL N` line each: the profile file matches the
|
||||
pin and no package owns it; `aa-status` has `bwrap` and `unpriv_bwrap` in
|
||||
enforce mode and no `mosaic-bwrap`; no other file under `/etc/apparmor.d`
|
||||
names `bwrap`; a bwrap child under the units' no-new-privs reads
|
||||
`bwrap//&unpriv_bwrap (enforce)`; `/usr/bin/bwrap` is `root mosaic 0750` by
|
||||
the statoverride and by `stat`; the user-namespace restriction and
|
||||
`fs.protected_hardlinks` are 1; `net.ipv4.ip_unprivileged_port_start` is
|
||||
above the Console unit's `--port`, or above 1023 with no unit. A `note` line
|
||||
says whether bubblewrap is the install pin and never fails.
|
||||
|
||||
`--check` exits 0 or 1; `stack-upgrade.sh` refuses `profile-drift` on 1.
|
||||
`--enforce` exits 0. On a failure, or while `/etc/mosaic-stack/DRIFT` exists,
|
||||
it unloads the profile if loaded, sets `/usr/bin/bwrap` to `root:root` 0700,
|
||||
appends the failed items to `DRIFT` and one line to
|
||||
`/var/log/mosaic-stack/hostcheck.log`. It never writes the statoverride. The
|
||||
apt hook runs it after every package run, and `mosaic-hostcheck.service`
|
||||
(`packages/cli/systemd/`) at boot before the bus. `--upstream` compares the
|
||||
`apparmor-profiles` candidate's profile to the pin, prints any difference
|
||||
and exits 0, or 1 when the download fails. Exit 2 is usage or a refusal:
|
||||
root takes no `--root` (the test seam), and other users run only with it.
|
||||
|
||||
#### Root's acceptance run (`scripts/case1-accept.sh`, row 195)
|
||||
|
||||
```
|
||||
case1-accept.sh --business ID --args FILE --claude-pin PIN --node-pin vN.N.N
|
||||
```
|
||||
|
||||
Root runs it on the Case 1 host after the install window, from a root-owned
|
||||
checkout. It reads the user, home, copy, node, claude and both ports from the
|
||||
args file (`/etc/mosaic-stack/dev.args` on .53), which must pass
|
||||
`stack-upgrade.sh`'s rules, say `--profile factory` and name `--business`.
|
||||
Every command that runs the stack's code runs as
|
||||
`sudo -u USER -H env PATH=<node dir>:/usr/local/bin:/usr/bin:/bin` from `/`.
|
||||
Root itself reads `ss`, `/proc`, the account database and file metadata. It
|
||||
runs `systemctl show` and `is-enabled`, `curl` on the Console's loopback
|
||||
`/healthz`, `sudo -l`, `sudo -u jarvis bwrap --version`, `hostcheck.sh
|
||||
--check`, and node to parse `managed-settings.json` for F9. F9 runs node only
|
||||
when `--node` is the `--node-pin` path, and the file and every directory above
|
||||
it are root's with no group or other write. The one write is F3's message and
|
||||
its reply, and it never opens `claude-session.env`.
|
||||
|
||||
It prints one line per check, `<id> <path> <mark> <evidence>`, then
|
||||
`count <mark> N` for each mark and a result line. The functional checks
|
||||
F1-F11 are `pass` or `FAIL`: both units active as the user, the Console on
|
||||
127.0.0.1 only, a tester message to chief answered, `case2-precheck.sh
|
||||
--profile factory`, the bwrap labels of a live role session, the claude and
|
||||
node pins, one live PM launch, the credential file's owner and mode, the
|
||||
autoupdater off, no Discord binding. The host paths P1-P11 are `closed`,
|
||||
`accepted <ruling>` (Q182, Q183, mos-1096, mos-1101) or `unnamed`, which means
|
||||
no ruling names the path; each `unnamed` line goes to Mos. P9 runs
|
||||
`hostcheck.sh --check`, never `--enforce`.
|
||||
|
||||
Exit 0 when every F check passes, 1 when one fails, 2 on usage or a refusal:
|
||||
a bad args file, or `--root` misused. A P check never changes the exit.
|
||||
`--root DIR` is the test seam for `packages/cli/tests/case1-accept.test.mjs`;
|
||||
root takes none, and other users run only with it. Design: row 195 r7
|
||||
section 5 (`agents/darkwing/work/queue-195/DESIGN.md`).
|
||||
|
||||
## Console service (`scripts/webui-service.sh`)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
# Your books in Mosaic: a setup sheet
|
||||
|
||||
This sheet is for you, Melanie. You keep writing in Obsidian the way you do now. Mosaic reads your
|
||||
books repo after obsidian-git pushes it to git.mosaicstack.dev. It changes nothing on your machine and
|
||||
commits nothing to your repo.
|
||||
|
||||
## What the agents read
|
||||
|
||||
The agents read your books repo as you last pushed it. A push reaches them in about a minute.
|
||||
|
||||
They read notes, the `.md` files, inside your book folders. They don't read:
|
||||
- notes at the top of the repo, outside any book folder
|
||||
- `.canvas` and `.base` files, even inside a book folder
|
||||
- images and other attachments
|
||||
- anything in your other repos under `~/src`
|
||||
|
||||
They can't write to your repo at all. That covers canon, the playground and everything else.
|
||||
|
||||
Which folders are book folders, and which hold characters, places, research, your tracker or chapter
|
||||
summaries, is set in one place. It is the `projects` entry of your business file in Mosaic. Jason
|
||||
edits it by hand, and the agents can't change it. He will ask you to confirm it before the agents start.
|
||||
|
||||
## Your playground
|
||||
|
||||
A folder named in the `playground` list of that entry is your playground. Each playground folder sits
|
||||
inside one book folder, so one per book works well, for example `<book>/playground/`. You choose the
|
||||
names and tell Jason. Until you have one, every note in a book folder is canon.
|
||||
|
||||
What the playground means for the agents:
|
||||
- They search canon unless they ask for the playground on purpose.
|
||||
- An agent may quote a playground note in an idea or a question. It may not use one to back a fact,
|
||||
a finding or a correction. Mosaic refuses that reply before you see it.
|
||||
- Only the folder decides. A tag or a property can't make a note playground or canon.
|
||||
|
||||
### Colour the playground in your graph
|
||||
|
||||
This is a setting of your own vault. It changes nothing for the agents.
|
||||
|
||||
1. Open the graph view and open its settings panel.
|
||||
2. Under Groups, choose New group.
|
||||
3. In the query box, type the playground's path with `path:`. Your vault is `~/src`, so the path
|
||||
starts with your books repo folder: `path:"<books repo folder>/<book>/<playground folder>"`.
|
||||
4. Pick a colour.
|
||||
|
||||
Add one group per playground folder.
|
||||
|
||||
## Two things you might not expect
|
||||
|
||||
### Moving a note into a book folder makes it canon
|
||||
|
||||
The folder decides, so a move changes what the agents may do with a note. Once you move a note out of
|
||||
the playground into a book folder and push, it is canon. The agents may then cite it as fact. A note
|
||||
that isn't settled yet belongs in the playground. Any note in a book folder outside the playground is
|
||||
canon, rough drafts included.
|
||||
|
||||
### A short link can point somewhere else for the agents
|
||||
|
||||
Obsidian looks up a short link like `[[Mara]]` across your whole vault, `~/src`. The agents see only
|
||||
your books repo. Say a note called `Mara` exists in your books repo and also in another of your repos.
|
||||
Obsidian and the agents can then open different notes for the same link, and the agents can't tell.
|
||||
If the name exists only in the other repo, the link leads nowhere for the agents.
|
||||
|
||||
A link with the full path from the top of your vault reaches the same note in both:
|
||||
|
||||
```
|
||||
[[<books repo folder>/<book>/<characters folder>/Mara]]
|
||||
```
|
||||
|
||||
Obsidian can write new links this way. In Settings, open Files and links and set New link format to
|
||||
Absolute path in vault.
|
||||
|
||||
## Lists of links in properties
|
||||
|
||||
The agents read the links in two properties, `appears_in` and `relationships`. Put quotes around each
|
||||
link in the list:
|
||||
|
||||
```yaml
|
||||
appears_in: ["[[A]]", "[[B]]"]
|
||||
```
|
||||
|
||||
One link per line works too:
|
||||
|
||||
```yaml
|
||||
appears_in:
|
||||
- "[[A]]"
|
||||
- "[[B]]"
|
||||
```
|
||||
|
||||
Obsidian's property editor adds the quotes for you. If you type the list by hand, don't leave them out:
|
||||
|
||||
```yaml
|
||||
appears_in: [[[A]], [[B]]]
|
||||
```
|
||||
|
||||
The agents' reader can't read that form. It then ignores every property of that note and lists the
|
||||
note as a problem in Mosaic's status. It still finds the note by its path, its name and its text. A
|
||||
value written without `[[ ]]` counts as a plain name, not a link.
|
||||
|
||||
## What changes for Bean and Chet
|
||||
|
||||
They keep their names and personalities. How they answer changes.
|
||||
|
||||
In Hermes they answer in free text and may write story text when you ask. In Mosaic every reply is a
|
||||
short list of items. Each item is an idea, a question, a finding or a fact. Each one quotes a line
|
||||
from your repo or from a web page the agent read for you.
|
||||
|
||||
They don't write passages. If you ask for prose, you get a question or an idea instead.
|
||||
|
||||
A reply holds at most 10 items and at most 1,200 characters of item text in all. It has at most 5
|
||||
ideas of up to 200 characters each. A question, finding or fact runs up to 300 characters. You or
|
||||
Jason can ask for lower limits. When an agent can't back an answer with a quote, you see "No cited
|
||||
answer" instead.
|
||||
|
||||
Each book world stays separate. One item can't mix quotes from two worlds.
|
||||
|
||||
You talk to them in the Console. Hermes keeps running until you have moved over, and stopping it is
|
||||
your choice.
|
||||
|
||||
Five more helpers can join them: ideas, research, proofreader, fact-checker and continuity. You
|
||||
choose which ones to start with, and you can add one later.
|
||||
|
||||
## What Jason will ask you
|
||||
|
||||
He will ask you to confirm your book folders and type folders, which books share a world, the name of
|
||||
each playground folder, and which helpers to start with and when.
|
||||
@@ -1,122 +0,0 @@
|
||||
# Survey run sheet for the library host
|
||||
|
||||
This sheet is for whoever runs `mosaic library survey` on `<host>`, 10.1.1.54, under design 10 step
|
||||
7. The survey reads one commit of a git repo and prints names and counts. It never prints note text,
|
||||
property values, file names or link targets. Jason fills in the mapping for Melanie's books repo
|
||||
from its output and her answers.
|
||||
|
||||
Out of scope: sending anything to Melanie. Design 10 step 7 makes that Jason's call.
|
||||
|
||||
## Before you start
|
||||
|
||||
- Jason's go for step 7. Step 4, the bot token in the `library-gitea` slot, comes first.
|
||||
- You run as `mosaic` with `sudo -u mosaic -H` from `localadmin`. `mosaic` has no login shell.
|
||||
- The stack copy from step 2 is in place at `/srv/mosaic/.local/share/mosaic-stack/copies/<id>`.
|
||||
`<id>` is the full commit id in Jason's go. It must hold row 231, which escapes control and bidi
|
||||
characters in folder names. In a canonical checkout, `git merge-base --is-ancestor 29ce1e360 <id>`
|
||||
exits 0 when it does.
|
||||
- `<node dir>`, the directory that holds `node`. It is the directory of the `--node` path given to
|
||||
`case2-unit.sh` in step 2c. `scripts/mosaic` runs `node` from `PATH`.
|
||||
- `<repo>`, an absolute path. See the next section.
|
||||
|
||||
## The repo to survey
|
||||
|
||||
The survey reads a local repo. It doesn't fetch. `<repo>` must:
|
||||
- be the repo's top folder, or a bare repo's own folder. A folder inside a repo refuses, because the
|
||||
survey stops git from looking above the path's parent.
|
||||
- belong to `mosaic`. The survey gives git no global config, so a `safe.directory` entry can't
|
||||
admit a repo another user owns.
|
||||
- have a `HEAD` that names a commit. The survey reads `HEAD` and has no flag for another ref.
|
||||
|
||||
`<repo>` is the bus's bare mirror at `<dataRoot>/projects/<business>/<project>`, which C2-04 (row 215)
|
||||
creates and C2-03a (row 222) fetches into. It is the only copy of her repo on the host (design 2.1), and
|
||||
`mosaic` owns it. Survey it after the first `library.sync` succeeds. The mirror's `HEAD` points at
|
||||
`refs/heads/served` (C2-03a brief), so the survey reads the commit the agents are served.
|
||||
|
||||
Don't survey anything else. A hand clone with the bot token would put the token where design 7.2 keeps
|
||||
it out, and the mirror's refs belong to the bus. If `HEAD` names no commit, stop and report to Sage.
|
||||
|
||||
## Steps
|
||||
|
||||
1. `ssh [email protected]`.
|
||||
2. Check which `node` runs. `PATH` ends in `/usr/bin:/bin`, so a wrong `<node dir>` would quietly run
|
||||
another `node`:
|
||||
|
||||
```sh
|
||||
cd /
|
||||
sudo -u mosaic -H env PATH="<node dir>:/usr/bin:/bin" sh -c 'command -v node; node --version'
|
||||
```
|
||||
|
||||
The first line must be `<node dir>/node`. If it isn't, fix `<node dir>` before going on.
|
||||
3. Run the survey without a mapping:
|
||||
|
||||
```sh
|
||||
cd /
|
||||
sudo -u mosaic -H env PATH="<node dir>:/usr/bin:/bin" \
|
||||
/srv/mosaic/.local/share/mosaic-stack/copies/<id>/scripts/mosaic library survey <repo>
|
||||
echo "exit $?"
|
||||
```
|
||||
|
||||
4. On exit 0, give the output to Jason. On any other exit, see "Refusals" below.
|
||||
5. When Jason has a draft mapping, run it again with `--mapping`. The file holds the library entry
|
||||
alone, `{"kind": "library", "remote": ..., "branch": ..., ...}`, without the business file or the
|
||||
`"projects"` wrapper around it. `remote` and `branch` are required even though the survey doesn't
|
||||
fetch. `mosaic` must be able to read the file. Set `vaultPrefix` to her books repo's folder name
|
||||
in her vault. Without it, a link with the full path from the top of her vault resolves to nothing,
|
||||
and the setup sheet tells her to write links that way.
|
||||
|
||||
```sh
|
||||
sudo -u mosaic -H env PATH="<node dir>:/usr/bin:/bin" \
|
||||
/srv/mosaic/.local/share/mosaic-stack/copies/<id>/scripts/mosaic library survey <repo> --mapping <file>
|
||||
echo "exit $?"
|
||||
```
|
||||
|
||||
The usage line is `mosaic library survey <repo> [--mapping <file>]`. `--mapping <file>` is the only
|
||||
flag, and it may appear once. `--mapping=<file>` and every other flag refuse.
|
||||
|
||||
## What the output holds
|
||||
|
||||
Plain text on stdout, one line per item:
|
||||
|
||||
| Line | What it holds |
|
||||
|---|---|
|
||||
| `commit <id>` | the 40-hex commit `HEAD` names |
|
||||
| `entries <n>` | every entry in that commit's tree |
|
||||
| `folders (direct children: md canvas base other)` | then one line per folder that holds a file directly: its path as a JSON string and four counts. `(root)` is the repo's top |
|
||||
| `markdown <n> files, <n> read, ...` | `.md` files, how many were read, how many have no front matter, how many have front matter the reader can't fully parse |
|
||||
| `front matter key sets` | then `<count> [<keys>]` per set of property names. A name that isn't a short identifier prints as `(other key)` |
|
||||
| `body links plain .., label .., heading .., embed .., self .., markdown ..; attachment ..` | link forms in note bodies, and embeds of a target with an extension other than `.md` |
|
||||
| `field appears_in wikilink .., name ..` | values written as links and as plain names. A `relationships` line follows |
|
||||
|
||||
With `--mapping` three lines follow:
|
||||
|
||||
| Line | What it holds |
|
||||
|---|---|
|
||||
| `scopes canon .., playground .., unmapped ..; indexed ..` | tree entries per scope, and notes indexed |
|
||||
| `resolved root .., vault-prefix .., relative .., shortest .., unresolved ..` | links per resolver step. `unresolved` is the R4 count |
|
||||
| `problems R1 .., ..., R7 ..` | problems per rule |
|
||||
|
||||
Folder paths and short property names are the only words from her repo in the output. In a folder
|
||||
name, tab, newline, carriage return, backspace and form feed print as `\t`, `\n`, `\r`, `\b` and `\f`.
|
||||
Any other control or bidi character prints as `\uXXXX`.
|
||||
|
||||
## Refusals
|
||||
|
||||
A refusal prints nothing on stdout and one line on stderr. The survey checks the mapping before the
|
||||
repo, so a bad mapping gives exit 3 even when the repo path is wrong too.
|
||||
|
||||
| Exit | stderr | Cause | What to do |
|
||||
|---|---|---|---|
|
||||
| 4 | `library survey: usage: mosaic library survey <repo> [--mapping <file>]` | no repo, two repos, an unknown flag, `--mapping` without a file or twice | Fix the command |
|
||||
| 3 | `library survey: library-mapping-invalid: <file> is not a readable JSON file` | missing, unreadable by `mosaic`, or not JSON | Fix the file or its mode |
|
||||
| 3 | `library survey: library-mapping-invalid: <file> <reason>` | the entry breaks the schema. The reason names the key | Give it to Jason for the draft |
|
||||
| 3 | `library survey: library-playground-outside-book: <file> universes.<u>.playground "<prefix>" is not inside one of its books` | a playground prefix outside its universe's books | Give it to Jason for the draft |
|
||||
| 2 | `library survey: library-git: <repo> is not a directory` | wrong path | Check `<repo>` |
|
||||
| 2 | `library survey: library-git: repository has no readable HEAD commit (project-git)` | not a repo, a folder inside one, another owner, no commits, a `HEAD` with no commit, or no `git` on `PATH` | Check each. If `<repo>` is the bus mirror, stop and report to Sage |
|
||||
| 2 | `library survey: library-git: <other reason>`, such as `tree listing refused (project-git)` | git failed or ran past 100 seconds | Report to Sage |
|
||||
| 2 | `library survey: library-commit: <reason>` | `HEAD` names something the reader can't take as a commit, for example in a SHA-256 repository | Report to Sage |
|
||||
| 127 | a line ending `exec: node: not found` | `node` isn't in `<node dir>` | Fix `<node dir>` |
|
||||
| other | a Node stack trace | a fault in the survey | Stop and report the exit code to Sage |
|
||||
|
||||
A refusal is the answer, not an obstacle. Don't run as another user, change modes on her repo or edit
|
||||
its refs to get past one.
|
||||
@@ -1,287 +0,0 @@
|
||||
# Host run sheet: preparing a host for the stack
|
||||
|
||||
ops-01 runs this on a fresh host before install (I2), and once per host for enrollment. Jason places the
|
||||
credentials in section 9. Brief: `docs/plans/2026-10-20_installer-rows.md`, I3 (row 233, #1735). The
|
||||
host design is Darkwing's r6 (`agents/darkwing/work/queue-195/DESIGN.md`, section 2) as the brief
|
||||
changes it.
|
||||
|
||||
The hosts are .53 (profile `factory`) and .54 (profile `library`). Both get every section unless a
|
||||
section says otherwise. Every command runs as root unless it starts with `sudo -u mosaic`.
|
||||
|
||||
Each item names the `scripts/case2-precheck.sh` line that verifies it. Install's precheck runs as the
|
||||
stack's user with `--business ID --profile factory|library`. Where no precheck line checks an item, the
|
||||
item says "precheck line missing", and Gosalyn adds the line to I2. Today's precheck (C2-13a) has these
|
||||
gaps; C2-13b adds the library lines (`library.sync`, persona modes, no Discord binding).
|
||||
|
||||
## 1. The stack's user and its home
|
||||
|
||||
```sh
|
||||
useradd --system --user-group --home-dir /srv/mosaic --create-home --shell /usr/sbin/nologin mosaic
|
||||
chmod 0700 /srv/mosaic
|
||||
install -d -o root -g root -m 0755 /etc/mosaic-stack
|
||||
install -d -o root -g root -m 0750 /var/log/mosaic-stack
|
||||
```
|
||||
|
||||
`mosaic` gets no sudo rule and no supplementary group. Check with `sudo -l -U mosaic` (expect "not allowed
|
||||
to run sudo") and `id -G mosaic` (one number).
|
||||
|
||||
Precheck lines:
|
||||
- `ok HOME is /srv/mosaic`
|
||||
- `ok sudo -n true fails as mosaic`
|
||||
- `ok id -G prints one group (<gid>)`
|
||||
- `/srv/mosaic` at 0700: precheck line missing.
|
||||
|
||||
## 2. node, pinned under /opt
|
||||
|
||||
The pin is the version and sha256 the window names. `<v>` below is that version, for example `v24.x.y`.
|
||||
|
||||
```sh
|
||||
install -d -o root -g root -m 0755 /etc/mosaic-stack/node-keys /opt/mosaic-stack
|
||||
cd "$(mktemp -d)"
|
||||
curl -fsSLO https://nodejs.org/dist/<v>/node-<v>-linux-x64.tar.xz
|
||||
curl -fsSLO https://nodejs.org/dist/<v>/SHASUMS256.txt
|
||||
curl -fsSLO https://nodejs.org/dist/<v>/SHASUMS256.txt.asc
|
||||
gpg --no-default-keyring --keyring /etc/mosaic-stack/node-keys/pubring.kbx --verify SHASUMS256.txt.asc SHASUMS256.txt
|
||||
grep " node-<v>-linux-x64.tar.xz\$" SHASUMS256.txt | sha256sum -c -
|
||||
echo "<pinned sha256> node-<v>-linux-x64.tar.xz" | sha256sum -c -
|
||||
tar -xJf node-<v>-linux-x64.tar.xz --no-same-owner
|
||||
mv node-<v>-linux-x64 /opt/mosaic-stack/node-<v>
|
||||
chown -R root:root /opt/mosaic-stack/node-<v>
|
||||
chmod -R go-w /opt/mosaic-stack/node-<v>
|
||||
```
|
||||
|
||||
The keyring holds the Node release keys from the nodejs/release-keys repository, nothing else. Stop on any
|
||||
failed check; don't unpack. Rollback: `rm -r /opt/mosaic-stack/node-<v>`.
|
||||
|
||||
Precheck lines:
|
||||
- `ok node <v>` (version 24 or later only).
|
||||
- The `--node` path in the args file must be a root-owned regular file outside the home. `case2-unit.sh`
|
||||
refuses otherwise at render, which is not a precheck line.
|
||||
- The installed version equals the pin: precheck line missing.
|
||||
|
||||
## 3. claude, factory only (.53)
|
||||
|
||||
Root installs it under `/opt`, never under the stack's home (this replaces r6's `/srv/mosaic/.local/bin`).
|
||||
The pin is 2.1.304 unless the window names a newer one.
|
||||
|
||||
```sh
|
||||
install -d -o root -g root -m 0755 /opt/mosaic-stack/claude-<pin> /etc/claude-code
|
||||
PATH=/opt/mosaic-stack/node-<v>/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||
npm install --prefix /opt/mosaic-stack/claude-<pin> @anthropic-ai/claude-code@<pin>
|
||||
chown -R root:root /opt/mosaic-stack/claude-<pin>
|
||||
/opt/mosaic-stack/claude-<pin>/node_modules/@anthropic-ai/claude-code/bin/claude.exe --version
|
||||
printf '%s\n' '{"env":{"DISABLE_AUTOUPDATER":"1"}}' > /etc/claude-code/managed-settings.json
|
||||
chown root:root /etc/claude-code/managed-settings.json
|
||||
chmod 0644 /etc/claude-code/managed-settings.json
|
||||
```
|
||||
|
||||
Rollback: `rm -r /opt/mosaic-stack/claude-<pin> /etc/claude-code/managed-settings.json`.
|
||||
|
||||
Root's PATH has no npm, so the install line puts section 2's pinned node first; the package's postinstall
|
||||
also runs `node`. The `--version` line must print `<pin> (Claude Code)`. Anything else means the postinstall
|
||||
left its 500-byte stub in `bin/claude.exe` (no native package for the platform, or scripts were off): stop and
|
||||
roll back.
|
||||
|
||||
The args file's `--claude` is
|
||||
`/opt/mosaic-stack/claude-<pin>/node_modules/@anthropic-ai/claude-code/bin/claude.exe`, the regular file the
|
||||
postinstall hardlinks or copies the native binary to. Not `node_modules/.bin/claude`: that is npm's symlink
|
||||
to it, and `case2-unit.sh` refuses a symlink, as it does anything that isn't a root-owned regular file outside
|
||||
the home. Checked against the 2.1.304 package's `install.cjs`; a newer pin needs the same check.
|
||||
|
||||
Precheck lines:
|
||||
- `ok claude replaces its prompt with --system-prompt-file (<n> input tokens)`. It runs the claude the bus
|
||||
unit's `MOSAIC_CLAUDE_BIN` names, so it needs the model credential from section 9.
|
||||
- Managed settings present with the autoupdater off: precheck line missing.
|
||||
|
||||
## 4. bubblewrap, AppArmor and the drift stop
|
||||
|
||||
Both hosts. These are r6's items, unchanged.
|
||||
|
||||
```sh
|
||||
apt-get install bubblewrap=0.9.0-1ubuntu0.3
|
||||
stat -c '%U:%G %a' /usr/bin/bwrap > /etc/mosaic-stack/bwrap.orig # expect root:root 755
|
||||
|
||||
cd "$(mktemp -d)"
|
||||
apt-get download apparmor-profiles=4.0.1really4.0.1-0ubuntu0.24.04.9
|
||||
dpkg-deb --fsys-tarfile apparmor-profiles_*.deb | tar -x ./usr/share/apparmor/extra-profiles/bwrap-userns-restrict
|
||||
echo "11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9 usr/share/apparmor/extra-profiles/bwrap-userns-restrict" | sha256sum -c -
|
||||
install -o root -g root -m 0644 usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict
|
||||
echo "11d39094f044f0cda0febb3ad517b830301da6b2ce929664af09ee9e4dd264f9 /etc/apparmor.d/bwrap-userns-restrict" | sha256sum -c -
|
||||
apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict
|
||||
|
||||
dpkg-statoverride --update --add root mosaic 0750 /usr/bin/bwrap
|
||||
```
|
||||
|
||||
Run as root in a `mktemp -d` dir, `apt-get download` prints a line starting `W: Download is performed
|
||||
unsandboxed as root`, because the `_apt` user can't write that dir. The line is expected and the
|
||||
download still succeeds. Each `sha256sum -c -` line must print `OK`. The second one checks the copy in
|
||||
`/etc/apparmor.d`, the file that loads. Stop on any other result.
|
||||
|
||||
`aa-status` then lists `bwrap` and `unpriv_bwrap` in enforce mode. Install the one profile file, never the
|
||||
whole `apparmor-profiles` package, whose 20 other profiles would load at the next boot. Rollback, in
|
||||
reverse order: `dpkg-statoverride --remove /usr/bin/bwrap`, then `chown` and `chmod` to the values in
|
||||
`bwrap.orig`; `apparmor_parser -R /etc/apparmor.d/bwrap-userns-restrict`, then `rm` it;
|
||||
`apt-get remove bubblewrap`.
|
||||
|
||||
The no-new-privs test must print `bwrap//&unpriv_bwrap (enforce)`:
|
||||
|
||||
```sh
|
||||
systemd-run --wait --pipe --uid=mosaic --gid=mosaic -p NoNewPrivileges=yes -p ProtectSystem=strict \
|
||||
-p ProtectHome=yes -p PrivateTmp=yes \
|
||||
/usr/bin/bwrap --unshare-user --unshare-pid --ro-bind / / cat /proc/self/attr/current
|
||||
```
|
||||
|
||||
The P9 checks: another user's `/usr/bin/bwrap --version` fails with Permission denied, `getent group mosaic`
|
||||
lists no member, and `sysctl fs.protected_hardlinks` prints 1. `sysctl
|
||||
kernel.apparmor_restrict_unprivileged_userns` prints 1 too (Ubuntu 24.04's default); leave it, so nothing
|
||||
else on the host gains user namespaces.
|
||||
|
||||
The drift stop (row 240, I4, #1746) goes in last, after the profile and the statoverride. As root, from
|
||||
the root-owned checkout of the reviewed commit:
|
||||
|
||||
```sh
|
||||
install -d -o root -g root -m 0755 /usr/local/lib/mosaic-stack
|
||||
install -o root -g root -m 0755 scripts/case1-hostcheck.sh /usr/local/lib/mosaic-stack/hostcheck.sh
|
||||
install -o root -g root -m 0644 scripts/case1-pins.sh /usr/local/lib/mosaic-stack/case1-pins.sh
|
||||
printf '%s\n' 'DPkg::Post-Invoke { "/usr/local/lib/mosaic-stack/hostcheck.sh --enforce || true"; };' \
|
||||
> /etc/apt/apt.conf.d/80mosaic-hostcheck
|
||||
install -o root -g root -m 0644 packages/cli/systemd/mosaic-hostcheck.service /etc/systemd/system/
|
||||
systemctl daemon-reload && systemctl enable mosaic-hostcheck.service
|
||||
/usr/local/lib/mosaic-stack/hostcheck.sh --check
|
||||
```
|
||||
|
||||
`--check` prints one `ok` line for each of its seven items and exits 0. Item 7 reads 1023 as the port
|
||||
until I2 installs the Console unit (section 10), then the unit's `--port`. `stack-upgrade.sh` runs `--check`
|
||||
before its plan and refuses `profile-drift` on any exit but 0, a missing file included. Rollback:
|
||||
`systemctl disable mosaic-hostcheck.service`, then `rm` the unit, the hook file and
|
||||
`/usr/local/lib/mosaic-stack`.
|
||||
|
||||
On drift, `--enforce` (the apt hook after every package run, and the unit at boot) unloads the profile,
|
||||
closes `/usr/bin/bwrap` to `root:root` 0700, appends the failed items to `/etc/mosaic-stack/DRIFT` and logs
|
||||
a line in `/var/log/mosaic-stack/hostcheck.log`. Every role session then refuses to start. ops-01 reads the
|
||||
log in each STATE report and sends any `DRIFT` to Mos. Only root removes `DRIFT`, in a reviewed step with
|
||||
a new pin or a Mos go. After that, `chown root:mosaic /usr/bin/bwrap`, `chmod 0750 /usr/bin/bwrap` and
|
||||
`apparmor_parser -r /etc/apparmor.d/bwrap-userns-restrict` put bwrap back, and `--check` must exit 0.
|
||||
|
||||
Before each re-pin window, ops-01 runs `/usr/local/lib/mosaic-stack/hostcheck.sh --upstream`. It
|
||||
downloads the `apparmor-profiles` candidate and compares its `bwrap-userns-restrict` to the pin. A
|
||||
`DIFFERS` line goes to Mos as a new-pin request; it refuses nothing.
|
||||
|
||||
Precheck lines:
|
||||
- `ok bwrap --unshare-all runs`
|
||||
- The AppArmor profile, the statoverride, the no-new-privs result and the P9 checks: precheck line missing.
|
||||
`/usr/local/lib/mosaic-stack/hostcheck.sh --check` covers them (row 240).
|
||||
|
||||
## 5. The port floor
|
||||
|
||||
Leave `net.ipv4.ip_unprivileged_port_start` at its default (1024) or higher. The Console's port is below
|
||||
it and the unit gets `CAP_NET_BIND_SERVICE`, so no other `mosaic` process can bind that port.
|
||||
|
||||
Precheck line: `ok net.ipv4.ip_unprivileged_port_start <n> is above the Console's port <port>`.
|
||||
|
||||
## 6. The release transport
|
||||
|
||||
A release is a git bundle of `refs/heads/refactor` plus the 40-character commit id (I1, mos-1118 choice 2).
|
||||
Mos's go names both, with the bundle's sha256. `<sha>` below is that id.
|
||||
|
||||
First install:
|
||||
|
||||
```sh
|
||||
# Copy the bundle in, then check it before anything reads it.
|
||||
echo "<bundle sha256> /root/<sha>.bundle" | sha256sum -c -
|
||||
git bundle verify /root/<sha>.bundle
|
||||
|
||||
# Root's checkout. stack-upgrade.sh and case2-unit.sh run from here, never from a copy.
|
||||
git clone -q -b refactor /root/<sha>.bundle /root/mosaic-stack
|
||||
git -C /root/mosaic-stack checkout -q --detach <sha>
|
||||
git -C /root/mosaic-stack rev-parse HEAD # must print <sha>
|
||||
|
||||
# The stack's user's copy of the bundle, and its own clone.
|
||||
install -d -o mosaic -g mosaic -m 0700 /srv/mosaic/incoming /srv/mosaic/src
|
||||
install -o mosaic -g mosaic -m 0600 /root/<sha>.bundle /srv/mosaic/incoming/<sha>.bundle
|
||||
sudo -u mosaic -H git clone -q -b refactor /srv/mosaic/incoming/<sha>.bundle /srv/mosaic/src/mosaic-stack
|
||||
```
|
||||
|
||||
Each upgrade, before `stack-upgrade.sh upgrade`:
|
||||
|
||||
```sh
|
||||
echo "<bundle sha256> /root/<sha>.bundle" | sha256sum -c -
|
||||
git bundle verify /root/<sha>.bundle
|
||||
git -C /root/mosaic-stack fetch -q /root/<sha>.bundle refs/heads/refactor:refs/remotes/origin/refactor
|
||||
git -C /root/mosaic-stack checkout -q --detach <sha>
|
||||
install -o mosaic -g mosaic -m 0600 /root/<sha>.bundle /srv/mosaic/incoming/<sha>.bundle
|
||||
/root/mosaic-stack/scripts/stack-upgrade.sh upgrade --instance dev --bundle /srv/mosaic/incoming/<sha>.bundle --sha <sha>
|
||||
```
|
||||
|
||||
The driver fetches the bundle into the stack's user's clone and makes the copy itself. It refuses
|
||||
`head-mismatch` when root's checkout isn't at `<sha>`. Usage, exits and the step log are in
|
||||
`docs/TOOLS.md`, under "Root's upgrade driver". Each run needs Mos's go, and `--migrate` needs Jason present
|
||||
(decision 96).
|
||||
|
||||
When a run doesn't exit 0, send Mos the exit and the last lines of `/var/log/mosaic-stack/upgrade-dev.log`:
|
||||
- Exit 3: refused before anything stopped. The last line names the code. The stack's user's clone and the
|
||||
new copy may already hold the new commit; the units still run the old one.
|
||||
- Exit 1: the old copy runs and passed its gate. Either the driver restarted it after A2 or a refused A3
|
||||
(A2f), or it rolled back after A3, A4 or A5.
|
||||
- Exit 2: both units are stopped and the activation stays open, so the next run refuses at its plan until
|
||||
it is finished with `confirm` or `revert`. One way here: a unit that doesn't stop within `--timeout`
|
||||
keeps its old start time, A2f's gate fails it `stale-start`, and the driver stops both (duckworth-0055
|
||||
N1). That is on purpose; a unit that won't stop is a reason to stop.
|
||||
- Exit 5: `args-failed`. The new copy is live and passed confirm, but `/etc/mosaic-stack/dev.args` still names
|
||||
the old copy. The next run refuses `args-differ`, and a render from that file would put the old copy back.
|
||||
Move the file the result line names into place with `mv -f`. The two differ only in the `--copy` value.
|
||||
|
||||
Precheck lines: none. Root's checkout at `<sha>` and the stack's user's clone: precheck line missing.
|
||||
After install, `stack-copy.sh check --units external` reads the copy the units run.
|
||||
|
||||
## 7. The args file
|
||||
|
||||
`/etc/mosaic-stack/dev.args` holds the `case2-unit.sh` arguments, one per line, with the serve flags after a
|
||||
line holding `--`. I2's install writes it; ops-01 doesn't write it by hand. It must be root's, with no group
|
||||
or other write (`chmod 0644` or tighter). Before `--` it takes only `--user`, `--home`, `--business`,
|
||||
`--copy` (exactly once), `--node`, `--profile`, `--claude` and `--pm`. `stack-upgrade.sh` changes only the
|
||||
`--copy` line, and only after confirm passes.
|
||||
|
||||
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` on any of these rules.
|
||||
|
||||
## 8. Enrollment, once per host
|
||||
|
||||
The machine-grant enrollment is Gizmoduck's design r6, commit f9039682e, section 3, steps E1-E4. ops-01's
|
||||
Q-O1 is answered (mos-1125). The host side is the `tools/host/` files: `mosaic-keys`, `mosaic-keys-write`,
|
||||
their sudoers line and the sshd drop-in `/etc/ssh/sshd_config.d/<nn>-mosaic-keys.conf`. The verbs that run
|
||||
it (`hosts.enroll`, the `host-enroll` decision) are rows 234 and 235, not yet on refactor. This item has no
|
||||
commands until they land.
|
||||
|
||||
Precheck line: none. The enrolled host's key and its `mosaic-keys` sudoers line: precheck line missing.
|
||||
|
||||
## 9. Credential slots
|
||||
|
||||
Jason places these, all in one sitting per host. No value appears here or in any message. Check a placed
|
||||
file with `stat -c '%U %a %n' PATH`, never `cat`. `<business>` is the host's business id.
|
||||
|
||||
### .53, factory
|
||||
|
||||
| Path | File | Mode | Owner | Profile | Precheck line |
|
||||
|---|---|---|---|---|---|
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | `claude-session.env`, one line `CLAUDE_CODE_OAUTH_TOKEN=...` (Q185) | 0600 | mosaic | factory | `claude replaces its prompt with --system-prompt-file` runs claude with it. The file's mode and owner: precheck line missing. |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/agents/<business>/<agent>/` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. |
|
||||
|
||||
### .54, library
|
||||
|
||||
| Path | File | Mode | Owner | Profile | Precheck line |
|
||||
|---|---|---|---|---|---|
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | `claude-session.env`, one line `ZAI_API_KEY=...` | 0600 | mosaic | library | `<path> sets ZAI_API_KEY` |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/` | the Console's proof file, the path the args file's `--proof-file` names | 0600 or 0400 | mosaic | library | none. `case2-unit.sh` refuses at render. Precheck line missing. |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | C2-13b's `library.sync` (not landed) |
|
||||
| `/srv/mosaic/.config/mosaic-dev/secrets/agents/<business>/<agent>/` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. |
|
||||
|
||||
The installer key, `<configDir>/secrets/hosts/<business>/<hostId>/installer`, is not a slot anyone places.
|
||||
Enrollment (section 8) writes it under the enrolling stack's config directory and deletes it when the host
|
||||
leaves.
|
||||
|
||||
## 10. Console start
|
||||
|
||||
None by hand. I2's `case2-unit.sh` enables both units, starts the bus, then the Console (Q189, mos-1122).
|
||||
`stack-upgrade.sh` stops and starts both units itself.
|
||||
|
||||
Precheck line: none. Both units active after the render: precheck line missing.
|
||||
@@ -102,45 +102,3 @@ 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,24 +55,3 @@ 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.
|
||||
|
||||
@@ -593,54 +593,6 @@ gate, along with every `scripts/test-*.sh`.
|
||||
|
||||
---
|
||||
|
||||
## C2-20b persona import follow-up: printed keys, the dry run fence check and the mkdir race
|
||||
|
||||
### Problem
|
||||
|
||||
Webby's round 1 review of row 221 (webby-0073, REVIEW-r1 a0a00630e on webby/reviews) approved it with
|
||||
four notes that don't block. Three are in `scripts/case2-persona-import.mjs` and go in before Jason's
|
||||
import run, because he runs the script once (sage-1941):
|
||||
- N1: lines :147 and :237 print channel and platform keys from `config.yaml` as they are. A key with a
|
||||
newline or a control character could forge an output line.
|
||||
- N2: the dry run counts but doesn't apply C2-08's 8000-char fence. A persona over the fence and under
|
||||
32000 bytes passes the dry run and then refuses at staging.
|
||||
- N4: `privateDir` runs lstat, then mkdir. A directory made between the two gives a raw EEXIST. It still
|
||||
fails closed, so only the error text is wrong.
|
||||
|
||||
N3 (Morgana's 213 `mutants.py:48` B3 pattern no longer matches `bundle.mjs`) isn't in this row. It's in
|
||||
DEFERRED.
|
||||
|
||||
### Owner and reviewer
|
||||
|
||||
- Owner: Gosalyn.
|
||||
- Reviewer: Webby.
|
||||
- After: row 221 (C2-20) lands.
|
||||
|
||||
### Files owned
|
||||
|
||||
`scripts/case2-persona-import.mjs`, `packages/cli/tests/case2-persona-import.test.mjs` and its fixture
|
||||
tree.
|
||||
|
||||
### What ships
|
||||
|
||||
- N1: every printed config key goes through `JSON.stringify`.
|
||||
- N2: the dry run calls `personaTextFault` on each persona and prints the fault's name. It still prints
|
||||
no persona text, no line and no line number.
|
||||
- N4: an EEXIST from the mkdir refuses with a named reason, not a raw EEXIST.
|
||||
|
||||
Tests:
|
||||
- A fixture key with a newline prints as one quoted line.
|
||||
- A persona over the fence makes the dry run print the fault, write nothing and exit nonzero.
|
||||
- The mkdir race refuses with the named reason. A stubbed mkdir that throws EEXIST is enough.
|
||||
- One mutant for each of the three fixes, each killed by its test.
|
||||
|
||||
### Gate
|
||||
|
||||
Webby approves the manifest. The test file runs green on its own, and the three mutants are killed on
|
||||
Sage's slot. The cli suite and every `scripts/test-*.sh` are green on Sage's gate.
|
||||
|
||||
---
|
||||
|
||||
## C2-03a fetch-only sync
|
||||
|
||||
### Problem
|
||||
@@ -675,10 +627,6 @@ Design 7.2's slice:
|
||||
- An `https://` URL only.
|
||||
- The served ref `refs/heads/served`, and `library-diverged`, held until a human `library.sync --accept
|
||||
<commit>`.
|
||||
- The mirror's `HEAD` is a symbolic ref to `refs/heads/served`, so `mosaic library survey <mirror>` reads
|
||||
the served commit. The survey reads `HEAD` and has no ref flag, and design 10 step 7 surveys this mirror
|
||||
(C2-12 run sheet; Sage, 2026-10-21). Test: after the first sync, `git symbolic-ref HEAD` in the mirror
|
||||
prints `refs/heads/served` and the survey's `commit` line equals the served commit.
|
||||
- The sync state file, written atomically inside the mirror, and the 60-second timer.
|
||||
- `library.sync`, human only. A test reaches a local bare repo only through the module parameter that
|
||||
replaces `syncCommand`.
|
||||
|
||||
@@ -106,68 +106,4 @@ X5). At start, after the grant reconcile, the bus unlinks every `host.<id>` key
|
||||
`host_grants` record (r6 N1, G27). It adds no schema step. If it needs one, Sage re-briefs it first and
|
||||
assigns the next free step (#436b holds 3j to 3k, row 206 3k to 3l).
|
||||
|
||||
Creative roles get no host grant (mos-1163, from Webby's N2 on row 213 r4). Since row 234, a role
|
||||
definition's credentials no longer gate host access, so the host grant writer itself refuses a grant whose
|
||||
agent is a `CREATIVE_DEFINITIONS` role (row 213), with a named reason and a test. The Case 2 hub runs
|
||||
creative agents on .54, and without this a creative role has a path to `host.run`. Duckworth's review checks
|
||||
it, and answers from the code whether a decision raised by another role can be spent by a creative one.
|
||||
|
||||
`host.run` refuses too, at spend time, when the agent's current definition is a `CREATIVE_DEFINITIONS` role
|
||||
(sage-1908, accepted in mos-1164). That covers a grant written before the role became creative. The refusal
|
||||
leaves the grant record in place, so `hosts.revoke` still finds it. Each path, the writer refusal at raise
|
||||
and at the step 1 recheck and the spend refusal, gets its own test and its own mutant.
|
||||
`CREATIVE_DEFINITIONS` comes from row 213, so row 213 lands before row b. If that order can't hold, Sage
|
||||
tells Mos before row b lands.
|
||||
|
||||
Gate: as row a, with section 10's row b tests and mutants, G27, G28 and the upsert mutant included, and
|
||||
the creative refusal tests and mutants (writer and spend).
|
||||
|
||||
## Machine grant row b follow-up: the held stubs end with their test
|
||||
|
||||
The row 197 r8 D5 proof stopped on a leftover process on 2026-10-21. In
|
||||
`packages/bus/tests/grant-executor.test.mjs`, the `add` stub waits for `release` when `hold-add`
|
||||
is set. G23 ("a revoke during install") and G13 write `release`, finish, and `t.after` removes the
|
||||
test dir. The stub polls every 50 ms, so it usually misses `release` and then loops forever on a
|
||||
dir that is gone. Four runs of the file on Sage's slot left 7 stubs, every run 37/0.
|
||||
|
||||
Build: each hold loop in that file, `hold-add` and `hold-remove`, also stops when `$D` no longer
|
||||
exists. The test file is the only change. Nothing under `src` changes.
|
||||
|
||||
Gate: Webby approves the commit. On Sage's gate the whole file passes three times in a row with no
|
||||
stub left, checked by pgrep on the run's TMPDIR, and the bus suite is green. A mutant that drops the
|
||||
dir check from `hold-add` leaves a stub behind.
|
||||
|
||||
## Machine grant row b follow-up: the undo can't prove the remote add has ended
|
||||
|
||||
Mos asked this in mos-1166. Gizmoduck answered in gizmoduck-0137 and gizmoduck-0139, and Duckworth
|
||||
checked the answer in duckworth-0091. Line numbers are at 6d5545d44.
|
||||
|
||||
An abort during install SIGKILLs the local ssh process group (host-run.mjs:136). A timeout does the
|
||||
same (host-run.mjs:131-134). sshd gives a non-pty command no SIGHUP when the client dies, so the
|
||||
remote `add` can keep running. The undo runs `remove <user> <tag>` once (broker.mjs:1017). If the add
|
||||
writes its line after that, the remove has already exited 0 and `hostLineLeft` stays unset (:1019).
|
||||
Then hosts.status shows the host clean (:1396), reconcile skips a grant that already ended
|
||||
(:1042), and nothing prompts a sweep. hosts.remove's host-line-left check (:1362) passes, and once
|
||||
the record is gone a sweep refuses host-unknown (:1350), so no verb reaches the line. The line
|
||||
authorizes a key nobody holds, under the installer's restrict prefix. No test covers this case. G24
|
||||
seeds its failed grant with `hostLineLeft` already set.
|
||||
|
||||
Build:
|
||||
- In `#runHostGrant`, mark install done right after `await install(...)` returns. In the catch, when
|
||||
install started and never returned, record `hostLineLeft: tag` whatever the remove's exit was. The
|
||||
rule keys on install not returning. It covers an abort, a timeout and a dropped connection, and
|
||||
doesn't depend on `signal.aborted`. The message names `mosaic hosts sweep <host>`.
|
||||
- An add that exits nonzero on the host also records the hint. That hint is false but harmless,
|
||||
since a sweep that doesn't list the tag drops it (G24, "a left tag no longer on the host").
|
||||
- Fix the comment at grant-executor.test.mjs:432, which says a killed session's add exits.
|
||||
- Row a's enroll add: check it for the same race, then fix it or record in BUILD.md why it can't
|
||||
happen.
|
||||
- Owner Gizmoduck. Reviewer Duckworth.
|
||||
|
||||
Tests: one abort case and one timeout case. Each uses a held stub add that writes after the remove
|
||||
and exits 0. Each asserts `hostLineLeft` on grant.failed and in hosts.status, that hosts.remove
|
||||
refuses host-line-left before the sweep, that the sweep removes the line, and that hosts.remove then
|
||||
succeeds. A mutant that drops the new catch branch fails a test.
|
||||
|
||||
Gate: Duckworth approves the manifest. On Sage's gate the bus suite and the whole touched test files
|
||||
are green, and the mutant is killed.
|
||||
Gate: as row a, with section 10's row b tests and mutants, G27, G28 and the upsert mutant included.
|
||||
|
||||
@@ -64,31 +64,3 @@ mos-1081 item 1). No new provider account.
|
||||
Duckworth approves DESIGN.md, then the manifest. Any repository change is green on Sage's gate (cli suite and
|
||||
every `scripts/test-*.sh`), with the design's mutants killed. The row is done when the window's acceptance
|
||||
run passes on .53, Duckworth reads its output, and Mos confirms.
|
||||
|
||||
## Row 195 follow-up: Duckworth's N1-N3 on the Case 1 acceptance script
|
||||
|
||||
Sage, 2026-10-21. Row 195's code landed as b65014647. Duckworth's manifest notes N1-N3 (REVIEW-m1 6afcee2a5)
|
||||
are recorded in docs/plans/DEFERRED.md (d6cff0cdd). Owner Darkwing, reviewer Duckworth, gate owner Sage.
|
||||
|
||||
- N1: F9 runs the `--node` binary as root (`scripts/case1-accept.sh:298`) before F10 checks its path
|
||||
(:303-304). Root must never run a binary the script hasn't checked yet: run F9's parse as `AS_USER`, or move
|
||||
F10's path check ahead of it.
|
||||
- N2: the header (:10) and the TOOLS.md entry must list everything root runs, not just "reads".
|
||||
- N3: F6's prefix match (:261) must refuse a `--claude` path with `..` after `claude-<pin>/`.
|
||||
- Build on a new branch from the refactor tip, starting from Darkwing's `darkwing/row195-n1` 1e3c4767c. That
|
||||
commit hasn't been run.
|
||||
|
||||
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and a mutant for each of N1 and N3 is killed. This row lands before the .53 window row below.
|
||||
|
||||
## Row 195 window: the Case 1 acceptance run on 10.1.1.53
|
||||
|
||||
Sage, 2026-10-21. This row carries row 195's done condition from the Gate section above. Sage moved row 195
|
||||
to done at its code landing (rev 2790), which was early, and done rows can't change, so the condition
|
||||
continues here. Owner Darkwing, reviewer Duckworth, gate owner Sage. It waits on row 226 and on the N1-N3
|
||||
follow-up landing.
|
||||
|
||||
The window needs a Jason or Mos go. It takes a backup first where state exists and gives a STATE report
|
||||
after. The Boundaries above apply unchanged.
|
||||
|
||||
Gate: the window's acceptance run passes on .53, Duckworth reads its output, and Mos confirms.
|
||||
|
||||
@@ -227,51 +227,3 @@ 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.
|
||||
|
||||
### Carried from the I3 review (Filbert 29922 on #1735, sage-1727)
|
||||
|
||||
Row 233 lands with three non-blocking notes that must be fixed before ops-01. This row takes them, and that
|
||||
widens its paths to `scripts/stack-upgrade.sh` and `packages/cli/tests/stack-upgrade.test.mjs`:
|
||||
- N1: after A5 confirms, a failed rename of the new args file calls `finish 1 args-failed` while the new copy
|
||||
is live (`stack-upgrade.sh:280-282` at 1a1dc80ca). TOOLS.md and the run sheet define exit 1 as "the old copy
|
||||
runs". Give this case its own exit code, add it to the exit table in TOOLS.md and the run sheet, and add a
|
||||
test that makes the rename fail after A5 and expects the new code.
|
||||
- N4: run sheet section 3 doesn't put the pinned node's npm on root's PATH and doesn't name the `--claude`
|
||||
path. The npm symlink `node_modules/.bin/claude` is what `case2-unit.sh:140` refuses. The sheet must name
|
||||
the path the check accepts.
|
||||
- N5: run sheet sections 8 and 10 get their precheck lines.
|
||||
|
||||
Filbert's N2 and N3 (no `$REPO` check, no signal handling between A2 and A5) are in DEFERRED.md, not here.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Gate environment rows (2026-10-21)
|
||||
|
||||
Sage, from the row 197 lane proof that stopped on 2026-10-21 (sage-1881, mos-1162). Two small rows.
|
||||
Each lands after row 197.
|
||||
|
||||
## The startup token refusal test sets its file mode
|
||||
|
||||
`packages/bus/tests/process.test.mjs:76` writes the "too open" token file with
|
||||
`writeFileSync(file, secret, { mode: 0o644 })`, and `:82` expects the broker to refuse it.
|
||||
`writeFileSync` applies the process umask to that mode. Under `umask 077` the file is 0600,
|
||||
`packages/bus/src/credentials.mjs:205` accepts it, and the test fails. That happened in the row 197 lane
|
||||
proof, where `scripts/testing/gate-lane.sh:3` set 077 for the test steps.
|
||||
|
||||
- Change: the test calls `chmodSync(file, 0o644)` after the write, so its result no longer depends on the
|
||||
umask.
|
||||
- Sweep: look in `packages/*/tests` for other tests that expect a refusal of a too-open mode set
|
||||
through `writeFileSync` or `mkdirSync`, and fix those the same way. List what the sweep found in
|
||||
BUILD.md, including when it found none.
|
||||
- Test: run the file whole twice, once under `umask 022` and once under `umask 077`. Both runs pass.
|
||||
- Order: Mos ruled (mos-1162) that this lands after row 197 r6. Then r6's proof runs on the unchanged
|
||||
test, so it shows the umask fix itself works.
|
||||
|
||||
## Gate runs use a fixture pi auth file
|
||||
|
||||
The `docs/plans/DEFERRED.md` entry "Gate runs mount the host's pi auth file" (059577886, Webby N4 on the
|
||||
row 197 proof driver, traced by Herb in herb-0051) gets its own row here.
|
||||
|
||||
- Change: every gate step that reaches Compose gets a fixture `PI_AUTH_FILE`. That covers the
|
||||
`test-task.sh` cases that call `scripts/run-task.sh`, `test-release.sh` through `release.sh`
|
||||
`health_check`, and row 197's lane `cleanEnv` in `scripts/testing/gate-lane.mjs`. Either the suites
|
||||
or the lane's `cleanEnv` can set it.
|
||||
- Test: no gate step mounts the host default `/home/jwoltje/.pi/agent/auth.json`. The test reads
|
||||
the Compose arguments or the environment that each step passes. It does not read the host file.
|
||||
- Not in scope: `compose.yaml`'s default for real runs stays as it is.
|
||||
- At landing, the DEFERRED entry moves to Done with the landing commit.
|
||||
@@ -216,123 +216,6 @@ at every gate. Started 2026-09-12 during the control board MVP.
|
||||
READMEs, `docs/TOOLS.md` and Sage's suite recipe should use the glob
|
||||
form wherever Node 24 is a target. Darkwing, S1 packet. (2026-10-04,
|
||||
#1518)
|
||||
- **`stack-upgrade.sh` doesn't check `$REPO`.** Nothing confirms the
|
||||
source checkout is root-owned or that its tree is clean before the
|
||||
upgrade builds from it. Filbert N2 on row 233 (29922, #1735).
|
||||
(2026-10-21)
|
||||
- **`stack-upgrade.sh` has no signal handling between A2 and A5.** A HUP,
|
||||
INT or TERM in that stretch leaves the run failed closed, but recovery
|
||||
is by hand. A trap that writes the stop point and the recovery step to
|
||||
the log would make it a sheet step. Filbert N3 on row 233 (29922,
|
||||
#1735). (2026-10-21)
|
||||
- **`checkCreative` doesn't refuse `credentials`.** Row 213's
|
||||
`checkCreative` (packages/business/src/role.mjs:143 at 2d2ad2760)
|
||||
limits a creative role's tools, authority and messaging, but a role
|
||||
file that gave companion or research a credential would still load.
|
||||
The business file could then map a credential to that instance
|
||||
(business.mjs:157). Nobody has traced whether a mapped credential
|
||||
reaches the run's environment. The decision paths stay closed (M6),
|
||||
but a creative role with `web` holding a live token is a wider channel
|
||||
than design 4 means. The fix: refuse any `credentials` in
|
||||
`checkCreative`, with a role.test.mjs case. It goes after row 213
|
||||
lands, as a follow-up row or in the next row that edits role.mjs.
|
||||
Webby N1 on row 213 r3 (REVIEW-r3.md, 3ab3c010b), mos-1160.
|
||||
(2026-10-21)
|
||||
- **The boot host check orders only one business's bus.**
|
||||
`mosaic-hostcheck.service` (row 240) has
|
||||
`[email protected]`. `case2-unit.sh` enables the
|
||||
bus as `mosaic-bus@<business>.service`, so on a host whose business id
|
||||
isn't `mosaic-stack` nothing orders the two. The bus can start before
|
||||
the boot stop runs, and after a boot-time drift role sessions can use
|
||||
bwrap until it does. The fix: `After=mosaic-hostcheck.service` in
|
||||
`packages/cli/systemd/mosaic-bus.service.in`, which orders every
|
||||
instance and does nothing where the unit isn't installed. It goes in
|
||||
before the first install whose business id isn't `mosaic-stack`.
|
||||
Filbert N1 on row 240 (29945, #1746). (2026-10-21)
|
||||
- **The run sheet writes the apt hook before the first `--check`.**
|
||||
Section 4 installs `80mosaic-hostcheck`, then runs
|
||||
`hostcheck.sh --check`. If that check fails, the next package run
|
||||
closes bwrap before anyone has read the failure. It fails closed, but
|
||||
it surprises an installer. The fix: run `--check` first, or say what a
|
||||
failure at that step means. Doc-only, for the next row that edits
|
||||
`docs/guides/host-run-sheet.md`. Filbert N2 on row 240 (29945, #1746).
|
||||
Filbert's N3, that a closed bwrap appends to `DRIFT` on every later
|
||||
apt run and boot, is by design; ops-01's STATE read should expect the
|
||||
repeats. (2026-10-21)
|
||||
- **The sso-config first-step hint has no business.** When the target
|
||||
copy has no `console.sso`, `stack-release.sh` (`:938`) refuses with
|
||||
"activate $TO without --sso first, then ... activate $TO --sso". The
|
||||
usage line is `activate ID BUSINESS` (`:47`), so pasting either command
|
||||
as printed gives a usage error, not the first step. It fails safe,
|
||||
since the usage error changes nothing. The fix: add `$BUSINESS`
|
||||
(set at `:2138`) to both commands, and the business (`acme`)
|
||||
to the three expected strings in `stack-release.test.mjs`
|
||||
(`:1538`, `:1689`, `:1691`). For a later row that edits
|
||||
`stack-release.sh`. Webby N1 on row 238 r1 (REVIEW-r1.md, 433a95292,
|
||||
#1744). (2026-10-21)
|
||||
- **Gate runs mount the host's pi auth file.** `compose.yaml:43` mounts
|
||||
`${PI_AUTH_FILE:-/home/jwoltje/.pi/agent/auth.json}` read-only into
|
||||
the agent container. No suite sets `PI_AUTH_FILE`, so the
|
||||
`test-task.sh` cases that call `scripts/run-task.sh`, and
|
||||
`test-release.sh` through `release.sh` `health_check` (`:41-54`), run
|
||||
Compose with the host file. Row 197's lane `cleanEnv`
|
||||
(`scripts/testing/gate-lane.mjs:109`) keeps the default too: it neither
|
||||
sets nor passes `PI_AUTH_FILE`. The mount is read-only, and the legacy
|
||||
gate already worked this way before row 197. `test-auth.sh` uses its
|
||||
own fixtures but calls `auth.sh`, not Compose. The fix: a fixture
|
||||
`PI_AUTH_FILE` for the Compose-reaching cases, set by the suites or by
|
||||
the lane's `cleanEnv`, and a test that no gate step mounts the host
|
||||
default. It needs its own row. Webby N4 on the row 197 proof driver
|
||||
(bba870ee9), traced by Herb (herb-0051). (2026-10-21)
|
||||
- **Lane image prep inspects a base that BuildKit never tagged.**
|
||||
(N7 of the row 197 proof.) After the Compose build,
|
||||
`scripts/testing/lane-image.mjs:41` (`prepareImage`) runs
|
||||
`docker image inspect node:24-bookworm-slim` to put the base digest
|
||||
in the receipt. BuildKit resolves the base from the registry
|
||||
without tagging it in the local store, so on a host without the tag
|
||||
the inspect throws "No such image" after a good build. That
|
||||
happened in Sage's proof try 1 (2026-10-21 09:03Z, run cf48efccf083).
|
||||
The throw fails closed: the run's terminal record is unclean, both
|
||||
lanes quarantine, and `gate-lane.sh recover` clears them. Pulling the
|
||||
base first fixes it, and the receipt digest then matches what the
|
||||
build used (91acba91 both ways). The fix: before the build, inspect
|
||||
the base and refuse with a named reason if it's missing, or pull it.
|
||||
After the build, refuse if the base digest changed. Add a test with a
|
||||
fake Docker that has no base tag. Kept out of row 197 r6 by Sage
|
||||
(sage-1883). Mos asked for this record in mos-1162. (2026-10-21)
|
||||
- **Case 1 acceptance script: root parses with the node it's checking, and the header says less than root does.**
|
||||
(Duckworth N1-N3 on the row 195 manifest, REVIEW-m1 6afcee2a5.)
|
||||
N1: F9 runs `$ROOT$NODE` as root (`scripts/case1-accept.sh:298`)
|
||||
before F10 checks that `--node` is the `/opt/mosaic-stack/node-<pin>`
|
||||
path (:303-304). On a host installed as designed, `case2-unit.sh:128-133`
|
||||
refused a non-root-owned `--node` and the args file is root's, so no
|
||||
escalation path exists today. Run F9's parse through `AS_USER`, or
|
||||
apply F10's path check first. N2: the header (:10) and the TOOLS.md
|
||||
entry say root only reads, but root also runs `curl` (F2), `sudo -l`
|
||||
(P1, P3), the jarvis probe (P9), `hostcheck.sh --check` and
|
||||
`systemctl`. Doc-only. N3: F6's prefix match (:261) accepts a
|
||||
`--claude` with `..` after `claude-<pin>/`. That claude runs only as
|
||||
`mosaic`, and F10 uses equality. Darkwing has a follow-up on
|
||||
`darkwing/row195-n1` 1e3c4767c, not yet run; it gets its own row now
|
||||
that 195 has landed (b65014647), Duckworth reviews. (2026-10-21)
|
||||
- **Morgana's row 213 mutation set: the B3 pattern no longer matches `bundle.mjs`.**
|
||||
(Webby N3 on row 221 round 1, REVIEW-r1 a0a00630e.) Row 221's
|
||||
candidate changes `packages/harness/src/bundle.mjs`, and the B3
|
||||
anchor at Morgana's 213 `mutants.py:48` stops matching on that
|
||||
base. This matters only if someone reruns that set. Fix the anchor
|
||||
in that tooling before any rerun, and check that a stale anchor
|
||||
refuses instead of skipping the mutant (sage-1941). (2026-10-21)
|
||||
- **Grant executor test X5 starts a `sleep 30` that can outlive its test.**
|
||||
(Webby N1 on row 248 r1, webby-0080; deferred by Duckworth in the row
|
||||
249 review, REVIEW-r1 28174fe08, #1751 comment 30070.) The test at
|
||||
`packages/bus/tests/grant-executor.test.mjs:989` (:934 before row
|
||||
249) runs `sleep 30` on the sshd side, where an abort doesn't reach it. Fix it with a stub
|
||||
that exits once its test dir is gone, as row 248 did for the hold
|
||||
stubs, or with a shorter sleep. This binds the next row that touches
|
||||
that test file. Until then, a lane cleanup that runs after this file
|
||||
waits 30 s before it calls a lone `sleep 30` a leak. The row 197
|
||||
lanes scan only at lane end, about 15 minutes after pkg-bus, so they
|
||||
can't see it (mos-1169). (2026-10-21)
|
||||
|
||||
## Queue
|
||||
|
||||
|
||||
+23
-37
@@ -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 2904 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 2539 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -102,7 +102,7 @@ Generated from `docs/plans/queue.json` revision 2904 by `queue render`. Do not e
|
||||
| 63 | Multi-project verbs: task verbs across the tree | darkwing; reviewers dewey, filbert | #1556 | done | 62 done | dewey and filbert approve on the issue naming the candidate manifest; tasks, harness and every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_multi-project-sync.md` § Multi-project verbs: task verbs across the tree (row B) | lands before row 90 (decision 125): both share packages/tasks/README.md and tests/verbs.test.mjs, so row 90 rebases after this lands |
|
||||
| 64 | Managed sessions get their role's skills on both harnesses | sage; reviewers darkwing, filbert | #1557 | done | 65 done | darkwing and filbert approve on #1557 naming the candidate manifest; business, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the role's skills (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions get their role's skills on both harnesses | Landed (mos-0863/0864): 9f0039711 = c6d7d538f (range-diff =, patch-id 7e1948e2f2e9), F1 doc 6b6567b8b, packet ebfc23c43; gate-land GATE_PASSED 2384/0 on 6b6567b8b. D2 on #1557 comment 29228: claude-code coder rc13bcd555652 replied with its 4 skills, pi coder r3455505dd5da launch only (no pi credential on host, to Mos); both manifests list the 4 coder skills, sha256 = landed files. D3 window step 46de60893, D4 #1672 row 174. |
|
||||
| 65 | Model routing table: sessions resolve harness, model and thinking from one table | sage; reviewers darkwing, dewey | #1558 | done | 49 done, 131 done | Mos clears the routing table's public/ diff (decision 123, mos-0840) before in-review; darkwing and dewey approve on #1558; plugins, bus, business, harness, seat, cli, webui, test-*.sh green on Sage's rerun; landing session shows module, route, counted model, thinking; report names the mos-0840 view (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Model routing table: sessions resolve harness, model and thinking from one table | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
|
||||
| 66 | Managed sessions run the unslop check on both harnesses | sage; reviewers dewey, filbert | #1559 | in-review, round 4, request posted | 64 done | dewey and filbert approve on #1559 naming the candidate manifest; unslop, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the notice, the correction and the counts (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions run the unslop check on both harnesses | Drift: row 245 (6d5545d44) changed docs/TOOLS.md, which is in this manifest. r4c must take 245's TOOLS.md hunks before its gate and new manifest; the approved TOOLS.md blob is now stale (sage-1992 sweep). |
|
||||
| 66 | Managed sessions run the unslop check on both harnesses | sage; reviewers dewey, filbert | #1559 | in-review, round 3, request posted | 64 done | dewey and filbert approve on #1559 naming the candidate manifest; unslop, harness, cli and every test-*.sh green on Sage's gate rerun; Sage's landing session on each harness shows the notice, the correction and the counts (sage) | `docs/plans/2026-10-11_managed-session-config.md` § Managed sessions run the unslop check on both harnesses | Decision 94 hold ended (cutover live 17:21Z); restarted under decision 111 and Mos sage-0091. |
|
||||
| 67 | Decisions read follow-up: seen-then-resolve, list-before-503 and full-row tests | filbert; reviewers darkwing, dewey | #1560 | done | — | darkwing and dewey approve on #1560 naming the candidate manifest; bus, webui and every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_decisions-read-follow-up.md` § Decisions read follow-up: seen-then-resolve, list-before-503 and full-row tests | — |
|
||||
| 68 | Bus host runs an explicit Claude Code binary | sage; reviewers dewey, filbert | #1561 | done | — | Filbert and Dewey approve; cli, seat, harness suites and every scripts/test-*.sh green on Sage's gate rerun; unit reinstalled and opus probe through MOSAIC_CLAUDE_BIN (sage) | `docs/plans/2026-10-11_bus-claude-binary.md` § Bus host runs an explicit Claude Code binary | — |
|
||||
| 69 | Terminal app follow-up: narrow-terminal guard test, hidden-box way out and width tests | darkwing; reviewers dewey, filbert | #1562 | done | 55 done | dewey and filbert approve on the issue naming the candidate manifest; cli and queue node suites plus every scripts/test-*.sh green on Sage's gate rerun (sage) | `docs/plans/2026-10-11_terminal-app-follow-up.md` § Terminal app follow-up: narrow-terminal guard test, hidden-box way out and width tests | Sage accepts the two shortened texts in round 1 (hidden box way out, status line), both forced by the 40x10 frame. Follow-up at landing: when help is cut to fit, draw() points at mosaic inbox --json though help has no whole text; open it as its own row (build.md Found, not fixed) |
|
||||
@@ -230,62 +230,48 @@ Generated from `docs/plans/queue.json` revision 2904 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 | 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 | done | 190 done, 226 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) | LANDED b65014647 (pushed 361ba2387 tip). Patch-id 656e43ff25de = 4ced5e63d..37171f65b; verify-commit ok, 5 paths. Combined gate with 213 on 38b83ea5c+paths 09:45:52-09:57:52Z: business 200/0 bus 542/0 harness 140/0 cli 663/0, all 10 scripts green, L1 killed. N1-N3 to DEFERRED next; follow-up darkwing/row195-n1 1e3c4767c. |
|
||||
| 194 | agent lifecycle from the Console and the CLI (create, launch, terminate, regenerate, delete) with a per-agent hire/fire veto (MVP T1.5, Vikunja #414/#415) | honker; reviewers webby | #1692 | in-progress | — | Webby approves DESIGN.md then the manifest; bus, cli, business and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto | — |
|
||||
| 195 | row 195: the Case 1 install on 10.1.1.53 as mosaic under /srv/mosaic, reusing C2-13's scripts by input under row 190's canon (Q181, Q182/Q183, Q185; ops-01 root steps under #423 step 1) | darkwing; reviewers duckworth | #1693 | briefed | 190 done, 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 6, 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 | r7 APPROVED (webby-0074 rev 2822). r7 proof sage-1973 11:50-11:51Z STOPPED at solo-a pkg-cli (#1695 c30044): gate-lane-image.test.mjs:93 and :103 call docker via PATH, which in a lane is the guard shim; composeFixture env drops MOSAIC_GATE_DOCKER_RECORD, shim exits 2. business 200/0, bus 596/0, cli 197/2. r8 Herb: test-only fix + run under a lane-shaped PATH. N10 still open. |
|
||||
| 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 | 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). |
|
||||
| 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) | — |
|
||||
| 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 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) | — |
|
||||
| 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) | — |
|
||||
| 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 | done | — | Duckworth approves DESIGN.md, then the manifest; bus, business, cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_host-credentials.md` § Brief: agent host access with sudo through the credential store (#424) | Landed f5753c0e8 on refactor (base 9d165be8c), pushed. Patch-id 723b1c83 matches git diff ad95c888c..0485384e9 on the 23 manifest paths; landed files pass r5 manifest 688d38b9. Duckworth approve r2 rev 2592, r3 29851. Mutants 52/53, M22b equivalent (rev 2584). Gate on ad95c888c+patch: packages 2796/0, scripts 429/0; landed blobs equal the gate tree. |
|
||||
| 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. |
|
||||
| 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 | done | — | 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 | Landed ee7b2f137 (pushed 09:13Z) on f6c70f944, alone per mos-1162: the 21 manifest paths from a2c72ac95. Patch-id ea84d3e35470 equals the candidate's; blobs and modes match; verify-commit ok (21 paths). No 212 path changed after c771a36f4. Gate: Sage's landing gate 08:51-09:03Z on 4ced5e63d plus 212's and 197's paths, GATE_PASSED, node 981/0, selftests 429/0. Gosalyn r3 slot 451/0, test-queue 28/0. Row 197 waits for r6 (lane proof stopped, sage-1881). |
|
||||
| 213 | Case 2 C2-08 creative roles, persona file and block, M1, M2, M6, M10, prompt passed by file | morgana; reviewers webby | #1714 | done | — | 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 | LANDED 361ba2387 (39 paths incl. 4 packet files). Patch-id f313e69b6f7e = c771a36f4..2b3e67e36; verify-commit ok, 35 manifest paths. Same combined gate as 195: business 200/0 bus 542/0 harness 140/0 cli 663/0, test-config 31/0 and all 10 scripts green, models mutant L1 killed. Webby N2 is row 235 acceptance (mos-1163). |
|
||||
| 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 | — |
|
||||
| 214 | Case 2 C2-11a editor wikilink node, after measuring whether [[ round-trips | rocko; reviewers filbert | #1715 | briefed | — | Filbert approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-11a editor wikilink node, after measuring whether [[ round-trips | — |
|
||||
| 215 | Case 2 C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | herb; reviewers filbert | #1716 | in-review, round 1, request posted | — | Filbert approves the manifest. The business, seat, cli, project, bus and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | Sage 15:01Z drift sweep after 249 landed 9de5a57ab (+7b9737953): manifest 8d5695d7 shares packages/bus/src/broker.mjs with 249, merge-tree was clean. Order stays 197 then 215 (sage-2029, sage-2030): after 197 lands Herb rebases 215 on that tip for a merge-scoped round with Filbert (broker.mjs from 249, stack-release.test.mjs from 197), then Sage's full landing gate. Rows 13, 66, 197, 202 have no overlap. |
|
||||
| 215 | Case 2 C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | herb; reviewers filbert | #1716 | briefed | — | Filbert approves the manifest. The business, seat, cli, project, bus and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-04 the library entry in the business file, consumer skips, M9, mirror, library.file and library.status | — |
|
||||
| 216 | Case 2 C2-05a lookup index and queries | researcher; reviewers filbert | #1717 | briefed | — | Filbert approves the manifest. The library suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05a lookup index and queries | — |
|
||||
| 217 | Case 2 C2-06 the cited-reply guard, the page store and the three notices | launchpad; reviewers webby | #1718 | briefed | — | Webby approves the manifest. The library, bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-06 the cited-reply guard, the page store and the three notices | — |
|
||||
| 218 | Case 2 C2-12 the setup sheet for Melanie and the survey run sheet | sage; reviewers webby | #1719 | done | — | Doc only. Webby approves it against C2-01's real flags. Before landing, grep `packages/*/tests` and `scripts/test-*.sh` to confirm no test reads either file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12 the setup sheet for Melanie and the survey run sheet | LANDED f8276c5eb (pushed, on Darkwing sessions b65f3a0e8). Webby APPROVE r1-r3 (revs 2848, 2856, 2860; r3 candidate 1a38baf5af2a = 89ba79d94). Patch-id 18ba1e40ad29 seat 5c6f73ed7..89ba79d94 equals landed b65f3a0e8..f8276c5eb; three blobs equal 89ba79d94. Gate: doc only, 0 test readers (control 3). Melanie handover waits on C2-06 and Jason (#419). |
|
||||
| 218 | Case 2 C2-12 the setup sheet for Melanie and the survey run sheet | sage; reviewers webby | #1719 | briefed | — | Doc only. Webby approves it against C2-01's real flags. Before landing, grep `packages/*/tests` and `scripts/test-*.sh` to confirm no test reads either file. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-12 the setup sheet for Melanie and the survey run sheet | — |
|
||||
| 219 | Case 2 C2-09a one thread per creative instance | rocko; reviewers dewey | #1720 | briefed | — | Dewey approves the manifest. The bus, cli and webui suites are green on Sage's gate, along with every `scripts/test-*.sh`. Watch for hash pins on `bus.js` and `broker.mjs` (`packages/bus/tests/channel-proof.test.mjs` pins a webui file). (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-09a one thread per creative instance | — |
|
||||
| 220 | Case 2 C2-10a no raw session view (M7) and no raw Discord engine (M8) | researcher; reviewers filbert | #1721 | briefed | — | Filbert approves the manifest. The conversation and discord suites and `scripts/test-discord.sh` are green on Sage's gate, along with every `scripts/test-*.sh`. No test reaches a Discord server. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10a no raw session view (M7) and no raw Discord engine (M8) | — |
|
||||
| 221 | Case 2 C2-20 Bean and Chet persona import, with a count-only dry run | gosalyn; reviewers webby | #1722 | done | — | 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 | LANDED 300766450, pushed. Cand 8f403d0d4, manifest 20f05e3a6ad5, 22 paths, verify-commit ok, patch-id dfbb754c6fb4 equals 62ddc96cc..8f403d0d4. Gate 11:07:50-11:19Z on b1fece6f0: test file alone 11/0, harness 140/0, cli 677/0, all scripts green. Brief Gate met: webby-0073 approve, test file alone, cli, scripts. No live run in the Gate; the import itself waits on Jason. N1 N2 N4 go to C2-20b, N3 to DEFERRED. |
|
||||
| 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 | sage-2024 on megavolt-0089: core.createObject=rename in the fetch argv is accepted as a brief departure, since C2-04 inspectMirror refuses nlink 2. Dewey checks it. The askpass env keys, export SAFE in project/src/git.mjs, bus/tests/library-sync.test.mjs and the SURVEY-RUN user line are accepted. The 5 s client timeout residual goes in BUILD.md and Dewey rules fix or defer. Slot after 197 proof 3 and the 249 gate. |
|
||||
| 221 | Case 2 C2-20 Bean and Chet persona import, with a count-only dry run | gosalyn; reviewers webby | #1722 | briefed | — | Webby approves the manifest. The test file runs green on its own and the cli suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20 Bean and Chet persona import, with a count-only dry run | — |
|
||||
| 222 | Case 2 C2-03a fetch-only sync | megavolt; reviewers dewey | #1723 | briefed | — | Dewey approves the manifest. The library and bus suites are green on Sage's gate, along with every `scripts/test-*.sh`, and the mutant is killed. A search of the test run's output for the fixture token finds nothing. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-03a fetch-only sync | — |
|
||||
| 223 | Case 2 C2-07 runner retry on a guard refusal, and the runner's notices | researcher; reviewers filbert | #1724 | briefed | — | Filbert approves the manifest. The harness suite is green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-07 runner retry on a guard refusal, and the runner's notices | — |
|
||||
| 224 | Case 2 C2-05b broker lookup verbs and typed tools | rocko; reviewers dewey | #1725 | briefed | — | Dewey approves the manifest. The bus and harness suites are green on Sage's gate, along with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-05b broker lookup verbs and typed tools | — |
|
||||
| 225 | Case 2 C2-10b Console chat: cited items and the status line | researcher; reviewers dewey | #1726 | briefed | — | Dewey approves the manifest. The webui suite is green on Sage's gate, along with every `scripts/test-*.sh`. Grep for hash pins on `bus.js` and `serve.mjs` before in-review. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-10b Console chat: cited items and the status line | — |
|
||||
| 226 | Case 2 C2-13 install script and precheck for the host | gosalyn; reviewers webby | #1727 | briefed | 215 done, 221 done, 222 done, 230 done | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. The run on the host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13b install script and library precheck | — |
|
||||
| 226 | Case 2 C2-13 install script and precheck for the host | gosalyn; reviewers webby | #1727 | briefed | — | Webby approves the manifest. The test file and the cli suite are green on Sage's gate, along with every `scripts/test-*.sh`. The run on the host is a separate window with Jason's go. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-13b install script and library precheck | — |
|
||||
| 227 | Case 2 C2-14 end-to-end acceptance on a scratch root | launchpad; reviewers dewey | #1728 | briefed | — | Dewey runs the script and approves. It is green on Sage's gate with every `scripts/test-*.sh`. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-14 end-to-end acceptance on a scratch root | — |
|
||||
| 228 | Row A: ZAI_API_KEY passes through ENV_ALLOW to sessions, README line, pass-through test (XS, Haiku high) | researcher; reviewers webby | #1729 | done | — | 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 | Landed 86249ac55 (pushed 07:25Z) on 59f817c32: the 3 manifest paths from cf18c42b9. Patch-id b6ffe31feb0d equals the candidate's; modes and blobs match; verify-commit ok (3 paths). Sage's combined 228+238 gate on c515273db plus both rows' 6 paths, 07:11:30Z to 07:23:34Z: seat 165/0, cli 640/0, all ten scripts/test-*.sh 585/0. |
|
||||
| 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 | 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 | done | 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 | LANDED 29ce1e360 (sage gate 10:05:31-10:07:52Z GREEN on 62ddc96cc: library 45/0, every scripts/test-*.sh). verify-commit ok, 9 paths; patch-id 59b98b1b3e00 equals fefd54e13..f37a65857. Pushed. Webby N4 stays as restored (sage-1917), doc-only. |
|
||||
| 232 | Installer I1: stack-release external units and stack-copy bundle copies (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1734 | done | — | Filbert approves the manifest. The cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I1 stack-release external units and bundle copies | Landed e65a3ab4d on refactor (base 0444acd08), pushed. Patch-id 5adfbab9 matches git diff 8fede5fc6..da2da2f6c on the 9 manifest paths; tree entries (modes too) equal da2da2f6c, files pass manifest 7af3c943. Filbert approve r1 29829, r2 29865. Darkwing's landing gate on da2da2f6c: cli 593/0, scripts 429/0 (+156 queue node tests). Mutants 17/17. |
|
||||
| 233 | Installer I3: stack-upgrade.sh root driver and the ops-01 host run sheet (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1735 | done | 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 | Landed 57f59e0c3 on 5465fd52d (pushed 05:39Z). verify-commit matches the manifest (7 paths). Patch-id b16ac924 equals the candidate's (1a1dc80ca vs e65a3ab4d). Modes match. Sage's gate on the landing tree: cli 617/0, scripts/test-*.sh 585/0. Filbert N1, N4 and N5 go to row 240, N2 and N3 to DEFERRED. |
|
||||
| 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 | done | 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 | LANDED c771a36f4 (pushed 06:16Z 10-21) on caa4b5944: r3 c783d5e39 41 paths via temp index, modes from ls-tree. patch-id 32bbbea2bd6f equals the candidate's; verify-commit ok 41 paths. Gate on the c783d5e39 tree 06:02:12Z-06:15:26Z: 15 packages 2890/0, ten scripts/test-*.sh 585/0. Duckworth r3 approve 29935 rev 2683. N10, N12 to row 235. |
|
||||
| 235 | Machine grant row b: raise binding, executor, reconcile and revoke (#436, design r6 f9039682e, C1, N1, G27, G28) | gizmoduck; reviewers duckworth | #1741 | done | 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 | LANDED b1fece6f0, pushed (rode Darkwing SESSIONS aa19a2464). Cand ed4193316, manifest dad377ca8fe4, 13 paths, verify-commit ok, patch-id c355d2fc457d. Gate 10:30:55-10:42:43Z: bus 596/0, business 200/0, cli 666/0, all scripts green. Brief Gate met: duckworth-0089 approve, upsert mutant equivalent, row b and creative tests and mutants on Gizmoduck slot runs (38 of 39 killed, B17 equivalent). |
|
||||
| 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 | done | 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 | Landed d49b1f2bd (pushed 07:25Z) on 86249ac55: the 3 manifest paths from 4c90099ea. Patch-id b9d0bb0ee3c7 equals the candidate's; modes and blobs match; verify-commit ok (3 paths). Sage's combined 228+238 gate on c515273db plus both rows' 6 paths, 07:11:30Z to 07:23:34Z: seat 165/0, cli 640/0, all ten scripts/test-*.sh 585/0. Mutant (sso-config drops --instance) killed, survived none. Round 1 N1 stays on the #1744 carry list. |
|
||||
| 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 | done | 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 | Landed c515273db (pushed 06:57Z) on da3051ccc: the 9 manifest paths from ad1b23ab2. Patch-id 48e376efe8a4 equals the candidate's; modes and blobs match; verify-commit ok (9 paths). Sage's gate on 060a17b54 plus the 9 paths, 06:45:05Z to 06:56:21Z: cli 639/0, all ten scripts/test-*.sh 585/0. Darkwing's r2: singles 37/0, M1-M8 8/8. Filbert N1 and N2 in DEFERRED (da3051ccc); N3 by design. |
|
||||
| 241 | Row 237 follow-up: one decision lands once, a two-ticket broker land test and three mutants on the landing guards (megavolt-0069, sage-1651, P19 441) | megavolt; reviewers duckworth | #1747 | done | 237 done | Duckworth approves the manifest. The bus, seat and cli suites and every scripts/test-*.sh are green on Sage's gate, and the three mutants are killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 237 follow-up, one decision lands once, with tests at the broker | Landed 5d4a61776 on refactor, pushed (base e65a3ab4d). Blobs from 3e3ed303e on 2 paths; patch-id a1649f80 equals git diff f5753c0e8..3e3ed303e on those paths; ls-tree modes equal; manifest e8266864 verifies on the landed files. Sage slot on bbf3b1ee8: M1-M3 killed; gate 15 packages 2797/0, 10 script suites 429/0. Duckworth approve 29870 rev 2628. |
|
||||
| 242 | Row 237 follow-up N1: a retry after publish-timeout pushes once; hold the decision or kill and wait for the push, a slow-push retry test and a mutant (duckworth-0069, P19 440) | megavolt; reviewers duckworth | #1748 | done | 237 done | Duckworth approves the manifest. The bus and cli suites and every scripts/test-*.sh are green on Sage's gate, and the mutant is killed. (sage) (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: row 237 follow-up N1, a retry after publish-timeout pushes once | Landed 5fb736b92 on refactor, pushed (base 96bac588f). Blobs from 635c10924 on 3 paths; patch-id ceb741ee equals git diff 8fede5fc6..635c10924 on those paths; ls-tree modes equal; manifest 1fbfab6c verifies on the landed files. Megavolt M1/M2 killed on Sage's slot; Sage gate 15 packages 2826/0, 10 script suites 429/0. Duckworth approve 29889 rev 2639. |
|
||||
| 243 | process.test.mjs startup token refusal sets its file mode (chmod), umask sweep of packages/*/tests | morgana; reviewers webby | — | briefed | 197 done | Webby approves the manifest. The test file passes whole under umask 022 and under umask 077 on Sage's slot. (sage) | `docs/plans/2026-10-21_gate-env-rows.md` § The startup token refusal test sets its file mode | mos-1162: lands after 197 r6, whose proof runs on the unchanged test. |
|
||||
| 244 | Gate runs use a fixture PI_AUTH_FILE (DEFERRED 059577886, Webby N4) | morgana; reviewers webby | — | briefed | 197 done | Webby approves the manifest. The new test and every touched file are green on Sage's slot, along with test-task.sh and test-release.sh. (sage) | `docs/plans/2026-10-21_gate-env-rows.md` § Gate runs use a fixture pi auth file | — |
|
||||
| 245 | row 195 follow-up: Duckworth's N1-N3 on scripts/case1-accept.sh (root never runs an unchecked --node; the header lists what root runs; F6 refuses .. after the claude pin dir) | darkwing; reviewers duckworth | #1693; closes none | done | — | Duckworth approves the manifest. The cli suite and every scripts/test-*.sh are green on Sage's gate, and a mutant for each of N1 and N3 is killed. (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Row 195 follow-up: Duckworth's N1-N3 on the Case 1 acceptance script | LANDED 6d5545d44 on 856c1826b, pushed. Patch-id 36a621ac3966 equals 62ddc96cc..ef8b4439e. Gate: Duckworth r1 APPROVE rev 2849; N1 mutants 13-15 and N3 mutant 16 killed (sage-1961, darkwing-0107); Sage gate 12:24-12:35Z: case1-accept alone 23/0, cli 681/0, scripts/test-*.sh 429/0. Lands before row 246 as the brief asks. #1693 stays open for 246. |
|
||||
| 246 | row 195 window: the Case 1 acceptance run on 10.1.1.53 (carries row 195's done condition; needs a Jason or Mos go) | darkwing; reviewers duckworth | #1693 | queued | 226 done, 245 done | The window's acceptance run passes on .53, Duckworth reads its output, and Mos confirms. (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Row 195 window: the Case 1 acceptance run on 10.1.1.53 | — |
|
||||
| 247 | Case 2 C2-20b persona import follow-up: printed keys, the dry run fence check and the mkdir race | gosalyn; reviewers webby | #1749 | done | 221 done | Webby approves the manifest. The test file runs green on its own, and the three mutants are killed on Sage's slot. The cli suite and every scripts/test-*.sh are green on Sage's gate. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-20b persona import follow-up: printed keys, the dry run fence check and the mkdir race | LANDED 856c1826b on f8276c5eb, pushed. Patch-id 373ab2cc4c50 equals 6d43618a3's. Gate: Webby r1 APPROVE rev 2852; test file alone 14/0 and M1-M4 killed (sage-1971); Sage gate 12:10-12:21Z: file 14/0, cli 680/0, scripts/test-*.sh 429/0. M1/M2 at :248 not :237 (gosalyn-0111). |
|
||||
| 248 | row 235 follow-up: the grant-executor hold-add and hold-remove stubs stop when their test dir is gone (found by the 197 r8 proof) | sage; reviewers webby | #1750 | done | — | Webby approves the commit. On Sage's gate the whole grant-executor.test.mjs passes three times with no stub left, the bus suite is green, and a mutant that drops the dir check leaves a stub. (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b follow-up: the held stubs end with their test | LANDED 8a04f4aa4 on refactor, pushed. Temp-index landing of candidate 4d99c178b's one path onto bc579f7a8. No drift on the path since 540239b6b. Patch-id 84974d6b08d6 matches the candidate's. Gate per the brief: Webby APPROVE r1 rev 2883, Sage's gate 13:05-13:07Z (file 3x 37/0 no stub, bus 596/0, mutant left 2). |
|
||||
| 249 | row 235 follow-up: the grant undo records hostLineLeft when install never returned, since the remote add can outlive an abort or timeout (mos-1166) | gizmoduck; reviewers duckworth | #1751 | done | — | Duckworth approves the manifest. On Sage's gate the bus suite and the whole touched test files are green, and a mutant that drops the new catch branch is killed. (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row b follow-up: the undo can't prove the remote add has ended | LANDED 9de5a57ab on refactor, pushed with 7b9737953 (DEFERRED N1). Range-diff: 8a04f4aa4..05595794b vs 8ca064158..9de5a57ab, same 3 paths, patch-id 99551b122e66 on both. Gate sage-2026 14:53Z: grant-executor whole 39/0, bus 598/0, no stub left, C1 (drop :1025) killed by G8 and both G23. Duckworth approve c30070. |
|
||||
| 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 | — |
|
||||
| 231 | Case 2 C2-01b survey escapes, list item lines and attachment embeds (row 212 N1-N3) | gosalyn; reviewers webby | #1733 | briefed | 212 done | Webby approves the manifest. The library tests are green on Sage's gate, along with every scripts/test-*.sh. (sage) | `docs/plans/2026-10-20_case2-rows.md` § C2-01b survey escapes, list item lines and attachment embeds | — |
|
||||
| 232 | Installer I1: stack-release external units and stack-copy bundle copies (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1734 | in-progress | — | Filbert approves the manifest. The cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I1 stack-release external units and bundle copies | — |
|
||||
| 233 | Installer I3: stack-upgrade.sh root driver and the ops-01 host run sheet (MVP, Q187, Vikunja #432) | darkwing; reviewers filbert | #1735 | queued | 232 done | Filbert approves the manifest. The test file, the cli suite and every scripts/test-*.sh are green on Sage's gate, with the mutants killed. (sage) | `docs/plans/2026-10-20_installer-rows.md` § I3 the upgrade command and the host run sheet | — |
|
||||
| 234 | Machine grant row a: host enrollment, store records and migration 3i-3j (#436a, design r6 f9039682e, lands by 10-25 before the .53/.54 installs) | gizmoduck; reviewers duckworth | #1740 | queued | 210 done | Duckworth approves the manifest. The bus, business and cli suites and every scripts/test-*.sh are green on Sage's gate. The design's section 10 row a tests and mutants pass, the migration test included. (sage) (sage) | `docs/plans/2026-10-20_host-credentials.md` § Machine grant row a: enrollment, store records and migration 3i-3j | — |
|
||||
| 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 | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+142
-12086
File diff suppressed because one or more lines are too long
+2
-265
@@ -1,6 +1,6 @@
|
||||
# PRD: Mosaic Stack
|
||||
|
||||
- Status: approved, version 1.8. Jason approved draft 0.4 unchanged as
|
||||
- Status: approved, version 1.6. 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,64 +15,7 @@
|
||||
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. 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"
|
||||
never edited in place. Changes are a new version.
|
||||
- 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
|
||||
@@ -319,39 +262,6 @@ 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
|
||||
@@ -379,111 +289,8 @@ Added in version 1.7:
|
||||
- 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
|
||||
@@ -531,31 +338,6 @@ 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).
|
||||
@@ -589,13 +371,6 @@ 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
|
||||
@@ -695,37 +470,6 @@ 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:
|
||||
@@ -1181,13 +925,6 @@ 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
|
||||
|
||||
|
||||
Generated
+1
-2
@@ -10,8 +10,7 @@
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
"oauth4webapi": "3.8.8",
|
||||
"yaml": "2.9.0"
|
||||
"oauth4webapi": "3.8.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
|
||||
+1
-2
@@ -6,7 +6,6 @@
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
"oauth4webapi": "3.8.8",
|
||||
"yaml": "2.9.0"
|
||||
"oauth4webapi": "3.8.8"
|
||||
}
|
||||
}
|
||||
|
||||
+4
-17
@@ -218,11 +218,6 @@ an operator inspect content and delivery evidence after an uncertain result.
|
||||
| `credentials.issue` | `{agent,service,token,date}`; human only (row 199, #1697). Writes the token to `<configDir>/secrets/agents/<business>/<agent>/<service>` (0600, dirs 0700: tmp, fsync, rename), rewrites the agent's reference in the business file to `{"store":true,<rotateBy or expires>:date}`, then holds the token in memory, with no restart. Over an issued entry it is a rotate: the old value is gone from disk and memory when it returns. `service` is `gitea` or `vikunja` and the business file must already name it for the agent (`credential-unknown`). A token the bus already holds is `credential-leak`; a bad token `credential-format`; a bad date `credential-date`. Returns `{agent,verb,service,date,state}`, `verb` `issue` or `rotate`. The tester gets `tester-refused`. |
|
||||
| `credentials.revoke` | `{agent,service?,reason}`; human, or the tester (Q173). Drops the entry from memory, then unlinks a store file; a `file` or `env` reference keeps its file and becomes `store`, so a restart loads nothing. Without `service`, every kind the agent has. Returns `{agent,verb,services:[{service,state}]}`. |
|
||||
| `credentials.delete` | `{agent,reason}`; human only. `credentials.revoke` for every kind, then removes the agent's store dir; a file there other than a token is `credential-file`. While the agent or one of its threads holds a claim, `agent-running`. |
|
||||
| `hosts.status` | No args; the human, the tester or the business's chief (row 234, #1740). `[{host,address,port,user,sudo,state,hostKeys,installerFingerprint,checkedAt,hostLineLeft,revoking}]` from the `hosts` records, with host keys as fingerprints. Never a key. |
|
||||
| `hosts.check` | `{host}`; same callers. Runs the host's `list <user>` as `mosaic-installer` with the installer key and the pinned host keys, then sets `state` `ready` and `checkedAt` and writes `grant.host.checked`. A nonzero exit is `host-failed`, a record that changed during the run `host-changed`, a record with no installer key `host-not-enrolled`. |
|
||||
| `hosts.remove` | `{host}`; the human or the tester. Deletes the record, writes `grant.host.removed` and removes the installer key. A `host_grants` record for the host is `host-in-use`, a guarded event naming a host line left on the host `host-line-left`. |
|
||||
| `hosts.revoke` | `{agent,host,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true` (row 235, #1741, r6 section 6). Anyone else gets `revoke-refused`. The tester check reads `bus.tester` again at the call, `tester-dev-only`. One transaction deletes the `host_grants` record and writes `grant.revoked` with `hostLine:"pending"`. With no await between, the key leaves memory and its file goes, and a `host.run` or executor in flight aborts. Then `remove <user> <tag>` runs on the host and `grant.revoke.closed` records `hostLine:"removed"` or `hostLineLeft`. A revoke with no record still runs, with `granted:false`. Returns `{agent,host,tag,granted,keyFile,hostLine or hostLineLeft}`. A restart with a pending marker runs the remove once the bus is up; `hosts.status` lists its tag under `revoking`. |
|
||||
| `hosts.sweep` | `{host}`; the human or the tester (r6 X3). Runs `list <user>` and removes each listed tag this business recorded for the host in a `grant.step` or `grant.revoked` that no live grant holds. Another business's line and an unrecorded `mosaic-` line stay. Writes `grant.host.swept {host,removed,left}`; `hostLineLeft` then counts from it. Returns `{host,removed,hostLineLeft}`. |
|
||||
|
||||
Each of the three leaves one receipt in the organization's `changes.jsonl`: the actor and a
|
||||
`detail` of `{verb,agent,kind,date or reason}`, never a value. A change to the store alone writes
|
||||
@@ -709,10 +704,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
|
||||
|
||||
## Schema and verification
|
||||
|
||||
`schema.sql` is v3j, SHA-256
|
||||
`0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c`.
|
||||
It has 18 tables, 72 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3j`, WAL,
|
||||
`schema.sql` is v3i, SHA-256
|
||||
`6cde5232c57ac04dc6073112c0830c89cfc5e953d7a5727e7716263747fe8737`.
|
||||
It has 16 tables, 71 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3i`, WAL,
|
||||
quick_check and foreign-key integrity. Missing metadata refuses; it never
|
||||
reinitializes an existing file. Full synchronous WAL transactions either commit
|
||||
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
|
||||
@@ -872,14 +867,6 @@ one transaction; a failure leaves the store at 3h. It runs only through
|
||||
refuses a 3i store. The 3h fixture, `tests/fixtures/schema-3h.sql`, is pinned from
|
||||
81f26551b's tree; no live store is used.
|
||||
|
||||
Schema 3j (row 234, #1740) adds only new objects: the `hosts` and `host_grants` tables, which
|
||||
hold enrolled hosts and host grants, and the `events_grant_actor` trigger. The trigger refuses an
|
||||
`action.allowed` event whose `body.action` or `body.operation` matches `grant.*` unless its actor
|
||||
is `actor_role` NULL with `actor_run` 'grant', the broker's grant writer (#436b section 7). The
|
||||
step runs in one transaction, and a failure leaves the store at 3i. The 3i fixture,
|
||||
`tests/fixtures/schema-3i.sql`, is pinned from 8fede5fc6's tree, and `tests/grant-migration.test.mjs`
|
||||
covers the step and the trigger.
|
||||
|
||||
The managed runner calls usage.begin before an actual harness turn and usage.end
|
||||
after it, including a failed/stopped turn. Each takes only {turn: deliveredMessageId}.
|
||||
The broker requires the bound role holder and its delivery, binds run identity,
|
||||
|
||||
@@ -363,34 +363,3 @@ CREATE TABLE usage_records (
|
||||
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TABLE hosts (
|
||||
business TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
|
||||
user TEXT NOT NULL,
|
||||
sudo INTEGER NOT NULL CHECK (sudo IN (0,1)),
|
||||
host_keys TEXT NOT NULL CHECK (json_valid(host_keys) AND json_type(host_keys) = 'array' AND json_array_length(host_keys) > 0),
|
||||
installer_fingerprint TEXT,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending-install','ready')),
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
checked_at TEXT,
|
||||
PRIMARY KEY (business, host),
|
||||
CHECK (state = 'pending-install' OR (installer_fingerprint IS NOT NULL AND checked_at IS NOT NULL))
|
||||
) STRICT;
|
||||
CREATE TABLE host_grants (
|
||||
business TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
commands TEXT NOT NULL CHECK (json_valid(commands) AND json_type(commands) = 'array' AND json_array_length(commands) > 0),
|
||||
rotate_by TEXT NOT NULL,
|
||||
tag TEXT NOT NULL CHECK (length(tag) = 39 AND tag GLOB 'mosaic-*' AND NOT substr(tag, 8) GLOB '*[^0-9a-f]*'),
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
PRIMARY KEY (business, agent, host),
|
||||
FOREIGN KEY (business, host) REFERENCES hosts(business, host)
|
||||
) STRICT;
|
||||
CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'action.allowed'
|
||||
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
|
||||
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
|
||||
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
|
||||
|
||||
+10
-967
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
import { BusError } from './broker.mjs';
|
||||
import { isHostKind } from '../../business/src/vocabulary.mjs';
|
||||
// Adapt S1's validated loadBusiness + resolveInstance outputs, without loading or writing files.
|
||||
export function busBusiness(business, resolved) {
|
||||
if (!business || typeof business.id !== 'string' || !business.roles || !resolved)
|
||||
@@ -18,10 +17,7 @@ export function busBusiness(business, resolved) {
|
||||
out.roles[role].authority = structuredClone(r.limits.authority);
|
||||
// REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs.
|
||||
out.roles[role].web = (r.limits.tools ?? []).includes('web');
|
||||
// Row 210 (#1707, design D5): resolveInstance leaves host.<id> keys out of the session's
|
||||
// credentials. The bus child uses them, so they come back here from the business entry.
|
||||
const hostKeys = Object.entries(business.roles[role].credentials ?? {}).filter(([kind]) => isHostKind(kind));
|
||||
out.roles[role].credentials = { ...structuredClone(r.credentials ?? {}), ...structuredClone(Object.fromEntries(hostKeys)) };
|
||||
out.roles[role].credentials = structuredClone(r.credentials ?? {});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { BusError } from './broker.mjs';
|
||||
import { isHostKind } from '../../business/src/vocabulary.mjs';
|
||||
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete of one agent's credentials, for
|
||||
// the broker's human-only verbs. The broker checks the session, the keys and a running claim first.
|
||||
// Each verb leaves one receipt in the organization's changes.jsonl: the actor and a detail naming the
|
||||
// verb, agent, kinds, date or reason, never a value. Nothing here logs, and a result carries no token.
|
||||
const DATE_KEY = Object.freeze({ gitea: 'rotateBy', vikunja: 'expires' });
|
||||
// Row 210 (#1707, design D1): a host.<id> key takes a rotate date, as gitea does.
|
||||
const dateKey = (service) => (isHostKind(service) ? 'rotateBy' : DATE_KEY[service]);
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
@@ -25,14 +22,12 @@ export function credentialVerbs({ credentials, writer }) {
|
||||
// rotate is an issue over an entry the bus holds now; the old value is gone when this returns.
|
||||
const kind = ['not-issued', 'revoked', undefined].includes(state(instance, service)) ? 'issue' : 'rotate';
|
||||
const next = structuredClone(document);
|
||||
next.roles[agent].credentials[service] = { store: true, [dateKey(service)]: date };
|
||||
next.roles[agent].credentials[service] = { store: true, [DATE_KEY[service]]: date };
|
||||
const detail = { verb: kind, agent, kind: [service], date };
|
||||
const status = credentials.issue(instance, service, token, date, () =>
|
||||
writer.write(id, next, { revision, actor, detail }),
|
||||
);
|
||||
// Row 210 (design D9): a host key's public line and fingerprint, for ops-01's authorized_keys.
|
||||
const host = status.fingerprint ? { publicKey: status.publicKey, fingerprint: status.fingerprint } : {};
|
||||
return { agent, verb: kind, service, date: status.date, state: status.state, ...host };
|
||||
return { agent, verb: kind, service, date: status.date, state: status.state };
|
||||
}
|
||||
const services = verb === 'credentials.revoke' && args.service ? [args.service] : Object.keys(current);
|
||||
if (services.some((service) => !current[service])) deny('credential-unknown');
|
||||
@@ -46,7 +41,7 @@ export function credentialVerbs({ credentials, writer }) {
|
||||
if (moved.length) {
|
||||
const next = structuredClone(document);
|
||||
for (const service of moved)
|
||||
next.roles[agent].credentials[service] = { store: true, [dateKey(service)]: current[service][dateKey(service)] };
|
||||
next.roles[agent].credentials[service] = { store: true, [DATE_KEY[service]]: current[service][DATE_KEY[service]] };
|
||||
writer.write(id, next, { revision, actor, detail });
|
||||
} else writer.record(id, { actor, detail });
|
||||
return {
|
||||
|
||||
@@ -3,9 +3,7 @@ import {
|
||||
renameSync, unlinkSync, rmdirSync, readdirSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { credentialKind, isHostKind } from '../../business/src/vocabulary.mjs';
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
@@ -13,92 +11,14 @@ const inside = (root, path) => {
|
||||
const r = relative(root, path);
|
||||
return r === '' || (!r.startsWith('..') && !isAbsolute(r));
|
||||
};
|
||||
export const day = (value) =>
|
||||
const day = (value) =>
|
||||
typeof value === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
|
||||
!Number.isNaN(Date.parse(value)) &&
|
||||
new Date(value).toISOString().slice(0, 10) === value;
|
||||
const TOKEN = /^[-A-Za-z0-9._~]+\n?$/;
|
||||
const PEM_BEGIN = '-----BEGIN OPENSSH PRIVATE KEY-----';
|
||||
const PEM_END = '-----END OPENSSH PRIVATE KEY-----';
|
||||
// Row 210 (#1707, design D2): an unencrypted openssh-key-v1 ed25519 key, parsed with Buffer so the
|
||||
// check runs at bus start with no process. Returns the public line and what D8 searches for.
|
||||
export function parseHostKey(text) {
|
||||
if (typeof text !== 'string' || text.length > 16384 || text.includes('\r')) deny('credential-format');
|
||||
const lines = text.replace(/\n$/, '').split('\n');
|
||||
if (lines.length < 3 || lines[0] !== PEM_BEGIN || lines.at(-1) !== PEM_END) deny('credential-format');
|
||||
const body = lines.slice(1, -1);
|
||||
if (body.some((l) => !/^[A-Za-z0-9+/]+={0,2}$/.test(l))) deny('credential-format');
|
||||
const joined = body.join('');
|
||||
const blob = Buffer.from(joined, 'base64');
|
||||
if (blob.toString('base64') !== joined) deny('credential-format');
|
||||
let at = 0;
|
||||
const bytes = (n) => {
|
||||
if (n < 0 || at + n > blob.length) deny('credential-format');
|
||||
const out = blob.subarray(at, at + n);
|
||||
at += n;
|
||||
return out;
|
||||
};
|
||||
const u32 = () => bytes(4).readUInt32BE(0);
|
||||
const str = () => bytes(u32());
|
||||
const magic = Buffer.from('openssh-key-v1\0');
|
||||
if (!bytes(magic.length).equals(magic)) deny('credential-format');
|
||||
if (str().toString() !== 'none' || str().toString() !== 'none' || str().length !== 0) deny('credential-format');
|
||||
if (u32() !== 1) deny('credential-format');
|
||||
const publicBlob = str();
|
||||
const priv = str();
|
||||
if (at !== blob.length) deny('credential-format');
|
||||
if (
|
||||
publicBlob.length !== 51 ||
|
||||
publicBlob.readUInt32BE(0) !== 11 ||
|
||||
publicBlob.subarray(4, 15).toString() !== 'ssh-ed25519' ||
|
||||
publicBlob.readUInt32BE(15) !== 32
|
||||
)
|
||||
deny('credential-format');
|
||||
const pub = publicBlob.subarray(19);
|
||||
// The private section: check1, check2, key type, public, private (seed then public), comment, padding.
|
||||
let p = 0;
|
||||
const take = (n) => {
|
||||
if (n < 0 || p + n > priv.length) deny('credential-format');
|
||||
const out = priv.subarray(p, p + n);
|
||||
p += n;
|
||||
return out;
|
||||
};
|
||||
const pu32 = () => take(4).readUInt32BE(0);
|
||||
const pstr = () => take(pu32());
|
||||
if (pu32() !== pu32()) deny('credential-format');
|
||||
if (pstr().toString() !== 'ssh-ed25519') deny('credential-format');
|
||||
if (!pstr().equals(pub)) deny('credential-format');
|
||||
const secret = pstr();
|
||||
if (secret.length !== 64 || !secret.subarray(32).equals(pub)) deny('credential-format');
|
||||
pstr();
|
||||
const padding = priv.subarray(p);
|
||||
if (padding.length >= 8 || padding.some((b, i) => b !== i + 1) || priv.length % 8 !== 0) deny('credential-format');
|
||||
const seed = Buffer.from(secret.subarray(0, 32));
|
||||
const publicLine = 'ssh-ed25519 ' + publicBlob.toString('base64');
|
||||
const fingerprint = 'SHA256:' + createHash('sha256').update(publicBlob).digest('base64').replace(/=+$/, '');
|
||||
// D8: each body line of 24 characters or more, the whole body, and the seed as hex and as base64
|
||||
// at each of the three byte offsets (only the characters that depend on the seed alone).
|
||||
const needles = [...body.filter((l) => l.length >= 24), joined, seed.toString('hex')];
|
||||
for (const offset of [0, 1, 2]) {
|
||||
const encoded = Buffer.concat([Buffer.alloc(offset), seed]).toString('base64');
|
||||
needles.push(encoded.slice(Math.ceil((8 * offset) / 6), Math.floor((8 * (offset + 32)) / 6)));
|
||||
}
|
||||
return { text: lines.join('\n') + '\n', publicLine, fingerprint, seed, needles };
|
||||
}
|
||||
// D8: refuses credential-leak when the value's JSON text holds any part of the key.
|
||||
export function assertCleanKey(value, key) {
|
||||
let encoded;
|
||||
try {
|
||||
encoded = JSON.stringify(value);
|
||||
} catch {
|
||||
deny('invalid-request');
|
||||
}
|
||||
if (encoded === undefined) return;
|
||||
if (key.needles.some((n) => encoded.includes(n))) deny('credential-leak');
|
||||
}
|
||||
// Row 199 (#1697, design D1, D4): a store dir is the stack instance's user's own and nobody else's.
|
||||
export const privateDir = (path, { missing = false } = {}) => {
|
||||
const privateDir = (path, { missing = false } = {}) => {
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(path);
|
||||
@@ -114,8 +34,6 @@ export class Credentials {
|
||||
// Row 199: every reference the bus loaded, so issue, rotate, revoke and status work per entry.
|
||||
#refs = new Map();
|
||||
#revoked = new Set();
|
||||
// Row 210 (design D5, D9): instance:host.<id> -> the AbortController of the call in flight.
|
||||
#inflight = new Map();
|
||||
#storeRoot;
|
||||
#repoRoots;
|
||||
#dataRoot;
|
||||
@@ -128,70 +46,28 @@ export class Credentials {
|
||||
try {
|
||||
for (const [instance, services] of Object.entries(references))
|
||||
for (const [service, ref] of Object.entries(services)) {
|
||||
const date = this.#checkRef(instance, service, ref);
|
||||
if (!['gitea', 'vikunja'].includes(service) || !ref || typeof ref !== 'object')
|
||||
deny('credential-reference');
|
||||
const date = service === 'vikunja' ? ref.expires : ref.rotateBy;
|
||||
if (!day(date)) deny('credential-date');
|
||||
if (
|
||||
(ref.service !== undefined && ref.service !== service) ||
|
||||
Object.keys(ref).some(
|
||||
(k) => !['service', 'file', 'env', 'store', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
|
||||
) ||
|
||||
Boolean(ref.file) === Boolean(ref.env) ||
|
||||
(ref.store !== undefined && (ref.store !== true || ref.file !== this.#storePath(instance, service)))
|
||||
)
|
||||
deny('credential-reference');
|
||||
this.#refs.set(instance + ':' + service, { ref, date });
|
||||
const value = this.#read(ref, env, service);
|
||||
if (value !== null) this.#tokens.set(instance + ':' + service, { ...value, date });
|
||||
const token = this.#read(ref, env);
|
||||
if (token !== null) this.#tokens.set(instance + ':' + service, { token, date });
|
||||
}
|
||||
} catch (e) {
|
||||
this.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
// One reference's checks, for the constructor and register (row 235, r6 X4). Returns its date.
|
||||
#checkRef(instance, service, ref) {
|
||||
if (!credentialKind(service) || !ref || typeof ref !== 'object') deny('credential-reference');
|
||||
const date = service === 'vikunja' ? ref.expires : ref.rotateBy;
|
||||
if (!day(date)) deny('credential-date');
|
||||
if (
|
||||
(ref.service !== undefined && ref.service !== service) ||
|
||||
Object.keys(ref).some(
|
||||
(k) => !['service', 'file', 'env', 'store', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
|
||||
) ||
|
||||
Boolean(ref.file) === Boolean(ref.env) ||
|
||||
(ref.store !== undefined && (ref.store !== true || ref.file !== this.#storePath(instance, service)))
|
||||
)
|
||||
deny('credential-reference');
|
||||
return date;
|
||||
}
|
||||
// Row 235 (#1741, r6 X4): the grant executor adds a reference before `issue`, which refuses an unknown
|
||||
// one. It reads no file, so a key file a failed revoke left can't come back. An entry with a live
|
||||
// token refuses, and an entry with none, revoked or never issued, is replaced.
|
||||
register(instance, service, ref) {
|
||||
const k = instance + ':' + service;
|
||||
if (this.#tokens.has(k)) deny('credential-reference');
|
||||
const date = this.#checkRef(instance, service, ref);
|
||||
this.#refs.set(k, { ref, date });
|
||||
this.#revoked.delete(k);
|
||||
}
|
||||
// r6 X3, X4: the executor's undo for a reference it registered. An entry with a live token refuses.
|
||||
unregister(instance, service) {
|
||||
const k = instance + ':' + service;
|
||||
if (this.#tokens.has(k)) deny('credential-reference');
|
||||
this.#refs.delete(k);
|
||||
this.#revoked.delete(k);
|
||||
}
|
||||
// r6 X5: the executor holds the entry's #inflight slot for its steps, so a host.run refuses host-busy
|
||||
// and revoke's abort() reaches the executor. A slot already held refuses host-busy. Returns the signal.
|
||||
claim(instance, service) {
|
||||
const k = instance + ':' + service;
|
||||
if (this.#inflight.has(k)) deny('host-busy');
|
||||
const controller = new AbortController();
|
||||
this.#inflight.set(k, controller);
|
||||
return controller.signal;
|
||||
}
|
||||
// Frees the slot only if `signal` is the claim's own, so a late release can't free a later claim.
|
||||
release(instance, service, signal) {
|
||||
const k = instance + ':' + service;
|
||||
if (this.#inflight.get(k)?.signal === signal) this.#inflight.delete(k);
|
||||
}
|
||||
// r6 X5: hosts.revoke with no reference to revoke. Aborts a live claim or call and says whether there
|
||||
// was one.
|
||||
abortClaim(instance, service) {
|
||||
const controller = this.#inflight.get(instance + ':' + service);
|
||||
controller?.abort();
|
||||
return { pending: Boolean(controller) };
|
||||
}
|
||||
#storePath(instance, service) {
|
||||
if (!this.#storeRoot) deny('credential-reference');
|
||||
const [business, agent, ...rest] = instance.split('/');
|
||||
@@ -199,7 +75,7 @@ export class Credentials {
|
||||
return join(this.#storeRoot, business, agent, service);
|
||||
}
|
||||
// One token, read and checked, or null for a store reference with no file yet (design D3a).
|
||||
#read(ref, env, service) {
|
||||
#read(ref, env) {
|
||||
let token;
|
||||
if (ref.store) {
|
||||
const agentDir = dirname(ref.file);
|
||||
@@ -261,18 +137,13 @@ export class Credentials {
|
||||
if (!/^[A-Z_][A-Z0-9_]*$/.test(ref.env)) deny('credential-reference');
|
||||
token = env[ref.env];
|
||||
}
|
||||
return Credentials.#format(service, token);
|
||||
return Credentials.#format(token);
|
||||
}
|
||||
// { token } for gitea and vikunja, { token, key } for host.<id> (design D2).
|
||||
static #format(service, token) {
|
||||
if (isHostKind(service)) {
|
||||
const key = parseHostKey(token);
|
||||
return { token: key.text, key };
|
||||
}
|
||||
static #format(token) {
|
||||
if (typeof token !== 'string' || token.length > 16384 || !TOKEN.test(token)) deny('credential-format');
|
||||
token = token.replace(/\n$/, '');
|
||||
if (token.length < 16) deny('credential-format');
|
||||
return { token };
|
||||
return token;
|
||||
}
|
||||
#known(instance, service) {
|
||||
const known = this.#refs.get(instance + ':' + service);
|
||||
@@ -285,7 +156,7 @@ export class Credentials {
|
||||
issue(instance, service, token, date, commit = () => {}) {
|
||||
this.#known(instance, service);
|
||||
if (!day(date)) deny('credential-date');
|
||||
const value = Credentials.#format(service, token);
|
||||
token = Credentials.#format(token);
|
||||
const file = this.#storePath(instance, service);
|
||||
const agentDir = dirname(file);
|
||||
mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 });
|
||||
@@ -303,7 +174,7 @@ export class Credentials {
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
writeSync(fd, value.key ? value.token : value.token + '\n');
|
||||
writeSync(fd, token + '\n');
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
@@ -317,7 +188,7 @@ export class Credentials {
|
||||
}
|
||||
commit();
|
||||
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
|
||||
this.#tokens.set(instance + ':' + service, { ...value, date });
|
||||
this.#tokens.set(instance + ':' + service, { token, date });
|
||||
this.#revoked.delete(instance + ':' + service);
|
||||
return this.status().find((e) => e.instance === instance && e.service === service);
|
||||
}
|
||||
@@ -327,8 +198,6 @@ export class Credentials {
|
||||
const { ref } = this.#known(instance, service);
|
||||
this.#tokens.delete(instance + ':' + service);
|
||||
this.#revoked.add(instance + ':' + service);
|
||||
// Row 210 (design D9): a host.run in flight for this agent and host is killed first.
|
||||
this.#inflight.get(instance + ':' + service)?.abort();
|
||||
if (ref.store) {
|
||||
try {
|
||||
unlinkSync(ref.file);
|
||||
@@ -350,16 +219,16 @@ export class Credentials {
|
||||
if (e.code === 'ENOENT') return { services };
|
||||
deny('credential-file');
|
||||
}
|
||||
if (names.some((n) => !credentialKind(n.replace(/\.tmp$/, '')))) deny('credential-file');
|
||||
if (names.some((n) => !/^(gitea|vikunja)(\.tmp)?$/.test(n))) deny('credential-file');
|
||||
for (const n of names) unlinkSync(join(agentDir, n));
|
||||
rmdirSync(agentDir);
|
||||
return { services };
|
||||
}
|
||||
// The agent-facing use (design D5): the key comes only from the broker's verified session.
|
||||
async useFor(session, service, operation, options) {
|
||||
async useFor(session, service, operation) {
|
||||
if (!session || typeof session.business !== 'string' || typeof session.role !== 'string' || session.role.startsWith('@'))
|
||||
deny('credential-not-yours');
|
||||
return this.use(session.business + '/' + session.role, service, operation, options);
|
||||
return this.use(session.business + '/' + session.role, service, operation);
|
||||
}
|
||||
assertClean(value) {
|
||||
let encoded;
|
||||
@@ -370,52 +239,24 @@ export class Credentials {
|
||||
}
|
||||
for (const { token } of this.#tokens.values()) if (encoded?.includes(token)) deny('credential-leak');
|
||||
}
|
||||
// Row 210 (design D5): whether a host.run for this agent and host is in flight.
|
||||
busy(instance, service) {
|
||||
return this.#inflight.has(instance + ':' + service);
|
||||
}
|
||||
// Row 210 (design D5, D8, D9): a host.<id> key runs one call at a time per agent and host, gets
|
||||
// an abort signal that revoke fires, and is checked against `input` before and the result after.
|
||||
async use(instance, service, operation, { input } = {}) {
|
||||
const k = instance + ':' + service;
|
||||
const entry = this.#tokens.get(k);
|
||||
async use(instance, service, operation) {
|
||||
const entry = this.#tokens.get(instance + ':' + service);
|
||||
if (!entry) deny('credential-unavailable');
|
||||
if (service === 'vikunja' && Date.now() >= Date.parse(entry.date + 'T00:00:00Z'))
|
||||
deny('credential-expired');
|
||||
if (!entry.key) {
|
||||
let result;
|
||||
try {
|
||||
result = await operation(entry.token);
|
||||
} catch (e) {
|
||||
// Row 199: a host step's own refusal (publish) keeps its code; anything else is the service's.
|
||||
if (e instanceof BusError) throw e;
|
||||
deny('service-failed');
|
||||
}
|
||||
this.assertClean(result);
|
||||
return result;
|
||||
}
|
||||
if (this.#inflight.has(k)) deny('host-busy');
|
||||
assertCleanKey(input, entry.key);
|
||||
const controller = new AbortController();
|
||||
this.#inflight.set(k, controller);
|
||||
let result;
|
||||
try {
|
||||
result = await operation(entry.token, { key: entry.key, signal: controller.signal });
|
||||
result = await operation(entry.token);
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) deny('credential-unavailable');
|
||||
// Row 199: a host step's own refusal (publish) keeps its code; anything else is the service's.
|
||||
if (e instanceof BusError) throw e;
|
||||
deny('host-failed');
|
||||
} finally {
|
||||
this.#inflight.delete(k);
|
||||
deny('service-failed');
|
||||
}
|
||||
if (controller.signal.aborted) deny('credential-unavailable');
|
||||
assertCleanKey(result, entry.key);
|
||||
this.assertClean(result);
|
||||
return result;
|
||||
}
|
||||
status() {
|
||||
return [...this.#refs.entries()].map(([key, { date }]) => {
|
||||
const hostKey = this.#tokens.get(key)?.key;
|
||||
const i = key.lastIndexOf(':'),
|
||||
service = key.slice(i + 1),
|
||||
left = Date.parse(date + 'T00:00:00Z') - Date.now();
|
||||
@@ -423,12 +264,11 @@ export class Credentials {
|
||||
instance: key.slice(0, i),
|
||||
service,
|
||||
date,
|
||||
...(hostKey ? { fingerprint: hostKey.fingerprint, publicKey: hostKey.publicLine } : {}),
|
||||
state: !this.#tokens.has(key)
|
||||
? this.#revoked.has(key)
|
||||
? 'revoked'
|
||||
: 'not-issued'
|
||||
: service !== 'vikunja'
|
||||
: service === 'gitea'
|
||||
? left <= 0
|
||||
? 'rotation-due'
|
||||
: 'valid'
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
// Row 234 (#1740, #436a row a): the trusted half of host enrollment, run in the bus child. The broker
|
||||
// gets it as `hostGrant` and holds every store write. This module holds what touches the network or
|
||||
// a key file: the raise's ssh-keyscan (E1), the installer key pair (E2 steps 2 and 3) and the installer's
|
||||
// `list` for hosts.check (E4). Row 235 (#1741, #436 row b) adds the host grant raise binding (R4), the
|
||||
// grant executor's remote steps (X2 steps 2 to 4 and the `remove` undo) and `grantKeys`, the broker's
|
||||
// narrow handle on Credentials.
|
||||
//
|
||||
// The installer key lives at <configDir>/secrets/hosts/<business>/<hostId>/installer, 0600 in 0700 dirs,
|
||||
// written as 210's issue writes (tmp, fsync, rename). It is never read into an event or a message.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
closeSync, constants, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync,
|
||||
rmdirSync, unlinkSync, writeSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { day, parseHostKey, privateDir } from './credentials.mjs';
|
||||
import { runHost, HOST_LIMITS } from './host-run.mjs';
|
||||
import { checkAddress, checkCommands, HOST_KEY, USER } from '../../business/src/hosts.mjs';
|
||||
import { canonicalJson } from '../../business/src/util.mjs';
|
||||
import { HOST_ID, ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
const PATH = '/usr/bin:/bin';
|
||||
const KILL_WITH_PARENT = ['/usr/bin/setpriv', '--pdeathsig', 'KILL', '--'];
|
||||
// E1: the users no enrollment may name. The host writer refuses them again (tools/host/mosaic-keys-write).
|
||||
export const RESERVED_USERS = Object.freeze(['root', 'localadmin', 'mosaic-installer']);
|
||||
const FINGERPRINT = /^SHA256:[A-Za-z0-9+/]{43}$/;
|
||||
// E1: the three key types the keyscan asks for, so any other fingerprint can't match.
|
||||
const SCAN_TYPES = 'ed25519,ecdsa,rsa';
|
||||
export const INSTALLER_USER = 'mosaic-installer';
|
||||
const INSTALLER_AGENT = '@installer';
|
||||
const SCAN_MS = 10000;
|
||||
|
||||
// X2 step 2: `mosaic-` and the first 32 hex digits of sha256 over business, agent and host id joined
|
||||
// with NUL. Names match ID_PATTERN or HOST_ID, neither holds NUL, so the join is one to one.
|
||||
export function grantTag(business, agent, hostId) {
|
||||
return 'mosaic-' + createHash('sha256').update([business, agent, hostId].join('\0')).digest('hex').slice(0, 32);
|
||||
}
|
||||
export const installerTag = (business, hostId) => grantTag(business, INSTALLER_AGENT, hostId);
|
||||
|
||||
// The OpenSSH fingerprint of a "<type> <base64>" line: sha256 over the decoded blob.
|
||||
export function fingerprint(line) {
|
||||
const blob = Buffer.from(line.split(' ')[1], 'base64');
|
||||
return 'SHA256:' + createHash('sha256').update(blob).digest('base64').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
// E1's binding. Anything off refuses invalid-request; the broker adds the chief, action and enrolled
|
||||
// checks. Returns a fresh object with only the bound keys.
|
||||
export function enrollCheck(grant) {
|
||||
const allowed = ['method', 'host', 'address', 'port', 'user', 'sudo', 'fingerprints'];
|
||||
if (!grant || typeof grant !== 'object' || Array.isArray(grant)) deny('invalid-request');
|
||||
const own = Object.keys(grant);
|
||||
if (own.some((k) => !allowed.includes(k)) || allowed.some((k) => !own.includes(k))) deny('invalid-request');
|
||||
if (grant.method !== 'host-enroll') deny('invalid-request');
|
||||
if (typeof grant.host !== 'string' || !HOST_ID.test(grant.host)) deny('invalid-request');
|
||||
try {
|
||||
checkAddress(grant.address, 'grant.address');
|
||||
} catch {
|
||||
deny('invalid-request');
|
||||
}
|
||||
if (!Number.isInteger(grant.port) || grant.port < 1 || grant.port > 65535) deny('invalid-request');
|
||||
if (typeof grant.user !== 'string' || !USER.test(grant.user) || RESERVED_USERS.includes(grant.user)) deny('invalid-request');
|
||||
if (typeof grant.sudo !== 'boolean') deny('invalid-request');
|
||||
const prints = grant.fingerprints;
|
||||
if (!Array.isArray(prints) || prints.length < 1 || prints.length > 3) deny('invalid-request');
|
||||
if (prints.some((f) => typeof f !== 'string' || !FINGERPRINT.test(f)) || new Set(prints).size !== prints.length) deny('invalid-request');
|
||||
return { method: 'host-enroll', host: grant.host, address: grant.address, port: grant.port, user: grant.user, sudo: grant.sudo, fingerprints: [...prints] };
|
||||
}
|
||||
|
||||
// R4's binding for a host grant. Anything off refuses invalid-request. `today` is the bus clock's UTC
|
||||
// day. rotateBy must be a real calendar day after it (210's day check, credentials.mjs; sage-1782). The
|
||||
// broker checks the agent, its definition and the host inside the raise transaction. Returns a fresh
|
||||
// object with only the bound keys.
|
||||
export function hostGrantCheck(grant, today) {
|
||||
const allowed = ['method', 'agent', 'host', 'commands', 'rotateBy'];
|
||||
if (!grant || typeof grant !== 'object' || Array.isArray(grant)) deny('invalid-request');
|
||||
const own = Object.keys(grant);
|
||||
if (own.some((k) => !allowed.includes(k)) || allowed.some((k) => !own.includes(k))) deny('invalid-request');
|
||||
if (grant.method !== 'host') deny('invalid-request');
|
||||
if (typeof grant.agent !== 'string' || !ID_PATTERN.test(grant.agent)) deny('invalid-request');
|
||||
if (typeof grant.host !== 'string' || !HOST_ID.test(grant.host)) deny('invalid-request');
|
||||
let commands;
|
||||
try {
|
||||
commands = checkCommands(grant.commands, 'grant.commands');
|
||||
} catch {
|
||||
deny('invalid-request');
|
||||
}
|
||||
if (!day(grant.rotateBy) || grant.rotateBy <= today) deny('invalid-request');
|
||||
return { method: 'host', agent: grant.agent, host: grant.host, commands, rotateBy: grant.rotateBy };
|
||||
}
|
||||
|
||||
// R4's definitionSha256: sha256 over the canonical JSON of the definition the business loaded, since
|
||||
// the broker doesn't read roles/ (sage-0590). A change to any field the loader keeps changes it.
|
||||
export const definitionSha256 = (definition) => createHash('sha256').update(canonicalJson(definition)).digest('hex');
|
||||
|
||||
// r6 X4: the store reference for an agent's host.<id> key, as Credentials checks it. The runtime's start
|
||||
// load and the executor's register both use it.
|
||||
export const hostReference = (storeRoot, business, agent, hostId, rotateBy) => ({
|
||||
store: true,
|
||||
file: storeRoot ? join(storeRoot, business, agent, 'host.' + hostId) : null,
|
||||
rotateBy,
|
||||
});
|
||||
|
||||
// Row 235 (r6 X2 to X5): the grant executor's calls on Credentials, keyed by (business, agent, hostId).
|
||||
// The broker gets this and never Credentials itself, so it still has no use().
|
||||
export function grantKeys({ credentials, storeRoot }) {
|
||||
const at = (business, agent) => business + '/' + agent;
|
||||
const service = (hostId) => 'host.' + hostId;
|
||||
return {
|
||||
claim: (business, agent, hostId) => credentials.claim(at(business, agent), service(hostId)),
|
||||
release: (business, agent, hostId, signal) => credentials.release(at(business, agent), service(hostId), signal),
|
||||
abortClaim: (business, agent, hostId) => credentials.abortClaim(at(business, agent), service(hostId)),
|
||||
register: (business, agent, hostId, rotateBy) =>
|
||||
credentials.register(at(business, agent), service(hostId), hostReference(storeRoot, business, agent, hostId, rotateBy)),
|
||||
unregister: (business, agent, hostId) => credentials.unregister(at(business, agent), service(hostId)),
|
||||
issue: (business, agent, hostId, text, rotateBy, commit) => credentials.issue(at(business, agent), service(hostId), text, rotateBy, commit),
|
||||
revoke: (business, agent, hostId) => credentials.revoke(at(business, agent), service(hostId)),
|
||||
};
|
||||
}
|
||||
|
||||
// r6 section 6 and N1 of duckworth-0063: at start, before Credentials loads, unlink each host.<id> key
|
||||
// file in a loaded business's agent store that has no host_grants record. `granted` holds
|
||||
// 'business/agent/hostId' for each record. A file an interrupted executor wrote before its commit, or one
|
||||
// a revoke failed to unlink, goes here. No reference loads for a file with no record (X4), so nothing
|
||||
// holds it. A store dir that isn't private refuses credential-file, as Credentials would.
|
||||
export function sweepHostKeys(storeRoot, businesses, granted) {
|
||||
const removed = [];
|
||||
if (!storeRoot || !privateDir(storeRoot, { missing: true })) return removed;
|
||||
for (const business of businesses) {
|
||||
const dir = join(storeRoot, business);
|
||||
if (!privateDir(dir, { missing: true })) continue;
|
||||
for (const agent of readdirSync(dir)) {
|
||||
const agentDir = join(dir, agent);
|
||||
if (!lstatSync(agentDir).isDirectory()) continue;
|
||||
privateDir(agentDir);
|
||||
for (const name of readdirSync(agentDir)) {
|
||||
const hostId = name.startsWith('host.') ? name.slice(5) : null;
|
||||
if (!hostId || !HOST_ID.test(hostId) || granted.has(business + '/' + agent + '/' + hostId)) continue;
|
||||
unlinkSync(join(agentDir, name));
|
||||
removed.push({ instance: business + '/' + agent, service: name });
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
// The tags in `list` output, one per line, the last field of each.
|
||||
export const listedTags = (stdout) => stdout.split('\n').filter(Boolean).map((line) => line.trim().split(/\s+/).at(-1));
|
||||
|
||||
// The keyscan's output lines, "<host> <type> <base64>", as "<type> <base64>". Comment lines and any
|
||||
// other type drop out. A key listed twice counts once.
|
||||
export function scanLines(stdout) {
|
||||
const keys = [];
|
||||
for (const line of stdout.split('\n')) {
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length !== 3) continue;
|
||||
const key = parts[1] + ' ' + parts[2];
|
||||
if (HOST_KEY.test(key) && !keys.includes(key)) keys.push(key);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
// E1: `ssh-keyscan -T 5 -p <port> -t ed25519,ecdsa,rsa <address>` from the stack host. It reads public
|
||||
// keys and logs in to nothing. A scan that can't start refuses host-failed. One that runs and gets no key,
|
||||
// as from a host that doesn't answer, returns no lines, so the raise refuses host-key-mismatch
|
||||
// (duckworth-0081 N3).
|
||||
export function keyscan(address, port, { bin = 'ssh-keyscan', limitMs = SCAN_MS } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(KILL_WITH_PARENT[0], [...KILL_WITH_PARENT.slice(1), bin, '-T', '5', '-p', String(port), '-t', SCAN_TYPES, '--', address], {
|
||||
env: { PATH },
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
const out = [];
|
||||
let size = 0;
|
||||
child.stdout.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size <= 65536) out.push(chunk);
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
} catch {}
|
||||
}, limitMs);
|
||||
child.on('error', () => (clearTimeout(timer), reject(new BusError('host-failed'))));
|
||||
child.on('close', () => (clearTimeout(timer), resolve(scanLines(Buffer.concat(out).toString('utf8')))));
|
||||
});
|
||||
}
|
||||
|
||||
// E1: the scanned set equals the requested set, or host-key-mismatch.
|
||||
export function sameFingerprints(lines, prints) {
|
||||
const scanned = new Set(lines.map(fingerprint));
|
||||
return scanned.size === prints.length && prints.every((f) => scanned.has(f));
|
||||
}
|
||||
|
||||
const run = (bin, args) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn(bin, args, { env: { PATH }, stdio: ['ignore', 'ignore', 'ignore'] });
|
||||
child.on('error', () => reject(new BusError('host-failed')));
|
||||
child.on('close', (code) => (code === 0 ? resolve() : reject(new BusError('host-failed'))));
|
||||
});
|
||||
|
||||
// `installerUser` is the installer's login. Only the tests change it, because a scratch sshd can only
|
||||
// log in as the test's own user.
|
||||
export function hostGrant({ configDir, runRoot, limits = HOST_LIMITS, scan = keyscan, keygen = 'ssh-keygen', installerUser = INSTALLER_USER }) {
|
||||
const keyRoot = join(configDir, 'secrets', 'hosts');
|
||||
const keyPath = (business, hostId) => join(keyRoot, business, hostId, 'installer');
|
||||
const readKey = (business, hostId) => {
|
||||
const file = keyPath(business, hostId);
|
||||
for (const dir of [keyRoot, dirname(dirname(file)), dirname(file)]) privateDir(dir);
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
return parseHostKey(readFileSync(fd, 'utf8'));
|
||||
} catch (e) {
|
||||
if (e instanceof BusError) throw e;
|
||||
deny('credential-file');
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
};
|
||||
// One installer request, `list`, `add` or `remove`, pinned to the record's host keys (E3).
|
||||
const installer = (business, hostId, record, command, signal = null) => {
|
||||
const key = readKey(business, hostId);
|
||||
const entry = { address: record.address, port: record.port, user: installerUser, hostKeys: record.hostKeys };
|
||||
return runHost({ root: runRoot, hostId, entry, key, timeout: 30, limits, command, signal });
|
||||
};
|
||||
const ok = (out) => {
|
||||
if (out.exit !== 0) deny('host-failed');
|
||||
return out;
|
||||
};
|
||||
return {
|
||||
enrollCheck,
|
||||
hostGrantCheck,
|
||||
definitionSha256,
|
||||
sameFingerprints,
|
||||
fingerprint,
|
||||
installerTag,
|
||||
grantTag,
|
||||
keyscan: (address, port) => scan(address, port),
|
||||
// E2 steps 2 and 3. Returns the public line and fingerprint; the private half only reaches the file.
|
||||
async enroll(business, hostId) {
|
||||
mkdirSync(runRoot, { recursive: true, mode: 0o700 });
|
||||
privateDir(runRoot);
|
||||
const dir = mkdtempSync(join(runRoot, 'h-'));
|
||||
try {
|
||||
const tag = installerTag(business, hostId);
|
||||
await run(keygen, ['-q', '-t', 'ed25519', '-N', '', '-C', INSTALLER_USER + '-' + tag.slice('mosaic-'.length), '-f', join(dir, 'key')]);
|
||||
const key = parseHostKey(readFileSync(join(dir, 'key'), 'utf8'));
|
||||
const file = keyPath(business, hostId);
|
||||
mkdirSync(keyRoot, { recursive: true, mode: 0o700 });
|
||||
privateDir(keyRoot);
|
||||
for (const d of [dirname(dirname(file)), dirname(file)]) {
|
||||
if (!privateDir(d, { missing: true })) mkdirSync(d, { mode: 0o700 });
|
||||
privateDir(d);
|
||||
}
|
||||
const tmp = file + '.tmp';
|
||||
try {
|
||||
unlinkSync(tmp);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
}
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
writeSync(fd, key.text);
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
renameSync(tmp, file);
|
||||
const dirFd = openSync(dirname(file), constants.O_RDONLY);
|
||||
try {
|
||||
fsyncSync(dirFd);
|
||||
} finally {
|
||||
closeSync(dirFd);
|
||||
}
|
||||
return { publicLine: key.publicLine, fingerprint: key.fingerprint, tag };
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
// The X3 undo and hosts.remove: the installer key and its two dirs, if present.
|
||||
unenroll(business, hostId) {
|
||||
const file = keyPath(business, hostId);
|
||||
for (const f of [file + '.tmp', file]) {
|
||||
try {
|
||||
unlinkSync(f);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
}
|
||||
}
|
||||
for (const d of [dirname(file), dirname(dirname(file))]) {
|
||||
try {
|
||||
rmdirSync(d);
|
||||
} catch (e) {
|
||||
if (!['ENOENT', 'ENOTEMPTY'].includes(e.code)) deny('credential-file');
|
||||
}
|
||||
}
|
||||
},
|
||||
// E4: `list <user>` as mosaic-installer with the installer key and the pinned host keys. A wrong
|
||||
// pin refuses host-key-mismatch (runHost). Returns the installer fingerprint and the exit.
|
||||
async check(business, hostId, record) {
|
||||
const key = readKey(business, hostId);
|
||||
const entry = { address: record.address, port: record.port, user: installerUser, hostKeys: record.hostKeys };
|
||||
const out = await runHost({ root: runRoot, hostId, entry, key, timeout: 30, limits, command: `list ${record.user}` });
|
||||
return { installerFingerprint: key.fingerprint, exit: out.exit };
|
||||
},
|
||||
// X2 step 2: a new ed25519 pair with the tag as its comment, made in a 0700 dir under the run root
|
||||
// and read back into memory. The dir goes before this returns, so no private half stays on disk.
|
||||
async grantKey(tag) {
|
||||
mkdirSync(runRoot, { recursive: true, mode: 0o700 });
|
||||
privateDir(runRoot);
|
||||
const dir = mkdtempSync(join(runRoot, 'h-'));
|
||||
try {
|
||||
await run(keygen, ['-q', '-t', 'ed25519', '-N', '', '-C', tag, '-f', join(dir, 'key')]);
|
||||
return parseHostKey(readFileSync(join(dir, 'key'), 'utf8'));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
// X2 step 3: `list`, `remove` the tag if it's there, then `add`. Any request that doesn't exit 0
|
||||
// refuses host-failed. `signal` is the executor's claim, so a revoke stops the call.
|
||||
async install(business, hostId, record, tag, publicLine, signal) {
|
||||
const listed = ok(await installer(business, hostId, record, `list ${record.user}`, signal));
|
||||
if (listedTags(listed.stdout).includes(tag)) ok(await installer(business, hostId, record, `remove ${record.user} ${tag}`, signal));
|
||||
ok(await installer(business, hostId, record, `add ${record.user} ${publicLine} ${tag}`, signal));
|
||||
},
|
||||
// X2 step 4: log in as the record's user with the new key and run `true`.
|
||||
async verify(hostId, record, key, signal) {
|
||||
const entry = { address: record.address, port: record.port, user: record.user, hostKeys: record.hostKeys };
|
||||
ok(await runHost({ root: runRoot, hostId, entry, key, timeout: 30, limits, command: 'true', signal }));
|
||||
},
|
||||
// X3's undo and sweep: `remove <user> <tag>`, with no signal, since it runs after an abort too.
|
||||
async remove(business, hostId, record, tag) {
|
||||
ok(await installer(business, hostId, record, `remove ${record.user} ${tag}`));
|
||||
},
|
||||
// X3's sweep (B4): `list <user>`, then `remove` for each listed tag in `tags`. A line with any other
|
||||
// tag stays, so another business's line and an unrecorded one are left alone. Returns the listed
|
||||
// tags and the removed ones.
|
||||
async sweep(business, hostId, record, tags) {
|
||||
const listed = listedTags(ok(await installer(business, hostId, record, `list ${record.user}`)).stdout);
|
||||
const removed = [];
|
||||
for (const tag of listed) {
|
||||
if (!tags.has(tag) || removed.includes(tag)) continue;
|
||||
ok(await installer(business, hostId, record, `remove ${record.user} ${tag}`));
|
||||
removed.push(tag);
|
||||
}
|
||||
return { listed, removed };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
// Row 210 (#1707, design D5 to D7): run one sudo command on a registered host over ssh, with the
|
||||
// agent's host key handed to a private ssh-agent and never written to disk, argv or env.
|
||||
//
|
||||
// Each call gets a 0700 dir under <configDir>/secrets/run holding known_hosts (the registry's pinned
|
||||
// keys), id.pub, the agent socket and a pids file. ssh-agent and ssh each lead their own process
|
||||
// group and run under `setpriv --pdeathsig KILL`, so they die with the bus child. The `finally`
|
||||
// kills both groups and removes the dir. If the bus child dies first, sweepRunDir clears the dir
|
||||
// at the next start.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { appendFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { BusError } from './broker.mjs';
|
||||
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
const KILL_WITH_PARENT = ['/usr/bin/setpriv', '--pdeathsig', 'KILL', '--'];
|
||||
const PATH = '/usr/bin:/bin';
|
||||
// Linux sun_path holds 108 bytes with the NUL.
|
||||
const SOCKET_MAX = 107;
|
||||
const RUN_DIR = /^h-[A-Za-z0-9]{6}$/;
|
||||
export const HOST_LIMITS = Object.freeze({ outputMax: 1024 * 1024, graceMs: 5000, agentWaitMs: 5000 });
|
||||
|
||||
// D6: each element POSIX single-quoted, so the remote shell hands sudo exactly these strings.
|
||||
export const quote = (element) => "'" + element.replaceAll("'", "'\\''") + "'";
|
||||
export const remoteCommand = (argv) => ['sudo', '-n', '--', ...argv.map(quote)].join(' ');
|
||||
|
||||
// D7 step 4: every option set here, nothing read from a config file.
|
||||
export function sshArgs({ hostId, address, port, user, dir }) {
|
||||
const o = (x) => ['-o', x];
|
||||
return [
|
||||
// ssh's own messages go to the run dir's log, apart from the remote command's stderr (N3).
|
||||
'-F', '/dev/null', '-T', '-E', join(dir, 'ssh.log'), '-p', String(port),
|
||||
...o('BatchMode=yes'), ...o('IdentitiesOnly=yes'), ...o(`IdentityFile=${join(dir, 'id.pub')}`),
|
||||
...o(`IdentityAgent=${join(dir, 'a.sock')}`),
|
||||
...o('StrictHostKeyChecking=yes'), ...o(`UserKnownHostsFile=${join(dir, 'known_hosts')}`),
|
||||
...o('GlobalKnownHostsFile=/dev/null'),
|
||||
...o(`HostKeyAlias=mosaic-host-${hostId}`), ...o('UpdateHostKeys=no'), ...o('CheckHostIP=no'), ...o('VerifyHostKeyDNS=no'),
|
||||
...o('ForwardAgent=no'), ...o('ForwardX11=no'), ...o('ClearAllForwardings=yes'), ...o('PermitLocalCommand=no'),
|
||||
...o('ProxyCommand=none'), ...o('ProxyJump=none'), ...o('ControlMaster=no'), ...o('ControlPath=none'),
|
||||
...o('ConnectTimeout=10'), ...o('ServerAliveInterval=15'), ...o('ServerAliveCountMax=3'),
|
||||
'-l', user, '--', address,
|
||||
];
|
||||
}
|
||||
|
||||
// Field 22 of /proc/<pid>/stat, the start time in clock ticks, or null. The comm field may hold
|
||||
// spaces and parentheses, so the count starts after the last ")" (field 2's end).
|
||||
export function startTime(pid) {
|
||||
try {
|
||||
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
||||
return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const alive = (pid, start) => start !== null && startTime(pid) === start;
|
||||
const record = (dir, pid) => appendFileSync(join(dir, 'pids'), `${pid} ${startTime(pid)}\n`, { mode: 0o600 });
|
||||
const group = (child, signal) => {
|
||||
if (!child?.pid) return;
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
} catch {}
|
||||
};
|
||||
const start = (bin, args, env, stdio) =>
|
||||
spawn(KILL_WITH_PARENT[0], [...KILL_WITH_PARENT.slice(1), bin, ...args], { env, detached: true, stdio });
|
||||
|
||||
// The run root is the stack instance's user's own, like the credential store.
|
||||
function runRoot(root) {
|
||||
mkdirSync(root, { recursive: true, mode: 0o700 });
|
||||
const st = lstatSync(root);
|
||||
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077) !== 0) deny('credential-file');
|
||||
return root;
|
||||
}
|
||||
|
||||
// Waits for ssh-agent's socket, or for the agent to exit first.
|
||||
async function waitForSocket(agent, sock, limitMs) {
|
||||
const until = Date.now() + limitMs;
|
||||
while (!existsSync(sock)) {
|
||||
if (agent.exitCode !== null || agent.signalCode !== null || Date.now() > until) deny('host-failed');
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
|
||||
// Runs ssh-add with the key on its stdin; resolves on exit 0. A hung ssh-add is killed at
|
||||
// `limitMs` (host-failed) or on abort (credential-unavailable), so a revoke or a stuck agent
|
||||
// never holds the host busy (N2).
|
||||
export function addKey(sock, text, lifetime, { limitMs = HOST_LIMITS.agentWaitMs, signal = null } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(new BusError('credential-unavailable'));
|
||||
const add = start('ssh-add', ['-q', '-t', String(lifetime), '-'], { PATH, SSH_AUTH_SOCK: sock }, ['pipe', 'ignore', 'ignore']);
|
||||
const stop = (code) => {
|
||||
group(add, 'SIGKILL');
|
||||
reject(new BusError(code));
|
||||
};
|
||||
const timer = setTimeout(() => stop('host-failed'), limitMs);
|
||||
const abort = () => stop('credential-unavailable');
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const settle = () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
};
|
||||
add.stdin.on('error', () => {});
|
||||
add.stdin.end(text);
|
||||
add.on('error', () => (settle(), reject(new BusError('host-failed'))));
|
||||
add.on('close', (code) => (settle(), code === 0 ? resolve() : reject(new BusError('host-failed'))));
|
||||
});
|
||||
}
|
||||
|
||||
// ssh itself: stdin written once, stdout and stderr each kept to outputMax and drained past it.
|
||||
function runSsh(args, stdin, timeoutMs, signal, limits, onSpawn) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const began = Date.now();
|
||||
const ssh = start('ssh', args, { PATH }, ['pipe', 'pipe', 'pipe']);
|
||||
onSpawn(ssh);
|
||||
const streams = { stdout: [], stderr: [] };
|
||||
const sizes = { stdout: 0, stderr: 0 };
|
||||
const truncated = { stdout: false, stderr: false };
|
||||
for (const name of ['stdout', 'stderr'])
|
||||
ssh[name].on('data', (chunk) => {
|
||||
const room = limits.outputMax - sizes[name];
|
||||
if (chunk.length > room) truncated[name] = true;
|
||||
if (room > 0) {
|
||||
streams[name].push(chunk.subarray(0, room));
|
||||
sizes[name] += Math.min(room, chunk.length);
|
||||
}
|
||||
});
|
||||
ssh.stdin.on('error', () => {});
|
||||
ssh.stdin.end(stdin);
|
||||
let timedOut = false;
|
||||
let kill = null;
|
||||
const term = setTimeout(() => {
|
||||
timedOut = true;
|
||||
group(ssh, 'SIGTERM');
|
||||
kill = setTimeout(() => group(ssh, 'SIGKILL'), limits.graceMs);
|
||||
}, timeoutMs);
|
||||
const abort = () => group(ssh, 'SIGKILL');
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const settle = (fn) => {
|
||||
clearTimeout(term);
|
||||
clearTimeout(kill);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
fn();
|
||||
};
|
||||
ssh.on('error', () => settle(() => reject(new BusError('host-failed'))));
|
||||
ssh.on('close', (code, sig) =>
|
||||
settle(() =>
|
||||
resolve({
|
||||
exit: code,
|
||||
signal: sig ?? (timedOut ? 'SIGTERM' : null),
|
||||
stdout: Buffer.concat(streams.stdout).toString('utf8'),
|
||||
stderr: Buffer.concat(streams.stderr).toString('utf8'),
|
||||
truncated,
|
||||
ms: Date.now() - began,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// D7: one call. `key` is the parsed entry from credentials.mjs (text and publicLine); `entry` is
|
||||
// the registry's { address, port, user, hostKeys }. `root` is <configDir>/secrets/run.
|
||||
// Row 234 (#1740): `command`, when set, is sent as is in place of the sudo argv. The installer key's
|
||||
// forced command reads it (tools/host/mosaic-keys).
|
||||
export async function runHost({ root, hostId, entry, key, argv, stdin, timeout, signal = null, limits = HOST_LIMITS, command = null }) {
|
||||
if (signal?.aborted) deny('credential-unavailable');
|
||||
const dir = mkdtempSync(join(runRoot(root), 'h-'));
|
||||
const sock = join(dir, 'a.sock');
|
||||
let agent = null,
|
||||
ssh = null;
|
||||
try {
|
||||
if (Buffer.byteLength(sock) > SOCKET_MAX) deny('host-failed');
|
||||
writeFileSync(join(dir, 'known_hosts'), entry.hostKeys.map((k) => `mosaic-host-${hostId} ${k}\n`).join(''), { mode: 0o600, flag: 'wx' });
|
||||
writeFileSync(join(dir, 'id.pub'), key.publicLine + '\n', { mode: 0o600, flag: 'wx' });
|
||||
writeFileSync(join(dir, 'pids'), `${process.pid} ${startTime(process.pid)}\n`, { mode: 0o600, flag: 'wx' });
|
||||
agent = start('ssh-agent', ['-D', '-a', sock], { PATH }, ['ignore', 'ignore', 'ignore']);
|
||||
agent.on('error', () => {});
|
||||
if (agent.pid) record(dir, agent.pid);
|
||||
await waitForSocket(agent, sock, limits.agentWaitMs);
|
||||
// D7 step 3: the agent drops the key at the call's limit plus 30 s even if nothing else runs.
|
||||
await addKey(sock, key.text, timeout + 30, { limitMs: limits.agentWaitMs, signal });
|
||||
if (signal?.aborted) deny('credential-unavailable');
|
||||
const out = await runSsh(
|
||||
[...sshArgs({ hostId, ...entry, dir }), command ?? remoteCommand(argv)],
|
||||
stdin,
|
||||
timeout * 1000,
|
||||
signal,
|
||||
limits,
|
||||
(child) => {
|
||||
ssh = child;
|
||||
if (child.pid) record(dir, child.pid);
|
||||
},
|
||||
);
|
||||
if (signal?.aborted) deny('credential-unavailable');
|
||||
// Only ssh writes its log, so a remote command that prints the same text can't fake a pin
|
||||
// refusal. On ssh's own exit 255 the agent still gets its message, after the remote stderr.
|
||||
const log = out.exit === 255 && existsSync(join(dir, 'ssh.log')) ? readFileSync(join(dir, 'ssh.log'), 'utf8').slice(0, 4096) : '';
|
||||
if (log.includes('Host key verification failed')) deny('host-key-mismatch');
|
||||
if (log && !out.truncated.stderr) out.stderr += log;
|
||||
return out;
|
||||
} finally {
|
||||
group(ssh, 'SIGKILL');
|
||||
group(agent, 'SIGKILL');
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// D7 step 6, at bus start before broker.sock listens. A dir whose bus pid is gone gets each of its
|
||||
// live recorded processes that names the dir killed, then is removed. A dir whose bus is alive is
|
||||
// left alone. Any entry that isn't a run dir refuses start.
|
||||
export function sweepRunDir(root) {
|
||||
let names;
|
||||
try {
|
||||
names = readdirSync(root);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') return { removed: [] };
|
||||
deny('credential-file');
|
||||
}
|
||||
runRoot(root);
|
||||
const removed = [];
|
||||
for (const name of names) {
|
||||
const dir = join(root, name);
|
||||
const st = lstatSync(dir);
|
||||
if (!RUN_DIR.test(name) || !st.isDirectory() || st.uid !== process.getuid()) deny('credential-file');
|
||||
let lines = [];
|
||||
try {
|
||||
lines = readFileSync(join(dir, 'pids'), 'utf8').split('\n').filter(Boolean).map((l) => l.split(' '));
|
||||
} catch {}
|
||||
const [bus, ...children] = lines;
|
||||
if (bus && alive(Number(bus[0]), bus[1])) continue;
|
||||
for (const [pid, at] of children) {
|
||||
if (!alive(Number(pid), at)) continue;
|
||||
let cmdline = '';
|
||||
try {
|
||||
cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
||||
} catch {}
|
||||
if (cmdline.includes(dir)) {
|
||||
try {
|
||||
process.kill(Number(pid), 'SIGKILL');
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
removed.push(name);
|
||||
}
|
||||
return { removed };
|
||||
}
|
||||
|
||||
// The broker's executor (design D5): withHost beside withGitea, the key used only through useFor.
|
||||
export function hostExecutor({ credentials, root, limits = HOST_LIMITS }) {
|
||||
return {
|
||||
busy: (session, hostId) => credentials.busy(session.business + '/' + session.role, 'host.' + hostId),
|
||||
run: (session, hostId, entry, { argv, stdin, timeout }) =>
|
||||
credentials.useFor(
|
||||
session,
|
||||
'host.' + hostId,
|
||||
(_token, { key, signal }) => runHost({ root, hostId, entry, key, argv, stdin, timeout, signal, limits }),
|
||||
{ input: { argv, stdin } },
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
-- Row 234 (#436a row a, #1740): enrolled hosts and host grants live in the store, written only by
|
||||
-- the grant executor, hosts.check, hosts.remove and revoke, each in the transaction of its event.
|
||||
-- A grant event is an action.allowed whose body.action or body.operation is grant.*; the trigger
|
||||
-- refuses one unless the actor is (NULL, 'grant'), the broker's own grant writer (#436b section 7).
|
||||
CREATE TABLE hosts (
|
||||
business TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
|
||||
user TEXT NOT NULL,
|
||||
sudo INTEGER NOT NULL CHECK (sudo IN (0,1)),
|
||||
host_keys TEXT NOT NULL CHECK (json_valid(host_keys) AND json_type(host_keys) = 'array' AND json_array_length(host_keys) > 0),
|
||||
installer_fingerprint TEXT,
|
||||
state TEXT NOT NULL CHECK (state IN ('pending-install','ready')),
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
checked_at TEXT,
|
||||
PRIMARY KEY (business, host),
|
||||
CHECK (state = 'pending-install' OR (installer_fingerprint IS NOT NULL AND checked_at IS NOT NULL))
|
||||
) STRICT;
|
||||
CREATE TABLE host_grants (
|
||||
business TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
commands TEXT NOT NULL CHECK (json_valid(commands) AND json_type(commands) = 'array' AND json_array_length(commands) > 0),
|
||||
rotate_by TEXT NOT NULL,
|
||||
tag TEXT NOT NULL CHECK (length(tag) = 39 AND tag GLOB 'mosaic-*' AND NOT substr(tag, 8) GLOB '*[^0-9a-f]*'),
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
PRIMARY KEY (business, agent, host),
|
||||
FOREIGN KEY (business, host) REFERENCES hosts(business, host)
|
||||
) STRICT;
|
||||
CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'action.allowed'
|
||||
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
|
||||
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
|
||||
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
|
||||
@@ -15,8 +15,6 @@ import { instanceSettings } from '../../business/src/instance-settings.mjs';
|
||||
import { organizationWriter } from '../../business/src/organization.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { webService } from './web.mjs';
|
||||
import { hostExecutor, sweepRunDir } from './host-run.mjs';
|
||||
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
|
||||
import { verifyHuman } from './human.mjs';
|
||||
import { createUsage } from './usage.mjs';
|
||||
import { createLimits } from './limits.mjs';
|
||||
@@ -96,21 +94,8 @@ export async function startBroker({
|
||||
];
|
||||
// Row 199 (#1697, design D1): the per-agent store is <configDir>/secrets/agents.
|
||||
const storeRoot = organization ? join(organization.dir, 'secrets', 'agents') : null;
|
||||
store = new Store(dataRoot);
|
||||
// Row 234 (#1740, r6 X4 third bullet): each host grant record gives its agent a store reference to
|
||||
// host.<id>, so a restart finds the keys the grant executor wrote. The loader refuses host.<id> in a
|
||||
// role's credentials, so these never meet a business-file reference.
|
||||
const granted = new Set();
|
||||
for (const g of store.all('SELECT business, agent, host, rotate_by FROM host_grants ORDER BY business, agent, host')) {
|
||||
granted.add(g.business + '/' + g.agent + '/' + g.host);
|
||||
if (!Object.hasOwn(businesses, g.business)) continue;
|
||||
const refs = (references[g.business + '/' + g.agent] = { ...(references[g.business + '/' + g.agent] ?? {}) });
|
||||
refs['host.' + g.host] = hostReference(storeRoot, g.business, g.agent, g.host, g.rotate_by);
|
||||
}
|
||||
// Row 235 (r6 section 6, N1): a host.<id> key file with no record goes before Credentials loads.
|
||||
for (const entry of sweepHostKeys(storeRoot, Object.keys(businesses), granted))
|
||||
process.stderr.write(`hosts: ${JSON.stringify({ event: 'host-key-swept', ...entry })}\n`);
|
||||
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot });
|
||||
store = new Store(dataRoot);
|
||||
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
|
||||
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
|
||||
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
|
||||
@@ -126,12 +111,7 @@ export async function startBroker({
|
||||
const limits = createLimits({ store, read: id => settings ? settings.current(id) : businesses[id], log: limitLog });
|
||||
for (const id of Object.keys(businesses)) await limits.refresh(id);
|
||||
const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) });
|
||||
// Row 210 (#1707, design D7 step 6) and row 234 (#1740, r6 E2): the run dir and the host enrollment half.
|
||||
const runRoot = organization ? join(organization.dir, 'secrets', 'run') : null;
|
||||
const broker = new Broker({
|
||||
hostGrant: runRoot ? hostGrant({ configDir: organization.dir, runRoot }) : null,
|
||||
// Row 235 (#1741, r6 X4, X5): the grant executor's claim, register and issue, never use().
|
||||
hostKeys: storeRoot ? grantKeys({ credentials, storeRoot }) : null,
|
||||
usage,
|
||||
limits,
|
||||
store,
|
||||
@@ -199,14 +179,9 @@ export async function startBroker({
|
||||
throw new BusError('invalid-adapter');
|
||||
}
|
||||
const web = await webServices(businesses);
|
||||
// Row 210 (#1707, design D7 step 6): clear what a killed bus child left, before anyone can call.
|
||||
if (runRoot) sweepRunDir(runRoot);
|
||||
// Row 234 (r6 X3): end the enrollments a killed bus child left, before anyone can call.
|
||||
broker.reconcileGrants();
|
||||
const path = join(store.directory, 'broker.sock');
|
||||
server = await serve({
|
||||
web: (business) => web.get(business) ?? null,
|
||||
host: runRoot ? hostExecutor({ credentials, root: runRoot }) : null,
|
||||
tasks: adapter ? { handle: adapter.handle, timeout: adapter.timeout } : null,
|
||||
broker,
|
||||
path,
|
||||
@@ -229,9 +204,6 @@ export async function startBroker({
|
||||
return broker.bindTester({ business });
|
||||
},
|
||||
});
|
||||
// Row 235 (r6 X3): the remote undo of interrupted host grants, once the bus is up, so a start doesn't
|
||||
// wait on the network.
|
||||
broker.reconcileHosts();
|
||||
// S6 launcher operations. The host decides capacity and the instance list; the broker
|
||||
// authorizes role.launch and keeps the evidence, refusals included.
|
||||
// A refusal is recorded against the caller, as request() records one.
|
||||
@@ -280,42 +252,9 @@ export async function startBroker({
|
||||
if (!(e instanceof BusError)) process.stderr.write(`bus: publish git-failed: ${JSON.stringify({ name: e?.name, code: e?.code })}\n`);
|
||||
Object.assign(entry, { state: 'refused', code: e instanceof BusError ? e.code : 'git-failed' });
|
||||
},
|
||||
).then(() => {
|
||||
if (entry.held) settle(ticket, entry, entry.held);
|
||||
});
|
||||
);
|
||||
return { state: 'running' };
|
||||
}
|
||||
// Row 242 (#1748): the git socket gave up on a publish (publish-timeout) whose push it never
|
||||
// collected. Its publishRefused waits for the push: until that ends the ticket stays open, so
|
||||
// checkPublish refuses another publish on the decision with publish-busy (row 237). A push that
|
||||
// reached the fork is then recorded, which spends the decision; one that didn't is filed with
|
||||
// the socket's code. Either way the decision can't push a second ref.
|
||||
function settle(ticket, entry, code) {
|
||||
remotes.delete(ticket);
|
||||
try {
|
||||
if (entry.state === 'pushed') {
|
||||
try {
|
||||
broker.recordPublish(ticket, { ref: entry.ref, commit: entry.commit, previous: entry.previous });
|
||||
process.stderr.write(`bus: publish ${ticket}: pushed after the git socket refused ${code}; recorded\n`);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (!(e instanceof BusError)) throw e;
|
||||
code = e.code;
|
||||
}
|
||||
}
|
||||
broker.publishRefused(ticket, { code });
|
||||
} catch (e) {
|
||||
process.stderr.write(`bus: publish ${ticket}: not settled: ${JSON.stringify({ name: e?.name, code: e?.code })}\n`);
|
||||
}
|
||||
}
|
||||
function publishRefused(ticket, args) {
|
||||
const entry = remotes.get(ticket);
|
||||
if (!entry) return broker.publishRefused(ticket, args);
|
||||
if (!/^[a-z-]{1,64}$/.test(args?.code ?? '')) throw new BusError('invalid-request');
|
||||
if (entry.state === 'running') entry.held = args.code;
|
||||
else settle(ticket, entry, args.code);
|
||||
return args.code;
|
||||
}
|
||||
function publishResult(ticket) {
|
||||
const entry = remotes.get(ticket);
|
||||
if (!entry) throw new BusError('publish-unknown');
|
||||
@@ -352,7 +291,7 @@ export async function startBroker({
|
||||
publishPush,
|
||||
publishResult,
|
||||
recordPublish: (ticket, pushed) => broker.recordPublish(ticket, pushed),
|
||||
publishRefused,
|
||||
publishRefused: (ticket, args) => broker.publishRefused(ticket, args),
|
||||
landHeld: (record) => broker.landHeld(record),
|
||||
// Row 156 (#1651): the launcher's landing pass.
|
||||
landHolders: (business) => broker.landHolders(business),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createServer } from 'node:net';
|
||||
import { lstatSync, chmodSync, unlinkSync } from 'node:fs';
|
||||
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, PROJECT_FILE_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
|
||||
import { BusError, TASK_VERBS, WEB_VERBS, PROJECT_FILE_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
|
||||
const LIMIT = 65536;
|
||||
// Row 148: a document save may carry up to 1 MiB of text. Only a request that names that verb near
|
||||
// its start (the proof object comes first) may pass LIMIT; the parsed verb is checked again below.
|
||||
@@ -9,10 +9,8 @@ const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.
|
||||
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
|
||||
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
|
||||
// the web verbs reach it, and without one they refuse web-not-configured (or web-not-granted).
|
||||
// `host` (row 210) is the trusted host.run executor (host-run.mjs), or null: host.run then refuses
|
||||
// host-not-configured.
|
||||
// A request carries exactly one of cap, human (row S4) and tester (row 110) proof.
|
||||
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, timeout = 5000, tasks = null, web = null, host = null }) {
|
||||
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, timeout = 5000, tasks = null, web = null }) {
|
||||
try {
|
||||
lstatSync(path);
|
||||
throw new BusError('socket-exists');
|
||||
@@ -104,29 +102,10 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Row 210 (#1707, design D5): a sudo command can run for minutes. As for web, the longer wait
|
||||
// goes only to a call the broker admitted: the call's own limit plus 10 s.
|
||||
if (HOST_VERBS.includes(r.verb)) {
|
||||
const own = transient;
|
||||
transient = null;
|
||||
broker
|
||||
.requestHost(cap, { verb: r.verb, args: r.args ?? {} }, host, { onAdmit: (ms) => socket.setTimeout(ms + 10000) })
|
||||
.then(
|
||||
(result) => finish({ ok: true, result }),
|
||||
(e) => finish({ ok: false, error: e instanceof BusError ? e.code : 'host-failed' }),
|
||||
)
|
||||
.finally(() => {
|
||||
if (own) broker.disconnect(own);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Row138: only project Git effects need an await; preserve every other sync path below.
|
||||
if (r.verb === 'usage.begin' || r.verb === 'usage.end' ||
|
||||
r.verb === 'spec.read' || r.verb === 'record.write' || PROJECT_FILE_VERBS.includes(r.verb) ||
|
||||
// Row 234 (#1740): a grant raise awaits its keyscan, and hosts.check its ssh.
|
||||
HOSTS_VERBS.includes(r.verb) ||
|
||||
(r.verb === 'decision.raise' && (Object.hasOwn(r.args ?? {}, 'spec_path') || Object.hasOwn(r.args ?? {}, 'spec_commit') ||
|
||||
Object.hasOwn(r.args ?? {}, 'grant')))) {
|
||||
(r.verb === 'decision.raise' && (Object.hasOwn(r.args ?? {}, 'spec_path') || Object.hasOwn(r.args ?? {}, 'spec_commit')))) {
|
||||
const own = transient;
|
||||
transient = null;
|
||||
// Local Git may take 100 seconds; keep its result beyond the ordinary idle deadline.
|
||||
|
||||
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
||||
export const SCHEMA_SHA256 = '0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c';
|
||||
export const SCHEMA_VERSION = '3j';
|
||||
export const SCHEMA_SHA256 = '6cde5232c57ac04dc6073112c0830c89cfc5e953d7a5727e7716263747fe8737';
|
||||
export const SCHEMA_VERSION = '3i';
|
||||
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
|
||||
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
|
||||
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
|
||||
@@ -56,11 +56,6 @@ const migrations = [{
|
||||
from: '3h',
|
||||
to: '3i',
|
||||
sql: readFileSync(new URL('./migrations/3h-3i.sql', import.meta.url), 'utf8'),
|
||||
}, {
|
||||
// Row 234 (#1740): enrolled hosts, host grants and the grant event guard. Only new objects.
|
||||
from: '3i',
|
||||
to: '3j',
|
||||
sql: readFileSync(new URL('./migrations/3i-3j.sql', import.meta.url), 'utf8'),
|
||||
}];
|
||||
const timedTables = [
|
||||
'events',
|
||||
|
||||
@@ -69,7 +69,6 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql'])
|
||||
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
|
||||
writeFileSync(join(moduleRoot, 'src/migrations/3h-3i.sql'), step(readFileSync(here('../src/migrations/3h-3i.sql'), 'utf8')));
|
||||
copyFileSync(here('../src/migrations/3i-3j.sql'), join(moduleRoot, 'src/migrations/3i-3j.sql'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
|
||||
@@ -77,7 +76,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
|
||||
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3j');
|
||||
assert.equal(version(path), '3i');
|
||||
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
|
||||
@@ -85,9 +84,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
|
||||
// sqlite_master as at 3h, the account line aside: same tables, indexes and triggers, same text.
|
||||
const old = ' account TEXT CHECK (account IS NULL),\n';
|
||||
assert.equal(was.filter(r => r.sql?.includes(old)).map(r => r.name).join(), 'usage_records');
|
||||
// Row 234 (#1740) adds 3i→3j's own objects after this step. Its test is grant-migration.test.mjs.
|
||||
const later = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1'];
|
||||
assert.deepEqual(master(path).filter(r => !later.includes(r.name)), was.map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
|
||||
assert.deepEqual(master(path), was.map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
|
||||
assert.equal(was.filter(r => r.type === 'trigger' && r.tbl_name === 'usage_records').length, 3);
|
||||
const store = new Store(root);
|
||||
try {
|
||||
@@ -125,11 +122,11 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
// Still a whole 3h store: the real step migrates it.
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3j');
|
||||
assert.equal(version(path), '3i');
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
|
||||
test('REQ-LIMIT-4 the 3h engine refuses a 3i store, migrating or not, and leaves it at 3i', async t => {
|
||||
const { root, path } = source3h(t);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
const after = snapshot(path), was = master(path);
|
||||
@@ -137,14 +134,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
|
||||
schema: ddl3h,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace("export const SCHEMA_VERSION = '3i';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
|
||||
assert.throws(() => new Store3h(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3j');
|
||||
assert.equal(version(path), '3i');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
|
||||
});
|
||||
|
||||
@@ -606,7 +606,7 @@ async function database3c(t) {
|
||||
let source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace("export const SCHEMA_VERSION = '3i';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.notEqual(pinned, source);
|
||||
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
|
||||
@@ -630,7 +630,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
|
||||
const b = new Broker({ store, businesses: demo(), organization: null });
|
||||
|
||||
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
|
||||
const original = readFileSync(source);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
|
||||
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
|
||||
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
|
||||
const sha = createHash('sha256').update(futureDDL).digest('hex');
|
||||
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
|
||||
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3k';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3j', to: '3k', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
.replace("export const SCHEMA_VERSION = '3i';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3i', to: '3j', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql'])
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql'])
|
||||
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
|
||||
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
|
||||
const future = new FutureStore(root, { migrateSchema: true });
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
|
||||
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
|
||||
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
|
||||
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.match(migrated.stdout, /bus schema 3j ready/);
|
||||
assert.match(migrated.stdout, /bus schema 3i ready/);
|
||||
new Store(root).close();
|
||||
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 2);
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||
|
||||
// Row 213 (C2-08, #1714, Case 2 design 5.1): a creative instance talks with the user only, and it has
|
||||
// no decision channel (M6). The messaging lists are read from the shipped role files.
|
||||
const shipped = (name) =>
|
||||
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
|
||||
const within = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const roles = {
|
||||
chief: { definition: 'chief-of-staff', ...within },
|
||||
assistant: { definition: 'assistant', ...within },
|
||||
pm: { definition: 'pm', ...within },
|
||||
bean: { definition: 'companion', ...within },
|
||||
quill: { definition: 'proofreader', ...within },
|
||||
};
|
||||
const definitions = {
|
||||
'chief-of-staff': {},
|
||||
assistant: { messaging: shipped('assistant').messaging },
|
||||
pm: {},
|
||||
companion: { messaging: shipped('companion').messaging },
|
||||
proofreader: { messaging: shipped('proofreader').messaging },
|
||||
};
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-creative-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const b = new Broker({ store, businesses: { demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' }, roles, definitions,
|
||||
projects: { stack: { root: '/nonexistent' } }, vars: { 'bus.tester': true },
|
||||
} } });
|
||||
const caps = {};
|
||||
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id;
|
||||
const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id;
|
||||
for (const role of Object.keys(roles)) {
|
||||
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) });
|
||||
b.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.tester = b.bindTester({ business: 'demo' });
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
const count = (sql) => store.get(sql).n;
|
||||
return { call, count, store };
|
||||
}
|
||||
|
||||
const raise = { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false };
|
||||
|
||||
test('M6: a creative raise refuses decision-not-allowed, cited or not, and no decision row appears; the assistant still raises', (t) => {
|
||||
const { call, count } = setup(t);
|
||||
for (const who of ['bean', 'quill']) {
|
||||
assert.throws(() => call(who, 'decision.raise', raise), { code: 'decision-not-allowed' }, who);
|
||||
// The refusal comes before the spec-cited path, so a cited raise never reaches the project.
|
||||
assert.throws(() => call(who, 'decision.raise', { ...raise, project: 'stack', spec_path: 'spec/north-star.md', spec_commit: 'a'.repeat(40) }),
|
||||
{ code: 'decision-not-allowed' }, `${who} cited`);
|
||||
}
|
||||
assert.equal(count('SELECT count(*) n FROM decisions'), 0);
|
||||
const d = call('assistant', 'decision.raise', raise);
|
||||
assert.match(String(d.id), /./);
|
||||
assert.equal(count('SELECT count(*) n FROM decisions'), 1);
|
||||
});
|
||||
|
||||
test('a creative instance sends only to the user and hears only from the user', (t) => {
|
||||
const { call, count } = setup(t);
|
||||
for (const who of ['bean', 'quill']) {
|
||||
call(who, 'message.send', { to: 'human', body: 'hello' });
|
||||
for (const to of ['chief', 'assistant', 'pm', 'bean', 'quill'].filter((n) => n !== who)) {
|
||||
assert.throws(() => call(who, 'message.send', { to, body: 'hello' }), { code: 'message-recipient-refused' }, `${who} to ${to}`);
|
||||
}
|
||||
for (const from of ['chief', 'pm']) {
|
||||
assert.throws(() => call(from, 'message.send', { to: who, body: 'hello' }), { code: 'message-sender-refused' }, `${from} to ${who}`);
|
||||
}
|
||||
call('human', 'message.send', { to: who, body: 'hello' });
|
||||
}
|
||||
assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2);
|
||||
assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 2);
|
||||
});
|
||||
|
||||
// M10 through the broker (webby-0040 B1): the broker names the actor from the session, so these cases
|
||||
// pin that a chief session reaches the writer as chief-of-staff.chief and a tester session as tester.
|
||||
// A real organization file and writer, as tester-writes.test.mjs.
|
||||
async function runtime(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-creative-m10-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { ...doc.plugins, harness: ['pi'] };
|
||||
doc.roles.bean = { definition: 'companion' };
|
||||
for (const r of Object.values(doc.roles)) r.vars = { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high', ...r.vars };
|
||||
// Only bean names the sonnet key, so the chief may still change every other key.
|
||||
for (const [name, r] of Object.entries(doc.roles)) if (name !== 'bean') r.vars.model = 'claude-opus-5-5';
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
writeJson(file, doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({
|
||||
dataRoot: join(root, 'data'),
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
||||
plugins: { harness: ['pi'] },
|
||||
roles: { chief: { definition: 'chief-of-staff', authority: shipped('chief-of-staff').authority }, bean: { definition: 'companion', ...within } },
|
||||
},
|
||||
},
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||
});
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const b = bus.broker;
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const tester = b.bindTester({ business: 'demo' });
|
||||
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
b.request(chief, { verb: 'role.claim' });
|
||||
const call = (cap, verb, args = {}) => b.request(cap, { verb, args });
|
||||
const history = join(root, 'businesses/.history/demo/changes.jsonl');
|
||||
const log = () => (existsSync(history) ? readFileSync(history, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) : []);
|
||||
const bytes = () => readFileSync(file, 'utf8');
|
||||
return { human, tester, chief, call, log, bytes };
|
||||
}
|
||||
|
||||
test('M10 through the broker: the chief and the tester are refused on bean, the human\'s limits.set of its models key works', async (t) => {
|
||||
const { human, tester, chief, call, log, bytes } = await runtime(t);
|
||||
const { revision, models } = call(human, 'limits');
|
||||
const changed = { ...models, 'claude-sonnet-5-5': { ...models['claude-sonnet-5-5'], model: 'claude-sonnet-5-5-next' } };
|
||||
const before = bytes();
|
||||
// The chief of staff's organization.set reaches the writer as chief-of-staff.chief and is refused there.
|
||||
assert.throws(() => call(chief, 'organization.set', { revision, models: changed }), { code: 'organization-creative-human-only' });
|
||||
assert.throws(() => call(chief, 'organization.set', { revision, instances: { bean: 'claude-opus-5-5' } }), { code: 'organization-creative-instance' });
|
||||
// The tester never reaches the writer through these two verbs (webby-0040 N5).
|
||||
assert.throws(() => call(tester, 'organization.set', { revision, models: changed }), { code: 'tester-refused' });
|
||||
assert.throws(() => call(tester, 'limits.set', { revision, models: changed }), { code: 'tester-refused' });
|
||||
// Its instances.set and routes.set do reach the writer, as tester, and are refused there.
|
||||
assert.throws(() => call(tester, 'instances.set', { revision, instance: 'bean', op: 'change', vars: { thinking: 'low' } }),
|
||||
{ code: 'organization-creative-human-only' });
|
||||
assert.throws(() => call(tester, 'instances.set', { revision, instance: 'muse', op: 'add', definition: 'ideas' }),
|
||||
{ code: 'organization-creative-human-only' });
|
||||
assert.throws(() => call(tester, 'routes.set', { revision, routes: { bean: { harness: 'pi', model: 'claude-opus-5-5', thinking: 'high', fallback: null } } }),
|
||||
{ code: 'organization-creative-human-only' });
|
||||
assert.equal(bytes(), before);
|
||||
assert.deepEqual(log().filter((e) => e.state === 'committed'), []);
|
||||
// The human's limits.set of the same change lands, logged with the human as actor.
|
||||
call(human, 'limits.set', { revision, models: changed });
|
||||
assert.equal(JSON.parse(bytes()).launch.models['claude-sonnet-5-5'].model, 'claude-sonnet-5-5-next');
|
||||
assert.deepEqual(log().filter((e) => e.state === 'committed').map((e) => e.actor), ['jason']);
|
||||
});
|
||||
@@ -1,152 +0,0 @@
|
||||
// Row 235 (#1741, #436 row b, r6 X4, X5): the grant executor's Credentials calls. register and
|
||||
// unregister add and drop one store reference in the running bus, and claim, release and abortClaim
|
||||
// hold the (agent, host.<id>) slot that host.run and revoke share. Keys are made by ssh-keygen in a
|
||||
// scratch root, and nothing connects to any host.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
|
||||
const AGENT = 'demo/ops';
|
||||
const SERVICE = 'host.web1';
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-claim-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const storeRoot = join(root, 'secrets', 'agents');
|
||||
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const file = join(storeRoot, 'demo', 'ops', SERVICE);
|
||||
const ref = { service: SERVICE, store: true, file, rotateBy: '2099-01-01' };
|
||||
const key = (name) => {
|
||||
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'test', '-f', join(root, name)]);
|
||||
return readFileSync(join(root, name), 'utf8');
|
||||
};
|
||||
const c = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
||||
t.after(() => c.close());
|
||||
const state = () => c.status().find((e) => e.instance === AGENT && e.service === SERVICE)?.state;
|
||||
const run = () => c.use(AGENT, SERVICE, async () => ({ ok: true }));
|
||||
return { root, storeRoot, file, ref, key, c, state, run };
|
||||
}
|
||||
|
||||
test('X4: register adds a store reference to the running bus, and issue then writes the key', async (t) => {
|
||||
const s = setup(t);
|
||||
assert.throws(() => s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01'), /credential-unknown/);
|
||||
s.c.register(AGENT, SERVICE, s.ref);
|
||||
assert.equal(s.state(), 'not-issued');
|
||||
await assert.rejects(s.run(), /credential-unavailable/);
|
||||
assert.equal(s.c.issue(AGENT, SERVICE, s.key('k2'), '2099-01-01').state, 'valid');
|
||||
assert.deepEqual(await s.run(), { ok: true });
|
||||
});
|
||||
|
||||
test('X4: register runs the constructor\'s reference checks', (t) => {
|
||||
const s = setup(t);
|
||||
const other = join(s.storeRoot, 'demo', 'pm', SERVICE);
|
||||
for (const [ref, code] of [
|
||||
[null, /credential-reference/],
|
||||
[{ ...s.ref, rotateBy: '2099-02-30' }, /credential-date/],
|
||||
[{ ...s.ref, file: other }, /credential-reference/],
|
||||
[{ ...s.ref, extra: 1 }, /credential-reference/],
|
||||
[{ ...s.ref, service: 'host.web2' }, /credential-reference/],
|
||||
[{ ...s.ref, env: 'X' }, /credential-reference/],
|
||||
])
|
||||
assert.throws(() => s.c.register(AGENT, SERVICE, ref), code);
|
||||
assert.throws(() => s.c.register(AGENT, 'nope', s.ref), /credential-reference/);
|
||||
assert.throws(() => s.c.register('demo/@tester', SERVICE, { ...s.ref, file: join(s.storeRoot, 'demo', '@tester', SERVICE) }), /credential-reference/);
|
||||
assert.equal(s.state(), undefined);
|
||||
});
|
||||
|
||||
test('X4: register reads no file, so a key file left at the path stays unused, and a live token refuses', async (t) => {
|
||||
const s = setup(t);
|
||||
mkdirSync(join(s.storeRoot, 'demo', 'ops'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(s.file, s.key('left'), { mode: 0o600 });
|
||||
s.c.register(AGENT, SERVICE, s.ref);
|
||||
assert.equal(s.state(), 'not-issued');
|
||||
await assert.rejects(s.run(), /credential-unavailable/);
|
||||
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
|
||||
assert.throws(() => s.c.register(AGENT, SERVICE, s.ref), /credential-reference/);
|
||||
assert.equal(s.state(), 'valid');
|
||||
// A revoked entry has no token, so a new grant registers again.
|
||||
s.c.revoke(AGENT, SERVICE);
|
||||
assert.equal(s.state(), 'revoked');
|
||||
s.c.register(AGENT, SERVICE, s.ref);
|
||||
assert.equal(s.state(), 'not-issued');
|
||||
});
|
||||
|
||||
test('X3, X4: unregister drops an entry with no token and refuses one with a live token', (t) => {
|
||||
const s = setup(t);
|
||||
s.c.unregister(AGENT, SERVICE);
|
||||
s.c.register(AGENT, SERVICE, s.ref);
|
||||
s.c.unregister(AGENT, SERVICE);
|
||||
assert.equal(s.state(), undefined);
|
||||
assert.throws(() => s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01'), /credential-unknown/);
|
||||
s.c.register(AGENT, SERVICE, s.ref);
|
||||
s.c.issue(AGENT, SERVICE, s.key('k2'), '2099-01-01');
|
||||
assert.throws(() => s.c.unregister(AGENT, SERVICE), /credential-reference/);
|
||||
assert.equal(s.state(), 'valid');
|
||||
s.c.revoke(AGENT, SERVICE);
|
||||
assert.equal(existsSync(s.file), false);
|
||||
s.c.unregister(AGENT, SERVICE);
|
||||
assert.equal(s.state(), undefined);
|
||||
});
|
||||
|
||||
test('X5: a claim holds the slot, so a second claim and a host.run refuse host-busy until release', async (t) => {
|
||||
const s = setup(t);
|
||||
s.c.register(AGENT, SERVICE, s.ref);
|
||||
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
|
||||
const signal = s.c.claim(AGENT, SERVICE);
|
||||
assert.equal(signal.aborted, false);
|
||||
assert.equal(s.c.busy(AGENT, SERVICE), true);
|
||||
assert.throws(() => s.c.claim(AGENT, SERVICE), /host-busy/);
|
||||
await assert.rejects(s.run(), /host-busy/);
|
||||
// A claim needs no reference: the executor claims before it registers a new key.
|
||||
const other = s.c.claim('demo/pm', SERVICE);
|
||||
s.c.release('demo/pm', SERVICE, other);
|
||||
s.c.release(AGENT, SERVICE, signal);
|
||||
assert.equal(s.c.busy(AGENT, SERVICE), false);
|
||||
assert.deepEqual(await s.run(), { ok: true });
|
||||
});
|
||||
|
||||
test('X5: a claim made while a host.run is in flight refuses host-busy', async (t) => {
|
||||
const s = setup(t);
|
||||
s.c.register(AGENT, SERVICE, s.ref);
|
||||
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
|
||||
let finish;
|
||||
const running = s.c.use(AGENT, SERVICE, () => new Promise((r) => (finish = r)));
|
||||
assert.throws(() => s.c.claim(AGENT, SERVICE), /host-busy/);
|
||||
finish({ ok: true });
|
||||
assert.deepEqual(await running, { ok: true });
|
||||
s.c.release(AGENT, SERVICE, s.c.claim(AGENT, SERVICE));
|
||||
});
|
||||
|
||||
test('X5: a stale release leaves a later claim in place', (t) => {
|
||||
const s = setup(t);
|
||||
const first = s.c.claim(AGENT, SERVICE);
|
||||
s.c.release(AGENT, SERVICE, first);
|
||||
const second = s.c.claim(AGENT, SERVICE);
|
||||
s.c.release(AGENT, SERVICE, first);
|
||||
s.c.release(AGENT, SERVICE);
|
||||
assert.equal(s.c.busy(AGENT, SERVICE), true);
|
||||
s.c.release(AGENT, SERVICE, second);
|
||||
assert.equal(s.c.busy(AGENT, SERVICE), false);
|
||||
});
|
||||
|
||||
test('X5: abortClaim aborts a live claim and says pending, and revoke\'s abort reaches a claim too', (t) => {
|
||||
const s = setup(t);
|
||||
assert.deepEqual(s.c.abortClaim(AGENT, SERVICE), { pending: false });
|
||||
const signal = s.c.claim(AGENT, SERVICE);
|
||||
assert.deepEqual(s.c.abortClaim(AGENT, SERVICE), { pending: true });
|
||||
assert.equal(signal.aborted, true);
|
||||
// The slot stays held until the executor releases it.
|
||||
assert.equal(s.c.busy(AGENT, SERVICE), true);
|
||||
s.c.release(AGENT, SERVICE, signal);
|
||||
s.c.register(AGENT, SERVICE, s.ref);
|
||||
s.c.issue(AGENT, SERVICE, s.key('k1'), '2099-01-01');
|
||||
const live = s.c.claim(AGENT, SERVICE);
|
||||
s.c.revoke(AGENT, SERVICE);
|
||||
assert.equal(live.aborted, true);
|
||||
s.c.release(AGENT, SERVICE, live);
|
||||
});
|
||||
-365
@@ -1,365 +0,0 @@
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
|
||||
CREATE TABLE events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN (
|
||||
'session.launched',
|
||||
'session.ended',
|
||||
'action.allowed',
|
||||
'action.refused',
|
||||
'task.created',
|
||||
'task.assigned',
|
||||
'task.state',
|
||||
'task.closed',
|
||||
'task.changed.external',
|
||||
'task.conflict',
|
||||
'task.missing',
|
||||
'review.requested',
|
||||
'review.verdict',
|
||||
'human.input',
|
||||
'config.refused',
|
||||
'credential.expiring',
|
||||
'credential.expired',
|
||||
'credential.changed',
|
||||
'launch.revoked',
|
||||
'launch.restored',
|
||||
'digest.sent')),
|
||||
actor_role TEXT, actor_run TEXT,
|
||||
subject TEXT,
|
||||
corrects TEXT REFERENCES events(id),
|
||||
body TEXT NOT NULL CHECK (json_valid(body))
|
||||
) STRICT;
|
||||
CREATE TABLE role_claims (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL, role TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
|
||||
holder_run TEXT NOT NULL,
|
||||
harness TEXT NOT NULL, address TEXT,
|
||||
by TEXT NOT NULL, reason TEXT,
|
||||
decision TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE decisions (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL, project TEXT,
|
||||
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
|
||||
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
|
||||
action TEXT NOT NULL,
|
||||
route_to TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
|
||||
recommendation TEXT NOT NULL,
|
||||
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
|
||||
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
|
||||
supersedes TEXT REFERENCES decisions(id)
|
||||
, spec_path TEXT, spec_commit TEXT) STRICT;
|
||||
CREATE TABLE decision_events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
at TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
|
||||
by TEXT NOT NULL,
|
||||
choice TEXT, note TEXT, via TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE messages (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
|
||||
to_role TEXT NOT NULL,
|
||||
class TEXT NOT NULL,
|
||||
in_reply_to TEXT REFERENCES messages(id),
|
||||
decision TEXT REFERENCES decisions(id),
|
||||
corrects TEXT REFERENCES messages(id),
|
||||
body TEXT NOT NULL
|
||||
, to_name TEXT) STRICT;
|
||||
CREATE TABLE deliveries (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message TEXT NOT NULL REFERENCES messages(id),
|
||||
at TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
|
||||
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE task_snapshots (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
|
||||
updated TEXT NOT NULL,
|
||||
etag TEXT,
|
||||
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
|
||||
fields TEXT NOT NULL CHECK (json_valid(fields)),
|
||||
source TEXT NOT NULL CHECK (source IN ('self','poll')),
|
||||
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
|
||||
read_at TEXT,
|
||||
role TEXT, run TEXT,
|
||||
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
|
||||
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
|
||||
CHECK (json_type(fields, '$.bucket') IS 'integer'
|
||||
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
|
||||
) STRICT;
|
||||
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
|
||||
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
|
||||
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
|
||||
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
|
||||
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
|
||||
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
|
||||
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
|
||||
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
|
||||
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
|
||||
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
|
||||
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
||||
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
||||
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
|
||||
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
|
||||
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
|
||||
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
|
||||
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
|
||||
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind GLOB 'credential.*' AND (
|
||||
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
|
||||
OR json_extract(NEW.body, '$.instance') IS NULL)
|
||||
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
|
||||
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'task.missing' AND (
|
||||
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
|
||||
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
|
||||
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
|
||||
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
|
||||
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
|
||||
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
|
||||
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'task.created' AND (
|
||||
json_type(NEW.body, '$.request') IS NOT 'text'
|
||||
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
|
||||
AND kind = 'human.input' AND business = NEW.business)
|
||||
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
|
||||
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
|
||||
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
|
||||
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
|
||||
CREATE VIEW launch_state AS
|
||||
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
|
||||
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
|
||||
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
|
||||
CREATE VIEW task_external_changes AS
|
||||
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
|
||||
FROM task_snapshots p
|
||||
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
|
||||
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
|
||||
WHERE p.source = 'poll'
|
||||
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
|
||||
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
|
||||
CREATE VIEW task_current AS
|
||||
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
|
||||
FROM task_snapshots s
|
||||
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
|
||||
WHERE c.business = s.business AND c.task_ref = s.task_ref
|
||||
AND (c.source = 'self'
|
||||
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
|
||||
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
|
||||
ORDER BY ls.seq DESC LIMIT 1), '')));
|
||||
CREATE VIEW tasks_open AS
|
||||
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
|
||||
FROM task_current s
|
||||
WHERE json_type(s.fields, '$.gone') IS NULL
|
||||
AND json_extract(s.fields, '$.done') = 0;
|
||||
CREATE VIEW urgent_inbox AS
|
||||
SELECT d.id, d.business, d.task_ref, d.question, d.at
|
||||
FROM decisions d
|
||||
WHERE d.class = 'gated' AND d.blocking = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
|
||||
CREATE TABLE task_comment_cursors (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
business TEXT NOT NULL,
|
||||
task_id INTEGER NOT NULL CHECK (task_id > 0),
|
||||
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
|
||||
) STRICT;
|
||||
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
|
||||
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
|
||||
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
|
||||
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
|
||||
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
|
||||
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
|
||||
CREATE TABLE projects (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
repo TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
UNIQUE (business, name)
|
||||
) STRICT;
|
||||
CREATE TABLE threads (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
project TEXT NOT NULL REFERENCES projects(id),
|
||||
instance TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
UNIQUE (project, name)
|
||||
) STRICT;
|
||||
CREATE TABLE thread_claims (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
|
||||
holder_run TEXT NOT NULL,
|
||||
harness TEXT NOT NULL, address TEXT,
|
||||
by TEXT NOT NULL, reason TEXT,
|
||||
decision TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE message_threads (
|
||||
message TEXT PRIMARY KEY REFERENCES messages(id),
|
||||
thread TEXT NOT NULL REFERENCES threads(id)
|
||||
) STRICT;
|
||||
CREATE TABLE thread_opens (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
|
||||
CREATE INDEX message_threads_thread ON message_threads (thread);
|
||||
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
|
||||
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
|
||||
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread already held'); END;
|
||||
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
|
||||
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = c.thread) AND c.op = 'claim')
|
||||
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); END;
|
||||
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread is not held'); END;
|
||||
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
||||
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
||||
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
|
||||
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
|
||||
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
|
||||
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
|
||||
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
|
||||
CREATE TRIGGER delivery_same_thread BEFORE INSERT ON deliveries
|
||||
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
|
||||
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
|
||||
WHERE mt.message = NEW.message AND m.to_role = t.instance
|
||||
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = t.id)
|
||||
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''s holder'); END;
|
||||
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TABLE instance_names (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
|
||||
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
|
||||
AND n.instance <> NEW.instance
|
||||
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
|
||||
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
|
||||
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
-- Row 124 (#1618): immutable source snapshots and turn outcomes, never transcript text.
|
||||
CREATE TABLE usage_records (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
run TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
account TEXT,
|
||||
turn TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
|
||||
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
|
||||
code TEXT,
|
||||
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
|
||||
input INTEGER,
|
||||
output INTEGER,
|
||||
cache_write INTEGER,
|
||||
cache_read INTEGER,
|
||||
UNIQUE (business,run,turn,source,phase),
|
||||
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
|
||||
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
|
||||
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
|
||||
) STRICT;
|
||||
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
@@ -1,489 +0,0 @@
|
||||
// Row 234 (#1740, #436a row a): host enrollment through a credential.mint raise with a `grant` binding
|
||||
// (r6 E1, E2, E4, X1, X3). The keyscan is a fake that returns key lines this test makes, ssh-keygen is
|
||||
// real, and the key files go under a scratch config dir. Nothing connects to any host.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker, BusError, writeGrantEvent, registrySha256 } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { hostGrant, fingerprint, grantTag, installerTag } from '../src/host-grant.mjs';
|
||||
import { grantDecision } from './host-fixtures.mjs';
|
||||
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const keyLine = (dir, name) => {
|
||||
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'x', '-f', join(dir, name)]);
|
||||
return readFileSync(join(dir, name + '.pub'), 'utf8').trim().split(' ').slice(0, 2).join(' ');
|
||||
};
|
||||
const refusesWith = async (p, code) => assert.rejects(Promise.resolve().then(() => p), (e) => (assert.equal(e.code, code), true));
|
||||
const refusesSync = (fn, code) => assert.throws(fn, (e) => (assert.equal(e.code, code), true));
|
||||
|
||||
function setup(t, { keygen, check, hang = false } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'g234-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const hostKeys = [keyLine(root, 'hk1'), keyLine(root, 'hk2')];
|
||||
const scans = [];
|
||||
const scan = { lines: hostKeys };
|
||||
const configDir = join(root, 'config');
|
||||
const real = hostGrant({
|
||||
configDir,
|
||||
runRoot: join(configDir, 'secrets', 'run'),
|
||||
scan: async (address, port) => (scans.push([address, port]), scan.lines),
|
||||
...(keygen ? { keygen } : {}),
|
||||
});
|
||||
const checks = [];
|
||||
// `hang`: steps 2 and 3 run, then the call never returns, as when the child dies before step 4.
|
||||
const enroll = hang ? async (...args) => (await real.enroll(...args), new Promise(() => {})) : real.enroll;
|
||||
const hg = { ...real, enroll, check: async (...args) => (checks.push(args), check ? check(...args) : { installerFingerprint: args[2].installer_fingerprint, exit: 0 }) };
|
||||
const business = {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
const make = (h = hg) => new Broker({ store, businesses: { demo: business }, hostGrant: h });
|
||||
const broker = make();
|
||||
const caps = {};
|
||||
const bind = (b) => {
|
||||
for (const role of ['chief', 'pm']) {
|
||||
caps[role] = b.bindLaunch({ business: 'demo', role, run: role + '-run-' + Math.random(), harness: 'pi', address: role });
|
||||
b.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
caps.tester = b.bindTester({ business: 'demo' });
|
||||
caps.reader = b.bindReader({ business: 'demo' });
|
||||
};
|
||||
bind(broker);
|
||||
const grant = (over = {}) => ({
|
||||
method: 'host-enroll',
|
||||
host: 'web1',
|
||||
address: '192.0.2.10',
|
||||
port: 22,
|
||||
user: 'jarvis',
|
||||
sudo: true,
|
||||
fingerprints: hostKeys.map(fingerprint),
|
||||
...over,
|
||||
});
|
||||
const call = (who, verb, args = {}, b = broker) => b.request(caps[who], { verb, args });
|
||||
const raise = (over = {}, who = 'chief', extra = {}) =>
|
||||
call(who, 'decision.raise', {
|
||||
action: 'credential.mint',
|
||||
question: 'Enroll web1?',
|
||||
options,
|
||||
recommendation: 'yes',
|
||||
blocking: false,
|
||||
grant: grant(over),
|
||||
...extra,
|
||||
});
|
||||
const events = () =>
|
||||
store
|
||||
.all("SELECT kind, actor_role, actor_run, body FROM events WHERE business='demo' ORDER BY seq")
|
||||
.map((e) => ({ kind: e.kind, actor_role: e.actor_role, actor_run: e.actor_run, ...JSON.parse(e.body) }));
|
||||
const grantEvents = () => events().filter((e) => e.actor_run === 'grant');
|
||||
const settle = async (decision) => {
|
||||
for (let i = 0; i < 250; i++) {
|
||||
if (grantEvents().some((e) => e.grantDecision === decision && ['grant.host.enrolled', 'grant.failed'].includes(e.action))) return;
|
||||
await sleep(20);
|
||||
}
|
||||
throw new Error('enrollment did not end');
|
||||
};
|
||||
const keyFile = (host = 'web1') => join(configDir, 'secrets', 'hosts', 'demo', host, 'installer');
|
||||
const hostRow = (host = 'web1') => store.get("SELECT * FROM hosts WHERE business='demo' AND host=?", host);
|
||||
const messages = () => store.all("SELECT from_role, to_role, class, decision, body FROM messages WHERE business='demo' ORDER BY rowid");
|
||||
return { root, store, broker, make, bind, caps, call, raise, grant, hostKeys, scan, scans, checks, events, grantEvents, settle, keyFile, hostRow, messages, configDir };
|
||||
}
|
||||
const approve = async (f, over) => {
|
||||
const d = await f.raise(over);
|
||||
const r = f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
await f.settle(d.id);
|
||||
return { d, r };
|
||||
};
|
||||
|
||||
test('G1, A1: only the chief raises a grant, and a refused raise stores nothing and runs no keyscan', async (t) => {
|
||||
const f = setup(t);
|
||||
await refusesWith(f.raise({}, 'pm'), 'grant-chief-only');
|
||||
await refusesWith(f.call('human', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: f.grant() }), 'agent-required');
|
||||
await refusesWith(f.call('tester', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: f.grant() }), 'tester-refused');
|
||||
assert.equal(f.scans.length, 0);
|
||||
assert.equal(f.store.get('SELECT count(*) AS n FROM decisions').n, 0);
|
||||
assert.deepEqual(f.events().filter((e) => e.kind === 'action.refused').map((e) => e.code), ['grant-chief-only', 'agent-required', 'tester-refused']);
|
||||
});
|
||||
|
||||
test('G1: grant goes only with credential.mint, row a takes only host-enroll, and E1 refuses each bad field', async (t) => {
|
||||
const f = setup(t);
|
||||
await refusesWith(f.raise({}, 'chief', { action: 'deploy' }), 'invalid-request');
|
||||
for (const over of [
|
||||
{ method: 'host' },
|
||||
{ user: 'root' },
|
||||
{ user: 'localadmin' },
|
||||
{ user: 'mosaic-installer' },
|
||||
{ user: 'Bad' },
|
||||
{ sudo: 'yes' },
|
||||
{ port: 0 },
|
||||
{ port: 65536 },
|
||||
{ host: 'Web_1' },
|
||||
{ address: '127.0.0.1' },
|
||||
{ address: 'localhost' },
|
||||
{ fingerprints: [] },
|
||||
{ fingerprints: ['SHA256:short'] },
|
||||
{ fingerprints: [f.grant().fingerprints[0], f.grant().fingerprints[0]] },
|
||||
{ extra: 1 },
|
||||
])
|
||||
await refusesWith(f.raise(over), 'invalid-request');
|
||||
const { method, ...noMethod } = f.grant();
|
||||
await refusesWith(
|
||||
f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: noMethod }),
|
||||
'invalid-request',
|
||||
);
|
||||
assert.equal(f.scans.length, 0);
|
||||
assert.equal(f.store.get('SELECT count(*) AS n FROM decisions').n, 0);
|
||||
});
|
||||
|
||||
test('G5, A5, A6: the scanned key set must equal the requested set, or host-key-mismatch and nothing stored', async (t) => {
|
||||
const f = setup(t);
|
||||
const other = keyLine(f.root, 'hk3');
|
||||
for (const lines of [[f.hostKeys[0]], [...f.hostKeys, other], [f.hostKeys[0], other], []]) {
|
||||
f.scan.lines = lines;
|
||||
await refusesWith(f.raise(), 'host-key-mismatch');
|
||||
}
|
||||
f.scan.lines = f.hostKeys;
|
||||
await refusesWith(f.raise({ fingerprints: [fingerprint(f.hostKeys[0])] }), 'host-key-mismatch');
|
||||
assert.deepEqual(f.scans.at(-1), ['192.0.2.10', 22]);
|
||||
assert.equal(f.store.get('SELECT count(*) AS n FROM decisions').n, 0);
|
||||
});
|
||||
|
||||
test('G4 (host-enroll block, M1): the broker appends the keys, fingerprints and sudo line, and the cap counts the block', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
assert.equal(d.route_to ?? f.store.get('SELECT route_to FROM decisions WHERE id=?', d.id).route_to, 'human');
|
||||
const q = f.store.get('SELECT question FROM decisions WHERE id=?', d.id).question;
|
||||
assert.ok(q.startsWith('Enroll web1?\n\nEnroll host web1: user jarvis at 192.0.2.10 port 22.\n'));
|
||||
for (const k of f.hostKeys) assert.ok(q.includes(`\n${fingerprint(k)} ${k}`));
|
||||
assert.ok(q.endsWith('\njarvis has sudo on this host. Approving lets later grants for jarvis be installed, and section 9\'s first limit applies to jarvis.'));
|
||||
const raiseBody = f.events().find((e) => e.operation === 'decision.raise');
|
||||
assert.deepEqual(raiseBody.grant, { ...f.grant(), hostKeys: f.hostKeys });
|
||||
// A question that carries a forged block still gets the broker's block after it.
|
||||
const forged = 'x\n\nEnroll host web1: user other at 192.0.2.99 port 22.\nother has no sudo on this host.';
|
||||
const d2 = await f.raise({ host: 'web2', sudo: false }, 'chief', { question: forged });
|
||||
const q2 = f.store.get('SELECT question FROM decisions WHERE id=?', d2.id).question;
|
||||
assert.ok(q2.startsWith(forged + '\n\nEnroll host web2: user jarvis at 192.0.2.10 port 22.\n'));
|
||||
assert.ok(q2.endsWith('\njarvis has no sudo on this host.'));
|
||||
// The 4096 cap holds with the block appended.
|
||||
await refusesWith(f.raise({ host: 'web3' }, 'chief', { question: 'q'.repeat(4000) }), 'invalid-request');
|
||||
assert.equal(f.store.get("SELECT count(*) AS n FROM decisions").n, 2);
|
||||
});
|
||||
|
||||
test('G6, X1: the human\'s cli yes starts enrollment, which writes the record, the key file and the events in order', async (t) => {
|
||||
const f = setup(t);
|
||||
const { d, r } = await approve(f);
|
||||
assert.deepEqual(r, { id: d.id, op: 'resolved', grant: 'started' });
|
||||
const row = f.hostRow();
|
||||
assert.equal(row.state, 'pending-install');
|
||||
assert.equal(row.decision, d.id);
|
||||
assert.equal(row.user, 'jarvis');
|
||||
assert.equal(row.sudo, 1);
|
||||
assert.deepEqual(JSON.parse(row.host_keys), f.hostKeys);
|
||||
assert.match(row.installer_fingerprint, /^SHA256:/);
|
||||
// E2 step 3: 0600 in 0700 dirs.
|
||||
assert.equal(statSync(f.keyFile()).mode & 0o777, 0o600);
|
||||
for (const dir of [join(f.keyFile(), '..'), join(f.keyFile(), '..', '..')]) assert.equal(statSync(dir).mode & 0o777, 0o700);
|
||||
const g = f.grantEvents();
|
||||
assert.deepEqual(g.map((e) => e.operation ?? e.action), ['grant.host-enroll', 'grant.host.enroll.step', 'grant.host.enrolled']);
|
||||
for (const e of g) assert.deepEqual([e.kind, e.actor_role, e.actor_run], ['action.allowed', null, 'grant']);
|
||||
assert.deepEqual(g[0], { kind: 'action.allowed', actor_role: null, actor_run: 'grant', operation: 'grant.host-enroll', decision: d.id });
|
||||
assert.equal(g[1].grantDecision, d.id);
|
||||
assert.equal(g[1].step, 'registry');
|
||||
const enrolled = g[2];
|
||||
assert.equal('decision' in enrolled, false);
|
||||
assert.equal(enrolled.registrySha256, registrySha256({ address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, hostKeys: f.hostKeys }));
|
||||
assert.equal(enrolled.installerFingerprint, row.installer_fingerprint);
|
||||
assert.deepEqual([enrolled.user, enrolled.sudo], ['jarvis', true]);
|
||||
// E2 step 4's message to the human and the chief: the installer line, public half only.
|
||||
const tag = installerTag('demo', 'web1');
|
||||
const pub = execFileSync('ssh-keygen', ['-y', '-f', f.keyFile()], { encoding: 'utf8' }).trim().split(' ').slice(0, 2).join(' ');
|
||||
const notes = f.messages().filter((m) => m.from_role === '_bus' && m.decision === d.id);
|
||||
assert.deepEqual(notes.map((m) => [m.to_role, m.class]), [['human', 'RESULT'], ['chief', 'RESULT']]);
|
||||
for (const m of notes)
|
||||
assert.ok(m.body.includes(`\nrestrict,command="/usr/local/sbin/mosaic-keys",from="<from>" ${pub} mosaic-installer-${tag.slice(7)}\n`));
|
||||
// A7: no part of the private key reaches the store.
|
||||
const secret = readFileSync(f.keyFile(), 'utf8').split('\n').filter((l) => l.length >= 24 && !l.startsWith('-----'));
|
||||
const dump = JSON.stringify([f.store.all('SELECT body FROM events'), f.store.all('SELECT body FROM messages'), f.store.all('SELECT * FROM hosts')]);
|
||||
for (const l of secret) assert.equal(dump.includes(l), false);
|
||||
// The tmp dir under the run root is gone.
|
||||
assert.deepEqual(readdirSync(join(f.configDir, 'secrets', 'run')), []);
|
||||
});
|
||||
|
||||
test('A2, A4: a credential.mint without grant, and a no, start nothing', async (t) => {
|
||||
const f = setup(t);
|
||||
const plain = f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'Hand-made key?', options, recommendation: 'yes', blocking: false });
|
||||
assert.deepEqual(f.call('human', 'decision.resolve', { id: plain.id, choice: 'yes' }), { id: plain.id, op: 'resolved' });
|
||||
const d = await f.raise();
|
||||
assert.deepEqual(f.call('human', 'decision.resolve', { id: d.id, choice: 'no' }), { id: d.id, op: 'resolved' });
|
||||
await sleep(200);
|
||||
assert.deepEqual(f.grantEvents(), []);
|
||||
assert.equal(f.hostRow(), undefined);
|
||||
});
|
||||
|
||||
test('A3, X1: a resolve by the tester or an agent starts nothing', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
for (const who of ['tester', 'pm', 'chief']) {
|
||||
try {
|
||||
f.call(who, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
} catch {}
|
||||
}
|
||||
await sleep(200);
|
||||
assert.deepEqual(f.grantEvents(), []);
|
||||
assert.equal(f.hostRow(), undefined);
|
||||
});
|
||||
|
||||
test('G6 (duckworth-0081 B3): a withdraw by the chief or the human, and an arbiter resolve, start nothing', async (t) => {
|
||||
const f = setup(t);
|
||||
for (const who of ['chief', 'human']) {
|
||||
const d = await f.raise();
|
||||
assert.deepEqual(f.call(who, 'decision.withdraw', { id: d.id }), { id: d.id, op: 'withdrawn' });
|
||||
}
|
||||
// A grant raise always routes to the human, so the fixture writes an open one routed to the pm
|
||||
// arbiter, which then resolves it yes through the verb.
|
||||
const d = { id: grantDecision(f.store, { route: 'pm', grant: f.grant(), close: null }) };
|
||||
assert.deepEqual(f.call('pm', 'decision.resolve', { id: d.id, choice: 'yes' }), { id: d.id, op: 'resolved' });
|
||||
await sleep(200);
|
||||
assert.deepEqual(f.grantEvents(), []);
|
||||
assert.equal(f.hostRow(), undefined);
|
||||
assert.deepEqual(f.broker.reconcileGrants(), []);
|
||||
assert.equal(await f.broker.runEnrollment('demo', d.id), 'not-authorized');
|
||||
assert.deepEqual(f.grantEvents(), []);
|
||||
});
|
||||
|
||||
test('G10 (duckworth-0081 B1): a second start for a spent decision refuses decision-consumed, and an unresolved or agent-resolved one not-authorized', async (t) => {
|
||||
const f = setup(t);
|
||||
const { d } = await approve(f);
|
||||
const before = { events: f.grantEvents(), row: f.hostRow(), messages: f.messages().length };
|
||||
assert.equal(await f.broker.runEnrollment('demo', d.id), 'decision-consumed');
|
||||
assert.deepEqual(f.grantEvents(), before.events);
|
||||
assert.deepEqual(f.hostRow(), before.row);
|
||||
assert.equal(f.messages().length, before.messages);
|
||||
// Unresolved, then resolved by an agent: written by SQL, since the verb refuses the agent first.
|
||||
const open = await f.raise({ host: 'web2' });
|
||||
assert.equal(await f.broker.runEnrollment('demo', open.id), 'not-authorized');
|
||||
f.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', open.id, new Date().toISOString(), 'resolved', 'chief', 'yes', 'broker');
|
||||
assert.equal(await f.broker.runEnrollment('demo', open.id), 'not-authorized');
|
||||
// A cli yes by someone other than the human, so the by check alone refuses it (duckworth-0083 N8, A3b).
|
||||
const other = await f.raise({ host: 'web3' });
|
||||
f.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', other.id, new Date().toISOString(), 'resolved', 'chief', 'yes', 'cli');
|
||||
assert.equal(await f.broker.runEnrollment('demo', other.id), 'not-authorized');
|
||||
assert.deepEqual(f.broker.reconcileGrants(), []);
|
||||
assert.deepEqual(f.grantEvents(), before.events);
|
||||
assert.equal(f.hostRow('web2'), undefined);
|
||||
assert.equal(existsSync(f.keyFile('web2')), false);
|
||||
assert.equal(f.hostRow('web3'), undefined);
|
||||
assert.equal(existsSync(f.keyFile('web3')), false);
|
||||
});
|
||||
|
||||
test('A8, S1: two approved enrollments for one host: the second ends host-enrolled and the pin stays', async (t) => {
|
||||
const f = setup(t);
|
||||
const d1 = await f.raise();
|
||||
const d2 = await f.raise({ user: 'melanie', sudo: false });
|
||||
f.call('human', 'decision.resolve', { id: d1.id, choice: 'yes' });
|
||||
await f.settle(d1.id);
|
||||
const before = f.hostRow();
|
||||
f.call('human', 'decision.resolve', { id: d2.id, choice: 'yes' });
|
||||
await f.settle(d2.id);
|
||||
assert.deepEqual(f.hostRow(), before);
|
||||
const failed = f.grantEvents().find((e) => e.grantDecision === d2.id && e.action === 'grant.failed');
|
||||
assert.equal(failed.code, 'host-enrolled');
|
||||
assert.ok(f.grantEvents().some((e) => e.operation === 'grant.host-enroll' && e.decision === d2.id));
|
||||
assert.equal(f.grantEvents().filter((e) => e.grantDecision === d2.id && e.action === 'grant.host.enroll.step').length, 0);
|
||||
// A raise for an enrolled host refuses before the keyscan.
|
||||
const scans = f.scans.length;
|
||||
await refusesWith(f.raise(), 'host-enrolled');
|
||||
assert.equal(f.scans.length, scans);
|
||||
});
|
||||
|
||||
test('A9: the hosts primary key refuses a second record for (business, host)', (t) => {
|
||||
const f = setup(t);
|
||||
const d = f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false });
|
||||
const insert = () =>
|
||||
f.store.run(
|
||||
"INSERT INTO hosts(business,host,address,port,user,sudo,host_keys,state,decision) VALUES('demo','web1','192.0.2.1',22,'u',0,'[\"k\"]','pending-install',?)",
|
||||
d.id,
|
||||
);
|
||||
insert();
|
||||
assert.throws(insert, /UNIQUE|PRIMARY/);
|
||||
});
|
||||
|
||||
test('X3: a failure at step 2 deletes the record again and ends grant.failed, with no key file', async (t) => {
|
||||
const f = setup(t, { keygen: '/nonexistent/ssh-keygen' });
|
||||
const { d } = await approve(f);
|
||||
assert.equal(f.hostRow(), undefined);
|
||||
assert.equal(existsSync(f.keyFile()), false);
|
||||
const failed = f.grantEvents().find((e) => e.action === 'grant.failed');
|
||||
assert.deepEqual([failed.grantDecision, failed.host, failed.code], [d.id, 'web1', 'host-failed']);
|
||||
const notes = f.messages().filter((m) => m.from_role === '_bus' && m.decision === d.id);
|
||||
assert.deepEqual(notes.map((m) => m.to_role), ['human', 'chief']);
|
||||
assert.ok(notes[0].body.includes('failed: host-failed'));
|
||||
});
|
||||
|
||||
test('A10, X3: at start a resolved, unspent enrollment is spent and ends interrupted, and never runs late', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise();
|
||||
// The resolve commits, and the child dies before step 1: the resolve row alone, written here.
|
||||
f.store.run("INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)", d.id, new Date().toISOString(), 'resolved', 'jason', 'yes', 'cli');
|
||||
const restarted = f.make();
|
||||
assert.deepEqual(restarted.reconcileGrants(), [d.id]);
|
||||
const g = f.grantEvents();
|
||||
assert.deepEqual(g.map((e) => e.operation ?? e.action), ['grant.host-enroll', 'grant.failed']);
|
||||
assert.deepEqual([g[1].code, g[1].host, g[1].tag], ['interrupted', 'web1', installerTag('demo', 'web1')]);
|
||||
assert.equal(f.hostRow(), undefined);
|
||||
assert.deepEqual(restarted.reconcileGrants(), []);
|
||||
});
|
||||
|
||||
test('X3: at start an enrollment that stopped after its registry step loses its record and key file', async (t) => {
|
||||
const f = setup(t, { hang: true });
|
||||
const d = await f.raise();
|
||||
f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
for (let i = 0; i < 250 && !existsSync(f.keyFile()); i++) await sleep(20);
|
||||
assert.equal(f.hostRow().state, 'pending-install');
|
||||
assert.ok(existsSync(f.keyFile()));
|
||||
const restarted = f.make();
|
||||
assert.deepEqual(restarted.reconcileGrants(), [d.id]);
|
||||
assert.equal(f.hostRow(), undefined);
|
||||
assert.equal(existsSync(f.keyFile()), false);
|
||||
const failed = f.grantEvents().find((e) => e.grantDecision === d.id && e.action === 'grant.failed');
|
||||
assert.deepEqual([failed.code, failed.host, failed.tag], ['interrupted', 'web1', installerTag('demo', 'web1')]);
|
||||
assert.deepEqual(f.messages().filter((m) => m.decision === d.id && m.from_role === '_bus').map((m) => m.to_role), ['human', 'chief']);
|
||||
assert.deepEqual(restarted.reconcileGrants(), []);
|
||||
});
|
||||
|
||||
test('G24 first sentence, M2, A12: tags are mosaic- and 32 hex, and differ by business, agent and host', () => {
|
||||
const long = 'a'.repeat(30) + '.' + '_'.repeat(2) + 'b'.repeat(31);
|
||||
assert.equal(long.length, 64);
|
||||
const tags = [grantTag('demo', long, 'web1'), grantTag('demo', long + 'x', 'web1'), grantTag('other', long, 'web1'), grantTag('demo', long, 'web2'), installerTag('demo', 'web1'), installerTag('other', 'web1')];
|
||||
for (const t of tags) assert.match(t, /^mosaic-[0-9a-f]{32}$/);
|
||||
assert.equal(new Set(tags).size, tags.length);
|
||||
});
|
||||
|
||||
test('E4: hosts.check sets the record ready and records grant.host.checked; a non-zero list leaves it pending', async (t) => {
|
||||
const exits = [1, 0];
|
||||
const f = setup(t, { check: (b, h, r) => ({ installerFingerprint: r.installer_fingerprint, exit: exits.shift() }) });
|
||||
await approve(f);
|
||||
await refusesWith(f.call('chief', 'hosts.check', { host: 'web1' }), 'host-failed');
|
||||
assert.equal(f.hostRow().state, 'pending-install');
|
||||
await refusesWith(f.call('pm', 'hosts.check', { host: 'web1' }), 'grant-chief-only');
|
||||
await refusesWith(f.call('chief', 'hosts.check', { host: 'nope' }), 'host-unknown');
|
||||
const r = await f.call('chief', 'hosts.check', { host: 'web1' });
|
||||
assert.equal(r.state, 'ready');
|
||||
assert.equal(f.hostRow().state, 'ready');
|
||||
assert.equal(f.hostRow().checked_at, r.checkedAt);
|
||||
const checked = f.grantEvents().find((e) => e.action === 'grant.host.checked');
|
||||
assert.deepEqual([checked.host, checked.by, checked.installerFingerprint], ['web1', 'chief', f.hostRow().installer_fingerprint]);
|
||||
assert.equal(checked.registrySha256, registrySha256({ address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, hostKeys: f.hostKeys }));
|
||||
const status = await f.call('human', 'hosts.status');
|
||||
assert.deepEqual(status.map((h) => [h.host, h.state, h.hostKeys, h.hostLineLeft]), [['web1', 'ready', f.hostKeys.map(fingerprint), []]]);
|
||||
});
|
||||
|
||||
test('G20 (duckworth-0081 B3): the human\'s hosts.check marks the host ready, and the reader refuses', async (t) => {
|
||||
const f = setup(t);
|
||||
await approve(f);
|
||||
await refusesWith(f.call('reader', 'hosts.check', { host: 'web1' }), 'read-only');
|
||||
const r = await f.call('human', 'hosts.check', { host: 'web1' });
|
||||
assert.equal(r.state, 'ready');
|
||||
assert.equal(f.hostRow().state, 'ready');
|
||||
const checked = f.grantEvents().filter((e) => e.action === 'grant.host.checked');
|
||||
assert.deepEqual(checked.map((e) => e.by), ['jason']);
|
||||
});
|
||||
|
||||
test('G18 (duckworth-0081 B2), broker half: a check refused host-key-mismatch leaves the host pending-install with no grant.host.checked', async (t) => {
|
||||
const f = setup(t, { check: () => { throw new BusError('host-key-mismatch'); } });
|
||||
await approve(f);
|
||||
await refusesWith(f.call('chief', 'hosts.check', { host: 'web1' }), 'host-key-mismatch');
|
||||
assert.equal(f.checks.length, 1);
|
||||
assert.equal(f.hostRow().state, 'pending-install');
|
||||
assert.equal(f.hostRow().checked_at, null);
|
||||
assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.host.checked'), []);
|
||||
});
|
||||
|
||||
test('E4: a check whose installer fingerprint differs from the record refuses host-changed', async (t) => {
|
||||
const f = setup(t, { check: () => ({ installerFingerprint: 'SHA256:' + 'A'.repeat(43), exit: 0 }) });
|
||||
await approve(f);
|
||||
await refusesWith(f.call('tester', 'hosts.check', { host: 'web1' }), 'host-changed');
|
||||
assert.equal(f.hostRow().state, 'pending-install');
|
||||
});
|
||||
|
||||
test('E4, G21: hosts.remove is the human\'s or the tester\'s, deletes the key and the record, and records grant.host.removed', async (t) => {
|
||||
const f = setup(t);
|
||||
await approve(f);
|
||||
await refusesWith(f.call('chief', 'hosts.remove', { host: 'web1' }), 'human-required');
|
||||
await refusesWith(f.call('reader', 'hosts.remove', { host: 'web1' }), 'read-only');
|
||||
assert.deepEqual(await f.call('human', 'hosts.remove', { host: 'web1' }), { host: 'web1', removed: true });
|
||||
assert.equal(f.hostRow(), undefined);
|
||||
assert.equal(existsSync(f.keyFile()), false);
|
||||
assert.equal(existsSync(join(f.keyFile(), '..')), false);
|
||||
const removed = f.grantEvents().find((e) => e.action === 'grant.host.removed');
|
||||
assert.deepEqual([removed.host, removed.by], ['web1', 'jason']);
|
||||
await refusesWith(f.call('tester', 'hosts.remove', { host: 'web1' }), 'host-unknown');
|
||||
});
|
||||
|
||||
test('G21, F1, F2, M4, A22: hosts.remove refuses host-line-left on a guarded hostLineLeft, and ignores look-alikes', async (t) => {
|
||||
const f = setup(t);
|
||||
const { d } = await approve(f);
|
||||
const at = new Date().toISOString();
|
||||
// Look-alikes: the same body under action.refused, and under another actor of another kind.
|
||||
const lookAlike = JSON.stringify({ action: 'grant.failed', grantDecision: d.id, host: 'web1', hostLineLeft: installerTag('demo', 'web1') });
|
||||
f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('la1',?,'demo','action.refused',NULL,'grant','web1',?)", at, lookAlike);
|
||||
f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('la2',?,'demo','action.refused','chief','chief-run','web1',?)", at, lookAlike);
|
||||
// The trigger refuses the look-alike as an action.allowed from a session.
|
||||
assert.throws(() =>
|
||||
f.store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES('la3',?,'demo','action.allowed','chief','chief-run','web1',?)", at, lookAlike),
|
||||
);
|
||||
assert.equal((await f.call('human', 'hosts.status'))[0].hostLineLeft.length, 0);
|
||||
// F1: the real shape, through the grant writer. F2: any guarded body, not only grant.failed.
|
||||
writeGrantEvent(f.store, at, 'demo', { action: 'grant.revoke.closed', tag: 'mosaic-' + '0'.repeat(32), host: 'web1', hostLineLeft: 'mosaic-' + '0'.repeat(32) }, 'web1');
|
||||
assert.deepEqual((await f.call('human', 'hosts.status'))[0].hostLineLeft, ['mosaic-' + '0'.repeat(32)]);
|
||||
await refusesWith(f.call('human', 'hosts.remove', { host: 'web1' }), 'host-line-left');
|
||||
assert.ok(f.hostRow());
|
||||
assert.ok(existsSync(f.keyFile()));
|
||||
});
|
||||
|
||||
test('hosts.remove refuses host-in-use while a grant record names the host', async (t) => {
|
||||
const f = setup(t);
|
||||
const { d } = await approve(f);
|
||||
f.store.run(
|
||||
"INSERT INTO host_grants(business,agent,host,commands,rotate_by,tag,decision) VALUES('demo','pm','web1','[[\"true\"]]','2099-01-01',?,?)",
|
||||
grantTag('demo', 'pm', 'web1'),
|
||||
d.id,
|
||||
);
|
||||
await refusesWith(f.call('human', 'hosts.remove', { host: 'web1' }), 'host-in-use');
|
||||
assert.ok(f.hostRow());
|
||||
});
|
||||
|
||||
test('the grant writer refuses a body outside section 7\'s shape', (t) => {
|
||||
const f = setup(t);
|
||||
const at = new Date().toISOString();
|
||||
for (const body of [
|
||||
{ action: 'host.sudo' },
|
||||
{ action: 'grant.failed', decision: 'd' },
|
||||
{ operation: 'grant.host-enroll', decision: 'd', host: 'web1' },
|
||||
{ operation: 'grant.host-enroll' },
|
||||
{ operation: 'decision.raise', decision: 'd' },
|
||||
])
|
||||
assert.throws(() => writeGrantEvent(f.store, at, 'demo', body), /grant event shape/);
|
||||
});
|
||||
|
||||
test('recordEvent refuses reserved-event for a grant.* action or operation', (t) => {
|
||||
const f = setup(t);
|
||||
for (const body of [{ action: 'grant.failed' }, { operation: 'grant.host-enroll', decision: 'x' }])
|
||||
refusesSync(() => f.broker.recordEvent(f.caps.chief, { kind: 'action.allowed', body }), 'reserved-event');
|
||||
assert.equal(f.store.get("SELECT count(*) AS n FROM events WHERE json_extract(body,'$.action') GLOB 'grant.*'").n, 0);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,228 +0,0 @@
|
||||
// Row 235 (#1741, #436 row b): the host grant raise (r6 R1-R4). A credential.mint raise whose `grant`
|
||||
// has method `host` binds an agent, an enrolled host, argv prefixes and a rotate date. The host record
|
||||
// comes from the fixture, and nothing here connects to any host.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker, registrySha256 } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { hostGrant, definitionSha256 } from '../src/host-grant.mjs';
|
||||
import { hostRecord, hostGrantRecord } from './host-fixtures.mjs';
|
||||
import { canonicalJson } from '../../business/src/util.mjs';
|
||||
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
const refusesWith = async (p, code) => assert.rejects(Promise.resolve().then(() => p), (e) => (assert.equal(e.code, code), true));
|
||||
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
||||
const ENTRY = { address: '192.0.2.10', port: 22, user: 'jarvis', hostKeys: ['ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHostKeyForTests'] };
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const DEFINITIONS = {
|
||||
'chief-of-staff': { roleVersion: 2, credentials: [] },
|
||||
pm: { roleVersion: 2, credentials: [] },
|
||||
ops: { roleVersion: 2, credentials: [{ service: 'gitea', scopes: ['read:repository'] }, { service: 'host' }] },
|
||||
};
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'g235-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const configDir = join(root, 'config');
|
||||
const business = {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', ...none },
|
||||
pm: { definition: 'pm', ...none },
|
||||
ops: { definition: 'ops', ...none },
|
||||
odd: { definition: 'constructor', ...none },
|
||||
// A creative role whose definition declares host, so only the creative check refuses it.
|
||||
muse: { definition: 'research', ...none },
|
||||
},
|
||||
definitions: { ...structuredClone(DEFINITIONS), research: structuredClone(DEFINITIONS.ops) },
|
||||
vars: { 'bus.tester': true },
|
||||
};
|
||||
const broker = new Broker({ store, businesses: { demo: business }, hostGrant: hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run') }) });
|
||||
const caps = {};
|
||||
for (const role of ['chief', 'pm']) {
|
||||
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
|
||||
broker.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
caps.tester = broker.bindTester({ business: 'demo' });
|
||||
hostRecord(store, { host: 'web1', entry: ENTRY });
|
||||
const grant = (over = {}) => ({ method: 'host', agent: 'ops', host: 'web1', commands: [['systemctl', 'status']], rotateBy: dayFrom(30), ...over });
|
||||
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
|
||||
const raise = (over = {}, who = 'chief', extra = {}) =>
|
||||
call(who, 'decision.raise', { action: 'credential.mint', question: 'Grant ops web1?', options, recommendation: 'yes', blocking: false, grant: grant(over), ...extra });
|
||||
const decisions = () => store.get('SELECT count(*) AS n FROM decisions').n;
|
||||
const question = (id) => store.get('SELECT question FROM decisions WHERE id=?', id).question;
|
||||
const raiseBody = (id) =>
|
||||
JSON.parse(store.get("SELECT body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.operation')='decision.raise' AND json_extract(body,'$.decision')=?", id).body);
|
||||
const refusals = () => store.all("SELECT body FROM events WHERE business='demo' AND kind='action.refused' ORDER BY seq").map((e) => JSON.parse(e.body).code);
|
||||
return { store, broker, caps, call, raise, grant, decisions, question, raiseBody, refusals };
|
||||
}
|
||||
|
||||
test('G1 (host): only the chief raises a host grant, a grant on any other action refuses, and nothing is stored', async (t) => {
|
||||
const f = setup(t);
|
||||
const before = f.decisions();
|
||||
await refusesWith(f.raise({}, 'pm'), 'grant-chief-only');
|
||||
await refusesWith(f.raise({}, 'chief', { action: 'deploy' }), 'invalid-request');
|
||||
await refusesWith(f.raise({}, 'tester'), 'tester-refused');
|
||||
assert.equal(f.decisions(), before);
|
||||
assert.deepEqual(f.refusals(), ['grant-chief-only', 'invalid-request', 'tester-refused']);
|
||||
});
|
||||
|
||||
test('G1, R4: the grant takes exactly method, agent, host, commands and rotateBy, and an unknown method refuses', async (t) => {
|
||||
const f = setup(t);
|
||||
const before = f.decisions();
|
||||
const g = f.grant();
|
||||
const { rotateBy, ...noRotate } = g;
|
||||
for (const bad of [
|
||||
noRotate,
|
||||
{ ...g, extra: 1 },
|
||||
{ ...g, method: 'gitea' },
|
||||
{ ...g, method: 'Host' },
|
||||
{ ...g, agent: 'Ops' },
|
||||
{ ...g, agent: '' },
|
||||
{ ...g, agent: 7 },
|
||||
{ ...g, host: 'Web_1' },
|
||||
{ ...g, host: 'a'.repeat(33) },
|
||||
])
|
||||
await refusesWith(f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: bad }), 'invalid-request');
|
||||
for (const bad of [null, [], 'host'])
|
||||
await refusesWith(f.call('chief', 'decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: bad }), 'invalid-request');
|
||||
assert.equal(f.decisions(), before);
|
||||
assert.ok(rotateBy);
|
||||
});
|
||||
|
||||
test('G2: an agent whose definition lacks host refuses host-not-declared, and an unknown agent unknown-role', async (t) => {
|
||||
const f = setup(t);
|
||||
const before = f.decisions();
|
||||
await refusesWith(f.raise({ agent: 'pm' }), 'host-not-declared');
|
||||
await refusesWith(f.raise({ agent: 'chief' }), 'host-not-declared');
|
||||
await refusesWith(f.raise({ agent: 'nobody' }), 'unknown-role');
|
||||
// An inherited name is not a definition.
|
||||
await refusesWith(f.raise({ agent: 'odd' }), 'host-not-declared');
|
||||
assert.equal(f.decisions(), before);
|
||||
});
|
||||
|
||||
test('mos-1163: a grant whose agent is a creative role refuses host-creative-role, even when its definition declares host', async (t) => {
|
||||
const f = setup(t);
|
||||
const before = f.decisions();
|
||||
await refusesWith(f.raise({ agent: 'muse' }), 'host-creative-role');
|
||||
assert.equal(f.decisions(), before);
|
||||
});
|
||||
|
||||
test('G3: wildcard, shell, empty and other 210 D6 argv prefixes refuse invalid-request', async (t) => {
|
||||
const f = setup(t);
|
||||
const before = f.decisions();
|
||||
for (const commands of [
|
||||
'any',
|
||||
'*',
|
||||
{},
|
||||
[],
|
||||
[[]],
|
||||
[['*']],
|
||||
[['systemctl', 'status*']],
|
||||
[['ls', '[a]']],
|
||||
[['bash']],
|
||||
[['/bin/sh', '-c']],
|
||||
[['sudo', 'ls']],
|
||||
[['python3']],
|
||||
[['env', 'ls']],
|
||||
[['bin/ls']],
|
||||
[['']],
|
||||
[['ls', 'a\0b']],
|
||||
[['ls', 3]],
|
||||
[['ls'], ['ls']],
|
||||
[Array.from({ length: 257 }, () => 'x')],
|
||||
])
|
||||
await refusesWith(f.raise({ commands }), 'invalid-request');
|
||||
assert.equal(f.decisions(), before);
|
||||
});
|
||||
|
||||
test('R4 rotateBy (sage-1782): a real calendar day after today UTC, or invalid-request', async (t) => {
|
||||
const f = setup(t);
|
||||
const before = f.decisions();
|
||||
for (const rotateBy of [dayFrom(0), dayFrom(-1), '2026-02-30', '2026-13-01', '20991231', '2099-1-01', 20991231, null])
|
||||
await refusesWith(f.raise({ rotateBy }), 'invalid-request');
|
||||
assert.equal(f.decisions(), before);
|
||||
await f.raise({ rotateBy: dayFrom(1) });
|
||||
await f.raise({ rotateBy: '2999-12-31' });
|
||||
assert.equal(f.decisions(), before + 2);
|
||||
});
|
||||
|
||||
test('G19: a grant naming a host that is pending-install refuses host-not-ready, and an unknown host host-unknown', async (t) => {
|
||||
const f = setup(t);
|
||||
hostRecord(f.store, { host: 'web2', entry: ENTRY });
|
||||
f.store.run("UPDATE hosts SET state='pending-install' WHERE business='demo' AND host='web2'");
|
||||
const before = f.decisions();
|
||||
await refusesWith(f.raise({ host: 'web2' }), 'host-not-ready');
|
||||
await refusesWith(f.raise({ host: 'web9' }), 'host-unknown');
|
||||
assert.equal(f.decisions(), before);
|
||||
});
|
||||
|
||||
test('R4, G5: the raise routes to the human and its event body binds the eight fields with their hashes', async (t) => {
|
||||
const f = setup(t);
|
||||
const commands = [['systemctl', 'status'], ['/usr/bin/journalctl', '-u', 'nginx']];
|
||||
const d = await f.raise({ commands });
|
||||
const row = f.store.get('SELECT action, class, route_to FROM decisions WHERE id=?', d.id);
|
||||
assert.deepEqual({ ...row }, { action: 'credential.mint', class: 'gated', route_to: 'human' });
|
||||
const body = f.raiseBody(d.id);
|
||||
assert.deepEqual(Object.keys(body.grant), ['method', 'agent', 'host', 'commands', 'commandsSha256', 'rotateBy', 'registrySha256', 'definitionSha256']);
|
||||
assert.deepEqual(body.grant, {
|
||||
method: 'host',
|
||||
agent: 'ops',
|
||||
host: 'web1',
|
||||
commands,
|
||||
commandsSha256: createHash('sha256').update(JSON.stringify(commands)).digest('hex'),
|
||||
rotateBy: f.grant().rotateBy,
|
||||
registrySha256: registrySha256({ ...ENTRY, sudo: true }),
|
||||
definitionSha256: createHash('sha256').update(canonicalJson(DEFINITIONS.ops)).digest('hex'),
|
||||
});
|
||||
// Key order in the definition doesn't change the hash, and a field change does.
|
||||
const reordered = { credentials: DEFINITIONS.ops.credentials, roleVersion: 2 };
|
||||
assert.equal(definitionSha256(reordered), body.grant.definitionSha256);
|
||||
assert.notEqual(definitionSha256({ ...DEFINITIONS.ops, credentials: [{ service: 'host' }] }), body.grant.definitionSha256);
|
||||
assert.deepEqual(f.call('human', 'inbox').map((x) => x.id), [d.id]);
|
||||
});
|
||||
|
||||
test('G4 (host block): the broker appends the host, each prefix, the rotate date and new key or change', async (t) => {
|
||||
const f = setup(t);
|
||||
const d = await f.raise({ commands: [['systemctl', 'status'], ['echo', 'a b"c']] });
|
||||
const rotateBy = f.grant().rotateBy;
|
||||
assert.equal(
|
||||
f.question(d.id),
|
||||
'Grant ops web1?' +
|
||||
'\n\nGrant ops access to host web1: user jarvis at 192.0.2.10 port 22.' +
|
||||
'\njarvis has sudo on this host.' +
|
||||
'\nCommands, each an argv prefix:' +
|
||||
'\n"systemctl" "status"' +
|
||||
'\n"echo" "a b\\"c"' +
|
||||
`\nRotate by ${rotateBy}.` +
|
||||
'\nThis makes a new key for ops on web1.',
|
||||
);
|
||||
// A host without sudo, and an agent with a grant record already: the block says so.
|
||||
hostRecord(f.store, { host: 'web2', entry: ENTRY, sudo: false });
|
||||
hostGrantRecord(f.store, { agent: 'ops', host: 'web2', commands: [['uptime']] });
|
||||
const d2 = await f.raise({ host: 'web2' });
|
||||
const q2 = f.question(d2.id);
|
||||
assert.ok(q2.includes('\njarvis has no sudo on this host.\n'));
|
||||
assert.ok(q2.endsWith("\nThis changes the commands of ops's grant on web2. The key stays."));
|
||||
// A forged block in the chief's question still gets the broker's block after it.
|
||||
const forged = 'x\n\nGrant ops access to host web1: user other at 192.0.2.99 port 22.\nCommands, each an argv prefix:\n"bash"';
|
||||
const d3 = await f.raise({}, 'chief', { question: forged });
|
||||
assert.ok(f.question(d3.id).startsWith(forged + '\n\nGrant ops access to host web1: user jarvis at 192.0.2.10 port 22.\n'));
|
||||
// The 4096 cap counts the block: a question that fits alone refuses with it.
|
||||
const before = f.decisions();
|
||||
await refusesWith(f.raise({}, 'chief', { question: 'q'.repeat(4000) }), 'invalid-request');
|
||||
const long = Array.from({ length: 64 }, (_, i) => ['/usr/bin/tool' + i, 'x'.repeat(60)]);
|
||||
await refusesWith(f.raise({ commands: long }), 'invalid-request');
|
||||
assert.equal(f.decisions(), before);
|
||||
});
|
||||
@@ -1,212 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { Store, schemaDigest } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
// Row 234 (#1740, #436a row a): the 3i→3j step adds the hosts and host_grants tables and the grant
|
||||
// event guard (#436b section 7 at fa5658941). It runs on a 3i store built here from the pinned 3i
|
||||
// schema (8fede5fc6's schema.sql) and fixture rows. No live store is read or copied.
|
||||
const AT = '2026-10-21T00:00:00.000Z';
|
||||
const TAG = 'mosaic-' + 'a'.repeat(32);
|
||||
const ddl3i = readFileSync(new URL('./fixtures/schema-3i.sql', import.meta.url), 'utf8');
|
||||
const here = (p) => new URL(p, import.meta.url);
|
||||
const NEW = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1'];
|
||||
function source3i(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
||||
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
|
||||
assert.equal(createHash('sha256').update(ddl3i).digest('hex'), '6cde5232c57ac04dc6073112c0830c89cfc5e953d7a5727e7716263747fe8737');
|
||||
db.exec(ddl3i);
|
||||
const run = (sql, ...args) => db.prepare(sql).run(...args);
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3i');
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
|
||||
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
|
||||
run('INSERT INTO events(id,at,business,kind,actor_role,actor_run,body) VALUES(?,?,?,?,?,?,?)', 'e1', AT, 'demo', 'action.allowed', 'coder', 'run1', '{"action":"host.sudo"}');
|
||||
run('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)', 'e2', AT, 'demo', 'action.allowed', '{}');
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
return { root, path };
|
||||
}
|
||||
function rows(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants')").all()
|
||||
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
|
||||
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
|
||||
} finally { db.close(); }
|
||||
}
|
||||
function master(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map(r => ({ ...r }));
|
||||
} finally { db.close(); }
|
||||
}
|
||||
const version = (path) => {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try { return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value; } finally { db.close(); }
|
||||
};
|
||||
// A copy of the bus module whose store.mjs, schema.sql and 3i→3j step the caller edits.
|
||||
async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
const moduleRoot = join(root, name);
|
||||
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8'), edited = store ? store(source) : source;
|
||||
if (store) assert.notEqual(edited, source);
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), schema ?? readFileSync(here('../schema.sql'), 'utf8'));
|
||||
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql'])
|
||||
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
|
||||
writeFileSync(join(moduleRoot, 'src/migrations/3i-3j.sql'), step(readFileSync(here('../src/migrations/3i-3j.sql'), 'utf8')));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'grant-store-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
store.run(`INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking)
|
||||
VALUES('d1',?,'demo','chief','run1','gated','credential.mint','human','q','["yes","no"]','yes',0)`, AT);
|
||||
return store;
|
||||
}
|
||||
const event = (store, actor, body, kind = 'action.allowed') => store.run(
|
||||
'INSERT INTO events(id,at,business,kind,actor_role,actor_run,body) VALUES(?,?,?,?,?,?,?)',
|
||||
'e' + Math.random().toString(16).slice(2), AT, 'demo', kind, actor[0], actor[1], JSON.stringify(body));
|
||||
const host = (store, values = {}) => store.run(
|
||||
`INSERT INTO hosts(business,host,address,port,user,sudo,host_keys,installer_fingerprint,state,decision,checked_at)
|
||||
VALUES(:business,:host,:address,:port,:user,:sudo,:host_keys,:fp,:state,:decision,:checked)`,
|
||||
{ business: 'demo', host: 'web1', address: '192.0.2.10', port: 22, user: 'deploy', sudo: 0,
|
||||
host_keys: '["ssh-ed25519 AAAA"]', fp: null, state: 'pending-install', decision: 'd1', checked: null, ...values });
|
||||
|
||||
test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the two tables, their keys and the guard', t => {
|
||||
const { root, path } = source3i(t), before = rows(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3j');
|
||||
assert.deepEqual(rows(path), before);
|
||||
const now = master(path);
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name)), was);
|
||||
assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort());
|
||||
const store = new Store(root);
|
||||
try {
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM hosts').n, 0);
|
||||
// An event row 210 wrote before the step stays, and a session can still write host.sudo.
|
||||
event(store, ['coder', 'run2'], { action: 'host.sudo', phase: 'requested' });
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('a 3i→3j step that fails partway leaves the store at 3i with every row and the schema intact', async t => {
|
||||
const { root, path } = source3i(t), before = rows(path), was = master(path);
|
||||
// The real step plus a statement that fails only when events has a row, so the empty replay at
|
||||
// import passes and the store's run fails after both tables and the trigger exist.
|
||||
const Failing = await engine(root, 'engine-failing', { step: (sql) => sql + 'INSERT INTO events SELECT * FROM events LIMIT 1;\n' });
|
||||
assert.throws(() => new Failing(root, { migrateSchema: true }), /events is append-only/);
|
||||
assert.equal(version(path), '3i');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3j');
|
||||
});
|
||||
|
||||
test('the 3i engine refuses a 3j store, migrating or not, and leaves it at 3j', async t => {
|
||||
const { root, path } = source3i(t);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
const after = rows(path), was = master(path);
|
||||
const Store3i = await engine(root, 'engine-3i', {
|
||||
schema: ddl3i,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
|
||||
assert.throws(() => new Store3i(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3j');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
test('A22b, sage-1703: a session-written grant.* row from before the step survives it, and the grant readers ignore it', async t => {
|
||||
// The step adds the guard but leaves old rows. At 3i recordEvent had no reserved-event refusal, so a
|
||||
// session could write these, and after the step only GRANT_EVIDENCE's actor filter keeps them out.
|
||||
const { root, path } = source3i(t);
|
||||
const db = new DatabaseSync(path);
|
||||
const run = (sql, ...args) => db.prepare(sql).run(...args);
|
||||
run(`INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking)
|
||||
VALUES('d1',?,'demo','chief','run1','gated','credential.mint','human','q','["yes","no"]','yes',0)`, AT);
|
||||
const legacy = [{ action: 'grant.host.removed', host: 'web1', hostLineLeft: TAG }, { operation: 'grant.host-enroll', decision: 'd1' }];
|
||||
for (const [i, body] of legacy.entries())
|
||||
run('INSERT INTO events(id,at,business,kind,actor_role,actor_run,body) VALUES(?,?,?,?,?,?,?)', 'old' + i, AT, 'demo', 'action.allowed', 'chief', 'run1', JSON.stringify(body));
|
||||
db.close();
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
const store = new Store(root);
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE id IN ('old0','old1')").n, 2);
|
||||
host(store);
|
||||
const business = {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
vars: {},
|
||||
};
|
||||
const unenrolled = [];
|
||||
const broker = new Broker({ store, businesses: { demo: business }, hostGrant: { unenroll: (b, h) => unenrolled.push([b, h]), installerTag: () => null } });
|
||||
// The enrollment look-alike is not a spent decision, so nothing ends interrupted.
|
||||
assert.deepEqual(broker.reconcileGrants(), []);
|
||||
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE actor_run='grant'").n, 0);
|
||||
// The hostLineLeft look-alike is not a line left on the host.
|
||||
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
assert.deepEqual(await broker.request(human, { verb: 'hosts.remove', args: { host: 'web1' } }), { host: 'web1', removed: true });
|
||||
assert.deepEqual(unenrolled, [['demo', 'web1']]);
|
||||
});
|
||||
|
||||
test('the guard: a grant.* action or operation on action.allowed needs the (NULL, grant) actor', t => {
|
||||
const store = fresh(t);
|
||||
const refused = /only the grant writer records grant events/;
|
||||
for (const body of [{ action: 'grant.step' }, { action: 'grant.host.enrolled' }, { operation: 'grant.host-enroll', decision: 'd1' }]) {
|
||||
assert.throws(() => event(store, ['chief', 'run1'], body), refused, JSON.stringify(body));
|
||||
assert.throws(() => event(store, ['tester', 'run1'], body), refused);
|
||||
assert.throws(() => event(store, [null, null], body), refused, 'the human and session-less pair is not the grant actor');
|
||||
assert.throws(() => event(store, [null, 'run1'], body), refused);
|
||||
assert.throws(() => event(store, ['grant', 'grant'], body), refused);
|
||||
event(store, [null, 'grant'], body);
|
||||
}
|
||||
// Row 210's host.sudo and host.run stay outside the namespace, and so does any other kind.
|
||||
event(store, ['coder', 'run1'], { action: 'host.sudo', phase: 'completed' });
|
||||
event(store, ['coder', 'run1'], { action: 'grant.step' }, 'action.refused');
|
||||
event(store, ['coder', 'run1'], { action: 'granted.step', operation: 'grantx' });
|
||||
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE actor_run='grant'").n, 3);
|
||||
});
|
||||
|
||||
test('hosts: one record per business and host, a ready host has its installer and check, grants need the host', t => {
|
||||
const store = fresh(t);
|
||||
host(store);
|
||||
assert.throws(() => host(store, { address: '192.0.2.11' }), /UNIQUE constraint failed: hosts\.business, hosts\.host/);
|
||||
host(store, { business: 'other' });
|
||||
assert.throws(() => host(store, { host: 'web2', state: 'ready' }), /CHECK constraint failed/);
|
||||
assert.throws(() => host(store, { host: 'web2', state: 'ready', fp: 'SHA256:x' }), /CHECK constraint failed/);
|
||||
host(store, { host: 'web2', state: 'ready', fp: 'SHA256:x', checked: AT });
|
||||
assert.throws(() => host(store, { host: 'web3', state: 'live' }), /CHECK constraint failed/);
|
||||
assert.throws(() => host(store, { host: 'web3', host_keys: '[]' }), /CHECK constraint failed/);
|
||||
assert.throws(() => host(store, { host: 'web3', decision: 'nope' }), /FOREIGN KEY constraint failed/);
|
||||
const grant = (values = {}) => store.run(
|
||||
'INSERT INTO host_grants(business,agent,host,commands,rotate_by,tag,decision) VALUES(:business,:agent,:host,:commands,:rotate,:tag,:decision)',
|
||||
{ business: 'demo', agent: 'coder', host: 'web2', commands: '[["true"]]', rotate: '2026-12-01', tag: TAG, decision: 'd1', ...values });
|
||||
assert.throws(() => grant({ host: 'web9' }), /FOREIGN KEY constraint failed/);
|
||||
for (const tag of ['mosaic-' + 'A'.repeat(32), 'mosaic-' + 'a'.repeat(31), 'mosaix-' + 'a'.repeat(32), 'mosaic-' + 'a'.repeat(31) + 'g'])
|
||||
assert.throws(() => grant({ tag }), /CHECK constraint failed/, tag);
|
||||
assert.throws(() => grant({ commands: '[]' }), /CHECK constraint failed/);
|
||||
grant();
|
||||
assert.throws(() => grant({ tag: 'mosaic-' + 'b'.repeat(32) }), /UNIQUE constraint failed/);
|
||||
assert.throws(() => store.run("DELETE FROM hosts WHERE business='demo' AND host='web2'"), /FOREIGN KEY constraint failed/);
|
||||
});
|
||||
@@ -7,11 +7,10 @@ import { Store } from '../src/store.mjs';
|
||||
import { Broker, ACTIONS, HARD_WAITS } from '../src/broker.mjs';
|
||||
import { ACTIONS as VOCABULARY, GATED_ONLY } from '../../business/src/vocabulary.mjs';
|
||||
import { fixture } from './spec-fixtures.mjs';
|
||||
import { hostGrantRecord, hostRecord } from './host-fixtures.mjs';
|
||||
|
||||
// PRD 1.5 REQ-WAIT-1, REQ-WAIT-2 and REQ-DEC-2 (row 139, #1633).
|
||||
const WAITS = ['spend', 'data.delete', 'network.expose', 'account.create', 'credential.mint', 'message.external',
|
||||
'git.push.protected', 'git.push.remote', 'host.sudo', 'deploy'];
|
||||
'git.push.protected', 'git.push.remote', 'deploy'];
|
||||
const LEFT = ['git.merge.protected', 'policy.change', 'prd.approve', 'role.revoke'];
|
||||
const cite = f => ({ project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
|
||||
const routed = (f, d) => ({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) });
|
||||
@@ -39,17 +38,12 @@ test('REQ-WAIT-2: a business granting a hard wait at either class is refused; th
|
||||
test('REQ-DEC-2: every hard wait goes to the human, cited or not, and the arbiter cannot resolve it', async t => {
|
||||
const f = await fixture(t, { roles: { coder: { definition: 'coder',
|
||||
authority: { withinRole: ['message.send', 'record.write', 'git.push.working'], crossRole: [] } } } });
|
||||
// Row 210: a host.sudo raise names a registered host the raiser is granted and an argv its grant allows.
|
||||
// Row 234 (#1740): both are store records.
|
||||
hostRecord(f.store, { host: 'web1', entry: { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] } });
|
||||
hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands: [['systemctl', 'status']] });
|
||||
// Row 199: a git.push.remote raise binds the task's latest push, so the task has one.
|
||||
f.broker.recordPush(f.coder, { task_ref: 'vikunja:1/2', project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2',
|
||||
commit: 'a'.repeat(40), previous: null });
|
||||
for (const action of WAITS) {
|
||||
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }]) {
|
||||
const d = await f.raise({ action, ...(action === 'git.push.remote' ? { target: 'vikunja:1/2' } : {}),
|
||||
...(action === 'host.sudo' ? { target: 'web1', argv: ['systemctl', 'status'] } : {}), ...args });
|
||||
const d = await f.raise({ action, ...(action === 'git.push.remote' ? { target: 'vikunja:1/2' } : {}), ...args });
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, `${action} ${JSON.stringify(args)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// Row 234 (#1740, r6 X4): host and grant store records for tests. Row a has no way to make a host_grants
|
||||
// record outside a fixture, because row b's grant executor writes them. The decision, its raise event and
|
||||
// its close go in by SQL. The grant evidence goes through writeGrantEvent (F1), so the trigger sees the
|
||||
// real shape.
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { registrySha256, writeGrantEvent } from '../src/broker.mjs';
|
||||
import { grantTag } from '../src/host-grant.mjs';
|
||||
|
||||
const AT = '2026-10-21T00:00:00.000Z';
|
||||
const OPTIONS = JSON.stringify([{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }]);
|
||||
export const HUMAN_YES = { op: 'resolved', by: 'jason', choice: 'yes', via: 'cli' };
|
||||
|
||||
// A credential.mint decision with `grant` in its raise body. `close` is the decision_events row, or null
|
||||
// for an open decision.
|
||||
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', grant, close = HUMAN_YES }) {
|
||||
const id = randomUUID();
|
||||
store.run(
|
||||
'INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
id, AT, business, 'chief', 'chief-run', 'gated', action, route, 'Grant?', OPTIONS, 'yes', 0,
|
||||
);
|
||||
store.run(
|
||||
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',?,?,?,?)",
|
||||
randomUUID(), AT, business, 'chief', 'chief-run', id,
|
||||
JSON.stringify({ decision: id, action, operation: 'decision.raise', approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
|
||||
);
|
||||
if (close)
|
||||
store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', id, AT, close.op, close.by, close.choice ?? null, close.via ?? null);
|
||||
return id;
|
||||
}
|
||||
|
||||
// A ready host record from an approved, spent and finished enrollment. grant.host.enrolled keeps the
|
||||
// start reconcile from reading the enrollment as interrupted.
|
||||
export function hostRecord(store, { business = 'demo', host = 'web1', entry, sudo = true }) {
|
||||
const grant = { method: 'host-enroll', host, address: entry.address, port: entry.port, user: entry.user, sudo, fingerprints: [] };
|
||||
const decision = grantDecision(store, { business, grant });
|
||||
writeGrantEvent(store, AT, business, { operation: 'grant.host-enroll', decision }, decision);
|
||||
const installerFingerprint = 'SHA256:' + 'A'.repeat(43);
|
||||
writeGrantEvent(store, AT, business, {
|
||||
action: 'grant.host.enrolled', grantDecision: decision, host, installerFingerprint, user: entry.user, sudo,
|
||||
registrySha256: registrySha256({ ...entry, sudo }),
|
||||
}, decision);
|
||||
store.run(
|
||||
'INSERT INTO hosts(business,host,address,port,user,sudo,host_keys,installer_fingerprint,state,decision,checked_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
business, host, entry.address, entry.port, entry.user, sudo ? 1 : 0, JSON.stringify(entry.hostKeys), installerFingerprint, 'ready', decision, AT,
|
||||
);
|
||||
return decision;
|
||||
}
|
||||
|
||||
// One agent's grant record on a host. By default its decision is a `host` grant for the same agent and
|
||||
// host, resolved yes by the human via cli and spent as grant.host. Each option breaks one part.
|
||||
export function hostGrantRecord(store, { business = 'demo', agent, host = 'web1', commands, rotateBy = '2099-01-01', grant = {}, close = HUMAN_YES, spend = 'grant.host', action }) {
|
||||
const decision = grantDecision(store, {
|
||||
business, close, action,
|
||||
grant: grant === null ? undefined : { method: 'host', agent, host, commands, rotateBy, ...grant },
|
||||
});
|
||||
if (spend) writeGrantEvent(store, AT, business, { operation: spend, decision }, decision);
|
||||
// Row 235: the executor commits each record with its grant.applied (r6 X2 step 5), so the start
|
||||
// reconcile reads a fixture record as a finished grant.
|
||||
if (spend === 'grant.host')
|
||||
writeGrantEvent(store, AT, business, { action: 'grant.applied', grantDecision: decision, agent, host, tag: grantTag(business, agent, host) }, host);
|
||||
store.run(
|
||||
'INSERT INTO host_grants(business,agent,host,commands,rotate_by,tag,decision) VALUES(?,?,?,?,?,?,?)',
|
||||
business, agent, host, JSON.stringify(commands), rotateBy, grantTag(business, agent, host), decision,
|
||||
);
|
||||
return decision;
|
||||
}
|
||||
@@ -1,642 +0,0 @@
|
||||
// Row 210 (#1707, design section 8): host.run over a scratch sshd on 127.0.0.1 that this test starts,
|
||||
// with keys this test makes and a fake sudo that records its argv and runs the command as the test
|
||||
// user. Nothing runs as root and nothing reaches another host.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { connect, createServer } from 'node:net';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir, userInfo } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Credentials, parseHostKey } from '../src/credentials.mjs';
|
||||
import { addKey, hostExecutor, runHost, sshArgs, startTime, sweepRunDir, quote, remoteCommand } from '../src/host-run.mjs';
|
||||
import { fixture } from './spec-fixtures.mjs';
|
||||
import { HUMAN_YES, hostGrantRecord, hostRecord } from './host-fixtures.mjs';
|
||||
import { Broker, ORGANIZATION_KEYS } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { hostGrant } from '../src/host-grant.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||
|
||||
const SSHD = '/usr/bin/sshd';
|
||||
const REPO = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
const USER = userInfo().username;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const keygen = (file, ...extra) => execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'test', ...extra, '-f', file]);
|
||||
const pubLine = (file) => readFileSync(file + '.pub', 'utf8').trim().split(' ').slice(0, 2).join(' ');
|
||||
// Every pid whose argv names `text`, with its argv and environment. ssh-agent makes itself
|
||||
// non-dumpable, so its environ reads as null. Its environment is the PATH runHost passes.
|
||||
const readOrNull = (file) => {
|
||||
try {
|
||||
return readFileSync(file, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
function procsNaming(text) {
|
||||
const out = [];
|
||||
for (const pid of readdirSync('/proc').filter((p) => /^\d+$/.test(p))) {
|
||||
const cmdline = readOrNull(`/proc/${pid}/cmdline`);
|
||||
if (cmdline?.includes(text)) out.push({ pid: Number(pid), cmdline, environ: readOrNull(`/proc/${pid}/environ`) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
async function until(fn, ms = 5000) {
|
||||
const end = Date.now() + ms;
|
||||
while (!fn()) {
|
||||
if (Date.now() > end) return false;
|
||||
await sleep(20);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const listening = (port) =>
|
||||
new Promise((resolve) => {
|
||||
const s = connect(port, '127.0.0.1');
|
||||
s.on('connect', () => (s.destroy(), resolve(true)));
|
||||
s.on('error', () => resolve(false));
|
||||
});
|
||||
|
||||
// A scratch sshd. ForceCommand puts the fake sudo first on PATH and hands the command to /bin/sh, so
|
||||
// the remote side parses the command line the way a real host's POSIX shell does.
|
||||
async function sshd(t, dir) {
|
||||
if (!existsSync(SSHD)) throw new Error(`${SSHD} is missing: the executor tests need it and fail, never skip`);
|
||||
keygen(join(dir, 'hostkey'));
|
||||
keygen(join(dir, 'otherhost'));
|
||||
keygen(join(dir, 'user'));
|
||||
writeFileSync(join(dir, 'authorized_keys'), readFileSync(join(dir, 'user.pub')), { mode: 0o600 });
|
||||
mkdirSync(join(dir, 'fake'));
|
||||
writeFileSync(
|
||||
join(dir, 'fake', 'sudo'),
|
||||
`#!/bin/sh\n'${process.execPath}' -e 'require("fs").appendFileSync(process.argv[1], JSON.stringify(process.argv.slice(2)) + "\\n")' '${dir}/argv.json' "$@"\nshift 2\nexec "$@"\n`,
|
||||
);
|
||||
chmodSync(join(dir, 'fake', 'sudo'), 0o755);
|
||||
// A remote command that prints ssh's pin refusal text and exits 255 (N3).
|
||||
writeFileSync(join(dir, 'fake', 'fail255'), '#!/bin/sh\necho "Host key verification failed." >&2\nexit 255\n', { mode: 0o755 });
|
||||
writeFileSync(join(dir, 'wrap.sh'), `#!/bin/sh\nPATH=${dir}/fake:/usr/bin:/bin; export PATH\nexec /bin/sh -c "$SSH_ORIGINAL_COMMAND"\n`);
|
||||
chmodSync(join(dir, 'wrap.sh'), 0o755);
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const port = 20000 + Math.floor(Math.random() * 20000);
|
||||
writeFileSync(
|
||||
join(dir, 'sshd_config'),
|
||||
[`Port ${port}`, 'ListenAddress 127.0.0.1', `HostKey ${dir}/hostkey`, `AuthorizedKeysFile ${dir}/authorized_keys`,
|
||||
`PidFile ${dir}/sshd.pid`, 'UsePAM no', 'StrictModes no', 'PasswordAuthentication no', 'KbdInteractiveAuthentication no',
|
||||
`ForceCommand ${dir}/wrap.sh`, ''].join('\n'),
|
||||
);
|
||||
const daemon = spawn(SSHD, ['-D', '-f', join(dir, 'sshd_config'), '-E', join(dir, 'sshd.log')], { stdio: 'ignore' });
|
||||
t.after(() => daemon.kill('SIGKILL'));
|
||||
const deadline = Date.now() + 3000;
|
||||
while (Date.now() < deadline && daemon.exitCode === null) {
|
||||
if (await listening(port)) return port;
|
||||
await sleep(30);
|
||||
}
|
||||
daemon.kill('SIGKILL');
|
||||
}
|
||||
throw new Error('scratch sshd did not start');
|
||||
}
|
||||
|
||||
const GRANT = [['true'], ['false'], ['echo'], ['sleep'], ['head'], ['cat'], ['systemctl', 'status']];
|
||||
async function hostFixture(t, { commands = GRANT, reviewerHosts, wrongHostKey = false, coderWeb2 = false } = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'h210-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const port = await sshd(t, dir);
|
||||
const keyFile = join(dir, 'user');
|
||||
const credentials = new Credentials({
|
||||
references: { 'demo/coder': { 'host.web1': { file: keyFile, rotateBy: '2099-01-01' } } },
|
||||
dataRoot: join(dir, 'data'),
|
||||
});
|
||||
t.after(() => credentials.close());
|
||||
const root = join(dir, 'run');
|
||||
const executor = hostExecutor({ credentials, root });
|
||||
const entry = { address: '127.0.0.1', port, user: USER, hostKeys: [pubLine(join(dir, wrongHostKey ? 'otherhost' : 'hostkey'))] };
|
||||
const authority = { withinRole: ['message.send', 'record.write'], crossRole: [] };
|
||||
const f = await fixture(t, { roles: { coder: { definition: 'coder', authority } } });
|
||||
// Row 234 (#1740, r6 X4): host.run reads store records, so the hosts and grants are fixtures there.
|
||||
for (const host of ['web1', 'web2']) hostRecord(f.store, { host, entry });
|
||||
hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands });
|
||||
if (coderWeb2) hostGrantRecord(f.store, { agent: 'coder', host: 'web2', commands });
|
||||
for (const [host, g] of Object.entries(reviewerHosts ?? {})) hostGrantRecord(f.store, { agent: 'reviewer', host, commands: g.commands });
|
||||
const approve = async (argv, stdin, host = 'web1') => {
|
||||
const d = await f.raise({ action: 'host.sudo', target: host, argv, ...(stdin !== undefined ? { stdin } : {}) });
|
||||
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
return d.id;
|
||||
};
|
||||
const run = (args, cap = f.coder) => f.broker.requestHost(cap, { verb: 'host.run', args }, executor);
|
||||
const call = async (argv, { stdin, timeout } = {}) =>
|
||||
run({ host: 'web1', argv, ...(stdin !== undefined ? { stdin } : {}), ...(timeout ? { timeout } : {}), decision: await approve(argv, stdin) });
|
||||
const events = () =>
|
||||
f.store.all("SELECT kind, body FROM events WHERE business='demo' ORDER BY seq").map((e) => ({ kind: e.kind, ...JSON.parse(e.body) }));
|
||||
const argvSeen = () => (existsSync(join(dir, 'argv.json')) ? readFileSync(join(dir, 'argv.json'), 'utf8').trim().split('\n').map((l) => JSON.parse(l)) : []);
|
||||
const key = parseHostKey(readFileSync(keyFile, 'utf8'));
|
||||
return { ...f, dir, root, port, entry, keyFile, key, credentials, executor, approve, run, call, events, argvSeen };
|
||||
}
|
||||
const refusesWith = async (p, code) => assert.rejects(p, (e) => (assert.equal(e.code, code), true));
|
||||
|
||||
test('the executor tests need sshd, ssh, ssh-agent, ssh-add, ssh-keygen and setpriv: a missing one fails, never skips', () => {
|
||||
for (const bin of [SSHD, '/usr/bin/ssh', '/usr/bin/ssh-agent', '/usr/bin/ssh-add', '/usr/bin/ssh-keygen', '/usr/bin/setpriv'])
|
||||
assert.ok(existsSync(bin), `${bin} is missing`);
|
||||
});
|
||||
|
||||
test('T3: organization.set, limits.set and instances.set refuse hosts, and the writer never runs', (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'h210-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const chiefAuthority = JSON.parse(readFileSync(join(REPO, 'roles', 'chief-of-staff.json'), 'utf8')).authority;
|
||||
const businesses = {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, definitions: {},
|
||||
roles: { chief: { definition: 'chief-of-staff', authority: chiefAuthority } }, projects: {},
|
||||
},
|
||||
};
|
||||
const calls = [];
|
||||
const b = new Broker({ store, businesses, organization: (...a) => (calls.push(a), { ok: true }) });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
b.request(chief, { verb: 'role.claim' });
|
||||
const revision = 'a'.repeat(64);
|
||||
const hosts = { web1: { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] } };
|
||||
assert.equal(ORGANIZATION_KEYS.includes('hosts'), false);
|
||||
assert.throws(() => b.request(chief, { verb: 'organization.set', args: { revision, hosts } }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'limits.set', args: { revision, hosts } }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'instances.set', args: { revision, instance: 'chief', op: 'change', hosts } }), /invalid-request/);
|
||||
assert.equal(calls.length, 0);
|
||||
// Controls: the same calls without hosts reach the writer.
|
||||
b.request(chief, { verb: 'organization.set', args: { revision, models: {} } });
|
||||
b.request(human, { verb: 'limits.set', args: { revision, models: {} } });
|
||||
b.request(human, { verb: 'instances.set', args: { revision, instance: 'chief', op: 'change', launch: true } });
|
||||
// organization.set reaches the writer as limits.set (broker.mjs:1379).
|
||||
assert.deepEqual(calls.map((c) => c[1]), ['limits.set', 'limits.set', 'instances.set']);
|
||||
});
|
||||
|
||||
test('T4: the value check takes one unencrypted ed25519 key and nothing else', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'h210-'));
|
||||
try {
|
||||
keygen(join(dir, 'good'));
|
||||
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', 'secret-pass', '-f', join(dir, 'enc')]);
|
||||
execFileSync('ssh-keygen', ['-q', '-t', 'rsa', '-b', '2048', '-N', '', '-f', join(dir, 'rsa')]);
|
||||
const good = readFileSync(join(dir, 'good'), 'utf8');
|
||||
// Two keys: the same blob with nkeys set to 2.
|
||||
const lines = good.trim().split('\n');
|
||||
const blob = Buffer.from(lines.slice(1, -1).join(''), 'base64');
|
||||
// magic (15), cipher "none" (4+4), kdf "none" (4+4), empty kdfoptions (4), then nkeys.
|
||||
blob.writeUInt32BE(2, 15 + 8 + 8 + 4);
|
||||
const pem = (b) => [lines[0], ...b.toString('base64').match(/.{1,70}/g), lines.at(-1)].join('\n') + '\n';
|
||||
// One check each: a field renamed in place, so every other check still passes. Offsets: magic 15,
|
||||
// cipher name 19, kdf name 27, nkeys 35, public type 47, private section 98, its type 110.
|
||||
const renamed = (at, text) => {
|
||||
const copy = Buffer.from(lines.slice(1, -1).join(''), 'base64');
|
||||
copy.write(text, at, 'latin1');
|
||||
return pem(copy);
|
||||
};
|
||||
const write = (name, text) => (writeFileSync(join(dir, name), text, { mode: 0o600 }), join(dir, name));
|
||||
const load = (file, service = 'host.web1') =>
|
||||
new Credentials({ references: { 'demo/coder': { [service]: { file, rotateBy: '2099-01-01' } } }, dataRoot: join(dir, 'data') });
|
||||
const ok = load(join(dir, 'good'));
|
||||
assert.match(ok.status()[0].fingerprint, /^SHA256:/);
|
||||
assert.equal(ok.status()[0].publicKey, pubLine(join(dir, 'good')));
|
||||
ok.close();
|
||||
for (const file of [
|
||||
join(dir, 'enc'),
|
||||
join(dir, 'rsa'),
|
||||
write('two', pem(blob)),
|
||||
write('cipher', renamed(19, 'aes1')),
|
||||
write('kdf', renamed(27, 'bcry')),
|
||||
write('public-type', renamed(47, 'ssh-ed25518')),
|
||||
write('private-type', renamed(110, 'ssh-ed25518')),
|
||||
write('crlf', good.replaceAll('\n', '\r\n')),
|
||||
write('token', 'fake_gitea_token_0123456789\n'),
|
||||
])
|
||||
assert.throws(() => load(file), /credential-format/, file);
|
||||
assert.throws(() => load(join(dir, 'good'), 'gitea'), /credential-format/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('T5 to T8: registry, grant, argv shape and allowlist refusals', async (t) => {
|
||||
const f = await hostFixture(t, { reviewerHosts: { web2: { commands: [['true']] } } });
|
||||
await refusesWith(f.run({ host: 'web9', argv: ['true'] }), 'host-unknown');
|
||||
await refusesWith(f.run({ host: 'web2', argv: ['true'] }), 'host-not-granted');
|
||||
for (const argv of [[], ['a\0'], ['a'.repeat(4097)], Array(257).fill('true'), ['bin/true'], [7], 'true'])
|
||||
await refusesWith(f.run({ host: 'web1', argv }), 'invalid-request');
|
||||
await refusesWith(f.run({ host: 'web1', argv: ['true'], stdin: 'x'.repeat(65537) }), 'invalid-request');
|
||||
await refusesWith(f.run({ host: 'web1', argv: ['true'], timeout: 901 }), 'invalid-request');
|
||||
await refusesWith(f.run({ host: 'web1', argv: ['systemctl'] }), 'command-not-granted');
|
||||
await refusesWith(f.run({ host: 'web1', argv: ['systemctl', 'restart', 'nginx'] }), 'command-not-granted');
|
||||
await refusesWith(f.run({ host: 'web1', argv: ['ls'] }), 'command-not-granted');
|
||||
// N1: the raise refuses the same way, so the human is never asked about a call host.run refuses.
|
||||
assert.throws(() => f.raise({ action: 'host.sudo', target: 'web2', argv: ['true'] }), /host-not-granted/);
|
||||
assert.throws(() => f.raise({ action: 'host.sudo', target: 'web1', argv: ['ls'] }), /command-not-granted/);
|
||||
assert.equal(f.store.get("SELECT count(*) AS n FROM decisions WHERE action='host.sudo'").n, 0);
|
||||
// Granted prefixes run, a longer argv too.
|
||||
await f.call(['systemctl', 'status']);
|
||||
assert.equal((await f.call(['echo', 'hi', 'there'])).stdout, 'hi there\n');
|
||||
assert.deepEqual(f.argvSeen(), [['-n', '--', 'systemctl', 'status'], ['-n', '--', 'echo', 'hi', 'there']]);
|
||||
const refused = f.events().filter((e) => e.kind === 'action.refused' && e.verb === 'host.run').map((e) => e.code);
|
||||
assert.ok(refused.includes('host-unknown') && refused.includes('command-not-granted'));
|
||||
});
|
||||
|
||||
test('T9: under 1.7 every call needs a decision bound to its host, argv and stdin, spent once', async (t) => {
|
||||
const f = await hostFixture(t, { coderWeb2: true });
|
||||
await refusesWith(f.run({ host: 'web1', argv: ['true'] }), 'decision-required');
|
||||
const other = await f.approve(['echo', 'a']);
|
||||
await refusesWith(f.run({ host: 'web1', argv: ['echo', 'b'], decision: other }), 'decision-mismatch');
|
||||
const stdin = await f.approve(['cat'], 'one');
|
||||
await refusesWith(f.run({ host: 'web1', argv: ['cat'], stdin: 'two', decision: stdin }), 'decision-mismatch');
|
||||
await refusesWith(f.run({ host: 'web1', argv: ['cat'], decision: stdin }), 'decision-mismatch');
|
||||
const host = await f.approve(['true'], undefined, 'web2');
|
||||
await refusesWith(f.run({ host: 'web1', argv: ['true'], decision: host }), 'decision-mismatch');
|
||||
const once = await f.approve(['cat'], 'one');
|
||||
assert.equal((await f.run({ host: 'web1', argv: ['cat'], stdin: 'one', decision: once })).stdout, 'one');
|
||||
await refusesWith(f.run({ host: 'web1', argv: ['cat'], stdin: 'one', decision: once }), 'decision-consumed');
|
||||
// An unresolved decision doesn't run.
|
||||
const open = (await f.raise({ action: 'host.sudo', target: 'web1', argv: ['true'] })).id;
|
||||
await refusesWith(f.run({ host: 'web1', argv: ['true'], decision: open }), 'decision-not-approved');
|
||||
// The human sees host, user, each argv element and all of stdin, appended by the broker.
|
||||
const q = f.store.get('SELECT question FROM decisions WHERE id=?', once).question;
|
||||
assert.ok(q.includes(`Host web1, ${USER} at 127.0.0.1 port ${f.port}`), q);
|
||||
assert.ok(q.includes('argv: "cat"'), q);
|
||||
assert.ok(q.endsWith('stdin: "one"'), q);
|
||||
assert.throws(() => f.raise({ action: 'host.sudo', target: 'web1', argv: ['cat'], stdin: 'x'.repeat(4096) }), /invalid-request/);
|
||||
assert.throws(() => f.raise({ action: 'host.sudo', target: 'web9', argv: ['cat'] }), /host-unknown/);
|
||||
assert.throws(() => f.raise({ action: 'deploy', argv: ['cat'] }), /invalid-request/);
|
||||
assert.equal(f.store.get("SELECT class FROM decisions WHERE id=?", once).class, 'gated');
|
||||
});
|
||||
|
||||
test('T10, T11: argv reaches sudo unchanged after sudo -n --', async (t) => {
|
||||
const f = await hostFixture(t);
|
||||
const argv = ['true', 'a b', "it's", '"q"', '$(touch x)', '`id`', 'a;b', 'line\nbreak', '*', '?[a]', '$HOME', '\\'];
|
||||
assert.equal((await f.call(argv)).exit, 0);
|
||||
assert.deepEqual(f.argvSeen(), [['-n', '--', ...argv]]);
|
||||
assert.equal(existsSync(join(f.dir, 'x')), false);
|
||||
assert.equal(quote("it's"), "'it'\\''s'");
|
||||
assert.equal(remoteCommand(['a b']), "sudo -n -- 'a b'");
|
||||
});
|
||||
|
||||
test('T12, T13: exit code passes through, the timeout kills the group, output is capped', async (t) => {
|
||||
const f = await hostFixture(t);
|
||||
assert.equal((await f.call(['false'])).exit, 1);
|
||||
const began = Date.now();
|
||||
const slow = await f.call(['sleep', '30'], { timeout: 1 });
|
||||
// D6: the timeout answers signal. ssh exits 255 on SIGTERM, which the result can't tell from a remote 255.
|
||||
assert.equal(slow.signal, 'SIGTERM');
|
||||
assert.ok(Date.now() - began < 10000);
|
||||
const big = await f.call(['head', '-c', '2097152', '/dev/zero']);
|
||||
assert.equal(Buffer.byteLength(big.stdout), 1024 * 1024);
|
||||
assert.deepEqual(big.truncated, { stdout: true, stderr: false });
|
||||
});
|
||||
|
||||
test('T14: a host key not in the registry refuses and nothing runs', async (t) => {
|
||||
const f = await hostFixture(t, { wrongHostKey: true });
|
||||
await refusesWith(f.call(['true']), 'host-key-mismatch');
|
||||
assert.deepEqual(f.argvSeen(), []);
|
||||
});
|
||||
|
||||
// Row 234 (#1740, r6 G18, duckworth-0081 B2): hosts.check's `list` goes through runHost with the record's
|
||||
// pinned keys. A stub `list` on the scratch sshd's PATH records each run.
|
||||
test('G18: hosts.check with a wrong pin refuses host-key-mismatch and runs nothing on the host', async (t) => {
|
||||
const f = await hostFixture(t);
|
||||
writeFileSync(join(f.dir, 'fake', 'list'), `#!/bin/sh\necho "$@" >> '${f.dir}/list.log'\n`, { mode: 0o755 });
|
||||
const configDir = join(f.dir, 'cfg');
|
||||
const keyDir = join(configDir, 'secrets', 'hosts', 'demo', 'web1');
|
||||
mkdirSync(keyDir, { recursive: true, mode: 0o700 });
|
||||
for (const d of [join(configDir, 'secrets'), join(configDir, 'secrets', 'hosts'), join(configDir, 'secrets', 'hosts', 'demo'), keyDir]) chmodSync(d, 0o700);
|
||||
writeFileSync(join(keyDir, 'installer'), readFileSync(f.keyFile), { mode: 0o600 });
|
||||
const hg = hostGrant({ configDir, runRoot: f.root });
|
||||
const record = { address: '127.0.0.1', port: f.port, user: 'jarvis', hostKeys: [pubLine(join(f.dir, 'otherhost'))] };
|
||||
await refusesWith(hg.check('demo', 'web1', record), 'host-key-mismatch');
|
||||
assert.equal(existsSync(join(f.dir, 'list.log')), false);
|
||||
// Control: with the right pin the same command reaches the stub.
|
||||
const out = await runHost({ root: f.root, hostId: 'web1', entry: f.entry, key: f.key, timeout: 10, command: 'list jarvis' });
|
||||
assert.equal(out.exit, 0);
|
||||
assert.equal(readFileSync(join(f.dir, 'list.log'), 'utf8'), 'jarvis\n');
|
||||
});
|
||||
|
||||
test('N3: a remote exit 255 that prints the pin refusal text is the command result, and an ssh failure keeps its message', async (t) => {
|
||||
const f = await hostFixture(t, { commands: [['fail255']] });
|
||||
const out = await f.call(['fail255']);
|
||||
assert.equal(out.exit, 255);
|
||||
assert.match(out.stderr, /Host key verification failed/);
|
||||
assert.deepEqual(f.argvSeen(), [['-n', '--', 'fail255']]);
|
||||
// A closed port: ssh's message reaches the agent from the run dir's log.
|
||||
const closed = await runHost({ root: f.root, hostId: 'web1', entry: { ...f.entry, port: 1 }, key: f.key, argv: ['true'], stdin: '', timeout: 10 });
|
||||
assert.equal(closed.exit, 255);
|
||||
assert.match(closed.stderr, /port 1: Connection refused/);
|
||||
});
|
||||
|
||||
test('T15: ssh reads no config file and no user known_hosts', async (t) => {
|
||||
// ssh finds ~/.ssh from the passwd entry, not $HOME, and the test may not write the real home, so
|
||||
// the options are checked here and the call runs with an alias no user file holds (BUILD.md).
|
||||
const args = sshArgs({ hostId: 'web1', address: '10.0.0.5', port: 22, user: 'admin', dir: '/r' });
|
||||
assert.deepEqual(args.slice(0, 2), ['-F', '/dev/null']);
|
||||
assert.deepEqual(args.slice(3, 5), ['-E', '/r/ssh.log']);
|
||||
for (const o of ['StrictHostKeyChecking=yes', 'UserKnownHostsFile=/r/known_hosts', 'GlobalKnownHostsFile=/dev/null',
|
||||
'HostKeyAlias=mosaic-host-web1', 'BatchMode=yes', 'IdentitiesOnly=yes', 'ForwardAgent=no', 'ProxyCommand=none', 'ControlMaster=no'])
|
||||
assert.ok(args.includes(o), o);
|
||||
assert.deepEqual(args.slice(-4), ['-l', 'admin', '--', '10.0.0.5']);
|
||||
const f = await hostFixture(t);
|
||||
assert.equal((await f.call(['true'])).exit, 0);
|
||||
});
|
||||
|
||||
test('T16, T17: key material in output or stdin refuses credential-leak, and no event holds it', async (t) => {
|
||||
const f = await hostFixture(t, { commands: [['cat']] });
|
||||
const line = f.key.text.split('\n')[2];
|
||||
writeFileSync(join(f.dir, 'one-line'), line + '\n');
|
||||
writeFileSync(join(f.dir, 'seed-hex'), f.key.seed.toString('hex') + '\n');
|
||||
for (const file of [f.keyFile, join(f.dir, 'one-line'), join(f.dir, 'seed-hex')]) await refusesWith(f.call(['cat', file]), 'credential-leak');
|
||||
const ran = f.argvSeen().length;
|
||||
await refusesWith(f.call(['cat'], { stdin: 'x' + line }), 'credential-leak');
|
||||
assert.equal(f.argvSeen().length, ran, 'nothing ran for a stdin leak');
|
||||
const text = JSON.stringify(f.events());
|
||||
for (const needle of [line, f.key.seed.toString('hex')]) assert.equal(text.includes(needle), false);
|
||||
const failed = f.events().filter((e) => e.action === 'host.sudo' && e.phase === 'completed');
|
||||
assert.ok(failed.every((e) => e.outcome === 'failed' && e.code === 'credential-leak'));
|
||||
});
|
||||
|
||||
test('T18, T19, T20: one call at a time, no key in any argv or env, and nothing left after', async (t) => {
|
||||
const f = await hostFixture(t);
|
||||
const first = f.call(['sleep', '3']);
|
||||
assert.ok(await until(() => f.argvSeen().length === 1), 'fake sudo started');
|
||||
const procs = procsNaming(f.root);
|
||||
assert.ok(procs.length >= 2, 'ssh-agent and ssh name the run dir');
|
||||
const needles = [...f.key.needles, f.key.text];
|
||||
assert.ok(procs.some((p) => p.environ !== null), 'ssh environ read');
|
||||
for (const p of procs) for (const n of needles) assert.equal(p.cmdline.includes(n) || (p.environ ?? '').includes(n), false);
|
||||
// host-busy refuses before the decision is spent, so it still runs after.
|
||||
const held = await f.approve(['true']);
|
||||
await refusesWith(f.run({ host: 'web1', argv: ['true'], decision: held }), 'host-busy');
|
||||
assert.equal((await first).exit, 0);
|
||||
assert.equal((await f.run({ host: 'web1', argv: ['true'], decision: held })).exit, 0);
|
||||
// Success, a refusal, a timeout and a thrown error each leave no run dir and no process.
|
||||
await refusesWith(f.call(['cat', f.keyFile]), 'credential-leak');
|
||||
await f.call(['sleep', '30'], { timeout: 1 });
|
||||
await assert.rejects(
|
||||
runHost({ root: f.root, hostId: 'web1', entry: { ...f.entry, hostKeys: null }, key: f.key, argv: ['true'], stdin: '', timeout: 5 }),
|
||||
);
|
||||
assert.deepEqual(readdirSync(f.root), []);
|
||||
assert.ok(await until(() => procsNaming(f.root).length === 0, 2000));
|
||||
});
|
||||
|
||||
test('T21: revoke kills the call in flight, and the next call refuses', async (t) => {
|
||||
const f = await hostFixture(t);
|
||||
const slow = f.call(['sleep', '30']);
|
||||
assert.ok(await until(() => f.argvSeen().length === 1));
|
||||
f.credentials.revoke('demo/coder', 'host.web1');
|
||||
await refusesWith(slow, 'credential-unavailable');
|
||||
await refusesWith(f.call(['true']), 'credential-unavailable');
|
||||
assert.ok(await until(() => procsNaming(f.root).length === 0, 2000));
|
||||
});
|
||||
|
||||
test('T22: events carry host, address, user and argv in clear, and sizes and hashes only', async (t) => {
|
||||
const f = await hostFixture(t);
|
||||
await f.call(['cat'], { stdin: 'secret-ish stdin' });
|
||||
const [requested, completed] = f.events().filter((e) => e.action === 'host.sudo' && e.phase);
|
||||
assert.deepEqual(
|
||||
{ host: requested.host, address: requested.address, user: requested.user, argv: requested.argv, stdinBytes: requested.stdinBytes },
|
||||
{ host: 'web1', address: '127.0.0.1', user: USER, argv: ['cat'], stdinBytes: 16 },
|
||||
);
|
||||
assert.match(requested.stdinSha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(completed.outcome, 'ran');
|
||||
assert.equal(completed.stdoutBytes, 16);
|
||||
assert.match(completed.stdoutSha256, /^[0-9a-f]{64}$/);
|
||||
assert.equal(JSON.stringify(f.events()).includes('secret-ish stdin'), false);
|
||||
});
|
||||
|
||||
test('T24: no literal gitea and vikunja kind list outside vocabulary.mjs, and the credential use sites are known', () => {
|
||||
const files = readdirSync(join(REPO, 'packages')).flatMap((pkg) => {
|
||||
const src = join(REPO, 'packages', pkg, 'src');
|
||||
return existsSync(src) ? readdirSync(src).filter((n) => n.endsWith('.mjs')).map((n) => `packages/${pkg}/src/${n}`) : [];
|
||||
});
|
||||
const code = (file) => readFileSync(join(REPO, file), 'utf8').split('\n').filter((l) => !/^\s*(\/\/|\*)/.test(l)).join('\n');
|
||||
const lists = files.filter((file) => !file.endsWith('business/src/vocabulary.mjs') &&
|
||||
/\[\s*['"]gitea['"]\s*,\s*['"]vikunja['"]\s*\]/.test(code(file)));
|
||||
assert.deepEqual(lists, []);
|
||||
const uses = files.filter((file) => /credentials\.(use|useFor)\(/.test(code(file))).sort();
|
||||
assert.deepEqual(uses, ['packages/bus/src/host-run.mjs', 'packages/bus/src/runtime.mjs', 'packages/tasks/src/adapter.mjs']);
|
||||
});
|
||||
|
||||
test('T25: no role definition lists host.sudo while it is a hard wait', () => {
|
||||
const dir = join(REPO, 'roles');
|
||||
for (const name of readdirSync(dir).filter((n) => n.endsWith('.json'))) {
|
||||
const role = JSON.parse(readFileSync(join(dir, name), 'utf8'));
|
||||
const authority = role.authority ?? {};
|
||||
for (const list of [authority.withinRole ?? [], authority.crossRole ?? []]) assert.equal(list.includes('host.sudo'), false, name);
|
||||
}
|
||||
});
|
||||
|
||||
test('T27: the bus child dying takes ssh and ssh-agent with it', async (t) => {
|
||||
const f = await hostFixture(t);
|
||||
// A separate bus child runs one call; the test SIGKILLs it while fake sudo sleeps.
|
||||
const script = join(f.dir, 'child.mjs');
|
||||
const src = new URL('../src/', import.meta.url).href;
|
||||
writeFileSync(script, `
|
||||
import { Credentials } from '${src}credentials.mjs';
|
||||
import { hostExecutor } from '${src}host-run.mjs';
|
||||
const credentials = new Credentials({ references: { 'demo/coder': { 'host.web1': { file: ${JSON.stringify(f.keyFile)}, rotateBy: '2099-01-01' } } }, dataRoot: ${JSON.stringify(join(f.dir, 'data'))} });
|
||||
const executor = hostExecutor({ credentials, root: ${JSON.stringify(join(f.dir, 'run2'))} });
|
||||
await executor.run({ business: 'demo', role: 'coder' }, 'web1', ${JSON.stringify(f.entry)}, { argv: ['sleep', '60'], stdin: '', timeout: 120 });
|
||||
`);
|
||||
const child = spawn(process.execPath, [script], { stdio: 'ignore' });
|
||||
t.after(() => child.kill('SIGKILL'));
|
||||
assert.ok(await until(() => f.argvSeen().length === 1, 10000), 'fake sudo started');
|
||||
const [run] = readdirSync(join(f.dir, 'run2'));
|
||||
const pids = readFileSync(join(f.dir, 'run2', run, 'pids'), 'utf8').trim().split('\n').map((l) => Number(l.split(' ')[0]));
|
||||
assert.equal(pids.length, 3);
|
||||
assert.equal(pids[0], child.pid);
|
||||
child.kill('SIGKILL');
|
||||
assert.ok(await until(() => pids.slice(1).every((p) => !existsSync(`/proc/${p}`)), 2000), 'ssh and ssh-agent are gone');
|
||||
// The next start sweeps the dir.
|
||||
assert.deepEqual(sweepRunDir(join(f.dir, 'run2')).removed, [run]);
|
||||
assert.deepEqual(readdirSync(join(f.dir, 'run2')), []);
|
||||
});
|
||||
|
||||
test('T27: ssh-add -t drops the key from an agent the test keeps alive', async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'h210-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
keygen(join(dir, 'k'));
|
||||
const sock = join(dir, 'a.sock');
|
||||
const agent = spawn('ssh-agent', ['-D', '-a', sock], { stdio: 'ignore' });
|
||||
t.after(() => agent.kill('SIGKILL'));
|
||||
assert.ok(await until(() => existsSync(sock)));
|
||||
await addKey(sock, readFileSync(join(dir, 'k'), 'utf8'), 1);
|
||||
const list = () => {
|
||||
try {
|
||||
return execFileSync('ssh-add', ['-l'], { env: { PATH: '/usr/bin:/bin', SSH_AUTH_SOCK: sock }, encoding: 'utf8' });
|
||||
} catch (e) {
|
||||
return e.stdout;
|
||||
}
|
||||
};
|
||||
assert.match(list(), /SHA256:/);
|
||||
await sleep(2500);
|
||||
assert.match(list(), /no identities/);
|
||||
});
|
||||
|
||||
// The test's own limit makes a hung addKey (M37a, M37b) fail in seconds, not at the batch timeout.
|
||||
test('N2: a hung ssh-add is killed at its limit or on abort, and nothing is left running', { timeout: 10000 }, async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'h210-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
keygen(join(dir, 'k'));
|
||||
const text = readFileSync(join(dir, 'k'), 'utf8');
|
||||
// An agent socket that accepts and never answers.
|
||||
const closed = [];
|
||||
const server = createServer((c) => c.resume().on('close', () => closed.push(Date.now())));
|
||||
const sock = join(dir, 'a.sock');
|
||||
await new Promise((r) => server.listen(sock, r));
|
||||
t.after(() => server.close());
|
||||
const began = Date.now();
|
||||
await refusesWith(addKey(sock, text, 1, { limitMs: 300 }), 'host-failed');
|
||||
assert.ok(Date.now() - began < 2000);
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 100);
|
||||
await refusesWith(addKey(sock, text, 1, { limitMs: 60000, signal: controller.signal }), 'credential-unavailable');
|
||||
// Both ssh-add processes are gone: the socket saw each connection close.
|
||||
assert.ok(await until(() => closed.length === 2), `closed ${closed.length}`);
|
||||
});
|
||||
|
||||
test('T27: the start sweep keeps a live bus dir and kills only a matching live process', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'h210-run-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
chmodSync(root, 0o700);
|
||||
const dead = spawn('true');
|
||||
await new Promise((r) => dead.on('close', r));
|
||||
const make = (name, lines) => {
|
||||
mkdirSync(join(root, name), { mode: 0o700 });
|
||||
writeFileSync(join(root, name, 'pids'), lines.map((l) => l.join(' ') + '\n').join(''));
|
||||
return join(root, name);
|
||||
};
|
||||
// A dir whose bus is gone, with a planted live process that names it.
|
||||
const gone = join(root, 'h-aaaaaa');
|
||||
const planted = spawn('/bin/sh', ['-c', 'sleep 60; :', gone], { stdio: 'ignore', detached: true });
|
||||
t.after(() => { try { process.kill(planted.pid, 'SIGKILL'); } catch {} });
|
||||
await until(() => startTime(planted.pid) !== null);
|
||||
make('h-aaaaaa', [[dead.pid, 'x'], [planted.pid, startTime(planted.pid)]]);
|
||||
// A dir whose bus is alive.
|
||||
make('h-bbbbbb', [[process.pid, startTime(process.pid)]]);
|
||||
// A dir whose bus is gone and whose recorded pid is alive but with another start time.
|
||||
const other = join(root, 'h-cccccc');
|
||||
const decoy = spawn('/bin/sh', ['-c', 'sleep 60; :', other], { stdio: 'ignore', detached: true });
|
||||
t.after(() => { try { process.kill(decoy.pid, 'SIGKILL'); } catch {} });
|
||||
await until(() => startTime(decoy.pid) !== null);
|
||||
make('h-cccccc', [[dead.pid, 'x'], [decoy.pid, '1']]);
|
||||
assert.deepEqual(sweepRunDir(root).removed.sort(), ['h-aaaaaa', 'h-cccccc']);
|
||||
assert.ok(await until(() => planted.exitCode !== null || planted.signalCode !== null, 2000), 'planted process killed');
|
||||
assert.equal(existsSync(`/proc/${decoy.pid}`), true, 'decoy kept');
|
||||
assert.deepEqual(readdirSync(root), ['h-bbbbbb']);
|
||||
writeFileSync(join(root, 'stray'), '');
|
||||
assert.throws(() => sweepRunDir(root), /credential-file/);
|
||||
});
|
||||
|
||||
test('T27: the bus start sweeps <configDir>/secrets/run before it listens, and a stray entry stops the start', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'h210-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
writeJson(join(root, 'businesses/demo.json'), businessDoc(root, 'demo'));
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const run = join(root, 'secrets', 'run');
|
||||
mkdirSync(join(run, 'h-dddddd'), { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const dead = spawn('true');
|
||||
await new Promise((r) => dead.on('close', r));
|
||||
writeFileSync(join(run, 'h-dddddd', 'pids'), `${dead.pid} x\n`);
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
};
|
||||
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const bus = await start();
|
||||
try {
|
||||
assert.deepEqual(readdirSync(run), []);
|
||||
} finally {
|
||||
await bus.close();
|
||||
}
|
||||
writeFileSync(join(run, 'stray'), '');
|
||||
// A bus that starts anyway is closed, so a failure here ends the file instead of holding it open.
|
||||
await assert.rejects(async () => { const again = await start(); await again.close(); }, /credential-file/);
|
||||
});
|
||||
|
||||
// Row 234 (#1740, duckworth-0079 M3, r6 X4 first bullet): host.run takes a grant record only behind the
|
||||
// Approve record check. Each case breaks one part of it. The refusals come before the executor, so none
|
||||
// needs sshd. The control passes the check and stops at host-not-configured.
|
||||
test('M3: host.run refuses host-not-granted unless the grant record has an approved, spent host grant', async (t) => {
|
||||
const f = await fixture(t, { roles: { coder: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: [] } } } });
|
||||
const entry = { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] };
|
||||
for (const host of ['web1', 'web2']) hostRecord(f.store, { host, entry });
|
||||
const run = (cap = f.coder, host = 'web1') => f.broker.requestHost(cap, { verb: 'host.run', args: { host, argv: ['true'] } }, null);
|
||||
const commands = [['true']];
|
||||
const cases = {
|
||||
'no record': null,
|
||||
unresolved: { close: null },
|
||||
'resolved no': { close: { ...HUMAN_YES, choice: 'no' } },
|
||||
withdrawn: { close: { op: 'withdrawn', by: 'jason', via: 'cli' } },
|
||||
'an arbiter': { close: { ...HUMAN_YES, by: 'reviewer', via: 'broker' } },
|
||||
'the tester': { close: { ...HUMAN_YES, by: 'tester' } },
|
||||
'an agent': { close: { ...HUMAN_YES, by: 'coder', via: 'broker' } },
|
||||
'not via cli': { close: { ...HUMAN_YES, via: 'web' } },
|
||||
'consumed as grant.host-enroll': { spend: 'grant.host-enroll' },
|
||||
unspent: { spend: null },
|
||||
'not a credential.mint': { action: 'deploy' },
|
||||
'no grant in the raise': { grant: null },
|
||||
'a host-enroll grant': { grant: { method: 'host-enroll' } },
|
||||
'a grant for another agent': { grant: { agent: 'reviewer' } },
|
||||
'a grant for another host': { grant: { host: 'web2' } },
|
||||
};
|
||||
for (const [name, opts] of Object.entries(cases)) {
|
||||
f.store.run('DELETE FROM host_grants');
|
||||
if (opts) hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands, ...opts });
|
||||
await refusesWith(run(), 'host-not-granted').catch((e) => assert.fail(`${name}: ${e.message}`));
|
||||
}
|
||||
f.store.run('DELETE FROM host_grants');
|
||||
hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands });
|
||||
await refusesWith(run(), 'host-not-configured');
|
||||
// The record is per agent: the reviewer holds none on web1, and web9 has no host record.
|
||||
await refusesWith(run(f.reviewer), 'host-not-granted');
|
||||
await refusesWith(run(f.coder, 'web9'), 'host-unknown');
|
||||
// The host.sudo raise takes the same check (N1).
|
||||
f.store.run('DELETE FROM host_grants');
|
||||
hostGrantRecord(f.store, { agent: 'coder', host: 'web1', commands, spend: null });
|
||||
assert.throws(() => f.raise({ action: 'host.sudo', target: 'web1', argv: ['true'] }), /host-not-granted/);
|
||||
});
|
||||
|
||||
// Row 234 (#1740, M3, r6 X4 third bullet): at start each host_grants record gives Credentials a store
|
||||
// reference to host.<id>, so a restart finds the key the grant executor wrote.
|
||||
test('M3: a host_grants record gives Credentials a store reference at start, and a restart reads its key', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'h234-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: { coder: { authority: { withinRole: ['message.send'], crossRole: [] }, credentials: {} } },
|
||||
};
|
||||
const start = () => startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
const hostKeys = (bus) => bus.credentials.status().filter((c) => c.service.startsWith('host.'));
|
||||
let bus = await start();
|
||||
assert.deepEqual(hostKeys(bus), []);
|
||||
await bus.close();
|
||||
const store = new Store(join(root, 'data'));
|
||||
hostRecord(store, { host: 'web1', entry: { address: '10.0.0.5', port: 22, user: 'admin', hostKeys: ['ssh-ed25519 AAAA'] } });
|
||||
hostGrantRecord(store, { agent: 'coder', host: 'web1', commands: [['true']], rotateBy: '2099-02-03' });
|
||||
store.close();
|
||||
bus = await start();
|
||||
try {
|
||||
assert.deepEqual(hostKeys(bus).map(({ instance, service, date, state }) => ({ instance, service, date, state })), [
|
||||
{ instance: 'demo/coder', service: 'host.web1', date: '2099-02-03', state: 'not-issued' },
|
||||
]);
|
||||
} finally {
|
||||
await bus.close();
|
||||
}
|
||||
const dir = join(root, 'secrets', 'agents', 'demo', 'coder');
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets', 'agents', 'demo'), 0o700);
|
||||
keygen(join(dir, 'host.web1'));
|
||||
bus = await start();
|
||||
try {
|
||||
const [key] = hostKeys(bus);
|
||||
assert.equal(key.publicKey, pubLine(join(dir, 'host.web1')));
|
||||
assert.notEqual(key.state, 'not-issued');
|
||||
} finally {
|
||||
await bus.close();
|
||||
}
|
||||
});
|
||||
@@ -1,358 +0,0 @@
|
||||
// Row 234 (#1740, #436a row a), design r6 E3: the four host files in tools/host, as G16 and G25.
|
||||
// The writer runs as the test user through a harness that loads it by path and passes its own dirs,
|
||||
// owner uid and user, group and sudo lookups, so nothing runs as root and nothing under /etc is read.
|
||||
// mosaic-keys runs from a copy whose sudo and logger paths point at stubs in a scratch dir.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import {
|
||||
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const REPO = fileURLToPath(new URL('../../../', import.meta.url));
|
||||
const TOOLS = join(REPO, 'tools', 'host');
|
||||
const WRITER = join(TOOLS, 'mosaic-keys-write');
|
||||
const REFUSED_GROUPS = ['sudo', 'admin', 'wheel', 'docker', 'lxd', 'disk', 'libvirt', 'incus-admin'];
|
||||
const tag = (n) => 'mosaic-' + createHash('sha256').update(String(n)).digest('hex').slice(0, 32);
|
||||
const u32 = (n) => {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n);
|
||||
return b;
|
||||
};
|
||||
const keyBlob = () => Buffer.concat([u32(11), Buffer.from('ssh-ed25519'), u32(32), randomBytes(32)]).toString('base64');
|
||||
const fingerprint = (b64) => 'SHA256:' + createHash('sha256').update(Buffer.from(b64, 'base64')).digest('base64').replace(/=+$/, '');
|
||||
const notAllowed = (u) => `User ${u} is not allowed to run sudo on testhost.\n`;
|
||||
|
||||
const HARNESS = `import importlib.machinery, importlib.util, json, os, sys
|
||||
sys.dont_write_bytecode = True
|
||||
cfg = json.loads(sys.argv[1])
|
||||
loader = importlib.machinery.SourceFileLoader('mosaic_keys_write', cfg['writer'])
|
||||
m = importlib.util.module_from_spec(importlib.util.spec_from_loader('mosaic_keys_write', loader))
|
||||
loader.exec_module(m)
|
||||
users = cfg['users']
|
||||
def log(msg):
|
||||
with open(cfg['log'], 'a') as f:
|
||||
f.write(msg + '\\n')
|
||||
def sudo(u):
|
||||
with open(cfg['sudoLog'], 'a') as f:
|
||||
f.write(u + '\\n')
|
||||
return users[u]['sudo'] if 'sudo' in users[u] else 'User %s is not allowed to run sudo on testhost.\\n' % u
|
||||
host = m.Host(key_dir=cfg['keyDir'], conf_dir=cfg['confDir'], owner=os.getuid(),
|
||||
passwd=lambda u: users[u]['uid'] if u in users else None,
|
||||
groups=lambda u: users[u].get('groups', []), sudo=sudo, hostname='testhost', log=log)
|
||||
sys.exit(m.main(sys.argv[2:], host))
|
||||
`;
|
||||
|
||||
// The users the harness's lookups know. users lists all of them but dave and eve. bob and dave are
|
||||
// privileged. carol has a sudo line. Each gX is in one refused group.
|
||||
function userTable() {
|
||||
const users = {
|
||||
alice: { uid: 1500 },
|
||||
bob: { uid: 1501, groups: ['sudo', 'docker'], sudo: 'User bob may run the following commands on testhost:\n (ALL) ALL\n' },
|
||||
carol: { uid: 1502, sudo: 'User carol may run the following commands on testhost:\n (root) /usr/bin/true\n' },
|
||||
dave: { uid: 1503 },
|
||||
eve: { uid: 1504 },
|
||||
frank: { uid: 1505, groups: ['users', 'adm'] },
|
||||
'../x': { uid: 1506 },
|
||||
root: { uid: 0 },
|
||||
toor: { uid: 0 },
|
||||
daemon2: { uid: 999 },
|
||||
localadmin: { uid: 1000 },
|
||||
'mosaic-installer': { uid: 1001 },
|
||||
};
|
||||
for (const [i, g] of REFUSED_GROUPS.entries()) users['g' + g] = { uid: 1600 + i, groups: ['users', g] };
|
||||
return users;
|
||||
}
|
||||
|
||||
function hostDirs() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'hk234-'));
|
||||
const keyDir = join(root, 'keys');
|
||||
const confDir = join(root, 'conf');
|
||||
mkdirSync(keyDir, { mode: 0o755 });
|
||||
mkdirSync(confDir, { mode: 0o755 });
|
||||
chmodSync(keyDir, 0o755);
|
||||
const users = userTable();
|
||||
const conf = (name, text) => {
|
||||
writeFileSync(join(confDir, name), text, { mode: 0o644 });
|
||||
chmodSync(join(confDir, name), 0o644);
|
||||
};
|
||||
conf('users', '# users the writer may touch\n' + Object.keys(users).filter((u) => !['dave', 'eve'].includes(u)).join('\n') + '\n');
|
||||
conf('privileged', 'bob\ndave\n');
|
||||
conf('from', '10.1.2.3/32\n');
|
||||
const harness = join(root, 'harness.py');
|
||||
writeFileSync(harness, HARNESS);
|
||||
const cfg = { writer: WRITER, keyDir, confDir, users, log: join(root, 'syslog'), sudoLog: join(root, 'sudo.log') };
|
||||
const run = (...args) => {
|
||||
const r = spawnSync('python3', ['-B', '-I', harness, JSON.stringify(cfg), ...args], { encoding: 'utf8' });
|
||||
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
|
||||
};
|
||||
const read = (file) => readFileSync(join(keyDir, file), 'utf8');
|
||||
const lastLog = () => readFileSync(cfg.log, 'utf8').trim().split('\n').at(-1);
|
||||
const sudoCalls = () => (existsSync(cfg.sudoLog) ? readFileSync(cfg.sudoLog, 'utf8').trim().split('\n') : []);
|
||||
return { root, keyDir, confDir, conf, run, read, lastLog, sudoCalls, done: () => rmSync(root, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
const refused = (r, code) => {
|
||||
assert.equal(r.status, 1, r.stderr);
|
||||
assert.equal(r.stderr, `mosaic-keys-write: refused ${code}\n`);
|
||||
};
|
||||
|
||||
test('G16: the writer refuses each user, tag and key the design names, before any key file', () => {
|
||||
const h = hostDirs();
|
||||
try {
|
||||
const key = keyBlob();
|
||||
const t = tag(1);
|
||||
const add = (user) => h.run('add', user, 'ssh-ed25519', key, t);
|
||||
// The user pattern stops `../x` before any path, though the lookups and users know it.
|
||||
for (const user of ['../x', 'Alice', 'a'.repeat(33), '1alice', 'al ice', '']) {
|
||||
refused(add(user), 'user-pattern');
|
||||
refused(h.run('list', user), 'user-pattern');
|
||||
}
|
||||
// users is always required: privileged alone allows nothing.
|
||||
refused(add('dave'), 'user-not-listed');
|
||||
refused(add('eve'), 'user-not-listed');
|
||||
for (const user of ['root', 'localadmin', 'mosaic-installer']) refused(add(user), 'user-not-listed');
|
||||
refused(add('toor'), 'user-system');
|
||||
refused(add('daemon2'), 'user-system');
|
||||
refused(add('ghost'), 'user-not-listed');
|
||||
for (const g of REFUSED_GROUPS) {
|
||||
refused(add('g' + g), 'user-group');
|
||||
refused(h.run('remove', 'g' + g, t), 'user-group');
|
||||
}
|
||||
refused(add('carol'), 'user-sudo');
|
||||
refused(h.run('list', 'carol'), 'user-sudo');
|
||||
assert.equal(h.lastLog(), 'result=refused user-sudo user=carol');
|
||||
for (const bad of ['mosaic-' + 'a'.repeat(31), 'mosaic-' + 'A'.repeat(32), 'mosaic-installer-' + 'a'.repeat(32), 'mosaic-' + 'g'.repeat(32)]) {
|
||||
refused(h.run('add', 'alice', 'ssh-ed25519', key, bad), 'tag-pattern');
|
||||
refused(h.run('remove', 'alice', bad), 'tag-pattern');
|
||||
}
|
||||
refused(h.run('add', 'alice', 'ssh-dss', key, t), 'key-format');
|
||||
refused(h.run('add', 'alice', 'ssh-ed25519', key.slice(0, -2) + '!!', t), 'key-format');
|
||||
refused(h.run('add', 'alice', 'ssh-ed25519', 'A', t), 'key-format');
|
||||
refused(h.run('add', 'alice', 'ssh-ed25519', key + ' x', t), 'key-format');
|
||||
for (const args of [[], ['list'], ['list', 'alice', 'bob'], ['add', 'alice', 'ssh-ed25519', key], ['remove', 'alice'], ['rm', 'alice', t]])
|
||||
refused(h.run(...args), 'request');
|
||||
// No refusal reached the key dir, so not even the lock exists.
|
||||
assert.deepEqual(readdirSync(h.keyDir), []);
|
||||
// Only users past the groups check reach sudo -l, and the privileged never do.
|
||||
assert.deepEqual([...new Set(h.sudoCalls())], ['carol']);
|
||||
} finally {
|
||||
h.done();
|
||||
}
|
||||
});
|
||||
|
||||
test('G16: from= comes from the host file, a privileged user skips the group and sudo checks, and the line is exact', () => {
|
||||
const h = hostDirs();
|
||||
try {
|
||||
const k1 = keyBlob();
|
||||
const k2 = keyBlob();
|
||||
assert.equal(h.run('add', 'alice', 'ssh-ed25519', k1, tag(1)).status, 0);
|
||||
assert.equal(h.lastLog(), `result=added user=alice tag=${tag(1)} key=${fingerprint(k1)}`);
|
||||
h.conf('from', '# the stack host\n10.9.9.9,192.168.1.0/24\n');
|
||||
assert.equal(h.run('add', 'alice', 'ssh-ed25519', k2, tag(2)).status, 0);
|
||||
assert.equal(
|
||||
h.read('alice'),
|
||||
`from="10.1.2.3/32",restrict ssh-ed25519 ${k1} ${tag(1)}\nfrom="10.9.9.9,192.168.1.0/24",restrict ssh-ed25519 ${k2} ${tag(2)}\n`,
|
||||
);
|
||||
// A tag already there changes nothing and exits 0.
|
||||
assert.equal(h.run('add', 'alice', 'ssh-ed25519', k2, tag(1)).status, 0);
|
||||
assert.equal(h.lastLog(), `result=present user=alice tag=${tag(1)} key=${fingerprint(k2)}`);
|
||||
assert.equal(h.read('alice').split('\n').length, 3);
|
||||
// bob is in sudo and docker with a sudo line, and privileged.
|
||||
assert.equal(h.run('add', 'bob', 'ssh-ed25519', k1, tag(3)).status, 0);
|
||||
// frank's groups are none of the refused ones.
|
||||
assert.equal(h.run('add', 'frank', 'ssh-ed25519', k1, tag(4)).status, 0);
|
||||
assert.deepEqual(h.sudoCalls(), ['alice', 'alice', 'alice', 'frank']);
|
||||
// A from value that could close the quote, two values, or none, refuses and writes nothing.
|
||||
for (const text of ['10.0.0.1",command="/bin/sh\n', '10.0.0.1\n10.0.0.2\n', '# none\n', '10.0.0.1 10.0.0.2\n']) {
|
||||
h.conf('from', text);
|
||||
refused(h.run('add', 'frank', 'ssh-ed25519', k2, tag(5)), 'config-from');
|
||||
}
|
||||
rmSync(join(h.confDir, 'from'));
|
||||
refused(h.run('add', 'frank', 'ssh-ed25519', k2, tag(5)), 'config-from');
|
||||
assert.ok(!h.read('frank').includes(tag(5)));
|
||||
// list and remove don't need from.
|
||||
assert.equal(h.run('list', 'frank').stdout, `from="10.9.9.9,192.168.1.0/24",restrict ssh-ed25519 ${k1} ${tag(4)}\n`);
|
||||
for (const name of ['users', 'privileged']) {
|
||||
chmodSync(join(h.confDir, name), 0o664);
|
||||
refused(h.run('list', 'alice'), 'not-root-owned');
|
||||
chmodSync(join(h.confDir, name), 0o644);
|
||||
}
|
||||
rmSync(join(h.confDir, 'users'));
|
||||
refused(h.run('list', 'alice'), 'config-users');
|
||||
} finally {
|
||||
h.done();
|
||||
}
|
||||
});
|
||||
|
||||
test('G16: list shows tagged lines only, and add and remove never touch a line outside the tag pattern', () => {
|
||||
const h = hostDirs();
|
||||
try {
|
||||
const k = keyBlob();
|
||||
const hand = [
|
||||
'# written by hand',
|
||||
`ssh-ed25519 ${k} mosaic-installer-${'a'.repeat(32)}`,
|
||||
'',
|
||||
`ssh-ed25519 ${k} ${tag(9).toUpperCase()}`,
|
||||
`ssh-ed25519 ${k} ${tag(1)} trailing`,
|
||||
];
|
||||
const tagged = `from="10.1.2.3/32",restrict ssh-ed25519 ${k} ${tag(7)}`;
|
||||
writeFileSync(join(h.keyDir, 'alice'), [hand[0], tagged, ...hand.slice(1)].join('\n') + '\n', { mode: 0o644 });
|
||||
assert.equal(h.run('list', 'alice').stdout, tagged + '\n');
|
||||
// The last field is the tag, so `... mosaic-<1> trailing` isn't tag 1.
|
||||
assert.equal(h.run('remove', 'alice', tag(1)).status, 0);
|
||||
assert.equal(h.lastLog(), `result=absent user=alice tag=${tag(1)}`);
|
||||
assert.equal(h.run('add', 'alice', 'ssh-ed25519', k, tag(8)).status, 0);
|
||||
assert.equal(h.run('remove', 'alice', tag(7)).status, 0);
|
||||
assert.equal(h.lastLog(), `result=removed user=alice tag=${tag(7)}`);
|
||||
assert.equal(h.read('alice'), [...hand, `from="10.1.2.3/32",restrict ssh-ed25519 ${k} ${tag(8)}`].join('\n') + '\n');
|
||||
assert.equal(h.run('remove', 'alice', tag(8)).status, 0);
|
||||
assert.equal(h.read('alice'), hand.join('\n') + '\n');
|
||||
// A missing file lists nothing and a remove there changes nothing.
|
||||
assert.equal(h.run('list', 'frank').stdout, '');
|
||||
assert.equal(h.run('remove', 'frank', tag(1)).status, 0);
|
||||
assert.ok(!existsSync(join(h.keyDir, 'frank')));
|
||||
} finally {
|
||||
h.done();
|
||||
}
|
||||
});
|
||||
|
||||
test('G25: the writer refuses a symlinked dir or user file, a group-writable dir and an odd tmp, and writes 0644', () => {
|
||||
const h = hostDirs();
|
||||
try {
|
||||
const k = keyBlob();
|
||||
const add = (n, user = 'alice') => h.run('add', user, 'ssh-ed25519', k, tag(n));
|
||||
// A first add creates the file, regular, 0644, owned by the owner uid, with no tmp left.
|
||||
assert.equal(add(1).status, 0);
|
||||
const st = statSync(join(h.keyDir, 'alice'));
|
||||
assert.equal(st.mode & 0o777, 0o644);
|
||||
assert.equal(st.uid, process.getuid());
|
||||
assert.deepEqual(readdirSync(h.keyDir).sort(), ['.lock', 'alice']);
|
||||
// A leftover regular tmp is unlinked and the write goes ahead.
|
||||
writeFileSync(join(h.keyDir, '.alice.tmp'), 'half a write');
|
||||
assert.equal(add(2).status, 0);
|
||||
assert.ok(h.read('alice').includes(tag(2)));
|
||||
assert.ok(!existsSync(join(h.keyDir, '.alice.tmp')));
|
||||
// A tmp that is a symlink, a dir or a fifo refuses, and nothing is written.
|
||||
const decoy = join(h.root, 'decoy');
|
||||
writeFileSync(decoy, 'decoy\n');
|
||||
const before = h.read('alice');
|
||||
for (const make of [
|
||||
(p) => symlinkSync(decoy, p),
|
||||
(p) => mkdirSync(p),
|
||||
(p) => execFileSync('mkfifo', [p]),
|
||||
]) {
|
||||
make(join(h.keyDir, '.alice.tmp'));
|
||||
refused(add(3), 'tmp-file');
|
||||
assert.equal(h.read('alice'), before);
|
||||
assert.equal(readFileSync(decoy, 'utf8'), 'decoy\n');
|
||||
rmSync(join(h.keyDir, '.alice.tmp'), { recursive: true });
|
||||
}
|
||||
// A symlinked user file refuses, though its target is a regular file the owner owns.
|
||||
symlinkSync(decoy, join(h.keyDir, 'frank'));
|
||||
refused(add(4, 'frank'), 'user-file');
|
||||
refused(h.run('list', 'frank'), 'user-file');
|
||||
assert.equal(readFileSync(decoy, 'utf8'), 'decoy\n');
|
||||
assert.ok(lstatSync(join(h.keyDir, 'frank')).isSymbolicLink());
|
||||
// A user "file" that is a dir refuses.
|
||||
mkdirSync(join(h.keyDir, 'bob'));
|
||||
refused(add(5, 'bob'), 'not-root-owned');
|
||||
// A group-writable dir refuses.
|
||||
chmodSync(h.keyDir, 0o775);
|
||||
refused(add(6), 'not-root-owned');
|
||||
chmodSync(h.keyDir, 0o755);
|
||||
// A symlinked key dir refuses, and its target is untouched.
|
||||
const real = join(h.root, 'real-keys');
|
||||
mkdirSync(real, { mode: 0o755 });
|
||||
rmSync(h.keyDir, { recursive: true });
|
||||
symlinkSync(real, h.keyDir);
|
||||
refused(add(7), 'key-dir');
|
||||
assert.deepEqual(readdirSync(real), []);
|
||||
} finally {
|
||||
h.done();
|
||||
}
|
||||
});
|
||||
|
||||
// mosaic-keys from a copy with its sudo and logger paths on stubs. The stub sudo records its argv,
|
||||
// prints a line and exits 3, so the test sees the exit and output pass through.
|
||||
function forcedCommand() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'hk234-'));
|
||||
const sudo = join(root, 'sudo');
|
||||
const logger = join(root, 'logger');
|
||||
writeFileSync(sudo, `#!/bin/bash\nprintf '%s\\n' "$@" > ${root}/sudo.argv\necho from-writer\nexit 3\n`, { mode: 0o755 });
|
||||
writeFileSync(logger, `#!/bin/bash\nprintf '%s\\n' "$*" >> ${root}/logger.log\n`, { mode: 0o755 });
|
||||
const source = readFileSync(join(TOOLS, 'mosaic-keys'), 'utf8');
|
||||
assert.equal(source.split('/usr/bin/sudo').length, 2);
|
||||
assert.equal(source.split('/usr/bin/logger').length, 2);
|
||||
const copy = join(root, 'mosaic-keys');
|
||||
writeFileSync(copy, source.replace('/usr/bin/sudo', sudo).replace('/usr/bin/logger', logger), { mode: 0o755 });
|
||||
const run = (request) => {
|
||||
rmSync(join(root, 'sudo.argv'), { force: true });
|
||||
const env = { PATH: '/usr/bin:/bin', SSH_CONNECTION: '10.1.2.3 50000 10.1.2.9 22' };
|
||||
if (request !== undefined) env.SSH_ORIGINAL_COMMAND = request;
|
||||
const r = spawnSync(copy, [], { encoding: 'utf8', env });
|
||||
const argv = existsSync(join(root, 'sudo.argv')) ? readFileSync(join(root, 'sudo.argv'), 'utf8').trim().split('\n') : null;
|
||||
return { status: r.status, stdout: r.stdout, stderr: r.stderr, argv };
|
||||
};
|
||||
const logged = () => readFileSync(join(root, 'logger.log'), 'utf8').trim().split('\n');
|
||||
return { run, logged, done: () => rmSync(root, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
test('G16: mosaic-keys passes the three requests to the writer through sudo and refuses anything else without it', () => {
|
||||
const f = forcedCommand();
|
||||
try {
|
||||
const k = keyBlob();
|
||||
const t = tag(1);
|
||||
const writer = ['-n', '/usr/local/sbin/mosaic-keys-write'];
|
||||
for (const words of [['list', 'alice'], ['add', 'alice', 'ssh-ed25519', k, t], ['add', 'alice', 'ecdsa-sha2-nistp256', 'AAAA', t], ['remove', 'alice', t]]) {
|
||||
const r = f.run(words.join(' '));
|
||||
assert.deepEqual(r.argv, [...writer, ...words]);
|
||||
assert.equal(r.status, 3);
|
||||
assert.equal(r.stdout, 'from-writer\n');
|
||||
}
|
||||
const bad = [
|
||||
undefined, '', 'list', 'list ../x', 'list Alice', 'list alice;id', 'list alice ', ' list alice', 'list alice', 'list\talice',
|
||||
'list alice\nlist bob', 'list $(id)', 'list alice`id`', 'list *', 'rm alice', 'list ' + 'a'.repeat(33),
|
||||
`add alice ssh-dss ${k} ${t}`, `add alice ssh-ed25519 ${k}! ${t}`, `add alice ssh-ed25519 ${k}`, `add alice ssh-ed25519 ${k} ${t} x`,
|
||||
`remove alice mosaic-${'a'.repeat(31)}`, `remove alice ${t.toUpperCase()}`, `remove alice ${t}\n`, `remove alice ${t}; rm -rf /`,
|
||||
];
|
||||
for (const request of bad) {
|
||||
const r = f.run(request);
|
||||
assert.equal(r.argv, null, JSON.stringify(request));
|
||||
assert.equal(r.status, 1);
|
||||
assert.equal(r.stderr, 'mosaic-keys: refused\n');
|
||||
}
|
||||
// Two bad requests hold a newline, so count the logger calls, not the lines.
|
||||
const logged = f.logged().filter((l) => l.startsWith('-t mosaic-keys -- '));
|
||||
assert.equal(logged.length, 4 + bad.length);
|
||||
assert.equal(logged[0], '-t mosaic-keys -- connection=10.1.2.3 50000 10.1.2.9 22 request=list alice');
|
||||
} finally {
|
||||
f.done();
|
||||
}
|
||||
});
|
||||
|
||||
test('E3: the sudoers line and the sshd drop-in are the design lines and parse', () => {
|
||||
const lines = (file) => readFileSync(join(TOOLS, file), 'utf8').split('\n').filter((l) => l && !l.startsWith('#'));
|
||||
assert.deepEqual(lines('mosaic-keys.sudoers'), ['mosaic-installer ALL=(root) NOPASSWD: /usr/local/sbin/mosaic-keys-write']);
|
||||
assert.deepEqual(lines('mosaic-keys.sshd.conf'), ['AuthorizedKeysFile .ssh/authorized_keys .ssh/authorized_keys2 /etc/ssh/mosaic-keys/%u']);
|
||||
assert.ok(existsSync('/usr/bin/visudo'), 'visudo on the test host');
|
||||
execFileSync('/usr/bin/visudo', ['-c', '-q', '-f', join(TOOLS, 'mosaic-keys.sudoers')]);
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hk234-'));
|
||||
try {
|
||||
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', join(dir, 'hk')]);
|
||||
writeFileSync(join(dir, 'cfg'), `HostKey ${join(dir, 'hk')}\nInclude ${join(TOOLS, 'mosaic-keys.sshd.conf')}\n`);
|
||||
const out = execFileSync('/usr/bin/sshd', ['-T', '-f', join(dir, 'cfg'), '-C', 'user=alice,host=x,addr=127.0.0.1'], { encoding: 'utf8' });
|
||||
// sshd -T prints this key as AuthorizedKeysFile, unlike most.
|
||||
const line = out.split('\n').filter((l) => /^authorizedkeysfile /i.test(l));
|
||||
assert.deepEqual(line.map((l) => l.split(' ').slice(1).join(' ')), ['.ssh/authorized_keys .ssh/authorized_keys2 /etc/ssh/mosaic-keys/%u']);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
for (const file of ['mosaic-keys', 'mosaic-keys-write']) assert.equal(statSync(join(TOOLS, file)).mode & 0o111, 0o111, file);
|
||||
assert.equal(readFileSync(WRITER, 'utf8').split('\n')[0], '#!/usr/bin/python3 -I');
|
||||
});
|
||||
@@ -306,28 +306,6 @@ test('a ticket outlives the run that asked, and recordLand checks the task again
|
||||
assert.throws(() => w.b.landRefused(ticket, { code: 'gate-failed' }), /land-unknown/);
|
||||
});
|
||||
|
||||
// Row 241 (#1747): one decision lands once. Two tickets on one decision can't both reach
|
||||
// recordLand through the git socket (land-busy, git-socket.mjs), so this pins the broker's own
|
||||
// guard: recordLand runs #landFacts again, and a decision spent by the first refuses the second.
|
||||
test('two tickets on one decision: the first recordLand lands, the second refuses decision-consumed and records nothing', (t) => {
|
||||
const w = setup(t);
|
||||
const { d } = w.ready();
|
||||
const first = w.check({ task_ref: TASK, decision: d.id }).ticket;
|
||||
const second = w.check({ task_ref: TASK, decision: d.id }).ticket;
|
||||
assert.notEqual(first, second);
|
||||
assert.deepEqual(w.b.recordLand(first, w.land), { task_ref: TASK, commit: B });
|
||||
assert.throws(() => w.b.recordLand(second, { ...w.land, commit: C, previous: B }), /decision-consumed/);
|
||||
assert.equal(w.merges().length, 1);
|
||||
assert.equal(w.merges()[0].body.commit, B);
|
||||
assert.equal(w.results().length, 1);
|
||||
assert.deepEqual(w.events('action.refused'), []);
|
||||
// The git socket then ends the second ticket with the broker's code, as it does any record refusal.
|
||||
assert.equal(w.b.landRefused(second, { code: 'decision-consumed', notify: true }), 'decision-consumed');
|
||||
assert.deepEqual(w.events('action.refused').map((e) => e.body), [{ code: 'decision-consumed' }]);
|
||||
assert.equal(w.merges().length, 1);
|
||||
assert.throws(() => w.b.recordLand(second, w.land), /land-unknown/);
|
||||
});
|
||||
|
||||
test('landRefused files the code on the task, tells the role only with notify, and ends the ticket', (t) => {
|
||||
const w = setup(t);
|
||||
const { d } = w.ready();
|
||||
|
||||
@@ -55,7 +55,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
|
||||
|
||||
@@ -12,8 +12,7 @@ import { fixture } from './spec-fixtures.mjs';
|
||||
test('Broker keeps the sandbox client import closure: only the listed imports, no lazy project import', () => {
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
|
||||
// Row 210 (#1707): createHash gives host.run events the argv, stdin and output sha256 values.
|
||||
"import { randomUUID, randomBytes, createHash } from 'node:crypto';",
|
||||
"import { randomUUID, randomBytes } from 'node:crypto';",
|
||||
// Row 65 (#1558): bindLaunch checks a run's transcript path is absolute.
|
||||
"import { isAbsolute } from 'node:path';",
|
||||
// Row 65: the plug-in name pattern a harness takes. kinds.mjs is in RUNNER_FILES and imports nothing.
|
||||
@@ -24,10 +23,7 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
// Row 141 (#1635): the business ID_PATTERN for instance names. The vocabulary is already in
|
||||
// RUNNER_FILES and imports nothing. Row 155 (#1649) reads THREAD_DEFINITION and END_PROTECTED
|
||||
// from it too. Row 199 (#1697) reads the D9c fork parse, forkProblem and forkUrl, from it.
|
||||
// Row 210 (#1707) reads credentialKind and the D6 argv check, validArgv and commandAllowed, from it.
|
||||
// Row 234 (#1740) reads HOST_ID for the hosts verbs.
|
||||
// Row 213 (#1714) reads CREATIVE_DEFINITIONS for the creative raise refusal (M6).
|
||||
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS } from '../../business/src/vocabulary.mjs';",
|
||||
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl } from '../../business/src/vocabulary.mjs';",
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
|
||||
@@ -181,32 +181,6 @@ 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, 68);
|
||||
assert.equal(list.requirements.length, 58);
|
||||
assert.deepEqual(call(b, agent('coder'), 'requirements'), list);
|
||||
assert.throws(() => call(b, human, 'requirements'), /agent-required/);
|
||||
assert.throws(() => call(b, reader, 'requirements'), /read-only/);
|
||||
|
||||
@@ -519,8 +519,7 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
|
||||
// Row 121 (#1615) adds 5 tables and 24 triggers for projects and threads; threads.test.mjs tests them.
|
||||
// Row 141 (#1635) adds instance_names and its 4 triggers; names.test.mjs tests them.
|
||||
// Row 124 adds usage_records and its three append-only triggers.
|
||||
// Row 234 (#1740) adds hosts, host_grants and the events_grant_actor guard; grant-migration.test.mjs tests them.
|
||||
assert.equal(count('table') - 1, 18);
|
||||
assert.equal(count('trigger'), 71);
|
||||
assert.equal(count('table') - 1, 16);
|
||||
assert.equal(count('trigger'), 70);
|
||||
assert.equal(count('view'), 5);
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
|
||||
@@ -49,7 +49,7 @@ async function database3d(t) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3j';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace("export const SCHEMA_VERSION = '3i';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
|
||||
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
|
||||
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
|
||||
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
|
||||
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3j ready', ''].join('\n'));
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3i ready', ''].join('\n'));
|
||||
});
|
||||
|
||||
@@ -51,12 +51,12 @@ function snapshot(path, columns) {
|
||||
return { columns, rows };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
test('REQ-LIMIT-4 3g copy migrates through 3h and 3i to 3j preserving every old column, row and sequence; new usage table is empty', t => {
|
||||
test('REQ-LIMIT-4 3g copy migrates through 3h to 3i preserving every old column, row and sequence; new usage table is empty', t => {
|
||||
const { root, path, copy } = source3g(t), before = snapshot(path), untouched = readFileSync(copy);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
|
||||
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
|
||||
|
||||
@@ -50,11 +50,10 @@ 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 nine hard waits in `GATED_ONLY`
|
||||
commit (PRD 1.5 REQ-DEC-2). The eight hard waits in `GATED_ONLY`
|
||||
(REQ-WAIT-1: `spend`, `data.delete`, `network.expose`, `account.create`,
|
||||
`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).
|
||||
`credential.mint`, `message.external`, `git.push.protected`, `deploy`)
|
||||
always go to Jason and can't appear in either list (REQ-WAIT-2).
|
||||
`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`,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { checkVars } from "./vars.mjs";
|
||||
import { parseCredentialRef } from "./credentials.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { checkPlugins } from "../../plugins/src/kinds.mjs";
|
||||
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
|
||||
import { forkProblem, giteaName, parseGiteaUrl } from "./vocabulary.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
} from "./util.mjs";
|
||||
@@ -19,7 +19,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
// How long a thread's session stays up with no turn before the launcher stops
|
||||
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
|
||||
export const THREAD_IDLE_SECONDS = 900;
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts"];
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins"];
|
||||
|
||||
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
||||
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
||||
@@ -102,19 +102,6 @@ function checkTracker(tracker, file) {
|
||||
return { sync, labels };
|
||||
}
|
||||
|
||||
// Row 213 (C2-08, Case 2 design 4): persona vars belong only to a creative instance, and come as a
|
||||
// pair, since the bundle reads the file only against its digest.
|
||||
function checkPersonaVars(vars, definition, at) {
|
||||
const set = ["persona.name", "persona.sha256"].filter((key) => Object.hasOwn(vars, key));
|
||||
if (set.length === 0) return;
|
||||
if (!CREATIVE_DEFINITIONS.includes(definition)) refuse(`${at}.vars: persona vars need a creative definition (${definition} isn't one)`);
|
||||
if (set.length === 1) refuse(`${at}.vars: persona.name and persona.sha256 are set together or not at all`);
|
||||
}
|
||||
|
||||
// Row 234 (#1740, r6 S1): hosts and host grants are bus store records made by a human-approved grant,
|
||||
// so the business file can't name them. The key stays known so the refusal says where they went.
|
||||
const HOSTS_MOVED = "lives in the bus store now: enroll a host with a host-enroll grant (mosaic hosts status)";
|
||||
|
||||
function checkRoles(roles, file, rolesDir, business) {
|
||||
const where = `${file} roles`;
|
||||
requireObject(roles, where);
|
||||
@@ -127,15 +114,12 @@ function checkRoles(roles, file, rolesDir, business) {
|
||||
requireId(instance, `${where} instance name`);
|
||||
const at = `${where}.${instance}`;
|
||||
requireObject(entry, at);
|
||||
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts"], at);
|
||||
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish"], at);
|
||||
requireId(entry.definition, `${at}.definition`);
|
||||
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
|
||||
definitions[entry.definition] = definition;
|
||||
if (definition.roleVersion !== 2) refuse(`${at}: role definition ${entry.definition} is version 1 and can't back a business role instance`);
|
||||
const services = definition.credentials.map((c) => c.service);
|
||||
if (entry.hosts !== undefined) refuse(`${at}.hosts ${HOSTS_MOVED}`);
|
||||
// A host.<id> key reference comes from the agent's grant record at bus start (r6 X4), never from here.
|
||||
const kinds = services.filter((s) => s !== HOST_SERVICE);
|
||||
let tracker = null;
|
||||
if (services.includes("vikunja")) {
|
||||
if (entry.tracker === undefined) refuse(`${at} needs "tracker" with its Vikunja bot, because ${entry.definition} uses vikunja`);
|
||||
@@ -152,7 +136,7 @@ function checkRoles(roles, file, rolesDir, business) {
|
||||
holder: entry.holder === undefined ? null : requireId(entry.holder, `${at}.holder`),
|
||||
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
|
||||
tracker,
|
||||
credentials: checkCredentialMap(entry.credentials ?? {}, kinds, `${at}.credentials`,
|
||||
credentials: checkCredentialMap(entry.credentials ?? {}, services, `${at}.credentials`,
|
||||
{ configDir: dirname(dirname(file)), business, agent: instance }),
|
||||
};
|
||||
// Row 199 (#1697, design D8, D9 step 2): identity and publish are optional, so today's files
|
||||
@@ -164,7 +148,6 @@ function checkRoles(roles, file, rolesDir, business) {
|
||||
if (!out[instance].identity || !services.includes("gitea")) refuse(`${at}.publish needs identity and a gitea definition`);
|
||||
out[instance].publish = requireDistinctList(entry.publish, `${at}.publish`, (p) => requireId(p, `${at}.publish entry`), { nonEmpty: true });
|
||||
}
|
||||
checkPersonaVars(out[instance].vars, entry.definition, at);
|
||||
}
|
||||
return { roles: out, definitions };
|
||||
}
|
||||
@@ -214,7 +197,6 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
|
||||
}
|
||||
const human = requireId(document.human, "business human");
|
||||
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
|
||||
if (document.hosts !== undefined) refuse(`${file} hosts ${HOSTS_MOVED}`);
|
||||
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id);
|
||||
|
||||
requireObject(document.arbiters, `${file} arbiters`);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { lstatSync, realpathSync } from "node:fs";
|
||||
import { isAbsolute, join, normalize, relative, sep } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { credentialKind, isHostKind } from "./vocabulary.mjs";
|
||||
import { SERVICES } from "./vocabulary.mjs";
|
||||
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
|
||||
|
||||
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
@@ -21,10 +21,9 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// can't use the form. A `file` reference into the agents' store must be the
|
||||
// agent's own path (`credential-not-yours`).
|
||||
export function parseCredentialRef(ref, service, where, { store = null } = {}) {
|
||||
if (!credentialKind(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
|
||||
if (!SERVICES.includes(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
|
||||
requireObject(ref, where);
|
||||
// Row 210 (#1707, design D1): an ssh key doesn't expire, so host.<id> takes rotateBy as gitea does.
|
||||
const dateKey = isHostKind(service) ? "rotateBy" : DATE_KEY[service];
|
||||
const dateKey = DATE_KEY[service];
|
||||
rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where);
|
||||
const hasFile = ref.file !== undefined;
|
||||
const hasEnv = ref.env !== undefined;
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
// Row 210 (#1707, design D3 and D6): the address, user and host key checks, and the argv allowlist the
|
||||
// broker matches each host.run against. Row 234 (#1740, r6 S1) moved the host registry and the grants
|
||||
// out of the business file into bus store records. The enrollment raise checks its fields with these,
|
||||
// and row b's host grant raise checks its commands with checkCommands.
|
||||
|
||||
import { isIP } from "node:net";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { ARGV_MAX } from "./vocabulary.mjs";
|
||||
// Row 210 r4 (#1707): the argv check lives in vocabulary.mjs, which the worker sandbox mounts
|
||||
// and which imports nothing, so the broker can read it. Re-exported so the D6 checks keep one
|
||||
// business home here.
|
||||
export { ARGV_MAX, ARG_BYTES, ARGV_BYTES, validArgv, commandAllowed } from "./vocabulary.mjs";
|
||||
|
||||
const DNS = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/;
|
||||
export const USER = /^[a-z_][a-z0-9_-]{0,31}$/;
|
||||
export const HOST_KEY = /^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
// D6: a prefix starting with one of these, by basename, would turn the grant back into "any".
|
||||
// The list can't be complete. Under PRD 1.7 the control is the human's decision on each call (D4).
|
||||
export const REFUSED_COMMANDS = Object.freeze([
|
||||
"sh", "bash", "dash", "zsh", "ksh", "fish", "busybox",
|
||||
"su", "sudo", "doas", "pkexec", "runuser", "env", "xargs", "nohup", "setsid", "timeout", "nice",
|
||||
"systemd-run", "chroot", "nsenter", "unshare", "setpriv", "capsh", "flock", "stdbuf", "ionice", "chrt",
|
||||
"taskset", "strace", "script", "watch",
|
||||
"perl", "ruby", "node", "sed", "vi", "vim", "less",
|
||||
]);
|
||||
// Versioned names: python, python3, python3.12; awk, gawk, mawk, nawk; php, php8.2.
|
||||
const REFUSED_FORMS = Object.freeze([/^python[0-9.]*$/, /^[gmn]?awk$/, /^php[0-9.]*$/]);
|
||||
|
||||
export function refusedCommand(first) {
|
||||
const base = first.split("/").pop();
|
||||
return REFUSED_COMMANDS.includes(base) || REFUSED_FORMS.some((form) => form.test(base));
|
||||
}
|
||||
|
||||
// D3 step 2: true when the address is this machine. A host grant there would be root on the
|
||||
// stack's own machine (section 6).
|
||||
export function ownMachine(address) {
|
||||
const version = isIP(address);
|
||||
if (version === 4) {
|
||||
const first = Number(address.split(".")[0]);
|
||||
return first === 127 || first === 0;
|
||||
}
|
||||
if (version === 6) {
|
||||
// The URL parser writes the canonical form, so 0:0:0:0:0:0:0:1 reads as [::1] and
|
||||
// ::ffff:127.0.0.1 as [::ffff:7f00:1].
|
||||
const canonical = new URL(`http://[${address}]`).hostname;
|
||||
return canonical === "[::1]" || canonical === "[::]" || /^\[::ffff:(7f[0-9a-f]{2}|[0-9a-f]{1,2}):/.test(canonical);
|
||||
}
|
||||
return address === "localhost" || address.endsWith(".localhost");
|
||||
}
|
||||
|
||||
export function checkAddress(address, where) {
|
||||
if (typeof address !== "string") refuse(`${where} must be an IP address or a DNS name`);
|
||||
const version = isIP(address);
|
||||
if (version === 6 && address.includes("%")) refuse(`${where} must not carry an IPv6 zone`);
|
||||
if (version === 0) {
|
||||
if (!DNS.test(address) || address.includes("..")) refuse(`${where} must be an IP address or a DNS name`);
|
||||
// 127.1, 2130706433 and 0177.0.0.1 pass the DNS pattern and the resolver reads them as IPv4.
|
||||
if (/^[0-9.]+$/.test(address)) refuse(`${where} must be a full four-part IPv4 address`);
|
||||
if (address.split(".").some((label) => label.startsWith("0x"))) refuse(`${where} must not hold a hex label`);
|
||||
}
|
||||
if (ownMachine(address)) refuse(`${where} names this machine (${address})`);
|
||||
return address;
|
||||
}
|
||||
|
||||
// D6: an argv allowlist and nothing else. No wildcard: "any", "*", {} and [] all refuse.
|
||||
export function checkCommands(commands, where) {
|
||||
if (!Array.isArray(commands)) refuse(`${where} must be a list of argv prefixes; there is no wildcard grant`);
|
||||
if (commands.length === 0) refuse(`${where} must not be empty`);
|
||||
const seen = new Set();
|
||||
return commands.map((prefix, i) => {
|
||||
const at = `${where}[${i}]`;
|
||||
if (!Array.isArray(prefix) || prefix.length === 0) refuse(`${at} must be a non-empty argv prefix`);
|
||||
if (prefix.length > ARGV_MAX) refuse(`${at} has more than ${ARGV_MAX} elements`);
|
||||
for (const element of prefix) {
|
||||
if (typeof element !== "string" || element === "" || element.includes("\0")) refuse(`${at} elements must be non-empty strings`);
|
||||
if (/[*?[]/.test(element)) refuse(`${at} must not hold *, ? or [; the match is literal`);
|
||||
}
|
||||
if (prefix[0].includes("/") && !prefix[0].startsWith("/")) refuse(`${at}[0] must be a bare name or an absolute path`);
|
||||
if (refusedCommand(prefix[0])) refuse(`${at} starts with ${prefix[0]}, a shell, runner or interpreter, which would grant any command`);
|
||||
const key = JSON.stringify(prefix);
|
||||
if (seen.has(key)) refuse(`${at} repeats a prefix`);
|
||||
seen.add(key);
|
||||
return [...prefix];
|
||||
});
|
||||
}
|
||||
@@ -6,12 +6,12 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { organizationWriter, OrganizationError } from "./organization.mjs";
|
||||
import { validateBusinessDocument, businessFilePath } from "./business.mjs";
|
||||
import { checkRoutes, creativeHumanOnly } from "./launch-settings.mjs";
|
||||
import { checkRoutes } from "./launch-settings.mjs";
|
||||
import { loadProject, projectFilePath } from "./project.mjs";
|
||||
import { resolveInstance } from "./resolve.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { isPlainObject, canonicalJson } from "./util.mjs";
|
||||
import { CREATIVE_DEFINITIONS, END_PROTECTED, ID_PATTERN } from "./vocabulary.mjs";
|
||||
import { END_PROTECTED, ID_PATTERN } from "./vocabulary.mjs";
|
||||
|
||||
const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch"];
|
||||
const id = value => typeof value === "string" && ID_PATTERN.test(value);
|
||||
@@ -19,12 +19,8 @@ const id = value => typeof value === "string" && ID_PATTERN.test(value);
|
||||
// A definition this writer can give an instance: version 2, not END_PROTECTED, and declaring no
|
||||
// credential. An instance of one that declares credentials needs credentials and a tracker bot,
|
||||
// which stay out of this row (sage-0918); the refusal names that, so a later row lifts it alone.
|
||||
// Row 213 (C2-08, M10): a creative definition is END_PROTECTED, yet this writer is how the human
|
||||
// adds and changes one, so it isn't closed here. The caller runs creativeHumanOnly first.
|
||||
const closed = name => END_PROTECTED.includes(name) && !CREATIVE_DEFINITIONS.includes(name);
|
||||
|
||||
function definitionFor(rolesDir, name) {
|
||||
if (closed(name)) throw new OrganizationError("organization-definition-refused");
|
||||
if (END_PROTECTED.includes(name)) throw new OrganizationError("organization-definition-refused");
|
||||
const definition = loadRole(rolesDir, name);
|
||||
if (definition.roleVersion !== 2) throw new OrganizationError("invalid-request", `role definition ${name} is version 1`);
|
||||
if (definition.credentials.length > 0) {
|
||||
@@ -53,7 +49,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
}
|
||||
if (definition.roleVersion !== 2) continue;
|
||||
const services = definition.credentials.map(c => c.service);
|
||||
out[name] = { services, accepted: !closed(name) && services.length === 0 };
|
||||
out[name] = { services, accepted: !END_PROTECTED.includes(name) && services.length === 0 };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -67,7 +63,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
vars: entry.vars ?? {},
|
||||
launched: launch ? launch.instances.includes(name) : false,
|
||||
launcher: launch ? launch.by === name : false,
|
||||
protected: closed(entry.definition),
|
||||
protected: END_PROTECTED.includes(entry.definition),
|
||||
}]));
|
||||
return { business, revision, launch: launch !== null, instances, definitions: definitions() };
|
||||
}
|
||||
@@ -101,7 +97,6 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
if (op === "add") {
|
||||
if (!Object.hasOwn(args, "definition")) throw new OrganizationError("invalid-request");
|
||||
if (exists) throw new OrganizationError("organization-instance-exists");
|
||||
creativeHumanOnly(old, actor, instance, args.definition);
|
||||
definitionFor(rolesDir, args.definition);
|
||||
next.roles[instance] = { definition: args.definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
|
||||
redefined = true;
|
||||
@@ -110,12 +105,9 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
|
||||
if (!exists) throw new OrganizationError("organization-unknown-instance");
|
||||
const entry = next.roles[instance];
|
||||
// An assistant's settings change only through limits.set's assistants key (row 155), so
|
||||
// nothing here touches a protected instance, its vars and launch membership included. A
|
||||
// creative instance is the exception: the human changes it here, and only the human (M10).
|
||||
creativeHumanOnly(old, actor, instance, entry.definition);
|
||||
if (closed(entry.definition)) throw new OrganizationError("organization-definition-refused");
|
||||
// nothing here touches a protected instance, its vars and launch membership included.
|
||||
if (END_PROTECTED.includes(entry.definition)) throw new OrganizationError("organization-definition-refused");
|
||||
if (Object.hasOwn(args, "definition") && args.definition !== entry.definition) {
|
||||
creativeHumanOnly(old, actor, instance, args.definition);
|
||||
definitionFor(rolesDir, args.definition);
|
||||
// The old definition's credentials would bind to the new one's authority.
|
||||
if (loadRole(rolesDir, entry.definition).credentials.length > 0) {
|
||||
|
||||
@@ -5,16 +5,7 @@ import { resolveInstance } from "./resolve.mjs";
|
||||
import { usageCounts } from "./launch.mjs";
|
||||
import { canonicalJson, isPlainObject, requireObject, rejectUnknownKeys } from "./util.mjs";
|
||||
import { FALLBACK_VARS } from "./vars.mjs";
|
||||
import { THREAD_DEFINITION, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
|
||||
|
||||
// Row 213 (C2-08, Case 2 design 4, M10): a creative instance, its persona included, is the
|
||||
// human's. A write that touches one is accepted only when its actor is the business file's
|
||||
// human; the broker sets the actor to the human's id, "tester" or <definition>.<instance>.
|
||||
export function creativeHumanOnly(document, actor, instance, definition) {
|
||||
if (CREATIVE_DEFINITIONS.includes(definition) && actor !== document.human) {
|
||||
throw new OrganizationError("organization-creative-human-only", `${instance} is a ${definition} instance, which only the human changes`);
|
||||
}
|
||||
}
|
||||
import { THREAD_DEFINITION } from "./vocabulary.mjs";
|
||||
|
||||
// Row 155 (#1649, Jason's ruling Q169): the fields of an assistant instance's entry the human and
|
||||
// the chief of staff may change. holder and credentials stay hand edits.
|
||||
@@ -175,30 +166,13 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
stamp(next.launch.limits, old.document.launch.limits, at);
|
||||
}
|
||||
if (Object.hasOwn(args, "reset")) reset(next.launch.limits, args.reset, at);
|
||||
if (Object.hasOwn(args, "models")) {
|
||||
// M10: replacing launch.models can change the model behind a key a creative instance names
|
||||
// without touching the instance, so a change to or removal of such a key is the human's.
|
||||
for (const [instance, entry] of Object.entries(old.document.roles)) {
|
||||
const vars = entry.vars ?? {};
|
||||
for (const key of [vars.model, vars["fallback.model"]]) {
|
||||
if (typeof key !== "string") continue;
|
||||
const was = own(old.document.launch.models, key) ? canonicalJson(old.document.launch.models[key]) : null;
|
||||
const is = own(args.models, key) ? canonicalJson(args.models[key]) : null;
|
||||
if (was !== is) creativeHumanOnly(old.document, actor, instance, entry.definition);
|
||||
}
|
||||
}
|
||||
next.launch.models = args.models;
|
||||
}
|
||||
if (Object.hasOwn(args, "models")) next.launch.models = args.models;
|
||||
if (Object.hasOwn(args, "instances")) {
|
||||
requireObject(args.instances, "instance model selections");
|
||||
for (const [instance, key] of Object.entries(args.instances)) {
|
||||
if (!Object.hasOwn(next.roles, instance) || typeof key !== "string") {
|
||||
throw new OrganizationError("invalid-request");
|
||||
}
|
||||
// M10: a creative instance's model changes through instances.set or routes.set, whoever asks.
|
||||
if (CREATIVE_DEFINITIONS.includes(next.roles[instance].definition)) {
|
||||
throw new OrganizationError("organization-creative-instance", `${instance}'s model changes through instances.set or routes.set`);
|
||||
}
|
||||
next.roles[instance].vars = { ...next.roles[instance].vars, model: key };
|
||||
}
|
||||
}
|
||||
@@ -276,7 +250,6 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
const exact = (value, keys) => isPlainObject(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
||||
for (const [instance, route] of Object.entries(args.routes)) {
|
||||
if (!Object.hasOwn(next.roles, instance)) throw new OrganizationError("organization-unknown-instance");
|
||||
creativeHumanOnly(old.document, actor, instance, next.roles[instance].definition);
|
||||
if (!exact(route, [...ROUTE_FIELDS, "fallback"]) || ROUTE_FIELDS.some(key => typeof route[key] !== "string")) {
|
||||
throw new OrganizationError("invalid-request");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// layer wins. Limits: every layer narrows, nothing widens.
|
||||
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { ACTIONS, NETWORKS, isHostKind } from "./vocabulary.mjs";
|
||||
import { ACTIONS, NETWORKS } from "./vocabulary.mjs";
|
||||
import { checkVars, mergeVars } from "./vars.mjs";
|
||||
import { canonicalJson, sha256, deepFreeze } from "./util.mjs";
|
||||
import { projectFilePath } from "./project.mjs";
|
||||
@@ -87,9 +87,7 @@ export function resolveInstance({ system, business, project = null, instance })
|
||||
vars,
|
||||
provenance,
|
||||
limits: { tools, network, authority: { withinRole, crossRole } },
|
||||
// Row 210 (#1707, design D5): a host key is used only by the bus child, never by the session,
|
||||
// so it stays out of the session's credentials and the harness's needs.
|
||||
credentials: Object.fromEntries(Object.entries(entry.credentials).filter(([kind]) => !isHostKind(kind))),
|
||||
credentials: entry.credentials,
|
||||
tracker: entry.tracker,
|
||||
launch,
|
||||
// REQ-HARN-2: the search-source plug-in the organization file switches on, by name.
|
||||
|
||||
@@ -8,8 +8,7 @@ import { lstatSync } from "node:fs";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import {
|
||||
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, HOST_SERVICE, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
CREATIVE_DEFINITIONS, CREATIVE_TOOLS,
|
||||
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
} from "./vocabulary.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
@@ -108,15 +107,10 @@ function checkCredentials(credentials) {
|
||||
const seen = new Set();
|
||||
return credentials.map((entry, i) => {
|
||||
requireObject(entry, `role credentials[${i}]`);
|
||||
// Row 210 (#1707, design D1): "host" takes no scopes; the business file names the hosts.
|
||||
const host = entry.service === HOST_SERVICE;
|
||||
rejectUnknownKeys(entry, host ? ["service"] : ["service", "scopes"], `role credentials[${i}]`);
|
||||
if (!host && !SERVICES.includes(entry.service)) {
|
||||
refuse(`role credentials[${i}].service must be one of: ${[...SERVICES, HOST_SERVICE].join(", ")}`);
|
||||
}
|
||||
rejectUnknownKeys(entry, ["service", "scopes"], `role credentials[${i}]`);
|
||||
if (!SERVICES.includes(entry.service)) refuse(`role credentials[${i}].service must be one of: ${SERVICES.join(", ")}`);
|
||||
if (seen.has(entry.service)) refuse(`role credentials name ${entry.service} twice`);
|
||||
seen.add(entry.service);
|
||||
if (host) return { service: HOST_SERVICE };
|
||||
const scopes = entry.service === "gitea" ? checkGiteaScopes(entry.scopes) : checkVikunjaScopes(entry.scopes);
|
||||
return { service: entry.service, scopes };
|
||||
});
|
||||
@@ -137,25 +131,6 @@ function checkContract(contract, file) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Row 213 (C2-08, Case 2 design 4, M1): a creative role only reads and answers the human. Its file
|
||||
// may name no tool outside CREATIVE_TOOLS, no authority but message.send, and must carry a messaging
|
||||
// key that names only the human, so it can't relay through another role.
|
||||
function checkCreative(role) {
|
||||
const wider = role.tools.filter((tool) => !CREATIVE_TOOLS.includes(tool));
|
||||
if (wider.length > 0) refuse(`creative role ${role.name} may not name tools ${wider.join(", ")} (allowed: ${CREATIVE_TOOLS.join(", ")})`);
|
||||
const actions = [...role.authority.withinRole, ...role.authority.crossRole];
|
||||
if (role.authority.crossRole.length > 0 || actions.some((a) => a !== "message.send")) {
|
||||
refuse(`creative role ${role.name} may hold only withinRole message.send`);
|
||||
}
|
||||
if (role.messaging === null) refuse(`creative role ${role.name} requires "messaging"`);
|
||||
for (const key of ["sendTo", "receiveFrom"]) {
|
||||
if (role.messaging[key].some((entry) => entry !== "human")) {
|
||||
refuse(`creative role ${role.name} messaging.${key} may name only human`);
|
||||
}
|
||||
}
|
||||
return role;
|
||||
}
|
||||
|
||||
// Validate a parsed role document read from `file`. Returns a frozen role:
|
||||
// { roleVersion, name, title, contract, contractPath, tools, network,
|
||||
// authority: { withinRole, crossRole }, credentials: [{ service, scopes }],
|
||||
@@ -169,7 +144,6 @@ export function validateRoleDocument(document, file) {
|
||||
if (document.name !== base) refuse(`role "name" (${document.name}) must match its filename (${base}.json)`);
|
||||
const tools = checkTools(document.tools);
|
||||
if (document.roleVersion === 1) {
|
||||
if (CREATIVE_DEFINITIONS.includes(document.name)) refuse(`creative role ${document.name} must be version 2`);
|
||||
const network = document.network === undefined ? "none" : checkNetwork(document.network);
|
||||
return deepFreeze({
|
||||
roleVersion: 1, name: document.name, title: null, contract: null, contractPath: null, tools, network,
|
||||
@@ -179,7 +153,7 @@ export function validateRoleDocument(document, file) {
|
||||
for (const key of V2_REQUIRED) {
|
||||
if (document[key] === undefined) refuse(`role version 2 requires "${key}"`);
|
||||
}
|
||||
const role = {
|
||||
return deepFreeze({
|
||||
roleVersion: 2,
|
||||
name: document.name,
|
||||
title: requireString(document.title, 'role "title"', { max: 80 }),
|
||||
@@ -191,8 +165,7 @@ export function validateRoleDocument(document, file) {
|
||||
credentials: checkCredentials(document.credentials),
|
||||
messaging: document.messaging === undefined ? null : checkMessaging(document.messaging),
|
||||
skills: document.skills === undefined ? [] : checkSkills(document.skills),
|
||||
};
|
||||
return deepFreeze(CREATIVE_DEFINITIONS.includes(role.name) ? checkCreative(role) : role);
|
||||
});
|
||||
}
|
||||
|
||||
export function loadRoleFile(file) {
|
||||
|
||||
@@ -145,18 +145,6 @@ export const REGISTRY = Object.freeze({
|
||||
"fallback.harness": def(["agent"], moduleName),
|
||||
"fallback.model": def(["agent"], string(200)),
|
||||
"fallback.thinking": def(["agent"], oneOf(THINKING)),
|
||||
// Row 213 (C2-08, Case 2 design 4): a creative instance's persona. The name the user sees, one line,
|
||||
// and the sha256 of <configDir>/personas/<business>/<instance>.md. The text stays out of the
|
||||
// business file; the bundle reads the file at launch and refuses it off this digest.
|
||||
"persona.name": def(["agent"], (v, key) => {
|
||||
requireString(v, `variable ${key}`, { max: 40 });
|
||||
if (/[\u0000-\u001f\u007f\u2028\u2029]/.test(v)) refuse(`variable ${key} must be one line with no control characters`);
|
||||
return v;
|
||||
}),
|
||||
"persona.sha256": def(["agent"], (v, key) => {
|
||||
if (typeof v !== "string" || !/^[0-9a-f]{64}$/.test(v)) refuse(`variable ${key} must be 64 lowercase hex characters`);
|
||||
return v;
|
||||
}),
|
||||
|
||||
// Limits narrow the role definition's ceilings. limits.authority is an
|
||||
// allowlist: a role action it doesn't name becomes gated.
|
||||
|
||||
@@ -23,9 +23,6 @@ export const ACTIONS = Object.freeze([
|
||||
// Row 199 (#1697, mos-1027 Q-M2): the host pushes an agent's recorded work commit to the fork its
|
||||
// business grants it. A hard wait under REQ-WAIT-3 until a PRD version routes it.
|
||||
"git.push.remote",
|
||||
// Row 210 (#1707, design D4): an agent runs a command with sudo on a host its business grants it.
|
||||
// A hard wait under REQ-WAIT-3 until a PRD version routes it, so Jason decides each call.
|
||||
"host.sudo",
|
||||
"review.request",
|
||||
"review.verdict",
|
||||
"record.write",
|
||||
@@ -90,7 +87,7 @@ export const ROUTED = Object.freeze([
|
||||
// cited spec or not. A role file may not list these under withinRole or
|
||||
// crossRole (REQ-WAIT-2). Today this is spend, data.delete, network.expose,
|
||||
// account.create, credential.mint, message.external, git.push.protected,
|
||||
// git.push.remote, host.sudo (row 210) and deploy; a test pins the list.
|
||||
// git.push.remote and deploy; a test pins the list.
|
||||
export const GATED_ONLY = Object.freeze(ACTIONS.filter((action) => !ROUTED.includes(action)));
|
||||
|
||||
// pi's documented built-in tools; the same list scripts/mosaic-task.mjs
|
||||
@@ -108,41 +105,6 @@ export const NETWORKS = Object.freeze(["none", "api-only", "open"]);
|
||||
|
||||
export const SERVICES = Object.freeze(["gitea", "vikunja"]);
|
||||
|
||||
// Row 210 (#1707, design D1): a role definition declares {"service": "host"} once, and the business
|
||||
// file binds each host. A host key's kind is host.<hostId>, one key per agent and host.
|
||||
export const HOST_SERVICE = "host";
|
||||
export const HOST_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
||||
export const HOST_PREFIX = "host.";
|
||||
export const isHostKind = (name) =>
|
||||
typeof name === "string" && name.startsWith(HOST_PREFIX) && HOST_ID.test(name.slice(HOST_PREFIX.length));
|
||||
// Every place that checks a credential kind asks this, so the closed set has one definition.
|
||||
export const credentialKind = (name) => SERVICES.includes(name) || isHostKind(name);
|
||||
|
||||
// Row 210 r4 (#1707): the broker checks argv with these. They sit here, not in hosts.mjs,
|
||||
// because broker.mjs runs in the worker sandbox and reads only mounted files that need nothing else.
|
||||
export const ARGV_MAX = 256;
|
||||
export const ARG_BYTES = 4096;
|
||||
export const ARGV_BYTES = 32768;
|
||||
|
||||
// D6: a call's argv. 1 to 256 strings, each 1 to 4096 bytes with no NUL, 32768 bytes in all, and
|
||||
// argv[0] a bare name or an absolute path. Also the broker's check, at raise and at call.
|
||||
export function validArgv(argv) {
|
||||
if (!Array.isArray(argv) || argv.length === 0 || argv.length > ARGV_MAX) return false;
|
||||
let total = 0;
|
||||
for (const element of argv) {
|
||||
if (typeof element !== "string" || element === "" || element.includes("\0")) return false;
|
||||
const bytes = Buffer.byteLength(element);
|
||||
if (bytes > ARG_BYTES) return false;
|
||||
total += bytes;
|
||||
}
|
||||
return total <= ARGV_BYTES && (!argv[0].includes("/") || argv[0].startsWith("/"));
|
||||
}
|
||||
|
||||
// True when argv starts with one of the grant's prefixes, element by element, literally.
|
||||
export function commandAllowed(argv, commands) {
|
||||
return commands.some((prefix) => prefix.length <= argv.length && prefix.every((element, i) => element === argv[i]));
|
||||
}
|
||||
|
||||
// Gitea token scopes are <read|write>:<category>. "all" and the admin
|
||||
// category are never granted to a role.
|
||||
export const GITEA_SCOPE_CATEGORIES = Object.freeze([
|
||||
@@ -170,29 +132,10 @@ export const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
// Row 121 (#1615): an instance of this definition talks with the user in threads (REQ-THREAD-1).
|
||||
// Row 155 (#1649) moves it here from the broker, so the organization writer reads the same name.
|
||||
export const THREAD_DEFINITION = "assistant";
|
||||
|
||||
// Row 213 (C2-08, #1714, Case 2 design 4): the creative roles. Each only reads and answers the human
|
||||
// in cited items; its instances are the human's alone (M10).
|
||||
export const CREATIVE_DEFINITIONS = Object.freeze(["companion", "ideas", "research", "proofreader", "fact-checker", "continuity"]);
|
||||
// The reply item kinds each creative definition may send (design 4, 5.2's reply-kind check).
|
||||
export const CREATIVE_KINDS = Object.freeze({
|
||||
companion: Object.freeze(["idea", "question", "finding", "fact"]),
|
||||
ideas: Object.freeze(["idea", "question"]),
|
||||
research: Object.freeze(["fact", "question"]),
|
||||
proofreader: Object.freeze(["correction", "question"]),
|
||||
"fact-checker": Object.freeze(["finding", "question"]),
|
||||
continuity: Object.freeze(["finding", "question"]),
|
||||
});
|
||||
// The only tools a creative role file may name (M1): reading, and the broker's web grant.
|
||||
export const CREATIVE_TOOLS = Object.freeze(["read", "grep", "find", "ls", "web"]);
|
||||
// The definitions that may hold a thread: the assistant and the creative roles. The definition the
|
||||
// chief of staff may set stays THREAD_DEFINITION.
|
||||
export const THREAD_DEFINITIONS = Object.freeze([THREAD_DEFINITION, ...CREATIVE_DEFINITIONS]);
|
||||
// Row 139 (#1633), Jason's ruling Q169 (PRD 1.6): definitions only the chief of staff, or a decision
|
||||
// the human resolved through the CLI, may end or revoke. Row 155 moves the list here, and no
|
||||
// organization write may give an instance one of them, except the human's instances.set for a
|
||||
// creative definition (row 213).
|
||||
export const END_PROTECTED = THREAD_DEFINITIONS;
|
||||
// organization write may give an instance one of them.
|
||||
export const END_PROTECTED = Object.freeze([THREAD_DEFINITION]);
|
||||
|
||||
// Row 199 (#1697, design D9c): the Gitea URL parse. It lives here because the broker reads it and
|
||||
// this file imports nothing. Gitea's name rule for users and repositories: letters, digits, "_", "."
|
||||
|
||||
@@ -159,11 +159,10 @@ test("the contract passes work to the chief of staff and directs no one", () =>
|
||||
assert.doesNotMatch(text, /to\s+the\s+role\s+it\s+concerns/);
|
||||
});
|
||||
|
||||
// Row 213 (C2-08, Case 2 design 4): companion and research hold it too, for the broker's web tools.
|
||||
test("REQ-HARN-2: the assistant, companion and research hold the web grant, and no other role file does", () => {
|
||||
test("REQ-HARN-2: the assistant holds the web grant, and no other role file does", () => {
|
||||
const names = readdirSync(REPO_ROLES).filter((f) => f.endsWith(".json") && f !== "conductor-policy.json").map((f) => f.slice(0, -5));
|
||||
const granted = names.filter((name) => loadRole(REPO_ROLES, name).tools.includes("web"));
|
||||
assert.deepEqual(granted, ["assistant", "companion", "research"]);
|
||||
assert.deepEqual(granted, ["assistant"]);
|
||||
for (const name of ["coder", "reviewer", "chief-of-staff", "pm", "cto", "researcher"]) {
|
||||
assert.ok(!loadRole(REPO_ROLES, name).tools.includes("web"), name);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user