Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86963d2816 | ||
|
|
7c3cdd9406 | ||
|
|
116eeec48c | ||
|
|
5fa7b25532 | ||
|
|
c6868ba413 | ||
|
|
ff22fadebf | ||
|
|
424bc67160 | ||
|
|
148044ef3f | ||
|
|
8d0f913a82 | ||
|
|
b415d4e765 | ||
|
|
c80a238534 | ||
|
|
48c2757f36 | ||
|
|
0103d08fbd | ||
|
|
4996380126 | ||
|
|
a9c59ee5ac | ||
|
|
12aea3416a | ||
|
|
e2f3208258 | ||
|
|
b8e10315d9 | ||
|
|
1cec552b8b | ||
|
|
297259d3e5 | ||
|
|
c33bc66802 | ||
|
|
f04bb00b40 | ||
|
|
20fa2595ea | ||
|
|
090cbb9629 | ||
|
|
627979a173 | ||
|
|
00d4cc1f69 | ||
|
|
99a406ed3c | ||
|
|
d0bbc5c1cb | ||
|
|
c9f374284b | ||
|
|
4385b9ec18 | ||
|
|
afad4993f6 | ||
|
|
f41729178e | ||
|
|
305857c8f9 | ||
|
|
176a6bf4b9 | ||
|
|
5b8def2f14 | ||
|
|
4429a104f6 | ||
|
|
32b54ac3b0 | ||
|
|
6025ddd9cf | ||
|
|
ecbfd6deb6 | ||
|
|
0498c0613f | ||
|
|
49d1f3a231 | ||
|
|
628e2ca5cd |
@@ -25,10 +25,15 @@ green at every step. Not production software — a proven foundation.
|
||||
1. **Root is bootstrap-only.** First-class system configuration lives at the
|
||||
repository root; everything else gets a dedicated directory (`roles/`,
|
||||
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
|
||||
2. **Configuration**: `~/.config/mosaic-dev/config.json` is the sole system
|
||||
config — created only by `scripts/bootstrap.sh`, never overwritten,
|
||||
fail-closed on any problem. Repo-scoped role authority lives in
|
||||
`roles/*.json` (versioned, reviewed commits only).
|
||||
2. **Configuration**: each stack instance has exactly one system config. The
|
||||
default instance's is `~/.config/mosaic-dev/config.json`. Instance NAME's
|
||||
is `~/.config/mosaic-NAME/config.json`, and its data root carries a marker
|
||||
that names it. `~` is the home of the OS user that runs the instance. A
|
||||
process reads only the config `MOSAIC_CONFIG` names, or the default's when
|
||||
that is unset. Configs are created only by
|
||||
`scripts/bootstrap.sh [--instance NAME]`, never overwritten, fail-closed on
|
||||
any problem. Repo-scoped role authority lives in `roles/*.json`
|
||||
(versioned, reviewed commits only).
|
||||
3. **Secrets** never enter the repository or container images; auth is
|
||||
runtime-only (read-only mount or environment variable).
|
||||
4. **Contracts** (`contracts/`) are immutable and image-baked. Missions and
|
||||
@@ -177,8 +182,8 @@ Full reference — usage, fields, exit codes, safety notes:
|
||||
|
||||
## Data map (canon)
|
||||
|
||||
- `~/.config/mosaic-dev/config.json` — system config (user-authored; never
|
||||
auto-written).
|
||||
- `~/.config/mosaic-dev/config.json`, or `~/.config/mosaic-NAME/config.json`
|
||||
per named instance: system config (user-authored; never auto-written).
|
||||
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
|
||||
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
|
||||
- `sessions/` — pi JSONL session trees, one directory per named session
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Gosalyn
|
||||
|
||||
Worker seat for Mosaic Stack, MVP track T2 (Case 2, the creative writing hub, Vikunja #416).
|
||||
|
||||
- T3 thread: 7604438a-0ded-41c8-bd3f-26f57fdb5cd2
|
||||
- Model: Opus 5.5 (Claude Code, T3)
|
||||
- Launched by Mos (Jason's main agent, T3 38bc4c81-6270-4021-b00c-6a15c43b9b0e), who briefs the seat
|
||||
- Repository lead and queue: Sage (T3 1ef1e4f8-3ead-4208-beca-38f9f1add079)
|
||||
- Branch: gosalyn/case2-design, pushed only by this seat, landed on refactor by Sage
|
||||
|
||||
Work lives in `work/`. The Case 2 design is `work/case2/DESIGN.md`.
|
||||
@@ -0,0 +1,216 @@
|
||||
# Row 199 (#1697): per-agent credential store, each agent's own git identity, publish to its fork
|
||||
|
||||
Builder: Megavolt. Brief: `docs/plans/2026-10-20_agent-credentials.md`. Design: `DESIGN.md` r6 at
|
||||
4d3007ec9, approved by Duckworth in duckworth-0027 (REVIEW-r6.md at 640011518). Authority model ruled
|
||||
by Mos in mos-1027. Branch: megavolt/row199. Reviewer: Duckworth.
|
||||
|
||||
Status: candidate on megavolt/row199-on-190, rebased on origin/refactor 00d4cc1f6 (sage-1555). The
|
||||
full gate and the section 11 mutants ran in Sage's slot; results under "Runs".
|
||||
|
||||
## Commits so far
|
||||
|
||||
- 286bf598c business: agent identity, the publish grant, the project upstream with the D9c parse, store
|
||||
credential references; git.push.remote as a hard wait.
|
||||
- a2c387dba bus: broker publish authority (checkPublish, recordPublish, publishRefused, pushCommit on
|
||||
the raise); the D9c parse moves to `business/src/vocabulary.mjs`; pm.md and chief-of-staff.md list
|
||||
git.push.remote among the hard waits.
|
||||
- 609dc9364 bus: the per-agent credential store in `credentials.mjs`.
|
||||
- 504336797 bus: runtime wiring, the store root and the four publish entries in the op table.
|
||||
- a18fc6cf8 tasks: request calls use `useFor` keyed from the session; the start probe keeps `use`. The
|
||||
D5 test in `credential-store.test.mjs` pins the `use(` call sites to `credentials.mjs` (useFor's own
|
||||
call) and `tasks/src/adapter.mjs` (the host's @sync and start probe).
|
||||
- aa740cc71 bus: the lifecycle verbs `credentials.issue`, `credentials.revoke` and `credentials.delete`
|
||||
in `credential-verbs.mjs`, the broker branch, the runtime wiring; `organizationWriter` gets a
|
||||
`detail` on `write` and a receipt-only `record`.
|
||||
- 0cdace415 cli: `mosaic credentials status|issue|rotate|revoke|delete`, the scripts/mosaic route, docs.
|
||||
|
||||
## Deviations from DESIGN.md r6
|
||||
|
||||
1. **Identity is optional on an agent.** D8 reads as identity on every agent. Today's business files
|
||||
carry none, so identity stays optional and `publish` requires it (N10's load refusal). An agent
|
||||
without identity commits as E7 (T6).
|
||||
2. **Revoke of a `{file}` or `{env}` reference.** Section 6 describes revoke for store references.
|
||||
For a reference outside the store, revoke forgets the token in memory and never unlinks the file;
|
||||
the lifecycle verb then turns the business file's reference into `store`, so a restart finds
|
||||
nothing. The test: `credential-store.test.mjs`, "revoke of a {file} reference".
|
||||
3. **Issue order.** Issue writes the token file (tmp, fsync, rename, dir fsync), then runs the
|
||||
caller's commit (the business file's reference becomes `store`), then sets memory. A failed commit
|
||||
leaves the file on disk and the token out of memory, so the agent stays `not-issued` until the
|
||||
issue is run again. The test: "a failed commit leaves the token on disk but not in memory".
|
||||
4. **The D9c parse lives in `business/src/vocabulary.mjs`, not `business.mjs`.** The broker reads
|
||||
`forkProblem` and `forkUrl`, and `project-runtime.test.mjs` pins the broker's import closure to
|
||||
files that import nothing. `business.mjs` wraps `parseGiteaUrl` with the loader's refusal
|
||||
messages. The pin test names the two new imports.
|
||||
5. **`withGitea` takes the publish ticket, not a cap (D9b).** The key is the session checkPublish
|
||||
checked with `#agent` (a run holding its claim; never a tester, reader or human), read through
|
||||
`broker.publishSession(ticket)`. The token is usable only between checkPublish and recordPublish
|
||||
or publishRefused. Its refusals are filed once, by publishRefused on the task's trail, and not
|
||||
also by `refusing()`. The test: `publish.test.mjs`, "runtime: withGitea keys from the open ticket
|
||||
only".
|
||||
6. **Role contracts.** `workflow-roles.test.mjs` requires the hard waits listed in `roles/pm.md` and
|
||||
`roles/chief-of-staff.md` to equal GATED_ONLY. Adding git.push.remote to the vocabulary made it
|
||||
fail, so both lists now name git.push.remote. Prose only; no hash pin reads either file. Duckworth
|
||||
should review this hunk because `roles/` is reviewed authority.
|
||||
|
||||
7. **Delete takes a reason.** Section 6 lists `delete AGENT` with no reason. The broker requires
|
||||
`reason` on delete as on revoke, so every receipt that ends a credential says why.
|
||||
8. **Rotate is `credentials.issue`.** There are three bus verbs, not four. An issue over an entry the
|
||||
bus holds is recorded as `rotate` in the receipt and the answer. The CLI's `rotate` reads the
|
||||
status first and refuses before reading stdin when nothing is issued.
|
||||
9. **Issue covers entries the bus loaded.** The business file must already name the kind for the
|
||||
agent, and the bus must have loaded that reference at start; otherwise `credential-unknown`. An
|
||||
agent added after the bus started needs the restart row 165 already asks for. Row 194's human
|
||||
`create` is where a new agent's `store` references come from.
|
||||
10. **No Console page yet.** Section 6 puts the verbs in the Console's agent page too. This branch has
|
||||
the bus verbs and the CLI. The Console page is a separate piece (packages/webui, its `BUS_VERBS`
|
||||
and `STATUS` pins); I raise the split with Sage before the manifest. Ruled in sage-1421: the
|
||||
Console page is out of row 199 and becomes a follow-up row with Webby as reviewer. The manifest
|
||||
holds bus, business, tasks, cli and seat.
|
||||
11. **The push runs in the bus child, and the git socket polls for it (D9b).** The git socket runs in
|
||||
the host process. The broker, the runtime and the token are in the bus child that
|
||||
`packages/cli/src/host.mjs` forks, and host ops cross that IPC one at a time with a 10 s answer
|
||||
limit. ls-remote and the push can run longer than that, so `withGitea(ticket, fn)` can't wrap a
|
||||
host-side git. The split: the socket runs D9 steps 3 and 4 (tip and identity under the lock,
|
||||
`working.publish`), then op `publishPush`, which starts `publishRemote` in the bus child under
|
||||
`withGitea` and answers `{state: 'running'}`. The socket polls op `publishResult` every 200 ms
|
||||
until it reads `pushed` or `refused`. Fork, user, source and destination come from the ticket
|
||||
(`broker.publishTarget`), never from the host's message. `recordPublish` also refuses `ref-moved`
|
||||
when the pushed ref isn't the ticket's destination. A second `publishPush` on a running ticket
|
||||
refuses `publish-busy`. The token reaches git on fd 3 only (`runGit` option `pass`), read once
|
||||
by a one-shot credential helper that answers `get` for https on the fork's exact host and port.
|
||||
On close, a running publish's git isn't awaited; git's own time limit ends it, and the ticket
|
||||
goes with the process. The 10 s IPC limit stays as it is (sage-1440). The poll has a bound
|
||||
(sage-1440): `publishDeadlineMs`, by default four times git's limit and grace (publishRemote runs
|
||||
at most four git commands) plus 30 s. Past it the socket refuses `publish-timeout`, filed once by
|
||||
publishRefused, and logs that the bus process's push may still reach the fork. A failure that is not a refusal is filed as `git-failed` and logged on
|
||||
the bus's stderr by error name and code only. Tests: `seat/tests/publish.test.mjs` (11),
|
||||
`bus/tests/publish.test.mjs` (the process ops), `cli/tests/publish-socket.test.mjs` (7, end to
|
||||
end through the socket, the in-process runtime, the store and a TLS fake Gitea; one is the
|
||||
publish-timeout outcome).
|
||||
12. **No harness tool for publish.** `packages/harness/src/git-tools.mjs` has push, fetch and land
|
||||
clients. A runner tool that calls the socket's `publish` op is in packages/harness, outside the
|
||||
manifest scope sage-1421 set. Today a publish needs a socket client; the tool is a follow-up.
|
||||
13. **The bind rule's `git.push.remote` clause has no test of its own.** D11 adds it and the
|
||||
launcher has it. A role can publish only a push its own role made (`work-not-yours`), and
|
||||
`recordPush` needs `git.push.working`, which already binds the socket. No role reaches the
|
||||
clause alone, so dropping it changes no outcome today. It matters once a role can publish work
|
||||
it didn't push. Ruled in sage-1440: keep it; its mutant goes in the section 11 list as
|
||||
equivalent, with this reasoning, and Duckworth judges it.
|
||||
14. **A push between checkPublish and the git push refuses; it never lands a different commit.**
|
||||
T3 says such a push "doesn't change the commit that lands". Under the hard wait it refuses:
|
||||
`working.publish` checks the work ref's tip against the ticket's source under the lock
|
||||
(`ref-moved`, `seat/tests/publish.test.mjs`), and `recordPublish` checks `pushCommit` again
|
||||
(`decision-mismatch`, `bus/tests/publish.test.mjs` "r4 B4"). The fork gets only the ticket's
|
||||
oid either way. N9's routing-commit test still applies. Ruled in sage-1440: T3's wording in
|
||||
DESIGN.md now reads "refuses with `ref-moved` or `decision-mismatch`", and an end-to-end test
|
||||
asserts each code mid-flight in `cli/tests/publish-socket.test.mjs`: a push after checkPublish
|
||||
refuses `ref-moved` with no request to the fork; a push recorded while the bus process pushes
|
||||
refuses `decision-mismatch`, filed once, and the fork holds only the ticket's oid.
|
||||
|
||||
15. **Fix: rotate on a due gitea key (gizmoduck-0050, sage-1494).** The CLI's `ISSUED` set
|
||||
(`packages/cli/src/credentials.mjs`) listed valid, expiring and expired, but the bus's `status()`
|
||||
gives a gitea key past its rotate-by date `rotation-due`. So `credentials rotate` refused the one
|
||||
case it exists for. `ISSUED` now names every state status() gives a held token. The test:
|
||||
`cli/tests/credentials.test.mjs`, "rotate goes ahead on each held state". Its mutant (the old set)
|
||||
fails that test and goes in the section 11 list.
|
||||
|
||||
16. **The bus process refuses a boot config carrying `publishTls` (M26, sage-1563).** process.mjs
|
||||
used to drop `publishTls` from the boot config before `startBroker`, and no test could see the
|
||||
drop. The host's boot config (`packages/cli/src/config.mjs`) never carries it; it is the tests'
|
||||
scratch CA for an in-process runtime only. A boot that carries it now refuses
|
||||
`invalid-host-request` and the process exits 2, rather than trusting it or dropping it quietly.
|
||||
The test: `bus/tests/publish.test.mjs`, "the bus process answers the publish ops from their own
|
||||
lines". Every test file that reads process.mjs ran singly, green (see "Runs").
|
||||
17. **M17 is equivalent: `#authorityFor`'s pushCommit comparison is also made by `#publishFacts`.**
|
||||
A git.push.remote raise with no recorded push refuses `no-push` (`broker.mjs` raise,
|
||||
`pushCommit = ... ?? fail('no-push')`), so every publish decision carries a pushCommit.
|
||||
`#publishFacts` takes `source = result.pushCommit` and refuses `decision-mismatch` when it isn't
|
||||
`#latestPush(task_ref).commit`, the same two values `#authorityFor` compares. The check stays as
|
||||
defense in depth, for a decision whose context lacks pushCommit, which no code path writes today.
|
||||
Duckworth judges it, as with M27.
|
||||
|
||||
## N9, for the routing commit (sage-1388)
|
||||
|
||||
Today git.push.remote is a hard wait, so every publish carries a decision, and the decision's
|
||||
`pushCommit` fixes the commit. If a later commit routes git.push.remote within role, a publish has
|
||||
no decision and no `pushCommit`. `#publishFacts` then takes the commit from `#latestPush`, and the
|
||||
git socket's tip check under the lock is the only thing that holds it. That commit needs a test
|
||||
that a push between checkPublish and the git push doesn't change what lands.
|
||||
|
||||
## R1, the backup set
|
||||
|
||||
Checked at 504336797. Row 185's `backup_store` (`scripts/stack-release.sh:372-392`) copies only
|
||||
`<dataRoot>/bus/bus.sqlite`, plus its sums, modes, listing and stamps, into
|
||||
`<dataRoot>/backups/stack/<activation>/`. Nothing under `<configDir>` is copied, so
|
||||
`<configDir>/secrets/agents` isn't in the backup set. `rehearse` copies the live business file
|
||||
through `R_BUSINESS_JS` (`:1268-1288`). Its `swap` replaces every credential reference, a `store`
|
||||
one included, with a placeholder file in the rehearsal's own dir. The guard then refuses
|
||||
`credential-ref-live` unless every reference is such a placeholder (`:1329-1337`), so a rehearsal
|
||||
never reads a store token. A backup tool outside this repository that copies `<configDir>` would
|
||||
copy tokens. That is R1's open half, and it is Jason's to check on each VM.
|
||||
|
||||
## T1 limit
|
||||
|
||||
A wrong owner on a store dir or file can't be set up without root, so T1 covers modes, symlinks and
|
||||
dirs. The owner check (`st.uid !== process.getuid()`) is in `privateDir` and `#read`, and is untested.
|
||||
|
||||
## Runs
|
||||
|
||||
Single files, each green on its commit: bus broker, decision-close, decision-route, decisions,
|
||||
hard-waits, land, project-runtime, launch-notice, review, publish, credential-store, credentials,
|
||||
credential-status, limits, tester, plugins-verb, routes-verb, limits-runtime, instances-verb, web,
|
||||
usage-runtime, limit-checks, daily-quota-runtime, chief-of-staff, tasks, tester-writes; every
|
||||
business test file. Since: credential-verbs (2/2), credential-store (11/11, with D5), each tasks file,
|
||||
tester, credential-status, plugins-verb, routes-verb, instances-verb; business launch-settings,
|
||||
plugin-settings, instance-settings, token-limit-settings; cli credentials (3/3), cli, app,
|
||||
tester-reads, tester, other-tree, transport, instances, limits, verbs. Hash pins: none on a changed
|
||||
file (`thread-migration.test.mjs:46` pins DDL only).
|
||||
|
||||
Publish, single files, each green before its commit: seat
|
||||
checkout (77), working (26), publish (11), landing (8); bus process (8), land (17), publish (12),
|
||||
credential-verbs (2); cli publish-socket (4), git-socket (24), launcher (29); harness tools (22).
|
||||
Hash pins: none on a changed file.
|
||||
|
||||
sage-1440 changes (the poll bound, the mid-flight tests), single files, green before the commit: cli
|
||||
publish-socket (7), git-socket (24), launcher (29). Hash pins: none on a changed file.
|
||||
|
||||
The rotation-due fix, single files: cli credentials (4), green; the old-set mutant fails it 3/1.
|
||||
Hash pins: none on a changed file.
|
||||
|
||||
Slip, 2026-10-20 about 19:51Z: I ran `node --test packages/tasks/tests/*.test.mjs` in one command,
|
||||
which is a package suite, without asking Sage for the slot. It passed, 97 pass and 0 fail, in under a
|
||||
minute. Reported to Sage in megavolt-0017.
|
||||
|
||||
Rebase (sage-1555): the 21 commits from megavolt/row199 sit on origin/refactor 00d4cc1f6 as the new
|
||||
branch megavolt/row199-on-190 (no force-push). `git range-diff` shows all 21 patches as `=`; the
|
||||
merge of 12c85ada1 flattened with an empty remerge-diff.
|
||||
|
||||
Full gate, Sage's slot, on c6868ba41 (base 00d4cc1f6), logs started 2026-10-20T22:55:44Z: bus 476
|
||||
pass 0 fail; business 191/0; tasks 97/0; cli 522/0 (472 s); seat 163/0; scripts/test-queue.sh
|
||||
156/0; test-auth, test-conductor, test-config, test-discord, test-extension-package,
|
||||
test-foundation, test-land-work, test-release and test-task each exit 0. The test-task compose run
|
||||
left the network megavolt-row199_default with 0 containers; I removed it.
|
||||
|
||||
Section 11 mutants, batch 1, on c6868ba41, 2026-10-20T23:09:03Z to 23:09:56Z, restores verified and
|
||||
packages/ clean: 22 of 28 killed; M27 survived, expected equivalent (deviation 13). Five expected
|
||||
kills survived, each a test gap: M05 (the token in the push URL), M06 (revoke unlinks before it drops
|
||||
memory), M12 (http.followRedirects=false dropped), M17 (#authorityFor's pushCommit check dropped)
|
||||
and M26 (publishTls reaches startBroker). Launchpad's stack-release single file ran alongside from
|
||||
23:09:28Z, so batch 2 reruns all 28 (sage-1563).
|
||||
|
||||
Fixes, each checked by its mutant alone: 5fa7b2553 seat publish, a test reads git's argv through a
|
||||
PATH shim and finds no token and no userinfo (M05 killed); the 302 test's target shares the CA git
|
||||
trusts, so only http.followRedirects=false keeps git off it (M12 killed). 116eeec48 bus,
|
||||
credential-store: revoke's unlink fails (dir 0500) and the token is already out of memory (M06
|
||||
killed); process.mjs refuses publishTls, deviation 16 (M26 killed, its anchor is now the refusal
|
||||
line). M17 is equivalent, deviation 17. Single files after the fixes, each green: seat publish (12),
|
||||
bus credential-store (12), bus publish (12), bus process (8), and every other file that reads
|
||||
process.mjs: bus credential-status (4), limit-checks (16), land (17), tasks (9), end-launch (7); cli
|
||||
trackers-boot (1), thread-launch (11), publish-socket (7). Hash pins: none on a changed file.
|
||||
|
||||
Section 11 mutants, batch 2, all 28, on 7c3cdd940 in Sage's slot (sage-1565), 2026-10-20T23:17:59Z to
|
||||
23:18:48Z, restores verified and packages/ clean: 26 killed; M17 and M27 survived, both expected
|
||||
equivalent (deviations 17 and 13). Each of the five batch 1 survivors but M17 now fails its test. Then
|
||||
pkg-bus 477 pass 0 fail, pkg-seat 164/0, pkg-cli 522/0 (461 s), ending 23:26:40Z. No other seat's run
|
||||
was active: the busy check said CLEAR before the batch started. Logs:
|
||||
/mnt/storage/scratch/megavolt-r199/batch2-20261020T231650Z/.
|
||||
@@ -0,0 +1,416 @@
|
||||
# Row 199 design: a credential store per agent, own git identity, pushes to a remote
|
||||
|
||||
Megavolt, 2026-10-20. Gitea #1697, MVP T1 item 6. Brief: `docs/plans/2026-10-20_agent-credentials.md`.
|
||||
Reviewer Duckworth. Mos rules on the authority model (section 9) before any code. Base: refactor at 992650232.
|
||||
No credential value was read to write this. Evidence is code and docs only.
|
||||
|
||||
r2 answers duckworth-0013 (review at 6ab9cd927 on duckworth/reviews): B1 in D3a, T1 and T5, B2 in D9 step 5
|
||||
and T3, N1 in section 6 and Q-M3, N2 in section 6, N3 in D9 step 3, N4 in D9a and T3, N5 in E5 and D9b.
|
||||
|
||||
r3 records Mos's rulings (mos-1027) in section 9. The Q-M2 hard wait changes D9 step 1, D9b and T3. Q-M1
|
||||
drops O2 (D10, A3, R2).
|
||||
|
||||
r4 answers duckworth-0017 (REVIEW-r3.md at 4065ed59f): B3 in T3 and D9 step 5, B4 in D9 steps 1, 3 and 5, N6
|
||||
and N7 in D9a, N8 in D9 step 1.
|
||||
|
||||
r5 takes webby-0011 N1 (row 194 review, through honker-0009): the host checks at each push that the
|
||||
remote's owner is the agent's own Gitea user. r4 named one `remote` per project, which can't be a fork per
|
||||
agent (O1) once two agents publish the same project. So `projects.<p>.remote` now names the upstream
|
||||
repository, and the host builds each agent's fork URL from it (D9 step 2, D10, T3). It also fixes a conflict
|
||||
found while building: D8 made `identity` required for every agent whose definition declares `gitea`, while D9
|
||||
step 2 says today's files load unchanged. Coder declares `gitea`, so r5 makes `identity` optional and
|
||||
`publish` require it (D8).
|
||||
|
||||
r6 answers duckworth-0026 (REVIEW-r5.md at 3cb6888e1), with Mos's acceptance (mos-1043). B5: the derived URL
|
||||
has one name, `fork`, and D9a's `ls-remote`, step 5's push, the helper's host match, `checkPublish`'s result
|
||||
and step 6's event all use it (D9, D9a, D9b, T3). B6: the URL parse is written down (D9c) and tested (T3).
|
||||
N10 in D8 and T3. N11 in D9a and A3. N9 is carried to BUILD.md for the routing commit.
|
||||
|
||||
## 0. Terms
|
||||
|
||||
- **Stack instance**: one Mosaic Stack deployment (row 190). It has one OS user, one config dir
|
||||
`~/.config/mosaic-NAME` (here `<configDir>`), one data root and one bus. Case 1 and Case 2 are two stack
|
||||
instances on two VMs (DECISIONS Q180).
|
||||
- **Agent**: a role instance in a business file (`roles.<agent>`). Today's code calls it "instance". This
|
||||
design says agent.
|
||||
- **Kind**: a credential type: `gitea`, `vikunja`, `model`.
|
||||
- **Host**: the bus process and its git socket, running as the stack instance's OS user, outside any sandbox.
|
||||
|
||||
## 1. What exists (measured at 992650232)
|
||||
|
||||
| Code | Fact | Where |
|
||||
|---|---|---|
|
||||
| E1 | The business file references credentials per agent: `roles.<agent>.credentials.<kind> = {file or env, rotateBy or expires}`. The kinds an agent may hold are the ones its role definition lists | `business/src/business.mjs:89-111`, `credentials.mjs:18-38`, `role.mjs:105-118` |
|
||||
| E2 | The bus reads each token once at start (O_NOFOLLOW, own uid, 0600, at most 16 KiB, outside the repos and data root) into a private map keyed `<business>/<agent>:<kind>` | `bus/src/credentials.mjs:18-89`, `bus/src/runtime.mjs:78-89` |
|
||||
| E3 | Only Vikunja uses a token: `credentials.use("<business>/<agent>", "vikunja", ...)`. Gitea tokens are loaded and never used. `assertClean` refuses any event that contains a token | `tasks/src/adapter.mjs:70`, `runtime.mjs:134`, `broker.mjs:560` |
|
||||
| E4 | Agents run in bwrap as the bus's own uid. `~/.config`, `~/.mosaic` and `~/.ssh` are not bound. The git socket is bound only for `git.push.working`, `review.verdict` or a landing | `seat/src/sandbox.mjs:21-30, 104-124`, `cli/src/launcher.mjs:401` |
|
||||
| E5 | A session proves who it is with a 64-hex cap the broker mints and maps to a frozen session record. The git socket resolves the cap to (business, role) before anything else | `broker.mjs:280, 379-383`, `packages/cli/src/git-socket.mjs:398-407` |
|
||||
| E6 | The model credential is one env file per host (`EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env`). `ENV_ALLOW` passes it into every sandbox, so every agent holds the same model token | `cli/systemd/mosaic-bus.service.in:24`, `seat/src/session.mjs:83-94` |
|
||||
| E7 | Commits in a session's clone use `<agent>@<business>.mosaic.invalid`. Landed squashes use "Mosaic bus host" | `sandbox.mjs:151-157`, `working.mjs:280-281` |
|
||||
| E8 | No code pushes to a remote. The git socket pushes bundles into a local bare working repo with no remote and no credential | `working.mjs:1-14, 198-238` |
|
||||
| E9 | Adding or changing an agent refuses a definition that declares credentials (`organization-credentials-out-of-scope`). Binding them is a hand edit. Row 194's design keeps that refusal for create and hire "until the credentials row" | `business/src/instance-settings.mjs:20-29`, row 194 DESIGN.md D1 |
|
||||
| E10 | The development repository's helper pushes as one identity from one JSON file | `scripts/git-credential-mosaic.mjs` |
|
||||
|
||||
So per-agent references and a host-only reader already exist for two kinds. What's missing: a fixed place for
|
||||
the files, an agent identity, any remote push, lifecycle verbs, and a model credential that agents don't
|
||||
share.
|
||||
|
||||
## 2. The store (brief item 1)
|
||||
|
||||
D1. **Layout.** Each stack instance keeps its agents' secrets under its own config dir:
|
||||
|
||||
```
|
||||
<configDir>/secrets/agents/ 0700, owner = the stack instance's OS user
|
||||
<configDir>/secrets/agents/<business>/<agent>/ 0700
|
||||
<configDir>/secrets/agents/<business>/<agent>/<kind> 0600, the token only, one line
|
||||
```
|
||||
|
||||
This is row 190's secrets root (`<configDir>/secrets/...`, as Q175's slot uses). The token file keeps E2's
|
||||
format, so the bus reader doesn't change. Nothing in a token file is a name, an email or a URL. Those are not
|
||||
secret and go in the business file (D6), where review sees them.
|
||||
|
||||
D2. **The business file reference.** A new reference form, `{"store": true, "rotateBy" | "expires": DATE}`,
|
||||
means "the file at the layout path for this business, agent and kind". The path is derived, never written,
|
||||
so an agent's entry can't name another agent's file. The old `{file}` and `{env}` forms stay valid for
|
||||
migration (section 7). A `{file}` reference that points inside `<configDir>/secrets/agents/` but not at its
|
||||
own agent's path refuses `credential-not-yours` when the business file loads.
|
||||
|
||||
D3. **Read path.** Unchanged in shape: the bus reads every entry once at start (E2) and on a store change
|
||||
(section 6), and holds the values in memory. No sandbox binds `<configDir>` (E4 already holds). No token goes
|
||||
into a sandbox for `gitea` or `vikunja`. The agent asks the bus to act, and the bus uses the token.
|
||||
|
||||
D3a. **A `store` reference with no file.** ENOENT on a `store` path loads as an absent entry. `status` shows
|
||||
`not-issued`, and `useFor` refuses `credential-unavailable`. The bus still starts. This is the state after a
|
||||
create without `issue` (D13) and after `revoke` (section 6). Every other defect still refuses start: wrong
|
||||
owner or mode on the file or a dir, a symlink, a non-regular file, bad format, a missing or wrong-mode
|
||||
`secrets/agents/` dir. `{file}` and `{env}` keep E2's behavior, where a missing file refuses start.
|
||||
|
||||
## 3. The "RLS equivalent" (brief item 1)
|
||||
|
||||
Three layers. Each one is enforced by code or the OS, not by prose.
|
||||
|
||||
D4. **OS, between stack instances.** Files are 0600 and dirs 0700, owned by the stack instance's own OS user.
|
||||
Case 1's user can't read Case 2's store (Q180: each instance under its own user without sudo). The bus refuses
|
||||
to start when any file or dir under `secrets/agents/` has the wrong owner or mode (`credential-file`, as E2
|
||||
does for files today, extended to the dirs).
|
||||
|
||||
D5. **Bus, between agents of one stack instance.** The principal for every credential use comes only from the
|
||||
verified cap (E5). The agent-facing API is `credentials.useFor(session, kind, op)`: `session` is the broker's
|
||||
frozen record, and the key is built from `session.business` and `session.role`. It has no agent argument, so
|
||||
there is nothing in a request to point at another agent. The existing `use(name, kind, op)` stays for the
|
||||
host's own `@sync` entry and is not reachable from a request. The tasks adapter's per-role calls (`ctx.as`,
|
||||
`verbs.mjs:126`) move to `useFor`. Any request field that names an agent other than the caller (`as`,
|
||||
`agent`, `instance`, `role` on a credential path) is refused `credential-not-yours` and recorded through
|
||||
`broker.refuse()` as `action.refused`, with the caller's cap and the task ref. A build test greps every
|
||||
`credentials.use(` call site and fails on a new one outside the allowlist.
|
||||
|
||||
D6. **What stays open.** A process of the same OS user outside the sandbox can read every agent's file. That
|
||||
is the same limit as the human proof (P19 333). Q180's per-user VMs move the boundary to "one stack instance
|
||||
per OS user". Agents of one instance are kept apart by the sandbox and D5, not by the OS.
|
||||
|
||||
## 4. Kinds (brief item 2)
|
||||
|
||||
| Kind | 10-27 | Use |
|
||||
|---|---|---|
|
||||
| `gitea` | yes | Git push to a granted remote (section 5). Gitea API verbs (issues, pull requests) wait. The factory's review flow runs in the working repo today, and no 10-27 case needs an agent to open a pull request |
|
||||
| `vikunja` | yes, exists | Moves to `store` references and `useFor`. No behavior change |
|
||||
| `model` | per stack instance only | See D7 |
|
||||
|
||||
D7. **Model provider.** A model token has to sit inside the sandbox today, because the harness calls the
|
||||
provider itself (E6). Per-agent model tokens need a proxy that holds the real token and gives each run its own
|
||||
short-lived one, which is the CLIProxyAPI line in TARGET.md. That waits for its own row. For 10-27 the model
|
||||
credential is per stack instance: row 190's `<configDir>/secrets/claude-session.env`, owned by that
|
||||
instance's OS user. It is never a copy of jwoltje's file. Jason signs in on each VM user himself (A6). Whether
|
||||
Case 1 and Case 2 run on Jason's existing subscription or need a new account is Jason's call (Q-J1). This row
|
||||
doesn't pick. A pi model credential also waits for P19 #383.
|
||||
|
||||
## 5. Git identity and push (brief item 3)
|
||||
|
||||
D8. **Identity.** A new business-file key, `roles.<agent>.identity = {name, email, gitea: {url, user}}`. It is
|
||||
optional, because today's business files with a `gitea` agent (coder) must load unchanged. `publish` (D9
|
||||
step 2) requires it, and requires that the definition declare `gitea`. A business file with a `publish`
|
||||
grant on an agent that lacks either refuses at load, and the error names `roles.<agent>.publish` and says
|
||||
"needs identity and a gitea definition" (N10). Load is the only place this is checked: a business file that
|
||||
fails it never reaches the bus. The sandbox sets `GIT_AUTHOR_*` and `GIT_COMMITTER_*` from
|
||||
it in place of E7's invalid domain. The email is the one Jason gives the agent's Gitea user, so Gitea links the
|
||||
commits to that user. Agents without `gitea` keep E7.
|
||||
|
||||
D9. **Push to a remote: a new git socket op, `publish`.** No new socket, no bundle, and no token in the sandbox.
|
||||
|
||||
```
|
||||
publish {"cap", "op": "publish", "task_ref", "project", "branch", "decision"}
|
||||
-> {"ok": true, "result": {"fork", "ref", "commit", "previous"}} or a refusal
|
||||
```
|
||||
|
||||
The host checks, in order, and each refusal goes through `broker.refuse()`:
|
||||
|
||||
1. Identity from the cap (E5), then the broker's `#checkAuthority(s, "git.push.remote", {decision, target:
|
||||
task_ref})`, as `checkLand` does for a merge (`broker.mjs:907-913`). `git.push.remote` is a new action in
|
||||
ACTIONS and is not in ROUTED (REQ-WAIT-3, `packages/business/src/vocabulary.mjs:52-58`,
|
||||
`broker.mjs:42-69`), so it is a hard wait and classifies `gated` for every role. Every publish therefore
|
||||
needs a decision the human resolved for this task ref. A decision is spent once (`decision-consumed`).
|
||||
|
||||
**The decision binds one commit.** `#authorityFor` binds action, role, thread and target only
|
||||
(`broker.mjs:769-798`), so the commit needs its own carrier. The raise records it the same way it records
|
||||
`holderRun` for `role.revoke` (`broker.mjs:1733`). When the action is `git.push.remote`, the
|
||||
`decision.raise` event body gets `pushCommit`. That is the commit of `#latestPush(business, target)` at
|
||||
raise time, read by the broker and never taken from the agent. A raise with no recorded push for the task
|
||||
refuses `no-push`. The decision's question shown to the human ends with that commit. `#authorityFor` then
|
||||
refuses `decision-mismatch` when `context.pushCommit` differs from the commit of `#latestPush` now, which
|
||||
is the same check `holderRun` gets at `broker.mjs:796`. So the sequence approve at A, push B, publish B
|
||||
refuses.
|
||||
No role definition grants the action yet. When Jason's PRD version routes it (Sage, Vikunja #420), a
|
||||
reviewed commit adds it to both ROUTED lists and to coder's `withinRole`. From then on a coder publishes
|
||||
without a decision, and `decision` is optional for a within-role class only.
|
||||
2. The project is in the agent's grant, `roles.<agent>.publish: [project]`, and the business file names the
|
||||
project's upstream repository, `projects.<project>.remote: "https://HOST/OWNER/REPO"`
|
||||
(`remote-not-granted`). Both URLs pass D9c's parse at load. The upstream's host and port equal
|
||||
`identity.gitea.url`'s (`remote-host-mismatch`). The host never contacts that URL. It builds the agent's
|
||||
fork, `fork = https://HOST[:PORT]/<gitea.user>/REPO`, from the upstream's origin and repository name and
|
||||
the agent's own `gitea.user`. `fork` is the only URL the rest of `publish` uses: D9a's `ls-remote`, step
|
||||
5's push, the helper's host match, `checkPublish`'s result and step 6's event. So the owner of the
|
||||
repository that receives a push is the agent's own Gitea user on every push, by construction, and the
|
||||
agent can't name it (webby-0011 N1). D9c is what makes "by construction" hold. Gitea's refusal of a
|
||||
push to a repository the user doesn't own or collaborate on is the second layer. An upstream whose owner
|
||||
equals the agent's `gitea.user`, compared without case, refuses `remote-not-fork`, at load and at push. `remote` is a new key
|
||||
beside `root` and `repo` (`business.mjs:186`), and `identity` and `publish` are new keys on a role entry
|
||||
(`business.mjs:89`). All three are optional, so today's business files load unchanged.
|
||||
3. The source is one commit, `C = context.pushCommit`. It is checked three ways, so all three are equal:
|
||||
the decision's commit, the commit of `#latestPush`, and the work ref's tip in the working repo,
|
||||
`work/vikunja-<project>-<task>` (`working.mjs:110-114`). The tip is read under the repository's lock, and
|
||||
if they differ the push refuses `ref-moved`. The work ref is per task, not per agent. Two checks make it the
|
||||
caller's: `#latestPush` was recorded by the caller's role (`work-not-yours`), and step 4. The destination is always `refs/heads/<gitea.user>/<branch>`, built by the host.
|
||||
`validBranch(branch)` holds (`invalid-request`). The agent never names the destination ref.
|
||||
4. Every commit in `main..<work ref>` has author and committer email equal to `identity.email`
|
||||
(`identity-mismatch`).
|
||||
5. The push runs through `credentials.useFor(session, "gitea", ...)`: `git push <fork> <C>:<dst>`, by oid,
|
||||
with no `+` and no `--force`. It never pushes by ref name, so a push to the working repo after
|
||||
`checkPublish` can't change what goes out (`non-fast-forward` from git stays a refusal). Git runs with E8's SAFE flags
|
||||
(`working.mjs:92-98`), which set `protocol.allow=never`. `publish` adds exactly two flags,
|
||||
`protocol.https.allow=always` and `http.followRedirects=false`, plus `GIT_TERMINAL_PROMPT=0` and no global
|
||||
or system config. The token reaches git through a one-shot credential helper that reads it from an
|
||||
inherited pipe, never through argv, env, the URL or a file. Seat `gitEnv` (`checkout.mjs:79-89`) takes only
|
||||
PATH from the host and sets the rest itself, so no proxy or `GIT_SSL_*` variable reaches git. `publish` takes a `tls` parameter that
|
||||
defaults to `[]`, the same way `push` takes `fsck = FSCK` (`working.mjs:100`). Tests pass
|
||||
`["-c", "http.sslCAInfo=<scratch CA>"]`. The host never sets it, and nothing in config or env reaches it. The helper answers `get` only when `protocol`
|
||||
is `https` and `host`, port included, equals `fork`'s. It writes nothing for any other `get` and for
|
||||
`store` and `erase`. `assertClean` checks git's output, so a fork that echoes the token gets
|
||||
`credential-leak`.
|
||||
6. The bus records `action.allowed` for `git.push.remote` with fork, ref and commit.
|
||||
|
||||
D9a. **The agent's fork must share root's history.** A push sends every object the fork lacks. The working
|
||||
repo's `main` is the project root's history (`working.mjs`, seed), so a fork that doesn't share it would
|
||||
receive all of root, landing squashes included. The check runs on `fork`, never on the upstream: a related
|
||||
upstream says nothing about what the agent's `<user>/REPO` holds.
|
||||
`publish` runs `seed` first (`working.mjs:164-176`), under the repository's lock as `push` does, so `main`
|
||||
catches up with root before the check below. That means a fork also receives any landed root commits that
|
||||
upstream doesn't have yet, because they sit under the agent's work. That is expected: the agent's branch is
|
||||
built on them.
|
||||
Before the push, the host runs `git ls-remote --symref <fork> HEAD` (same flags and helper). It refuses
|
||||
`remote-unrelated` when the fork is empty or when that tip isn't a commit the working repo already has and an
|
||||
ancestor of `main`. When `ls-remote` exits non-zero, for a fork that doesn't exist, a refused token or a
|
||||
network failure, `publish` refuses `fork-unavailable` and the push doesn't run (N11). Git's stderr isn't
|
||||
parsed to tell those apart and isn't returned, since it can carry the URL. Only `ls-remote` crosses the
|
||||
network for this, so nothing is fetched from the fork. If the fork has moved past root, the refusal stands
|
||||
until root catches up. That is a human pull into root, not an agent's.
|
||||
|
||||
D9c. **The URL parse (B6).** `projects.<p>.remote` and `identity.gitea.url` are parsed with `new URL()` at load,
|
||||
and the business file refuses to load unless all of these hold:
|
||||
|
||||
- The scheme is `https:`. There is no username, password, query or fragment.
|
||||
- The string equals its own normalized form: for the upstream, `url.origin + url.pathname`; for
|
||||
`gitea.url`, `url.origin + "/"` or `url.origin`. So a dot segment (`/<user>/../OWNER/REPO`), a
|
||||
percent-encoded dot, an upper-case host, an explicit `:443`, a backslash, userinfo, a query and a fragment
|
||||
each refuse, because `new URL()` would change them (checked with node: each differs from its normalized
|
||||
form). A trailing slash survives normalization and is refused by the two-segment rule below. The checks below run on that path, so the owner that is checked is
|
||||
the owner that is used.
|
||||
- The upstream's path is exactly `/OWNER/REPO`: two non-empty segments, no more and no fewer. One trailing `.git` on
|
||||
REPO is allowed and stripped. `fork` is built from the stripped REPO.
|
||||
- `OWNER`, `REPO` and `gitea.user` each match Gitea's name rule, `^[A-Za-z0-9_.-]{1,100}$` and not `.` or
|
||||
`..`. REPO after stripping must still match.
|
||||
- `gitea.url`'s path is `/`. A Gitea served under a sub-path isn't supported by this row and refuses at load.
|
||||
- Host and port are compared after `new URL()` normalized them, and an omitted port counts as 443 on both
|
||||
sides.
|
||||
- Gitea compares user and repository names without case (`lower_name`), so `remote-not-fork` compares the
|
||||
upstream's OWNER with `gitea.user` without case. An upstream owned by `Coder` and a `gitea.user` of `coder`
|
||||
would otherwise build a "fork" that is the upstream itself.
|
||||
|
||||
D9b. **How `publish` reaches the broker and the store.** The git socket runs in the bus process
|
||||
(`cli.mjs:504-506`) and reaches the broker only through the host's op table (`bus/src/runtime.mjs:222-246`).
|
||||
`publish` adds three entries there, each wrapped in `refusing()`:
|
||||
|
||||
- `checkPublish(cap, args)`: steps 1 to 3, including the decision check. It returns `{ticket, fork, user,
|
||||
email, source}` and no token. The ticket holds the session and the decision until `recordPublish`, the
|
||||
same way `checkLand` holds a landing.
|
||||
- `withGitea(cap, fn)`: `credentials.useFor(broker.session(cap), "gitea", fn)`. `fn` gets the token and runs
|
||||
step 5. The token never leaves that call.
|
||||
- `recordPublish(ticket, result)`: step 6, and it spends the decision.
|
||||
|
||||
The op table gets no entry that returns a token.
|
||||
|
||||
D10. **Which refs.** Our side: only `refs/heads/<gitea.user>/*`, set by the host (D9 step 3). The default
|
||||
branch and other agents' prefixes can't be named. Gitea side, two options (Q-M1):
|
||||
|
||||
- **O1. A fork per agent. Ruled by Mos (mos-1027).** The agent's Gitea user has read on the upstream repo and owns a fork
|
||||
named like it. The host derives the fork's URL from the upstream and the agent's user (D9 step 2).
|
||||
Its token can write only repos the user owns or collaborates on, so Gitea itself stops any push to upstream
|
||||
or to another agent's fork. No branch rules are needed per agent, so none can drift. The cost: work reaches
|
||||
upstream as a pull request from the fork, opened by a human or a later API verb.
|
||||
- **O2. One shared repo. Not chosen.** The agent is a Write collaborator. Upstream's default and release branches get
|
||||
branch protection with no agent on the push list, plus a rule `<user>/*` per agent. Gitea doesn't stop agent
|
||||
A from creating an unprotected name outside `A/*` unless a catch-all rule outranks the per-agent ones. That
|
||||
depends on Gitea's rule priority, which I haven't verified on our version (probe P1). Every hire adds a rule.
|
||||
|
||||
Token scopes for either: `write:repository` and `read:user`, nothing else, per vocabulary.mjs rules (no
|
||||
`all`, no admin).
|
||||
|
||||
D11. **The host git socket.** `publish` is added to the socket. `push`, `fetch` and `land` don't change. The
|
||||
socket's bind rule (E4) adds `git.push.remote`. The working repo stays credential-free and remote-free. The
|
||||
`fork` URL is passed per call and never written into its config. A landing still squashes into the project
|
||||
root as "Mosaic bus host". Landings don't push anywhere.
|
||||
|
||||
## 6. Lifecycle (brief item 4)
|
||||
|
||||
All verbs are on the bus, in the CLI (`mosaic credentials ...`) and in the Console's agent page. Every verb is
|
||||
human-only. Under Q173 the tester may run `status` and `revoke` on the dev stack instance. `issue` and
|
||||
`rotate` stay with Jason, because the tester is an agent and the token would pass through its session
|
||||
(duckworth N1). Each one appends to the organization
|
||||
writer's `changes.jsonl` (actor, agent, kind, verb, date; never a value). No schema migration, no new event
|
||||
kind. A refusal uses `action.refused`.
|
||||
|
||||
| Verb | What it does |
|
||||
|---|---|
|
||||
| `issue AGENT KIND --rotate-by DATE` (gitea) or `--expires DATE` (vikunja) | Reads the token on stdin (never argv), checks E2's format, writes `<kind>.tmp` 0600, fsyncs, renames over `<kind>`, and sets the business file reference to `{"store": true, date}`. The bus reloads that one entry, with no restart |
|
||||
| `rotate` | Same as `issue` over an existing entry. The old value is gone from disk and memory when the call returns. Jason then deletes the old token in Gitea (A5) |
|
||||
| `revoke AGENT [KIND] --reason` | Drops the entry from the bus's memory first, then unlinks the file. A use in flight finishes. Every later use refuses `credential-unavailable` |
|
||||
| `delete AGENT` | `revoke` for every kind, then removes the agent's dir. Refused while the agent has a running claim |
|
||||
| `status [AGENT]` | Kind, date and state per entry (E3's `status()`), no values |
|
||||
|
||||
D12. **"Stops working at once."** On our side this holds without a timing window. A fired agent never holds a
|
||||
`gitea` or `vikunja` token (D3), so revoking means the bus no longer has it. On the Gitea and Vikunja side the
|
||||
token still exists until Jason deletes it (A5). The status line for a revoked entry says so until Jason
|
||||
confirms. The model token is the exception: it sits in the running sandbox (D7), so a fired agent's runs must
|
||||
end. Row 194's fire ends them.
|
||||
|
||||
D13. **What row 194 calls.**
|
||||
|
||||
- `credentials.issue` is never called by the chief. Row 194's hire stays uncredentialed (its D1).
|
||||
- A human `create` of a credentialed definition: row 194 lifts E9's refusal for the human path only. The agent
|
||||
is created with `store` references and no files. `launch` refuses `credential-unavailable` until Jason runs
|
||||
`issue`.
|
||||
- `fire` and `delete`: row 194 calls `credentials.revoke(business, agent, {reason, actor})` before it changes
|
||||
the business file. A failed revoke stops the fire (`credential-revoke-failed`), so an agent can't be fired
|
||||
with its credentials still live on our side.
|
||||
- `regenerate`: no credential change.
|
||||
|
||||
## 7. Who does what (brief item 5)
|
||||
|
||||
Agents never mint accounts or tokens. Jason's steps, per agent that needs `gitea`:
|
||||
|
||||
- A1. Create the Gitea user: Site Administration, User Accounts, Create User Account. The name is the agent's
|
||||
`gitea.user`. The email is the agent's `identity.email`.
|
||||
- A2. Create its token: sign in as that user, Settings, Applications, Generate New Token, scopes
|
||||
`write:repository` and `read:user`, nothing else.
|
||||
- A3. Create the fork of each granted repo as that user (O1). Gitea's push-to-create
|
||||
(`ENABLE_PUSH_CREATE_USER`, `ENABLE_PUSH_CREATE_ORG`) stays off, so a push to a missing `<user>/REPO`
|
||||
fails rather than creating a plain repository (N11). `publish` refuses `fork-unavailable` before that.
|
||||
- A4. On the stack instance's VM, as its OS user: `mosaic credentials issue AGENT gitea --rotate-by DATE`, with
|
||||
the token pasted into stdin.
|
||||
- A5. On fire or rotate: delete the token in Gitea (Settings, Applications), or disable the user.
|
||||
- A6. Model: sign in to the provider on each VM user to write `<configDir>/secrets/claude-session.env`.
|
||||
|
||||
Vikunja bot accounts and tokens are already Jason's (tracker bots). Only A4 changes for them.
|
||||
|
||||
## 8. Migration (brief item 6)
|
||||
|
||||
- M1. Existing `{file}` and `{env}` references keep working. A business moves one agent at a time with
|
||||
`issue`, which rewrites that agent's reference to `store`.
|
||||
- M2. Nothing here touches the dev repository's helper, its `.git/config`, `~/.mosaic` or `~/.mosaic-dev`.
|
||||
Dev seats (jarvis, seat tokens) keep pushing as today. The mos-0981 rule ends only when Mos says so.
|
||||
- M3. The live dev stack instance needs no change at re-pin: no reference uses `store` until someone issues
|
||||
one.
|
||||
|
||||
## 9. Questions
|
||||
|
||||
Mos ruled on the authority model in mos-1027 (2026-10-20T19:27Z):
|
||||
|
||||
- Q-M1. O1, one fork per agent.
|
||||
- Q-M2. `git.push.remote` and its bounds in D9 to D11 are approved. It lands as a hard wait: out of ROUTED,
|
||||
and no role grants it within-role (D9 step 1). Sage puts its routing in the PRD version draft going to
|
||||
Jason with `role.hire` and `role.fire` (Vikunja #420). After his approval, a reviewed commit routes it and
|
||||
grants it to coder.
|
||||
- Q-M3. Issue, rotate and delete are human-only, and the chief gets none of them. Under Q173 the dev
|
||||
instance's tester gets `status` and `revoke` only.
|
||||
- Q-M4. The model credential stays per stack instance for 10-27. Per-agent model tokens wait for a proxy
|
||||
row.
|
||||
|
||||
Open for Jason. Mos takes these to him one at a time:
|
||||
|
||||
- Q-J1. The model account for Case 1 and Case 2: existing subscription or a new account.
|
||||
- Q-J2. Gitea users and tokens for the agents that need them (A1 to A3).
|
||||
|
||||
## 10. What this closes (brief item 7)
|
||||
|
||||
- G11: closes the store (D1 to D6) and lifecycle (section 6) for `gitea` and `vikunja`. Still open: per-agent
|
||||
model credentials (D7), and the same-uid limit inside one stack instance (D6).
|
||||
- G13: closes "own credentials for git" (D8 to D10). Still open: Gitea API verbs (section 4).
|
||||
- C2.5 (git remote): Case 2's books repo can use `publish` once its project names a remote.
|
||||
|
||||
## 11. Tests and mutants
|
||||
|
||||
All tests use scratch roots, fake tokens and a fake Gitea: `git http-backend` behind a node http server on
|
||||
127.0.0.1 that checks basic auth against a fake token and records each ref update. No request leaves the host.
|
||||
|
||||
- T1. Layout: wrong mode or owner on a dir or file refuses start. A `{file}` reference into another agent's
|
||||
path refuses `credential-not-yours`. A bus restart after a create without `issue` starts, and the agent's
|
||||
status is `not-issued`. A missing `{file}` still refuses start.
|
||||
- T2. `useFor` keys from the cap only. A request naming another agent is refused and leaves one
|
||||
`action.refused`.
|
||||
- T3. The fake Gitea serves https on 127.0.0.1 with a cert from a scratch CA made by the test. It reaches git
|
||||
only through the `tls` parameter (D9 step 5). Each refusal test that should reach the network asserts that
|
||||
the first port received the request, so no test passes because git refused http first. `publish`: the
|
||||
happy path lands `refs/heads/<user>/<branch>` on the agent's fork in the fake, which shares root's history, at
|
||||
exactly the decision's commit. Refusals for each step in D9, one test each, including `work-not-yours`. Until routing, a publish with
|
||||
no decision refuses, one with a decision for another task or commit refuses, and a second publish on a
|
||||
spent decision refuses `decision-consumed`. Approve while the latest push is A, push B, publish: refuses
|
||||
`decision-mismatch`, and the fork receives nothing. A push to the working repo between `checkPublish` and
|
||||
the git push refuses with `ref-moved` or `decision-mismatch` (sage-1440, BUILD.md deviation 14). A raise with no recorded push refuses `no-push`. A test pins that `git.push.remote` is in neither ROUTED list
|
||||
and that no `roles/*.json` grants it. A force-push attempt is
|
||||
refused by git's non-fast-forward. A fake fork that answers 302 to a second 127.0.0.1 port: the push
|
||||
fails, and the second port receives no request with an Authorization header. A remote on the right host
|
||||
and a different port is refused at step 2. Two agents granted one project each land on their own fork:
|
||||
agent A's push reaches `/<A's user>/REPO` only and B's reaches `/<B's user>/REPO` only, and the fake's
|
||||
upstream repository receives nothing. An upstream owned by the agent's own user refuses `remote-not-fork`,
|
||||
and so does one whose owner differs from `gitea.user` only in case. With an upstream that shares root's
|
||||
history, a first publish to an empty fork and to an unrelated fork each refuse `remote-unrelated`, and
|
||||
neither fork nor upstream receives objects. A fork that doesn't exist refuses `fork-unavailable`, and the
|
||||
fake records no push. `ls-remote` and the push each reach the fork's path only (the fake records every
|
||||
path). Load refusals (D9c), one test each: a dot segment, an extra path segment, a missing segment,
|
||||
a trailing slash, userinfo, a query, a fragment, an explicit `:443`, a name outside Gitea's rule, and a `gitea.url` with a
|
||||
sub-path. A `.git` suffix loads and builds the fork without it. A `publish` grant on an agent without
|
||||
`identity`, and on one whose definition lacks `gitea`, each refuse at load with the N10 message.
|
||||
- T4. Token handoff: the token never appears in argv, env, the fork URL, the working repo config,
|
||||
`changes.jsonl`, events or logs (scan every file under the scratch roots).
|
||||
- T5. Lifecycle: issue, rotate, revoke and delete, each live with no restart. After revoke, `publish` and the
|
||||
Vikunja path refuse `credential-unavailable`, and so they do after a bus restart that follows the revoke.
|
||||
The tester can run `status` and `revoke`, and `issue` and `rotate` refuse it.
|
||||
- T6. Default: an agent without `identity` commits as E7. Existing `{file}` references load unchanged.
|
||||
|
||||
Mutants to kill: drop the cap-derived key in `useFor`, let the request name the destination ref, add `+` to
|
||||
the refspec, skip the identity check, put the token in the URL, revoke file before memory, skip the dir mode
|
||||
check, allow a `{file}` reference into another agent's dir, make ENOENT on a `store` path fatal again, make
|
||||
ENOENT on a `{file}` path silent, a helper that answers any host, drop `http.followRedirects=false`, drop the
|
||||
`work-not-yours` check, drop the `remote-unrelated` check, add `git.push.remote` to ROUTED, skip the spent
|
||||
check on the decision, drop the `pushCommit` comparison in `#authorityFor`, push by ref name in place of the
|
||||
oid, push to the upstream URL in place of the derived fork, run `ls-remote` against the upstream in place of
|
||||
the fork, take the fork's owner from anywhere but the agent's `gitea.user`, compare the owner with case, run the
|
||||
D9c checks on the raw path in place of the normalized one, read a failed `ls-remote` as empty, skip the tip
|
||||
check under the lock, and pass a CA flag in production.
|
||||
|
||||
## 12. Risks
|
||||
|
||||
- R1. Backups: if a backup tool copies `<configDir>`, it copies tokens. The build checks row 185's backup set
|
||||
and says what it includes.
|
||||
- R2. Withdrawn. O1 was ruled, so probe P1 isn't needed.
|
||||
- R3. Outbound HTTPS from the bus host to Gitea is new network reach for the host process. On the VMs this is
|
||||
Jason's network to confirm.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"seq": 22,
|
||||
"id": "3a6c8fb9-a5ab-42f8-9755-902a88375ca2",
|
||||
"at": "2026-10-20T22:41:14.444Z",
|
||||
"business": "mosaic-stack",
|
||||
"project": null,
|
||||
"raised_by_role": "pm",
|
||||
"raised_by_run": "rc9e993da24cf",
|
||||
"class": "gated",
|
||||
"action": "prd.approve",
|
||||
"route_to": "human",
|
||||
"question": "Approve PRD 1.8 exactly as drafted?\n\nFile: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 (revision 5), 319 lines, sha256 8eee52470b1d7df139164e3ba5cc128dc469457b2b52f298c12685c3483011a8. Duckworth reviewed it (duckworth-0061, confirmed duckworth-0062) and Mos checked it (mos-1140). The tip of branch honker/row194-b2 holds a later r6 (aba3cbdeb), which is not used here. This approval covers only the r5 commit and sha256 above. The PM copied these values from the request and did not compute or check the sha256.\n\nNotes the approval carries:\n1. Mos's reading: \"the main agent or chief of staff\" in Q10 is one role under two names, the chief of staff. Only the chief raises a grant request; the assistant passes the user's request to the chief. To change it, say so at approval and REQ-GRANT-1 changes first.\n2. Mos's reading: revoke by \"you, Mos or the tester\" means the user, the assistant (\"Mos\", Jason's assistant) for grants in its own organization, and the tester only on a dev instance.\n3. N5: host.sudo keeps the arbiter route version 1.7 set for git.push.remote. A raise for it that cites the spec and isn't within-role goes to the arbiter for its domain, not to the user. In manual mode it comes to the user.\n4. B7 residual: only an Approve creates a grant, but an agent running as the stack's OS user can still write the stack's own store (Q182 on dragon-lin, #423 on .53 and .54). This version doesn't close it.\n5. N6: the broker enforces only that the chief of staff raised a grant request. The rest of the chain of command is the messaging lists and role contracts, not a broker check.\n\nSource: a tester request relayed by the chief (message 6990efb4, tester message d54a190e). It is a test request, not Jason's instruction. It takes the same route as PRD 1.7 (decision d52febb5): non-blocking, no task, uncited, so it goes to the human's Inbox. I recommend yes because two reviewers have passed r5 and the open points are written down above, not hidden.",
|
||||
"options": [
|
||||
{
|
||||
"key": "yes",
|
||||
"text": "Approve PRD 1.8 exactly as drafted: the r5 file at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 with the sha256 below, and only that."
|
||||
},
|
||||
{
|
||||
"key": "no",
|
||||
"text": "Don't approve. Say what changes, and a new revision comes back for review."
|
||||
}
|
||||
],
|
||||
"recommendation": "yes",
|
||||
"task_ref": null,
|
||||
"requirement_ref": null,
|
||||
"blocking": false,
|
||||
"supersedes": null,
|
||||
"spec_path": null,
|
||||
"spec_commit": null,
|
||||
"authorization": {
|
||||
"action": "prd.approve",
|
||||
"target": null,
|
||||
"approvalChoice": "yes"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
[
|
||||
{
|
||||
"seq": 22,
|
||||
"id": "3a6c8fb9-a5ab-42f8-9755-902a88375ca2",
|
||||
"at": "2026-10-20T22:41:14.444Z",
|
||||
"business": "mosaic-stack",
|
||||
"project": null,
|
||||
"raised_by_role": "pm",
|
||||
"raised_by_run": "rc9e993da24cf",
|
||||
"class": "gated",
|
||||
"action": "prd.approve",
|
||||
"route_to": "human",
|
||||
"question": "Approve PRD 1.8 exactly as drafted?\n\nFile: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 (revision 5), 319 lines, sha256 8eee52470b1d7df139164e3ba5cc128dc469457b2b52f298c12685c3483011a8. Duckworth reviewed it (duckworth-0061, confirmed duckworth-0062) and Mos checked it (mos-1140). The tip of branch honker/row194-b2 holds a later r6 (aba3cbdeb), which is not used here. This approval covers only the r5 commit and sha256 above. The PM copied these values from the request and did not compute or check the sha256.\n\nNotes the approval carries:\n1. Mos's reading: \"the main agent or chief of staff\" in Q10 is one role under two names, the chief of staff. Only the chief raises a grant request; the assistant passes the user's request to the chief. To change it, say so at approval and REQ-GRANT-1 changes first.\n2. Mos's reading: revoke by \"you, Mos or the tester\" means the user, the assistant (\"Mos\", Jason's assistant) for grants in its own organization, and the tester only on a dev instance.\n3. N5: host.sudo keeps the arbiter route version 1.7 set for git.push.remote. A raise for it that cites the spec and isn't within-role goes to the arbiter for its domain, not to the user. In manual mode it comes to the user.\n4. B7 residual: only an Approve creates a grant, but an agent running as the stack's OS user can still write the stack's own store (Q182 on dragon-lin, #423 on .53 and .54). This version doesn't close it.\n5. N6: the broker enforces only that the chief of staff raised a grant request. The rest of the chain of command is the messaging lists and role contracts, not a broker check.\n\nSource: a tester request relayed by the chief (message 6990efb4, tester message d54a190e). It is a test request, not Jason's instruction. It takes the same route as PRD 1.7 (decision d52febb5): non-blocking, no task, uncited, so it goes to the human's Inbox. I recommend yes because two reviewers have passed r5 and the open points are written down above, not hidden.",
|
||||
"options": [
|
||||
{
|
||||
"key": "yes",
|
||||
"text": "Approve PRD 1.8 exactly as drafted: the r5 file at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 with the sha256 below, and only that."
|
||||
},
|
||||
{
|
||||
"key": "no",
|
||||
"text": "Don't approve. Say what changes, and a new revision comes back for review."
|
||||
}
|
||||
],
|
||||
"recommendation": "yes",
|
||||
"task_ref": null,
|
||||
"requirement_ref": null,
|
||||
"blocking": false,
|
||||
"supersedes": null,
|
||||
"spec_path": null,
|
||||
"spec_commit": null,
|
||||
"authorization": {
|
||||
"action": "prd.approve",
|
||||
"target": null,
|
||||
"approvalChoice": "yes"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
[mos test] Please have the PM raise a prd.approve decision for PRD 1.8, the same route as PRD 1.7 (decision d52febb5): blocking false, no task_ref, recommendation yes, gated to the human. The PM must not compute or change the sha256; it copies the values below.
|
||||
|
||||
Question: "Approve PRD 1.8 exactly as drafted?"
|
||||
File: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit 762a6711ff7ed049aa73dfde98b75ebf326bdd99 (revision 5), 319 lines, sha256 8eee52470b1d7df139164e3ba5cc128dc469457b2b52f298c12685c3483011a8. Reviewed by Duckworth (duckworth-0061, confirmed duckworth-0062) and checked by Mos (mos-1140). The branch tip of honker/row194-b2 holds a later r6 (aba3cbdeb) that is not used; the approval covers only the r5 commit and sha256 above.
|
||||
|
||||
The decision notes must carry these five items:
|
||||
1. Mos's reading: "the main agent or chief of staff" in Q10 is one role under two names, the chief of staff. Only the chief raises a grant request; the assistant passes the user's request to the chief. To change it, say so at approval and REQ-GRANT-1 changes first.
|
||||
2. Mos's reading: revoke by "you, Mos or the tester" means the user, the assistant ("Mos", Jason's assistant) for grants in its own organization, and the tester only on a dev instance.
|
||||
3. N5: host.sudo keeps the arbiter route version 1.7 set for git.push.remote. A raise for it that cites the spec and isn't within-role goes to the arbiter for its domain, not to the user. In manual mode it comes to the user.
|
||||
4. B7 residual: only an Approve creates a grant, but an agent running as the stack's OS user can still write the stack's own store (Q182 on dragon-lin, #423 on .53 and .54). This version doesn't close it.
|
||||
5. N6: the broker enforces only that the chief of staff raised a grant request. The rest of the chain of command is the messaging lists and role contracts, not a broker check.
|
||||
|
||||
Reply with the decision id once raised. Do not resolve it.
|
||||
@@ -0,0 +1,8 @@
|
||||
sent d54a190e to chief
|
||||
chief (RESULT, re d54a190e) cc46a7b4:
|
||||
I passed your request to the pm without changing any of it, and launched the pm so it can act on it. No decision id yet, because the pm hasn't raised the decision.
|
||||
|
||||
- **Message to pm:** 6990efb4-8321-4d0e-9b8a-771f65625b83. It says the request comes from a tester and authorizes nothing, and it carries your PRD 1.8 values and the five notes as you wrote them.
|
||||
- **pm launch:** run rc9e993da24cf.
|
||||
|
||||
When the pm answers with the decision id, I'll send it to you.
|
||||
@@ -0,0 +1,5 @@
|
||||
trail 3a6c8fb9-a5ab-42f8-9755-902a88375ca2: 4 row(s)
|
||||
2026-10-20T22:40:47.949Z events#1550 session.launched pm
|
||||
2026-10-20T22:40:47.969Z role_claims#102 claim pm by rc9e993da24cf
|
||||
2026-10-20T22:41:14.444Z decisions#22 gated prd.approve → human: Approve PRD 1.8 exactly as drafted? File: agents/honker/work/queue-194/PRD-1.8-DRAFT.md on mosaicstack/stack at commit …
|
||||
2026-10-20T22:41:14.445Z events#1553 action.allowed pm decision.raise decision 3a6c8fb9
|
||||
@@ -0,0 +1,125 @@
|
||||
# Re-pin 490e2771d to 4429a104f (row 192, Q175 phase 1), with npm ci in the new copy
|
||||
|
||||
Sage, 2026-10-20, draft 1, for mos-1099 item 3 under DECISIONS Q173. Mos checks this file at its commit before
|
||||
P0. Any change to this file or to NEW after the check means Mos checks again (mos-0849/0850).
|
||||
|
||||
This is a smaller window than `repin-rows-177-185.md` (draft 2, which Mos checked in mos-0929). Where a step is
|
||||
the same, this file names that file's step and says what differs. The 7332 matrix, the snapshot, the W9 list and
|
||||
the rollback compare are reused unchanged.
|
||||
|
||||
## Who, and what nobody does
|
||||
|
||||
- Sage operates, as the tester. Darkwing holds the gate lane for row 190 round 3 tonight, so I take the
|
||||
operator seat. Mos verifies each step's output before the next one and runs `rehearse` (P3), which reads the
|
||||
live config and a live backup (`docs/TOOLS.md` `rehearse`: Jason or Mos runs it).
|
||||
- Jason has no step. Q173 covers a re-pin of this test system with a backup first and a STATE report after.
|
||||
Nothing is pruned.
|
||||
- Nobody acts as Jason: no `--by jason`, no human shell or tmux pane, every bus read and write carries
|
||||
`--tester`, and no request carries `jason.woltje`. `bus.tester` stays as it is; if it isn't `true`, stop at P1.
|
||||
- No pi (mos-0865 D2, P19 #383). No Save in the human Console, no `mosaic name`.
|
||||
- Outside reach doesn't change. The Console keeps its current flags. `--sso` stays off: row 192's sign-in code is
|
||||
dormant without the flag, and `cli.mjs:57` refuses `--sso` with the preview flags the pointer carries anyway.
|
||||
The proof pair stays unset. The tunnel still forwards 7332 only (P4 and W10 check it).
|
||||
|
||||
## Names
|
||||
|
||||
- OLD, live: `490e2771d3d057ab76991e2c7859cd251857e64c`, activation `20261020T192956Z-490e2771d3d0`, pointer
|
||||
`pm: true`, schema 3i, Console `--port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username
|
||||
--allow-user jason.woltje --tester-port 7333` (no `--send`, read from `stack-release.sh status` between 21:36Z and 21:39Z).
|
||||
- NEW: `4429a104f6d65ccf165f6c83b5d1dce3147dfde0`, row 192's landing, 103 commits past OLD (`git rev-list --count 490e2771d..4429a104f`).
|
||||
The 106 that `status` showed is OLD to HEAD 305857c8f, so it also counts the three record commits below.
|
||||
The commits after it (5b8def2f1 plans, 176a6bf4b sessions, 305857c8f queue) are records and don't move NEW. If
|
||||
another code row lands before W0 (row 190 is the likely one), NEW stays here unless Mos rules otherwise.
|
||||
- REPO: the canonical checkout. `activate` runs REPO's `webui-service.sh`, so the four service scripts are held
|
||||
at NEW for the window (P5).
|
||||
- `P=/mnt/storage/scratch/sage-pin-de352dfaf` (`business-check.mjs`, `console-check.sh`, checked against
|
||||
`agents/sage/work/window-plan/repin-de352dfaf/SCRIPTS.sha256`).
|
||||
- `WIN=agents/sage/work/window-repin-4429a104f/`, where every transcript goes.
|
||||
- `SNAP=$WIN/snapshot.mjs`, a copy of `agents/darkwing/work/window-rows-177-185/snapshot.mjs`, sha256
|
||||
`639fd28a2674aaa03ce824e3394b4052f21cdef7d6fa49bae61a9927ed341e67` (mos-0899). I check the sha before S1, S2
|
||||
and any D3.
|
||||
|
||||
## What NEW carries over OLD
|
||||
|
||||
The path-limited log over `packages`, `scripts`, `roles`, `contracts`, `adapters`, `skills` and the package files:
|
||||
|
||||
| Row | Commit | Live effect |
|
||||
|---|---|---|
|
||||
| 186 | 269134d8d | Tool only: the activate plan's backup line says what the run will do. |
|
||||
| 187 | 9a71e4e80 | Tool: activate sets, carries and drops the proof pair. The pointer has no pair, so nothing changes. |
|
||||
| 188 | 8fc0ee216 | Tool: proof file checks on a carried pair. No pair, no effect. |
|
||||
| 163 | 8ecc7a239 | Tool: `rehearse`, which P3 uses, and `bus start --no-launch`. CLI `cli.mjs` and `launcher.mjs` gain the `--no-launch` path; the default start is unchanged. |
|
||||
| 189 | ace27dc5e | Tool: `activate --no-send`. Live already runs without `--send`; the pointer carries that. |
|
||||
| 192 | 4429a104f | Console `--sso` (off here), `scripts/mosaic-config.mjs` load becomes async, `webui-service.sh` renders `--sso` only when given, and a new dependency, oauth4webapi 3.8.8. `cli.mjs` imports `sso.mjs` at the top, so the Console won't start from a copy without that module. |
|
||||
|
||||
No schema step: `SCHEMA_VERSION` (`packages/bus/src/store.mjs:17`) is 3i at OLD and NEW, and no migration was
|
||||
added. `--plan` must say `migration: none`. No roles, skills, contracts, adapters or plug-in kinds changed. So: no
|
||||
harness set, no skill carry, no `pm-on`. One `activate --pm`.
|
||||
|
||||
## npm ci in the new copy
|
||||
|
||||
mos-1099 asks for npm ci in the new copy. `stack-copy.sh copy` already does it: it extracts the archive to a temp
|
||||
dir, refuses without `package.json` and `package-lock.json` (:55), runs `npm ci --no-audit --no-fund` there and
|
||||
writes no copy if it fails (:56). W0 checks the result: `node_modules/oauth4webapi/package.json` in the copy reads
|
||||
3.8.8, and `node --input-type=module -e "await import('oauth4webapi')"` from the copy exits 0. The live copy is
|
||||
163M; `/home` has 30G free (94% used), enough for one more.
|
||||
|
||||
## Backups
|
||||
|
||||
| Before | Backup | Taken by |
|
||||
|---|---|---|
|
||||
| W5 | the stopped store, `$DATA/backups/stack/<W5 activation id>/` with SHA256SUMS, DATA_ROOT, SCHEMA 3i, STAMP | `stack-release.sh` (row 185: every activate) |
|
||||
| W5 | the rollback set (units, wrapper, pointer) | `stack-release.sh` |
|
||||
| W9 | the business file, `cp -p` to `~/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-<stamp>` with its sha256, as evidence nothing wrote it | Sage, in W0b |
|
||||
|
||||
## Before the window (nothing stopped)
|
||||
|
||||
| # | Who | Step |
|
||||
|---|-----|------|
|
||||
| P0 | Mos | The `$P` sha check, as in the 177-185 plan P0. |
|
||||
| P1 | Sage | The business file, read only, the same values as the 177-185 plan P1. I report its sha256 now rather than assume the 06:15Z value. `business-check.mjs` for OLD now and for NEW after W0: LOAD_OK both. |
|
||||
| W0 | Sage | `cd $REPO && scripts/stack-copy.sh copy $NEW`, in a gap between gate-lane runs, since npm ci is heavy. Then the two npm checks above, and `diff <(git -C $REPO show $NEW:package-lock.json) $COPIES/$NEW/package-lock.json` empty. W0 writes only under `copies/` and changes nothing live, so it moves before P2 and P3, which need the copy. |
|
||||
| P2 | Sage | `console-check.sh $NEW` against a stub Console of NEW on a scratch port, once without and once with `--tester-port` on a second scratch port. CONSOLE_PASS both. |
|
||||
| P3 | Mos | `scripts/stack-release.sh rehearse $NEW mosaic-stack --backup 20261020T192956Z-490e2771d3d0 --port <scratch>`. It must pass every gate check but the PM's. It stops no unit and writes no pointer. Transcript in `$WIN/p3/`. Correction (mos-1107): `rehearse` starts NEW's Console with only `--port` and `--business`, not the pointer's flags. So Mos also starts NEW's Console by hand with the pointer's flags on two scratch ports and reads healthz and `/api/config` on both, then stops it (`$WIN/p3/flags-check.txt`, `flags-console.log`). |
|
||||
| P4 | Sage | The tunnel read, as in the 177-185 plan P4. |
|
||||
| P5 | Sage | `git -C $REPO diff --quiet $NEW HEAD -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh` and an empty `git status --porcelain` on the same four. Row 190 round 3 touches `webui-service.sh`; if it lands before W5, P5 fails and the window waits for Mos's ruling. |
|
||||
| P6 | Sage | The 7332 matrix (a)-(g) on OLD, as in the 177-185 plan, saved as `$WIN/matrix-old.txt`. |
|
||||
|
||||
## The window
|
||||
|
||||
| # | Who | Step |
|
||||
|---|-----|------|
|
||||
| W0b | Sage | The business file copy. |
|
||||
| W1 | Sage | Idle check (`mosaic agents --business mosaic-stack --tester`, `mosaic launches list`), the start stamp from `date -u`, then S1 `node $SNAP --store "$DATA/bus/bus.sqlite" --held`. |
|
||||
| W2 | Sage | `stack-release.sh status mosaic-stack`: pointer on OLD, `pm: true`, 3i, last line ok, unit flags match. |
|
||||
| W3 | Sage | `stack-release.sh activate $NEW mosaic-stack --pm --plan`. Must show from OLD to NEW, `migration: none`, the backup line, and the Console line with OLD's flags exactly: no `--send`, no `--sso`, no proof pair, `--tester-port 7333`. Any other difference stops here. |
|
||||
| W4 | Sage | The unit reads, as in the 177-185 plan W4. |
|
||||
| W5 | Sage, on Mos's go | `stack-release.sh activate $NEW mosaic-stack --pm`. Exit 0, `result ok`, every gate check including `pm_launched`. Record the activation id, the backup path and STAMP, and the backup SHA256SUMS sha256. Then `status`. |
|
||||
| W9 | Sage, after Mos verifies W5 | The 177-185 plan's W9 list unchanged, the chief turn and the one `assistant` thread turn included (mos-0929 Q3), plus two lines: the webui journal since the W5 stamp (with `UTC`) has one `Proxy: proxy proof off` and no `Sign-in:` line, and `ls -l /proc/<Console pid>/cwd` names the NEW copy. |
|
||||
| W10 | Sage | The browser check on 127.0.0.1:7333 and the matrix again as `$WIN/matrix-new.txt`, identical to `matrix-old.txt`. Then P4 again. Corrections after the window (mos-1135, mos-1137): the matrix line for a file the pin changes isn't "identical". Line (g) serves `index.html`, which row 192 grows by 121 bytes (6470 to 6591), so (g) is 200 non-json at 6575 bytes, not 6454. The two browser checks come from the 177-185 window, before row 192 and before a second thread project. "Only tester forms" now allows `#sign-out` while it is `hidden` and `/api/config` has no `sso: true`. "Chat lists the W9 thread" now reads every `.chat-projects section.panel` list (chat.js:93 renders one per project), not the first. |
|
||||
| W11 | Sage | STATE report in `$WIN/STATE.md`, posted on P19 386 and P19 412 and on Gitea #1690. |
|
||||
|
||||
## Stop conditions and rollback
|
||||
|
||||
The same as the 177-185 plan. Two failures in one window: stop and report (mos-0823).
|
||||
|
||||
Rollback: `stack-release.sh rollback mosaic-stack --plan`, then the real run. The previous distinct copy in the
|
||||
log is 490e2771d, both are 3i, so no `--restore` and no data loss. OLD takes the same Console flags. The R1 and
|
||||
R1post compares are as in the 177-185 plan. A restore past W5 follows that plan's D3 steps, with Mos deciding.
|
||||
|
||||
Keep every copy. Prune nothing.
|
||||
|
||||
## Questions for the plan check
|
||||
|
||||
- Q1: NEW is row 192 alone, not bundled with row 190. Row 190 (several instances) changes `stack-release.sh`
|
||||
and `webui-service.sh` and deserves its own window. I recommend running this one first.
|
||||
- Q2: Sage operates instead of Darkwing, who is on the row 190 gate. OK?
|
||||
- Q3: W0 before P2 and P3. It writes a copy only. OK?
|
||||
|
||||
## What Sage ran for this plan
|
||||
|
||||
Read only, 2026-10-20 between 21:36Z and 21:39Z (clock read 21:39:50Z at the commit): `stack-release.sh status mosaic-stack` (pointer, last five log
|
||||
lines, unit flags match, 106 commits of drift), `git rev-list --count`, `SCHEMA_VERSION` at both ends, the
|
||||
path-limited log, `git diff --stat` on the service scripts, CLI and config loader, `stack-copy.sh:23-56`,
|
||||
`webui/src/cli.mjs:15-120`, `docs/TOOLS.md` on `rehearse`, `du -sh` of the live copy and `df -h /home`. Nothing
|
||||
live was changed.
|
||||
@@ -0,0 +1,3 @@
|
||||
chief rbb708846b605 claude-code since 2026-10-20T19:29:59.053Z
|
||||
pm rc36b17ef3788 claude-code since 2026-10-20T20:20:56.278Z
|
||||
# exit 0
|
||||
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"settings loaded": {
|
||||
"pass": true
|
||||
},
|
||||
"settings": {
|
||||
"banner": "Tester Console. Changes are recorded as tester, never as the human.",
|
||||
"h1": "Settings",
|
||||
"forms": [
|
||||
"sign-out",
|
||||
"conv-form",
|
||||
"routes-form",
|
||||
"instance-add-form",
|
||||
"instance-change-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form"
|
||||
],
|
||||
"convFormHidden": true,
|
||||
"panels": [
|
||||
"Routing",
|
||||
"Role instances",
|
||||
"Seat names"
|
||||
],
|
||||
"instancesTable": true,
|
||||
"nameForms": 6,
|
||||
"routesForm": true,
|
||||
"routesSubmitEnabled": true,
|
||||
"forbidden": [],
|
||||
"tokenLimitsPanel": false
|
||||
},
|
||||
"tester banner": {
|
||||
"pass": true,
|
||||
"detail": "Tester Console. Changes are recorded as tester, never as the human."
|
||||
},
|
||||
"instance, routes and name editors present": {
|
||||
"pass": true
|
||||
},
|
||||
"only tester forms (conv-form is index.html:21, global and hidden)": {
|
||||
"pass": false,
|
||||
"detail": [
|
||||
"sign-out",
|
||||
"conv-form",
|
||||
"routes-form",
|
||||
"instance-add-form",
|
||||
"instance-change-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form",
|
||||
"tester-name-form"
|
||||
]
|
||||
},
|
||||
"no launch settings, assistant, limit reset or send": {
|
||||
"pass": true,
|
||||
"detail": []
|
||||
},
|
||||
"no Token limits panel (plan W10 item 2, amended)": {
|
||||
"pass": true
|
||||
},
|
||||
"board": {
|
||||
"busViewHidden": true,
|
||||
"banner": true,
|
||||
"headings": [
|
||||
"Waiting on you –",
|
||||
"All sessions –"
|
||||
],
|
||||
"unavailable": false,
|
||||
"length": 179,
|
||||
"forms": [
|
||||
"sign-out",
|
||||
"conv-form"
|
||||
]
|
||||
},
|
||||
"board reads": {
|
||||
"pass": true,
|
||||
"detail": {
|
||||
"busViewHidden": true,
|
||||
"banner": true,
|
||||
"headings": [
|
||||
"Waiting on you –",
|
||||
"All sessions –"
|
||||
],
|
||||
"unavailable": false,
|
||||
"length": 179,
|
||||
"forms": [
|
||||
"sign-out",
|
||||
"conv-form"
|
||||
]
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
"hash": "#/chat",
|
||||
"banner": true,
|
||||
"threads": [
|
||||
{
|
||||
"href": "#/chat/threads/d0ba7a66-aa3f-4918-9518-94132bce6b28",
|
||||
"name": "w9",
|
||||
"instance": "assistant"
|
||||
}
|
||||
],
|
||||
"conversationOpen": false,
|
||||
"forms": [
|
||||
"sign-out",
|
||||
"conv-form",
|
||||
"chat-project-form"
|
||||
]
|
||||
},
|
||||
"chat lists the W9 thread": {
|
||||
"pass": false,
|
||||
"detail": [
|
||||
{
|
||||
"href": "#/chat/threads/d0ba7a66-aa3f-4918-9518-94132bce6b28",
|
||||
"name": "w9",
|
||||
"instance": "assistant"
|
||||
}
|
||||
]
|
||||
},
|
||||
"W9 thread page not opened": {
|
||||
"pass": true,
|
||||
"detail": "#/chat"
|
||||
},
|
||||
"tester banner on chat": {
|
||||
"pass": true
|
||||
},
|
||||
"requests": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/config",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/config",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/messages",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus-status",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/messages",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/settings",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/usage",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/settings",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/usage",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/names",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/names",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/instances",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/instances",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/routes",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/routes",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/limits",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/limits",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/inbox",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/agents",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/messages",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/chat/projects",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/chat/threads",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/business",
|
||||
"refused": false
|
||||
}
|
||||
],
|
||||
"no write through the listener": {
|
||||
"pass": true,
|
||||
"detail": []
|
||||
},
|
||||
"no request to the W9 thread messages or open": {
|
||||
"pass": true,
|
||||
"detail": []
|
||||
}
|
||||
}
|
||||
W10 browser: FAIL
|
||||
# exit 1
|
||||
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"config": {
|
||||
"sso": null
|
||||
},
|
||||
"forms": [
|
||||
{
|
||||
"id": "sign-out",
|
||||
"hidden": true,
|
||||
"action": "/auth/logout",
|
||||
"method": "post"
|
||||
},
|
||||
{
|
||||
"id": "conv-form",
|
||||
"hidden": true,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "routes-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "instance-add-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "instance-change-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
},
|
||||
{
|
||||
"id": "tester-name-form",
|
||||
"hidden": false,
|
||||
"action": null,
|
||||
"method": null
|
||||
}
|
||||
],
|
||||
"chat": {
|
||||
"hash": "#/chat",
|
||||
"open": false,
|
||||
"projects": [
|
||||
{
|
||||
"project": "window-20261020-0801",
|
||||
"threads": [
|
||||
"#/chat/threads/d0ba7a66-aa3f-4918-9518-94132bce6b28"
|
||||
]
|
||||
},
|
||||
{
|
||||
"project": "window-20261020-2205",
|
||||
"threads": [
|
||||
"#/chat/threads/446201de-e8b5-4df3-9356-d0d5422cc899"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"requests": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/config",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/config",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/messages",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus-status",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/messages",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/settings",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/usage",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/settings",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/usage",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/names",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/names",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/instances",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/instances",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/routes",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/routes",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/limits",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/bus/limits",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/config",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/chat/projects",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/chat/threads",
|
||||
"refused": false
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/api/business",
|
||||
"refused": false
|
||||
}
|
||||
]
|
||||
}
|
||||
sign-out hidden true, sso null; W9 thread listed true; thread page open false; writes 0
|
||||
# exit 0
|
||||
@@ -0,0 +1,8 @@
|
||||
a 200 ok=true
|
||||
b 401 error="sign-in required"
|
||||
c 401 error="sign-in required"
|
||||
d 403 error="proxy Host refused"
|
||||
e 403 error="tester listener required"
|
||||
f 403 error="proxy header refused"
|
||||
g 200 non-json 6575 bytes
|
||||
# exit 0
|
||||
@@ -0,0 +1,7 @@
|
||||
# P4 2026-10-20T22:31:29Z
|
||||
DropInPaths=
|
||||
ExecStart={ path=/home/jwoltje/.local/bin/chisel ; argv[]=/home/jwoltje/.local/bin/chisel client --fingerprint ce+ISFvMAhuoCRDuc7riIf+hYz87TLHk/C9C/H8QsUg= --keepalive 25s --max-retry-interval 10s http://10.1.1.227:8080 R:0.0.0.0:7332:127.0.0.1:7332 ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
|
||||
# exit 0
|
||||
# R: specs (1): R:0.0.0.0:7332:127.0.0.1:7332
|
||||
P4 PASS: one R: spec R:0.0.0.0:7332:127.0.0.1:7332, DropInPaths empty
|
||||
# exit 0
|
||||
@@ -0,0 +1,6 @@
|
||||
ActiveState=active
|
||||
DropInPaths=
|
||||
MainPID=2950923
|
||||
ExecStart={ path=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/scripts/mosaic ; argv[]=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/scripts/mosaic bus start mosaic-stack --pm ; ignore_errors=no ; start_time=[Tue 2026-10-20 17:02:08 CDT] ; stop_time=[n/a] ; pid=2950923 ; code=(null) ; status=0/0 }
|
||||
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
# exit 0
|
||||
@@ -0,0 +1,12 @@
|
||||
● jarvis-curation-gate.service loaded failed failed Jarvis curation-gate liveness check (wake the agent if staged pulls sit uncurated)
|
||||
● jarvis-retro-check.service loaded failed failed Jarvis weekly retrospective ARTIFACT check (did Sunday's tick produce a retro?)
|
||||
● jarvis-retro.service loaded failed failed Jarvis weekly retrospective tick
|
||||
● mosaic-install-drift.service loaded failed failed Assert the installed Mosaic binaries still match the committed source (drift, not failure)
|
||||
● mosaic-standup-pm.service loaded failed failed Coordinator standup trigger (evening)
|
||||
● [email protected] loaded failed failed Escalate failed Mosaic unit jarvis-curation-gate.service to the local agent seat
|
||||
● [email protected] loaded failed failed Escalate failed Mosaic unit jarvis-retro-check.service to the local agent seat
|
||||
● [email protected] loaded failed failed Escalate failed Mosaic unit jarvis-retro.service to the local agent seat
|
||||
● [email protected] loaded failed failed Escalate failed Mosaic unit mosaic-install-drift.service to the local agent seat
|
||||
● [email protected] loaded failed failed Escalate failed Mosaic unit mosaic-reconcile.service to the local agent seat
|
||||
● setspark-tmp-guard.service loaded failed failed SetSpark: /tmp and scratch guard
|
||||
# exit 0
|
||||
@@ -0,0 +1,5 @@
|
||||
ActiveState=active
|
||||
SubState=running
|
||||
ActiveEnterTimestamp=Tue 2026-10-13 08:40:19 CDT
|
||||
MainPID=1317
|
||||
# exit 0
|
||||
@@ -0,0 +1,6 @@
|
||||
ActiveState=active
|
||||
DropInPaths=
|
||||
MainPID=2950925
|
||||
ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/packages/webui/src/cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333 ; ignore_errors=no ; start_time=[Tue 2026-10-20 17:02:08 CDT] ; stop_time=[n/a] ; pid=2950925 ; code=(null) ; status=0/0 }
|
||||
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
# exit 0
|
||||
@@ -0,0 +1,2 @@
|
||||
chief r097436c91816 claude-code since 2026-10-20T22:02:08.951Z
|
||||
# exit 0
|
||||
@@ -0,0 +1,7 @@
|
||||
PASS markdown-preview.js is served and equals the copy's
|
||||
PASS /api/config on loopback: send=false edit=false
|
||||
PASS project.files through the Console: head=ae0b17c9586e51da7bc1b7e548c6acf913a55665 files=2 spec=true error=none
|
||||
PASS project.file spec/north-star.md through the Console: commit=ae0b17c9586e51da7bc1b7e548c6acf913a55665 length=99 error=none
|
||||
PASS loopback save refused: HTTP 403 error=read-only sent=false
|
||||
CONSOLE_PASS
|
||||
# exit 0
|
||||
@@ -0,0 +1,19 @@
|
||||
[
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"instance": "chief",
|
||||
"run": "r097436c91816",
|
||||
"launchedBy": "jason",
|
||||
"harness": "claude-code",
|
||||
"model": "claude-opus-5-5",
|
||||
"provider": "anthropic",
|
||||
"modelKey": "opus",
|
||||
"pid": 2951160,
|
||||
"startTime": "63491912",
|
||||
"runnerPid": 2951161,
|
||||
"runnerStartTime": "63491912",
|
||||
"runDir": "/home/jwoltje/.mosaic-dev/launches/mosaic-stack/r097436c91816",
|
||||
"startedAt": "2026-10-20T22:02:08.941Z",
|
||||
"live": true
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
|
||||
# exit 0
|
||||
@@ -0,0 +1,2 @@
|
||||
[]
|
||||
# exit 0
|
||||
@@ -0,0 +1,2 @@
|
||||
created project window-20261020-2205 id 77e7b45f-8c34-49a3-b2fa-9cf565177c30
|
||||
# exit 0
|
||||
@@ -0,0 +1 @@
|
||||
1792533965
|
||||
@@ -0,0 +1,21 @@
|
||||
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
|
||||
{"copy":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T220206Z-4429a104f6d6","at":"2026-10-20T22:02:09Z"}
|
||||
log (last five lines):
|
||||
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
|
||||
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T22:02:06Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":null,"activation":"20261020T220206Z-4429a104f6d6","result":"started"}
|
||||
{"at":"2026-10-20T22:02:09Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":"20261020T220206Z-4429a104f6d6","activation":"20261020T220206Z-4429a104f6d6","result":"ok"}
|
||||
check:
|
||||
bus: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
Console: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
result: pass
|
||||
bus status:
|
||||
host: mosaic-stack, pid 2950923, running, since 2026-10-20T22:02:08.133Z, notifier off
|
||||
socket: present
|
||||
writer.lock: pid 2950981
|
||||
boot: organization file loaded at 2026-10-20T22:02:08.125Z
|
||||
unit flags: match the pointer
|
||||
drift: 6 commit(s) on refactor since the pointer's copy
|
||||
# exit 0
|
||||
@@ -0,0 +1,4 @@
|
||||
sent c5b43f04 to chief
|
||||
chief (RESULT, re c5b43f04) c3e236d7:
|
||||
Got the re-pin of 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 from the tester; I took no action because a tester message authorizes nothing, and I launched and changed nothing.
|
||||
# exit 0
|
||||
@@ -0,0 +1,4 @@
|
||||
sent b4b66ced to window-20261020-2205/w9
|
||||
assistant (RESULT, re b4b66ced) cfa7ea99:
|
||||
Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.
|
||||
# exit 0
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"seq": 570,
|
||||
"id": "b4b66ced-d088-4b9c-a944-306f164028f9",
|
||||
"at": "2026-10-20T22:05:58.283Z",
|
||||
"business": "mosaic-stack",
|
||||
"from_role": "tester",
|
||||
"from_run": "tester-cli",
|
||||
"to_role": "assistant",
|
||||
"class": "REQUEST",
|
||||
"in_reply_to": null,
|
||||
"decision": null,
|
||||
"corrects": null,
|
||||
"body": "[mos test] reply with one line, then stop",
|
||||
"to_name": null
|
||||
},
|
||||
{
|
||||
"seq": 571,
|
||||
"id": "cfa7ea99-7ccb-4225-80a6-9a903540e337",
|
||||
"at": "2026-10-20T22:06:04.247Z",
|
||||
"business": "mosaic-stack",
|
||||
"from_role": "assistant",
|
||||
"from_run": "r497f1c817d4d",
|
||||
"to_role": "tester",
|
||||
"class": "RESULT",
|
||||
"in_reply_to": "b4b66ced-d088-4b9c-a944-306f164028f9",
|
||||
"decision": null,
|
||||
"corrects": null,
|
||||
"body": "Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.",
|
||||
"to_name": null
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
created window-20261020-2205/w9 id 446201de-e8b5-4df3-9356-d0d5422cc899 instance assistant
|
||||
# exit 0
|
||||
@@ -0,0 +1,3 @@
|
||||
window-20261020-0801/w9 assistant d0ba7a66
|
||||
window-20261020-2205/w9 assistant 446201de
|
||||
# exit 0
|
||||
@@ -0,0 +1 @@
|
||||
446201de-e8b5-4df3-9356-d0d5422cc899
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"total": {
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
"byRun": [
|
||||
{
|
||||
"run": "r8fba7e598156",
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheWrite": 0,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"run": "r4a96e2142e4d",
|
||||
"input": 2,
|
||||
"output": 52,
|
||||
"cacheWrite": 11750,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc78c9700c8e9",
|
||||
"input": 14,
|
||||
"output": 1519,
|
||||
"cacheWrite": 18187,
|
||||
"cacheRead": 89559,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r293f33bcbc63",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 18595,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r34520a581318",
|
||||
"input": 6,
|
||||
"output": 323,
|
||||
"cacheWrite": 26349,
|
||||
"cacheRead": 14307,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r7e99838f9f93",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 9858,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc964611b2092",
|
||||
"input": 4,
|
||||
"output": 120,
|
||||
"cacheWrite": 26138,
|
||||
"cacheRead": 23870,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rbb708846b605",
|
||||
"input": 12,
|
||||
"output": 2089,
|
||||
"cacheWrite": 17285,
|
||||
"cacheRead": 73823,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc36b17ef3788",
|
||||
"input": 16,
|
||||
"output": 5221,
|
||||
"cacheWrite": 36717,
|
||||
"cacheRead": 202573,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r097436c91816",
|
||||
"input": 2,
|
||||
"output": 131,
|
||||
"cacheWrite": 11915,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r497f1c817d4d",
|
||||
"input": 2,
|
||||
"output": 80,
|
||||
"cacheWrite": 9856,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
}
|
||||
],
|
||||
"byInstance": [
|
||||
{
|
||||
"instance": "chief",
|
||||
"input": 36,
|
||||
"output": 4114,
|
||||
"cacheWrite": 85486,
|
||||
"cacheRead": 177689,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"instance": "coder",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 18595,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"instance": "assistant",
|
||||
"input": 4,
|
||||
"output": 124,
|
||||
"cacheWrite": 19714,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"instance": "pm",
|
||||
"input": 20,
|
||||
"output": 5341,
|
||||
"cacheWrite": 62855,
|
||||
"cacheRead": 226443,
|
||||
"unknownTurns": 0
|
||||
}
|
||||
],
|
||||
"byProvider": [
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
],
|
||||
"byModel": [
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-5-5",
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
],
|
||||
"byAccount": [
|
||||
{
|
||||
"account": null,
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
# exit 0
|
||||
@@ -0,0 +1,2 @@
|
||||
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
|
||||
# exit 0
|
||||
@@ -0,0 +1,19 @@
|
||||
[
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"instance": "chief",
|
||||
"run": "r097436c91816",
|
||||
"launchedBy": "jason",
|
||||
"harness": "claude-code",
|
||||
"model": "claude-opus-5-5",
|
||||
"provider": "anthropic",
|
||||
"modelKey": "opus",
|
||||
"pid": 2951160,
|
||||
"startTime": "63491912",
|
||||
"runnerPid": 2951161,
|
||||
"runnerStartTime": "63491912",
|
||||
"runDir": "/home/jwoltje/.mosaic-dev/launches/mosaic-stack/r097436c91816",
|
||||
"startedAt": "2026-10-20T22:02:08.941Z",
|
||||
"live": true
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"schema": "3i",
|
||||
"tables": {
|
||||
"decision_events": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "28dd95056663dd5eb7442a3d815b73a4a3cd311a0e854fd82dc54f7dc3e6ef26"
|
||||
},
|
||||
"decisions": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "fef1058660b0d128ef21089dda5e0fba7f71763d8325148fe521de0d3f57e4ad"
|
||||
},
|
||||
"deliveries": {
|
||||
"rows": 1726,
|
||||
"maxSeq": 1726,
|
||||
"sha256": "8667571f4a05ce3621b04cf2dc5eb0311bac3d3aff1858584c78445c5c4c2fb4"
|
||||
},
|
||||
"events": {
|
||||
"rows": 1525,
|
||||
"maxSeq": 1525,
|
||||
"sha256": "e51827e31f2fa5a9a50993e5a10f37f1dee383b7d673f793006bd2ba1e9a5573"
|
||||
},
|
||||
"instance_names": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"message_threads": {
|
||||
"rows": 2,
|
||||
"maxSeq": null
|
||||
},
|
||||
"messages": {
|
||||
"rows": 567,
|
||||
"maxSeq": 567
|
||||
},
|
||||
"meta": {
|
||||
"rows": 3,
|
||||
"maxSeq": null,
|
||||
"sha256": "89fec56e7293d19e0b2f6cd8c0ef745ebf99e4115abeaea7fbdbc98a261332d9"
|
||||
},
|
||||
"projects": {
|
||||
"rows": 1,
|
||||
"maxSeq": 1,
|
||||
"sha256": "a3945f7462895e0e302029c542d384a6d33e559da900e6461d78bbcd7cc28b16"
|
||||
},
|
||||
"role_claims": {
|
||||
"rows": 98,
|
||||
"maxSeq": 98,
|
||||
"sha256": "ac6694fd0b365469f7486ae1f31486cd52b31be7bc0399938b7361f22454362e"
|
||||
},
|
||||
"task_comment_cursors": {
|
||||
"rows": 215,
|
||||
"maxSeq": 215,
|
||||
"sha256": "7f80df8efb90b502a9530a7ed5068d59be59b2eb318aa4e64ce6bf481762a5e9"
|
||||
},
|
||||
"task_snapshots": {
|
||||
"rows": 401,
|
||||
"maxSeq": 401,
|
||||
"sha256": "cced8c7f90ecaa60eee3010d987013d8c0301563cb888860fd91b8c5a02f0a00"
|
||||
},
|
||||
"thread_claims": {
|
||||
"rows": 2,
|
||||
"maxSeq": 2,
|
||||
"sha256": "6046c7088779aa2f6e9b3fd2c7520404b1678a4fcae71c2b9157a008bba22a5f"
|
||||
},
|
||||
"thread_opens": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"threads": {
|
||||
"rows": 1,
|
||||
"maxSeq": 1,
|
||||
"sha256": "3043721efd386f23dfdaad4acf41e0d553ff6182f0068c2133a615141bcf772a"
|
||||
},
|
||||
"usage_records": {
|
||||
"rows": 30,
|
||||
"maxSeq": 30,
|
||||
"sha256": "7ecbc0606273043b017d778bb23880377bfe367f788d234289b7c132caa588f2",
|
||||
"byRun": [
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r8fba7e598156",
|
||||
"input": "0",
|
||||
"output": "0",
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r4a96e2142e4d",
|
||||
"input": "2",
|
||||
"output": "52",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc78c9700c8e9",
|
||||
"input": "14",
|
||||
"output": "1519",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r293f33bcbc63",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r34520a581318",
|
||||
"input": "6",
|
||||
"output": "323",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r7e99838f9f93",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc964611b2092",
|
||||
"input": "4",
|
||||
"output": "120",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rbb708846b605",
|
||||
"input": "12",
|
||||
"output": "2089",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc36b17ef3788",
|
||||
"input": "16",
|
||||
"output": "5221",
|
||||
"unknownTurns": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"untimed": [
|
||||
"message_threads",
|
||||
"meta",
|
||||
"task_comment_cursors"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"schema": "3i",
|
||||
"tables": {
|
||||
"decision_events": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "28dd95056663dd5eb7442a3d815b73a4a3cd311a0e854fd82dc54f7dc3e6ef26"
|
||||
},
|
||||
"decisions": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "fef1058660b0d128ef21089dda5e0fba7f71763d8325148fe521de0d3f57e4ad"
|
||||
},
|
||||
"deliveries": {
|
||||
"rows": 1740,
|
||||
"maxSeq": 1740,
|
||||
"sha256": "554ce5b377ffacc72bfde11c99c920cd5d30337c8bfa217275d0073b587e2169"
|
||||
},
|
||||
"events": {
|
||||
"rows": 1543,
|
||||
"maxSeq": 1543,
|
||||
"sha256": "0151673193d0286ce8f3b234d7d21fa2b0f43f2ceec55644cccaa5cbd77603dd"
|
||||
},
|
||||
"instance_names": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"message_threads": {
|
||||
"rows": 4,
|
||||
"maxSeq": null
|
||||
},
|
||||
"messages": {
|
||||
"rows": 571,
|
||||
"maxSeq": 571
|
||||
},
|
||||
"meta": {
|
||||
"rows": 3,
|
||||
"maxSeq": null,
|
||||
"sha256": "89fec56e7293d19e0b2f6cd8c0ef745ebf99e4115abeaea7fbdbc98a261332d9"
|
||||
},
|
||||
"projects": {
|
||||
"rows": 2,
|
||||
"maxSeq": 2,
|
||||
"sha256": "3435f574ab94bc2fcc65fb8605251f30d4e9e63baf38e6fdf3c39c126531aa63"
|
||||
},
|
||||
"role_claims": {
|
||||
"rows": 101,
|
||||
"maxSeq": 101,
|
||||
"sha256": "fb47114defbb8a57dd148a06fbaf61b30f791a81f14b1510b8c32690506b4308"
|
||||
},
|
||||
"task_comment_cursors": {
|
||||
"rows": 220,
|
||||
"maxSeq": 220,
|
||||
"sha256": "df03c84094999299499de01107ddd08636df6156f5c9ccd2ff6e8c43e459f7e4"
|
||||
},
|
||||
"task_snapshots": {
|
||||
"rows": 408,
|
||||
"maxSeq": 408,
|
||||
"sha256": "e2243a89388afba07e8d096b4961deeb2d17c656cdcace54b46eac7001393814"
|
||||
},
|
||||
"thread_claims": {
|
||||
"rows": 4,
|
||||
"maxSeq": 4,
|
||||
"sha256": "088b8236570d8afe03364b85803fdc5034cce2cef6ed7b16f8b99bf6366524ad"
|
||||
},
|
||||
"thread_opens": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"threads": {
|
||||
"rows": 2,
|
||||
"maxSeq": 2,
|
||||
"sha256": "819fd24c64e279d0debad5ab5dbbe533d8e6f26de58277c69510b9cbbaadbc3a"
|
||||
},
|
||||
"usage_records": {
|
||||
"rows": 34,
|
||||
"maxSeq": 34,
|
||||
"sha256": "de614f3d62c9b3819757c5659bee40085cb86cc873b7bfe381b13e254ddb7cad",
|
||||
"byRun": [
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r8fba7e598156",
|
||||
"input": "0",
|
||||
"output": "0",
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r4a96e2142e4d",
|
||||
"input": "2",
|
||||
"output": "52",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc78c9700c8e9",
|
||||
"input": "14",
|
||||
"output": "1519",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r293f33bcbc63",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r34520a581318",
|
||||
"input": "6",
|
||||
"output": "323",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r7e99838f9f93",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc964611b2092",
|
||||
"input": "4",
|
||||
"output": "120",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rbb708846b605",
|
||||
"input": "12",
|
||||
"output": "2089",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc36b17ef3788",
|
||||
"input": "16",
|
||||
"output": "5221",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r097436c91816",
|
||||
"input": "2",
|
||||
"output": "131",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r497f1c817d4d",
|
||||
"input": "2",
|
||||
"output": "80",
|
||||
"unknownTurns": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"untimed": [
|
||||
"message_threads",
|
||||
"meta",
|
||||
"task_comment_cursors"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
# Re-pin window 490e2771d to 4429a104f (row 192, Q175 phase 1), STATE report (W11)
|
||||
|
||||
Operator and tester: Sage. Plan: `agents/sage/work/window-plan/repin-4429a104f.md` at f41729178, sha256
|
||||
e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9 (checked before W0b, W9). The plan file in
|
||||
this commit carries three corrections made after the window. They are listed at the end, so its sha256 differs
|
||||
now. Tool blob 3b068b1546d4 (`scripts/stack-release.sh`). OLD 490e2771d3d057ab76991e2c7859cd251857e64c, NEW
|
||||
4429a104f6d65ccf165f6c83b5d1dce3147dfde0. Every stamp below comes from `date -u` or from the transcript `W.txt`
|
||||
in this directory. Mos's gos: mos-1113 (W5), mos-1114 (W9 to W11), mos-1128 (W9b accepted), mos-1135 (W10
|
||||
browser part), mos-1137 (line g, post W11).
|
||||
|
||||
## Outcome
|
||||
|
||||
NEW is live for mosaic-stack with its PM, and the store stays on 3i. Row 192's sign-in code is in the copy but
|
||||
dormant, because the Console runs without `--sso`. W0 to W10 passed, and nothing was rolled back. W10 stopped
|
||||
once on two failed browser checks. I diagnosed both read-only as checks written before row 192, not faults in
|
||||
NEW, and Mos passed them (mos-1135). Matrix line (g) isn't byte-identical, for a known cause, and Mos passed it
|
||||
(mos-1137). Nobody acted as Jason. There was no `--by jason`, no pi, no Save and no `mosaic name`, and
|
||||
`bus.tester` was not touched.
|
||||
|
||||
## Stamps and activation
|
||||
|
||||
| Step | Start (UTC) | Result |
|
||||
| --- | --- | --- |
|
||||
| P1 values, business-check OLD | file written 21:43:13Z | values as planned, LOAD_OK (`p1.txt`) |
|
||||
| W0 copy NEW with npm ci, business-check NEW | 21:45:57Z | copied, lockfile diff empty, LOAD_OK (`w0.txt`) |
|
||||
| P2 console-check on a stub of NEW | 21:46:09Z | CONSOLE_PASS without and with `--tester-port` (`p2.txt`) |
|
||||
| P3 rehearsal (Mos) | 21:52:31Z to 21:52:34Z | REHEARSAL_PASS, rc 0 (`p3/`) |
|
||||
| P3 flags check (Mos) | 21:53:52Z to 21:53:56Z | NEW Console with the pointer flags on 7341/7342: healthz 200 both, then gone, 0 listeners |
|
||||
| P4, P5, P6 | 22:00:17Z | P4 PASS, P5 PASS (HEAD 4385b9ec1), `matrix-old.txt` |
|
||||
| W0b business file copy | 22:00:49Z | sha256 1e49bdf0 |
|
||||
| W1 idle check, S1 | 22:00:49.758Z | chief idle; S1 a7649760 |
|
||||
| W2 status | 22:00:49.819Z | pointer OLD, pm true, 3i, unit flags match |
|
||||
| W3 plan | 22:00:50.068Z | exit 0 (`W3-plan.txt` 45de68d4) |
|
||||
| W4 unit read | 22:00:50.238Z | OLD units, no drop-ins, bus `--pm`, no quote or backslash |
|
||||
| W5 activate (mos-1113) | 22:02:06.776Z, end 22:02:09.556Z | exit 0, gate passed, activate ok |
|
||||
| W5 status | 22:02:13.885Z | pointer NEW, pm true, 3i, unit flags match |
|
||||
| W9 checks and turns (mos-1114) | 22:05:51Z to 22:06:06Z | pass |
|
||||
| W9b run end and S2 | 22:21:05.171Z, S2 22:21:07Z | ended by the launcher (thread-idle); S2 1a444f5a |
|
||||
| W10 browser (stop), diagnosis | 22:29:14Z, 22:30:02Z | 10 of 12, then both failures traced to the checks |
|
||||
| W10 matrix, P4 (mos-1135) | 22:31:29Z | a-f identical, g +121 bytes; P4 PASS |
|
||||
| W11 reads | 22:44:33Z to 22:44:34Z | units, tunnel, business file, failed units |
|
||||
|
||||
Activation id 20261020T220206Z-4429a104f6d6. Pointer `at` 2026-10-20T22:02:09Z.
|
||||
|
||||
## Backups and hashes
|
||||
|
||||
- Store backup: `~/.mosaic-dev/backups/stack/20261020T220206Z-4429a104f6d6`, SHA256SUMS sha256
|
||||
b61d64843c9e2a153842606353450838da9cb778c8f3b9c27cc9366dd72ec0c9.
|
||||
- Rollback set: `~/.mosaic-dev/state/stack-rollback/20261020T220206Z-4429a104f6d6`. Rollback goes to 490e2771d.
|
||||
- Business file: 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93 at P1, at W0b and again at
|
||||
W11. The W0b copy is `~/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-20261020T220049Z` with the
|
||||
same hash. Nothing in the window wrote it. `backups.txt` holds the W0b and W5 lines.
|
||||
|
||||
## Pointer before and after
|
||||
|
||||
Before (W2): copy 490e2771d3d0, pm true, schema 3i, activation 20261020T192956Z-490e2771d3d0. Console flags
|
||||
`--port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje
|
||||
--tester-port 7333`.
|
||||
|
||||
After (W5, W9 and W11): copy 4429a104f6d6, pm true, schema 3i, activation 20261020T220206Z-4429a104f6d6. The
|
||||
Console flags are the same, with no `--send`, no `--sso` and no proof pair. Bus pid 2950923 and Console pid
|
||||
2950925, both since 22:02:08Z. Status shows drift of 6 commits on refactor since the pointer's copy (records).
|
||||
|
||||
## Unit ExecStart lines (no Environment values)
|
||||
|
||||
W11, after. Both units active, DropInPaths empty, WorkingDirectory the NEW copy:
|
||||
|
||||
```
|
||||
bus: ~/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/scripts/mosaic bus start mosaic-stack --pm (MainPID 2950923)
|
||||
webui: /usr/bin/node ~/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0/packages/webui/src/cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333 (MainPID 2950925)
|
||||
```
|
||||
|
||||
W4 read the same two lines on OLD's copy (W.txt 329-344). The bus Environment has 2 assignments with no quote or
|
||||
backslash. The values are not recorded. The Console's cwd is the NEW copy (W9). The webui journal since W5 has one
|
||||
"Proxy: proxy proof off" line and no "Sign-in:" line.
|
||||
|
||||
Tunnel: `mosaic-preview-tunnel.service` active, running, MainPID 1317, since 2026-10-13 08:40:19 CDT. The window
|
||||
didn't touch it. P4 before and after shows one R: spec, `R:0.0.0.0:7332:127.0.0.1:7332`.
|
||||
|
||||
## S1, S2 and the store
|
||||
|
||||
S1 (`S1.json`, sha256 a7649760c3439705c8b5f3af26d0c21aea1081c1eb295fd02aef3a642064194b) is W1's held read at
|
||||
22:00:49Z. S2 (`agents/sage/work/window-repin-4429a104f/S2.json`, sha256
|
||||
1a444f5ac0049bc78e397b6928e1008cfe8cbc30989bbf2042b4ed27f56a4173) is the held read after the assistant run
|
||||
ended, at 22:21:07Z. Both are 3i. SNAP (`snapshot.mjs`, 639fd28a) was checked before each.
|
||||
|
||||
| Table | S1 rows | S2 rows | S2 vs S1 |
|
||||
| --- | --- | --- | --- |
|
||||
| decisions, decision_events | 21, 21 | 21, 21 | same sha256 |
|
||||
| meta, instance_names, thread_opens | 3, 0, 0 | 3, 0, 0 | same sha256 |
|
||||
| deliveries | 1726 | 1740 | +14 |
|
||||
| events | 1525 | 1543 | +18 |
|
||||
| role_claims | 98 | 101 | +3 |
|
||||
| messages | 567 | 571 | +4 (counts only) |
|
||||
| message_threads | 2 | 4 | +2 (counts only) |
|
||||
| usage_records | 30 | 34 | +4 |
|
||||
| projects, threads, thread_claims | 1, 1, 2 | 2, 2, 4 | +1, +1, +2 |
|
||||
| task_snapshots | 401 | 408 | +7 |
|
||||
| task_comment_cursors | 215 | 220 | +5 |
|
||||
|
||||
For every grown table with a seq, maxSeq grew by exactly the row delta.
|
||||
|
||||
The task_snapshots and task_comment_cursors rows (mos-1128 item 2). The writer is the bus's own Vikunja poll, not
|
||||
an agent. `packages/bus/src/process.mjs:104` loads the tasks adapter. Its timer runs `tick` every `pollSeconds`
|
||||
(`packages/tasks/src/adapter.mjs:192-197`), and `sync.mjs:169` calls `broker.recordTask` with no cap
|
||||
(`packages/bus/src/broker.mjs:1123`). That writes `source 'poll'` with role and run null (`broker.mjs:1159-1174`)
|
||||
and the comment cursors (`:1131-1138`). The adapter logs only on an error (`adapter.mjs:115`), so the bus journal
|
||||
has no line for a good tick. That's why there's no sync line between 22:05Z and 22:22Z. The rows, read
|
||||
read-only after the window:
|
||||
|
||||
- task_snapshots 402-404 at 22:05:08Z: P19 436, 435 and 434, all updated 22:04:55-56Z. 405-406 at 22:12:38Z: 438
|
||||
and 437. 407 at 22:17:08Z: 438 again. 408 at 22:19:08Z: 431. All have `source poll`, `via cursor`, role null
|
||||
and run null. These are tracker edits made by seats during the window, which the poll picked up.
|
||||
- task_comment_cursors 216-220: tasks 424, 432, 438, 431 and 436, last comments 397 to 401. These are the
|
||||
comments seats posted on P19 in the same minutes (398 is Mos's comment on #432).
|
||||
|
||||
Rows after S2 (task_snapshots 409 at 22:33Z, cursors 221-223) are the same poll and are outside the window.
|
||||
|
||||
## W9 output
|
||||
|
||||
- Status, console-check (CONSOLE_PASS), the journal counts, the Console cwd and one running chief from this
|
||||
start (r097436c91816) all passed.
|
||||
- `talk chief --tester` (c5b43f04): the chief replied that a tester message authorizes nothing and it took no
|
||||
action.
|
||||
- Project `window-20261020-2205`, thread 446201de-e8b5-4df3-9356-d0d5422cc899. `talk --thread` (b4b66ced) got one
|
||||
assistant reply from run r497f1c817d4d. `thread read` shows the tester REQUEST and the assistant RESULT.
|
||||
- `threads`, `names` (empty) and `usage` read fine. The human-thread probe was skipped: S1's one thread is the
|
||||
177-185 window's tester thread d0ba7a66, so no human thread exists.
|
||||
- W9b: the launcher logged launch at 22:06:00.766Z, thread-idle at 22:21:05.149Z and end at 22:21:05.171Z
|
||||
(reason stopped, exit 0). Nobody stopped it by hand.
|
||||
|
||||
## W10
|
||||
|
||||
The browser check (`w10-browser.mjs`, dda57ccf) ran against 127.0.0.1:7333 with fetch and XHR limited to GET and
|
||||
HEAD. It made no writes and sent no W9 thread request. 10 of 12 checks passed. The two that failed:
|
||||
|
||||
1. "Only tester forms": `#sign-out` was in the list. It is row 192's form at `packages/webui/src/public/index.html:12`,
|
||||
`hidden`. `app.js:479` shows it only when `/api/config` has `sso: true`. The live config has no `sso`
|
||||
key, and the form stayed hidden.
|
||||
2. "Chat lists the W9 thread": the check read only the first `ul.chat-thread-list`. `chat.js:93` renders one list
|
||||
per project, and W9 made the second project. The diagnosis (`w10-diag.mjs`, 42d0844b) read every panel and
|
||||
found 446201de listed.
|
||||
|
||||
Both checks come from the 177-185 window (Darkwing's `window-rows-177-185/w10-browser.mjs`), written before row
|
||||
192 and before a second thread project existed. Corrected, they now read:
|
||||
|
||||
- Only tester forms, allowing `#sign-out` when it is `hidden` and `/api/config` has no `sso: true`.
|
||||
- The W9 thread is in some `.chat-projects section.panel` thread list, not only the first.
|
||||
|
||||
Both corrections are in the plan's W10 line, for the next window that copies this one.
|
||||
|
||||
The matrix (`matrix-new.txt` against `matrix-old.txt`). Lines a to f are byte-identical. Line g is 200 non-json
|
||||
both times, but 6454 bytes on OLD and 6575 on NEW. `index.html` is 6470 bytes at 490e2771d and 6591 at 4429a104f,
|
||||
+121, and the only hunk is the line 12 form. The served page grew by the same 121. The plan's W10 line now says a
|
||||
line for a file the pin changes isn't "identical", with this case as its example. P4 again at 22:31:29Z: PASS.
|
||||
|
||||
## Failed user units (mos-1128 item 3)
|
||||
|
||||
All three predate the re-pin at 22:02Z and come from the legacy `~/.mosaic` and jarvis-brain setup, not the
|
||||
stack. P19 #393, "dragon-lin: seven legacy user units in failed state", covers all three (filed by Mos on
|
||||
10-20 at about 01:42Z). It has no comments yet. The current failure times are later than the ones #393 lists,
|
||||
so I added a comment with them. Times are from `systemctl --user show` (CDT) with UTC alongside.
|
||||
|
||||
| Unit | Runs | Failed | #393 |
|
||||
| --- | --- | --- | --- |
|
||||
| mosaic-standup-pm.service | `~/.mosaic/tools/coordinator/standup_trigger.py pm` | 2026-10-19 19:00:28 CDT (10-20 00:00:28Z), exit 1 | listed |
|
||||
| mosaic-install-drift.service | `~/src/jarvis-brain/scripts/alerting/mosaic-install-drift` | 2026-10-20 00:12:54 CDT (05:12:54Z), exit 9 | listed, with an older time |
|
||||
| mosaic-unit-failed@mosaic-reconcile.service | `~/.local/bin/mosaic-unit-failed mosaic-reconcile.service` | 2026-10-20 13:16:12 CDT (18:16:12Z), exit 4 | listed, with an older time |
|
||||
|
||||
The marker's journal at 18:16:12Z says the escalation's tmux send to jarvis failed ("tmux target not found",
|
||||
socket mosaic-fleet), so the page wasn't delivered. The record is on disk in
|
||||
`~/.local/state/mosaic-unit-failures/20261020.jsonl`. `mosaic-reconcile.service` failed at that time (exit 7)
|
||||
but last ran OK at 17:16:27 CDT (22:16:27Z). The marker stays failed until something resets it. The journal no
|
||||
longer has lines for the other two units. The window changed none of these units, and touching them is
|
||||
outside it (`~/.mosaic` is off limits, and #393 needs Jason or ops-01's charter to disable any). W11's list also
|
||||
shows `setspark-tmp-guard.service` failed. It belongs to another project, and I've left it.
|
||||
|
||||
## Corrections to the plan, made after the window
|
||||
|
||||
- NEW is 103 commits past OLD (`git rev-list --count 490e2771d..4429a104f`), not 106. The 106 in `status` was OLD
|
||||
to HEAD 305857c8f, so it also counted the three record commits.
|
||||
- P3: `rehearse` starts NEW's Console with only `--port` and `--business`, not the pointer's flags. Mos's
|
||||
separate flags check covers that (`p3/flags-check.txt`, `p3/flags-console.log`). The line was wrong as
|
||||
written (mos-1107).
|
||||
- W10: the matrix line g size change and the two corrected browser checks, as above.
|
||||
|
||||
## Files
|
||||
|
||||
`W.txt` (transcript), `S1.json`, `S2.json`, `matrix-old.txt`, `matrix-new.txt`, `backups.txt`, `W3-plan.txt`,
|
||||
`W5.txt`, `W5-status.txt`, `w0.txt`, `p1.txt`, `p2.txt`, `p3/` (Mos's rehearsal and flags check), `p456.sh`,
|
||||
`w9.sh`, `w9b.sh`, `w9.out`, `w9b.out`, `w10-browser.mjs`, `w10-diag.mjs` with their outputs (the three
|
||||
screenshots stay on disk in this directory, uncommitted, since the push check refuses binaries),
|
||||
and the `.w9*`, `.w10*` and `.w11*` reads. The store snapshot tool is `snapshot.mjs` (639fd28a).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
plan: activate for mosaic-stack
|
||||
from: 490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
to: 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
store schema: 3i (read from the store the running bus holds)
|
||||
target schema: 3i
|
||||
migration: none; the store is at the target's version
|
||||
backup: the run backs up the stopped store to /home/jwoltje/.mosaic-dev/backups/stack/<activation>/ before any migration
|
||||
commits: the run adds 103 (newest first, at most 200):
|
||||
4429a104f webui: Q175 phase 1, the Console verifies SSO itself under --sso, OIDC RP with oauth4webapi 3.8.8 (row 192, #1690)
|
||||
32b54ac3b plans: Case 2 row briefs from Gosalyn's 51006710a (webby-0034 N1/N2 and webby-0035: row A README :711 one line per variable, C2-13 owns README :713 and says the precheck prints only variable names)
|
||||
6025ddd9c plans: Case 2 row briefs from Gosalyn's a2d94b2c2 (Webby r9 notes, webby-0032: row A README line on claude-session.env and the dist/providers/data/zai.json path, C2-13 XDG_DATA_HOME precheck and last-assignment-wins key file parse)
|
||||
ecbfd6deb plans: Case 2 row briefs from Gosalyn's 29e85196b (design r9 for mos-1090: row A out of scope points at design 10 step 2d, C2-13 precheck checks the hub key file by name and mode, claude marker check only for claude-code harness, EnvironmentFile= fixed in case2-unit.sh)
|
||||
0498c0613 agents: Gosalyn seat dir, README from gosalyn/case2-design, so Case 2 rows can name the seat as owner (#416)
|
||||
49d1f3a23 plans: Case 2 slice row briefs plus rows A and B, from Gosalyn's f325657c7 on gosalyn/case2-design (#416, design r8, C2-13 copy id and profile asks for row 195, C2-20 count-only note, row A ZAI_API_KEY per mos-1086/mos-1089, row B #428 after 10-27)
|
||||
628e2ca5c sessions: Filbert row 190 manifest round 1 CHANGES (base drift on row 189)
|
||||
088087ac9 queue: revs 2248-2361+. Sage: rows 210 (#424 host access, Gizmoduck) and 211 (#426 force-push guard, Rocko) added and briefed; row 195 re-briefed to the Case 1 install on .53; review requests for 200 r1/r2 and 202; plus earlier Sage revs. Other seats' revs: Launchpad 2254, 2283-2284, 2286, 2311-2313, 2328-2329, 2344, 2347-2348 (rows 192/200); Honker 2255; Morgana 2257, 2320, 2326-2327 (row 204); Gander 2262; Herb 2265; Dewey 2268; Bushroot 2273; Megavolt 2276; Webby 2303, 2316, 2339, 2355 (row 192/200 verdicts); Gizmoduck 2304, 2318-2319 and the row 210 claim (rows 201/210); Quackerjack 2317, 2341-2342 (row 202); Duckworth 2323, 2335, 2353 (rows 201/204/202 verdicts); Darkwing 2354, 2357 (row 190 in-review)
|
||||
a0fbefd48 plans: pre-push force-push hook and failing busy check brief (Vikunja #426, mos-1078, mos-1083; owner Rocko, reviewer Dewey)
|
||||
f24f2cd40 plans: row 195 brief becomes the Case 1 install on 10.1.1.53 (Q181 hosts, Q182/Q183 recorded acceptance, Q185 existing account, chained on rows 190 and C2-13; ops-01 root steps under #423 step 1; sage-1439)
|
||||
9ba31b5b3 agents: Honker's PRD 1.7 draft r2 on refactor for the prd.approve raise (11cb0423b, sha256 e896746df595d1b5155130ca163fbf63b0dc38a87a8dd154af77f1a6d1328647, Duckworth approved duckworth-0039, Vikunja #420)
|
||||
9db578ff5 plans: agent host access with sudo through the credential store brief (#1707, Vikunja #424, Q183/Q184, chained on row 199)
|
||||
8eb9ddd12 plans: FED R8 brief to r4.11 (b93f098f0, recorded acceptance, Duckworth N1/N2, #416 date), R5 context source (sage-1419), 193a makes covers level required (duckworth-0033 N1)
|
||||
ae3c36f24 agents: Gizmoduck seat dir and roster row (FED R1 owner, #414)
|
||||
a2af7ab49 plans: FED R1, R5, R2 and R8 briefs (#414, r4.9 a1daa3c07) and row 193a-d build briefs (#1691); Quackerjack seat dir and roster row
|
||||
aa1bd209e gate-lane: row 189 gate timing table for row 197 (Herb)
|
||||
ace27dc5e row 189 (#1687): stack-release.sh activate --no-send drops the Console's --send (Sage; Dewey approve 29485; Sage gate passed)
|
||||
a4a42dbac plans: stack-release --sso brief (#1698, Launchpad, reviewer Webby, row 192 F3; chained on 192)
|
||||
a31f1a0f8 plans: row 198 brief records Mos's call, in-process board reads behind the Console's auth (mos-1019)
|
||||
992650232 plans: agent credentials and git identity brief (MVP T1 item 6, #1697, Megavolt, reviewer Duckworth); Megavolt seat dir
|
||||
a7661ada0 plans: row 195 acceptance script takes Gizmoduck's gate item list; B8 issuer and TLS front end (mos-1011)
|
||||
f871cb309 plans: retire the control board writer on 7331 brief (#1696, Bushroot, reviewer Webby, mos-1008 item 2); Bushroot seat dir
|
||||
0bbd23c61 plans: row 195 brief adds the identity provider check and flags any new model account for Jason (mos-1008)
|
||||
0217b71c8 plans: row 195 brief rewritten as the hub on its own host, host left to Jason (#1693, mos-0998, mos-1005)
|
||||
c6f528a32 plans: second gate lane brief (#1695, Herb, reviewer Webby, mos-0977 item 6); Herb seat dir; gate template and busy check copies for the row
|
||||
96f243faf plans: human boundary row brief (P19 333, #1694, Gander, reviewer Duckworth, mos-0989); Gander seat dir; seat READMEs name the credentials row, not row 195 (now 190b)
|
||||
c86269935 sessions: Filbert row 190 design review r3, approved
|
||||
48ebee0ff plans: row 190 brief scopes isolation to addressing on one uid, points to row 190b (Filbert N7, mos-0986)
|
||||
66bdd84b8 sessions: Filbert row 190 design review r2, changes requested
|
||||
6966164f0 plans: row 190b brief, Melanie's hub under its own Linux user (#1693, Darkwing, reviewer Duckworth), mos-0986
|
||||
53bb59462 sage: common start for MVP T1 builder seats (mos-0974, mos-0977, mos-0981)
|
||||
5e01c3683 agents: seat dirs and roster rows for Launchpad, Morgana, Honker (Sage's T1 builders) and Webby, Duckworth (Mos's reviewers); row 193 brief takes Mos's B1 starting rule (mos-0985)
|
||||
9f56d2098 plans: rows 192 and 194 review by Webby, not Filbert or Dewey (mos-0981)
|
||||
0599b119f plans: MVP T1 briefs, SSO phase 1 (#1690, Launchpad), human users (#1691, Morgana), agent lifecycle (#1692, Honker), mos-0974/0977
|
||||
b416d2ede sage: Q175 SSO design, Mos C1 condition (mos-0977): straggler check before Jason step 4 in the phase 2 window
|
||||
5fc73a729 revs 2237-2247: row 189 added, briefed, started, in-review round 1 (Dewey, comment 29457); rows 190 (Darkwing) and 191 (Rocko) added and briefed for MVP T1 (mos-0974); darkwing-190-start (rev 2245, Darkwing's) and rocko-191-start (rev 2244, made for Rocko)
|
||||
99772421a sage: row 189 candidate manifest for Dewey's review (d6116771f)
|
||||
da230ca48 plans: MVP T1 briefs, several instances per host (#1688, Darkwing) and the kill switch (#1689, Rocko), mos-0974
|
||||
4f2d1be68 sessions: Filbert Q175 SSO design D1/D2 ack
|
||||
d55d86bb0 sage: Q175 SSO design r3 fix, Filbert r3 D1 (loader checks slot shape only) and D2 (slot from config dir, scratch Console dataRoot and unreachable issuer)
|
||||
c56ccf916 sessions: Filbert Q175 SSO design review r3, approve with D1
|
||||
817e8f51b sage: Q175 SSO design r3 for #412, Filbert r2 C1 (shared loader, config.json inside the phase 2 window, rollback order, test 25) and C2 (401 signed-out page, test 24), notes and Q-E
|
||||
5d019d876 sessions: Filbert Q175 SSO design review r2, changes
|
||||
59475fc5a sage: row 163 landing record, P19 comments 372/373 on 337/412, and P19 #413 filed for the --no-send row
|
||||
1a268249e sage: Q175 SSO design r2 for #412, Filbert r1 B1-B6, rulings Q-B/Q-C/Q-D, config in config.json (mos-0973)
|
||||
def2e7932 plans: brief for stack-release activate --no-send (Q175 interim, mos-0972/0973, #1687, P19 413)
|
||||
c30b36fff sessions: Filbert Q175 SSO design review r1, changes
|
||||
9ae8c73ac queue: revs 2235-2236: row 163 Sage landing note (8ecc7a239, patch-id b8d5f8a37c24, gate passed on eaa0f2666) and done
|
||||
8ecc7a239 row 163 (#1660): stack-release.sh rehearse, the D2 rehearsal on a scratch root from a backup, and bus start --no-launch (Sage; Filbert approve 29447; Sage gate passed)
|
||||
7f57419b3 sage: Q175 SSO design for #412, OIDC relying party on oauth4webapi 3.8.8, for Filbert review (mos-0970, mos-0971)
|
||||
4ca8d6e94 sage: row 42 run log, S7 reachability pass (51cebff7 answered by cff1e9ad after a chief launch, mos-0969)
|
||||
22e5e825b sage: row 42 run log, plain mosaic on a pinned host supersedes the 10-12 full-path line (mos-0968)
|
||||
e6dea74b2 sessions: Sage row 163 r2 approval intake; P19 337 comment 368 script and record
|
||||
cd2b377bb queue: rev 2234: row 163 r2 approve from Filbert (op filbert-163-r2, comment 29447, candidate c6f4f4c12596)
|
||||
ceccb0ef2 sessions: Filbert row 163 r2 review, approve
|
||||
10ab8ea62 sessions: Sage row 163 r2 in-review; P19 337 comment 367 script and record
|
||||
c539995cf queue: revs 2231-2233: row 163 r2 in-review from Sage (ops sage-163-r2-start, sage-163-inreview-2, .outcome; request comment 29446, reviewer Filbert, manifest c6f4f4c12596)
|
||||
92cc6011c queue: rev 2230: row 163 r1 changes from Filbert (op filbert-163-r1, comment 29445, candidate 430212299beb)
|
||||
7af166574 sage: row 163 r2 packet (candidate 2c090eefd on code 2a5f3a3a0, base 4f02486d5: diff, manifest c6f4f4c12596, gate passed, mutants K34-K41 killed)
|
||||
2244e8baf sessions: Filbert row 163 r1 review, changes
|
||||
bde0fdec9 sessions: Sage row 163 r1 in-review; P19 337 comment 366 script and record
|
||||
def677bc7 queue: revs 2228-2229: row 163 r1 in-review from Sage (ops sage-163-inreview-1, .outcome; request comment 29444, reviewer Filbert, manifest 430212299beb)
|
||||
51f67f1f8 sage: row 163 packet (candidate 4be3f6aec on 580500699: diff, manifest 430212299beb, gate passed, mutants K1-K33 killed after four survivors got tests)
|
||||
5b298bd00 sessions: Dewey row 66 r1 (changes) and r2 (approve) reviews
|
||||
565bc32c2 queue: revs 2223-2227: row 66 r2 round from Sage (2223-2225, ops sage-r66-r2-start, sage-r66-inreview-2); r2 verdicts from Filbert (2226, op filbert-66-r2, approve) and Dewey (2227, op dewey-66-r2, approve)
|
||||
5140aa9e8 sessions: Filbert row 66 r2 review, approve
|
||||
973cb5a87 queue: revs 2220-2222: row 163 Sage start (2220); row 66 r1 verdicts from Filbert (2221, op filbert-66-r1) and Dewey (2222, op dewey-66-r1)
|
||||
b82ec9353 sage: row 66 r2 packet (candidate 0f4a57956: diff, manifest cee63e06fdea, gate, mutants K1-K4 killed, singles rerun)
|
||||
3560c450a sessions: Filbert row 66 r1 review, changes
|
||||
4f02486d5 sage: row 66 r1 packet (candidate 8ec03b45a: diff, manifest e68c6bce5ec4, gate r2), row 42 run log (trigger (a), mos-0960), SESSIONS line
|
||||
263ea8a9a queue: revs 2215-2219: row 66 Sage start (2215), in-review round 1 on candidate manifest e68c6bce5ec4 (8ec03b45a) and request comment 29437 (2216-2217); row 163 brief re-pinned to b1500d96ad27 (6020bb43a) and briefed (2218-2219)
|
||||
6020bb43a plans: D2 brief owner is sage (row 163, #1660), and D2 lands after the Part B window (mos-0958, mos-0959)
|
||||
5487ed39d sage: row 188 landing record: P19 411 (N1) and comments 361-363 script and record, 409/410 description restore after a partial done update, SESSIONS line
|
||||
31e328958 queue: revs 2209-2214: row 188 Darkwing start, in-review and request 29432 (2209-2211); Filbert approve 29433 (2212); Sage landing note (8fc0ee216, patch-id c9a56b167c07, stack-release.sh blob 2586a2a37cd2, gate passed) and done (2213-2214)
|
||||
8fc0ee216 row 188 (#1686, P19 409, 410): stack-release T54, the proof file checks on a carried pair and a proof file inside the repository (Darkwing; Filbert approve 29433; Sage gate passed)
|
||||
23a609c5c sessions: Filbert row 188 r1 review, approve
|
||||
b9c1c9fe2 sage: Part B draft 2 A2 passed (mos-0958), row 188 launched, Vikunja comments 358-360 script and record, SESSIONS line
|
||||
525990ecb queue: revs 2207-2208: row 188 add and briefed (Sage), #1686, P19 409/410, gates Part B (mos-0956)
|
||||
148a9503d sage: Part B window file draft 2 for mos-0957 (F1: B6 and B-R1 name the stop's endLaunch rows, PB5 counts per step; F2: PB6, PB7 and B-R1 matrices run (a)-(h)); Q2 answered, Q1 to Jason's list
|
||||
240f21650 brief: stack-release tests for a carried proof pair and a proof file inside the repository (P19 409, 410, #1686, mos-0956)
|
||||
742e98976 sage: Part B window file, the read window's end stamp from the clock (10:29Z, not 10:40Z)
|
||||
288ab1b5a sage: Part B window file for Mos's A2 check (live-copy B3 and B-R1, Q1 on J2 order), SESSIONS lines for Darkwing (row 187, 10:25:22Z) and Sage
|
||||
a843ae284 sage: row 187 landing, P19 407-410 filed and P19 385 comment 357, #1685 closed (comment 29425), SESSIONS line
|
||||
652d6f3a8 queue: revs 2201-2206: row 187 Darkwing start, in-review and request comment 29423 (2201-2203); Filbert approve comment 29424 (2204); landing note (9a71e4e80, patch-id 6b57c47bc661, gate passed) and done (Sage 2205-2206)
|
||||
9a71e4e80 row 187 (#1685): activate sets, carries and drops the Console's proof pair
|
||||
d17328a55 sessions: Filbert row 187 r1 review, approve
|
||||
f81ebfbae sage: row 186 follow-ups filed as P19 405 and 406, P19 404 closed (mos-0955)
|
||||
9760d4967 sage: row 186 landing, P19 404 comment 354 and SESSIONS line, with Darkwing's row 186 SESSIONS line (09:13:21Z)
|
||||
16a936c79 queue: revs 2198-2200: row 186 Filbert approve comment 29419 (2198); landing note (269134d8d, patch-id c97b9f6e25cd, gate passed) and done (Sage 2199-2200)
|
||||
269134d8d row 186 (#1684): the activate plan's backup line says what the run will do
|
||||
3c5da3a91 sessions: Filbert row 186 r1 review, approve
|
||||
b367f5730 sage: rows 186-187 Vikunja comments (P19 404 351, P19 385 352-353) and SESSIONS line (mos-0954)
|
||||
5a768d82c queue: row 187 added and briefed (Part B A0, #1685), with Darkwing's row 186 start (rev 2193)
|
||||
6de3c86be plans: brief for row 187, activate carries the row 178 proof pair (mos-0928 Part B A0, #1685)
|
||||
1d7fd75e2 queue: row 186 added and briefed (P19 404, #1684)
|
||||
f5c72a5c3 plans: brief for row 186, the activate plan's backup line (P19 404, #1684)
|
||||
b724b7b0b sessions: Sage Part A window line, with Darkwing's 06:53:53Z and 08:28:01Z lines
|
||||
e24a13ad8 sage: Vikunja scripts and read-back records 2026-10-13 to 10-20 (decision 110, mos-0882)
|
||||
21aa1cab0 darkwing: re-pin window rows 177-185 packet, STATE.md (sha256 b586f11c9b6f1329), W.txt (edd4622c5c45ef3f), S1/S2/S2b/B snapshots, matrices, W10 browser check and P3 rehearsals (#1683; P19 386)
|
||||
9ddd8a92c plans: repin-rows-177-185 draft 4, R1 snapshot after the plain rollback (mos-0943)
|
||||
e7637b99c plans: repin-rows-177-185 draft 3, R1post expects one tester action.refused per refused read (mos-0935, mos-0942)
|
||||
2ea05700b plans: mos-0928 plan draft 2, NEW is the row 185 landing 490e2771d
|
||||
010666074 queue: revs 2185-2190: row 185 start and in-review on bf87dbba with request comment 29403 (Darkwing 2185-2187); Filbert approve comment 29404 (2188); landing note (490e2771d, patch-id 774cd23186d5, gate passed) and done (Sage 2189-2190)
|
||||
bus: scripts/bus-service.sh install --copy 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 --copies /home/jwoltje/.local/share/mosaic-stack/copies --pm --claude /home/jwoltje/.local/bin/claude
|
||||
proof: off
|
||||
send: off
|
||||
Console: scripts/webui-service.sh install --copy 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 --copies /home/jwoltje/.local/share/mosaic-stack/copies --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333
|
||||
CLI: scripts/stack-copy.sh wrapper 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 --copies /home/jwoltje/.local/share/mosaic-stack/copies --bin /home/jwoltje/.npm-global/bin
|
||||
@@ -0,0 +1,21 @@
|
||||
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
|
||||
{"copy":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T220206Z-4429a104f6d6","at":"2026-10-20T22:02:09Z"}
|
||||
log (last five lines):
|
||||
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
|
||||
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T22:02:06Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":null,"activation":"20261020T220206Z-4429a104f6d6","result":"started"}
|
||||
{"at":"2026-10-20T22:02:09Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":"20261020T220206Z-4429a104f6d6","activation":"20261020T220206Z-4429a104f6d6","result":"ok"}
|
||||
check:
|
||||
bus: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
Console: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
result: pass
|
||||
bus status:
|
||||
host: mosaic-stack, pid 2950923, running, since 2026-10-20T22:02:08.133Z, notifier off
|
||||
socket: present
|
||||
writer.lock: pid 2950981
|
||||
boot: organization file loaded at 2026-10-20T22:02:08.125Z
|
||||
unit flags: match the pointer
|
||||
drift: 6 commit(s) on refactor since the pointer's copy
|
||||
# exit 0
|
||||
@@ -0,0 +1,8 @@
|
||||
stack-release: captured the rollback set: /home/jwoltje/.mosaic-dev/state/stack-rollback/20261020T220206Z-4429a104f6d6
|
||||
stack-release: stopped both units
|
||||
stack-release: stopped store schema: 3i
|
||||
stack-release: backup: /home/jwoltje/.mosaic-dev/backups/stack/20261020T220206Z-4429a104f6d6
|
||||
stack-release: installed 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
stack-release: gate passed on 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
stack-release: activate ok: 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 is live for mosaic-stack
|
||||
# exit 0
|
||||
@@ -0,0 +1,2 @@
|
||||
W0b 2026-10-20T22:00:49Z business file copy /home/jwoltje/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-20261020T220049Z sha256 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93 (source /home/jwoltje/.config/mosaic-dev/businesses/mosaic-stack.json sha256 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93)
|
||||
W5 2026-10-20T22:02:13Z activation 20261020T220206Z-4429a104f6d6 backup /home/jwoltje/.mosaic-dev/backups/stack/20261020T220206Z-4429a104f6d6 SHA256SUMS sha256 b61d64843c9e2a153842606353450838da9cb778c8f3b9c27cc9366dd72ec0c9
|
||||
@@ -0,0 +1,7 @@
|
||||
a 200 ok=true
|
||||
b 401 error="sign-in required"
|
||||
c 401 error="sign-in required"
|
||||
d 403 error="proxy Host refused"
|
||||
e 403 error="tester listener required"
|
||||
f 403 error="proxy header refused"
|
||||
g 200 non-json 6575 bytes
|
||||
@@ -0,0 +1,8 @@
|
||||
# P6 2026-10-20T22:00:17Z
|
||||
a 200 ok=true
|
||||
b 401 error="sign-in required"
|
||||
c 401 error="sign-in required"
|
||||
d 403 error="proxy Host refused"
|
||||
e 403 error="tester listener required"
|
||||
f 403 error="proxy header refused"
|
||||
g 200 non-json 6454 bytes
|
||||
@@ -0,0 +1,4 @@
|
||||
copy 490e2771d: roles 6, projects 1, arbiters 2
|
||||
HOLDS chief instance.name yes
|
||||
HOLDS pm instance.name no
|
||||
LOAD_OK
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"bus.tester": true,
|
||||
"plugins": {
|
||||
"usage-source": [
|
||||
"transcript"
|
||||
],
|
||||
"channel": [
|
||||
"cli",
|
||||
"webui"
|
||||
],
|
||||
"harness": [
|
||||
"claude-code"
|
||||
]
|
||||
},
|
||||
"launch.by": "chief",
|
||||
"launch.instances has assistant": true,
|
||||
"roles with pi harness": [],
|
||||
"sha256": "1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
start 2026-10-20T21:46:09Z
|
||||
business-check.mjs: OK
|
||||
console-check.sh: OK
|
||||
1ba3c7efbc84a1d7eb8ee0068d32b7917f4e75cdc4066703944cea4c20db5b93 /mnt/storage/scratch/sage-pin-de352dfaf/fixture/console-stub.mjs
|
||||
a4500094e7c7a6dc21797eaf2d696f9814f272ece945c2735b79e4593f9c9503 /mnt/storage/scratch/sage-p2-4429/console-stub-tester.mjs
|
||||
== run 1: pinned stub, no tester listener
|
||||
stub pid 2495939 port 39561
|
||||
PASS markdown-preview.js is served and equals the copy's
|
||||
PASS /api/config on loopback: send=false edit=false
|
||||
PASS project.files through the Console: head=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa files=2 spec=true error=none
|
||||
PASS project.file spec/north-star.md through the Console: commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa length=4 error=none
|
||||
PASS loopback save refused: HTTP 403 error=read-only sent=false
|
||||
CONSOLE_PASS
|
||||
PORT 39561
|
||||
CALLS []
|
||||
run 1 console-check rc=0
|
||||
== run 2: main listener plus a tester listener on a second port
|
||||
stub pid 2496085 port 35213 tester port 35945
|
||||
PASS markdown-preview.js is served and equals the copy's
|
||||
PASS /api/config on loopback: send=false edit=false
|
||||
PASS project.files through the Console: head=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa files=2 spec=true error=none
|
||||
PASS project.file spec/north-star.md through the Console: commit=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa length=4 error=none
|
||||
PASS loopback save refused: HTTP 403 error=read-only sent=false
|
||||
CONSOLE_PASS
|
||||
PASS main refuses x-mosaic-tester: HTTP 403 "tester listener required"
|
||||
PASS tester /api/config: HTTP 200 "tester":true
|
||||
PASS tester /api/config send and edit off: HTTP 200 "send":false,"edit":false
|
||||
PASS tester refuses the main auth header: HTTP 403 "proxy header refused"
|
||||
PASS tester refuses project-file save: HTTP 403
|
||||
PORT 35213
|
||||
TPORT 35945
|
||||
CALLS []
|
||||
TESTER_PASS
|
||||
run 2 console-check rc=0 tester rc=0
|
||||
end 2026-10-20T21:46:10Z
|
||||
@@ -0,0 +1 @@
|
||||
2026-10-20T21:52:34Z
|
||||
@@ -0,0 +1,10 @@
|
||||
flags-check start 2026-10-20T21:53:52Z
|
||||
pid 2729699 alive: yes
|
||||
127.0.0.1:7341
|
||||
127.0.0.1:7342
|
||||
main /healthz: 200
|
||||
tester /healthz: 200
|
||||
main /api/config: {"board":"http://127.0.0.1:7331","send":false,"edit":false}
|
||||
pid 2729699 after TERM: gone
|
||||
listeners after: 0
|
||||
flags-check end 2026-10-20T21:53:56Z
|
||||
@@ -0,0 +1,7 @@
|
||||
Mosaic Console: http://127.0.0.1:7341/
|
||||
Board: http://127.0.0.1:7331
|
||||
Bus: mosaic-stack
|
||||
Preview: https://mosaic.woltje.com/ through the proxy, signed-in user from x-authentik-username, read-only
|
||||
Proxy: proxy proof off
|
||||
Tester Console: http://127.0.0.1:7342/ (binds the tester)
|
||||
Ctrl-C stops this server.
|
||||
@@ -0,0 +1 @@
|
||||
rc=0
|
||||
@@ -0,0 +1,20 @@
|
||||
PASS copy: 4429a104f6d65ccf165f6c83b5d1dce3147dfde0, schema 3i
|
||||
PASS port: 127.0.0.1:7341 is free
|
||||
PASS backup: /home/jwoltje/.mosaic-dev/backups/stack/20261020T192956Z-490e2771d3d0 matches its SHA256SUMS
|
||||
PASS scratch-root: /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/data from the backup, config and business file under /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/config, business.diff beside them
|
||||
PASS data-root-live: /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/data is not the live data root
|
||||
PASS sessions-live: no running session named in the data root
|
||||
PASS data-root-scratch
|
||||
PASS notify-off: binding null, so no Discord bridge
|
||||
PASS tracker-configured: bootConfig sets no trackers
|
||||
PASS credential-ref-live: 9 reference(s), each a placeholder
|
||||
PASS migration: not needed: the backup is already 3i
|
||||
PASS gate: host-up, bus-status, console-up, healthz and copy-match on 4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
PASS no-session: no sessions.json and no launches under /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6/data
|
||||
PASS console-stopped: pid 2675437
|
||||
PASS host-stopped: pid 2675436
|
||||
PASS host-released-store
|
||||
PASS backup-unchanged
|
||||
PASS live-config-unchanged
|
||||
REHEARSAL_PASS
|
||||
rehearsal directory: /home/jwoltje/.mosaic-dev/rehearsals/20261020T215231Z-4429a104f6d6
|
||||
@@ -0,0 +1 @@
|
||||
2026-10-20T21:52:31Z
|
||||
@@ -0,0 +1,7 @@
|
||||
# P4 2026-10-20T22:00:17Z
|
||||
DropInPaths=
|
||||
ExecStart={ path=/home/jwoltje/.local/bin/chisel ; argv[]=/home/jwoltje/.local/bin/chisel client --fingerprint ce+ISFvMAhuoCRDuc7riIf+hYz87TLHk/C9C/H8QsUg= --keepalive 25s --max-retry-interval 10s http://10.1.1.227:8080 R:0.0.0.0:7332:127.0.0.1:7332 ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
|
||||
# exit 0
|
||||
# R: specs (1): R:0.0.0.0:7332:127.0.0.1:7332
|
||||
P4 PASS: one R: spec R:0.0.0.0:7332:127.0.0.1:7332, DropInPaths empty
|
||||
rc 0
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# P4-P6 of agents/sage/work/window-plan/repin-4429a104f.md (f41729178), adapted from Darkwing's window-rows-177-185/p456.sh, read only.
|
||||
# P4 the tunnel unit, P5 the four REPO scripts against NEW, P6 the 7332 matrix on OLD (matrix-old.txt).
|
||||
# usage: p456.sh p4|p5|p6
|
||||
set -uo pipefail
|
||||
REPO=/mnt/storage/src/mosaic-stack
|
||||
WIN=$REPO/agents/sage/work/window-repin-4429a104f
|
||||
NEW=4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
FILES=(scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh)
|
||||
# The plan's <32 letters>: a fixed dummy, not a secret. matrix-new.txt uses the same value.
|
||||
PROOF=abcdefghijklmnopqrstuvwxyzabcdef
|
||||
|
||||
p4() {
|
||||
echo "# P4 $(date -u +%FT%TZ)"
|
||||
local out; out="$(systemctl --user show mosaic-preview-tunnel.service -p ExecStart -p DropInPaths)"; local rc=$?
|
||||
printf '%s\n' "$out"; echo "# exit $rc"
|
||||
local specs; specs="$(grep -oE 'R:[^ ;]+' <<<"$out")"
|
||||
local n; n="$(grep -c . <<<"$specs")"
|
||||
echo "# R: specs ($n): $(tr '\n' ' ' <<<"$specs")"
|
||||
local drop; drop="$(sed -n 's/^DropInPaths=//p' <<<"$out")"
|
||||
if [[ $rc -eq 0 && $n -eq 1 && "$specs" == "R:0.0.0.0:7332:127.0.0.1:7332" && -z "$drop" ]]; then
|
||||
echo "P4 PASS: one R: spec R:0.0.0.0:7332:127.0.0.1:7332, DropInPaths empty"
|
||||
else
|
||||
echo "P4 FAIL"; return 3
|
||||
fi
|
||||
}
|
||||
|
||||
p5() {
|
||||
echo "# P5 $(date -u +%FT%TZ); HEAD $(git -C $REPO rev-parse HEAD)"
|
||||
git -C $REPO diff --quiet $NEW HEAD -- "${FILES[@]}"; local d=$?
|
||||
echo "# git diff --quiet $NEW HEAD -- ${FILES[*]}: exit $d"
|
||||
local st; st="$(git -C $REPO status --porcelain -- "${FILES[@]}")"; local s=$?
|
||||
echo "# git status --porcelain (exit $s): [${st}]"
|
||||
for f in "${FILES[@]}"; do echo "# $f HEAD blob $(git -C $REPO rev-parse HEAD:$f) NEW blob $(git -C $REPO rev-parse $NEW:$f)"; done
|
||||
if [[ $d -eq 0 && $s -eq 0 && -z "$st" ]]; then echo "P5 PASS"; else echo "P5 FAIL"; return 3; fi
|
||||
}
|
||||
|
||||
# one matrix line: label, then curl arguments; prints "<label> <code> <error or ok field>"
|
||||
req() {
|
||||
local label="$1"; shift
|
||||
local body; body="$(mktemp -p "${TMPDIR:-/tmp}")"
|
||||
local code; code="$(curl -s -m 10 -o "$body" -w '%{http_code}' "$@")"
|
||||
local field; field="$(node -e '
|
||||
let s = require("fs").readFileSync(process.argv[1], "utf8"), j;
|
||||
try { j = JSON.parse(s); } catch { console.log("non-json " + s.length + " bytes"); process.exit(0); }
|
||||
console.log(j.error !== undefined ? "error=" + JSON.stringify(j.error) : "ok=" + JSON.stringify(j.ok));
|
||||
' "$body")"
|
||||
rm -f "$body"
|
||||
echo "$label $code $field"
|
||||
}
|
||||
|
||||
p6() {
|
||||
req a http://127.0.0.1:7332/healthz
|
||||
req b -H 'Host: mosaic.woltje.com' http://127.0.0.1:7332/
|
||||
req c -H 'Host: mosaic.woltje.com' -H "X-Mosaic-Proof: $PROOF" http://127.0.0.1:7332/
|
||||
req d -H 'X-authentik-username: tester-probe' http://127.0.0.1:7332/
|
||||
req e -H 'x-mosaic-tester: 1' http://127.0.0.1:7332/
|
||||
req f -H 'X-authentik-username: tester-probe' http://127.0.0.1:7333/
|
||||
req g -H "X-Mosaic-Proof: $PROOF" http://127.0.0.1:7333/
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
p4) p4 ;;
|
||||
p5) p5 ;;
|
||||
p6) p6 ;;
|
||||
*) echo "usage: p456.sh p4|p5|p6" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -0,0 +1,9 @@
|
||||
# P5 2026-10-20T22:00:17Z; HEAD 4385b9ec183a2da4136ccd639d2a6b041eb9c68f
|
||||
# git diff --quiet 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 HEAD -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh: exit 0
|
||||
# git status --porcelain (exit 0): []
|
||||
# scripts/stack-release.sh HEAD blob 3b068b1546d4020cffde5d73929d9130afe91911 NEW blob 3b068b1546d4020cffde5d73929d9130afe91911
|
||||
# scripts/webui-service.sh HEAD blob 0a32547c54ac233e75a21f4817ae230855dbc088 NEW blob 0a32547c54ac233e75a21f4817ae230855dbc088
|
||||
# scripts/bus-service.sh HEAD blob f71250204dfeceb51beeea5941ebbf3a33ce4b92 NEW blob f71250204dfeceb51beeea5941ebbf3a33ce4b92
|
||||
# scripts/stack-copy.sh HEAD blob 24bbb02a6aa8d7857da7be3b6815105a9fef629f NEW blob 24bbb02a6aa8d7857da7be3b6815105a9fef629f
|
||||
P5 PASS
|
||||
rc 0
|
||||
@@ -0,0 +1,235 @@
|
||||
// Merged window B+C, S1, S2 and D3 (repin-merged-bc.md "S1 and S2"): a read-only snapshot of every
|
||||
// table in a bus store, so the 3h to 3i migration and a restore can be checked without the bus.
|
||||
// OLD's broker doesn't take --tester on usage, names or threads.
|
||||
//
|
||||
// usage: node snapshot.mjs --store /abs/bus.sqlite (--held | --stopped) [--after STAMP] [--backup /abs/bus.sqlite]
|
||||
// --held the running bus holds the store: a plain readOnly open, which adds no file to a WAL
|
||||
// store that already has -wal and -shm (as stack-release.sh does for a held store).
|
||||
// Refuses store-not-held when either is absent
|
||||
// --stopped the bus is stopped: refuses store-unclean when -wal or -shm is present, else opens
|
||||
// with immutable=1 and readOnly, which creates no file and leaves the store's mtime
|
||||
// and sha256 alone
|
||||
// --after also counts each timed table's rows with `at` after STAMP (mos-0838 D3, before a
|
||||
// --restore). STAMP is a backup's STAMP file, in now_ms form (stack-release.sh:43,
|
||||
// 2026-10-20T00:11:14.123Z). The count is a text compare, as the tool's own heldcount
|
||||
// does, so every `at` in every timed table must have that form: a table with any
|
||||
// other value refuses at-format and names the table (sage-1125)
|
||||
// --backup also compares every table with a stack-release backup's bus.sqlite, which opens as
|
||||
// --stopped opens a store: immutable=1 and readOnly, refusing backup-unclean when -wal
|
||||
// or -shm is present, so nothing writes into the backup set
|
||||
//
|
||||
// It prints one JSON object: the schema version, then for every table in sqlite_master (type
|
||||
// table, less sqlite_*) the row count, the max seq and a sha256 of the rows (in seq order, else in
|
||||
// column order). `untimed` names the tables with no `at` column. For usage_records it adds per-run
|
||||
// input and output sums, counted as `mosaic usage` counts them (usage.mjs turnRecords). messages
|
||||
// and message_threads hold bodies and names, so they print counts only, no sha256 (sage-1125). It
|
||||
// prints counts, hashes and table and meta key names only: no message body, no instance, project
|
||||
// or thread name, no credential and no file content. It reads no config: the store path is always
|
||||
// given.
|
||||
//
|
||||
// With --backup, each table gets `vsBackup` and `stopTables` names every table other than events
|
||||
// whose rows differ from the backup's (sage-1123): a row with `at` after STAMP, a row past the
|
||||
// backup's max seq, a changed row at or below it, a changed untimed table, or a table only one
|
||||
// side has. meta counts only when a key other than schema_version and schema_digest changed (the
|
||||
// 3h to 3i migration sets those two). Events are left to the tool's own --events N.
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, lstatSync } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
// usage_records is hashed on these columns in this order, never SELECT * (mos-0888 F1), as
|
||||
// 3h-3i.sql:9 copies it. Every other table is hashed on its own columns in name order, so a
|
||||
// rebuild that reorders columns keeps the hash. usage_records with any other column set refuses.
|
||||
const USAGE_COLUMNS = ['seq', 'at', 'business', 'run', 'instance', 'provider', 'model', 'account', 'turn', 'source',
|
||||
'phase', 'status', 'code', 'cursor', 'input', 'output', 'cache_write', 'cache_read'];
|
||||
const COUNTS_ONLY = new Set(['messages', 'message_threads']);
|
||||
const SCHEMA_KEYS = ['schema_digest', 'schema_version'];
|
||||
const STAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
||||
|
||||
function refuse(code, detail) {
|
||||
process.stderr.write(`snapshot: ${code}: ${detail}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function args(argv) {
|
||||
const o = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--store' || a === '--after' || a === '--backup') {
|
||||
if (i + 1 >= argv.length) refuse('usage', `${a} needs a value`);
|
||||
o[a.slice(2)] = argv[++i];
|
||||
} else if (a === '--held' || a === '--stopped') {
|
||||
if (o.mode) refuse('usage', 'give one of --held and --stopped');
|
||||
o.mode = a.slice(2);
|
||||
} else refuse('usage', `unknown argument ${a}`);
|
||||
}
|
||||
if (!o.store || !o.mode) refuse('usage', 'node snapshot.mjs --store /abs/bus.sqlite (--held | --stopped) [--after STAMP] [--backup /abs/bus.sqlite]');
|
||||
if (!isAbsolute(o.store)) refuse('usage', '--store must be an absolute path');
|
||||
if (o.backup !== undefined && !isAbsolute(o.backup)) refuse('usage', '--backup must be an absolute path');
|
||||
if (o.after !== undefined && !(STAMP.test(o.after) && Number.isFinite(Date.parse(o.after))))
|
||||
refuse('usage', '--after must be a STAMP like 2026-10-20T00:11:14.123Z');
|
||||
return o;
|
||||
}
|
||||
|
||||
function file(path, code) {
|
||||
if (!existsSync(path) || !lstatSync(path).isFile()) refuse(code, `${path} is not a file`);
|
||||
}
|
||||
|
||||
function immutable(path, code) {
|
||||
for (const f of [path + '-wal', path + '-shm'])
|
||||
if (existsSync(f)) refuse(code, `${f} is present; an immutable read would skip the WAL`);
|
||||
return new DatabaseSync(pathToFileURL(path).href + '?immutable=1', { readOnly: true });
|
||||
}
|
||||
|
||||
function open({ store, mode }) {
|
||||
file(store, 'store-missing');
|
||||
if (mode === 'stopped') return immutable(store, 'store-unclean');
|
||||
// A plain readOnly open of a WAL store with no -wal and -shm creates them, so a store that
|
||||
// nothing holds refuses here instead of gaining two files.
|
||||
for (const f of [store + '-wal', store + '-shm'])
|
||||
if (!existsSync(f)) refuse('store-not-held', `${f} is absent; use --stopped for a store the bus doesn't hold`);
|
||||
return new DatabaseSync(store, { readOnly: true });
|
||||
}
|
||||
|
||||
const q = (name) => `"${name.replaceAll('"', '""')}"`;
|
||||
const value = (v) => (typeof v === 'bigint' ? String(v) : v instanceof Uint8Array ? { hex: Buffer.from(v).toString('hex') } : v);
|
||||
|
||||
// The same records `mosaic usage` counts: per (run, turn, source) the latest row, an end row
|
||||
// winning; a turn is known only when that row is an end row with status known.
|
||||
function byRun(rows) {
|
||||
const turns = new Map();
|
||||
for (const row of rows) {
|
||||
const key = JSON.stringify([row.business, row.run, row.turn, row.source]);
|
||||
if (row.phase === 'end' || !turns.has(key)) turns.set(key, row);
|
||||
}
|
||||
const runs = new Map();
|
||||
for (const row of turns.values()) {
|
||||
const key = JSON.stringify([row.business, row.run]);
|
||||
if (!runs.has(key)) runs.set(key, { business: row.business, run: row.run, input: 0n, output: 0n, unknown: new Set() });
|
||||
const r = runs.get(key);
|
||||
if (row.phase === 'end' && row.status === 'known') {
|
||||
r.input += BigInt(row.input);
|
||||
r.output += BigInt(row.output);
|
||||
} else r.unknown.add(row.turn);
|
||||
}
|
||||
return [...runs.values()].map(({ unknown, input, output, ...r }) => ({
|
||||
...r, input: String(input), output: String(output), unknownTurns: unknown.size,
|
||||
}));
|
||||
}
|
||||
|
||||
// meta is compared by key: the names of the keys whose value differs, never the values.
|
||||
function metaKeys(db) {
|
||||
return new Map(db.prepare('SELECT key, value FROM meta').all().map((r) => [String(r.key), String(r.value)]));
|
||||
}
|
||||
|
||||
// One table: rows, max seq and sha256; rows after `after`; with `priorMax` (the backup's max
|
||||
// seq) a second hash of the rows at or below it and the count of rows above it. The hashes stay
|
||||
// inside: a COUNTS_ONLY table's `t` gets none.
|
||||
function read(db, table, { after, priorMax } = {}) {
|
||||
const cols = db.prepare('SELECT name FROM pragma_table_info(?)').all(table).map((c) => c.name);
|
||||
if (!cols.length) refuse('table-missing', table);
|
||||
let hashed = [...cols].sort();
|
||||
if (table === 'usage_records') {
|
||||
if (JSON.stringify(hashed) !== JSON.stringify([...USAGE_COLUMNS].sort()))
|
||||
refuse('columns-changed', `usage_records has columns ${hashed.join(',')}`);
|
||||
hashed = USAGE_COLUMNS;
|
||||
}
|
||||
const seq = cols.includes('seq');
|
||||
const at = cols.includes('at');
|
||||
const stmt = db.prepare(`SELECT ${hashed.map(q).join(',')} FROM ${q(table)} ORDER BY ${seq ? 'seq' : hashed.map(q).join(',')}`);
|
||||
stmt.setReadBigInts(true);
|
||||
const full = createHash('sha256');
|
||||
const prior = createHash('sha256');
|
||||
const limit = priorMax === undefined ? undefined : BigInt(priorMax ?? 0);
|
||||
let rows = 0;
|
||||
let rowsAfter = 0;
|
||||
let badAt = 0;
|
||||
let newRows = 0;
|
||||
const kept = [];
|
||||
for (const row of stmt.iterate()) {
|
||||
rows++;
|
||||
const line = JSON.stringify(hashed.map((c) => value(row[c]))) + '\n';
|
||||
full.update(line);
|
||||
if (seq && limit !== undefined) {
|
||||
if (row.seq <= limit) prior.update(line);
|
||||
else newRows++;
|
||||
}
|
||||
if (after !== undefined && at) {
|
||||
if (typeof row.at !== 'string' || !STAMP.test(row.at)) badAt++;
|
||||
else if (row.at > after) rowsAfter++;
|
||||
}
|
||||
if (table === 'usage_records') kept.push(row);
|
||||
}
|
||||
if (badAt) refuse('at-format', `${table} has ${badAt} row(s) whose at is not in the STAMP form, so a text compare with --after would miscount`);
|
||||
const sha256 = full.digest('hex');
|
||||
const t = { rows, maxSeq: seq ? (db.prepare(`SELECT max(seq) AS m FROM ${q(table)}`).get().m ?? null) : null };
|
||||
if (!COUNTS_ONLY.has(table)) t.sha256 = sha256;
|
||||
if (after !== undefined && at) t.rowsAfter = rowsAfter;
|
||||
if (table === 'usage_records') t.byRun = byRun(kept);
|
||||
return { t, sha256, cols: hashed, seq, at, newRows, priorSha: seq && limit !== undefined ? prior.digest('hex') : null };
|
||||
}
|
||||
|
||||
// Tables only: no index, view or trigger, and none of SQLite's own sqlite_* tables.
|
||||
const tablesOf = (db) => db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name")
|
||||
.all().map((r) => r.name);
|
||||
|
||||
const o = args(process.argv.slice(2));
|
||||
if (o.backup !== undefined) file(o.backup, 'backup-missing');
|
||||
const db = open(o);
|
||||
const bdb = o.backup === undefined ? null : immutable(o.backup, 'backup-unclean');
|
||||
const out = {};
|
||||
try {
|
||||
db.exec('BEGIN');
|
||||
out.schema = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value ?? null;
|
||||
const names = tablesOf(db);
|
||||
const base = new Map();
|
||||
if (bdb) {
|
||||
out.backupSchema = bdb.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value ?? null;
|
||||
for (const name of tablesOf(bdb)) base.set(name, read(bdb, name));
|
||||
}
|
||||
out.tables = {};
|
||||
out.untimed = [];
|
||||
const stop = [];
|
||||
for (const name of names) {
|
||||
const b = base.get(name);
|
||||
const r = read(db, name, { after: o.after, priorMax: b ? b.t.maxSeq : undefined });
|
||||
if (!r.at) out.untimed.push(name);
|
||||
if (bdb) {
|
||||
const v = { backupRows: b ? b.t.rows : null };
|
||||
let changed;
|
||||
if (!b) changed = true;
|
||||
else if (name === 'meta') {
|
||||
const now = metaKeys(db);
|
||||
const then = metaKeys(bdb);
|
||||
v.keysChanged = [...new Set([...now.keys(), ...then.keys()])].filter((k) => now.get(k) !== then.get(k)).sort();
|
||||
changed = v.keysChanged.some((k) => !SCHEMA_KEYS.includes(k));
|
||||
} else if (r.seq) {
|
||||
v.backupMaxSeq = b.t.maxSeq;
|
||||
v.newRows = r.newRows;
|
||||
v.priorSame = JSON.stringify(r.cols) === JSON.stringify(b.cols) && r.priorSha === b.sha256;
|
||||
changed = r.newRows > 0 || !v.priorSame;
|
||||
} else {
|
||||
v.same = JSON.stringify(r.cols) === JSON.stringify(b.cols) && r.sha256 === b.sha256;
|
||||
changed = !v.same;
|
||||
}
|
||||
if ((r.t.rowsAfter ?? 0) > 0) changed = true;
|
||||
r.t.vsBackup = v;
|
||||
if (changed && name !== 'events') stop.push(name);
|
||||
}
|
||||
out.tables[name] = r.t;
|
||||
}
|
||||
if (bdb) {
|
||||
out.onlyInBackup = [...base.keys()].filter((n) => !names.includes(n));
|
||||
out.stopTables = [...stop, ...out.onlyInBackup].sort();
|
||||
}
|
||||
if (o.after !== undefined) {
|
||||
out.after = o.after;
|
||||
out.eventsAfter = out.tables.events?.rowsAfter ?? null;
|
||||
}
|
||||
} finally {
|
||||
try { db.exec('ROLLBACK'); } catch {}
|
||||
db.close();
|
||||
bdb?.close();
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Re-pin 4429a104f window step wrapper (copied from Darkwing's window-rows-177-185/w.sh): w.sh FILE TITLE CMD... runs CMD in REPO under a clean env (no ANTHROPIC_*, CLAUDE_*,
|
||||
# MOSAIC_* or pi variables), appends the title, command, output, exit code and date -u stamps to window/FILE,
|
||||
# prints the output, and exits with CMD's exit code.
|
||||
set -uo pipefail
|
||||
W=/mnt/storage/src/mosaic-stack/agents/sage/work/window-repin-4429a104f
|
||||
f="$W/$1"; title="$2"; shift 2
|
||||
E=(env -i HOME="$HOME" PATH="$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" LANG=C.UTF-8
|
||||
XDG_RUNTIME_DIR="$XDG_RUNTIME_DIR" DBUS_SESSION_BUS_ADDRESS="${DBUS_SESSION_BUS_ADDRESS:-unix:path=$XDG_RUNTIME_DIR/bus}")
|
||||
t0=$(date -u +%FT%T.%3NZ)
|
||||
{ echo; echo "## $title"; echo "# start $t0"; echo "# command: (cd \$REPO && <clean env> $*)"; } >> "$f"
|
||||
out=$(cd /mnt/storage/src/mosaic-stack && "${E[@]}" "$@" 2>&1); rc=$?
|
||||
printf '%s\n' "$out" >> "$f"
|
||||
echo "# exit $rc, end $(date -u +%FT%T.%3NZ)" >> "$f"
|
||||
printf '%s\n# exit %s\n' "$out" "$rc"
|
||||
exit $rc
|
||||
@@ -0,0 +1,9 @@
|
||||
npm warn deprecated [email protected]: Use your platform's native DOMException instead
|
||||
|
||||
added 128 packages in 2s
|
||||
npm warn install-scripts 3 packages had install scripts blocked because they are not covered by allowScripts:
|
||||
npm warn install-scripts @google/[email protected] (preinstall: echo 'preinstall: no-op')
|
||||
npm warn install-scripts [email protected] (postinstall: node install.js)
|
||||
npm warn install-scripts [email protected] (postinstall: node scripts/postinstall)
|
||||
npm warn install-scripts
|
||||
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.
|
||||
@@ -0,0 +1 @@
|
||||
copied: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
@@ -0,0 +1,16 @@
|
||||
start 2026-10-20T21:45:57Z
|
||||
stack-copy rc=0
|
||||
copied: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
npm warn install-scripts [email protected] (postinstall: node scripts/postinstall)
|
||||
npm warn install-scripts
|
||||
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.
|
||||
oauth4webapi 3.8.8
|
||||
IMPORT_OK
|
||||
LOCK_MATCH
|
||||
163M /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
/dev/nvme1n1p5 458G 405G 30G 94% /home
|
||||
copy 4429a104f: roles 6, projects 1, arbiters 2
|
||||
HOLDS chief instance.name yes
|
||||
HOLDS pm instance.name no
|
||||
LOAD_OK
|
||||
end 2026-10-20T21:45:59Z
|
||||
@@ -0,0 +1,109 @@
|
||||
// W10 of repin-4429a104f.md (e1272d1a), adapted from Darkwing's window-rows-177-185/w10-browser.mjs: the tester listener
|
||||
// on 127.0.0.1:7333 through the webui browser harness (packages/webui/tests/browser.mjs, same blob at HEAD and NEW). Reads only: before any page
|
||||
// script runs, fetch and XMLHttpRequest are wrapped so every call is logged and any method other than
|
||||
// GET or HEAD is refused in the page, and the script submits no form and clicks no button. The chat
|
||||
// check reads the #/chat list only and never opens the W9 thread page (the launcher reads lastOpen).
|
||||
import { browser } from '/mnt/storage/src/mosaic-stack/packages/webui/tests/browser.mjs';
|
||||
|
||||
const OUT = '/mnt/storage/src/mosaic-stack/agents/sage/work/window-repin-4429a104f';
|
||||
const BASE = 'http://127.0.0.1:7333/';
|
||||
const W9_THREAD = '446201de-e8b5-4df3-9356-d0d5422cc899';
|
||||
const GUARD = `(() => {
|
||||
const log = window.__w10 = [];
|
||||
const ok = m => m === 'GET' || m === 'HEAD';
|
||||
const f = window.fetch;
|
||||
window.fetch = (input, init = {}) => {
|
||||
const method = String(init.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
||||
const url = String(input instanceof Request ? input.url : input);
|
||||
log.push({ method, url, refused: !ok(method) });
|
||||
return ok(method) ? f(input, init) : Promise.reject(new TypeError('w10 refused ' + method));
|
||||
};
|
||||
const open = XMLHttpRequest.prototype.open, send = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function (method, url, ...rest) { this.__m = String(method).toUpperCase(); this.__u = String(url); return open.call(this, method, url, ...rest); };
|
||||
XMLHttpRequest.prototype.send = function (...a) { log.push({ method: this.__m, url: this.__u, xhr: true, refused: !ok(this.__m) }); if (!ok(this.__m)) throw new TypeError('w10 refused ' + this.__m); return send.apply(this, a); };
|
||||
})();`;
|
||||
|
||||
const b = await browser({ machine: false });
|
||||
const result = {};
|
||||
let failed = false;
|
||||
const check = (name, pass, detail) => { result[name] = { pass, ...(detail === undefined ? {} : { detail }) }; if (!pass) failed = true; };
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: GUARD });
|
||||
await b.viewport(1280, 1600);
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<300;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,50));}return false;})()`);
|
||||
|
||||
await b.navigate(BASE + '#/settings');
|
||||
const ready = await wait('!!document.querySelector("#tester-banner") && !!document.querySelector("#routes-form") && !!document.querySelector(".tester-name-form")');
|
||||
check('settings loaded', ready);
|
||||
const s = await b.evaluate(`(() => {
|
||||
const q = x => document.querySelector(x);
|
||||
return {
|
||||
banner: q('#tester-banner')?.textContent.trim() ?? null,
|
||||
h1: q('#bus-view h1')?.textContent.trim() ?? null,
|
||||
forms: [...document.querySelectorAll('form')].map(f => f.id || f.className),
|
||||
convFormHidden: q('#conv-form')?.hidden ?? null,
|
||||
panels: [...document.querySelectorAll('#bus-view h2')].map(h => h.textContent.trim()),
|
||||
instancesTable: !!q('#instances-table'),
|
||||
nameForms: document.querySelectorAll('.tester-name-form').length,
|
||||
routesForm: !!q('#routes-form'),
|
||||
routesSubmitEnabled: q('#routes-form button[type=submit]')?.disabled === false,
|
||||
forbidden: [...document.querySelectorAll('#launch-settings-form, .assistant-form, #token-limits-table [data-limit-reset], #send-section')].map(e => e.id || e.className || e.tagName),
|
||||
tokenLimitsPanel: !!q('#token-limits-title') || !!q('#token-limits-table'),
|
||||
};
|
||||
})()`);
|
||||
result.settings = s;
|
||||
check('tester banner', /tester/i.test(s.banner ?? ''), s.banner);
|
||||
check('instance, routes and name editors present', s.instancesTable && s.routesForm && s.nameForms > 0);
|
||||
check('only tester forms (conv-form is index.html:21, global and hidden)', s.convFormHidden === true && s.forms.every(f => f === 'conv-form' || f === 'routes-form' || /tester-name-form/.test(f) || /^instance-(add|change)-form$/.test(f)), s.forms);
|
||||
check('no launch settings, assistant, limit reset or send', s.forbidden.length === 0, s.forbidden);
|
||||
check('no Token limits panel (plan W10 item 2, amended)', s.tokenLimitsPanel === false);
|
||||
await b.screenshot(`${OUT}/w10-settings.png`);
|
||||
|
||||
await b.navigate(BASE + '#/');
|
||||
// The Board is index.html:22 #board-view; bus.js hides #bus-view on it.
|
||||
const board = await wait('document.querySelector("#bus-view")?.hidden === true && !!document.querySelector("#board-view #waiting-title")');
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
const bd = await b.evaluate(`(() => { const v = document.querySelector('#board-view'); return {
|
||||
busViewHidden: document.querySelector('#bus-view')?.hidden ?? null,
|
||||
banner: !!document.querySelector('#tester-banner'),
|
||||
headings: [...v.querySelectorAll('h2')].map(h => h.textContent.replace(/\\s+/g, ' ').trim()),
|
||||
unavailable: /unavailable|refused|not authorized/i.test(v.textContent),
|
||||
length: v.textContent.length,
|
||||
forms: [...document.querySelectorAll('form')].map(f => f.id || f.className),
|
||||
}; })()`);
|
||||
result.board = bd;
|
||||
check('board reads', board && bd.busViewHidden === true && !bd.unavailable && bd.headings.length > 0, bd);
|
||||
await b.screenshot(`${OUT}/w10-board.png`);
|
||||
|
||||
await b.navigate(BASE + '#/chat');
|
||||
// chat.js threads(): ul.chat-thread-list with <a href="#/chat/threads/<id>">. Read only; no link is followed.
|
||||
const chatReady = await wait('!!document.querySelector("ul.chat-thread-list") || /No threads yet/.test(document.body.textContent)');
|
||||
const c = await b.evaluate(`(() => {
|
||||
const list = document.querySelector('ul.chat-thread-list');
|
||||
return {
|
||||
hash: location.hash,
|
||||
banner: !!document.querySelector('#tester-banner'),
|
||||
threads: list ? [...list.querySelectorAll('li')].map(li => ({ href: li.querySelector('a')?.getAttribute('href') ?? null, name: li.querySelector('a')?.textContent.trim() ?? null, instance: li.querySelector('.muted')?.textContent.trim() ?? null })) : [],
|
||||
conversationOpen: !!document.querySelector('.chat-conversation'),
|
||||
forms: [...document.querySelectorAll('form')].map(f => f.id || f.className),
|
||||
};
|
||||
})()`);
|
||||
result.chat = c;
|
||||
check('chat lists the W9 thread', chatReady && c.threads.some(t => t.href === '#/chat/threads/' + W9_THREAD), c.threads);
|
||||
check('W9 thread page not opened', c.hash === '#/chat' && c.conversationOpen === false, c.hash);
|
||||
check('tester banner on chat', c.banner);
|
||||
await b.screenshot(`${OUT}/w10-chat.png`);
|
||||
|
||||
const log = await b.evaluate('window.__w10');
|
||||
result.requests = log;
|
||||
check('no write through the listener', log.every(r => !r.refused && (r.method === 'GET' || r.method === 'HEAD')), log.filter(r => r.refused));
|
||||
check('no request to the W9 thread messages or open', !log.some(r => r.url.includes(W9_THREAD)), log.filter(r => r.url.includes(W9_THREAD)));
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
result.error = String(e?.message ?? e);
|
||||
} finally {
|
||||
await b.close();
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
console.log(failed ? 'W10 browser: FAIL' : 'W10 browser: pass');
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -0,0 +1,30 @@
|
||||
// W10 diagnosis after the two failed checks of w10-browser.mjs (sign-out form listed; chat list missing the W9 thread).
|
||||
// Read only, with the same GET/HEAD guard as w10-browser.mjs. Records each form's hidden flag on Settings, the /api/config
|
||||
// sso value the page sees, and every ul.chat-thread-list on #/chat (chat.js:93 renders one per project). No screenshots.
|
||||
import { browser } from '/mnt/storage/src/mosaic-stack/packages/webui/tests/browser.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
const src = readFileSync(new URL('./w10-browser.mjs', import.meta.url), 'utf8');
|
||||
const GUARD = eval(src.slice(src.indexOf('const GUARD = ') + 14, src.indexOf('`;', src.indexOf('const GUARD = ')) + 1));
|
||||
const BASE = 'http://127.0.0.1:7333/';
|
||||
const W9_THREAD = '446201de-e8b5-4df3-9356-d0d5422cc899';
|
||||
const b = await browser({ machine: false });
|
||||
const out = {};
|
||||
try {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: GUARD });
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<300;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,50));}return false;})()`);
|
||||
await b.navigate(BASE + '#/settings');
|
||||
await wait('!!document.querySelector("#routes-form")');
|
||||
out.config = await b.evaluate(`fetch('/api/config').then(r=>r.json()).then(j=>({sso:j.sso??null}))`);
|
||||
out.forms = await b.evaluate(`[...document.querySelectorAll('form')].map(f=>({id:f.id||f.className,hidden:f.hidden,action:f.getAttribute('action'),method:f.getAttribute('method')}))`);
|
||||
await b.navigate(BASE + '#/chat');
|
||||
await wait('document.querySelectorAll("ul.chat-thread-list").length >= 1');
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
out.chat = await b.evaluate(`({hash:location.hash,open:!!document.querySelector('.chat-conversation'),projects:[...document.querySelectorAll('.chat-projects section.panel')].map(s=>({project:s.querySelector('h2')?.textContent.trim(),threads:[...s.querySelectorAll('ul.chat-thread-list li a')].map(a=>a.getAttribute('href'))}))})`);
|
||||
out.requests = await b.evaluate('window.__w10');
|
||||
} finally { await b.close(); }
|
||||
const signOut = out.forms.find(f => f.id === 'sign-out');
|
||||
const listed = out.chat.projects.some(p => p.threads.includes('#/chat/threads/' + W9_THREAD));
|
||||
const writes = out.requests.filter(r => r.refused || !['GET', 'HEAD'].includes(r.method));
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
console.log(`sign-out hidden ${signOut?.hidden}, sso ${out.config.sso}; W9 thread listed ${listed}; thread page open ${out.chat.open}; writes ${writes.length}`);
|
||||
process.exit(signOut?.hidden === true && listed && !out.chat.open && writes.length === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,277 @@
|
||||
# W9 go mos-1114, start 2026-10-20T22:05:51Z
|
||||
# plan e1272d1a, tool blob 3b068b1546d4, console-check.sh d068fe02, wrapper on NEW: checked
|
||||
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
|
||||
{"copy":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T220206Z-4429a104f6d6","at":"2026-10-20T22:02:09Z"}
|
||||
log (last five lines):
|
||||
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
|
||||
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T22:02:06Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":null,"activation":"20261020T220206Z-4429a104f6d6","result":"started"}
|
||||
{"at":"2026-10-20T22:02:09Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"4429a104f6d65ccf165f6c83b5d1dce3147dfde0","backup":"20261020T220206Z-4429a104f6d6","activation":"20261020T220206Z-4429a104f6d6","result":"ok"}
|
||||
check:
|
||||
bus: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
Console: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
result: pass
|
||||
bus status:
|
||||
host: mosaic-stack, pid 2950923, running, since 2026-10-20T22:02:08.133Z, notifier off
|
||||
socket: present
|
||||
writer.lock: pid 2950981
|
||||
boot: organization file loaded at 2026-10-20T22:02:08.125Z
|
||||
unit flags: match the pointer
|
||||
drift: 6 commit(s) on refactor since the pointer's copy
|
||||
# exit 0
|
||||
PASS markdown-preview.js is served and equals the copy's
|
||||
PASS /api/config on loopback: send=false edit=false
|
||||
PASS project.files through the Console: head=ae0b17c9586e51da7bc1b7e548c6acf913a55665 files=2 spec=true error=none
|
||||
PASS project.file spec/north-star.md through the Console: commit=ae0b17c9586e51da7bc1b7e548c6acf913a55665 length=99 error=none
|
||||
PASS loopback save refused: HTTP 403 error=read-only sent=false
|
||||
CONSOLE_PASS
|
||||
# exit 0
|
||||
# journal since W5: proxy proof off 1, Sign-in 0
|
||||
# Console pid 2950925 cwd /home/jwoltje/.local/share/mosaic-stack/copies/4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
|
||||
# exit 0
|
||||
[
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"instance": "chief",
|
||||
"run": "r097436c91816",
|
||||
"launchedBy": "jason",
|
||||
"harness": "claude-code",
|
||||
"model": "claude-opus-5-5",
|
||||
"provider": "anthropic",
|
||||
"modelKey": "opus",
|
||||
"pid": 2951160,
|
||||
"startTime": "63491912",
|
||||
"runnerPid": 2951161,
|
||||
"runnerStartTime": "63491912",
|
||||
"runDir": "/home/jwoltje/.mosaic-dev/launches/mosaic-stack/r097436c91816",
|
||||
"startedAt": "2026-10-20T22:02:08.941Z",
|
||||
"live": true
|
||||
}
|
||||
]
|
||||
# exit 0
|
||||
# one running chief from this start: run r097436c91816
|
||||
chief r097436c91816 claude-code since 2026-10-20T22:02:08.951Z
|
||||
# exit 0
|
||||
sent c5b43f04 to chief
|
||||
chief (RESULT, re c5b43f04) c3e236d7:
|
||||
Got the re-pin of 4429a104f6d65ccf165f6c83b5d1dce3147dfde0 from the tester; I took no action because a tester message authorizes nothing, and I launched and changed nothing.
|
||||
# exit 0
|
||||
created project window-20261020-2205 id 77e7b45f-8c34-49a3-b2fa-9cf565177c30
|
||||
# exit 0
|
||||
created window-20261020-2205/w9 id 446201de-e8b5-4df3-9356-d0d5422cc899 instance assistant
|
||||
# exit 0
|
||||
# project window-20261020-2205, thread 446201de-e8b5-4df3-9356-d0d5422cc899
|
||||
sent b4b66ced to window-20261020-2205/w9
|
||||
assistant (RESULT, re b4b66ced) cfa7ea99:
|
||||
Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.
|
||||
# exit 0
|
||||
# reply received 2026-10-20T22:06:05Z
|
||||
[
|
||||
{
|
||||
"seq": 570,
|
||||
"id": "b4b66ced-d088-4b9c-a944-306f164028f9",
|
||||
"at": "2026-10-20T22:05:58.283Z",
|
||||
"business": "mosaic-stack",
|
||||
"from_role": "tester",
|
||||
"from_run": "tester-cli",
|
||||
"to_role": "assistant",
|
||||
"class": "REQUEST",
|
||||
"in_reply_to": null,
|
||||
"decision": null,
|
||||
"corrects": null,
|
||||
"body": "[mos test] reply with one line, then stop",
|
||||
"to_name": null
|
||||
},
|
||||
{
|
||||
"seq": 571,
|
||||
"id": "cfa7ea99-7ccb-4225-80a6-9a903540e337",
|
||||
"at": "2026-10-20T22:06:04.247Z",
|
||||
"business": "mosaic-stack",
|
||||
"from_role": "assistant",
|
||||
"from_run": "r497f1c817d4d",
|
||||
"to_role": "tester",
|
||||
"class": "RESULT",
|
||||
"in_reply_to": "b4b66ced-d088-4b9c-a944-306f164028f9",
|
||||
"decision": null,
|
||||
"corrects": null,
|
||||
"body": "Assistant (run r497f1c817d4d) got the [mos test] message and is replying in one line as asked, without treating it as Jason's instruction or acting on it.",
|
||||
"to_name": null
|
||||
}
|
||||
]
|
||||
# exit 0
|
||||
# thread read: 2 messages: tester->assistant REQUEST, assistant->tester RESULT
|
||||
window-20261020-0801/w9 assistant d0ba7a66
|
||||
window-20261020-2205/w9 assistant 446201de
|
||||
# exit 0
|
||||
[]
|
||||
# exit 0
|
||||
{
|
||||
"total": {
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
"byRun": [
|
||||
{
|
||||
"run": "r8fba7e598156",
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheWrite": 0,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"run": "r4a96e2142e4d",
|
||||
"input": 2,
|
||||
"output": 52,
|
||||
"cacheWrite": 11750,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc78c9700c8e9",
|
||||
"input": 14,
|
||||
"output": 1519,
|
||||
"cacheWrite": 18187,
|
||||
"cacheRead": 89559,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r293f33bcbc63",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 18595,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r34520a581318",
|
||||
"input": 6,
|
||||
"output": 323,
|
||||
"cacheWrite": 26349,
|
||||
"cacheRead": 14307,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r7e99838f9f93",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 9858,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc964611b2092",
|
||||
"input": 4,
|
||||
"output": 120,
|
||||
"cacheWrite": 26138,
|
||||
"cacheRead": 23870,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rbb708846b605",
|
||||
"input": 12,
|
||||
"output": 2089,
|
||||
"cacheWrite": 17285,
|
||||
"cacheRead": 73823,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "rc36b17ef3788",
|
||||
"input": 16,
|
||||
"output": 5221,
|
||||
"cacheWrite": 36717,
|
||||
"cacheRead": 202573,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r097436c91816",
|
||||
"input": 2,
|
||||
"output": 131,
|
||||
"cacheWrite": 11915,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"run": "r497f1c817d4d",
|
||||
"input": 2,
|
||||
"output": 80,
|
||||
"cacheWrite": 9856,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
}
|
||||
],
|
||||
"byInstance": [
|
||||
{
|
||||
"instance": "chief",
|
||||
"input": 36,
|
||||
"output": 4114,
|
||||
"cacheWrite": 85486,
|
||||
"cacheRead": 177689,
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"instance": "coder",
|
||||
"input": 2,
|
||||
"output": 44,
|
||||
"cacheWrite": 18595,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"instance": "assistant",
|
||||
"input": 4,
|
||||
"output": 124,
|
||||
"cacheWrite": 19714,
|
||||
"cacheRead": 0,
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"instance": "pm",
|
||||
"input": 20,
|
||||
"output": 5341,
|
||||
"cacheWrite": 62855,
|
||||
"cacheRead": 226443,
|
||||
"unknownTurns": 0
|
||||
}
|
||||
],
|
||||
"byProvider": [
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
],
|
||||
"byModel": [
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-5-5",
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
],
|
||||
"byAccount": [
|
||||
{
|
||||
"account": null,
|
||||
"input": 62,
|
||||
"output": 9623,
|
||||
"cacheWrite": 186650,
|
||||
"cacheRead": 404132,
|
||||
"unknownTurns": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
# exit 0
|
||||
# human-thread probe: skipped. S1 has 1 threads row, and threads --tester lists the 177-185 window's tester thread d0ba7a66, so no human thread exists to read
|
||||
# W9 checks and turns end 2026-10-20T22:06:06Z; the assistant run wait and S2 are w9b.sh
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# Re-pin 4429a104f, W9 checks and the turns (repin-4429a104f.md at f41729178, sha256 e1272d1a; the W9 list of
|
||||
# repin-rows-177-185.md plus the journal Sign-in line and the Console cwd), on Mos's go mos-1114.
|
||||
# Adapted from Darkwing's window-rows-177-185/w9.sh. Every call goes through w.sh into W.txt; every bus call carries
|
||||
# --tester. A failed check stops the script. The wait for the assistant run and S2 are in w9b.sh, so the interim
|
||||
# report to Mos goes out between the two (report rule, waits over 5 minutes).
|
||||
# usage: w9.sh
|
||||
set -uo pipefail
|
||||
REPO=/mnt/storage/src/mosaic-stack
|
||||
WIN=$REPO/agents/sage/work/window-repin-4429a104f
|
||||
NEW=4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
W5_STAMP="2026-10-20 22:02:06 UTC"
|
||||
W5_MS=$(date -d "2026-10-20T22:02:06Z" +%s)000
|
||||
PLAN=$REPO/agents/sage/work/window-plan/repin-4429a104f.md
|
||||
PLAN_SHA=e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9
|
||||
TOOL_BLOB=3b068b1546d4020cffde5d73929d9130afe91911
|
||||
CC=/mnt/storage/scratch/sage-pin-de352dfaf/console-check.sh
|
||||
CC_SHA=d068fe02da897b700110a162d13d9671fe5994f053c45aff99113fade737094c
|
||||
CONSOLE_PID=2950925
|
||||
PRIOR_TID=d0ba7a66-aa3f-4918-9518-94132bce6b28
|
||||
MOSAIC=$HOME/.npm-global/bin/mosaic
|
||||
C_NEW=(node "$HOME/.local/share/mosaic-stack/copies/$NEW/packages/cli/src/cli.mjs")
|
||||
BZ=(--business mosaic-stack)
|
||||
W=$WIN/W.txt
|
||||
note() { echo "# $*" >> "$W"; echo "# $*"; }
|
||||
stop() { note "STOP: $*"; exit 1; }
|
||||
sha() { sha256sum < "$1" | cut -c1-64; }
|
||||
step() { local f="$1"; shift; $WIN/w.sh W.txt "$@" > "$WIN/$f"; local rc=$?; cat "$WIN/$f"; return $rc; }
|
||||
|
||||
note "W9 go mos-1114, start $(date -u +%FT%TZ)"
|
||||
[ "$(sha $PLAN)" = "$PLAN_SHA" ] || stop "the plan file is not e1272d1a"
|
||||
[ "$(git -C $REPO rev-parse HEAD:scripts/stack-release.sh)" = "$TOOL_BLOB" ] || stop "tool blob moved"
|
||||
[ "$(sha $CC)" = "$CC_SHA" ] || stop "console-check.sh is not d068fe02 (SCRIPTS.sha256)"
|
||||
grep -q "/copies/$NEW/scripts/mosaic" "$MOSAIC" || stop "the wrapper is not pinned to NEW"
|
||||
note "plan e1272d1a, tool blob 3b068b1546d4, console-check.sh d068fe02, wrapper on NEW: checked"
|
||||
|
||||
# checks
|
||||
step .w9-status.txt "W9 status" scripts/stack-release.sh status mosaic-stack || stop "W9 status exit"
|
||||
grep -q "\"copy\":\"$NEW\"" $WIN/.w9-status.txt && grep -q '"pm":true' $WIN/.w9-status.txt && grep -q '"schema":"3i"' $WIN/.w9-status.txt || stop "W9 status: pointer not on NEW, pm true, 3i"
|
||||
grep -q 'unit flags: match the pointer' $WIN/.w9-status.txt || stop "W9 status: unit flags"
|
||||
step .w9-cc.txt "W9 console-check NEW ($CC, d068fe02)" bash $CC $NEW || stop "console-check exit"
|
||||
grep -qx CONSOLE_PASS $WIN/.w9-cc.txt || stop "console-check not CONSOLE_PASS"
|
||||
J=$(journalctl --user -u mosaic-webui.service --since "$W5_STAMP" --no-pager)
|
||||
N=$(printf '%s\n' "$J" | grep -c 'Proxy: proxy proof off'); S=$(printf '%s\n' "$J" | grep -c 'Sign-in:')
|
||||
{ echo; echo "## W9 webui journal since $W5_STAMP: 'Proxy: proxy proof off' and 'Sign-in:' counts"; echo "proxy proof off $N, Sign-in $S"; } >> "$W"
|
||||
note "journal since W5: proxy proof off $N, Sign-in $S"
|
||||
[ "$N" = 1 ] || stop "proxy proof off count is $N, not 1"
|
||||
[ "$S" = 0 ] || stop "Sign-in count is $S, not 0"
|
||||
systemctl --user show -p MainPID --value mosaic-webui.service | grep -qx "$CONSOLE_PID" || stop "Console MainPID is not $CONSOLE_PID"
|
||||
CWD=$(readlink /proc/$CONSOLE_PID/cwd)
|
||||
{ echo; echo "## W9 Console pid $CONSOLE_PID cwd"; ls -l /proc/$CONSOLE_PID/cwd | sed 's/^.* -> /-> /'; } >> "$W"
|
||||
note "Console pid $CONSOLE_PID cwd $CWD"
|
||||
[ "$CWD" = "$HOME/.local/share/mosaic-stack/copies/$NEW" ] || stop "Console cwd is not the NEW copy"
|
||||
step .w9-launches.txt "W9 launches list" $MOSAIC launches list || stop "launches list"
|
||||
step .w9-launches.json "W9 launches list --json" $MOSAIC launches list --json || stop "launches list --json"
|
||||
sed -i '$d' $WIN/.w9-launches.json
|
||||
CHIEF=$(node -e '
|
||||
const l=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")),since=Number(process.argv[2]);
|
||||
const c=l.filter(x=>x.business==="mosaic-stack"&&x.instance==="chief"&&x.live===true);
|
||||
if(c.length!==1||!(Date.parse(c[0].startedAt)>=since)){console.error(`running chiefs ${c.length}`);process.exit(1)}
|
||||
process.stdout.write(c[0].run)' $WIN/.w9-launches.json "$W5_MS") || stop "launches: not one running chief from this start"
|
||||
note "one running chief from this start: run $CHIEF"
|
||||
step .w9-agents.txt "W9 agents --tester" $MOSAIC agents "${BZ[@]}" --tester || stop "agents"
|
||||
grep -E '(^|[[:space:]])chief[[:space:]].*working' $WIN/.w9-agents.txt && stop "the chief is working"
|
||||
|
||||
# turns
|
||||
step .w9-talk-chief.txt "W9 talk chief --tester" $MOSAIC talk chief "[mos test] re-pin $NEW: reply with one line, no staffing request" "${BZ[@]}" --tester || stop "talk chief"
|
||||
PROJ=window-$(date -u +%Y%m%d-%H%M)
|
||||
step .w9-project.txt "W9 project create $PROJ --tester" $MOSAIC project create $PROJ "${BZ[@]}" --tester || stop "project create"
|
||||
step .w9-thread.txt "W9 thread create $PROJ w9 --tester" $MOSAIC thread create $PROJ w9 "${BZ[@]}" --tester || stop "thread create"
|
||||
TID=$(sed -n "s/^created $PROJ\/w9 id \([0-9a-f-]*\) instance .*$/\1/p" $WIN/.w9-thread.txt)
|
||||
[ -n "$TID" ] || stop "no thread id"
|
||||
note "project $PROJ, thread $TID"
|
||||
echo "$TID" > $WIN/.w9-tid.txt
|
||||
step .w9-talk-thread.txt "W9 talk --thread --wait 180 --tester" $MOSAIC talk --thread "$TID" "[mos test] reply with one line, then stop" --wait 180 "${BZ[@]}" --tester || stop "talk --thread"
|
||||
note "reply received $(date -u +%FT%TZ)"
|
||||
date -u +%s > $WIN/.w9-reply-at.txt
|
||||
step .w9-thread-read.json "W9 thread read --json --tester" $MOSAIC thread read "$TID" --json "${BZ[@]}" --tester || stop "thread read"
|
||||
sed -i '$d' $WIN/.w9-thread-read.json
|
||||
node -e '
|
||||
const t=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));
|
||||
console.log(`# thread read: ${t.length} messages: ${t.map(m=>`${m.from_role}->${m.to_role} ${m.class}`).join(", ")}`);
|
||||
process.exit(t.length===2&&t[0].from_role==="tester"&&t[1].from_role==="assistant"?0:1)' $WIN/.w9-thread-read.json | tee -a "$W" || stop "thread read is not the tester message plus one assistant reply"
|
||||
step .w9-threads.txt "W9 threads --tester" $MOSAIC threads "${BZ[@]}" --tester || stop "threads"
|
||||
grep -q "$TID\|$PROJ" $WIN/.w9-threads.txt || stop "threads does not list the W9 thread"
|
||||
step .w9-names.json "W9 C_NEW names --tester --json" "${C_NEW[@]}" names "${BZ[@]}" --tester --json || stop "names"
|
||||
step .w9-usage.json "W9 usage --tester --json" $MOSAIC usage "${BZ[@]}" --tester --json || stop "usage"
|
||||
if grep -q "${PRIOR_TID:0:8}" $WIN/.w9-threads.txt; then
|
||||
note "human-thread probe: skipped. S1 has 1 threads row, and threads --tester lists the 177-185 window's tester thread ${PRIOR_TID:0:8}, so no human thread exists to read"
|
||||
else
|
||||
note "human-thread probe: S1's one thread is not in threads --tester; probe it by hand before S2"
|
||||
fi
|
||||
note "W9 checks and turns end $(date -u +%FT%TZ); the assistant run wait and S2 are w9b.sh"
|
||||
@@ -0,0 +1,185 @@
|
||||
# W9b start 2026-10-20T22:06:27Z: waiting for the launcher to end r497f1c817d4d (thread 446201de-e8b5-4df3-9356-d0d5422cc899), no stop by hand
|
||||
# r497f1c817d4d not live in launches list --json by 2026-10-20T22:21:07Z (5 s poll)
|
||||
r097436c91816 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T22:02:08.941Z running
|
||||
# exit 0
|
||||
|
||||
## W9b launches log lines for r497f1c817d4d (at, event, instance, thread, run, reason, exitCode, signal only)
|
||||
at=2026-10-20T22:06:00.766Z event=launch instance=assistant thread=446201de-e8b5-4df3-9356-d0d5422cc899 run=r497f1c817d4d
|
||||
at=2026-10-20T22:21:05.149Z event=thread-idle instance=assistant thread=446201de-e8b5-4df3-9356-d0d5422cc899 run=r497f1c817d4d
|
||||
at=2026-10-20T22:21:05.171Z event=end instance=assistant thread=446201de-e8b5-4df3-9356-d0d5422cc899 run=r497f1c817d4d reason=stopped exitCode=0 signal=null
|
||||
|
||||
## W9b bus and webui journal since 2026-10-20 22:05:00 UTC, lines naming r497f1c817d4d or thread-idle
|
||||
2026-10-20T17:21:05.171444-05:00 dragon-lin mosaic[2950923]: mosaic-bus: run r497f1c817d4d (assistant) ended: stopped
|
||||
(grep exit 0)
|
||||
# SNAP sha256 639fd28a checked before S2 2026-10-20T22:21:07Z
|
||||
{
|
||||
"schema": "3i",
|
||||
"tables": {
|
||||
"decision_events": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "28dd95056663dd5eb7442a3d815b73a4a3cd311a0e854fd82dc54f7dc3e6ef26"
|
||||
},
|
||||
"decisions": {
|
||||
"rows": 21,
|
||||
"maxSeq": 21,
|
||||
"sha256": "fef1058660b0d128ef21089dda5e0fba7f71763d8325148fe521de0d3f57e4ad"
|
||||
},
|
||||
"deliveries": {
|
||||
"rows": 1740,
|
||||
"maxSeq": 1740,
|
||||
"sha256": "554ce5b377ffacc72bfde11c99c920cd5d30337c8bfa217275d0073b587e2169"
|
||||
},
|
||||
"events": {
|
||||
"rows": 1543,
|
||||
"maxSeq": 1543,
|
||||
"sha256": "0151673193d0286ce8f3b234d7d21fa2b0f43f2ceec55644cccaa5cbd77603dd"
|
||||
},
|
||||
"instance_names": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"message_threads": {
|
||||
"rows": 4,
|
||||
"maxSeq": null
|
||||
},
|
||||
"messages": {
|
||||
"rows": 571,
|
||||
"maxSeq": 571
|
||||
},
|
||||
"meta": {
|
||||
"rows": 3,
|
||||
"maxSeq": null,
|
||||
"sha256": "89fec56e7293d19e0b2f6cd8c0ef745ebf99e4115abeaea7fbdbc98a261332d9"
|
||||
},
|
||||
"projects": {
|
||||
"rows": 2,
|
||||
"maxSeq": 2,
|
||||
"sha256": "3435f574ab94bc2fcc65fb8605251f30d4e9e63baf38e6fdf3c39c126531aa63"
|
||||
},
|
||||
"role_claims": {
|
||||
"rows": 101,
|
||||
"maxSeq": 101,
|
||||
"sha256": "fb47114defbb8a57dd148a06fbaf61b30f791a81f14b1510b8c32690506b4308"
|
||||
},
|
||||
"task_comment_cursors": {
|
||||
"rows": 220,
|
||||
"maxSeq": 220,
|
||||
"sha256": "df03c84094999299499de01107ddd08636df6156f5c9ccd2ff6e8c43e459f7e4"
|
||||
},
|
||||
"task_snapshots": {
|
||||
"rows": 408,
|
||||
"maxSeq": 408,
|
||||
"sha256": "e2243a89388afba07e8d096b4961deeb2d17c656cdcace54b46eac7001393814"
|
||||
},
|
||||
"thread_claims": {
|
||||
"rows": 4,
|
||||
"maxSeq": 4,
|
||||
"sha256": "088b8236570d8afe03364b85803fdc5034cce2cef6ed7b16f8b99bf6366524ad"
|
||||
},
|
||||
"thread_opens": {
|
||||
"rows": 0,
|
||||
"maxSeq": null,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"threads": {
|
||||
"rows": 2,
|
||||
"maxSeq": 2,
|
||||
"sha256": "819fd24c64e279d0debad5ab5dbbe533d8e6f26de58277c69510b9cbbaadbc3a"
|
||||
},
|
||||
"usage_records": {
|
||||
"rows": 34,
|
||||
"maxSeq": 34,
|
||||
"sha256": "de614f3d62c9b3819757c5659bee40085cb86cc873b7bfe381b13e254ddb7cad",
|
||||
"byRun": [
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r8fba7e598156",
|
||||
"input": "0",
|
||||
"output": "0",
|
||||
"unknownTurns": 1
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r4a96e2142e4d",
|
||||
"input": "2",
|
||||
"output": "52",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc78c9700c8e9",
|
||||
"input": "14",
|
||||
"output": "1519",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r293f33bcbc63",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r34520a581318",
|
||||
"input": "6",
|
||||
"output": "323",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r7e99838f9f93",
|
||||
"input": "2",
|
||||
"output": "44",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc964611b2092",
|
||||
"input": "4",
|
||||
"output": "120",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rbb708846b605",
|
||||
"input": "12",
|
||||
"output": "2089",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "rc36b17ef3788",
|
||||
"input": "16",
|
||||
"output": "5221",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r097436c91816",
|
||||
"input": "2",
|
||||
"output": "131",
|
||||
"unknownTurns": 0
|
||||
},
|
||||
{
|
||||
"business": "mosaic-stack",
|
||||
"run": "r497f1c817d4d",
|
||||
"input": "2",
|
||||
"output": "80",
|
||||
"unknownTurns": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"untimed": [
|
||||
"message_threads",
|
||||
"meta",
|
||||
"task_comment_cursors"
|
||||
]
|
||||
}
|
||||
# exit 0
|
||||
# S2.json sha256 1a444f5ac0049bc78e397b6928e1008cfe8cbc30989bbf2042b4ed27f56a4173
|
||||
# W9b end 2026-10-20T22:21:07Z
|
||||
rc=0
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Re-pin 4429a104f, W9 tail (adapted from Darkwing's window-rows-177-185/w9b.sh, under mos-0948's ruling for that window: the launcher
|
||||
# ends the assistant run at threadIdleSeconds 900 after the 22:06:05Z reply, about 22:21:05Z). Nothing is stopped by hand. Report if live at 22:27Z.
|
||||
# usage: w9b.sh
|
||||
set -uo pipefail
|
||||
REPO=/mnt/storage/src/mosaic-stack
|
||||
WIN=/mnt/storage/src/mosaic-stack/agents/sage/work/window-repin-4429a104f
|
||||
RUN=r497f1c817d4d
|
||||
TID=446201de-e8b5-4df3-9356-d0d5422cc899
|
||||
DATA=/home/jwoltje/.mosaic-dev
|
||||
SNAP=$WIN/snapshot.mjs
|
||||
SNAP_SHA=639fd28a2674aaa03ce824e3394b4052f21cdef7d6fa49bae61a9927ed341e67
|
||||
PLAN=$REPO/agents/sage/work/window-plan/repin-4429a104f.md
|
||||
PLAN_SHA=e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9
|
||||
TOOL_BLOB=3b068b1546d4020cffde5d73929d9130afe91911
|
||||
MOSAIC=$HOME/.npm-global/bin/mosaic
|
||||
DEADLINE=$(date -d "2026-10-20T22:27:00Z" +%s)
|
||||
MARK18=$(date -d "2026-10-20T22:22:00Z" +%s)
|
||||
W=$WIN/W.txt
|
||||
note() { echo "# $*" >> "$W"; echo "# $*"; }
|
||||
stop() { note "STOP: $*"; exit 1; }
|
||||
sha() { sha256sum < "$1" | cut -c1-64; }
|
||||
step() { local f="$1"; shift; $WIN/w.sh W.txt "$@" > "$WIN/$f"; local rc=$?; cat "$WIN/$f"; return $rc; }
|
||||
# launches log lines for the run: at, event, instance, thread, run, reason, exitCode, signal only
|
||||
runlog() { node -e '
|
||||
const keep=["at","event","instance","thread","run","reason","exitCode","signal"];
|
||||
for(const s of require("fs").readFileSync(process.argv[1],"utf8").trim().split("\n")){const o=JSON.parse(s);if(o.run!==process.argv[2])continue;
|
||||
console.log(keep.filter(k=>k in o).map(k=>`${k}=${o[k]}`).join(" "))}' "$DATA/launches/mosaic-stack.jsonl" "$RUN"; }
|
||||
|
||||
note "W9b start $(date -u +%FT%TZ): waiting for the launcher to end $RUN (thread $TID), no stop by hand"
|
||||
[ "$(sha $PLAN)" = "$PLAN_SHA" ] || stop "the plan file is not e1272d1a"
|
||||
[ "$(git -C $REPO rev-parse HEAD:scripts/stack-release.sh)" = "$TOOL_BLOB" ] || stop "tool blob moved"
|
||||
END=; M18=
|
||||
while :; do
|
||||
$MOSAIC launches list --json > $WIN/.w9b-poll.json 2>&1 || stop "launches poll"
|
||||
LIVE=$(node -e 'const l=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(l.filter(x=>x.run===process.argv[2]&&x.live===true).map(x=>x.run).join(" "))' $WIN/.w9b-poll.json $RUN)
|
||||
[ -z "$LIVE" ] && { END=$(date -u +%FT%TZ); break; }
|
||||
NOW=$(date +%s)
|
||||
if [ -z "$M18" ] && [ $NOW -ge $MARK18 ]; then M18=1; note "22:22Z mark $(date -u +%FT%TZ): $RUN still live; launches log lines for it:"; runlog | tee -a "$W"; fi
|
||||
[ $NOW -ge $DEADLINE ] && break
|
||||
sleep 5
|
||||
done
|
||||
if [ -z "$END" ]; then
|
||||
note "STOP: $RUN still live at $(date -u +%FT%TZ) (22:27Z mark); a stop is Mos's call. S2 waits."
|
||||
runlog | tee -a "$W"
|
||||
exit 4
|
||||
fi
|
||||
note "$RUN not live in launches list --json by $END (5 s poll)"
|
||||
step .w9b-launches-after.txt "W9b launches list after the assistant run" $MOSAIC launches list
|
||||
{ echo; echo "## W9b launches log lines for $RUN (at, event, instance, thread, run, reason, exitCode, signal only)"; runlog; } | tee -a "$W"
|
||||
{ echo; echo "## W9b bus and webui journal since 2026-10-20 22:05:00 UTC, lines naming $RUN or thread-idle"; journalctl --user -u [email protected] -u mosaic-webui.service --since "2026-10-20 22:05:00 UTC" --no-pager -o short-iso-precise | grep -E "$RUN|thread-idle|threadIdle"; echo "(grep exit ${PIPESTATUS[1]})"; } | tee -a "$W"
|
||||
[ "$(sha $SNAP)" = "$SNAP_SHA" ] || stop "SNAP sha before S2"
|
||||
note "SNAP sha256 639fd28a checked before S2 $(date -u +%FT%TZ)"
|
||||
step S2.json "S2 snapshot --held" node $SNAP --store "$DATA/bus/bus.sqlite" --held || stop "S2"
|
||||
sed -i '$d' $WIN/S2.json
|
||||
note "S2.json sha256 $(sha $WIN/S2.json)"
|
||||
note "W9b end $(date -u +%FT%TZ)"
|
||||
@@ -0,0 +1,43 @@
|
||||
# W0b-W4 start 2026-10-20T22:00:49Z
|
||||
# plan e1272d1a, tool blob 3b068b1546d4, P5 four scripts at NEW and clean, business-check.mjs eece4d46: checked 2026-10-20T22:00:49Z
|
||||
# W0 copy present, w0.txt LOAD_OK
|
||||
# W0b: /home/jwoltje/.config/mosaic-dev/businesses/mosaic-stack.json.pre-window-20261020T220049Z, sha256 1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93, recorded in backups.txt
|
||||
rbb708846b605 mosaic-stack/chief claude-code claude-opus-5-5 by jason since 2026-10-20T19:29:59.045Z running
|
||||
rc36b17ef3788 mosaic-stack/pm claude-code claude-opus-5-5 by chief since 2026-10-20T20:20:56.252Z running
|
||||
# exit 0
|
||||
# W1 start stamp 2026-10-20T22:00:49Z
|
||||
# SNAP sha256 639fd28a checked before S1
|
||||
# S1.json sha256 a7649760c3439705c8b5f3af26d0c21aea1081c1eb295fd02aef3a642064194b
|
||||
pointer: /home/jwoltje/.mosaic-dev/state/stack-active.json
|
||||
{"copy":"490e2771d3d057ab76991e2c7859cd251857e64c","business":"mosaic-stack","pm":true,"claude":"/home/jwoltje/.local/bin/claude","console":["--port","7332","--public-host","mosaic.woltje.com","--auth-header","X-authentik-username","--allow-user","jason.woltje","--tester-port","7333"],"schema":"3i","activation":"20261020T192956Z-490e2771d3d0","at":"2026-10-20T19:30:00Z"}
|
||||
log (last five lines):
|
||||
{"at":"2026-10-20T01:41:05Z","op":"pm-on","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","backup":null,"activation":"20261020T014103Z-89af38a83afa","result":"ok","pm":"on"}
|
||||
{"at":"2026-10-20T07:58:00Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T075800Z-490e2771d3d0","result":"started"}
|
||||
{"at":"2026-10-20T07:58:02Z","op":"activate","from":"89af38a83afa22a8a4f0d368a0a4030ad15a0efa","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T075800Z-490e2771d3d0","activation":"20261020T075800Z-490e2771d3d0","result":"ok"}
|
||||
{"at":"2026-10-20T19:29:56Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":null,"activation":"20261020T192956Z-490e2771d3d0","result":"started"}
|
||||
{"at":"2026-10-20T19:30:00Z","op":"activate","from":"490e2771d3d057ab76991e2c7859cd251857e64c","to":"490e2771d3d057ab76991e2c7859cd251857e64c","backup":"20261020T192956Z-490e2771d3d0","activation":"20261020T192956Z-490e2771d3d0","result":"ok"}
|
||||
check:
|
||||
bus: /home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
Console: /home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
CLI: /home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
result: pass
|
||||
bus status:
|
||||
host: mosaic-stack, pid 3229540, running, since 2026-10-20T19:29:57.662Z, notifier off
|
||||
socket: present
|
||||
writer.lock: pid 3229657
|
||||
boot: organization file loaded at 2026-10-20T19:29:57.661Z
|
||||
unit flags: match the pointer
|
||||
drift: 109 commit(s) on refactor since the pointer's copy
|
||||
# exit 0
|
||||
# W3-plan.txt sha256 45de68d42b4cf88640fe0986d071dba4fa076b7db86ff4ed34de4bb6fa2915c6
|
||||
DropInPaths=
|
||||
ExecStart={ path=/home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c/scripts/mosaic ; argv[]=/home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c/scripts/mosaic bus start mosaic-stack --pm ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
|
||||
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
# exit 0
|
||||
DropInPaths=
|
||||
ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node /home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c/packages/webui/src/cli.mjs serve --checkout /mnt/storage/src/mosaic-stack --port 7332 --public-host mosaic.woltje.com --auth-header X-authentik-username --allow-user jason.woltje --tester-port 7333 ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
|
||||
WorkingDirectory=/home/jwoltje/.local/share/mosaic-stack/copies/490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
# exit 0
|
||||
# W4 checks: DropInPaths empty on both, both ExecStart name the OLD copy, bus has --pm, no quote or backslash in either ExecStart
|
||||
# W4 bus Environment: 2 assignments; quote or backslash: none (values not recorded)
|
||||
# W0b-W4 end 2026-10-20T22:00:50Z
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Re-pin 4429a104f, W0b-W4 (repin-4429a104f.md at f41729178, sha256 e1272d1a...32b9; mos-1105, mos-1107 go). W0 ran before P2.
|
||||
# Adapted from Darkwing's window-rows-177-185/wsteps.sh.
|
||||
# Nothing is stopped. Each step goes through w.sh into W.txt; a failed check stops the script.
|
||||
# usage: wsteps.sh
|
||||
set -uo pipefail
|
||||
REPO=/mnt/storage/src/mosaic-stack
|
||||
WIN=$REPO/agents/sage/work/window-repin-4429a104f
|
||||
OLD=490e2771d3d057ab76991e2c7859cd251857e64c
|
||||
NEW=4429a104f6d65ccf165f6c83b5d1dce3147dfde0
|
||||
PLAN=$REPO/agents/sage/work/window-plan/repin-4429a104f.md
|
||||
PLAN_SHA=e1272d1a391e00fb1291d47da9973c0ba342d65fcd42f583ea007e883c1f32b9
|
||||
TOOL_BLOB=3b068b1546d4020cffde5d73929d9130afe91911
|
||||
BC=$REPO/agents/sage/work/window-plan/repin-de352dfaf/business-check.mjs
|
||||
BC_SHA=eece4d4671e1ce15ba81e1654c641925ac4988b343687b062897392285a8a1a7
|
||||
BIZ=$HOME/.config/mosaic-dev/businesses/mosaic-stack.json
|
||||
BIZ_SHA=1e49bdf05e62607972958f5b7298cfd600c7825812370e26d7c2a842f2af9b93
|
||||
DATA=/home/jwoltje/.mosaic-dev
|
||||
SNAP=$WIN/snapshot.mjs
|
||||
SNAP_SHA=639fd28a2674aaa03ce824e3394b4052f21cdef7d6fa49bae61a9927ed341e67
|
||||
MOSAIC=$HOME/.npm-global/bin/mosaic
|
||||
W=$WIN/W.txt
|
||||
note() { echo "# $*" >> "$W"; echo "# $*"; }
|
||||
stop() { note "STOP: $*"; exit 1; }
|
||||
sha() { sha256sum < "$1" | cut -c1-64; }
|
||||
|
||||
note "W0b-W4 start $(date -u +%FT%TZ)"
|
||||
[ "$(git -C $REPO show f41729178:agents/sage/work/window-plan/repin-4429a104f.md | sha256sum | cut -c1-64)" = "$PLAN_SHA" ] || stop "plan at f41729178 is not e1272d1a"
|
||||
[ "$(sha $PLAN)" = "$PLAN_SHA" ] || stop "the plan file in REPO is not e1272d1a"
|
||||
[ "$(git -C $REPO rev-parse HEAD:scripts/stack-release.sh)" = "$TOOL_BLOB" ] || stop "tool blob moved"
|
||||
git -C $REPO diff --quiet $NEW HEAD -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh || stop "P5: the four scripts differ from NEW"
|
||||
[ -z "$(git -C $REPO status --porcelain -- scripts/stack-release.sh scripts/webui-service.sh scripts/bus-service.sh scripts/stack-copy.sh)" ] || stop "P5: the four scripts are dirty"
|
||||
[ "$(sha $BC)" = "$BC_SHA" ] || stop "business-check.mjs sha"
|
||||
note "plan e1272d1a, tool blob 3b068b1546d4, P5 four scripts at NEW and clean, business-check.mjs eece4d46: checked $(date -u +%FT%TZ)"
|
||||
|
||||
# W0 ran at 21:45:57Z (w0.txt): the copy must still be there with LOAD_OK recorded.
|
||||
[ -f "$HOME/.local/share/mosaic-stack/copies/$NEW/scripts/mosaic" ] || stop "W0 copy missing"
|
||||
grep -qx LOAD_OK $WIN/w0.txt || stop "w0.txt has no LOAD_OK"
|
||||
note "W0 copy present, w0.txt LOAD_OK"
|
||||
|
||||
# W0b
|
||||
[ "$(sha $BIZ)" = "$BIZ_SHA" ] || stop "business file sha256 is not P1's 1e49bdf0"
|
||||
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
COPY=$BIZ.pre-window-$STAMP
|
||||
[ ! -e "$COPY" ] || stop "$COPY exists"
|
||||
cp -p "$BIZ" "$COPY" || stop "W0b cp"
|
||||
[ "$(sha "$COPY")" = "$BIZ_SHA" ] || stop "W0b copy sha256"
|
||||
echo "W0b $(date -u +%FT%TZ) business file copy $COPY sha256 $(sha "$COPY") (source $BIZ sha256 $(sha "$BIZ"))" >> $WIN/backups.txt
|
||||
note "W0b: $COPY, sha256 $(sha "$COPY"), recorded in backups.txt"
|
||||
|
||||
# W1
|
||||
$WIN/w.sh W.txt "W1 agents --tester" $MOSAIC agents --business mosaic-stack --tester > $WIN/.w1-agents.txt || stop "W1 agents"
|
||||
grep -E '(^|[[:space:]])chief[[:space:]].*working' $WIN/.w1-agents.txt && stop "W1: the chief is working"
|
||||
$WIN/w.sh W.txt "W1 launches list" $MOSAIC launches list || stop "W1 launches list"
|
||||
note "W1 start stamp $(date -u +%FT%TZ)"
|
||||
[ "$(sha $SNAP)" = "$SNAP_SHA" ] || stop "SNAP sha before S1"
|
||||
note "SNAP sha256 639fd28a checked before S1"
|
||||
$WIN/w.sh W.txt "S1 snapshot --held" node $SNAP --store "$DATA/bus/bus.sqlite" --held > $WIN/S1.json || stop "S1"
|
||||
sed -i '$d' $WIN/S1.json # w.sh prints "# exit N" last; S1.json keeps the snapshot only
|
||||
note "S1.json sha256 $(sha $WIN/S1.json)"
|
||||
|
||||
# W2
|
||||
$WIN/w.sh W.txt "W2 status" scripts/stack-release.sh status mosaic-stack || stop "W2 status"
|
||||
|
||||
# W3
|
||||
$WIN/w.sh W.txt "W3 activate NEW --pm --plan" scripts/stack-release.sh activate $NEW mosaic-stack --pm --plan > $WIN/W3-plan.txt || stop "W3 plan exit"
|
||||
sed -i '$d' $WIN/W3-plan.txt
|
||||
note "W3-plan.txt sha256 $(sha $WIN/W3-plan.txt)"
|
||||
|
||||
# W4
|
||||
$WIN/w.sh W.txt "W4 bus unit (Environment omitted from the transcript)" systemctl --user show [email protected] -p ExecStart -p WorkingDirectory -p DropInPaths || stop "W4 bus"
|
||||
$WIN/w.sh W.txt "W4 webui unit" systemctl --user show mosaic-webui.service -p ExecStart -p WorkingDirectory -p DropInPaths || stop "W4 webui"
|
||||
BUSL=$(systemctl --user show [email protected] -p ExecStart -p DropInPaths -p WorkingDirectory)
|
||||
WEBL=$(systemctl --user show mosaic-webui.service -p ExecStart -p DropInPaths -p WorkingDirectory)
|
||||
for u in BUSL WEBL; do v=${!u}; grep -qx 'DropInPaths=' <<< "$v" || stop "W4 $u DropInPaths not empty"; grep '^ExecStart=' <<< "$v" | grep -q "copies/$OLD/" || stop "W4 $u ExecStart does not name the OLD copy"; grep '^ExecStart=' <<< "$v" | grep -q "copies/$NEW" && stop "W4 $u ExecStart names NEW"; grep '^ExecStart=' <<< "$v" | grep -q "[\"'\\\\]" && stop "W4 $u ExecStart has a quote or backslash"; done
|
||||
grep '^ExecStart=' <<< "$BUSL" | grep -q -- ' --pm' || stop "W4 bus ExecStart has no --pm"
|
||||
note "W4 checks: DropInPaths empty on both, both ExecStart name the OLD copy, bus has --pm, no quote or backslash in either ExecStart"
|
||||
ENVL=$(systemctl --user show [email protected] -p Environment)
|
||||
note "W4 bus Environment: $(tr ' ' '\n' <<< "${ENVL#Environment=}" | grep -c =) assignments; quote or backslash: $(grep -q "[\"'\\\\]" <<< "$ENVL" && echo FOUND || echo none) (values not recorded)"
|
||||
note "W0b-W4 end $(date -u +%FT%TZ)"
|
||||
@@ -1363,3 +1363,6 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-20T18:41:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design D1/D2 check (DESIGN.md blob eba861f5d2bb at d55d86bb0), static git diff 817e8f51b..d55d86bb0 | ack: D1 and D2 match; design approved; Mos C1 sequence confirm still open; no Gitea/queue record
|
||||
2026-10-20T18:57:10Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r2 (#1688, DESIGN.md sha256 5493c9df3ec0) | CHANGES to Darkwing, cc Sage: B1 claim check on MOSAIC_CONFIG-only selection, B2 shared prefix drop-in dirs; N1-N7 notes; static read, no slot, no Gitea or queue record
|
||||
2026-10-20T19:00:04Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r3 (#1688, DESIGN.md sha256 98236a1aff98) | APPROVE to Darkwing, cc Sage: B1 and B2 closed with M13 and M14, N7 settled by Sage's 48ebee0ff; static read, no slot; manifest round gets a queue review record (sage-1337)
|
||||
2026-10-20T20:40:15Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 1 review, candidate 8862feccc (static) | CHANGES: B1 base drift, row 189 ace27dc5e conflicts in stack-release.sh, rebase + full gate rerun; N1-N7 notes; Gitea 29634, queue rev 2362 (uncommitted, left for Sage)
|
||||
2026-10-20T21:29:20Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 2 review, candidate 537a02392 (static) | CHANGES: B1 base drift, row 192 4429a104f conflicts in mosaic-config.mjs and webui-service.sh (async load); round 2 content fine; Gitea 29685, queue rev 2447 (uncommitted, left for Sage)
|
||||
2026-10-20T22:16:20Z | Filbert (Opus 5.5, 048f3c63) | row 190 (#1688) manifest round 3 review, candidate df65a3d4c on darkwing/row190-r3 | APPROVE, Gitea 29713, queue rev 2504 (left for Sage's queue-commit); static review, no slot used
|
||||
|
||||
+95
-10
@@ -15,7 +15,7 @@ and changes nothing.
|
||||
|
||||
| Command | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| `scripts/bootstrap.sh` | Create `~/.config/mosaic-dev/config.json` if absent | Idempotent; existing config validated, never rewritten |
|
||||
| `scripts/bootstrap.sh [--instance NAME]` | Create `~/.config/mosaic-dev/config.json` if absent; with `--instance NAME`, `~/.config/mosaic-NAME/config.json` and the claimed root `~/.mosaic-NAME` | Idempotent; existing config validated, never rewritten. See [Stack instances](#stack-instances---instance-name-row-190) |
|
||||
| `scripts/build.sh` | Build the release image | Tag derived from `RELEASE` + pinned pi version |
|
||||
| `scripts/hello.sh` | One-shot startup request | Prints model response on stdout |
|
||||
| `scripts/verify.sh` | Full gated test | Exit 0 only on exact `MOSAIC_HELLO_OK`; `EXPECTED_MARKER` overrides for negative drills |
|
||||
@@ -238,6 +238,12 @@ Exit codes: `0` ok · `2` invalid file or credential reference · `3` system
|
||||
config problem · `4` usage or a required file missing. Details:
|
||||
`packages/business/README.md`.
|
||||
|
||||
`mosaic credentials status|issue|rotate|revoke|delete` (row 199) goes to
|
||||
`packages/cli` and the bus's human-only credential verbs: an agent's own
|
||||
token in `<configDir>/secrets/agents/<business>/<agent>/<kind>`, read on
|
||||
stdin, never printed. The tester may run `status` and `revoke`. Details:
|
||||
`packages/cli/README.md`.
|
||||
|
||||
`instances` and `instance add|set` (row 165) go to `packages/cli` and the
|
||||
bus's human-only `instances` and `instances.set` verbs, so they need a live
|
||||
bus host and refuse inside an agent session. `instances` prints the revision,
|
||||
@@ -270,9 +276,9 @@ scripts/mosaic follow --thread <thread> [--tester]
|
||||
scripts/mosaic stop <run>
|
||||
scripts/mosaic launches off | on [--business <id>]
|
||||
scripts/mosaic launches list [--json]
|
||||
scripts/bus-service.sh render [--copy ID [--copies DIR]] [--pm] [--claude PATH]
|
||||
scripts/bus-service.sh install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]
|
||||
scripts/bus-service.sh uninstall | status <business>
|
||||
scripts/bus-service.sh [--instance NAME] render [--copy ID [--copies DIR]] [--pm] [--claude PATH]
|
||||
scripts/bus-service.sh [--instance NAME] install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]
|
||||
scripts/bus-service.sh [--instance NAME] uninstall | status <business>
|
||||
```
|
||||
|
||||
The human commands read and resolve through the broker's human transport
|
||||
@@ -321,12 +327,89 @@ stdin and stdout it exits 4 with the usage.
|
||||
Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input ·
|
||||
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
|
||||
|
||||
## Stack instances (`--instance NAME`, row 190)
|
||||
|
||||
Several stacks can run on one host, each with its own config, data root,
|
||||
units, Console port and CLI wrapper. The default instance is today's stack
|
||||
and keeps every name it had. A named instance is created once:
|
||||
|
||||
```
|
||||
scripts/bootstrap.sh --instance NAME
|
||||
```
|
||||
|
||||
NAME matches `^[a-z][a-z0-9]{1,15}$`; anything else exits 4
|
||||
(`instance-name-invalid`), and `--instance dev` is the default. Bootstrap
|
||||
writes `~/.config/mosaic-NAME/config.json` (0644) and claims `~/.mosaic-NAME`
|
||||
with the marker `instance.json`, `{"instanceVersion":1,"name":"NAME"}`. It
|
||||
creates either directory 0700; one that already exists keeps its mode. It refuses with exit 3 and writes
|
||||
nothing when the root exists, is not empty and holds no marker naming NAME.
|
||||
A second run changes nothing. `~` is the home of the OS user that runs the
|
||||
instance.
|
||||
|
||||
`--instance NAME` is taken, once and in any position, by `bootstrap.sh`,
|
||||
`bus-service.sh`, `webui-service.sh` (all but `copy`), `discord-service.sh`,
|
||||
`stack-copy.sh` (`wrapper` and `check`) and `stack-release.sh`. Copies serve
|
||||
every instance, so both `copy` verbs refuse it with exit 4. Inside a process the config
|
||||
travels as `MOSAIC_CONFIG`. Selection, checked before any read or write:
|
||||
|
||||
| Given | Config | Names |
|
||||
|---|---|---|
|
||||
| neither | `~/.config/mosaic-dev/config.json` | the default's |
|
||||
| `MOSAIC_CONFIG` only | that file | the default's |
|
||||
| `--instance NAME` only | `~/.config/mosaic-NAME/config.json` | NAME's |
|
||||
| both | must be one file (a symlink to it agrees), else exit 4 `instance-config-mismatch` | NAME's |
|
||||
|
||||
Every selection checks the data root's marker. A named instance whose root
|
||||
lacks its own marker refuses `instance-root-unclaimed`; a root whose marker
|
||||
names another instance refuses `instance-root-mismatch`, also when a config
|
||||
reaches it with no flag. Both exit 3 before anything is read or changed. A
|
||||
missing config exits 3 with the bootstrap command that creates it.
|
||||
|
||||
| | Default | Instance NAME |
|
||||
|---|---|---|
|
||||
| bus unit | `[email protected]` | `[email protected]` |
|
||||
| Console unit | `mosaic-webui.service` | `mosaic-webui-NAME.service` |
|
||||
| Discord unit | `[email protected]` | `[email protected]` |
|
||||
| CLI wrapper | `$BIN/mosaic` (Q81 marker) | `$BIN/mosaic-NAME`, marker `# mosaic-cli wrapper, instance NAME (row 190)`, exports `MOSAIC_CONFIG` |
|
||||
| bus session env | `%h/.config/mosaic-dev/secrets/claude-session.env` | `~/.config/mosaic-NAME/secrets/claude-session.env` |
|
||||
| Console port | `--port`, 7332 when absent | `--port` required; 7330, 7332 and 7333 exit 4 `port-reserved` |
|
||||
|
||||
A named instance's units add `Environment=MOSAIC_CONFIG=<its config>` and
|
||||
` [NAME]` after the Description, and the Console unit's comment on the
|
||||
variables it sets names `MOSAIC_CONFIG`. The default's renders are
|
||||
byte-identical to the templates' renders before row 190. `stack-copy.sh
|
||||
wrapper --instance NAME` creates `$BIN/mosaic-NAME` or replaces one that
|
||||
carries NAME's marker, and refuses any other file. `check --instance NAME`
|
||||
needs `--business ID`, and fails with `CLI: <copy> (MOSAIC_CONFIG mismatch)`
|
||||
when the wrapper does not export NAME's config.
|
||||
|
||||
For every instance, a Console `--port` or `--tester-port` that another
|
||||
installed Console unit in the unit directory already takes refuses
|
||||
`port-claimed`, naming that unit: `webui-service.sh install` exits 1, and
|
||||
`stack-release.sh activate` and `rollback` exit 3 before anything stops
|
||||
(`--plan` lists it). A unit with no `--port` holds 7332.
|
||||
|
||||
`stack-release.sh --instance NAME` reads only NAME's pointer, log, store and
|
||||
backups. Its busy check sees only NAME's bus units. Its drop-in scan adds
|
||||
`mosaic-bus-NAME@*.service.d` and the shared `mosaic-bus-.service.d`. Its
|
||||
rollback set holds NAME's two unit files, their own `.d` directories and
|
||||
`$BIN/mosaic-NAME`; it never captures or restores the shared prefix
|
||||
directories (`mosaic-bus-.service.d`, `mosaic-webui-.service.d`,
|
||||
`mosaic-discord-.service.d`). The live-session guard refuses a path inside
|
||||
any named instance's config directory or `~/.mosaic-NAME`, even when that
|
||||
instance's config is unreadable.
|
||||
|
||||
All instances run under one OS user for now. That user's processes can
|
||||
read and control every instance; a separate user per instance is row 195.
|
||||
A real second instance on this host is a Q173 window, and tunnel or Traefik
|
||||
exposure of a named Console waits for Jason after Q175 SSO.
|
||||
|
||||
## Stack copies (`scripts/stack-copy.sh`)
|
||||
|
||||
```
|
||||
scripts/stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree]
|
||||
scripts/stack-copy.sh wrapper ID [--copies DIR] [--bin DIR]
|
||||
scripts/stack-copy.sh check [--copies DIR] [--bin DIR] [--business ID]
|
||||
scripts/stack-copy.sh wrapper ID [--instance NAME] [--copies DIR] [--bin DIR]
|
||||
scripts/stack-copy.sh check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID]
|
||||
```
|
||||
|
||||
One read-only tree supplies the bus, Console and human CLI (row 85,
|
||||
@@ -372,7 +455,7 @@ scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--mig
|
||||
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
|
||||
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
|
||||
scripts/stack-release.sh status BUSINESS
|
||||
common options: --copies DIR, --bin DIR
|
||||
common options: --copies DIR, --bin DIR, --instance NAME
|
||||
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
|
||||
every table in COUNTED.
|
||||
```
|
||||
@@ -602,6 +685,7 @@ its runs alone. No unit passes it, and `--pm` with it is a usage error.
|
||||
|
||||
```bash
|
||||
scripts/webui-service.sh copy COMMIT [--copies DIR] # make a pinned copy
|
||||
# render, install, uninstall and status also take --instance NAME (row 190); copy refuses it (exit 4)
|
||||
scripts/webui-service.sh render [--copy ID [--copies DIR]] [FLAGS] # print the unit
|
||||
scripts/webui-service.sh install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [FLAGS]
|
||||
scripts/webui-service.sh uninstall [--dir DIR] [--no-reload]
|
||||
@@ -679,8 +763,9 @@ Each value is held to a plain character set the unit can carry (no space,
|
||||
`%`, `$`, quote, backslash, `&` or `|`), and the preview values must start
|
||||
with a letter or digit; anything else is refused. With `--auth-header
|
||||
X-authentik-uid`, in any letter case, `--allow-user` must be the Authentik
|
||||
uid, `^[0-9a-f]{64}$` (lead decision 101). The unit sets no `MOSAIC_*`,
|
||||
`CLAUDE*`, `CODEX*` or `PI_*` variable. Serve's exit 2 (usage, config, or a
|
||||
uid, `^[0-9a-f]{64}$` (lead decision 101). The default's unit sets no `MOSAIC_*`,
|
||||
`CLAUDE*`, `CODEX*` or `PI_*` variable; a named instance's sets only
|
||||
`MOSAIC_CONFIG` (row 190). Serve's exit 2 (usage, config, or a
|
||||
port already in use) is not restarted.
|
||||
|
||||
The update/rollback sequence is [Pinned stack update](#pinned-stack-update)
|
||||
@@ -705,7 +790,7 @@ scripts/discord.sh stop <binding>
|
||||
scripts/discord.sh unlock <binding>
|
||||
scripts/discord.sh recover <binding>
|
||||
scripts/discord.sh reload <binding>
|
||||
scripts/discord-service.sh render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>
|
||||
scripts/discord-service.sh [--instance NAME] render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>
|
||||
```
|
||||
|
||||
One seat's conversation reachable from listed channels of one Discord
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+23
-4
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
|
||||
<!-- mosaic-queue:begin -->
|
||||
|
||||
Generated from `docs/plans/queue.json` revision 2361 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 2455 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -226,14 +226,14 @@ Generated from `docs/plans/queue.json` revision 2361 by `queue render`. Do not e
|
||||
| 187 | stack-release activate carries the row 178 proof pair (mos-0928 Part B A0) | darkwing; reviewers filbert | #1685 | done | 186 done | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-proof-pair.md` § Brief: activate carries the row 178 proof pair | Landed 9a71e4e80 (author Darkwing) on d17328a55; patch-id 6b57c47bc661 matches candidate 52a5ecc1d143 and Filbert's r1 reading. Sage gate passed 10:21:55Z: cli 446/446 and every package and scripts/test-*.sh green. Filbert approve 29424, rev 2204; follow-ups N1-N4 filed on P19. |
|
||||
| 188 | stack-release tests: a carried proof pair and a proof file inside the repository (P19 409, 410; gates Part B) | darkwing; reviewers filbert | #1686 | done | 187 settled | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_proof-test-gaps.md` § Brief: tests for the two proof-file paths row 187 left open | Landed 8fc0ee216 (author Darkwing) on 23a609c5c; patch-id c9a56b167c07 matches candidate 86aa549bad75 and Filbert's r1 reading; stack-release.sh blob 2586a2a37cd2 unchanged. Sage gate passed 10:58Z: cli 453/453 and every package and scripts/test-*.sh green. Filbert approve 29433, rev 2212; N1 (REPO logical path) to P19 after Part B (mos-0959). |
|
||||
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | done | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | Landed ace27dc5e on refactor (cherry-pick of candidate d6116771f onto a4a42dbac). Gate GATE_PASSED on 0c177e9ef (base 0217b71c8), 2026-10-20T19:09:37Z-19:20:36Z, 25 suites rc=0. patch-id ce89378718f6 for both 0c177e9ef and ace27dc5e; range-diff differs only in the message. Landed manifest hashes to 67af69bd (Dewey approve 29485). Base drift 0217b71c8..a4a42dbac: 7 doc/README files, none on manifest paths, no test reads them. Brief re-pinned to blob 29a9e6d2d. |
|
||||
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-review, round 1, request posted | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
|
||||
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-review, round 2, request posted | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
|
||||
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | in-progress | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | — |
|
||||
| 192 | Q175 phase 1: the Console verifies SSO itself (--sso), OIDC RP with oauth4webapi 3.8.8, no live change (MVP T1.2, #412) | launchpad; reviewers webby | #1690 | in-review, round 2, request posted | — | Webby approves the manifest; webui and cli suites, scripts/test-config.sh and every scripts/test-*.sh green on Sage's gate; design section 9 mutants killed (sage) | `docs/plans/2026-10-20_sso-phase1.md` § Brief: Q175 phase 1, the Console verifies SSO itself (--sso) | — |
|
||||
| 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 | — |
|
||||
| 195 | row 195: the Case 1 install on 10.1.1.53 as mosaic under /srv/mosaic, reusing C2-13's scripts by input under row 190's canon (Q181, Q182/Q183, Q185; ops-01 root steps under #423 step 1) | darkwing; reviewers duckworth | #1693 | briefed | 190 done | Duckworth approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Brief: the Case 1 install on 10.1.1.53 (row 195) | sage-1439: rewritten to Case 1 on .53 (f24f2cd40). Chains on C2-13's candidate too once C2-13 is queued. Darkwing starts DESIGN.md after row 190's mutants. |
|
||||
| 196 | the human boundary across uids and humans: Console, tester listener, broker.sock and the path-bound human proof (P19 333, before federation R3, mos-0989) | gander; reviewers duckworth | #1694 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-boundary.md` § Brief: the human boundary across uids and humans (P19 333) | — |
|
||||
| 197 | a second gate lane: two full gates at once on dragon-lin, design first (mos-0977 item 6) | herb; reviewers webby | #1695 | in-progress | — | Webby approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate, singly and in the two-lane proof (sage) | `docs/plans/2026-10-20_second-gate-lane.md` § Brief: a second gate lane, two full gates at once on dragon-lin | Order with row 198 (Sage, mos-1040 N1): 197 lands first. After B1 the shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. Row 198 rebases onto 197's landing before its manifest review. |
|
||||
| 197 | a second gate lane: two full gates at once on dragon-lin, design first (mos-0977 item 6) | herb; reviewers webby | #1695 | in-review, round 1, request posted | — | Webby approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate, singly and in the two-lane proof (sage) | `docs/plans/2026-10-20_second-gate-lane.md` § Brief: a second gate lane, two full gates at once on dragon-lin | Order with row 198 (Sage, mos-1040 N1): 197 lands first. After B1 the shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. Row 198 rebases onto 197's landing before its manifest review. |
|
||||
| 198 | retire the control board writer on 7331 (unauthenticated loopback writer, Q175; mos-1008) | bushroot; reviewers webby | #1696 | in-progress | 192 done, 197 done | Webby approves INVENTORY.md then the manifest; control-board, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_retire-control-board.md` § Brief: retire the control board writer on 7331 | Order with row 197 (Sage, mos-1040 N1): 197 lands first. Row 198 rebases onto 197's landing before its manifest review; shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. |
|
||||
| 199 | MVP T1 item 6: a credential store per agent, own git identity, pushes to a remote (G11, G13; mos-0974) | megavolt; reviewers duckworth | #1697 | in-progress | — | Duckworth approves DESIGN.md; Mos rules on the authority model; Duckworth approves the manifest; bus, business, cli and tasks suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: a credential store per agent, own git identity, pushes to a remote | — |
|
||||
| 200 | stack-release.sh activate --sso for the Q175 phase 2 window (row 192 F3; chained on 192) | launchpad; reviewers webby | #1698 | in-review, round 2, request posted | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite) | — |
|
||||
@@ -248,6 +248,25 @@ Generated from `docs/plans/queue.json` revision 2361 by `queue render`. Do not e
|
||||
| 209 | FED R8: the hub window on 10.1.1.54, a recorded acceptance (#414, r4.11 b93f098f0; live window run by Jason and Mos, after the Case 2 install #416, Bean and Chet live and both Hermes gateways stopped) | sage; reviewers duckworth | — | queued | — | Every entry check's output is in the R8 record with who ran it; Jason signs in to the hub and sends one message, Melanie revokes, his next request is refused; Duckworth checks the record (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R8, the hub window on 10.1.1.54 | Stays queued until its entry gates near: Case 2 install on .54 (#416, by 2026-10-27), Bean and Chet live, both Hermes gateways on .54 stopped, R7, #412 phase 2, #419 answer. Filed per mos-1073 after duckworth-0034 (Sage) |
|
||||
| 210 | Agent host access with sudo through the credential store: grant per agent and host, the stack does ssh and sudo, the agent never sees the key (#1707, Vikunja #424, Q183/Q184, due 10-27; chained on row 199's candidate) | gizmoduck; reviewers duckworth | #1707 | in-progress | — | Duckworth approves DESIGN.md, then the manifest; bus, business, cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_host-credentials.md` § Brief: agent host access with sudo through the credential store (#424) | Design runs before 199 lands (Q177 chain on 199's candidate). after cleared so Gizmoduck can claim; lands only after 199 and rebases onto it. |
|
||||
| 211 | Force-push guard: scripts/git-hooks/pre-push refuses non-fast-forward and delete pushes from every worktree, and scripts/slot-busy.sh exits 1 on BUSY (#1709, Vikunja #426, mos-1078/1083) | rocko; reviewers dewey | #1709 | briefed | — | Dewey approves the manifest; the new or changed scripts/test-*.sh and every other scripts/test-*.sh green on Sage's gate; the mutants killed; hook installed in the canonical checkout with a scripted refused force-push (sage) | `docs/plans/2026-10-20_force-push-guard.md` § Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426) | — |
|
||||
| 212 | Case 2 C2-01 packages/library: mapping, scope, reader, resolver, R-rules, renderItems, survey | gosalyn; reviewers webby | #1713 | in-progress | — | 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-progress | — | 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 | 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 | 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 | 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 | — | 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 | 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 | briefed | — | 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 | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+3546
-16
File diff suppressed because it is too large
Load Diff
Generated
+11
-1
@@ -9,7 +9,8 @@
|
||||
"version": "0.0.3",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1"
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
"oauth4webapi": "3.8.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
@@ -1933,6 +1934,15 @@
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
"version": "3.8.8",
|
||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz",
|
||||
"integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/openai": {
|
||||
"version": "6.40.0",
|
||||
"resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz",
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@
|
||||
"description": "Minimal Mosaic Stack container proof of concept: one Pi agent, four local contract files, one real model request returning MOSAIC_HELLO_OK.",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-coding-agent": "0.85.1"
|
||||
"@earendil-works/pi-coding-agent": "0.85.1",
|
||||
"oauth4webapi": "3.8.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,13 @@ an operator inspect content and delivery evidence after an uncertain result.
|
||||
| `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. |
|
||||
| `decisions` | `{list}`: `roles` or `recent`; human and reader sessions only. Open decisions routed to a role, or the last 50 closed. |
|
||||
| `credentials` | No args; human and reader sessions only. `[{service,account,role,dateKind,date,state}]` for the capability's business, never a token, file path or variable name. |
|
||||
| `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`. |
|
||||
|
||||
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
|
||||
a receipt with empty `changes`. A refusal is `action.refused` with its code alone.
|
||||
| `requirements` | No args; role sessions only, as `task.create` is (row 90, #1584). The approved PRD's requirement ids, `{prd,version,requirements:[{id,title}]}`, from `packages/tasks/requirements.json`, loaded when the broker starts; a bad file refuses the start. A human session refuses `agent-required`, a reader `read-only`. |
|
||||
| `state` | No args; role, human and reader sessions (row 109, #1603). `{launch:{state,at,event},tasks:{open:[{task_ref,title}],total,cap}}` for the capability's business. `launch` is the latest `launch.revoked` or `launch.restored` event: `allowed` with `at` and `event` null when none exists. `open` is the tracker snapshot's open tasks in `task_ref` order, at most `cap` (the `tasks` page size, 50); `total` counts every open task. The harness runner reads it before each turn. Writes nothing. |
|
||||
| `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. |
|
||||
|
||||
+153
-6
@@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path';
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -18,6 +18,8 @@ export const ACTIONS = Object.freeze([
|
||||
'git.push.working',
|
||||
'git.push.protected',
|
||||
'git.merge.protected',
|
||||
// Row 199 (#1697, mos-1027 Q-M2): publish to the agent's fork. Not in ROUTED, so a hard wait.
|
||||
'git.push.remote',
|
||||
'review.request',
|
||||
'review.verdict',
|
||||
'record.write',
|
||||
@@ -101,6 +103,11 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
|
||||
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
|
||||
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
|
||||
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
|
||||
// Row 199 (#1697, design section 6): the credential lifecycle, human-only. Under Q173 the tester may
|
||||
// revoke; issue (rotate is an issue over an issued entry) and delete refuse it, because a token it
|
||||
// issued would pass through its session (duckworth N1). The status read stays the `credentials` verb.
|
||||
export const CREDENTIAL_VERBS = Object.freeze(['credentials.issue', 'credentials.revoke', 'credentials.delete']);
|
||||
const TESTER_CREDENTIAL_VERBS = Object.freeze(['credentials.revoke']);
|
||||
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
|
||||
const POLL_KINDS = new Set([
|
||||
'task.changed.external',
|
||||
@@ -191,10 +198,13 @@ export class Broker {
|
||||
#received = new Set();
|
||||
// Row 137 (#1631): landings checkLand let start, by ticket, until recordLand or landRefused ends them.
|
||||
#landings = new Map();
|
||||
// Row 199: a publish between checkPublish and recordPublish or publishRefused.
|
||||
#publishes = new Map();
|
||||
#secretCheck;
|
||||
#credentialStatus;
|
||||
#requirements;
|
||||
#organization;
|
||||
#credentialVerbs;
|
||||
#project;
|
||||
#usage;
|
||||
#limits;
|
||||
@@ -203,13 +213,15 @@ export class Broker {
|
||||
#stops = new Map();
|
||||
#clock = 0;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null }) {
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
this.#businesses = structuredClone(businesses);
|
||||
this.#store = store;
|
||||
this.#secretCheck = secretCheck;
|
||||
this.#credentialStatus = credentialStatus;
|
||||
this.#organization = organization;
|
||||
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
|
||||
this.#credentialVerbs = credentialVerbs;
|
||||
this.#project = project;
|
||||
this.#usage = usage;
|
||||
this.#limits = limits;
|
||||
@@ -795,7 +807,12 @@ export class Broker {
|
||||
// records `?? null` and the current holder reads undefined.
|
||||
if (action === 'role.revoke' && (context.holderRun ?? null) !== (this.#holder(s.business, target)?.holder_run ?? null))
|
||||
fail('decision-mismatch');
|
||||
return { class: cls, decision: d.id };
|
||||
// Row 199 (r4 B4): approve at A, push B, publish refuses.
|
||||
if (action === 'git.push.remote' && (context.pushCommit ?? null) !== (this.#latestPush(s.business, target)?.commit ?? null))
|
||||
fail('decision-mismatch');
|
||||
return action === 'git.push.remote'
|
||||
? { class: cls, decision: d.id, pushCommit: context.pushCommit ?? null }
|
||||
: { class: cls, decision: d.id };
|
||||
}
|
||||
// The raise's context: its target and approval option, from the decision.raise action.allowed.
|
||||
#raised(business, decision) {
|
||||
@@ -858,13 +875,14 @@ export class Broker {
|
||||
if (body.commit !== push.commit) fail('verdict-stale');
|
||||
}
|
||||
// A push is the action.allowed recordPush wrote, the only one for git.push.working with a commit.
|
||||
// role is the role that pushed (row 199, work-not-yours).
|
||||
#latestPush(business, task) {
|
||||
const r = this.#store.get(
|
||||
"SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.action')='git.push.working' AND json_type(body,'$.commit')='text' AND subject=? ORDER BY seq DESC LIMIT 1",
|
||||
"SELECT body, actor_role FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.action')='git.push.working' AND json_type(body,'$.commit')='text' AND subject=? ORDER BY seq DESC LIMIT 1",
|
||||
business,
|
||||
task,
|
||||
);
|
||||
return r ? JSON.parse(r.body) : null;
|
||||
return r ? { ...JSON.parse(r.body), role: r.actor_role } : null;
|
||||
}
|
||||
// Row 93: the bus host's git socket asks before it takes a push's bytes. Nothing is recorded
|
||||
// unless it refuses: the push is recorded, and its authority consumed, once the ref moved.
|
||||
@@ -998,6 +1016,98 @@ export class Broker {
|
||||
this.#landings.delete(ticket);
|
||||
return code;
|
||||
}
|
||||
// Row 199 (#1697, design D9 steps 1 to 3, D9b): the bus host's git socket publishes a task's
|
||||
// recorded work commit to the agent's own fork. checkPublish runs before anything touches the
|
||||
// network. The run holds its claim; git.push.remote is a hard wait, so it cites a decision the
|
||||
// human resolved for this task, bound to the task's latest push (#authorityFor). The project must
|
||||
// be in the agent's publish grant with an upstream; the fork is built from that upstream and the
|
||||
// agent's own Gitea user, never from the request. The ticket carries the session and decision to
|
||||
// recordPublish or publishRefused. No token is in the result.
|
||||
checkPublish(cap, args) {
|
||||
const s = this.#session(cap);
|
||||
return this.#store.transaction(() => {
|
||||
keys(args, ['task_ref', 'project', 'branch', 'decision'], ['task_ref', 'project', 'branch']);
|
||||
if (!taskRef(args.task_ref) || (args.decision !== undefined && !id(args.decision))) fail('invalid-request');
|
||||
identifier(args.project);
|
||||
string(args.branch, 200);
|
||||
this.#agent(s);
|
||||
const { task_ref, project, branch } = args;
|
||||
const decision = args.decision ?? null;
|
||||
const result = this.#checkAuthority(s, 'git.push.remote', { decision, target: task_ref });
|
||||
const facts = this.#publishFacts(s, task_ref, project, result);
|
||||
const ticket = randomUUID();
|
||||
const dst = `refs/heads/${facts.user}/${branch}`;
|
||||
this.#publishes.set(ticket, { s, task_ref, project, decision, dst, ...facts });
|
||||
return { ticket, task_ref, project, fork: facts.fork, user: facts.user, email: facts.email, source: facts.source, dst };
|
||||
});
|
||||
}
|
||||
// Steps 2 and 3, checked again at recordPublish. After routing a within-role publish has no
|
||||
// decision, so the commit is the latest push's (N9); the git socket still checks the work ref tip.
|
||||
#publishFacts(s, task_ref, project, result) {
|
||||
if (result.decision && this.#spent(s.business, result.decision)) fail('decision-consumed');
|
||||
const b = this.#business(s.business);
|
||||
const role = b.roles?.[s.role];
|
||||
const upstream = b.projects?.[project]?.remote;
|
||||
if (!role?.identity || !(role.publish ?? []).includes(project) || !upstream) fail('remote-not-granted');
|
||||
const problem = forkProblem(upstream, role.identity);
|
||||
if (problem) fail(problem);
|
||||
const push = this.#latestPush(s.business, task_ref);
|
||||
if (!push) fail('no-push');
|
||||
if (push.role !== s.role) fail('work-not-yours');
|
||||
if (push.project !== project) fail('work-ref');
|
||||
const source = result.pushCommit ?? push.commit;
|
||||
if (source !== push.commit) fail('decision-mismatch');
|
||||
const user = role.identity.gitea.user;
|
||||
return { fork: forkUrl(upstream, user), user, email: role.identity.email, source };
|
||||
}
|
||||
#publish(ticket) {
|
||||
return (typeof ticket === 'string' && this.#publishes.get(ticket)) || fail('publish-unknown');
|
||||
}
|
||||
// Design D5: the only key the git socket's gitea token use gets is the agent checkPublish checked
|
||||
// (#agent: a run holding its claim, never a tester, reader or human). Trusted host only.
|
||||
publishSession(ticket) {
|
||||
const { s } = this.#publish(ticket);
|
||||
return Object.freeze({ business: s.business, role: s.role });
|
||||
}
|
||||
// Where the bus process's git sends the push (D9 step 5): all from the ticket, none from the
|
||||
// host's message. Trusted host only.
|
||||
publishTarget(ticket) {
|
||||
const { s, project, fork, user, source, dst } = this.#publish(ticket);
|
||||
return Object.freeze({ business: s.business, project, fork, user, source, dst });
|
||||
}
|
||||
// Step 6: records the push the git socket made as the action.allowed for git.push.remote, which
|
||||
// spends the decision. The checks run again; the run may have ended while git ran, so the holder
|
||||
// check is not repeated (as recordLand). Trusted host only.
|
||||
recordPublish(ticket, pushed) {
|
||||
const t = this.#publish(ticket);
|
||||
const done = this.#store.transaction(() => {
|
||||
keys(pushed, ['ref', 'commit', 'previous'], ['ref', 'commit', 'previous']);
|
||||
if (!oid(pushed.commit) || (pushed.previous !== null && !oid(pushed.previous))) fail('invalid-request');
|
||||
string(pushed.ref, 260);
|
||||
const result = this.#authorityFor(t.s, 'git.push.remote', { decision: t.decision, target: t.task_ref });
|
||||
const facts = this.#publishFacts(t.s, t.task_ref, t.project, result);
|
||||
if (facts.source !== t.source || pushed.commit !== t.source || facts.fork !== t.fork || pushed.ref !== t.dst) fail('ref-moved');
|
||||
const { ref, commit, previous } = pushed;
|
||||
this.#event(
|
||||
t.s,
|
||||
'action.allowed',
|
||||
{ project: t.project, fork: t.fork, ref, commit, previous, action: 'git.push.remote', ...result, target: t.task_ref },
|
||||
t.task_ref,
|
||||
);
|
||||
return { task_ref: t.task_ref, fork: t.fork, ref, commit, previous };
|
||||
});
|
||||
this.#publishes.delete(ticket);
|
||||
return done;
|
||||
}
|
||||
// Ends a publish that didn't push: action.refused with the code on the task's trail. The git
|
||||
// socket answers the run with the same code. Trusted host only.
|
||||
publishRefused(ticket, { code } = {}) {
|
||||
const t = this.#publish(ticket);
|
||||
if (!/^[a-z-]{1,64}$/.test(code ?? '')) fail('invalid-request');
|
||||
this.#store.transaction(() => this.#event(t.s, 'action.refused', { code }, t.task_ref));
|
||||
this.#publishes.delete(ticket);
|
||||
return code;
|
||||
}
|
||||
// The outcome as a RESULT from the bus, as #notice sends: the runner takes a turn on it and sends
|
||||
// no automatic reply. It goes to the role in the thread the decision was raised in.
|
||||
#landMessage(s, decision, body) {
|
||||
@@ -1245,6 +1355,7 @@ export class Broker {
|
||||
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
|
||||
if (PROJECT_FILE_VERBS.includes(request.verb))
|
||||
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
|
||||
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set"].includes(request.verb)) {
|
||||
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
|
||||
// file lists organization.change. It takes the same keys, runs the same write path and
|
||||
@@ -1316,6 +1427,35 @@ export class Broker {
|
||||
throw this.#refused(s, e, request);
|
||||
}
|
||||
}
|
||||
// Row 199 (#1697, design section 6). The token of an issue comes in args from the human's stdin; the
|
||||
// secret check above has refused one the bus already holds, a refusal records its code alone, and
|
||||
// nothing returned carries it. Outside the store transaction, as the organization branch.
|
||||
#requestCredential(s, verb, a) {
|
||||
if (s.tester ? !TESTER_CREDENTIAL_VERBS.includes(verb) : !s.human) fail(s.tester ? 'tester-refused' : 'human-required');
|
||||
if (!this.#credentialVerbs) fail('not-configured');
|
||||
if (verb === 'credentials.issue') keys(a, ['agent', 'service', 'token', 'date'], ['agent', 'service', 'token', 'date']);
|
||||
else keys(a, verb === 'credentials.revoke' ? ['agent', 'service', 'reason'] : ['agent', 'reason'], ['agent', 'reason']);
|
||||
string(a.agent, 64);
|
||||
if (a.service !== undefined && !['gitea', 'vikunja'].includes(a.service)) fail('invalid-request');
|
||||
if (a.token !== undefined) string(a.token, 16384);
|
||||
if (a.date !== undefined) string(a.date, 10);
|
||||
if (a.reason !== undefined) string(a.reason, 500);
|
||||
if (!Object.hasOwn(this.#business(s.business).roles, a.agent)) fail('credential-unknown');
|
||||
// A running agent keeps its tokens until its runs end: delete waits for every claim to end.
|
||||
if (verb === 'credentials.delete' && (this.#holder(s.business, a.agent)?.op === 'claim' || this.#threadHolders(s.business, a.agent).length))
|
||||
fail('agent-running');
|
||||
let result;
|
||||
try {
|
||||
result = this.#credentialVerbs(s.business, verb, a, s.tester ? 'tester' : s.human);
|
||||
} catch (e) {
|
||||
if (e instanceof BusError && e.code.startsWith('credential-')) throw e;
|
||||
if (e.code === 'revision-conflict' || e.code?.startsWith('organization-')) fail(e.code);
|
||||
if (e.name === 'BusinessError') fail('invalid-request');
|
||||
throw new BusError('outcome-unknown');
|
||||
}
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
}
|
||||
// Only the runner's bound session and delivered message identify a turn; no host path crosses the wire.
|
||||
// Row 156 (#1651, sage-0808): a role run's landing turn is a merge decision id instead. Its begin
|
||||
// needs the decision held for the run's role; its end needs that begin, since the landing may
|
||||
@@ -1698,6 +1838,12 @@ export class Broker {
|
||||
by,
|
||||
);
|
||||
}
|
||||
// Row 199 (#1697, design D9 step 1, r4 B4): a publish decision binds one commit, the task's
|
||||
// latest recorded push now, read here and never taken from the agent. The human sees it.
|
||||
const pushCommit =
|
||||
a.action === 'git.push.remote' ? (this.#latestPush(s.business, a.target)?.commit ?? fail('no-push')) : null;
|
||||
const question = pushCommit ? `${a.question} (commit ${pushCommit})` : a.question;
|
||||
string(question);
|
||||
const did = randomUUID();
|
||||
this.#store.run(
|
||||
'INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,task_ref,requirement_ref,blocking,supersedes,spec_path,spec_commit) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
@@ -1710,7 +1856,7 @@ export class Broker {
|
||||
cls,
|
||||
a.action,
|
||||
route,
|
||||
a.question,
|
||||
question,
|
||||
JSON.stringify(a.options),
|
||||
a.recommendation,
|
||||
a.task_ref ?? null,
|
||||
@@ -1732,6 +1878,7 @@ export class Broker {
|
||||
approvalChoice,
|
||||
holderRun:
|
||||
a.action === 'role.revoke' ? (this.#holder(s.business, a.target)?.holder_run ?? null) : null,
|
||||
...(pushCommit ? { pushCommit } : {}),
|
||||
},
|
||||
a.task_ref ?? (taskRef(a.target) ? a.target : did),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { BusError } from './broker.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' });
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
export function credentialVerbs({ credentials, writer }) {
|
||||
const state = (instance, service) =>
|
||||
credentials.status().find((e) => e.instance === instance && e.service === service)?.state;
|
||||
return (id, verb, args, actor) => {
|
||||
const { agent } = args;
|
||||
const instance = id + '/' + agent;
|
||||
const { document, revision } = writer.read(id);
|
||||
const current = document.roles?.[agent]?.credentials;
|
||||
if (!current) deny('credential-unknown');
|
||||
if (verb === 'credentials.issue') {
|
||||
const { service, token, date } = args;
|
||||
if (!current[service]) deny('credential-unknown');
|
||||
// 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, [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 }),
|
||||
);
|
||||
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');
|
||||
// Memory first, then the store file (Credentials.revoke), then the business file. A reference
|
||||
// outside the store becomes `store` with its date, so a restart loads nothing for it. When that
|
||||
// write fails, the entry stays revoked in memory and the refusal says to run the verb again.
|
||||
if (verb === 'credentials.delete') credentials.remove(instance);
|
||||
else for (const service of services) credentials.revoke(instance, service);
|
||||
const detail = { verb: verb.slice('credentials.'.length), agent, kind: services, reason: args.reason };
|
||||
const moved = services.filter((service) => current[service].store !== true);
|
||||
if (moved.length) {
|
||||
const next = structuredClone(document);
|
||||
for (const service of moved)
|
||||
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 {
|
||||
agent,
|
||||
verb: detail.verb,
|
||||
services: services.map((service) => ({ service, state: state(instance, service) ?? 'not-issued' })),
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { openSync, closeSync, fstatSync, readFileSync, lstatSync, realpathSync, constants } from 'node:fs';
|
||||
import { isAbsolute, relative, resolve } from 'node:path';
|
||||
import {
|
||||
openSync, closeSync, fstatSync, readFileSync, lstatSync, realpathSync, constants, mkdirSync, writeSync, fsyncSync,
|
||||
renameSync, unlinkSync, rmdirSync, readdirSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { BusError } from './broker.mjs';
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
@@ -13,9 +16,33 @@ const day = (value) =>
|
||||
/^\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?$/;
|
||||
// Row 199 (#1697, design D1, D4): a store dir is the stack instance's user's own and nobody else's.
|
||||
const privateDir = (path, { missing = false } = {}) => {
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(path);
|
||||
} catch (e) {
|
||||
if (missing && e.code === 'ENOENT') return false;
|
||||
deny('credential-file');
|
||||
}
|
||||
if (!st.isDirectory() || st.uid !== process.getuid() || (st.mode & 0o077) !== 0) deny('credential-file');
|
||||
return true;
|
||||
};
|
||||
export class Credentials {
|
||||
#tokens = new Map();
|
||||
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env }) {
|
||||
// Row 199: every reference the bus loaded, so issue, rotate, revoke and status work per entry.
|
||||
#refs = new Map();
|
||||
#revoked = new Set();
|
||||
#storeRoot;
|
||||
#repoRoots;
|
||||
#dataRoot;
|
||||
// storeRoot is <configDir>/secrets/agents (design D1); a reference with store: true must name the
|
||||
// path under it for its own business, agent and service.
|
||||
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null }) {
|
||||
this.#storeRoot = storeRoot;
|
||||
this.#repoRoots = repoRoots;
|
||||
this.#dataRoot = dataRoot;
|
||||
try {
|
||||
for (const [instance, services] of Object.entries(references))
|
||||
for (const [service, ref] of Object.entries(services)) {
|
||||
@@ -26,12 +53,41 @@ export class Credentials {
|
||||
if (
|
||||
(ref.service !== undefined && ref.service !== service) ||
|
||||
Object.keys(ref).some(
|
||||
(k) => !['service', 'file', 'env', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
|
||||
(k) => !['service', 'file', 'env', 'store', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k),
|
||||
) ||
|
||||
Boolean(ref.file) === Boolean(ref.env)
|
||||
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 token = this.#read(ref, env);
|
||||
if (token !== null) this.#tokens.set(instance + ':' + service, { token, date });
|
||||
}
|
||||
} catch (e) {
|
||||
this.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
#storePath(instance, service) {
|
||||
if (!this.#storeRoot) deny('credential-reference');
|
||||
const [business, agent, ...rest] = instance.split('/');
|
||||
if (!business || !agent || rest.length || agent.startsWith('@')) deny('credential-reference');
|
||||
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) {
|
||||
let token;
|
||||
if (ref.store) {
|
||||
const agentDir = dirname(ref.file);
|
||||
privateDir(this.#storeRoot);
|
||||
if (!privateDir(dirname(agentDir), { missing: true }) || !privateDir(agentDir, { missing: true })) return null;
|
||||
try {
|
||||
lstatSync(ref.file);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') return null;
|
||||
deny('credential-file');
|
||||
}
|
||||
}
|
||||
if (ref.file) {
|
||||
if (!isAbsolute(ref.file)) deny('credential-location');
|
||||
let resolved, original;
|
||||
@@ -43,7 +99,7 @@ export class Credentials {
|
||||
}
|
||||
if (original.isSymbolicLink() || !original.isFile()) deny('credential-file');
|
||||
if (
|
||||
[...repoRoots, dataRoot].filter(Boolean).some((root) =>
|
||||
[...this.#repoRoots, this.#dataRoot].filter(Boolean).some((root) =>
|
||||
inside(
|
||||
(() => {
|
||||
try {
|
||||
@@ -81,16 +137,98 @@ export class Credentials {
|
||||
if (!/^[A-Z_][A-Z0-9_]*$/.test(ref.env)) deny('credential-reference');
|
||||
token = env[ref.env];
|
||||
}
|
||||
if (typeof token !== 'string' || token.length > 16384 || !/^[-A-Za-z0-9._~]+\n?$/.test(token))
|
||||
deny('credential-format');
|
||||
token = token.replace(/\n$/, '');
|
||||
if (token.length < 16) deny('credential-format');
|
||||
this.#tokens.set(instance + ':' + service, { token, date });
|
||||
}
|
||||
} catch (e) {
|
||||
this.close();
|
||||
throw e;
|
||||
return Credentials.#format(token);
|
||||
}
|
||||
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;
|
||||
}
|
||||
#known(instance, service) {
|
||||
const known = this.#refs.get(instance + ':' + service);
|
||||
if (!known) deny('credential-unknown');
|
||||
return known;
|
||||
}
|
||||
// Row 199 (design section 6): write the token to the agent's store path (tmp, fsync, rename), then
|
||||
// `commit` (the business file's reference becomes store), then memory. The token comes from the
|
||||
// caller's stdin and is never logged. Returns the entry's new status.
|
||||
issue(instance, service, token, date, commit = () => {}) {
|
||||
this.#known(instance, service);
|
||||
if (!day(date)) deny('credential-date');
|
||||
token = Credentials.#format(token);
|
||||
const file = this.#storePath(instance, service);
|
||||
const agentDir = dirname(file);
|
||||
mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 });
|
||||
privateDir(this.#storeRoot);
|
||||
for (const dir of [dirname(agentDir), agentDir]) {
|
||||
if (!privateDir(dir, { missing: true })) mkdirSync(dir, { mode: 0o700 });
|
||||
privateDir(dir);
|
||||
}
|
||||
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, token + '\n');
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
renameSync(tmp, file);
|
||||
const dirFd = openSync(agentDir, constants.O_RDONLY);
|
||||
try {
|
||||
fsyncSync(dirFd);
|
||||
} finally {
|
||||
closeSync(dirFd);
|
||||
}
|
||||
commit();
|
||||
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
|
||||
this.#tokens.set(instance + ':' + service, { token, date });
|
||||
this.#revoked.delete(instance + ':' + service);
|
||||
return this.status().find((e) => e.instance === instance && e.service === service);
|
||||
}
|
||||
// Memory first, then the store file (design section 6, D12). A file or env reference outside the
|
||||
// store is never unlinked; the caller turns its reference into store, so a restart finds nothing.
|
||||
revoke(instance, service) {
|
||||
const { ref } = this.#known(instance, service);
|
||||
this.#tokens.delete(instance + ':' + service);
|
||||
this.#revoked.add(instance + ':' + service);
|
||||
if (ref.store) {
|
||||
try {
|
||||
unlinkSync(ref.file);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
}
|
||||
}
|
||||
return { file: Boolean(ref.store) };
|
||||
}
|
||||
// revoke for every service, then the agent's dir. A file other than a token or its tmp refuses.
|
||||
remove(instance) {
|
||||
const services = [...this.#refs.keys()].filter((k) => k.startsWith(instance + ':')).map((k) => k.slice(instance.length + 1));
|
||||
for (const service of services) this.revoke(instance, service);
|
||||
const agentDir = dirname(this.#storePath(instance, 'gitea'));
|
||||
let names;
|
||||
try {
|
||||
names = readdirSync(agentDir);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') return { services };
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
assertClean(value) {
|
||||
let encoded;
|
||||
@@ -109,14 +247,16 @@ export class Credentials {
|
||||
let result;
|
||||
try {
|
||||
result = await operation(entry.token);
|
||||
} catch {
|
||||
} 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;
|
||||
}
|
||||
status() {
|
||||
return [...this.#tokens.entries()].map(([key, { date }]) => {
|
||||
return [...this.#refs.entries()].map(([key, { date }]) => {
|
||||
const i = key.lastIndexOf(':'),
|
||||
service = key.slice(i + 1),
|
||||
left = Date.parse(date + 'T00:00:00Z') - Date.now();
|
||||
@@ -124,8 +264,11 @@ export class Credentials {
|
||||
instance: key.slice(0, i),
|
||||
service,
|
||||
date,
|
||||
state:
|
||||
service === 'gitea'
|
||||
state: !this.#tokens.has(key)
|
||||
? this.#revoked.has(key)
|
||||
? 'revoked'
|
||||
: 'not-issued'
|
||||
: service === 'gitea'
|
||||
? left <= 0
|
||||
? 'rotation-due'
|
||||
: 'valid'
|
||||
@@ -139,5 +282,6 @@ export class Credentials {
|
||||
}
|
||||
close() {
|
||||
this.#tokens.clear();
|
||||
this.#refs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ const LAUNCH_OPS = new Set([
|
||||
'checkLand',
|
||||
'recordLand',
|
||||
'landRefused',
|
||||
'checkPublish',
|
||||
'publishPush',
|
||||
'publishResult',
|
||||
'recordPublish',
|
||||
'publishRefused',
|
||||
'landHeld',
|
||||
'threadsState',
|
||||
'threadIdle',
|
||||
@@ -34,6 +39,12 @@ function launchOp(m) {
|
||||
if (m.op === 'checkLand') return runtime.checkLand(m.cap, m.args);
|
||||
if (m.op === 'recordLand') return runtime.recordLand(m.ticket, m.land);
|
||||
if (m.op === 'landRefused') return runtime.landRefused(m.ticket, m.args);
|
||||
// Row 199 (#1697, design D9b): the git socket's publish op. No entry returns a token.
|
||||
if (m.op === 'checkPublish') return runtime.checkPublish(m.cap, m.args);
|
||||
if (m.op === 'publishPush') return runtime.publishPush(m.ticket);
|
||||
if (m.op === 'publishResult') return runtime.publishResult(m.ticket);
|
||||
if (m.op === 'recordPublish') return runtime.recordPublish(m.ticket, m.pushed);
|
||||
if (m.op === 'publishRefused') return runtime.publishRefused(m.ticket, m.args);
|
||||
if (m.op === 'landHeld') return runtime.landHeld(m.record);
|
||||
// Row 121 (#1615): the launcher's view of the business's threads, and its idle release.
|
||||
if (m.op === 'threadsState') return runtime.threadsState(m.business);
|
||||
@@ -99,7 +110,10 @@ if (!process.send) {
|
||||
booted = true;
|
||||
clearTimeout(timer);
|
||||
// `trackers` is S3's plain-data boot config; the adapter is loaded only when a business has one.
|
||||
// Row 199: publishTls is the tests' scratch CA, for an in-process runtime only. The host's boot
|
||||
// config never carries it, so a boot that does is refused rather than trusted.
|
||||
const { trackers, ...config } = message.config ?? {};
|
||||
if (Object.hasOwn(config, 'publishTls')) throw new BusError('invalid-host-request');
|
||||
if (trackers) {
|
||||
const { tasksAdapter } = await import('../../tasks/src/adapter.mjs');
|
||||
config.tasks = tasksAdapter({ trackers });
|
||||
|
||||
@@ -8,9 +8,12 @@ import { Credentials } from './credentials.mjs';
|
||||
import { serve } from './server.mjs';
|
||||
import { launchSettings } from '../../business/src/launch-settings.mjs';
|
||||
import { readSessions } from '../../seat/src/session.mjs';
|
||||
import { publishRemote, workingRepo, WorkingError } from '../../seat/src/working.mjs';
|
||||
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
|
||||
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
|
||||
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 { verifyHuman } from './human.mjs';
|
||||
import { createUsage } from './usage.mjs';
|
||||
@@ -72,6 +75,9 @@ export async function startBroker({
|
||||
repoRoots = [],
|
||||
tasks = null,
|
||||
organization = null,
|
||||
// Row 199: the tests' scratch CA for publish's git (`-c http.sslCAInfo=...`). process.mjs drops
|
||||
// it from the boot config, so only an in-process caller sets it.
|
||||
publishTls = [],
|
||||
}) {
|
||||
let store, credentials, server, adapter;
|
||||
try {
|
||||
@@ -86,7 +92,9 @@ export async function startBroker({
|
||||
...repoRoots,
|
||||
...Object.values(businesses).flatMap((b) => Object.values(b.projects ?? {}).map((p) => p.root)),
|
||||
];
|
||||
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot });
|
||||
// Row 199 (#1697, design D1): the per-agent store is <configDir>/secrets/agents.
|
||||
const storeRoot = organization ? join(organization.dir, 'secrets', 'agents') : null;
|
||||
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.
|
||||
@@ -133,6 +141,11 @@ export async function startBroker({
|
||||
}:null,
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
credentialStatus: () => credentials.status(),
|
||||
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete, live with no restart; each
|
||||
// rewrites or records through the organization writer, so changes.jsonl holds the receipt.
|
||||
credentialVerbs: organization
|
||||
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) })
|
||||
: null,
|
||||
// Row 65: a new launch binds only on a harness its business switches on, read from the file
|
||||
// as it stands now; an unreadable file switches none on.
|
||||
harnessOn: (id, name) => {
|
||||
@@ -212,6 +225,42 @@ export async function startBroker({
|
||||
// Row 137 (#1631): the git socket's land op. checkLand's refusal is the run's, like checkPush's.
|
||||
// A landing then ends in recordLand or landRefused under its ticket, which outlive the run's cap.
|
||||
const checkLand = (cap, args) => refusing(cap, () => broker.checkLand(cap, args), args?.task_ref);
|
||||
// Row 199 (#1697, design D9b): the git socket's publish op. checkPublish's refusal is the run's.
|
||||
// The gitea token is used only under the open ticket, keyed from the agent checkPublish checked,
|
||||
// and the op table returns no token. A publish then ends in recordPublish or publishRefused,
|
||||
// which files any refusal once, withGitea's included.
|
||||
const checkPublish = (cap, args) => refusing(cap, () => broker.checkPublish(cap, args), args?.task_ref);
|
||||
const withGitea = async (ticket, fn) => credentials.useFor(broker.publishSession(ticket), 'gitea', fn);
|
||||
// The push itself runs here, where the token is (D9 step 5), and the git socket's host polls
|
||||
// for its end: the host's op channel answers one op at a time within 10 s, and ls-remote and
|
||||
// push can take longer. Fork, user, source and dst all come from the ticket. A WorkingError's
|
||||
// code is the refusal; git's stderr stays here.
|
||||
const remotes = new Map();
|
||||
function publishPush(ticket) {
|
||||
if (remotes.has(ticket)) throw new BusError('publish-busy');
|
||||
const { business, project, fork, user, source, dst } = broker.publishTarget(ticket);
|
||||
const entry = { state: 'running' };
|
||||
remotes.set(ticket, entry);
|
||||
withGitea(ticket, (token) =>
|
||||
publishRemote({ repo: workingRepo(dataRoot, business, project), fork, user, source, dst, token, tls: publishTls }).catch((e) => {
|
||||
throw e instanceof WorkingError ? new BusError(e.code) : e;
|
||||
}),
|
||||
).then(
|
||||
(r) => Object.assign(entry, { state: 'pushed', ...r }),
|
||||
(e) => {
|
||||
// Not a refusal: the error's name and code only, never a message that could carry git's output.
|
||||
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' });
|
||||
},
|
||||
);
|
||||
return { state: 'running' };
|
||||
}
|
||||
function publishResult(ticket) {
|
||||
const entry = remotes.get(ticket);
|
||||
if (!entry) throw new BusError('publish-unknown');
|
||||
if (entry.state !== 'running') remotes.delete(ticket);
|
||||
return { ...entry };
|
||||
}
|
||||
// Metadata only (service, date, state); the token stays in Credentials.
|
||||
function credentialStatus(business, role) {
|
||||
return credentials
|
||||
@@ -237,6 +286,12 @@ export async function startBroker({
|
||||
checkLand,
|
||||
recordLand: (ticket, land) => broker.recordLand(ticket, land),
|
||||
landRefused: (ticket, args) => broker.landRefused(ticket, args),
|
||||
checkPublish,
|
||||
withGitea,
|
||||
publishPush,
|
||||
publishResult,
|
||||
recordPublish: (ticket, pushed) => broker.recordPublish(ticket, pushed),
|
||||
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),
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// Row 199 (#1697, design D1, D3a, D4, section 6, T1 and T5): the per-agent credential store under
|
||||
// <configDir>/secrets/agents/<business>/<agent>/<service>. Fake tokens in a scratch root only.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
|
||||
const TOKEN = 'fake_store_token_0123456789';
|
||||
const ROTATED = 'fake_rotated_token_9876543210';
|
||||
const AGENT = { business: 'acme', role: 'coder' };
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-store-'));
|
||||
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, 'acme', 'coder', 'gitea');
|
||||
const references = { 'acme/coder': { gitea: { service: 'gitea', store: true, file, rotateBy: '2099-01-01' } } };
|
||||
const open = (extra = {}) => new Credentials({ references, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data', ...extra });
|
||||
const state = (c) => c.status().find((e) => e.instance === 'acme/coder' && e.service === 'gitea').state;
|
||||
const read = (c, session = AGENT) => c.useFor(session, 'gitea', async (token) => ({ same: token === TOKEN, rotated: token === ROTATED }));
|
||||
return { root, storeRoot, file, references, open, state, read };
|
||||
}
|
||||
|
||||
test('T1: a store reference with no file yet starts, and the agent is not-issued until issue', async (t) => {
|
||||
const s = setup(t);
|
||||
const c = s.open();
|
||||
assert.equal(s.state(c), 'not-issued');
|
||||
await assert.rejects(s.read(c), /credential-unavailable/);
|
||||
const status = c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
|
||||
assert.equal(status.state, 'valid');
|
||||
assert.deepEqual(await s.read(c), { same: true, rotated: false });
|
||||
assert.equal(statSync(s.file).mode & 0o777, 0o600);
|
||||
for (const dir of [join(s.storeRoot, 'acme'), join(s.storeRoot, 'acme', 'coder')]) assert.equal(statSync(dir).mode & 0o777, 0o700);
|
||||
assert.deepEqual(readdirSync(join(s.storeRoot, 'acme', 'coder')), ['gitea']);
|
||||
// A restart reads the issued file.
|
||||
assert.deepEqual(await s.read(s.open()), { same: true, rotated: false });
|
||||
});
|
||||
|
||||
test('T1: a store path the reference does not own, or no store root, refuses the reference', (t) => {
|
||||
const s = setup(t);
|
||||
const other = join(s.storeRoot, 'acme', 'pm', 'gitea');
|
||||
assert.throws(() => s.open({ references: { 'acme/coder': { gitea: { store: true, file: other, rotateBy: '2099-01-01' } } } }), /credential-reference/);
|
||||
assert.throws(() => new Credentials({ references: s.references, repoRoots: [], dataRoot: '/nonexistent-data' }), /credential-reference/);
|
||||
assert.throws(() => s.open({ references: { 'acme/@tester': { gitea: { store: true, file: join(s.storeRoot, 'acme', '@tester', 'gitea'), rotateBy: '2099-01-01' } } } }),
|
||||
/credential-reference/);
|
||||
});
|
||||
|
||||
test('T1: a wrong mode on the store root, a business dir, an agent dir or the file refuses start', (t) => {
|
||||
const s = setup(t);
|
||||
s.open().issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
|
||||
for (const [path, bad, good] of [
|
||||
[s.storeRoot, 0o750, 0o700],
|
||||
[join(s.storeRoot, 'acme'), 0o705, 0o700],
|
||||
[join(s.storeRoot, 'acme', 'coder'), 0o770, 0o700],
|
||||
[s.file, 0o640, 0o600],
|
||||
]) {
|
||||
chmodSync(path, bad);
|
||||
assert.throws(() => s.open(), /credential-file/, path);
|
||||
chmodSync(path, good);
|
||||
}
|
||||
s.open();
|
||||
});
|
||||
|
||||
test('T1: a missing store root refuses start; a missing {file} reference still refuses start', (t) => {
|
||||
const s = setup(t);
|
||||
rmSync(s.storeRoot, { recursive: true });
|
||||
assert.throws(() => s.open(), /credential-file/);
|
||||
assert.throws(() => s.open({ references: { coder: { gitea: { file: join(s.root, 'absent'), rotateBy: '2099-01-01' } } } }), /credential-file/);
|
||||
});
|
||||
|
||||
test('T1: a store file that is a directory or a symlink refuses start', (t) => {
|
||||
const s = setup(t);
|
||||
mkdirSync(join(s.storeRoot, 'acme', 'coder'), { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(s.storeRoot, 'acme'), 0o700);
|
||||
mkdirSync(s.file, { mode: 0o700 });
|
||||
assert.throws(() => s.open(), /credential-file/);
|
||||
rmSync(s.file, { recursive: true });
|
||||
const target = join(s.root, 'target');
|
||||
writeFileSync(target, TOKEN + '\n', { mode: 0o600 });
|
||||
symlinkSync(target, s.file);
|
||||
assert.throws(() => s.open(), /credential-file/);
|
||||
});
|
||||
|
||||
test('T2: useFor keys from the session only', async (t) => {
|
||||
const s = setup(t);
|
||||
const c = s.open();
|
||||
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
|
||||
await assert.rejects(s.read(c, { business: 'acme', role: 'pm' }), /credential-unavailable/);
|
||||
await assert.rejects(s.read(c, { business: 'acme', role: '@tester' }), /credential-not-yours/);
|
||||
await assert.rejects(s.read(c, null), /credential-not-yours/);
|
||||
await assert.rejects(s.read(c, { business: 'acme' }), /credential-not-yours/);
|
||||
});
|
||||
|
||||
test('T5: issue, rotate, revoke and delete are live with no restart, and revoke holds across one', async (t) => {
|
||||
const s = setup(t);
|
||||
const c = s.open();
|
||||
let committed = 0;
|
||||
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01', () => committed++);
|
||||
assert.equal(committed, 1);
|
||||
// Rotate is a second issue: the new token is used at once, with no tmp file left.
|
||||
c.issue('acme/coder', 'gitea', ROTATED + '\n', '2099-06-01');
|
||||
assert.deepEqual(await s.read(c), { same: false, rotated: true });
|
||||
assert.equal(c.status()[0].date, '2099-06-01');
|
||||
assert.deepEqual(readdirSync(join(s.storeRoot, 'acme', 'coder')), ['gitea']);
|
||||
// Revoke: memory first, then the file.
|
||||
assert.deepEqual(c.revoke('acme/coder', 'gitea'), { file: true });
|
||||
assert.equal(s.state(c), 'revoked');
|
||||
assert.equal(existsSync(s.file), false);
|
||||
await assert.rejects(s.read(c), /credential-unavailable/);
|
||||
const restarted = s.open();
|
||||
await assert.rejects(s.read(restarted), /credential-unavailable/);
|
||||
assert.equal(s.state(restarted), 'not-issued');
|
||||
// Delete removes the agent's dir.
|
||||
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
|
||||
assert.deepEqual(c.remove('acme/coder'), { services: ['gitea'] });
|
||||
assert.equal(existsSync(join(s.storeRoot, 'acme', 'coder')), false);
|
||||
await assert.rejects(s.read(c), /credential-unavailable/);
|
||||
});
|
||||
|
||||
test('T5: a failed commit leaves the token on disk but not in memory, and a bad token or date writes nothing', async (t) => {
|
||||
const s = setup(t);
|
||||
const c = s.open();
|
||||
assert.throws(() => c.issue('acme/coder', 'gitea', 'short', '2099-01-01'), /credential-format/);
|
||||
assert.throws(() => c.issue('acme/coder', 'gitea', TOKEN, '2099-13-01'), /credential-date/);
|
||||
assert.throws(() => c.issue('acme/pm', 'gitea', TOKEN, '2099-01-01'), /credential-unknown/);
|
||||
assert.equal(existsSync(s.file), false);
|
||||
assert.throws(() => c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01', () => { throw new Error('commit failed'); }), /commit failed/);
|
||||
assert.equal(existsSync(s.file), true);
|
||||
assert.equal(s.state(c), 'not-issued');
|
||||
await assert.rejects(s.read(c), /credential-unavailable/);
|
||||
});
|
||||
|
||||
test('T5: revoke drops the token from memory before the unlink, so a failed unlink still leaves it revoked', async (t) => {
|
||||
const s = setup(t);
|
||||
const c = s.open();
|
||||
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
|
||||
assert.deepEqual(await s.read(c), { same: true, rotated: false });
|
||||
// An agent dir the bus can't write: the unlink fails with EACCES.
|
||||
const agentDir = join(s.storeRoot, 'acme', 'coder');
|
||||
chmodSync(agentDir, 0o500);
|
||||
try {
|
||||
assert.throws(() => c.revoke('acme/coder', 'gitea'), /credential-file/);
|
||||
} finally {
|
||||
chmodSync(agentDir, 0o700);
|
||||
}
|
||||
assert.equal(existsSync(s.file), true);
|
||||
assert.equal(s.state(c), 'revoked');
|
||||
await assert.rejects(s.read(c), /credential-unavailable/);
|
||||
});
|
||||
|
||||
test('T5: revoke of a {file} reference forgets the token and never unlinks the file', async (t) => {
|
||||
const s = setup(t);
|
||||
const path = join(s.root, 'outside-token');
|
||||
writeFileSync(path, TOKEN + '\n', { mode: 0o600 });
|
||||
const c = s.open({ references: { 'acme/coder': { gitea: { file: path, rotateBy: '2099-01-01' } } } });
|
||||
assert.deepEqual(await s.read(c), { same: true, rotated: false });
|
||||
assert.deepEqual(c.revoke('acme/coder', 'gitea'), { file: false });
|
||||
assert.equal(existsSync(path), true);
|
||||
await assert.rejects(s.read(c), /credential-unavailable/);
|
||||
});
|
||||
|
||||
test('T5: delete refuses an agent dir holding anything but tokens', (t) => {
|
||||
const s = setup(t);
|
||||
const c = s.open();
|
||||
c.issue('acme/coder', 'gitea', TOKEN, '2099-01-01');
|
||||
writeFileSync(join(s.storeRoot, 'acme', 'coder', 'notes'), 'x', { mode: 0o600 });
|
||||
assert.throws(() => c.remove('acme/coder'), /credential-file/);
|
||||
assert.equal(existsSync(join(s.storeRoot, 'acme', 'coder', 'notes')), true);
|
||||
});
|
||||
|
||||
// Design D5: the name-keyed use() is the host's own, for @sync and the tracker's start check of each
|
||||
// role's token. Every other credential use goes through useFor, keyed from the broker's session. A
|
||||
// new use( call site outside this list fails here.
|
||||
test('D5: every credentials use( call site is on the allowlist', () => {
|
||||
const packages = new URL('../../', import.meta.url);
|
||||
const allowed = new Map([
|
||||
['bus/src/credentials.mjs', 1], // useFor's own call
|
||||
['tasks/src/adapter.mjs', 1], // the host's use(instance) for @sync and probe
|
||||
]);
|
||||
const found = new Map();
|
||||
const walk = (dir, rel) => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === 'node_modules' || name === 'tests') continue;
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) walk(path, `${rel}${name}/`);
|
||||
else if (name.endsWith('.mjs') || name.endsWith('.js')) {
|
||||
const code = readFileSync(path, 'utf8').split('\n').filter((line) => !/^\s*\/\//.test(line)).join('\n');
|
||||
const n = (code.match(/(?:credentials|this)\.use\(/g) ?? []).length;
|
||||
if (n) found.set(`${rel}${name}`, n);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const pkg of readdirSync(packages)) if (statSync(new URL(`${pkg}/src`, packages), { throwIfNoEntry: false })?.isDirectory())
|
||||
walk(new URL(`${pkg}/src`, packages).pathname, `${pkg}/src/`);
|
||||
assert.deepEqual(Object.fromEntries([...found].sort()), Object.fromEntries([...allowed].sort()));
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
// Row 199 (#1697, design section 6, T4, T5): credentials.issue, credentials.revoke and
|
||||
// credentials.delete through the runtime. Human-only; the tester revokes and never issues. Each verb
|
||||
// is live with no restart and leaves one receipt in changes.jsonl with no value in it. Fake tokens in a
|
||||
// scratch root only.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { Broker, CREDENTIAL_VERBS } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||
|
||||
const TOKEN = 'fake_issued_token_0123456789';
|
||||
const ROTATED = 'fake_rotated_token_9876543210';
|
||||
const OTHER = 'fake_other_token_5555555555';
|
||||
const DATE = '2099-01-01';
|
||||
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
|
||||
async function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
|
||||
doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE };
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
|
||||
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
|
||||
// The bus's resolved business carries the same references; a store one names its derived file.
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
|
||||
roles: {
|
||||
coder: { ...role, credentials: { gitea: { store: true, file: store('coder', 'gitea'), rotateBy: DATE }, vikunja: doc.roles.coder.credentials.vikunja } },
|
||||
reviewer: { ...role, credentials: doc.roles.reviewer.credentials },
|
||||
},
|
||||
};
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(() => bus.close());
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const tester = bus.broker.bindTester({ business: 'demo' });
|
||||
const ask = (cap, verb, args) => bus.broker.request(cap, { verb, args });
|
||||
const file = () => JSON.parse(readFileSync(join(root, 'businesses/demo.json'), 'utf8'));
|
||||
const receipts = () => {
|
||||
const log = join(root, 'businesses/.history/demo/changes.jsonl');
|
||||
return existsSync(log) ? readFileSync(log, 'utf8').trim().split('\n').map(JSON.parse) : [];
|
||||
};
|
||||
const committed = () => receipts().filter((r) => r.state === 'committed');
|
||||
const status = (agent) => Object.fromEntries(bus.credentialStatus('demo', agent).map((e) => [e.service, e.state]));
|
||||
return { root, bus, human, tester, ask, file, receipts, committed, status, store };
|
||||
}
|
||||
|
||||
test('row 199: the lifecycle verbs refuse every session but the human, and the tester revokes only', (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-cred-gate-'));
|
||||
const s = new Store(root);
|
||||
t.after(() => { s.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const calls = [];
|
||||
const b = new Broker({
|
||||
store: s,
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true }, roles: { coder: role } } },
|
||||
credentialVerbs: (id, verb, args, actor) => (calls.push([verb, actor]), { ok: true }),
|
||||
});
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const tester = b.bindTester({ business: 'demo' });
|
||||
const reader = b.bindReader({ business: 'demo' });
|
||||
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' });
|
||||
const args = {
|
||||
'credentials.issue': { agent: 'coder', service: 'gitea', token: TOKEN, date: DATE },
|
||||
'credentials.revoke': { agent: 'coder', reason: 'fired' },
|
||||
'credentials.delete': { agent: 'coder', reason: 'fired' },
|
||||
};
|
||||
assert.deepEqual(CREDENTIAL_VERBS, Object.keys(args));
|
||||
for (const verb of CREDENTIAL_VERBS) {
|
||||
assert.throws(() => b.request(reader, { verb, args: args[verb] }), /human-required/, verb);
|
||||
assert.throws(() => b.request(coder, { verb, args: args[verb] }), /human-required/, verb);
|
||||
}
|
||||
assert.throws(() => b.request(tester, { verb: 'credentials.issue', args: args['credentials.issue'] }), /tester-refused/);
|
||||
assert.throws(() => b.request(tester, { verb: 'credentials.delete', args: args['credentials.delete'] }), /tester-refused/);
|
||||
// Keys: a token on revoke, a missing reason, an unknown service, an unknown agent, an @ agent.
|
||||
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { ...args['credentials.revoke'], token: TOKEN } }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { agent: 'coder' } }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], service: 'model' } }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], file: '/x' } }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'credentials.issue', args: { ...args['credentials.issue'], agent: 'pm' } }), /credential-unknown/);
|
||||
assert.throws(() => b.request(human, { verb: 'credentials.revoke', args: { agent: '@sync', reason: 'x' } }), /credential-unknown/);
|
||||
assert.equal(calls.length, 0);
|
||||
// A refusal records its code alone, never the token.
|
||||
const refused = s.all("SELECT body FROM events WHERE kind='action.refused'");
|
||||
assert.ok(refused.length >= 10);
|
||||
for (const { body } of refused) assert.equal(body.includes(TOKEN), false);
|
||||
// delete waits for the agent's claim to end.
|
||||
b.request(coder, { verb: 'role.claim' });
|
||||
assert.throws(() => b.request(human, { verb: 'credentials.delete', args: args['credentials.delete'] }), /agent-running/);
|
||||
b.request(coder, { verb: 'role.release' });
|
||||
for (const verb of CREDENTIAL_VERBS) b.request(human, { verb, args: args[verb] });
|
||||
b.request(tester, { verb: 'credentials.revoke', args: args['credentials.revoke'] });
|
||||
assert.deepEqual(calls, [['credentials.issue', 'jason'], ['credentials.revoke', 'jason'], ['credentials.delete', 'jason'], ['credentials.revoke', 'tester']]);
|
||||
// No lifecycle without the runtime's store.
|
||||
const bare = new Broker({ store: s, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } } } });
|
||||
const h2 = bare.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
assert.throws(() => bare.request(h2, { verb: 'credentials.revoke', args: args['credentials.revoke'] }), /not-configured/);
|
||||
});
|
||||
|
||||
test('T5: issue, rotate, revoke and delete are live, rewrite the business file and leave one receipt each, with no value anywhere', async (t) => {
|
||||
const c = await setup(t);
|
||||
const coder = { business: 'demo', role: 'coder' };
|
||||
const read = (service) => c.bus.credentials.useFor(coder, service, async (token) => token === TOKEN ? 'issued' : token === ROTATED ? 'rotated' : 'other');
|
||||
assert.deepEqual(c.status('coder'), { gitea: 'not-issued', vikunja: 'valid' });
|
||||
// issue: the file, then the business file's reference, then memory.
|
||||
const issued = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN + '\n', date: DATE });
|
||||
assert.deepEqual(issued, { agent: 'coder', verb: 'issue', service: 'gitea', date: DATE, state: 'valid' });
|
||||
assert.equal(await read('gitea'), 'issued');
|
||||
assert.equal(statSync(c.store('coder', 'gitea')).mode & 0o777, 0o600);
|
||||
assert.deepEqual(c.file().roles.coder.credentials.gitea, { store: true, rotateBy: DATE });
|
||||
let last = c.committed().at(-1);
|
||||
assert.equal(last.actor, 'jason');
|
||||
assert.deepEqual(last.detail, { verb: 'issue', agent: 'coder', kind: ['gitea'], date: DATE });
|
||||
// The same token again is one the bus holds: the secret check refuses it before any write.
|
||||
const count = c.receipts().length;
|
||||
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: TOKEN, date: DATE }), /credential-leak/);
|
||||
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: 'short', date: DATE }), /credential-format/);
|
||||
assert.throws(() => c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: OTHER, date: '2099-02-30' }), /credential-date/);
|
||||
assert.equal(c.receipts().length, count);
|
||||
// rotate: an issue over an issued entry, used at once.
|
||||
const rotated = c.ask(c.human, 'credentials.issue', { agent: 'coder', service: 'gitea', token: ROTATED, date: '2099-06-01' });
|
||||
assert.equal(rotated.verb, 'rotate');
|
||||
assert.equal(await read('gitea'), 'rotated');
|
||||
assert.deepEqual(c.file().roles.coder.credentials.gitea, { store: true, rotateBy: '2099-06-01' });
|
||||
assert.deepEqual(c.committed().at(-1).detail, { verb: 'rotate', agent: 'coder', kind: ['gitea'], date: '2099-06-01' });
|
||||
// revoke of a store entry by the tester: memory and file, no document change, a receipt.
|
||||
const bytes = readFileSync(join(c.root, 'businesses/demo.json'), 'utf8');
|
||||
const revoked = c.ask(c.tester, 'credentials.revoke', { agent: 'coder', service: 'gitea', reason: 'test' });
|
||||
assert.deepEqual(revoked, { agent: 'coder', verb: 'revoke', services: [{ service: 'gitea', state: 'revoked' }] });
|
||||
await assert.rejects(read('gitea'), /credential-unavailable/);
|
||||
assert.equal(existsSync(c.store('coder', 'gitea')), false);
|
||||
assert.equal(readFileSync(join(c.root, 'businesses/demo.json'), 'utf8'), bytes);
|
||||
last = c.committed().at(-1);
|
||||
assert.equal(last.actor, 'tester');
|
||||
assert.deepEqual(last.changes, []);
|
||||
assert.deepEqual(last.detail, { verb: 'revoke', agent: 'coder', kind: ['gitea'], reason: 'test' });
|
||||
// revoke of a {file} reference: memory, the outside file kept, the reference becomes store.
|
||||
const outside = c.file().roles.coder.credentials.vikunja.file;
|
||||
c.ask(c.human, 'credentials.revoke', { agent: 'coder', service: 'vikunja', reason: 'moved' });
|
||||
await assert.rejects(read('vikunja'), /credential-unavailable/);
|
||||
assert.equal(existsSync(outside), true);
|
||||
assert.deepEqual(c.file().roles.coder.credentials.vikunja, { store: true, expires: DATE });
|
||||
assert.ok(c.committed().at(-1).changes.some((e) => e.path === 'roles.coder.credentials.vikunja.store' && e.new === true));
|
||||
// delete: every kind revoked, the agent's dir gone, both references store.
|
||||
c.ask(c.human, 'credentials.issue', { agent: 'reviewer', service: 'gitea', token: OTHER, date: DATE });
|
||||
assert.equal(existsSync(c.store('reviewer', 'gitea')), true);
|
||||
const deleted = c.ask(c.human, 'credentials.delete', { agent: 'reviewer', reason: 'fired' });
|
||||
assert.deepEqual(deleted.services.map((e) => e.state), ['revoked', 'revoked']);
|
||||
assert.equal(existsSync(join(c.root, 'secrets', 'agents', 'demo', 'reviewer')), false);
|
||||
assert.deepEqual(c.file().roles.reviewer.credentials, { gitea: { store: true, rotateBy: DATE }, vikunja: { store: true, expires: DATE } });
|
||||
assert.deepEqual(c.committed().at(-1).detail, { verb: 'delete', agent: 'reviewer', kind: ['gitea', 'vikunja'], reason: 'fired' });
|
||||
// The status read never carries a value.
|
||||
assert.deepEqual(c.ask(c.human, 'credentials', {}).map((e) => e.state).sort(), ['revoked', 'revoked', 'revoked', 'revoked']);
|
||||
// T4: no token in any file under the root (receipts, business file, events, logs).
|
||||
const walk = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? walk(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
|
||||
for (const path of walk(c.root))
|
||||
for (const token of [TOKEN, ROTATED, OTHER]) assert.equal(readFileSync(path).includes(token), false, path);
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { fixture } from './spec-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', '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) });
|
||||
@@ -36,10 +36,14 @@ 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);
|
||||
const f = await fixture(t, { roles: { coder: { definition: 'coder',
|
||||
authority: { withinRole: ['message.send', 'record.write', 'git.push.working'], crossRole: [] } } } });
|
||||
// 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, ...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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
"import { MERGE, LandingRefusal, readLanding } from './landing.mjs';",
|
||||
// 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.
|
||||
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';",
|
||||
// from it too. Row 199 (#1697) reads the D9c fork parse, forkProblem and forkUrl, from it.
|
||||
"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/);
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
// Row 199 (#1697, design D9 steps 1 to 3 and 6, D9b): the broker side of publish. git.push.remote
|
||||
// is a hard wait; its decision binds the task's latest push (pushCommit). checkPublish builds the
|
||||
// agent's fork from the project's upstream and the agent's own Gitea user; recordPublish spends the
|
||||
// decision; publishRefused files the refusal.
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fork } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
|
||||
const TASK = 'vikunja:1/2';
|
||||
const A = 'a'.repeat(40);
|
||||
const B = 'b'.repeat(40);
|
||||
const C = 'c'.repeat(40);
|
||||
const identity = (user) => ({ name: user, email: `${user}@example.com`, gitea: { url: 'https://git.example', user } });
|
||||
const pusher = { withinRole: ['git.push.working', 'message.send'], crossRole: [] };
|
||||
function businesses(upstream = 'https://git.example/mosaic/stack') {
|
||||
return {
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'reviewer', delivery: 'reviewer' },
|
||||
projects: { stack: { root: '/nonexistent', remote: upstream }, books: { root: '/nonexistent-books' } },
|
||||
roles: {
|
||||
coder: { authority: pusher, identity: identity('acme-coder'), publish: ['stack'] },
|
||||
second: { authority: pusher, identity: identity('acme-second'), publish: ['stack'] },
|
||||
plain: { authority: pusher },
|
||||
reviewer: { authority: { withinRole: ['message.send'], crossRole: [] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setup(t, upstream) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-publish-'));
|
||||
const store = new Store(root);
|
||||
const b = new Broker({ store, businesses: businesses(upstream) });
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const caps = {};
|
||||
for (const role of ['coder', 'second', 'plain', 'reviewer']) {
|
||||
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
|
||||
b.request(caps[role], { verb: 'role.claim', args: {} });
|
||||
}
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (cap, verb, args) => b.request(cap, { verb, args });
|
||||
const push = (commit, previous = null, role = 'coder', project = 'stack') => {
|
||||
b.checkPush(caps[role], TASK);
|
||||
return b.recordPush(caps[role], { task_ref: TASK, project, branch: 'feat', ref: 'work/vikunja-stack-2', commit, previous });
|
||||
};
|
||||
const raise = (role = 'coder') =>
|
||||
call(caps[role], 'decision.raise', {
|
||||
action: 'git.push.remote', target: TASK, task_ref: TASK, question: 'Publish?', blocking: false, recommendation: 'yes',
|
||||
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }],
|
||||
});
|
||||
const approve = (role = 'coder') => {
|
||||
const d = raise(role);
|
||||
call(human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
return d;
|
||||
};
|
||||
const publish = (decision, role = 'coder', extra = {}) =>
|
||||
b.checkPublish(caps[role], { task_ref: TASK, project: 'stack', branch: 'feat', ...(decision ? { decision: decision.id } : {}), ...extra });
|
||||
const events = (kind) => store.all('SELECT * FROM events WHERE kind=? ORDER BY seq', kind).map((e) => ({ ...e, body: JSON.parse(e.body) }));
|
||||
return { b, store, caps, human, call, push, raise, approve, publish, events };
|
||||
}
|
||||
|
||||
test('a raise with no recorded push refuses no-push; a raise binds the latest push and shows it', (t) => {
|
||||
const f = setup(t);
|
||||
assert.throws(() => f.raise(), /no-push/);
|
||||
f.push(A);
|
||||
const d = f.raise();
|
||||
assert.match(d.question, new RegExp(`commit ${A}\\)$`));
|
||||
const raised = f.events('action.allowed').find((e) => e.body.operation === 'decision.raise');
|
||||
assert.equal(raised.body.pushCommit, A);
|
||||
assert.deepEqual({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) }, { class: 'gated', route_to: 'human' });
|
||||
});
|
||||
|
||||
test('happy path: the fork is the upstream repo under the agent\'s own user, the destination is host-built', (t) => {
|
||||
const f = setup(t);
|
||||
f.push(A);
|
||||
const d = f.approve();
|
||||
const r = f.publish(d);
|
||||
assert.deepEqual({ ...r, ticket: typeof r.ticket }, {
|
||||
ticket: 'string', task_ref: TASK, project: 'stack', fork: 'https://git.example/acme-coder/stack',
|
||||
user: 'acme-coder', email: '[email protected]', source: A, dst: 'refs/heads/acme-coder/feat',
|
||||
});
|
||||
const done = f.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null });
|
||||
assert.deepEqual(done, { task_ref: TASK, fork: r.fork, ref: r.dst, commit: A, previous: null });
|
||||
const allowed = f.events('action.allowed').filter((e) => e.body.action === 'git.push.remote' && e.body.operation !== 'decision.raise');
|
||||
assert.equal(allowed.length, 1);
|
||||
assert.equal(allowed[0].subject, TASK);
|
||||
assert.equal(allowed[0].body.decision, d.id);
|
||||
assert.equal(allowed[0].body.fork, r.fork);
|
||||
// Spent once.
|
||||
assert.throws(() => f.publish(d), /decision-consumed/);
|
||||
assert.throws(() => f.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null }), /publish-unknown/);
|
||||
});
|
||||
|
||||
test('a hard wait: no decision refuses, and a decision for another task refuses', (t) => {
|
||||
const f = setup(t);
|
||||
f.push(A);
|
||||
assert.throws(() => f.publish(null), /decision-required/);
|
||||
const other = f.call(f.caps.coder, 'decision.raise', {
|
||||
action: 'deploy', target: 'vikunja:1/3', question: 'Ship?', blocking: false, recommendation: 'yes',
|
||||
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }],
|
||||
});
|
||||
f.call(f.human, 'decision.resolve', { id: other.id, choice: 'yes' });
|
||||
assert.throws(() => f.publish(other), /decision-mismatch/);
|
||||
const d = f.raise();
|
||||
assert.throws(() => f.publish(d), /decision-not-approved/);
|
||||
});
|
||||
|
||||
test('r4 B4: approve while the latest push is A, push B, publish refuses decision-mismatch', (t) => {
|
||||
const f = setup(t);
|
||||
f.push(A);
|
||||
const d = f.approve();
|
||||
f.push(B, A);
|
||||
assert.throws(() => f.publish(d), /decision-mismatch/);
|
||||
// And a push between checkPublish and recordPublish refuses the record.
|
||||
const f2 = setup(t);
|
||||
f2.push(A);
|
||||
const d2 = f2.approve();
|
||||
const r = f2.publish(d2);
|
||||
f2.push(C, A);
|
||||
assert.throws(() => f2.b.recordPublish(r.ticket, { ref: r.dst, commit: A, previous: null }), /decision-mismatch/);
|
||||
assert.throws(() => f2.b.recordPublish(r.ticket, { ref: r.dst, commit: C, previous: null }), /decision-mismatch/);
|
||||
});
|
||||
|
||||
test('grant and fork refusals', (t) => {
|
||||
const f = setup(t);
|
||||
f.push(A, null, 'plain');
|
||||
// plain has no identity and no grant; its raise still binds the push, the publish refuses.
|
||||
const d = f.approve('plain');
|
||||
assert.throws(() => f.publish(d, 'plain'), /remote-not-granted/);
|
||||
assert.throws(() => f.publish(d, 'plain', { project: 'books' }), /remote-not-granted|decision-mismatch/);
|
||||
// work-not-yours: second publishes a push coder made.
|
||||
const g = setup(t);
|
||||
g.push(A);
|
||||
const d2 = g.approve('second');
|
||||
assert.throws(() => g.publish(d2, 'second'), /work-not-yours/);
|
||||
});
|
||||
|
||||
test('remote-not-fork, compared without case, and remote-host-mismatch, at push', (t) => {
|
||||
for (const [upstream, code] of [
|
||||
['https://git.example/acme-coder/stack', /remote-not-fork/],
|
||||
['https://git.example/ACME-Coder/stack', /remote-not-fork/],
|
||||
['https://other.example/mosaic/stack', /remote-host-mismatch/],
|
||||
['https://git.example:3000/mosaic/stack', /remote-host-mismatch/],
|
||||
]) {
|
||||
const f = setup(t, upstream);
|
||||
f.push(A);
|
||||
const d = f.approve();
|
||||
assert.throws(() => f.publish(d), code, upstream);
|
||||
}
|
||||
});
|
||||
|
||||
test('two agents granted one project each get their own fork', (t) => {
|
||||
const f = setup(t);
|
||||
f.push(A);
|
||||
const r1 = f.publish(f.approve());
|
||||
assert.equal(r1.fork, 'https://git.example/acme-coder/stack');
|
||||
const g = setup(t);
|
||||
g.push(A, null, 'second');
|
||||
const r2 = g.publish(g.approve('second'), 'second');
|
||||
assert.equal(r2.fork, 'https://git.example/acme-second/stack');
|
||||
assert.equal(r2.dst, 'refs/heads/acme-second/feat');
|
||||
});
|
||||
|
||||
test('the request cannot name the fork, the user or the destination', (t) => {
|
||||
const f = setup(t);
|
||||
f.push(A);
|
||||
const d = f.approve();
|
||||
for (const extra of [{ fork: 'https://git.example/x/stack' }, { dst: 'refs/heads/main' }, { user: 'x' }, { remote: 'https://x' }])
|
||||
assert.throws(() => f.publish(d, 'coder', extra), /invalid-request/);
|
||||
});
|
||||
|
||||
test('publishRefused files the code on the task\'s trail and drops the ticket', (t) => {
|
||||
const f = setup(t);
|
||||
f.push(A);
|
||||
const r = f.publish(f.approve());
|
||||
assert.equal(f.b.publishRefused(r.ticket, { code: 'remote-unrelated' }), 'remote-unrelated');
|
||||
const refused = f.events('action.refused').filter((e) => e.subject === TASK);
|
||||
assert.deepEqual(refused.at(-1).body, { code: 'remote-unrelated' });
|
||||
assert.throws(() => f.b.publishRefused(r.ticket, { code: 'x' }), /publish-unknown/);
|
||||
});
|
||||
|
||||
test('git.push.remote is in neither ROUTED list and no shipped role grants it', async () => {
|
||||
const { ACTIONS, HARD_WAITS } = await import('../src/broker.mjs');
|
||||
const vocabulary = await import('../../business/src/vocabulary.mjs');
|
||||
assert.ok(ACTIONS.includes('git.push.remote') && HARD_WAITS.includes('git.push.remote'));
|
||||
assert.ok(!vocabulary.ROUTED.includes('git.push.remote'));
|
||||
const { readdirSync, readFileSync } = await import('node:fs');
|
||||
const dir = new URL('../../../roles/', import.meta.url);
|
||||
for (const name of readdirSync(dir).filter((n) => n.endsWith('.json')))
|
||||
assert.ok(!readFileSync(new URL(name, dir), 'utf8').includes('git.push.remote'), name);
|
||||
});
|
||||
|
||||
// D1 and D9b through the real startBroker: the store is <configDir>/secrets/agents, and the gitea
|
||||
// token is used only under an open publish ticket, keyed from the agent checkPublish checked.
|
||||
test('runtime: withGitea keys from the open ticket only, and the store root is the organization dir', async (t) => {
|
||||
const { startBroker } = await import('../src/runtime.mjs');
|
||||
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
|
||||
const { chmodSync, mkdirSync } = await import('node:fs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-publish-rt-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
// The runtime reads the harness switch from the organization file as it stands.
|
||||
writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
mkdirSync(join(root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const demo = businesses().demo;
|
||||
demo.roles.coder.credentials = { gitea: { store: true, file: join(root, 'secrets', 'agents', 'demo', 'coder', 'gitea'), rotateBy: '2099-01-01' } };
|
||||
const organization = { dir: root, rolesDir, system: systemFor(root) };
|
||||
// No organization, no store root: a store reference refuses the start.
|
||||
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo } }), /credential-reference/);
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization });
|
||||
t.after(() => bus.close());
|
||||
assert.deepEqual(bus.credentialStatus('demo', 'coder'), [{ service: 'gitea', date: '2099-01-01', state: 'not-issued' }]);
|
||||
const coder = bus.broker.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
bus.broker.request(coder, { verb: 'role.claim', args: {} });
|
||||
bus.checkPush(coder, TASK);
|
||||
bus.recordPush(coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-stack-2', commit: A, previous: null });
|
||||
const d = bus.broker.request(coder, { verb: 'decision.raise', args: {
|
||||
action: 'git.push.remote', target: TASK, task_ref: TASK, question: 'Publish?', blocking: false, recommendation: 'yes',
|
||||
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }] } });
|
||||
bus.broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
|
||||
assert.throws(() => bus.checkPublish(coder, { task_ref: TASK, project: 'stack', branch: 'feat' }), /decision-required/);
|
||||
const ticket = bus.checkPublish(coder, { task_ref: TASK, project: 'stack', branch: 'feat', decision: d.id }).ticket;
|
||||
await assert.rejects(bus.withGitea(ticket, async () => 1), /credential-unavailable/);
|
||||
bus.credentials.issue('demo/coder', 'gitea', 'fake_publish_token_0123456789', '2099-01-01');
|
||||
assert.equal(await bus.withGitea(ticket, async (token) => token === 'fake_publish_token_0123456789'), true);
|
||||
await assert.rejects(bus.withGitea(ticket, async (token) => ({ echo: token })), /credential-leak/);
|
||||
await assert.rejects(bus.withGitea('not-a-ticket', async () => 1), /publish-unknown/);
|
||||
await assert.rejects(bus.withGitea(coder, async () => 1), /publish-unknown/);
|
||||
bus.recordPublish(ticket, { ref: 'refs/heads/acme-coder/feat', commit: A, previous: null });
|
||||
await assert.rejects(bus.withGitea(ticket, async () => 1), /publish-unknown/);
|
||||
});
|
||||
|
||||
// Row 199 (D9b, deviation 11): the bus process answers the socket's five publish ops from their own
|
||||
// launchOp lines. A LAUNCH_OPS name without one would get launch-op-refused. A boot config that
|
||||
// carries publishTls is refused: the scratch CA is for an in-process runtime only.
|
||||
test('the bus process answers the publish ops from their own lines, not launch-op-refused', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-publish-ops-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const child = fork(new URL('../src/process.mjs', import.meta.url), [], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
|
||||
t.after(() => {
|
||||
if (child.exitCode === null) child.kill('SIGKILL');
|
||||
});
|
||||
const ask = async (m) => {
|
||||
const reply = once(child, 'message');
|
||||
child.send(m);
|
||||
return (await reply)[0];
|
||||
};
|
||||
const config = { dataRoot: root, businesses: businesses(), launches: [], readers: [] };
|
||||
const tlsChild = fork(new URL('../src/process.mjs', import.meta.url), [], { stdio: ['ignore', 'ignore', 'ignore', 'ipc'] });
|
||||
t.after(() => {
|
||||
if (tlsChild.exitCode === null) tlsChild.kill('SIGKILL');
|
||||
});
|
||||
const refused = once(tlsChild, 'message');
|
||||
const gone = once(tlsChild, 'exit');
|
||||
tlsChild.send({ op: 'boot', config: { ...config, publishTls: ['-c', 'http.sslCAInfo=/nonexistent'] } });
|
||||
assert.deepEqual((await refused)[0], { ok: false, error: 'invalid-host-request' });
|
||||
assert.equal((await gone)[0], 2);
|
||||
assert.equal((await ask({ op: 'boot', config })).ok, true);
|
||||
const unknown = 'not-a-ticket';
|
||||
assert.deepEqual(await ask({ op: 'publishPush', ticket: unknown, id: 1 }), { ok: false, error: 'publish-unknown', id: 1 });
|
||||
assert.deepEqual(await ask({ op: 'publishResult', ticket: unknown, id: 2 }), { ok: false, error: 'publish-unknown', id: 2 });
|
||||
assert.deepEqual(await ask({ op: 'recordPublish', ticket: unknown, pushed: { ref: 'refs/heads/x/y', commit: A, previous: null }, id: 3 }), { ok: false, error: 'publish-unknown', id: 3 });
|
||||
assert.deepEqual(await ask({ op: 'publishRefused', ticket: unknown, args: { code: 'x' }, id: 4 }), { ok: false, error: 'publish-unknown', id: 4 });
|
||||
assert.deepEqual(await ask({ op: 'checkPublish', cap: 'not-a-cap', args: { task_ref: TASK, project: 'stack', branch: 'feat' }, id: 5 }), { ok: false, error: 'unauthenticated', id: 5 });
|
||||
const exit = once(child, 'exit');
|
||||
child.send({ op: 'close' });
|
||||
assert.equal((await exit)[0], 0);
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
// The access file, <configDir>/access.json (federation design section 3.5,
|
||||
// FED R1, #414). It names the extra users who may sign in to this instance
|
||||
// and the grants that let them reach a business's threads. The validator is
|
||||
// pure: providers, peers, businesses and the owner's sign-in subjects come in
|
||||
// as arguments. Any failed rule refuses the whole file, which leaves every
|
||||
// user with no grant (fail closed).
|
||||
|
||||
import { join } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { configDir } from "./business.mjs";
|
||||
import { isPlainObject, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, readJsonFile, deepFreeze } from "./util.mjs";
|
||||
|
||||
// The grant levels, most to least (row 193a, #1691): a viewer reads threads shared with it and drives
|
||||
// no instance. covers() relies on this order.
|
||||
export const LEVELS = Object.freeze(["collaborator", "viewer"]);
|
||||
// Ids a user may never take: the bus's own principals.
|
||||
const RESERVED_USERS = Object.freeze(["human", "tester"]);
|
||||
const LOCAL_HOME = "local";
|
||||
const ALL = "*";
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
||||
const SUB_MAX = 255;
|
||||
|
||||
export function accessFilePath(dir = configDir()) {
|
||||
return join(dir, "access.json");
|
||||
}
|
||||
|
||||
function requireArray(value, where) {
|
||||
if (!Array.isArray(value)) refuse(`${where} must be an array`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireText(value, where) {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > SUB_MAX || value.includes("\0")) {
|
||||
refuse(`${where} must be a non-empty string of at most ${SUB_MAX} characters`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function subjectKey(provider, sub) {
|
||||
return JSON.stringify([provider, sub]);
|
||||
}
|
||||
|
||||
function checkContext(context) {
|
||||
const where = "access context";
|
||||
requireObject(context, where);
|
||||
rejectUnknownKeys(context, ["providers", "peers", "businesses", "ssoSubjects"], where);
|
||||
const providers = new Set(requireArray(context.providers, `${where}.providers`).map((p, i) => requireText(p, `${where}.providers[${i}]`)));
|
||||
const peers = new Set(requireArray(context.peers, `${where}.peers`).map((p, i) => requireId(p, `${where}.peers[${i}]`)));
|
||||
if (peers.has(LOCAL_HOME)) refuse(`${where}.peers must not name ${JSON.stringify(LOCAL_HOME)}`);
|
||||
requireObject(context.businesses, `${where}.businesses`);
|
||||
const businesses = new Map();
|
||||
for (const [id, business] of Object.entries(context.businesses)) {
|
||||
const at = `${where}.businesses.${id}`;
|
||||
requireId(id, `${where}.businesses key`);
|
||||
requireObject(business, at);
|
||||
requireId(business.human, `${at}.human`);
|
||||
const instances = requireArray(business.instances, `${at}.instances`).map((x, i) => requireId(x, `${at}.instances[${i}]`));
|
||||
businesses.set(id, { human: business.human, instances: new Set(instances) });
|
||||
}
|
||||
const owners = new Set();
|
||||
requireArray(context.ssoSubjects, `${where}.ssoSubjects`).forEach((s, i) => {
|
||||
const at = `${where}.ssoSubjects[${i}]`;
|
||||
requireObject(s, at);
|
||||
owners.add(subjectKey(requireText(s.provider, `${at}.provider`), requireText(s.sub, `${at}.sub`)));
|
||||
});
|
||||
return { providers, peers, businesses, owners };
|
||||
}
|
||||
|
||||
function checkUser(user, at, ctx, humans, seenSubjects) {
|
||||
requireObject(user, at);
|
||||
rejectUnknownKeys(user, ["id", "home", "subjects"], at);
|
||||
requireId(user.id, `${at}.id`);
|
||||
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
|
||||
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
|
||||
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
|
||||
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)} (got ${JSON.stringify(user.home)})`);
|
||||
}
|
||||
const subjects = requireArray(user.subjects, `${at}.subjects`);
|
||||
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
|
||||
subjects.forEach((s, i) => {
|
||||
const sat = `${at}.subjects[${i}]`;
|
||||
requireObject(s, sat);
|
||||
rejectUnknownKeys(s, ["provider", "sub"], sat);
|
||||
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
|
||||
refuse(`${sat}.provider must be a configured provider (got ${JSON.stringify(s.provider)})`);
|
||||
}
|
||||
requireText(s.sub, `${sat}.sub`);
|
||||
const key = subjectKey(s.provider, s.sub);
|
||||
if (ctx.owners.has(key)) refuse(`${sat} is an owner sign-in subject (console.sso.subjects)`);
|
||||
if (seenSubjects.has(key)) refuse(`${sat} appears more than once in the file`);
|
||||
seenSubjects.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
// Either ["*"] alone or a non-empty list of distinct entries, each checked.
|
||||
function checkScope(value, where, check) {
|
||||
const list = requireArray(value, where);
|
||||
if (list.length === 0) refuse(`${where} must not be empty`);
|
||||
if (list.includes(ALL)) {
|
||||
if (list.length !== 1) refuse(`${where} must be ["*"] alone or list entries without "*"`);
|
||||
return;
|
||||
}
|
||||
const seen = new Set();
|
||||
list.forEach((entry, i) => {
|
||||
check(entry, `${where}[${i}]`);
|
||||
if (seen.has(entry)) refuse(`duplicate entry in ${where}: ${JSON.stringify(entry)}`);
|
||||
seen.add(entry);
|
||||
});
|
||||
}
|
||||
|
||||
function checkExpires(value, where) {
|
||||
if (typeof value !== "string" || !ISO_TIME.test(value) || Number.isNaN(Date.parse(value))) {
|
||||
refuse(`${where} must be an ISO time such as 2026-12-31T00:00:00Z (got ${JSON.stringify(value)})`);
|
||||
}
|
||||
const iso = new Date(value).toISOString();
|
||||
if (iso.slice(0, 19) !== value.slice(0, 19)) refuse(`${where} is not a real time: ${value}`);
|
||||
}
|
||||
|
||||
function checkGrant(grant, at, ctx, users, seenGrants) {
|
||||
requireObject(grant, at);
|
||||
rejectUnknownKeys(grant, ["id", "user", "business", "projects", "instances", "level", "expires"], at);
|
||||
requireId(grant.id, `${at}.id`);
|
||||
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
|
||||
seenGrants.add(grant.id);
|
||||
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file (got ${JSON.stringify(grant.user)})`);
|
||||
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
|
||||
refuse(`${at}.business must name a declared business (got ${JSON.stringify(grant.business)})`);
|
||||
}
|
||||
const business = ctx.businesses.get(grant.business);
|
||||
checkScope(grant.projects, `${at}.projects`, (p, where) => {
|
||||
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
|
||||
});
|
||||
checkScope(grant.instances, `${at}.instances`, (x, where) => {
|
||||
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of ${grant.business} (got ${JSON.stringify(x)})`);
|
||||
});
|
||||
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
|
||||
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
|
||||
}
|
||||
|
||||
// Check a parsed access document against the instance's context. Returns the
|
||||
// document frozen. Every refusal is a BusinessError naming the key path. An
|
||||
// expired grant is not refused: covers() treats it as absent.
|
||||
export function checkAccess(document, context) {
|
||||
const ctx = checkContext(context);
|
||||
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
|
||||
if (!isPlainObject(document)) refuse("access file must be a JSON object");
|
||||
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
|
||||
if (document.accessVersion !== 1) refuse(`accessVersion must be 1 (got ${JSON.stringify(document.accessVersion)})`);
|
||||
requirePositiveInt(document.revision, "revision");
|
||||
const users = new Set();
|
||||
const seenSubjects = new Set();
|
||||
requireArray(document.users, "users").forEach((user, i) => {
|
||||
checkUser(user, `users[${i}]`, ctx, humans, seenSubjects);
|
||||
if (users.has(user.id)) refuse(`users[${i}].id is a duplicate user id: ${user.id}`);
|
||||
users.add(user.id);
|
||||
});
|
||||
const seenGrants = new Set();
|
||||
requireArray(document.grants, "grants").forEach((grant, i) => checkGrant(grant, `grants[${i}]`, ctx, users, seenGrants));
|
||||
return deepFreeze(structuredClone(document));
|
||||
}
|
||||
|
||||
// Read and check the access file. One descriptor, opened without following a
|
||||
// link, serves the stat checks and the read (readJsonFile). The file holds
|
||||
// authority, so it must belong to this uid and not be writable by group or
|
||||
// other, as the business file is.
|
||||
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
|
||||
const document = readJsonFile(file, "access file", (stat) => {
|
||||
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`);
|
||||
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
|
||||
});
|
||||
return checkAccess(document, context);
|
||||
}
|
||||
|
||||
// Does a checked grant cover a thread? `target` is the thread's business, bus
|
||||
// project id and instance. `level` is required (row 193a, Duckworth's N1 on R1):
|
||||
// it is the least level the action needs (LEVELS runs from most to least), so `viewer` asks for
|
||||
// read and any grant covers it, while `collaborator` asks to drive the instance
|
||||
// and a viewer grant never covers it. An expired grant covers nothing.
|
||||
export function covers(grant, { business, project, instance }, now, { level } = {}) {
|
||||
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
|
||||
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")} (got ${JSON.stringify(level)})`);
|
||||
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
|
||||
if (grant.business !== business) return false;
|
||||
if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;
|
||||
if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;
|
||||
// A grant whose own level is unknown covers nothing, even if it skipped checkAccess.
|
||||
if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -10,6 +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 } from "./vocabulary.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
} from "./util.mjs";
|
||||
@@ -40,7 +41,7 @@ function checkBot(tracker, where) {
|
||||
return { bot: tracker.bot, botId: tracker.botId };
|
||||
}
|
||||
|
||||
function checkCredentialMap(map, services, where) {
|
||||
function checkCredentialMap(map, services, where, store = null) {
|
||||
requireObject(map, where);
|
||||
const names = Object.keys(map).sort();
|
||||
const wanted = [...services].sort();
|
||||
@@ -48,10 +49,37 @@ function checkCredentialMap(map, services, where) {
|
||||
refuse(`${where} must reference exactly the services its role definition needs (${wanted.join(", ") || "none"}; got ${names.join(", ") || "none"})`);
|
||||
}
|
||||
const out = {};
|
||||
for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`);
|
||||
for (const service of names) out[service] = parseCredentialRef(map[service], service, `${where}.${service}`, { store });
|
||||
return out;
|
||||
}
|
||||
|
||||
// Row 199 (#1697, design D8): the agent's own git identity. The email is the one its Gitea user
|
||||
// has, so Gitea links the commits to that user; gitea.url is the instance its pushes go to.
|
||||
const EMAIL = /^[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,190}\.[A-Za-z]{2,24}$/;
|
||||
const NAME = /^[^\0\n\r<>]{1,100}$/;
|
||||
|
||||
// Row 199 (design D9c): the parse is vocabulary.mjs's, where the broker reads it; this wraps it with
|
||||
// the loader's refusals. Returns {url, owner, repo}.
|
||||
export function httpsUrl(value, where, { repo = false } = {}) {
|
||||
const parsed = parseGiteaUrl(value, { repo });
|
||||
if (parsed.problem === "form") refuse(`${where} must be a normalized https URL with no user, query or fragment`);
|
||||
if (parsed.problem === "instance") refuse(`${where} must name the Gitea instance only, https://HOST[:PORT]`);
|
||||
if (parsed.problem === "repository") refuse(`${where} must name a repository, https://HOST/OWNER/REPO`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function checkIdentity(identity, where) {
|
||||
requireObject(identity, where);
|
||||
rejectUnknownKeys(identity, ["name", "email", "gitea"], where);
|
||||
if (typeof identity.name !== "string" || !NAME.test(identity.name)) refuse(`${where}.name must be 1 to 100 characters with no <, > or line break`);
|
||||
if (typeof identity.email !== "string" || !EMAIL.test(identity.email)) refuse(`${where}.email must be an email address`);
|
||||
requireObject(identity.gitea, `${where}.gitea`);
|
||||
rejectUnknownKeys(identity.gitea, ["url", "user"], `${where}.gitea`);
|
||||
httpsUrl(identity.gitea.url, `${where}.gitea.url`);
|
||||
if (!giteaName(identity.gitea.user)) refuse(`${where}.gitea.user must be a Gitea user name`);
|
||||
return { name: identity.name, email: identity.email, gitea: { url: identity.gitea.url, user: identity.gitea.user } };
|
||||
}
|
||||
|
||||
function checkTracker(tracker, file) {
|
||||
const where = `${file} tracker`;
|
||||
requireObject(tracker, where);
|
||||
@@ -74,7 +102,7 @@ function checkTracker(tracker, file) {
|
||||
return { sync, labels };
|
||||
}
|
||||
|
||||
function checkRoles(roles, file, rolesDir) {
|
||||
function checkRoles(roles, file, rolesDir, business) {
|
||||
const where = `${file} roles`;
|
||||
requireObject(roles, where);
|
||||
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
|
||||
@@ -86,7 +114,7 @@ function checkRoles(roles, file, rolesDir) {
|
||||
requireId(instance, `${where} instance name`);
|
||||
const at = `${where}.${instance}`;
|
||||
requireObject(entry, at);
|
||||
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials"], 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;
|
||||
@@ -108,8 +136,18 @@ function checkRoles(roles, file, rolesDir) {
|
||||
holder: entry.holder === undefined ? null : requireId(entry.holder, `${at}.holder`),
|
||||
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
|
||||
tracker,
|
||||
credentials: checkCredentialMap(entry.credentials ?? {}, services, `${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
|
||||
// load unchanged. publish needs both an identity and a gitea credential; the project ids it
|
||||
// names are checked against projects once those are read.
|
||||
if (entry.identity !== undefined) out[instance].identity = checkIdentity(entry.identity, `${at}.identity`);
|
||||
if (entry.publish !== undefined) {
|
||||
// N10: one message for both, naming the grant.
|
||||
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 });
|
||||
}
|
||||
}
|
||||
return { roles: out, definitions };
|
||||
}
|
||||
@@ -159,7 +197,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
|
||||
}
|
||||
const human = requireId(document.human, "business human");
|
||||
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
|
||||
const { roles, definitions } = checkRoles(document.roles, file, rolesDir);
|
||||
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id);
|
||||
|
||||
requireObject(document.arbiters, `${file} arbiters`);
|
||||
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
|
||||
@@ -183,7 +221,7 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
|
||||
for (const [id, entry] of Object.entries(document.projects)) {
|
||||
requireId(id, `${file} project id`);
|
||||
requireObject(entry, `${file} projects.${id}`);
|
||||
rejectUnknownKeys(entry, ["root", "repo"], `${file} projects.${id}`);
|
||||
rejectUnknownKeys(entry, ["root", "repo", "remote"], `${file} projects.${id}`);
|
||||
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
|
||||
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
|
||||
}
|
||||
@@ -191,7 +229,18 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
|
||||
normalize(entry.repo) !== entry.repo || entry.repo.includes("\0"))) {
|
||||
refuse(`${file} projects.${id}.repo must be a normalized absolute path`);
|
||||
}
|
||||
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}) };
|
||||
// Row 199 (#1697, design D9 step 2): the upstream; a granted agent's work goes to its fork.
|
||||
if (Object.hasOwn(entry, "remote")) httpsUrl(entry.remote, `${file} projects.${id}.remote`, { repo: true });
|
||||
projects[id] = { root: entry.root, ...(Object.hasOwn(entry, "repo") ? { repo: entry.repo } : {}),
|
||||
...(Object.hasOwn(entry, "remote") ? { remote: entry.remote } : {}) };
|
||||
}
|
||||
for (const [instance, role] of Object.entries(roles)) {
|
||||
for (const project of role.publish ?? []) {
|
||||
if (!Object.hasOwn(projects, project)) refuse(`${file} roles.${instance}.publish names ${project}, which the business file doesn't declare`);
|
||||
if (!projects[project].remote) refuse(`${file} roles.${instance}.publish names ${project}, which has no "remote"`);
|
||||
const problem = forkProblem(projects[project].remote, role.identity);
|
||||
if (problem) refuse(`${file} roles.${instance}.publish names ${project}: ${problem}`);
|
||||
}
|
||||
}
|
||||
|
||||
const tracker = checkTracker(document.tracker, file);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// lstat and realpath only; nothing in this package opens a token file.
|
||||
|
||||
import { lstatSync, realpathSync } from "node:fs";
|
||||
import { isAbsolute, normalize, relative, sep } from "node:path";
|
||||
import { isAbsolute, join, normalize, relative, sep } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { SERVICES } from "./vocabulary.mjs";
|
||||
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
|
||||
@@ -14,21 +14,35 @@ const WARN_DAYS = 7;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// Shape check for one reference. Returns a frozen { service, file | env,
|
||||
// rotateBy | expires }.
|
||||
export function parseCredentialRef(ref, service, where) {
|
||||
// rotateBy | expires }. Row 199 (#1697, design D1 and D2): `store` is the
|
||||
// file at <configDir>/secrets/agents/<business>/<agent>/<service>, derived
|
||||
// here and never written, so an entry can't name another agent's file. A
|
||||
// reference with no agent (the tracker's sync bot) passes store = null and
|
||||
// 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 (!SERVICES.includes(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
|
||||
requireObject(ref, where);
|
||||
const dateKey = DATE_KEY[service];
|
||||
rejectUnknownKeys(ref, ["file", "env", dateKey], where);
|
||||
rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where);
|
||||
const hasFile = ref.file !== undefined;
|
||||
const hasEnv = ref.env !== undefined;
|
||||
if (hasFile === hasEnv) refuse(`${where} must name exactly one of "file" or "env"`);
|
||||
const hasStore = ref.store !== undefined;
|
||||
if (Number(hasFile) + Number(hasEnv) + Number(hasStore) !== 1) refuse(`${where} must name exactly one of "file", "env" or "store"`);
|
||||
const out = { service };
|
||||
if (hasFile) {
|
||||
if (hasStore) {
|
||||
if (ref.store !== true) refuse(`${where}.store must be true`);
|
||||
if (!store) refuse(`${where}: a "store" reference needs an agent`);
|
||||
out.store = true;
|
||||
out.file = storePath(store, service);
|
||||
} else if (hasFile) {
|
||||
if (typeof ref.file !== "string" || !isAbsolute(ref.file) || normalize(ref.file) !== ref.file || ref.file.includes("\0")) {
|
||||
refuse(`${where}.file must be a normalized absolute path`);
|
||||
}
|
||||
out.file = ref.file;
|
||||
if (store && inside(join(store.configDir, "secrets", "agents"), ref.file) && ref.file !== storePath(store, service)) {
|
||||
refuse(`${where}.file credential-not-yours: ${ref.file} is in the agents' store but not this agent's path`);
|
||||
}
|
||||
} else {
|
||||
if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`);
|
||||
out.env = ref.env;
|
||||
@@ -38,6 +52,11 @@ export function parseCredentialRef(ref, service, where) {
|
||||
return Object.freeze(out);
|
||||
}
|
||||
|
||||
// The store path for one agent and service (design D1).
|
||||
export function storePath({ configDir, business, agent }, service) {
|
||||
return join(configDir, "secrets", "agents", business, agent, service);
|
||||
}
|
||||
|
||||
function inside(root, path) {
|
||||
const rel = relative(root, path);
|
||||
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
||||
@@ -55,8 +74,10 @@ export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(),
|
||||
let stat = null;
|
||||
try {
|
||||
stat = lstatSync(ref.file);
|
||||
} catch {
|
||||
problems.push(`${label}: not found`);
|
||||
} catch (e) {
|
||||
// Row 199 (design D3a): a store reference with no file is an agent not issued yet.
|
||||
if (ref.store && e.code === "ENOENT") warnings.push(`${label}: not issued`);
|
||||
else problems.push(`${label}: not found`);
|
||||
}
|
||||
if (stat) {
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) problems.push(`${label}: must be a regular file, not a symbolic link`);
|
||||
|
||||
@@ -10,5 +10,6 @@ export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
|
||||
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
|
||||
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
|
||||
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
|
||||
export { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "./access.mjs";
|
||||
export { projectFilePath, validateProjectDocument, loadProject } from "./project.mjs";
|
||||
export { systemVars, resolveInstance, classify } from "./resolve.mjs";
|
||||
|
||||
@@ -174,7 +174,9 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
}
|
||||
}
|
||||
|
||||
function write(id, document, { revision, actor }) {
|
||||
// Row 199 (#1697, design section 6): `detail` says what a credential verb did (verb, agent, kind,
|
||||
// date, reason), never a value; the other setters pass none.
|
||||
function write(id, document, { revision, actor, detail }) {
|
||||
requireId(actor, "authenticated actor");
|
||||
requireId(id, "organization id");
|
||||
return withLock(id, ({ home, parent }) => {
|
||||
@@ -201,7 +203,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
syncDir(tx);
|
||||
const event = {
|
||||
transaction, actor, business: id, before, after,
|
||||
changes: changes(old, written), at: new Date().toISOString(),
|
||||
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
|
||||
};
|
||||
log(id, { ...event, state: "prepared" });
|
||||
syncDir(home);
|
||||
@@ -219,5 +221,22 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, write, recover: id => withLock(id, () => recover(id)) };
|
||||
// Row 199: a receipt with no document change, for a credential verb that changes only the agents'
|
||||
// store (a revoke or delete of store references). Committed at once; there is nothing to recover.
|
||||
function record(id, { actor, detail }) {
|
||||
requireId(actor, "authenticated actor");
|
||||
requireId(id, "organization id");
|
||||
return withLock(id, () => {
|
||||
recover(id);
|
||||
const hash = sha256(regular(businessFilePath(id, dir)));
|
||||
const transaction = randomUUID();
|
||||
log(id, {
|
||||
transaction, actor, business: id, before: hash, after: hash, changes: [], detail,
|
||||
at: new Date().toISOString(), state: "committed",
|
||||
});
|
||||
return { revision: hash, transaction };
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, write, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ export const ACTIONS = Object.freeze([
|
||||
"git.push.working",
|
||||
"git.push.protected",
|
||||
"git.merge.protected",
|
||||
// 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",
|
||||
"review.request",
|
||||
"review.verdict",
|
||||
"record.write",
|
||||
@@ -83,8 +86,8 @@ export const ROUTED = Object.freeze([
|
||||
// The hard waits (PRD 1.5 REQ-WAIT-1): always gated, always the human's,
|
||||
// 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 and
|
||||
// deploy; a test pins the list.
|
||||
// account.create, credential.mint, message.external, git.push.protected,
|
||||
// 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
|
||||
@@ -133,3 +136,50 @@ export const THREAD_DEFINITION = "assistant";
|
||||
// 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.
|
||||
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, "_", "."
|
||||
// and "-", not "." or "..".
|
||||
export const giteaName = (name) =>
|
||||
typeof name === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(name) && name !== "." && name !== "..";
|
||||
|
||||
// An https URL that is already its own normalized form, so a dot segment, an encoded dot, userinfo, a
|
||||
// query, a fragment, an upper-case host or an explicit :443 each fail, and the path checked is the
|
||||
// path used. With repo, the path is exactly /OWNER/REPO (one ".git" stripped); without, it is "/".
|
||||
// Returns {url, owner, repo}, or {problem} naming the rule that failed: form, instance or repository.
|
||||
export function parseGiteaUrl(value, { repo = false } = {}) {
|
||||
let url = null;
|
||||
try {
|
||||
url = typeof value === "string" ? new URL(value) : null;
|
||||
} catch {
|
||||
url = null;
|
||||
}
|
||||
const normal = url && (repo ? [url.origin + url.pathname] : [url.origin, url.origin + "/"]);
|
||||
if (!url || url.protocol !== "https:" || !normal.includes(value)) return { problem: "form" };
|
||||
if (!repo) return url.pathname === "/" ? { url } : { problem: "instance" };
|
||||
const segments = url.pathname.split("/").slice(1);
|
||||
const name = segments[1]?.endsWith(".git") ? segments[1].slice(0, -4) : segments[1];
|
||||
if (segments.length !== 2 || !giteaName(segments[0]) || !giteaName(name)) return { problem: "repository" };
|
||||
return { url, owner: segments[0], repo: name };
|
||||
}
|
||||
|
||||
// Design D9 step 2: the agent's fork of an upstream, https://HOST[:PORT]/<user>/REPO, or null for an
|
||||
// upstream that doesn't parse. The owner is always the agent's own Gitea user; nothing else names it.
|
||||
// URL.host drops a default port, so 443 counts the same on both sides.
|
||||
export function forkUrl(upstream, user) {
|
||||
const { url, repo } = parseGiteaUrl(upstream, { repo: true });
|
||||
return url ? `https://${url.host}/${user}/${repo}` : null;
|
||||
}
|
||||
|
||||
// The refusal code for publishing `upstream` as an agent with `identity`, or null. Gitea compares
|
||||
// names without case (lower_name), so the owner check does too. An upstream or gitea.url that doesn't
|
||||
// parse is remote-not-granted: the business loader refuses both, so only a hand-built business gets
|
||||
// here.
|
||||
export function forkProblem(upstream, identity) {
|
||||
const { url, owner } = parseGiteaUrl(upstream, { repo: true });
|
||||
const instance = parseGiteaUrl(identity?.gitea?.url).url;
|
||||
if (!url || !instance || typeof identity.gitea.user !== "string") return "remote-not-granted";
|
||||
if (url.host !== instance.host) return "remote-host-mismatch";
|
||||
if (owner.toLowerCase() === identity.gitea.user.toLowerCase()) return "remote-not-fork";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { BusinessError } from "../src/errors.mjs";
|
||||
import * as access from "../src/access.mjs";
|
||||
|
||||
// Row 193a (#1691): the viewer level. R1 (federation design r4.5, section 7) owns access.mjs and its
|
||||
// tests; this file pins only what row 193 adds: a grant's level is collaborator or viewer, and covers()
|
||||
// tells the two apart with a required least level. Every call into R1 goes through check() below, so a change to R1's loader
|
||||
// interface edits one place.
|
||||
const CONTEXT = {
|
||||
providers: ["authentik"],
|
||||
peers: ["factory"],
|
||||
businesses: { "creative-hub": { human: "melanie", instances: ["researcher", "writer"] } },
|
||||
ssoSubjects: [],
|
||||
};
|
||||
const check = (doc) => access.checkAccess(doc, CONTEXT);
|
||||
|
||||
const PROJECT = "0b6f4a52-6f1e-4c7a-9a0e-3f1d2c4b5a69";
|
||||
const NOW = new Date("2026-10-20T19:30:00Z");
|
||||
|
||||
function file(level) {
|
||||
const grant = { id: "g-1", user: "jason", business: "creative-hub", projects: ["*"], instances: ["researcher"] };
|
||||
if (level !== undefined) grant.level = level;
|
||||
return {
|
||||
accessVersion: 1,
|
||||
revision: 1,
|
||||
users: [{ id: "jason", home: "local", subjects: [{ provider: "authentik", sub: "s-1" }] }],
|
||||
grants: [grant],
|
||||
};
|
||||
}
|
||||
|
||||
function refuses(fn, pattern) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
|
||||
assert.equal(error.exitCode, 2);
|
||||
assert.match(error.message, pattern);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
test("a grant's level is collaborator or viewer", () => {
|
||||
for (const level of ["collaborator", "viewer"]) {
|
||||
assert.doesNotThrow(() => check(file(level)), level);
|
||||
}
|
||||
});
|
||||
|
||||
test("any other level refuses with the key path named", () => {
|
||||
for (const level of ["owner", "admin", "member", "Viewer", "viewer ", "", null, 1, ["viewer"], undefined]) {
|
||||
refuses(() => check(file(level)), /grants\[0\]\.level/);
|
||||
}
|
||||
});
|
||||
|
||||
test("covers tells a viewer grant from a collaborator grant", () => {
|
||||
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
|
||||
const viewer = file("viewer").grants[0];
|
||||
const collaborator = file("collaborator").grants[0];
|
||||
// The level is the least one the action needs: share reads ask viewer; create, open, send and share ask
|
||||
// collaborator (design 9).
|
||||
assert.equal(access.covers(viewer, target, NOW, { level: "viewer" }), true);
|
||||
assert.equal(access.covers(viewer, target, NOW, { level: "collaborator" }), false);
|
||||
assert.equal(access.covers(collaborator, target, NOW, { level: "viewer" }), true);
|
||||
assert.equal(access.covers(collaborator, target, NOW, { level: "collaborator" }), true);
|
||||
});
|
||||
|
||||
test("covers refuses a call with no level, so a viewer grant can't slip past a collaborator check", () => {
|
||||
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
|
||||
const viewer = file("viewer").grants[0];
|
||||
assert.throws(() => access.covers(viewer, target, NOW), TypeError);
|
||||
assert.throws(() => access.covers(viewer, target, NOW, {}), TypeError);
|
||||
});
|
||||
|
||||
test("a viewer grant still needs its business, project, instance and expiry to cover", () => {
|
||||
const viewer = { ...file("viewer").grants[0], projects: [PROJECT], expires: "2026-10-21T00:00:00Z" };
|
||||
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
|
||||
const read = { level: "viewer" };
|
||||
assert.equal(access.covers(viewer, target, NOW, read), true);
|
||||
assert.equal(access.covers(viewer, { ...target, business: "other" }, NOW, read), false);
|
||||
assert.equal(access.covers(viewer, { ...target, project: "7c1e2d3f-0a9b-4c8d-8e7f-6a5b4c3d2e1f" }, NOW, read), false);
|
||||
assert.equal(access.covers(viewer, { ...target, instance: "writer" }, NOW, read), false);
|
||||
assert.equal(access.covers(viewer, target, new Date("2026-10-21T00:00:01Z"), read), false);
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
// FED R1 (#414, row 201): the access file loader and validator, DESIGN.md
|
||||
// section 3.5, and covers(). Every test works in a scratch directory.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chmodSync, mkdirSync, symlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { BusinessError } from "../src/errors.mjs";
|
||||
import { LEVELS, accessFilePath, checkAccess, loadAccess, covers } from "../src/access.mjs";
|
||||
import { tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
const P1 = "0f8b6c1e-2a3d-4e5f-8a9b-0c1d2e3f4a5b";
|
||||
const P2 = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
function context(over = {}) {
|
||||
return {
|
||||
providers: ["authentik"],
|
||||
peers: ["factory"],
|
||||
businesses: {
|
||||
"creative-hub": { human: "melanie", instances: ["researcher", "writer"] },
|
||||
acme: { human: "jason", instances: ["coder"] },
|
||||
},
|
||||
ssoSubjects: [{ provider: "authentik", sub: "owner-sub" }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function doc() {
|
||||
return {
|
||||
accessVersion: 1,
|
||||
revision: 3,
|
||||
users: [
|
||||
{ id: "bean", home: "factory", subjects: [{ provider: "authentik", sub: "s-1" }] },
|
||||
{ id: "chet", home: "local", subjects: [{ provider: "authentik", sub: "s-2" }] },
|
||||
],
|
||||
grants: [
|
||||
{ id: "g-1", user: "bean", business: "creative-hub", projects: ["*"], instances: ["researcher"], level: "collaborator", expires: "2026-12-31T00:00:00Z" },
|
||||
{ id: "g-2", user: "chet", business: "creative-hub", projects: [P1], instances: ["*"], level: "collaborator" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Refuses with a BusinessError, exit 2 unless given, and a message matching `pattern`.
|
||||
function refuses(fn, pattern, exitCode = 2) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `not a BusinessError: ${error}`);
|
||||
assert.equal(error.exitCode, exitCode);
|
||||
assert.match(error.message, pattern);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function mutated(change) {
|
||||
const d = doc();
|
||||
change(d);
|
||||
return d;
|
||||
}
|
||||
|
||||
test("a valid document passes and comes back frozen", () => {
|
||||
const checked = checkAccess(doc(), context());
|
||||
assert.deepEqual(checked, doc());
|
||||
assert.ok(Object.isFrozen(checked));
|
||||
assert.ok(Object.isFrozen(checked.grants[0].projects));
|
||||
});
|
||||
|
||||
test("checkAccess does not freeze or alias the caller's document", () => {
|
||||
const d = doc();
|
||||
const checked = checkAccess(d, context());
|
||||
assert.ok(!Object.isFrozen(d));
|
||||
assert.notEqual(checked.grants, d.grants);
|
||||
});
|
||||
|
||||
test("LEVELS is the single allowed level set, collaborator and viewer (row 193a)", () => {
|
||||
assert.deepEqual([...LEVELS], ["collaborator", "viewer"]);
|
||||
assert.ok(Object.isFrozen(LEVELS));
|
||||
});
|
||||
|
||||
test("accessFilePath is access.json in the config directory", () => {
|
||||
assert.equal(accessFilePath("/x/y"), join("/x/y", "access.json"));
|
||||
const dir = tmp();
|
||||
const saved = process.env.MOSAIC_CONFIG;
|
||||
process.env.MOSAIC_CONFIG = join(dir, "config.json");
|
||||
try {
|
||||
assert.equal(accessFilePath(), join(dir, "access.json"));
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.MOSAIC_CONFIG;
|
||||
else process.env.MOSAIC_CONFIG = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test("top level: object, unknown keys, accessVersion, revision, arrays", () => {
|
||||
refuses(() => checkAccess([], context()), /access file must be a JSON object/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.extra = 1; }), context()), /unsupported access file key: "extra"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.accessVersion = 2; }), context()), /accessVersion must be 1/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.accessVersion; }), context()), /accessVersion must be 1/);
|
||||
for (const bad of [0, -1, 1.5, "3", undefined]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.revision = bad; }), context()), /revision must be a positive integer/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { d.users = {}; }), context()), /^users must be an array/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.grants; }), context()), /^grants must be an array/);
|
||||
});
|
||||
|
||||
test("users: unknown keys and the id rule", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].role = "x"; }), context()), /unsupported users\[1\] key: "role"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0] = "bean"; }), context()), /users\[0\] must be a JSON object/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "Bean"; }), context()), /users\[0\]\.id must match/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id: bean/);
|
||||
});
|
||||
|
||||
test("users: human, tester and any business's human are refused", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human \("melanie"\)/);
|
||||
// The human of a business the grant doesn't name is refused too.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human \("jason"\)/);
|
||||
});
|
||||
|
||||
test("users: home is a given peer or local", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local" \(got "elsewhere"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.users[1].home; }), context()), /users\[1\]\.home must be a configured peer/);
|
||||
refuses(() => checkAccess(doc(), context({ peers: [] })), /users\[0\]\.home must be a configured peer/);
|
||||
});
|
||||
|
||||
test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects = []; }), context()), /users\[0\]\.subjects must not be empty/);
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.users[0].subjects; }), context()), /users\[0\]\.subjects must be an array/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].extra = 1; }), context()), /unsupported users\[0\]\.subjects\[0\] key: "extra"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider \(got "google"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = ""; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = 7; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
|
||||
// A pair twice in one user, and across two users.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects.push({ provider: "authentik", sub: "s-1" }); }), context()), /users\[0\]\.subjects\[1\] appears more than once/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "s-1"; }), context()), /users\[1\]\.subjects\[0\] appears more than once/);
|
||||
// The owner's own sign-in subject can't be an extra user.
|
||||
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].sub = "owner-sub"; }), context()), /users\[1\]\.subjects\[0\] is an owner sign-in subject/);
|
||||
// The same sub under another provider is a different pair.
|
||||
const twoProviders = context({ providers: ["authentik", "google"] });
|
||||
assert.ok(checkAccess(mutated((d) => { d.users[1].subjects[0] = { provider: "google", sub: "owner-sub" }; }), twoProviders));
|
||||
});
|
||||
|
||||
test("grants: unknown keys, id, user and business", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].role = "x"; }), context()), /unsupported grants\[0\] key: "role"/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id: g-1/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].id = "G 1"; }), context()), /grants\[0\]\.id must match/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file \(got "nobody"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business \(got "other"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "constructor"; }), context()), /grants\[1\]\.business must name a declared business/);
|
||||
});
|
||||
|
||||
test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID \(got "stack"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1.toUpperCase()]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, 7]; }), context()), /grants\[1\]\.projects\[1\] must be "\*" or a bus project id/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", P1]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", "*"]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, P1]; }), context()), /duplicate entry in grants\[1\]\.projects/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = []; }), context()), /grants\[1\]\.projects must not be empty/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = "*"; }), context()), /grants\[1\]\.projects must be an array/);
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[1].projects = [P1, P2]; }), context()));
|
||||
});
|
||||
|
||||
test("grants: instances is [\"*\"] or distinct declared instances of that business", () => {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["coder"]; }), context()), /grants\[0\]\.instances\[0\] must be "\*" or a declared instance of creative-hub \(got "coder"\)/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["*", "writer"]; }), context()), /grants\[0\]\.instances must be \["\*"\] alone/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = ["writer", "writer"]; }), context()), /duplicate entry in grants\[0\]\.instances/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].instances = []; }), context()), /grants\[0\]\.instances must not be empty/);
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[0].instances = ["researcher", "writer"]; }), context()));
|
||||
});
|
||||
|
||||
test("grants: any level other than collaborator or viewer refuses, naming grants[i].level", () => {
|
||||
for (const bad of ["owner", "Collaborator", "COLLABORATOR", "Viewer", "", null, 1]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[1].level = bad; }), context()), /grants\[1\]\.level must be one of collaborator, viewer/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator, viewer \(got undefined\)/);
|
||||
});
|
||||
|
||||
test("grants: expires is optional ISO time, and a past expiry is not refused", () => {
|
||||
for (const bad of ["2026-12-31", "2026-12-31 00:00:00Z", "2026-12-31T00:00:00+01:00", "tomorrow", 1798675200000, null]) {
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = bad; }), context()), /grants\[0\]\.expires must be an ISO time/);
|
||||
}
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-02-30T00:00:00Z"; }), context()), /grants\[0\]\.expires is not a real time/);
|
||||
refuses(() => checkAccess(mutated((d) => { d.grants[0].expires = "2026-13-01T00:00:00Z"; }), context()), /grants\[0\]\.expires must be an ISO time/);
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2026-12-31T00:00:00.500Z"; }), context()));
|
||||
// DESIGN 3.5: a grant past expires is treated as absent, not refused (sage-1399).
|
||||
assert.ok(checkAccess(mutated((d) => { d.grants[0].expires = "2001-01-01T00:00:00Z"; }), context()));
|
||||
});
|
||||
|
||||
test("the context is required and checked", () => {
|
||||
refuses(() => checkAccess(doc()), /access context must be a JSON object/);
|
||||
for (const key of ["providers", "peers", "ssoSubjects"]) {
|
||||
const c = context();
|
||||
delete c[key];
|
||||
refuses(() => checkAccess(doc(), c), new RegExp(`access context\\.${key} must be an array`));
|
||||
}
|
||||
refuses(() => checkAccess(doc(), context({ businesses: [] })), /access context\.businesses must be a JSON object/);
|
||||
refuses(() => checkAccess(doc(), context({ extra: 1 })), /unsupported access context key: "extra"/);
|
||||
refuses(() => checkAccess(doc(), context({ peers: ["local"] })), /access context\.peers must not name "local"/);
|
||||
refuses(() => checkAccess(doc(), context({ ssoSubjects: [{ provider: "authentik" }] })), /access context\.ssoSubjects\[0\]\.sub must be a non-empty string/);
|
||||
refuses(() => checkAccess(doc(), context({ businesses: { acme: { human: "jason" } } })), /access context\.businesses\.acme\.instances must be an array/);
|
||||
});
|
||||
|
||||
// loadAccess: the file checks, then checkAccess.
|
||||
|
||||
test("loadAccess reads a valid file and checks it", () => {
|
||||
const file = writeJson(join(tmp(), "access.json"), doc());
|
||||
assert.deepEqual(loadAccess(file, context()), doc());
|
||||
});
|
||||
|
||||
test("loadAccess refuses a missing file, a symlink and a directory with exit 4", () => {
|
||||
const dir = tmp();
|
||||
refuses(() => loadAccess(join(dir, "access.json"), context()), /access file not found/, 4);
|
||||
const real = writeJson(join(dir, "real.json"), doc());
|
||||
const link = join(dir, "access.json");
|
||||
symlinkSync(real, link);
|
||||
refuses(() => loadAccess(link, context()), /access file must be a regular, non-symbolic-link file/, 4);
|
||||
const sub = join(dir, "d");
|
||||
mkdirSync(sub);
|
||||
refuses(() => loadAccess(sub, context()), /access file must be a regular, non-symbolic-link file/, 4);
|
||||
});
|
||||
|
||||
test("loadAccess refuses another owner and group or other write", () => {
|
||||
const file = writeJson(join(tmp(), "access.json"), doc());
|
||||
refuses(() => loadAccess(file, context(), { uid: process.getuid() + 1 }), /access file must belong to uid/);
|
||||
for (const mode of [0o620, 0o602, 0o666]) {
|
||||
chmodSync(file, mode);
|
||||
refuses(() => loadAccess(file, context()), /access file must not be writable by group or other/);
|
||||
}
|
||||
chmodSync(file, 0o644);
|
||||
assert.ok(loadAccess(file, context()));
|
||||
});
|
||||
|
||||
test("loadAccess refuses bad JSON and a bad document with exit 2", () => {
|
||||
const dir = tmp();
|
||||
refuses(() => loadAccess(writeJson(join(dir, "a.json"), "{"), context()), /access file is not valid JSON/);
|
||||
refuses(() => loadAccess(writeJson(join(dir, "b.json"), mutated((d) => { d.grants[0].level = "owner"; })), context()), /grants\[0\]\.level/);
|
||||
});
|
||||
|
||||
// covers.
|
||||
|
||||
const NOW = new Date("2026-10-20T12:00:00Z");
|
||||
const target = (over = {}) => ({ business: "creative-hub", project: P1, instance: "researcher", ...over });
|
||||
// Any level reads: the least level is viewer (row 193a).
|
||||
const READ = { level: "viewer" };
|
||||
|
||||
test("covers: * and listed ids match", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g1, target({ project: P2 }), NOW, READ), true);
|
||||
assert.equal(covers(g2, target({ instance: "writer" }), NOW, READ), true);
|
||||
assert.equal(covers(g2, target(), NOW, READ), true);
|
||||
});
|
||||
|
||||
test("covers: an unlisted project, instance or business doesn't match", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g2, target({ project: P2 }), NOW, READ), false);
|
||||
assert.equal(covers(g1, target({ instance: "writer" }), NOW, READ), false);
|
||||
assert.equal(covers(g2, target({ business: "acme" }), NOW, READ), false);
|
||||
assert.equal(covers(g1, target({ business: "acme" }), NOW, READ), false);
|
||||
});
|
||||
|
||||
test("covers: an expired grant doesn't match, at and after the instant", () => {
|
||||
const [g1, g2] = checkAccess(doc(), context()).grants;
|
||||
assert.equal(covers(g1, target(), new Date("2026-12-30T23:59:59.999Z"), READ), true);
|
||||
assert.equal(covers(g1, target(), new Date("2026-12-31T00:00:00Z"), READ), false);
|
||||
assert.equal(covers(g1, target(), new Date("2027-01-01T00:00:00Z"), READ), false);
|
||||
// No expires never expires.
|
||||
assert.equal(covers(g2, target(), new Date("2099-01-01T00:00:00Z"), READ), true);
|
||||
});
|
||||
|
||||
test("covers: level is the least level asked for (row 193a)", () => {
|
||||
const g = checkAccess(doc(), context()).grants[0];
|
||||
const v = { ...g, level: "viewer" };
|
||||
assert.equal(covers(g, target(), NOW, { level: "collaborator" }), true);
|
||||
assert.equal(covers(g, target(), NOW, READ), true);
|
||||
assert.equal(covers(v, target(), NOW, READ), true);
|
||||
assert.equal(covers(v, target(), NOW, { level: "collaborator" }), false);
|
||||
});
|
||||
|
||||
test("covers: a missing or unknown level throws (row 193a, N1)", () => {
|
||||
const v = { ...checkAccess(doc(), context()).grants[0], level: "viewer" };
|
||||
assert.throws(() => covers(v, target(), NOW), TypeError);
|
||||
assert.throws(() => covers(v, target(), NOW, {}), TypeError);
|
||||
assert.throws(() => covers(v, target(), NOW, { level: undefined }), TypeError);
|
||||
for (const bad of ["owner", "Viewer", "", null, 1]) {
|
||||
assert.throws(() => covers(v, target(), NOW, { level: bad }), /covers needs a level, one of collaborator, viewer/);
|
||||
}
|
||||
});
|
||||
|
||||
test("covers: a grant with an unknown level covers nothing (row 193a)", () => {
|
||||
const g = checkAccess(doc(), context()).grants[0];
|
||||
for (const bad of ["owner", "Collaborator", undefined]) {
|
||||
assert.equal(covers({ ...g, level: bad }, target(), NOW, READ), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("covers: now must be a valid Date", () => {
|
||||
const g = checkAccess(doc(), context()).grants[0];
|
||||
assert.throws(() => covers(g, target(), Date.now(), READ), TypeError);
|
||||
assert.throws(() => covers(g, target(), new Date("nope"), READ), TypeError);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
// Row 199 (#1697): an agent's own git identity, its publish grant, the project's upstream and the
|
||||
// credential store reference. Design D1, D8, D9 step 2 and D9c.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import { BusinessError, parseCredentialRef, validateBusinessDocument } from "../src/index.mjs";
|
||||
import { forkProblem, forkUrl } from "../src/vocabulary.mjs";
|
||||
import { businessDoc, FAR_DATE, REPO_ROLES, tmp } from "./helpers.mjs";
|
||||
|
||||
function refuses(fn, pattern) {
|
||||
assert.throws(fn, (error) => {
|
||||
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
|
||||
assert.equal(error.exitCode, 2);
|
||||
assert.match(error.message, pattern);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const IDENTITY = { name: "Coder", email: "[email protected]", gitea: { url: "https://git.example", user: "acme-coder" } };
|
||||
|
||||
// The fixture with coder granted publish on stack, whose upstream is mosaic/stack.
|
||||
function check(mutate) {
|
||||
const root = tmp();
|
||||
const doc = businessDoc(root);
|
||||
doc.projects.stack.remote = "https://git.example/mosaic/stack";
|
||||
doc.roles.coder.identity = structuredClone(IDENTITY);
|
||||
doc.roles.coder.publish = ["stack"];
|
||||
mutate?.(doc, root);
|
||||
return validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
|
||||
}
|
||||
|
||||
test("today's files load unchanged: identity, publish and remote are optional", () => {
|
||||
const root = tmp();
|
||||
const business = validateBusinessDocument(businessDoc(root), join(root, "businesses", "acme.json"), { rolesDir: REPO_ROLES });
|
||||
assert.equal(business.roles.coder.identity, undefined);
|
||||
assert.equal(business.roles.coder.publish, undefined);
|
||||
assert.equal(business.projects.stack.remote, undefined);
|
||||
});
|
||||
|
||||
test("a granted agent loads with its identity, grant and the project's upstream", () => {
|
||||
const business = check();
|
||||
assert.deepEqual({ ...business.roles.coder.identity, gitea: { ...business.roles.coder.identity.gitea } }, IDENTITY);
|
||||
assert.deepEqual([...business.roles.coder.publish], ["stack"]);
|
||||
assert.equal(business.projects.stack.remote, "https://git.example/mosaic/stack");
|
||||
});
|
||||
|
||||
test("N10: publish needs identity and a gitea definition, and the error names the grant", () => {
|
||||
refuses(() => check((d) => { delete d.roles.coder.identity; }), /roles\.coder\.publish needs identity and a gitea definition/);
|
||||
refuses(() => check((d) => { d.roles.chief.identity = structuredClone(IDENTITY); d.roles.chief.publish = ["stack"]; }),
|
||||
/roles\.chief\.publish needs identity and a gitea definition/);
|
||||
});
|
||||
|
||||
test("publish names declared projects that have a remote, each once", () => {
|
||||
refuses(() => check((d) => { d.roles.coder.publish = ["books"]; }), /publish names books, which the business file doesn't declare/);
|
||||
refuses(() => check((d) => { delete d.projects.stack.remote; }), /publish names stack, which has no "remote"/);
|
||||
refuses(() => check((d) => { d.roles.coder.publish = []; }), /publish/);
|
||||
refuses(() => check((d) => { d.roles.coder.publish = ["stack", "stack"]; }), /publish/);
|
||||
});
|
||||
|
||||
test("identity fields are checked", () => {
|
||||
refuses(() => check((d) => { d.roles.coder.identity.email = "coder"; }), /identity\.email/);
|
||||
refuses(() => check((d) => { d.roles.coder.identity.name = "a\nb"; }), /identity\.name/);
|
||||
refuses(() => check((d) => { d.roles.coder.identity.extra = 1; }), /identity/);
|
||||
refuses(() => check((d) => { d.roles.coder.identity.gitea.user = ".."; }), /gitea\.user/);
|
||||
refuses(() => check((d) => { d.roles.coder.identity.gitea.user = "a/b"; }), /gitea\.user/);
|
||||
});
|
||||
|
||||
test("D9c: the upstream must be its own normalized form, exactly /OWNER/REPO", () => {
|
||||
const bad = {
|
||||
"a dot segment": "https://git.example/acme-coder/../mosaic/stack",
|
||||
"an encoded dot segment": "https://git.example/acme-coder/%2e%2e/mosaic/stack",
|
||||
"an extra segment": "https://git.example/mosaic/stack/more",
|
||||
"a missing segment": "https://git.example/stack",
|
||||
"a trailing slash": "https://git.example/mosaic/stack/",
|
||||
userinfo: "https://u:[email protected]/mosaic/stack",
|
||||
"a query": "https://git.example/mosaic/stack?x=1",
|
||||
"a fragment": "https://git.example/mosaic/stack#f",
|
||||
"an explicit :443": "https://git.example:443/mosaic/stack",
|
||||
"an upper-case host": "https://GIT.example/mosaic/stack",
|
||||
"a backslash": "https://git.example\\mosaic\\stack",
|
||||
"a name outside Gitea's rule": "https://git.example/mosaic/st%41ck",
|
||||
"a dot-dot repo": "https://git.example/mosaic/...git",
|
||||
http: "http://git.example/mosaic/stack",
|
||||
};
|
||||
for (const [what, remote] of Object.entries(bad)) {
|
||||
assert.throws(() => check((d) => { d.projects.stack.remote = remote; }), BusinessError, what);
|
||||
}
|
||||
});
|
||||
|
||||
test("D9c: one .git is stripped, and the fork is built from the stripped name", () => {
|
||||
assert.equal(check((d) => { d.projects.stack.remote = "https://git.example/mosaic/stack.git"; }).projects.stack.remote,
|
||||
"https://git.example/mosaic/stack.git");
|
||||
assert.equal(forkUrl("https://git.example/mosaic/stack.git", "acme-coder"), "https://git.example/acme-coder/stack");
|
||||
assert.equal(forkUrl("https://git.example:3000/mosaic/stack", "acme-coder"), "https://git.example:3000/acme-coder/stack");
|
||||
});
|
||||
|
||||
test("D9c: gitea.url names the instance only", () => {
|
||||
for (const url of ["https://git.example/gitea", "https://git.example/gitea/", "https://git.example/?x", "https://git.example:443", "http://git.example"]) {
|
||||
assert.throws(() => check((d) => { d.roles.coder.identity.gitea.url = url; }), BusinessError, url);
|
||||
}
|
||||
check((d) => { d.roles.coder.identity.gitea.url = "https://git.example/"; });
|
||||
});
|
||||
|
||||
test("the upstream's host and port must be the agent's Gitea's", () => {
|
||||
refuses(() => check((d) => { d.projects.stack.remote = "https://other.example/mosaic/stack"; }), /remote-host-mismatch/);
|
||||
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example:3000/mosaic/stack"; }), /remote-host-mismatch/);
|
||||
assert.equal(forkProblem("https://git.example/mosaic/stack", { gitea: { url: "https://git.example/", user: "u" } }), null);
|
||||
});
|
||||
|
||||
test("remote-not-fork: an upstream the agent's own user owns, compared without case", () => {
|
||||
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example/acme-coder/stack"; }), /remote-not-fork/);
|
||||
refuses(() => check((d) => { d.projects.stack.remote = "https://git.example/ACME-Coder/stack"; }), /remote-not-fork/);
|
||||
});
|
||||
|
||||
test("store references: own path only (design D1, D3a)", () => {
|
||||
const store = { configDir: "/cfg", business: "acme", agent: "coder" };
|
||||
const ref = parseCredentialRef({ store: true, rotateBy: FAR_DATE }, "gitea", "r", { store });
|
||||
assert.equal(ref.file, "/cfg/secrets/agents/acme/coder/gitea");
|
||||
assert.equal(ref.store, true);
|
||||
refuses(() => parseCredentialRef({ store: true, rotateBy: FAR_DATE }, "gitea", "r"), /store/);
|
||||
refuses(() => parseCredentialRef({ store: true, file: "/a", rotateBy: FAR_DATE }, "gitea", "r", { store }), /exactly one/);
|
||||
refuses(() => parseCredentialRef({ file: "/cfg/secrets/agents/acme/pm/gitea", rotateBy: FAR_DATE }, "gitea", "r", { store }),
|
||||
/credential-not-yours/);
|
||||
assert.equal(parseCredentialRef({ file: "/cfg/secrets/agents/acme/coder/gitea", rotateBy: FAR_DATE }, "gitea", "r", { store }).file,
|
||||
"/cfg/secrets/agents/acme/coder/gitea");
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { validateRoleDocument } from "../src/index.mjs";
|
||||
import { tmp } from "./helpers.mjs";
|
||||
|
||||
const WAITS = ["spend", "data.delete", "network.expose", "account.create", "credential.mint", "message.external",
|
||||
"git.push.protected", "deploy"];
|
||||
"git.push.protected", "deploy", "git.push.remote"];
|
||||
const LEFT = ["git.merge.protected", "policy.change", "prd.approve", "role.revoke"];
|
||||
const role = (authority) => {
|
||||
const dir = tmp();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user