Compare commits

..
Author SHA1 Message Date
fred e3b0375211 docs(hierarchy): revision 9 — complete the runner's importer enumeration (terra r8 F10)
ci/woodpecker/pr/ci Pipeline was successful
The 'one live importer' claim was false: the measured production importer
set of the db migration runner has four members (Gateway database module
via the barrel, storage Postgres adapter, fleet-backlog and gateway-verify
CLI commands via literal dynamic import). The enumeration example now
names all four, the import analysis resolves literal dynamic import()
routes, and a non-literal dynamic import of the db package fails the
assertion. Injection-only consumers (schema-check) have no import edge.
2026-08-26 19:31:09 -05:00
fred f07b4c73b1 docs: hierarchy schema revision 8 — re-export-aware import analysis, per-module importer enumeration (terra F9 residual)
ci/woodpecker/pr/ci Pipeline is pending
2026-08-26 19:18:11 -05:00
fred bf3bd6c477 docs: hierarchy schema revision 7 — infrastructure register for legitimate non-hierarchy raw execution (terra F9)
ci/woodpecker/pr/ci Pipeline was canceled
2026-08-26 19:09:00 -05:00
fred 5c1e8d290e docs: hierarchy contract revision 6 — three-prong wording, allowlist composition rules, honest detection scope (terra r5 F7/F8)
ci/woodpecker/pr/ci Pipeline is pending
2026-08-26 19:02:11 -05:00
fred e32d77e5a1 docs: hierarchy contract revision 5 — raw-execution prong closes dynamic-identifier evasion (terra r4 F7)
ci/woodpecker/pr/ci Pipeline is pending
2026-08-26 18:55:08 -05:00
fred dcf4220823 docs: hierarchy schema contract revision 4 (terra r3 residual F7: raw-SQL literal prong)
ci/woodpecker/pr/ci Pipeline is pending
2026-08-26 18:48:13 -05:00
fred f5e3165ea5 docs: hierarchy schema contract revision 3 (terra r2 residuals F4, F7)
ci/woodpecker/pr/ci Pipeline is pending
2026-08-26 18:43:00 -05:00
fred ee8c593858 docs: hierarchy schema contract revision 2 (terra review findings 1-9)
ci/woodpecker/pr/ci Pipeline was canceled
- tenancy-FK exemption: canonical workspace_id is the one permitted
  business->class reference; all other forms forbidden with control test
- record class = 4 node tables + hierarchy_grants per A1 8.1.2
- grant uniqueness via UNIQUE NULLS NOT DISTINCT (or 6 partial indexes);
  NOT NULL on role/granted_by/name/slug
- grant FK delete actions split: target cascade, principal RESTRICT
- audit parity: REQ-AUD-001 guarantees, same-transaction outbox,
  deletion-safe snapshot linkage, cascade coverage, rollback witness
- roll-up never-a-write restored to full A1 strength (no canonical writes)
- ownership invariant completed via contract 2; both-sides transfer rule
  marked new policy; legacy premise corrected
- corrected cardinality witness (one-parent-per-child + sibling control)
- provenance fixes: REQ-MIG-001 attribution dropped, projects table
  described accurately, D8-rank-1 claim removed
2026-08-26 18:29:53 -05:00
fred 7ef620cda7 docs: hierarchy schema contract (S2 contract 1, D2)
ci/woodpecker/pr/ci Pipeline was canceled
Turns PRD D2/D13 and kanban-SOT Amendment A1 into a concrete schema
contract: companies/estates/platform_projects/workspaces with NOT NULL
RESTRICT parent chains (chain uniqueness by construction), per-parent slug
scoping, hierarchy_grants attachment shape (vocabulary deferred to
contract 2), transfer as single audited parent-FK update requiring
both-sides authority, no owner columns (ownership = grants), fail-closed
bottom-up deletion, Gateway-only mutation path, and a verification matrix
with real-Postgres constraint witnesses.
2026-08-26 18:10:45 -05:00
fred 49b7943420 fix(gateway): scope /api/teams endpoints to team membership (#1428) (#1429)
ci/woodpecker/push/publish Pipeline is pending
2026-08-26 22:45:54 +00:00
fred 19e16bd44f ci: publish web+appservice sha images on next (#1407) (#1427)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-26 22:42:03 +00:00
jason.woltje 3bd490c080 Merge pull request 'docs: north-star PRD rewrite (D1-D14), ROADMAP, kanban SOT Amendment A1' (#1425) from docs/prd-north-star-rewrite into next
ci/woodpecker/push/publish Pipeline was successful
Reviewed-on: #1425
2026-08-26 16:57:16 +00:00
fred 4b448109dd docs/prd: address independent review findings 1-10 (fidelity, A1 record class + carve-out, roadmap completeness)
ci/woodpecker/pr/ci Pipeline was successful
2026-08-25 23:20:40 -05:00
fred bc1149c15e docs: north-star PRD rewrite (D1-D14), ROADMAP.md, kanban SOT Amendment A1
ci/woodpecker/pr/ci Pipeline was successful
- docs/PRD.md: Part I product north star authored from ratified decisions
  D1-D14; Part II preserves all active workstream contracts verbatim
  (KBN-101, FCM #758, FCOM #766, TESS, #756, MOS-PORT, #1150, #1174, #1194,
  RI #1275, M1). Referenced anchors unchanged.
- docs/archive/PRD-v0.1.md: v0.1.0 beta PRD body archived verbatim with
  supersession header.
- docs/ROADMAP.md: all phases P0-P5 present from day one per D11
  (P2-P5 as explicit placeholders).
- docs/requirements/native-kanban-sot.md: Amendment A1 (D13) - hierarchy
  parentage + RBAC chain above workspaces; sections 1-7 untouched.
2026-08-25 22:23:07 -05:00
orch-01 089953a7cf ci: enable turbo remote cache on trusted publish events (#1424)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: orch-01 <[email protected]>
2026-08-25 18:32:55 +00:00
ops-deploy-01 7b25be22e9 fix(#1394): recover-token headless — dual path (--email flag + piped stdin) with documented precedence (#1423)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: ops-deploy-01 <[email protected]>
2026-08-25 16:14:56 +00:00
ops-deploy-01 4e3d179e61 fix(#1390): gateway uninstall headless — --yes/--remove-data; non-TTY without consent fails loud (#1422)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: ops-deploy-01 <[email protected]>
2026-08-25 16:00:17 +00:00
ops-deploy-01andorch-01 ae58482b72 fix(#1392): review-285 N1+N2 follow-up — env has no config authority in schema-check; verification throws fatal at install (#1421)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: ops-deploy-01 <[email protected]>
2026-08-25 15:08:04 +00:00
code-infra-01 b2d40dada0 fix(#1391): boot-time ValidationPipe metatype self-check — fail loud at startup (#1419)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-infra-01 <[email protected]>
2026-08-25 14:01:24 +00:00
code-be-02andorch-01 d30a4cce00 feat(git-tools): consume .mosaic/repo.json declarations in compat mode (T51 WP5b) (#1416)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: code-be-02 <[email protected]>
2026-08-25 12:55:40 +00:00
veronicaandorch-01 4cd280e48d feat(tools/git): grant-reviewer.sh org-team reviewer grant with fail-closed read-back (#1415) (#1417)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: veronica <[email protected]>
2026-08-25 02:30:33 +00:00
code-infra-01andorch-01 8738a03893 fix(#1395): accounts.issuer column + credential-only backfill — password auth on fresh installs (#1401)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: code-infra-01 <[email protected]>
2026-08-25 01:19:29 +00:00
code-be-01andorch-01 04a01be992 ci(publish): serialize workspace-consuming image builds after publish-next-npm (#1411) (#1412)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: code-be-01 <[email protected]>
2026-08-25 01:18:02 +00:00
veronicaandorch-01 812e2df1da fix(#1408): mosaic-agent@ condition arms on either home shape (#1410)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: veronica <[email protected]>
2026-08-25 01:17:15 +00:00
veronicaandorch-01 8c292fb32f fix(#1408): legacy-socket launch guard + seat launch.sh preference (#1409)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: veronica <[email protected]>
2026-08-25 00:49:27 +00:00
code-be-01andorch-01 f45928c311 fix(ci): restore workspace manifests after publish pin transform (#1404) (#1405)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: code-be-01 <[email protected]>
2026-08-25 00:08:40 +00:00
ops-deploy-01andorch-01 4d24ae8618 fix(#1392): hash-ledger migrations + install-time schema verification (closes #1392, closes #1402) (#1403)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: ops-deploy-01 <[email protected]>
2026-08-24 23:11:50 +00:00
code-be-01andorch-01 d790572e2e ci(publish): pin next-channel @mosaicstack deps to exact same-pipeline builds (#1389) (#1400)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: code-be-01 <[email protected]>
2026-08-24 23:05:37 +00:00
code-be-01andorch-01 d7b1dd9601 test(git-tools): wrapper-guard harness distinguishes tool failure from drift (#1380-FF) (#1388)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-be-01 <[email protected]>
2026-08-24 21:43:09 +00:00
code-be-01andorch-01 0db2d19a22 feat(framework): mosaic doctor structure-anchor provisioning check (T51 WP0b) (#1379)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-be-01 <[email protected]>
2026-08-24 20:15:08 +00:00
code-be-01andorch-01 8eb7e6354e fix(fleet): resolve-then-validate symlink guard + framework helper resolution (#1380) (#1383)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: code-be-01 <[email protected]>
2026-08-24 19:39:13 +00:00
code-be-01andorch-01 9014a510a9 ci(mosaic): repo-structure declaration CI gate (T51 WP5c) (#1378)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-be-01 <[email protected]>
2026-08-24 04:43:26 +00:00
code-be-01andorch-01 974e4740ab docs(mosaic): declare repo structure v2 (T51 WP2a) (#1377)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-be-01 <[email protected]>
2026-08-24 03:49:33 +00:00
orch-01 9cd6d39b71 fix(git-tools): pr-create API fallback resolves base from forge default branch (E4) (#1376)
ci/woodpecker/push/publish Pipeline was successful
2026-08-24 02:49:38 +00:00
code-be-01andorch-01 143f925fd8 fix(git-tools): admin-gated --no-ci-expected merge assertion for CI-less repositories (#1373)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: code-be-01 <[email protected]>
2026-08-23 18:54:49 +00:00
fredandgate-merge-01 24294d3b77 fix(git-tools): issue-view shows comment bodies and names the real tea failure (#1357) (#1365)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: fred <[email protected]>
2026-08-22 00:23:22 +00:00
veronicaandgate-merge-01 24caeab057 fix(tmux): locate the REPL input box by shape, not by a Claude-only glyph (#1363)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: veronica <[email protected]>
2026-08-21 23:45:08 +00:00
fredandgate-merge-01 888a6ad29b fix(#1356): tea login resolution fails closed on a declared git identity (#1361)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: fred <[email protected]>
2026-08-21 23:04:31 +00:00
fred 24462f460e docs(git-wrappers): correct the stale --login fallback and document the test suite's identity split (#1354)
ci/woodpecker/push/publish Pipeline was successful
2026-08-21 14:42:36 +00:00
veronicaandfred a480ee83dc docs(W4): document contract — stamp kind and status on 104 live docs (#1350)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: veronica <[email protected]>
2026-08-21 14:07:36 +00:00
fred fd43ed5420 fix(git-wrappers): no --login must use the caller's own credential, not a guessed shared login (#1352)
ci/woodpecker/push/publish Pipeline was successful
2026-08-21 04:10:42 +00:00
fargoandfred 1d84bc3f3d fix(fleet): lease-broker activation, symlink-safe unit placement, named launch refusal — Wall 6 (#1292) (#1297)
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: fargo <[email protected]>
2026-08-21 00:40:29 +00:00
94 changed files with 14701 additions and 1384 deletions
+8 -1
View File
@@ -1,4 +1,11 @@
{
"schema_version": 2,
"integration_trunk": "next",
"release_branch": "main"
"release_branch": "main",
"flow": "trunk-release",
"canonical_remote": "https://git.mosaicstack.dev/mosaicstack/stack",
"canonical_clone": "host:/src/mosaic-stack",
"worktree_root": "host:/src/mosaic-stack-worktrees",
"worktree_policy": "orchestrator-precreated",
"notes": "next=development/integration; main=production release. Never branch work off main. worktree_policy is TRANSITIONAL: the wrapper worktree consumer is BLOCKED on the J3/#1174 amendment (checked roots + capacity guard); pre-creation is the interim orchestration choice, not closed policy — it becomes a timing choice only after the wrapper can validate this root."
}
+49 -4
View File
@@ -38,10 +38,12 @@ when:
- event: push
branch: main
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
# This avoids from_secret which is blocked on pull_request events.
# If the env vars aren't set, turbo falls back to local cache only.
# Turbo remote cache (turbo.mosaicstack.dev) is wired in publish.yml via the
# org-level Woodpecker secret `turbo_token` (events: push/tag/cron/manual/
# deployment — never pull_request). This PR pipeline deliberately gets no
# remote-cache credentials: an untrusted PR must not be able to write to (or
# poison) the shared cache. Without TURBO_* env vars turbo falls back to
# local cache only, which is the intended behavior here.
steps:
install:
@@ -91,6 +93,15 @@ steps:
# and sandboxes a throwaway git repo, so it resolves no real credentials and
# joins CI directly rather than the exclusions file.
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
# Hermetic regression for the git identity ladder (#1356): mock tea on PATH,
# sandboxed repo, no real credentials (3/3 green under an empty HOME). Pins
# fail-closed: a seat whose login is missing gets a named error, never a
# borrowed identity. Joins CI directly; its #1007 exclusion is burned down.
- bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh
# Hermetic regression for issue-view.sh (#1357): mock tea/curl, sandboxed
# repo. Pins that comment BODIES render on both paths and that a tea
# failure is named as what it was (git-config vs credential).
- bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
# it still blocks the three mistakes AND still lets reads, unwrapped
# endpoints and ordinary commands through. Both directions are asserted —
@@ -104,6 +115,40 @@ steps:
# stub supplies the scale instead of the host's own checkout.
- bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh
# Canonical repo-structure declaration gate (T51 WP5c, spec §5.4 point 2):
# .mosaic/repo.json is the machine-readable structure SSOT consumed by git
# wrappers and the T32 gate seat; this is its repo-side CI enforcement.
# Path-conditional: runs when the declaration, the vendored validator, or this
# pipeline config changes (manual runs always include it). Fails the pipeline
# on any VALIDATION_ERROR and enforces the schema_version 2 authoring rule
# (--require-v2: edited/new declarations may not stay v1). The validator is
# vendored into the framework tree (spec §5.1 final home) — provenance in its
# header; the hostile-input suite (101 arms, hermetic) runs alongside so the
# gate's own instrument ships in the same commit as the gate.
structure-declaration:
image: *node_image
commands:
- apk add --no-cache bash git
# MOSAIC_HOST_ROOT is a runtime anchor (spec §1.2a: unset fails closed
# for managed validation). CI has no host, so the step provisions an
# EXPLICIT fixture root — honest configuration for the resolution path,
# never a guess about a real host; the per-host containment checks are
# runtime concerns and do not run against a fixture. Grammar, schema,
# refs, flow, remote normalization, and path grammar all prove here.
- mkdir -p /tmp/t51-ci-hostroot
- bash packages/mosaic/framework/tools/structure/validate-repo-json.sh .mosaic/repo.json --require-v2
- bash packages/mosaic/framework/tools/structure/test-validate-repo-json.sh
environment:
MOSAIC_HOST_ROOT: /tmp/t51-ci-hostroot
when:
- event: pull_request
path:
include:
- '.mosaic/repo.json'
- 'packages/mosaic/framework/tools/structure/**'
- '.woodpecker/ci.yml'
- event: manual
# Canonical verify:release stage `upgrade-guard`.
# Blocking gate (#791): a framework upgrade must never write or delete an
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
+216 -21
View File
@@ -32,6 +32,11 @@ variables:
# non-excluded change still builds, so no transitive dep can silently go stale.
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
# the separate `event: tag` entry.)
# #1407: ONE shared anchor for all three image steps. A second main-only
# anchor previously gated build-web/build-appservice, so next-lane pushes
# published gateway sha images with no web/appservice counterpart — no
# sha-parity set existed for next-lane containerized deploys. Every image
# step now builds on next too (sha-only destinations, enforced per step).
- &image_build_when
- event: tag
- event: [push, manual]
@@ -44,16 +49,6 @@ variables:
- '.woodpecker/**'
- event: [push, manual]
branch: next
- &main_image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when:
- branch: [main, next]
@@ -73,6 +68,13 @@ steps:
# being empty) and on any incomplete verification.
verify:
image: *node_image
environment:
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
TURBO_API: https://turbo.mosaicstack.dev
TURBO_TEAM: mosaic
TURBO_TOKEN:
from_secret: turbo_token
commands:
- *enable_pnpm
# (a) Commit identity: the provider's claimed SHA must equal the actual
@@ -108,6 +110,13 @@ steps:
build:
image: *node_image
environment:
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
TURBO_API: https://turbo.mosaicstack.dev
TURBO_TEAM: mosaic
TURBO_TOKEN:
from_secret: turbo_token
commands:
- *enable_pnpm
- pnpm build
@@ -202,6 +211,20 @@ steps:
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
# #1404: snapshot every publishable manifest BEFORE the transform so the
# workspace can be restored byte-exact after publish. The transform
# rewrites package.json in place (needed: pnpm publish reads the
# workspace manifests); without restore, later steps in this pipeline
# (build-gateway kaniko COPY + pnpm install --frozen-lockfile) see
# manifests that no longer match pnpm-lock.yaml and fail
# ERR_PNPM_OUTDATED_LOCKFILE. Snapshot dir is step-local tmp.
SNAPSHOT_DIR="$(mktemp -d /tmp/publish-next-manifests.XXXXXX)"
export SNAPSHOT_DIR
find apps packages plugins -name package.json -not -path "*/node_modules/*" -not -path "*/dist/*" | while read -r mf; do
mkdir -p "$SNAPSHOT_DIR/$(dirname "$mf")"
cp -p "$mf" "$SNAPSHOT_DIR/$mf"
done
echo "[publish-next] snapshotted $(find "$SNAPSHOT_DIR" -name package.json | wc -l) manifests to $SNAPSHOT_DIR"
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
@@ -209,23 +232,38 @@ steps:
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
const roots = ['apps', 'packages', 'plugins'];
const updated = [];
const exactVersions = new Map(); // name -> bumped next version
function walk(dir) {
function walk(dir, visit) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const packagePath = path.join(fullPath, 'package.json');
if (fs.existsSync(packagePath)) updatePackage(packagePath);
walk(fullPath);
if (fs.existsSync(packagePath)) {
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (manifest.name?.startsWith('@mosaicstack/') && !manifest.private) {
visit(manifest, packagePath);
}
}
walk(fullPath, visit);
}
}
}
function updatePackage(packagePath) {
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
// #1389: two passes. Pass 1 bumps every publishable manifest to
// <stable+1>-next.<pipeline> exactly as before, recording name ->
// bumped version. Pass 2 rewrites every published manifest's
// @mosaicstack/* dependency entries (dependencies, devDependencies,
// peerDependencies, optionalDependencies) to the EXACT same-pipeline
// build. A caret range like ^0.0.3-next.2636 leaves the resolver free
// to pick any later build — and on a host with a stale cache, an
// installer-side scaffold pinned at stable, or a registry hiccup, that
// freedom is how a "next" install ends up executing stable-era code
// (web1 evidence: old tier validator, missing migrations). Exact pins
// make the defect class unrepresentable regardless of resolver path.
function bump(manifest, packagePath) {
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
if (!stableMatch) {
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
@@ -234,13 +272,40 @@ steps:
const oldVersion = manifest.version;
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
exactVersions.set(manifest.name, manifest.version);
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
}
for (const root of roots) walk(root);
const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
let pinnedEntries = 0;
function pin(manifest, packagePath) {
let changed = false;
for (const field of DEP_FIELDS) {
const deps = manifest[field];
if (!deps || typeof deps !== 'object') continue;
for (const [name, range] of Object.entries(deps)) {
if (!name.startsWith('@mosaicstack/')) continue;
const exact = exactVersions.get(name);
if (!exact) {
throw new Error(
manifest.name + ' depends on ' + name +
' which has no bumped version in this publish set — cannot pin');
}
if (range === exact) continue;
deps[name] = exact;
pinnedEntries++;
changed = true;
}
}
if (changed) fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
}
for (const root of roots) walk(root, bump);
for (const root of roots) walk(root, pin);
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
for (const line of updated) console.log('[publish-next] ' + line);
console.log('[publish-next] pinned ' + pinnedEntries + ' @mosaicstack/* dep entries to exact same-pipeline versions across ' + updated.length + ' manifests');
NODE
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
@@ -250,6 +315,94 @@ steps:
exit 1
fi
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
# #1389 post-publish guard: every freshly published manifest must carry
# EXACT same-pipeline @mosaicstack/* dep pins (no ranges, no stable
# fallback). A leak here fails the pipeline instead of shipping.
node <<'GUARD'
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
const registry = 'https://git.mosaicstack.dev/api/packages/mosaicstack/npm/';
const roots = ['apps', 'packages', 'plugins'];
const published = [];
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const packagePath = path.join(fullPath, 'package.json');
if (fs.existsSync(packagePath)) {
const m = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (m.name?.startsWith('@mosaicstack/') && !m.private) published.push(m.name);
}
walk(fullPath);
}
}
}
for (const root of roots) walk(root);
let failures = 0;
for (const name of published) {
let manifest;
try {
const out = execFileSync('npm', ['view', name + '@next', '--json', '--registry', registry],
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
const arr = JSON.parse(out);
manifest = Array.isArray(arr) ? arr[arr.length - 1] : arr;
} catch (e) {
console.error('[publish-next-guard] FAIL ' + name + ': npm view failed: ' + e.message);
failures++;
continue;
}
const fields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
for (const field of fields) {
const deps = manifest[field];
if (!deps || typeof deps !== 'object') continue;
for (const [dep, range] of Object.entries(deps)) {
if (!dep.startsWith('@mosaicstack/')) continue;
const expected = dep === name ? manifest.version : null;
const isExactPin = /^\d+\.\d+\.\d+-next\./.test(range);
const samePipeline = range.endsWith('-next.' + pipelineNumber);
if (!isExactPin) {
console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' range "' + range + '" is not an exact -next pin (stable-leak class, #1389)');
failures++;
} else if (!samePipeline) {
console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' pinned "' + range + '" but this pipeline published -next.' + pipelineNumber + ' (cross-pipeline pin)');
failures++;
}
}
}
}
if (failures > 0) {
console.error('[publish-next-guard] FATAL: ' + failures + ' dep-pin violation(s) — stable-dep leak into next publish (#1389)');
process.exit(1);
}
console.log('[publish-next-guard] OK: all ' + published.length + ' published manifests carry exact same-pipeline @mosaicstack/* dep pins');
GUARD
# #1404 restore: put the workspace manifests back byte-exact so later
# steps (build-gateway frozen-lockfile install) see the committed tree.
RESTORE_FAIL=0
while read -r mf; do
if [ -f "$SNAPSHOT_DIR/$mf" ]; then
cp -p "$SNAPSHOT_DIR/$mf" "$mf"
else
echo "[publish-next] FATAL: no snapshot for $mf — cannot restore (snapshot incomplete?)" >&2
RESTORE_FAIL=1
fi
done < <(find apps packages plugins -name package.json -not -path "*/node_modules/*" -not -path "*/dist/*")
# Pristine guard (#1404 red-first control): the publish step must leave
# the workspace byte-identical to the checkout for every manifest.
# git diff is the arbiter — any residual mutation fails THIS step
# instead of surfacing as ERR_PNPM_OUTDATED_LOCKFILE in build-gateway.
if ! git diff --exit-code -- '**/package.json' >/dev/null 2>&1; then
echo "[publish-next] FATAL: workspace package.json files still differ from HEAD after restore (#1404 class)" >&2
git diff --stat -- '**/package.json' >&2 || true
RESTORE_FAIL=1
fi
rm -rf "$SNAPSHOT_DIR"
if [ "$RESTORE_FAIL" -ne 0 ]; then exit 1; fi
echo "[publish-next] workspace manifests restored byte-exact (git diff clean); later steps see the committed tree"
depends_on:
- build
- verify
@@ -305,10 +458,18 @@ steps:
depends_on:
- build
- verify
# #1411: publish-next-npm mutates workspace manifests in place during
# its transform window and restores them at step end. Any step that
# reads the pipeline workspace (kaniko COPY of manifests, later
# installs) must run AFTER publish-next-npm, never concurrently —
# pipeline 2648 raced a COPY inside the window and failed
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
# serialization invariant; add it to every new workspace consumer.
- publish-next-npm
build-appservice:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
@@ -322,8 +483,17 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next appservice publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: appservice image publish may only run for main, next, or tag events" >&2
exit 1
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
@@ -332,10 +502,18 @@ steps:
depends_on:
- build
- verify
# #1411: publish-next-npm mutates workspace manifests in place during
# its transform window and restores them at step end. Any step that
# reads the pipeline workspace (kaniko COPY of manifests, later
# installs) must run AFTER publish-next-npm, never concurrently —
# pipeline 2648 raced a COPY inside the window and failed
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
# serialization invariant; add it to every new workspace consumer.
- publish-next-npm
build-web:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
@@ -349,8 +527,17 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next web publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2
exit 1
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
@@ -359,3 +546,11 @@ steps:
depends_on:
- build
- verify
# #1411: publish-next-npm mutates workspace manifests in place during
# its transform window and restores them at step end. Any step that
# reads the pipeline workspace (kaniko COPY of manifests, later
# installs) must run AFTER publish-next-npm, never concurrently —
# pipeline 2648 raced a COPY inside the window and failed
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
# serialization invariant; add it to every new workspace consumer.
- publish-next-npm
@@ -0,0 +1,71 @@
import { describe, it, expect, vi } from 'vitest';
// The module under test imports @mosaicstack/db at module scope; we replace only the
// pieces DatabaseModule uses (partial mock — the real module also exports the
// schema the storage adapter's import chain needs) so the test pins the #1392
// contract (refuse to start on an incomplete schema) without a live database.
vi.mock('@mosaicstack/db', async (importOriginal) => {
const actual: object = await importOriginal();
return {
...actual,
createDb: vi.fn(),
createPgliteDb: vi.fn(),
getMigrationStatus: vi.fn(),
runPgliteMigrations: vi.fn(),
};
});
import { DatabaseModule } from './database.module.js';
import { getMigrationStatus } from '@mosaicstack/db';
import type { DbHandle } from '@mosaicstack/db';
import type { StorageAdapter } from '@mosaicstack/storage';
import type { MosaicConfig } from '@mosaicstack/config';
function makeModule(storageType: 'postgres' | 'pglite', tier: string) {
const storageAdapter = {
name: storageType,
migrate: vi.fn(),
close: vi.fn(),
} as unknown as StorageAdapter;
const handle = { close: vi.fn() } as unknown as DbHandle;
const config = {
tier,
storage: { type: storageType, url: 'postgresql://x' },
} as unknown as MosaicConfig;
return {
mod: new DatabaseModule(handle, storageAdapter, config),
storageAdapter,
};
}
describe('DatabaseModule.onModuleInit — #1392 schema verification', () => {
it('refuses to start when the postgres schema is incomplete', async () => {
const { mod, storageAdapter } = makeModule('postgres', 'standalone');
vi.mocked(getMigrationStatus).mockResolvedValue({
appliedCount: 15,
expectedCount: 17,
expectedLastTag: '0016_salty_morlocks',
complete: false,
});
await expect(mod.onModuleInit()).rejects.toThrow('Database schema incomplete: 15/17');
expect(storageAdapter.migrate).toHaveBeenCalled(); // migrations attempted first
});
it('starts normally when the schema is complete', async () => {
const { mod } = makeModule('postgres', 'standalone');
vi.mocked(getMigrationStatus).mockResolvedValue({
appliedCount: 17,
expectedCount: 17,
expectedLastTag: '0016_salty_morlocks',
complete: true,
});
await expect(mod.onModuleInit()).resolves.toBeUndefined();
});
it('does not verify postgres status for the local tier (PGlite migrates itself)', async () => {
const { mod } = makeModule('pglite', 'local');
vi.mocked(getMigrationStatus).mockClear();
await expect(mod.onModuleInit()).resolves.toBeUndefined();
expect(getMigrationStatus).not.toHaveBeenCalled();
});
});
@@ -12,6 +12,7 @@ import {
import {
createDb,
createPgliteDb,
getMigrationStatus,
runPgliteMigrations,
type Db,
type DbHandle,
@@ -74,6 +75,11 @@ export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
// the same DATABASE_URL, so a single call covers both the gateway DB and
// the storage tables. We deliberately do NOT call runMigrations() here to
// avoid opening a second short-lived connection and doubling startup cost.
//
// #1392: we DO verify afterwards (getMigrationStatus opens one short-lived
// connection) and refuse to start on an incomplete schema. A gateway that
// boots "healthy" on an empty or partial database is precisely the failure
// that shipped in the T63 batch: silent at startup, catastrophic later.
async onModuleInit(): Promise<void> {
if (this.config.tier === 'local') {
this.logger.log('Applying PGlite schema migrations...');
@@ -81,6 +87,24 @@ export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
}
this.logger.log(`Initializing storage adapter (${this.storageAdapter.name})...`);
await this.storageAdapter.migrate();
if (this.config.storage.type === 'postgres') {
const status = await getMigrationStatus(this.config.storage.url);
if (!status.complete) {
this.logger.error(
`Database schema incomplete: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied ` +
`(last expected: ${status.expectedLastTag}). ` +
'Refusing to start on a partial schema — see issues #1392/#1402. ' +
"Remediation: re-run 'mosaic gateway install' (it now verifies), or apply migrations manually.",
);
throw new Error(
`Database schema incomplete: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied`,
);
}
this.logger.log(
`Database schema verified: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied.`,
);
}
}
async onApplicationShutdown(): Promise<void> {
+6
View File
@@ -14,10 +14,16 @@ import { mountMcpHandler } from './mcp/mcp.controller.js';
import { McpService } from './mcp/mcp.service.js';
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
import { resolveGatewayConfigPath } from './env.js';
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
async function bootstrap(): Promise<void> {
const logger = new Logger('Bootstrap');
// Fail loud BEFORE anything else if the global ValidationPipe cannot see
// the guarded DTOs' decorated properties (#1391): a broken metatype turns
// every request body into a 400 at first use; this surfaces it at boot.
assertValidationPipeSeesDtoDecorators();
if (!process.env['BETTER_AUTH_SECRET']) {
throw new Error('BETTER_AUTH_SECRET is required');
}
@@ -0,0 +1,104 @@
/**
* Boot-time ValidationPipe metatype self-check (#1391).
*
* The check exists to fail loud at boot when the global pipe cannot see a
* guarded DTO's decorated properties — the #436 class-erasure signature and
* its dependency-graph cousins. Red/green arms:
*
* GREEN real module state: BootstrapSetupDto's three properties are
* decorated and visible through the globalThis-shared storage.
* RED a control class with NO decorators (the erasure shape): the
* check throws PipeMetatypeCheckError naming every property.
* RED-2 a control where one property is decorated and two are not: the
* error names exactly the missing two — the miss list is precise,
* not a blanket failure.
*/
import { describe, expect, it } from 'vitest';
import { IsString } from 'class-validator';
import {
assertValidationPipeSeesDtoDecorators,
PipeMetatypeCheckError,
} from './validation-pipe-check.js';
describe('assertValidationPipeSeesDtoDecorators (#1391 boot check)', () => {
it('GREEN: passes on real module state (decorated DTO visible to the pipe)', () => {
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
});
it('RED control: a class whose properties lost their decorators throws, naming them', async () => {
// Simulate metatype erasure: an undecorated class standing where a
// decorated DTO should be. Redefine the guard table for the test by
// importing the module and pointing its table at the eroded class —
// the check reads the table at call time, so a fresh module instance
// with a swapped table reproduces the boot failure deterministically.
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
class ErodedDto {
name?: string;
email?: string;
password?: string;
}
const original = PIPE_GUARDED_DTOS[0];
expect(original).toBeDefined();
// Swap in the eroded target (same declared properties, zero decorators).
(
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
).splice(0, PIPE_GUARDED_DTOS.length, {
name: 'ErodedDto',
target: ErodedDto,
properties: ['name', 'email', 'password'],
});
try {
expect(() => assertValidationPipeSeesDtoDecorators()).toThrow(PipeMetatypeCheckError);
try {
assertValidationPipeSeesDtoDecorators();
} catch (err) {
const message = err instanceof Error ? err.message : '';
expect(message).toContain('ErodedDto.name');
expect(message).toContain('ErodedDto.email');
expect(message).toContain('ErodedDto.password');
}
} finally {
// Restore real module state for any later test in this file.
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
}
// And confirm the restore is real.
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
});
it('RED-2 control: a partially decorated class names exactly the missing properties', async () => {
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
class HalfErodedDto {
@IsString()
name?: string;
email?: string;
password?: string;
}
const original = PIPE_GUARDED_DTOS[0];
(
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
).splice(0, PIPE_GUARDED_DTOS.length, {
name: 'HalfErodedDto',
target: HalfErodedDto,
properties: ['name', 'email', 'password'],
});
try {
try {
assertValidationPipeSeesDtoDecorators();
expect.unreachable('partially decorated DTO must fail the boot check');
} catch (err) {
const message = err instanceof Error ? err.message : '';
expect(message).toContain('HalfErodedDto.email');
expect(message).toContain('HalfErodedDto.password');
expect(message).not.toContain('HalfErodedDto.name has no');
}
} finally {
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
}
});
});
+94
View File
@@ -0,0 +1,94 @@
import 'reflect-metadata';
import { getMetadataStorage } from 'class-validator';
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
/**
* Boot-time self-check: the global ValidationPipe must be able to SEE the
* decorated properties of the DTOs it guards (#1391, #436 class).
*
* WHY THIS EXISTS. When Nest resolves a @Body() metatype to Object — via
* `import type` class erasure (#436), or a dependency graph where the
* controller's decorators and the application's route enhancers disagree
* (#1391's hypothesized dual-@nestjs/common on a mixed install) — the
* ValidationPipe's whitelist treats every property as forbidden. The first
* symptom is a 400 on the FIRST bootstrap attempt of a fresh install, the
* worst place to discover wiring damage: the operator cannot tell a broken
* payload from a broken daemon.
*
* This check fails LOUD at boot instead: if the pipe cannot see the DTO's
* decorated properties, the gateway refuses to start with a named cause.
* It catches the whole class — erasure, decorator metadata loss — on every
* host, at the moment the damage exists rather than at first use.
*
* Storage sharing note: class-validator keys its metadata storage on
* globalThis, so duplicate package copies do NOT hide metadata (measured,
* #1391 diagnosis). What hides it is losing the metatype itself, which is
* what this asserts against.
*/
/**
* DTOs the global pipe guards, mapped to the properties the whitelist must
* admit. Target is the CONSTRUCTOR (the object class itself): class-validator
* decorators register metadata keyed on the constructor, and its executor
* looks up `object.constructor` (ValidationExecutor.js:50) — the probe
* through `prototype` returns zero. Extend when adding DTOs to the app.
*/
export const PIPE_GUARDED_DTOS: Array<{
name: string;
target: abstract new (...args: never[]) => unknown;
properties: string[];
}> = [
{
name: 'BootstrapSetupDto',
target: BootstrapSetupDto,
properties: ['name', 'email', 'password'],
},
];
export class PipeMetatypeCheckError extends Error {
constructor(missing: string[]) {
super(
'ValidationPipe metatype check failed: ' +
missing.join('; ') +
'. The global ValidationPipe cannot see decorated DTO properties — ' +
'every request body would be rejected as non-whitelisted. ' +
'Check for import-type erasure or decorator metadata loss in the ' +
'dependency graph (see issues #436, #1391).',
);
this.name = 'PipeMetatypeCheckError';
}
}
/**
* Assert the pipe's whitelist can see every guarded DTO's decorated
* properties. Throws PipeMetatypeCheckError (fail-loud at boot) listing
* each miss. Pure function of module state: no I/O, safe to call twice.
*/
export function assertValidationPipeSeesDtoDecorators(): void {
const storage = getMetadataStorage();
const missing: string[] = [];
for (const dto of PIPE_GUARDED_DTOS) {
// class-validator records constraints keyed on the DTO's constructor
// (decorators run on the class), and its executor resolves them via
// object.constructor. A property with no recorded metadata is invisible
// to the whitelist — whatever the cause — and fails here.
// Signature mirrors ValidationExecutor.js:50 — (constructor, schema, always,
// strictGroups, groups?). No schema, always=true, no groups: every
// constraint regardless of grouping, which is what the whitelist sees.
const metadatas = storage.getTargetValidationMetadatas(dto.target, '', true, false);
const decorated = new Set(metadatas.map((m) => m.propertyName));
for (const property of dto.properties) {
if (!decorated.has(property)) {
missing.push(
`${dto.name}.${property} has no class-validator constraints visible to the pipe`,
);
}
}
}
if (missing.length > 0) {
throw new PipeMetatypeCheckError(missing);
}
}
@@ -0,0 +1,123 @@
import 'reflect-metadata';
import { type CanActivate, type ExecutionContext, type INestApplication } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthGuard } from '../auth/auth.guard.js';
import { TeamsController } from './teams.controller.js';
import { TeamsService } from './teams.service.js';
const teamAlpha = { id: 'team-alpha', name: 'Alpha' };
const teamBeta = { id: 'team-beta', name: 'Beta' };
// user-1 is a member of team-alpha only; admin-1 has role admin.
let currentUser: { id: string; role?: string } = { id: 'user-1' };
const teamsServiceMock = {
findAll: vi.fn(() => Promise.resolve([teamAlpha, teamBeta])),
findAllForUser: vi.fn((userId: string) =>
Promise.resolve(userId === 'user-1' ? [teamAlpha] : []),
),
findById: vi.fn((id: string) => Promise.resolve([teamAlpha, teamBeta].find((t) => t.id === id))),
listMembers: vi.fn(() => Promise.resolve([{ teamId: 'team-alpha', userId: 'user-1' }])),
isMember: vi.fn((teamId: string, userId: string) =>
Promise.resolve(teamId === 'team-alpha' && userId === 'user-1'),
),
};
const authGuard: CanActivate = {
canActivate(context: ExecutionContext): boolean {
const requestContext = context
.switchToHttp()
.getRequest<{ user?: { id: string; role?: string } }>();
requestContext.user = currentUser;
return true;
},
};
describe('teams endpoints are scoped to membership', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [TeamsController],
providers: [{ provide: TeamsService, useValue: teamsServiceMock }],
})
.overrideGuard(AuthGuard)
.useValue(authGuard)
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
beforeEach(() => {
currentUser = { id: 'user-1' };
vi.clearAllMocks();
});
afterAll(async () => {
await app.close();
});
it('GET /api/teams returns only the teams the user belongs to', async () => {
const response = await request(app.getHttpServer()).get('/api/teams');
expect(response.status).toBe(200);
expect(response.body).toEqual([teamAlpha]);
expect(teamsServiceMock.findAll).not.toHaveBeenCalled();
});
it('GET /api/teams returns every team for an admin', async () => {
currentUser = { id: 'admin-1', role: 'admin' };
const response = await request(app.getHttpServer()).get('/api/teams');
expect(response.status).toBe(200);
expect(response.body).toEqual([teamAlpha, teamBeta]);
expect(teamsServiceMock.findAllForUser).not.toHaveBeenCalled();
});
it('GET /api/teams/:teamId returns 403 for a non-member', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-beta');
expect(response.status).toBe(403);
});
it('GET /api/teams/:teamId returns 404 for a missing team', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-missing');
expect(response.status).toBe(404);
});
it('GET /api/teams/:teamId returns the team for a member', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha');
expect(response.status).toBe(200);
expect(response.body).toEqual(teamAlpha);
});
it('GET /api/teams/:teamId/members returns 403 for a non-member and members for a member', async () => {
const denied = await request(app.getHttpServer()).get('/api/teams/team-beta/members');
expect(denied.status).toBe(403);
expect(teamsServiceMock.listMembers).not.toHaveBeenCalled();
const allowed = await request(app.getHttpServer()).get('/api/teams/team-alpha/members');
expect(allowed.status).toBe(200);
expect(allowed.body).toEqual([{ teamId: 'team-alpha', userId: 'user-1' }]);
});
it('GET /api/teams/:teamId/members/:userId allows a self-lookup on any team', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-1');
expect(response.status).toBe(200);
expect(response.body).toEqual({ isMember: false });
});
it('GET /api/teams/:teamId/members/:userId denies looking up another user on a foreign team', async () => {
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-2');
expect(response.status).toBe(403);
});
it('an admin can look up any membership', async () => {
currentUser = { id: 'admin-1', role: 'admin' };
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha/members/user-1');
expect(response.status).toBe(200);
expect(response.body).toEqual({ isMember: true });
});
});
+45 -7
View File
@@ -1,30 +1,68 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import {
Controller,
ForbiddenException,
Get,
NotFoundException,
Param,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { TeamsService } from './teams.service.js';
type RequestUser = { id: string; role?: string };
@Controller('api/teams')
@UseGuards(AuthGuard)
export class TeamsController {
constructor(private readonly teams: TeamsService) {}
@Get()
async list() {
return this.teams.findAll();
async list(@CurrentUser() user: RequestUser) {
if (user.role === 'admin') {
return this.teams.findAll();
}
return this.teams.findAllForUser(user.id);
}
@Get(':teamId')
async findOne(@Param('teamId') teamId: string) {
return this.teams.findById(teamId);
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
return this.getAccessibleTeam(teamId, user);
}
@Get(':teamId/members')
async listMembers(@Param('teamId') teamId: string) {
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
await this.getAccessibleTeam(teamId, user);
return this.teams.listMembers(teamId);
}
@Get(':teamId/members/:userId')
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
async checkMembership(
@Param('teamId') teamId: string,
@Param('userId') userId: string,
@CurrentUser() user: RequestUser,
) {
// A user may always ask about their own membership; anything else is
// team-scoped like the other routes.
if (userId !== user.id) {
await this.getAccessibleTeam(teamId, user);
}
const isMember = await this.teams.isMember(teamId, userId);
return { isMember };
}
/**
* Team-scoped access: admins see any team; everyone else only teams they
* are a member of. NotFoundException when the team does not exist and
* ForbiddenException when the user lacks access (same convention as the
* projects controller).
*/
private async getAccessibleTeam(teamId: string, user: RequestUser) {
const team = await this.teams.findById(teamId);
if (!team) throw new NotFoundException('Team not found');
if (user.role === 'admin') return team;
const isMember = await this.teams.isMember(teamId, user.id);
if (!isMember) throw new ForbiddenException('Not a member of this team');
return team;
}
}
+16 -1
View File
@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
import { eq, and, inArray, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
@Injectable()
@@ -56,6 +56,21 @@ export class TeamsService {
return this.db.select().from(teams);
}
/**
* List only the teams the user is a member of.
*/
async findAllForUser(userId: string) {
const memberRows = await this.db
.select({ teamId: teamMembers.teamId })
.from(teamMembers)
.where(eq(teamMembers.userId, userId));
const teamIds = memberRows.map((r) => r.teamId);
if (teamIds.length === 0) return [];
return this.db.select().from(teams).where(inArray(teams.id, teamIds));
}
/**
* Find a team by ID.
*/
+242 -1014
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
---
kind: spec
status: active
---
# Mosaic Stack Roadmap
Companion to [docs/PRD.md](./PRD.md). Governed by the D11 rule: **every planned
phase appears here from day one, even as a placeholder** — nothing exists only
in heads. A phase marked _placeholder_ is a commitment to design it, not a
design; scoping one requires its own PRD section or requirements doc plus
review.
Phases are product phases. The in-flight platform workstreams (KBN-100/101
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, and the other
Part II contracts in the PRD) run as parallel tracks under their own issues
and are prerequisites where noted.
| Phase | Scope | Status |
| ----- | ------------------------------------------------------------------------------ | ----------------------------------------- |
| P0 | Current state on `next`: read-only dashboard, chat, auth/SSO login, admin tabs | shipped, evolving |
| P1 | **v1 slice** (PRD Part I §9) | next up |
| P2 | Connectors + comms + wizard expansion | placeholder |
| P3 | Full onboarding profile + M365 | placeholder |
| P4 | Enterprise mode + one-way conversion | placeholder |
| P5 | Federation | placeholder (deliberately undesigned, D3) |
## P0 — current state
What exists on `next` today: web dashboard (login/register/SSO, chat,
read-only projects/tasks, settings, admin user/system-health tabs), the
Gateway, the CLI-first framework tooling, and the fleet control plane. The
webUI audit (USC estate, webui-audit lane) measures the gap between this and
P1.
## P1 — v1 slice (D11)
1. Standalone onboarding wizard: system/company name, component choices,
initial user, initial estate + project, seeded examples, re-runnable.
2. Hierarchy core: company → estate → project → workspace → kanban, read-only
task bubble-up (kanban SOT Amendment A1 is the schema contract).
3. Basic RBAC on the hierarchy.
4. Minimal agent enrollment: one harness, API key, name/persona.
Prerequisites: KBN-100/101 schema foundation; the D8 tool inventory and
webUI→tool mapping (any missing tool is built first, D12).
## P2 — connectors + comms + wizard expansion (placeholder)
Email and drive connectors (Gmail/IMAP, Google Drive/OneDrive/Dropbox) with
granular agentic-access consent; comms integrations (Matrix/Discord/Slack)
including agent auto-enroll. Wizard gains the corresponding tabs (D4), plus
the D4 capabilities deferred out of P1's minimal slice: expanded agent
enrollment (OAuth login, multi-account, model choice with recommendation,
account assignment, comms auto-enroll) and the Standalone SSO/OIDC
configuration tab.
## P3 — full onboarding profile + M365 (placeholder)
Complete user onboarding profile (communication-style capture, optional
voice-matching interview) under the D14 custody rule; M365 connectors,
available to both deployment modes as ordinary connectors (same consent model
as the P2 connector class). The Enterprise install flow's M365 prominence
(D4) arrives with the Enterprise phase, P4.
## P4 — Enterprise mode + conversion (placeholder)
Enterprise install flow (org chart, RBAC focus, immediate OIDC, SSO
prominent); per-user brains with architectural isolation (D14); Vault
required; the one-way Standalone → Enterprise conversion (D3).
## P5 — federation (placeholder)
Connecting deployments: system-level config, assigned users, rights and
data-access control, trusts with boundaries, strict data access, exfiltration
monitoring. Explicitly not designed yet (D3); nothing in earlier phases may
foreclose it. Requires its own PRD + threat model before any scoping.
File diff suppressed because it is too large Load Diff
+380
View File
@@ -0,0 +1,380 @@
# Hierarchy Schema Contract (D2)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 1 of 9).
Authority: PRD D2/D9/D13 (Part I §4) and the native-kanban SOT Amendment A1
(`docs/requirements/native-kanban-sot.md` §8, ratified 2026-08-25). This
document turns the ratified hierarchy into a concrete schema contract:
tables, cardinalities, constraints, and ownership/transfer semantics. It is
the prerequisite for the hierarchy command family and for the RBAC grant
model (contract 2, `docs/requirements/rbac-grant-model.md`).
Revision 2 (independent review, GPT-5.6 terra): tenancy-FK exemption made
explicit (§1.1); record class extended to include `hierarchy_grants`
(§1.1); provenance corrections on legacy tables and the planning `projects`
table (§1.3, §2 naming note); NOT NULL and `NULLS NOT DISTINCT` grant
uniqueness (§2.6, §3.2); grant FK delete actions split cascade/restrict
(§3.3); transfer transaction includes its audit write (§4.3); ownership
invariant completed via contract 2 with the both-sides rule marked as new
policy (§4.2, §4.4); hierarchy audit brought under REQ-AUD-001-equivalent
guarantees with deletion-safe linkage (§5.2); roll-up never-a-write restored
to full A1 strength (§5.4); §6 rebuilt with bounded observables for every
MUST (allowlist, command surface, audit, corrected cardinality witness).
Revision 3 (terra re-review residuals): §4.3 transfer write inventory
reconciled with §5.2 — the transaction's writes are the single class-row
mutation plus that mutation's §5.2 audit writes (event + outbox record),
not "exactly two writes"; §6.3 extended with a closed writer-coverage
witness so an unregistered internal writer cannot pass a registered-route
inventory. (Terra's finding-8 residual — a stale contract 2 §7.8 backlink
to contract 1 §6.2 — was already fixed in contract 2 revision 2, which
cites §6.5; measured against `origin/contract/rbac-grants` head
`501112d2`.)
Revision 4 (terra r3 residual F7): the §6.3(b) writer-coverage assertion
extended to raw SQL — it now also fails on class-table name literals
inside SQL strings or tagged SQL templates outside the allowlist, so a
raw-SQL writer that touches no schema symbol is still caught.
Revision 5 (terra r4 residual F7): §6.3(b) gains a third prong — any
raw-SQL execution primitive outside the allowlist fails the assertion
regardless of its SQL content, closing the evasion where a
dynamically constructed table name carries neither a schema symbol nor
a class-table literal. The detection claim is now coextensive with
what the three prongs statically see.
Revision 6 (terra r5 residual F7 + new F8): the "two prongs" wording
corrected to three (F8); §6.3(b) gains the allowlist composition rules
(no generic raw-SQL helper is allowlisted; an allowlisted module may
not export caller-supplied-SQL execution) and fails outright on
runtime code-construction primitives; the detection claim is scoped
honestly to the stated syntactic forms, with evasions beyond static
reach assigned to §5.1 review/audit rather than claimed for CI.
Revision 7 (terra r6 new F9): the false-positive remedy no longer
contradicts the composition rules — legitimate non-hierarchy raw
execution (e.g. the db package's migration runner) is dispositioned
onto a second closed enumerated list, the infrastructure register,
exempt from prong (iii) only, still bound by prongs (i)/(ii), barred
from the writer allowlist, and importable only by registered modules
or the operational entry points.
Revision 8 (terra r7 residual F9): the register's import rule made
satisfiable by the live tree — imports are checked re-export-aware
(package barrels followed), and each registered module carries its own
closed importer enumeration, which may name operational entry points
such as the Gateway's startup migration hook; named importers stay
subject to prongs (i)/(ii) and gain no writer standing.
Revision 9 (terra r8 F10): revision 8 called the Gateway database
module the runner's "one live importer today". That was false — the
measured production importer set has four members. The enumeration
example now lists the complete measured set, and the import analysis
is extended to resolve literal dynamic `import()` routes, which two of
the four members use.
Scope: the tenancy/authorization structure record class — companies,
estates, platform-projects, workspaces, hierarchy grants, their parentage,
and constraints. Out of scope: the RBAC grant vocabulary and evaluation
semantics (contract 2), roll-up projection semantics (contract 8), kanban
planning entities inside workspaces (SOT §5), migration or retirement of
legacy flat data (future work; see §1.3).
## 1. Record class and placement
1. The **tenancy/authorization structure record class** defined by
Amendment A1 §8.1.2 comprises five tables: the four node tables of §2
AND `hierarchy_grants` (§3) — A1 includes hierarchy-level access grants
in the class. Every rule addressed to "the class" in this contract
(payload prohibition, mutation path, audit) binds all five tables. Class
rows carry parentage, naming, grant, and audit-linkage data only — never
task, plan, or any business/orchestration payload.
References from business/orchestration rows into the class are limited
to exactly one form: the canonical `workspace_id` tenancy column that
REQ-TEN-001 requires on every canonical row, referencing
`workspaces.id`. No business/orchestration row may reference a company,
estate, platform-project, or grant id in any position, and no
business/orchestration row may reference a workspace id in any
non-tenancy position (dependency, claim target, work subject).
2. Hierarchy records are NOT workspace-scoped rows: REQ-TEN-001's
`workspace_id` obligation binds business/orchestration rows and does not
apply to this class (A1 §8.1.2). The `workspaces` table itself is the
anchor the obligation points at.
3. The legacy flat tables (`teams`, and the Brain planning `projects` table
in `packages/db/src/schema.ts`) are not part of this class. What A1
§8.1.4 pins is narrower: the planning `projects` table and
`platform_projects` stay distinct tables. This contract adds, as new
policy ratified here: neither `teams` nor `projects` is repurposed as a
hierarchy table. Their eventual migration or retirement is future work
that no existing REQ assigns; it is out of scope here.
## 2. Tables and cardinalities
Naming: the level above workspaces is `platform_projects`, per A1 §8.1.4.
The existing `projects` table is Brain planning data (so labeled in
`packages/db/src/schema.ts`; it carries no `workspace_id`), and the schema
MUST NOT merge the two. (A rename of either remains an implementation-PR
decision under A1; this contract pins only that they stay distinct tables.)
1. `companies` — id (uuid pk), name, slug (unique per deployment),
created_at, updated_at. N per deployment (D2).
2. `estates` — id, name, slug, `company_id` NOT NULL →
`companies.id` ON DELETE RESTRICT. Exactly one company per estate; a
company holds any number of estates.
3. `platform_projects` — id, name, slug, `estate_id` NOT NULL →
`estates.id` ON DELETE RESTRICT. Exactly one estate per
platform-project; an estate holds any number of platform-projects.
4. `workspaces` — id, name, slug, `platform_project_id` NOT NULL →
`platform_projects.id` ON DELETE RESTRICT. Exactly one platform-project
per workspace. This table is the referent of every `workspace_id` column
the SOT requires on canonical rows.
5. **Chain resolution is by construction.** Because every parent FK is NOT
NULL and single-valued (one FK column, no parentage edge tables, no
multi-parent forms, no nullable "detached" states), each workspace
resolves to exactly one platform-project → estate → company chain (A1
§8.3 acceptance 1). One-parent-per-child is the constrained direction;
many children per parent is valid data.
6. **Slug scoping.** All `name` and `slug` columns are NOT NULL.
`estates.slug` is unique within its company, `platform_projects.slug`
within its estate, `workspaces.slug` within its platform-project
(composite unique constraints). Display names are unconstrained beyond
NOT NULL.
7. No hierarchy table carries a `metadata` jsonb column or any
free-form payload field. The columns declared in this section and §3
are exhaustive: a class table's column set is exactly its declared set
(verified per §6.2) — nothing else (A1 §8.1.2).
## 3. Grant attachment points
The grant vocabulary (which roles exist, what each permits, how evaluation
and revocation work) is contract 2. This contract pins only the schema
shape contract 2 attaches to:
1. `hierarchy_grants` — id, subject (exactly one of `user_id``users.id`,
`team_id``teams.id`; CHECK-enforced exactly-one-of), target (exactly
one of `company_id`, `estate_id`, `platform_project_id`;
CHECK-enforced exactly-one-of), `role` (text NOT NULL; vocabulary and
its CHECK constraint owned by contract 2 §2), `granted_by` NOT NULL →
`users.id`, created_at.
2. Uniqueness: at most one grant row per (subject, target, role). Because
the subject and target columns are nullable by design, ordinary
PostgreSQL composite uniqueness treats NULLs as distinct and would not
enforce this. The implementation MUST use a single
`UNIQUE NULLS NOT DISTINCT` constraint across (`user_id`, `team_id`,
`company_id`, `estate_id`, `platform_project_id`, `role`) or six
equivalent partial unique indexes (one per subject×target form). The
pinned Drizzle ORM supports `nullsNotDistinct()`.
3. Delete actions are split by column class:
- Target FKs (`company_id`, `estate_id`, `platform_project_id`):
ON DELETE CASCADE — the one permitted cascade in this class. A grant
on a deleted node is meaningless and fail-open if retained. Cascaded
grant deletions are audited per §5.2.
- Principal FKs (`user_id`, `team_id`, `granted_by`): ON DELETE
RESTRICT. The identity contract (§7.3) gates user deletion today and
defines no team-deletion rule; this contract does not invent one.
These FKs stay RESTRICT until an explicit deletion-and-retention
contract ratifies otherwise.
4. Workspace-level access is evaluated, not stored here: a grant at any of
the three levels evaluates down the chain to workspace-scoped
authorization (A1 §8.1.3). No `workspace_id` column exists on
`hierarchy_grants` — workspace membership (REQ-ID-001) remains its own
mechanism inside the SOT schema, and the chain adds where grants can be
declared, never a bypass.
## 4. Ownership and transfer
"Assets are transferable subject to the structure" (PRD Part I §4):
1. A transfer changes exactly one parent FK on exactly one hierarchy row:
workspace → new platform-project, platform-project → new estate, estate
→ new company. Nothing else in the class or the SOT changes: business
and orchestration rows inside affected workspaces are untouched, keep
their `workspace_id`, and never cross a workspace boundary (A1 §8.1.3
"chain maintenance").
2. Transfer authorization requires authority over BOTH the source and the
destination parent. This both-sides predicate is **new policy
introduced by this contract pair** (D2/A1 do not state it); its
evaluation semantics are contract 2 §5. The structural half — that the
transfer command evaluates it before mutating — binds here.
3. A transfer transaction mutates exactly one class-table row — the
single-row parent-FK update — and contains, beyond that, only the
§5.2 audit writes for that mutation (the audit event and its
hierarchy-outbox record, committing in the same transaction). No other
class, business, or orchestration row changes. There are no multi-row
transfer batches at the schema level; bulk moves are N audited
transfers.
4. Hierarchy records have no `owner_id`. Ownership in the hierarchy IS the
grant structure: a "company owner" is a subject with an `owner` grant
on that company or an ancestor (contract 2 §2), not a column. The
ownership invariant across the contract pair: a node may hold zero
direct owner grants (authority can derive from an ancestor grant); node
creation names the initial `owner` grant in the same audited operation
and the wizard seeds the first company's owner the same way (contract 2
§4.3); transfer and revocation semantics are contract 2 §§56. This
avoids column-encoded authority of the kind the legacy schema carries
(`teams.owner_id` and `teams.manager_id` are required user FKs, and
`team_members.role` is a further authority field — none of them
evaluable under a grant model).
## 5. Mutation path, audit, and deletion
1. All hierarchy mutations flow through the same sole-writable-SOT,
fail-closed, audited Gateway command path as everything else (A1 §8.2.3,
REQ-API-001). No direct-DB writers, no raw CRUD endpoints.
2. **Audit parity.** A1 §8.2 leaves every pre-existing REQ binding, so
hierarchy mutations get REQ-AUD-001's guarantees, not a weakened
substitute. Concretely:
- Every create, rename, transfer, grant create/change/revoke, and
delete — including every grant deletion cascaded by a node delete —
emits a semantic audit event carrying actor, verb, target, and (for
transfers) source and destination parents, with the correlation,
causation, idempotency, and per-target ordering guarantees REQ-AUD-001
defines.
- The state change and its audit event(s) commit in the same
transaction, delivered through a transactional outbox. Hierarchy
events are not workspace-scoped rows and do not ride the workspace
outbox; they get an equivalent hierarchy outbox under the same
append-only, same-transaction rules.
- **Deletion-safe linkage:** audit events reference their target by an
immutable snapshot (id, slug, and parent chain at event time), never
by a foreign key into the class tables, so append-only events survive
the deletion of their target.
3. Deletion is fail-closed bottom-up: a hierarchy record with children
cannot be deleted (RESTRICT FKs, §2). Deleting a workspace is a SOT-side
operation subject to the kanban SOT's own rules and is not granted any
new semantics by this contract.
4. **Roll-up is never a write** (A1 §8.2.2, preserved at full strength). A
roll-up read mutates nothing — not hierarchy state, and not business or
orchestration state: it must not mutate, claim, order, or gate
workspace work. Contract 8 owns projection details but cannot narrow
this rule. This contract additionally guarantees the chain roll-ups
aggregate over is unique and non-null (§2.5).
## 6. Verification requirements
Binding on the implementing PRs (extends A1 §8.3):
1. Schema witnesses (real PostgreSQL, §6.8): chain construction — insert
with a null parent FK refused; insert with one valid parent accepted;
two siblings under one parent accepted (the control proving the
constraint rejects only what §2.5 forbids); catalog assertion that each
child table has exactly one parent-FK column and no parentage edge
table exists. Composite slug uniqueness per parent (duplicate slug
under same parent refused; same slug under different parents accepted).
Grant CHECKs: exactly-one-of subject and exactly-one-of target each
witnessed (zero and two set → refused). Grant uniqueness: a duplicate
(subject, target, role) row refused for each of the six subject×target
forms, proving NULLS-NOT-DISTINCT semantics; NOT NULL on `role`,
`granted_by`, and all `name`/`slug` columns witnessed.
2. Column allowlist: an information_schema assertion that each class
table's column set is exactly the set declared in §2/§3 — the bounded
observable for no-payload (§2.7) and no-`owner_id` (§4.4).
3. Command surface: two witnesses, both required (§5.1). (a) Route
inventory: an assertion over the Gateway's registered hierarchy
routes/commands proving the registered mutation surface is exactly the
declared hierarchy command family — no generic CRUD endpoint. (b)
Writer coverage — the closed allowlist a route inventory cannot
provide: a static CI assertion over the Gateway and package sources
with three prongs, each bound to one explicitly enumerated allowlist
of hierarchy command/repository modules. (i) Symbol prong: write
references to the class-table schema symbols (insert, update, delete)
occur only in allowlisted modules. (ii) Literal prong: a class-table
name appearing inside a SQL string or tagged SQL template outside the
allowlist fails the assertion — this is what catches a raw-SQL writer
that references no schema symbol. (iii) Raw-execution prong: any call
to a raw-SQL execution primitive (the ORM's raw/unsafe constructors,
driver-level query/execute) outside the allowlist fails the
assertion, regardless of what the SQL string contains or how it is
constructed — the call site is statically detectable even when a
dynamically assembled table name is not, so a raw writer with a
runtime-built identifier is caught by its primitive, not its
payload. Two composition rules keep prong (iii) meaningful: the
allowlist names hierarchy command/repository modules only — a
generic raw-SQL helper or database-utility module is never
allowlisted; and an allowlisted module MUST NOT export a function
that executes caller-supplied SQL (such an export is itself a
raw-execution primitive, and the exporting module is treated as
unallowlisted for prong (iii) if it does). Legitimate raw execution
that is not a hierarchy writer — e.g. the migration runner in the
db package — lives on a second, separately enumerated
**infrastructure register**, distinct from the writer allowlist and
equally closed. A registered module is exempt from prong (iii) only:
prongs (i) and (ii) apply to it with no exemption, so it can hold no
class-table schema symbol or class-table SQL literal, and it can
never appear on the writer allowlist. To close the laundering path,
the same assertion checks imports, and the import analysis is
**re-export-aware**: it follows package barrels and re-exports, so a
route hidden behind an index module is still a route — and it
resolves literal dynamic imports the same way: an
`await import('<literal specifier>')` is an import edge like any
static import, not an evasion of the analysis (a dynamic import of
the db package whose specifier is not a literal fails the assertion
outright, because it makes the import graph unanalyzable). A
registered module may be imported only by other registered modules
or by importers named on that module's own closed importer
enumeration in the register — operational entry points such as the
migration/bootstrap CLI or the Gateway's startup migration hook.
The enumeration names the complete permitted production consumer
set, and completeness is measured, not asserted: the migration
runner's measured production importer set today has four members —
the Gateway database module (reached through the db package
barrel), the storage package's Postgres adapter, and two mosaic CLI
commands, the fleet-backlog command and the gateway verify command,
both routed through literal dynamic imports of the db package — so
its enumeration names those four. A module that only receives the
runner's functions by parameter injection (the gateway schema-check
module takes them as arguments from the verify command) has no
import edge of its own and is not enumerated. Any import route
outside the enumeration fails the assertion. Being a
named importer confers nothing else: the importer stays fully
subject to prongs (i) and (ii), gains no writer-allowlist standing,
and whether it uses the registered module beyond its operational
purpose is a §5.1 review question, not a static claim. Runtime code-construction
primitives (`eval`, `new Function`) anywhere in the scanned sources
fail the assertion outright, allowlist or not. Schema definitions
and generated migrations are excluded from the literal prong; a
false positive is resolved in the same PR by adding the module to
the one enumerated list its role permits — the writer allowlist for
a hierarchy command/repository module, the infrastructure register
for non-hierarchy raw execution — never by weakening the assertion,
and neither list may take a module the composition rules bar from
it. Both lists are closed, and the assertion's detection
claim is exactly its prongs: it statically surfaces every writer
expressed as a schema-symbol reference, a class-table SQL literal, a
raw-execution call site, or runtime code construction. An evasion
engineered outside those syntactic forms is a §5.1 violation that
review and audit own — the witness does not claim to catch what
static analysis cannot see, and any such evasion found later is
corrected as a conformance defect, not grandfathered.
4. Audit witnesses: for each mutation class (create, rename, transfer,
grant create/change/revoke, delete) — the event exists after commit
with actor/verb/target and same-transaction atomicity; a rolled-back
mutation leaves no event (rollback witness); a node delete's cascaded
grant deletions are each covered by events; events survive deletion of
their target (query the events of a deleted node).
5. Transfer tests: parent-FK update moves the subtree resolution and
modifies zero business/orchestration rows (row-count and content
assertions on workspace contents before/after); transfer without
authority on the source or on the destination side is refused (with
contract 2 §7.8).
6. Deletion tests: delete with children refused at the database level;
delete of a leaf cascades its grants and nothing else; deleting a user
or team that is a grant subject (or `granted_by` referent) is refused
(RESTRICT witnesses for §3.3).
7. Negative tests: no business/orchestration table accepts a company,
estate, platform-project, or grant id in any reference position, and
none accepts a workspace id in any non-tenancy position; the canonical
tenancy FK control — a business row inserted with a valid
`workspace_id` succeeds, with an invalid one is refused; roll-up
endpoints mutate no canonical state anywhere (assert zero writes across
hierarchy AND workspace tables, not hierarchy only); readers see
aggregates only over workspaces they are authorized on, with no
cross-tenant existence oracles (A1 §8.3 acceptance 3).
8. Real-PostgreSQL coverage for every constraint witness (unique/CHECK/
RESTRICT/NULLS NOT DISTINCT behavior), using the `ci-postgres` service
in the `test` CI step; mocked specs cannot witness database constraints.
## Ruling request
Ratify sections 16 as written, with one decision embedded: hierarchy
records carry no owner column — ownership is expressed solely through
grants (§4.4) — say "agreed" or name the ownership model you want.
+84
View File
@@ -372,3 +372,87 @@ The P0P3 canon does not authorize:
## 7. Global release evidence
P0P3 may close only when requirements traceability maps every requirement above to automated and situational evidence, including cross-workspace denials, DB/Valkey fault injection, concurrent leases, stale fencing, generated-file immutability, UI conflict/reconnect behavior, migration reconciliation, independent review, mandatory SecReview, and final Certifier evidence.
## 8. Amendment A1 — hierarchy parentage and RBAC chain above workspaces
**Status:** amendment to the ratified canon, added by reviewed PR under
decision D13 (operator ruling, 2026-08-25; decision owner Jason). It adds
parent structure ABOVE workspaces. Sections 17, every invariant in §3, and
every REQ above remain binding verbatim, with exactly one express modification:
the narrow portfolio-analytics carve-out stated in §8.2.4. Nothing else below
this line is weakened.
### 8.1 What is added
1. A platform hierarchy exists above workspaces:
**company/organization → estate → platform-project → workspace**. Each
workspace belongs to exactly one platform-project, each platform-project to
exactly one estate, each estate to exactly one company.
2. **Record class.** Hierarchy records (company, estate, platform-project,
their parentage edges, and hierarchy-level access grants) are a new,
explicitly named record class: **tenancy/authorization structure records**.
They are not business or orchestration records, so §3 invariant 10 and
REQ-TEN-001 do not apply to them and are not weakened by them — those two
requirements bind business/orchestration rows exactly as before.
Constraints on the new class:
- Hierarchy tables MUST NOT carry task, plan, or any other
business/orchestration payload — parentage, naming, and grant data only.
- A hierarchy record can never be the subject of work: it cannot be
claimed, ordered, gated, or referenced as a dependency by any
business/orchestration row.
- Hierarchy mutations flow through the same sole-writable-SOT, fail-closed,
audited mutation path as everything else (§8.2.3).
3. The hierarchy serves exactly two runtime functions, plus audited
maintenance of its own structure:
- **RBAC evaluation:** access grants are declared per company, estate, or
platform-project and evaluate down the chain to workspace-scoped
authorization. Tenant context continues to be derived from authenticated
authority (REQ-TEN-001); the chain adds where grants can be declared,
not a bypass of workspace authorization.
- **Read-only roll-ups:** task and status visualization bubbles up the
hierarchy as aggregation over workspaces the reader is authorized on.
- **Chain maintenance (not a third runtime function):** re-parenting an
asset — moving a workspace to another platform-project, a
platform-project to another estate, and so on ("assets are transferable
subject to the structure", PRD Part I §4) — is an audited edit of the
hierarchy records themselves under §8.3. It never modifies
business/orchestration rows and never crosses a workspace boundary for
them; the workspace's contents move with the workspace untouched.
4. Naming: this amendment says **platform-project** for the hierarchy level
above workspaces, because §5 REQ-PLAN-001 already defines `projects` as
planning entities INSIDE a workspace. The two are different objects. Final
terminology (rename of one or the other) is an implementation-PR decision
under this amendment's review; the schema MUST NOT merge them.
### 8.2 What is explicitly unchanged
1. `workspace_id` remains the hard mechanical isolation unit (§2 D2,
REQ-TEN-001). Hierarchy tables carry parentage; they do not create
cross-workspace relationships between business/orchestration rows, which
remain rejected (§3 invariant 10).
2. Roll-up is **never a write**: no aggregation path may mutate, claim, order,
or gate work in any workspace. Bubble-up views are generated projections in
the sense of §3 invariant 5 — non-authoritative and never import sources.
3. Fail-closed mutation health (§3 invariants 34), sole writable PostgreSQL
SOT, fencing, audit, and the Coordinator/Certifier authority rules are
untouched.
4. No §6 non-goal is authorized, with one express, narrow carve-out that this
amendment makes to the "portfolio analytics" non-goal: the read-only
roll-up of §8.1 — per-workspace task counts and statuses aggregated up the
parent chain, over workspaces the reader is authorized on — is in scope.
Everything beyond that boundary (metrics, trends, forecasting, scoring,
dashboards computed across workspaces, any derived analytic that is not a
direct count/status aggregation) remains a non-goal. This is an explicit
narrowing by amendment, not a claim that §6 is unchanged; every other §6
non-goal is untouched.
### 8.3 Acceptance (binding on the implementing PRs)
- Schema tests prove each workspace resolves to exactly one
platform-project/estate/company chain and that chain edits are audited.
- Authorization tests prove a grant at each hierarchy level yields exactly the
workspace permissions the chain implies, and that revocation up the chain
propagates.
- Negative tests prove roll-up endpoints cannot mutate state and that a
reader sees aggregates only over workspaces they are authorized on
(no cross-tenant existence oracles).
@@ -0,0 +1,9 @@
ALTER TABLE "accounts" ADD COLUMN "issuer" text;
--> statement-breakpoint
-- Backfill (#1395): better-auth >=1.7 sign-in filters accounts on
-- (provider_id = 'credential' AND issuer = 'local:credential'). Existing
-- credential rows predate the column and would fail that filter on upgraded
-- installs. Credential rows ONLY: better-auth owns issuer semantics for
-- oauth/sso rows going forward (each provider's real issuer value), so those
-- stay NULL until the provider's next flow writes them.
UPDATE "accounts" SET "issuer" = 'local:credential' WHERE "provider_id" = 'credential' AND "issuer" IS NULL;
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -120,6 +120,13 @@
"when": 1784050648841,
"tag": "0016_salty_morlocks",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1787609223282,
"tag": "0017_accounts_issuer",
"breakpoints": true
}
]
}
}
+10 -1
View File
@@ -1,6 +1,15 @@
export { createDb, type Db, type DbHandle } from './client.js';
export { createPgliteDb } from './client-pglite.js';
export { runMigrations, runPgliteMigrations } from './migrate.js';
export {
runMigrations,
runPgliteMigrations,
getMigrationStatus,
readJournalTags,
applyMigrationsByHash,
type HashLedgerDeps,
type MigrationPlanEntry,
type MigrationStatus,
} from './migrate.js';
export * from './schema.js';
export * from './federation.js';
export {
+169
View File
@@ -0,0 +1,169 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
applyMigrationsByHash,
readJournalTags,
type HashLedgerDeps,
type MigrationPlanEntry,
} from './migrate.js';
/* ------------------------------------------------------------------ */
/* In-memory hash-ledger harness */
/* ------------------------------------------------------------------ */
interface LedgerHarness extends HashLedgerDeps {
ledger: Map<string, number>;
/** Recorded statement executions in order: `${hashPrefix}:${stmtIndex}`. */
executed: string[];
/** Optional: statements that should throw when executed. */
failOn?: (migrationHash: string, stmtIdx: number) => boolean;
}
function makeHarness(plan: MigrationPlanEntry[]): LedgerHarness {
const hashToEntry = new Map(plan.map((p) => [p.hash, p]));
const h: LedgerHarness = {
ledger: new Map(),
executed: [],
ensureLedger: async () => {},
appliedHashes: async () => [...h.ledger.keys()],
recordApplied: async (hash, folderMillis) => {
h.ledger.set(hash, folderMillis);
},
runStatement: async (statement) => {
void statement;
// runStatement does not know which migration it belongs to; the
// executed log is filled by the wrapper below.
},
};
// Wrap runStatement so the executed log records migration context. We
// reconstruct context by tracking a cursor the core advances per migration.
let cursor = 0;
const flat: Array<{ hash: string; idx: number }> = [];
for (const m of plan)
for (const [i] of m.statements.entries()) flat.push({ hash: m.hash, idx: i });
h.runStatement = async () => {
const at = flat[cursor] ?? { hash: '??', idx: -1 };
cursor += 1;
if (h.failOn && at.hash !== '??' && h.failOn(at.hash, at.idx)) {
throw new Error(`simulated failure in ${at.hash} #${at.idx.toString()}`);
}
h.executed.push(`${at.hash.slice(0, 6)}:${at.idx.toString()}`);
};
void hashToEntry;
return h;
}
/* ------------------------------------------------------------------ */
/* Fixtures */
/* ------------------------------------------------------------------ */
// Reproduces the REAL journal defect shape (#1402 D1): 0009/0010 carry
// `when` timestamps BELOW 0008's. Under the old drizzle postgres-js
// migrator these were silently skipped on any upgrade whose ledger was
// last stamped in the 0008 era.
const JOURNAL_FIXTURE: MigrationPlanEntry[] = [
{ hash: 'aaaa0000', folderMillis: 1773368153122, statements: ['CREATE TABLE a (id int)'] },
{ hash: 'bbbb0008', folderMillis: 1776822435828, statements: ['CREATE TABLE b (id int)'] },
// Backdated entries, exactly as shipped:
{
hash: 'cccc0009',
folderMillis: 1745280000000,
statements: ['ALTER TYPE t ADD VALUE', "CREATE TABLE c (s t DEFAULT 'pending')"],
},
{ hash: 'dddd0010', folderMillis: 1745366400000, statements: ['CREATE TABLE d (id int)'] },
];
/** A ledger last stamped at the 0008 era: only pre-0009 hashes recorded. */
const LEDGER_AT_0008_ERA = new Map<string, number>([
['aaaa0000', 1773368153122],
['bbbb0008', 1776822435828],
]);
/* ------------------------------------------------------------------ */
/* The core: apply-by-hash in journal order */
/* ------------------------------------------------------------------ */
describe('applyMigrationsByHash', () => {
it('applies backdated journal entries that a timestamp-based migrator would skip (#1402 D1)', async () => {
const h = makeHarness(JOURNAL_FIXTURE);
h.ledger = new Map(LEDGER_AT_0008_ERA);
const result = await applyMigrationsByHash(h, JOURNAL_FIXTURE);
// D1 in one sentence: 0009 and 0010 applied despite folderMillis < 0008.
expect(result).toEqual({ applied: 2, skipped: 2 });
expect(h.ledger.has('cccc0009')).toBe(true);
expect(h.ledger.has('dddd0010')).toBe(true);
});
it('executes statements individually (ALTER TYPE visibility, #1402 D2 shape)', async () => {
const h = makeHarness(JOURNAL_FIXTURE);
await applyMigrationsByHash(h, JOURNAL_FIXTURE);
// 0009's two statements recorded as separate executions, in order.
expect(h.executed).toContain('cccc00:0');
expect(h.executed).toContain('cccc00:1');
expect(h.executed.indexOf('cccc00:0')).toBeLessThan(h.executed.indexOf('cccc00:1'));
});
it('is idempotent: a fully-applied ledger applies nothing', async () => {
const h = makeHarness(JOURNAL_FIXTURE);
const first = await applyMigrationsByHash(h, JOURNAL_FIXTURE);
const second = await applyMigrationsByHash(h, JOURNAL_FIXTURE);
expect(first.applied).toBe(4);
expect(second).toEqual({ applied: 0, skipped: 4 });
expect(h.executed).toHaveLength(5); // 5 statements; second run executed NONE (not 10)
});
it('records no ledger row when a statement fails (crash prefix replays loudly)', async () => {
const h = makeHarness(JOURNAL_FIXTURE);
h.failOn = (hash, idx) => hash === 'cccc0009' && idx === 1;
await expect(applyMigrationsByHash(h, JOURNAL_FIXTURE)).rejects.toThrow(
/cccc0009 statement #1 failed: simulated failure/,
);
// Statement 0 of 0009 executed, but NO ledger row for 0009: the next run
// replays it and fails loudly on "already exists" instead of silently
// believing 0009 applied.
expect(h.ledger.has('cccc0009')).toBe(false);
expect(h.executed).toContain('cccc00:0');
});
it('applies in JOURNAL order, not timestamp order', async () => {
const h = makeHarness(JOURNAL_FIXTURE);
await applyMigrationsByHash(h, JOURNAL_FIXTURE);
// 5 statements total (0009 has two); prefix per migration: journal order,
// so 0009's pair sits between 0008 and 0010.
const order = h.executed.map((e) => e.slice(0, 4));
expect(order).toEqual(['aaaa', 'bbbb', 'cccc', 'cccc', 'dddd']);
});
});
/* ------------------------------------------------------------------ */
/* Journal integrity against the shipped folder */
/* ------------------------------------------------------------------ */
describe('readJournalTags', () => {
it('reads the shipped journal in order and sees the known backdated pair', () => {
const folder = resolve(__dirname, '../drizzle');
const tags = readJournalTags(folder);
expect(tags.length).toBeGreaterThan(0);
// The shipped defect (#1402 D1): these two entries carry April-2025
// timestamps below 0008's June-2026 one. If this assertion ever fails
// because the journal was FIXED (timestamps corrected or drizzle-kit
// regenerated), update #1402 — the hash-ledger core stays correct either
// way; this test pins the shipped reality the core was built for.
const t9 = tags.find((t) => t.startsWith('0009_'));
const t10 = tags.find((t) => t.startsWith('0010_'));
const t8 = tags.find((t) => t.startsWith('0008_'));
expect([t8, t9, t10]).toBeDefined();
const journal = JSON.parse(readFileSync(resolve(folder, 'meta', '_journal.json'), 'utf8')) as {
entries: Array<{ tag: string; when: number }>;
};
const when = new Map(journal.entries.map((e) => [e.tag, e.when]));
if (t8 && t9 && t10) {
expect(when.get(t9)!).toBeLessThan(when.get(t8)!); // backdated below 0008
expect(when.get(t10)!).toBeLessThan(when.get(t8)!); // backdated below 0008
}
});
});
+69
View File
@@ -51,6 +51,75 @@ describe('runPgliteMigrations', () => {
await expect(runPgliteMigrations(handle)).resolves.toBeUndefined();
});
it('gives accounts an issuer column (#1395) — better-auth >=1.7 requires it', async () => {
await runPgliteMigrations(handle);
const result = (await handle.db.execute(sql`
SELECT column_name, is_nullable, data_type
FROM information_schema.columns
WHERE table_name = 'accounts' AND column_name = 'issuer'
`)) as unknown as {
rows: Array<{ column_name: string; is_nullable: string; data_type: string }>;
};
// Nullable by design: the 1.5.x line this repo's lockfile resolves to does
// not write the field; 1.7+ populates it. One schema serves both.
expect(result.rows).toHaveLength(1);
expect(result.rows[0]?.is_nullable).toBe('YES');
expect(result.rows[0]?.data_type).toBe('text');
});
it('backfills ONLY credential rows with the synthetic issuer (#1395 upgrade path)', async () => {
// Simulate an upgraded install: migrate through 0016 only, seed pre-issuer
// rows (one credential, one oauth), then apply 0017 and discriminate.
const client = (handle.db as unknown as { $client: PgliteExec }).$client;
// Migrate to 0016 by replaying every ledger file except 0017 — the ledger
// table gates re-application, so a plain replay of 0000..0016 is enough.
const fs = await import('node:fs');
const path = await import('node:path');
const dir = path.join(import.meta.dirname, '..', 'drizzle');
const files = fs
.readdirSync(dir)
.filter((f) => /^\d{4}_.*\.sql$/.test(f) && f < '0017')
.sort();
for (const f of files) {
const raw = fs.readFileSync(path.join(dir, f), 'utf-8');
for (const stmt of raw.split('--> statement-breakpoint')) {
const trimmed = stmt.trim();
if (trimmed) await client.exec(trimmed);
}
}
await client.exec(`
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
VALUES ('u1', 'Legacy User', '[email protected]', true, now(), now());
INSERT INTO accounts (id, account_id, provider_id, user_id, created_at, updated_at)
VALUES
('a1', '[email protected]', 'credential', 'u1', now(), now()),
('a2', 'oauth-provider-1', 'google', 'u1', now(), now());
`);
// Apply 0017 (column + backfill).
const sql0017 = fs.readFileSync(path.join(dir, '0017_accounts_issuer.sql'), 'utf-8');
for (const stmt of sql0017.split('--> statement-breakpoint')) {
const trimmed = stmt.trim();
if (trimmed) await client.exec(trimmed);
}
const rows = (await handle.db.execute(sql`
SELECT provider_id, issuer FROM accounts ORDER BY id
`)) as unknown as { rows: Array<{ provider_id: string; issuer: string | null }> };
const byProvider = new Map(rows.rows.map((r) => [r.provider_id, r.issuer]));
// Credential rows get better-auth's synthetic local issuer — the value
// sign-in filters on (better-auth dist createLocalAccountIssuer).
expect(byProvider.get('credential')).toBe('local:credential');
// OAuth rows are LEFT NULL: better-auth owns their issuer semantics going
// forward (each provider's real issuer on its next flow).
expect(byProvider.get('google')).toBeNull();
});
it('surfaces statement-level error context on failure and leaves no ledger row', async () => {
// Pre-create a `users` table that conflicts with migration 0000's CREATE TABLE,
// forcing it to fail without IF NOT EXISTS.
+222 -69
View File
@@ -1,8 +1,7 @@
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sql } from 'drizzle-orm';
import { drizzle as drizzlePostgres } from 'drizzle-orm/postgres-js';
import { migrate as migratePostgres } from 'drizzle-orm/postgres-js/migrator';
import { readMigrationFiles } from 'drizzle-orm/migrator';
import postgres from 'postgres';
import { DEFAULT_DATABASE_URL } from './defaults.js';
@@ -21,89 +20,243 @@ function migrationsFolder(): string {
return resolve(here, '../drizzle');
}
/* ------------------------------------------------------------------ */
/* Shared hash-ledger migration core (#1392 / #1402) */
/* ------------------------------------------------------------------ */
//
// Both tiers migrate through this single core, which applies migrations in
// JOURNAL ORDER, one statement at a time, and skips by HASH — never by
// folderMillis timestamp. The previous postgres path delegated to drizzle's
// postgres-js migrator, which:
//
// * applies only migrations with folderMillis > last-applied, silently
// skipping journal entries whose `when` is older than the ledger's newest
// stamp — 0009/0010 carry April-2025 timestamps below 0008's June-2026
// one, so any database last migrated in the 0008 era silently loses
// 0009/0010 forever (#1402 D1); and
// * wraps each migration in ONE transaction, which breaks migrations that
// do `ALTER TYPE ADD VALUE` and then reference the new value in the same
// migration (0009) — Postgres' check_safe_enum_use rejects it (#1402 D2).
//
// Per-statement execution (each statement autocommits) and skip-by-hash fix
// both. The PGlite path has run this way since it was written; this is the
// TODO it left behind, now shared instead of duplicated.
/** One migration as loaded from the shipped drizzle/ folder. */
export interface MigrationPlanEntry {
hash: string;
folderMillis: number;
statements: string[];
}
/** The persistence operations the hash-ledger core needs, per tier. */
export interface HashLedgerDeps {
/** Create the drizzle schema + ledger table if absent (idempotent). */
ensureLedger(): Promise<void>;
/** Hashes already recorded in the ledger. */
appliedHashes(): Promise<string[]>;
/** Record one fully-applied migration in the ledger. */
recordApplied(hash: string, folderMillis: number): Promise<void>;
/** Execute one SQL statement, autocommitting (never inside a wider tx). */
runStatement(statement: string): Promise<void>;
}
function loadPlan(): MigrationPlanEntry[] {
return readMigrationFiles({ migrationsFolder: migrationsFolder() }).map((m) => ({
hash: m.hash,
folderMillis: m.folderMillis,
statements: m.sql.map((s) => s.trim()).filter((s) => s.length > 0),
}));
}
/**
* Apply every unapplied migration in journal order, skipping by hash.
*
* Failure model: each statement autocommits, and the ledger row is written
* only after all statements of a migration succeed. A crash mid-migration
* leaves the prefix applied with no ledger entry, so the next boot replays
* those statements and fails loudly on "already exists". Recovery: drop the
* partially-applied objects, or insert the migration's hash into
* `drizzle.__drizzle_migrations` manually. The thrown error identifies the
* statement and migration that failed.
*/
export async function applyMigrationsByHash(
deps: HashLedgerDeps,
plan: MigrationPlanEntry[] = loadPlan(),
): Promise<{ applied: number; skipped: number }> {
await deps.ensureLedger();
const alreadyApplied = new Set(await deps.appliedHashes());
let applied = 0;
let skipped = 0;
for (const migration of plan) {
if (alreadyApplied.has(migration.hash)) {
skipped += 1;
continue;
}
for (const [stmtIdx, stmt] of migration.statements.entries()) {
try {
await deps.runStatement(stmt);
} catch (err) {
const cause = err instanceof Error ? err.message : String(err);
throw new Error(
`migration hash=${migration.hash} statement #${stmtIdx} failed: ${cause}\n` +
`Statement: ${stmt.slice(0, 200)}${stmt.length > 200 ? '…' : ''}`,
{ cause: err },
);
}
}
await deps.recordApplied(migration.hash, migration.folderMillis);
applied += 1;
}
return { applied, skipped };
}
const LEDGER_DDL = [
'CREATE SCHEMA IF NOT EXISTS drizzle',
`CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at bigint
)`,
];
function connectionString(url?: string): string {
return url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
}
/**
* Apply Drizzle migrations against a postgres database, hash-ledger style.
* Idempotent: re-running against a fully-migrated database applies nothing.
*/
export async function runMigrations(url?: string): Promise<void> {
const connectionString = url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
const sqlClient = postgres(connectionString, { max: 1 });
const db = drizzlePostgres(sqlClient);
const sqlClient = postgres(connectionString(url), { max: 1 });
try {
// TODO: postgres-tier first-install also fails because (a) Drizzle wraps every
// migration in one transaction (breaks 0009's ALTER TYPE ADD VALUE → SET DEFAULT
// sequence) and (b) drizzle/meta/_journal.json has 0009 ordered before 0008,
// which the postgres-js migrator skips by `created_at < folderMillis`. The
// PGlite path below sidesteps both. A follow-up should either share the
// per-statement loop (see runPgliteMigrations) or fix the journal ordering.
await migratePostgres(db, { migrationsFolder: migrationsFolder() });
await applyMigrationsByHash({
ensureLedger: async () => {
for (const ddl of LEDGER_DDL) await sqlClient.unsafe(ddl);
},
appliedHashes: async () => {
const rows = (await sqlClient.unsafe(
'SELECT hash FROM drizzle.__drizzle_migrations',
)) as Array<{ hash: string }>;
return rows.map((r) => String(r.hash));
},
recordApplied: async (hash, folderMillis) => {
await sqlClient.unsafe(
'INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)',
[hash, folderMillis],
);
},
runStatement: async (stmt) => {
await sqlClient.unsafe(stmt);
},
});
} finally {
await sqlClient.end();
}
}
// Apply Drizzle migrations against an embedded PGlite database.
//
// We don't reuse drizzle's pglite migrator because it wraps ALL migrations in
// one outer transaction, which breaks Postgres' `check_safe_enum_use` rule —
// e.g. migration 0009 does `ALTER TYPE ADD VALUE 'pending'` then references
// `'pending'` as a default in the same tx. PGlite's `exec()` runs each
// statement under the Simple Query protocol, autocommitting between them.
//
// We still write to the standard `drizzle.__drizzle_migrations` ledger so the
// result is interoperable with `runMigrations()` on a postgres-backed deploy
// (modulo the journal-ordering bug noted above).
//
// We skip-by-hash rather than skip-by-folderMillis (which is what Drizzle's
// postgres-js migrator does). That's deliberate — out-of-order timestamps in
// `_journal.json` won't silently drop migrations.
//
// Failure model: each statement autocommits, and the ledger row is written
// only after all statements in a migration succeed. A crash mid-migration
// leaves the prefix applied with no ledger entry, so the next boot will
// replay those statements and fail loudly on "already exists". Recovery:
// drop the partially-applied objects, or insert the migration's hash into
// `drizzle.__drizzle_migrations` manually. The error log identifies which
// statement of which migration was the culprit.
/**
* Apply Drizzle migrations against an embedded PGlite database.
*
* We don't reuse drizzle's pglite migrator for the same reasons as the
* postgres path (single-transaction wrap; folderMillis skip). PGlite's
* `exec()` runs each statement under the Simple Query protocol,
* autocommitting between them exactly the semantics the shared core needs.
*
* The ledger rows this writes are interoperable with the postgres path (same
* schema, same hashes), because both consume the same shipped migrations.
*/
export async function runPgliteMigrations(handle: DbHandle): Promise<void> {
const client = (handle.db as unknown as { $client?: PgliteExecutor }).$client;
if (!client || typeof client.exec !== 'function') {
throw new Error('runPgliteMigrations: handle.db is not backed by a PGlite client');
}
await client.exec('CREATE SCHEMA IF NOT EXISTS drizzle');
await client.exec(`
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at bigint
)
`);
await applyMigrationsByHash({
ensureLedger: async () => {
for (const ddl of LEDGER_DDL) await client.exec(ddl);
},
appliedHashes: async () => {
const rows = (await handle.db.execute(
sql`SELECT hash FROM drizzle.__drizzle_migrations`,
)) as unknown as ExecuteRows<{ hash: string }>;
return rows.rows.map((r) => String(r.hash));
},
recordApplied: async (hash, folderMillis) => {
await handle.db.execute(
sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (${hash}, ${folderMillis})`,
);
},
runStatement: async (stmt) => {
await client.exec(stmt);
},
});
}
const appliedRows = (await handle.db.execute(
sql`SELECT hash FROM drizzle.__drizzle_migrations`,
)) as unknown as ExecuteRows<{ hash: string }>;
const applied = new Set(appliedRows.rows.map((r) => r.hash));
/* ------------------------------------------------------------------ */
/* Migration status (#1392: the installer must VERIFY, not assume) */
/* ------------------------------------------------------------------ */
const migrations = readMigrationFiles({ migrationsFolder: migrationsFolder() });
for (const migration of migrations) {
if (applied.has(migration.hash)) continue;
/** Read the journal tags (migration folder names) in journal order. */
export function readJournalTags(folder: string = migrationsFolder()): string[] {
const journal = JSON.parse(readFileSync(resolve(folder, 'meta', '_journal.json'), 'utf8')) as {
entries?: Array<{ tag?: string }>;
};
return (journal.entries ?? []).map((e) => e.tag ?? '').filter((t) => t.length > 0);
}
// Run each statement-breakpoint chunk in its own exec() call so PGlite
// commits between statements — this is what lets `ALTER TYPE ADD VALUE`
// become visible before a subsequent statement references the new value.
for (const [stmtIdx, stmt] of migration.sql.entries()) {
const trimmed = stmt.trim();
if (!trimmed) continue;
try {
await client.exec(trimmed);
} catch (err) {
const cause = err instanceof Error ? err.message : String(err);
throw new Error(
`runPgliteMigrations: migration hash=${migration.hash} statement #${stmtIdx} failed: ${cause}\n` +
`Statement: ${trimmed.slice(0, 200)}${trimmed.length > 200 ? '…' : ''}`,
{ cause: err },
);
}
export interface MigrationStatus {
/** Hashes recorded in the database's ledger (0 if no ledger exists). */
appliedCount: number;
/** Migrations shipped in this package's drizzle/ folder. */
expectedCount: number;
/** The tag (folder name) of the last journal entry, for error messages. */
expectedLastTag: string;
/** True iff every shipped migration's hash is in the ledger. */
complete: boolean;
}
/**
* Report whether a postgres database carries the full shipped schema.
*
* Read-only apart from `ensureLedger` semantics: it never creates the ledger
* (unlike the migrators), so a database with NO ledger reports
* appliedCount=0 / complete=false the exact #1392/#1389 signature (an
* install whose dependency set shipped no migrations at all).
*/
export async function getMigrationStatus(url?: string): Promise<MigrationStatus> {
const sqlClient = postgres(connectionString(url), { max: 1 });
try {
const plan = loadPlan();
const tags = readJournalTags();
const expectedHashes = new Set(plan.map((m) => m.hash));
let appliedHashes: string[] = [];
const regRows = (await sqlClient.unsafe(
"SELECT to_regclass('drizzle.__drizzle_migrations') AS reg",
)) as Array<{ reg: string | null }>;
if (regRows[0]?.reg) {
const rows = (await sqlClient.unsafe(
'SELECT hash FROM drizzle.__drizzle_migrations',
)) as Array<{ hash: string }>;
appliedHashes = rows.map((r) => String(r.hash));
}
await handle.db.execute(
sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (${migration.hash}, ${migration.folderMillis})`,
);
const appliedSet = new Set(appliedHashes);
return {
appliedCount: appliedHashes.length,
expectedCount: plan.length,
expectedLastTag: tags[tags.length - 1] ?? '',
complete:
plan.length > 0 &&
plan.every((m) => appliedSet.has(m.hash)) &&
// A ledger with entries OUTSIDE the shipped plan means the database
// came from a different (e.g. newer) build — not "complete" either.
appliedHashes.every((h) => expectedHashes.has(h)),
};
} finally {
await sqlClient.end();
}
}
+6
View File
@@ -63,6 +63,12 @@ export const accounts = pgTable(
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
// better-auth >=1.7 requires an issuer on every account row: credential
// sign-up writes the synthetic 'local:credential', OAuth rows carry the
// provider's real issuer, and sign-in filters on (providerId, issuer).
// Nullable because the 1.5.x line this repo's lockfile resolves to does
// not know the field — 1.5 ignores it, 1.7 populates it (#1395).
issuer: text('issuer'),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
@@ -23,8 +23,14 @@ absent. Do not use raw `tmux send-keys` for fleet messaging.
```bash
tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ...
tools/git/ci-queue-wait.sh --purpose push|merge # REQUIRED before any push/merge
tools/git/repo-decl.sh # shared .mosaic/repo.json consumption lib (sourced)
```
**Reviewer grants** — `tools/git/grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]` adds a
review seat to an org repo through an org team (Gitea only; code read + issues/pulls write, verified
by read-back). Team approvals do not count as official under branch protection unless the team is
whitelisted — see the tool header.
**GITEA_LOGIN gotcha** — the wrappers default to login `mosaicstack`; on a USC repo that fails with
`gitea / Error: GetUserByName ... not found`. Pick the login from the repo's `origin` host first:
@@ -49,6 +49,10 @@ supply it explicitly on any host where the provider CLI's default account is an
| `milestone-list.sh` | List milestones |
| `milestone-close.sh` | Close a milestone |
| Access grants | |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `grant-reviewer.sh` | Grant a review seat on an org repo via an org team (Gitea only): code read + issues/pulls write, verified by read-back. Team approvals count as official only if branch protection whitelists the team — see the tool header |
| Gates and guards | |
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) |
@@ -111,7 +115,7 @@ approve path carries the trap.) `pr-review.sh` sends the correct token for the d
Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict
placed.
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. For a repository with no CI configured at all, `pr-merge.sh --no-ci-expected` is the sanctioned merge path: it forwards to `ci-queue-wait.sh --no-ci-expected`, which reclassifies a zero-context merge head as queue-clear only when the acting token holds repository admin and `MOSAIC_GIT_IDENTITY` names the asserting identity (a caller without one is refused with exit 78 before the admin lookup), and records the assertion (or its refusal) in the same JSONL audit log. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
### Code Review (Codex)
@@ -219,6 +223,23 @@ Multi-instance support: `-a <instance>` selects a named instance (e.g. `personal
~/.config/mosaic/tools/health/stack-health.sh -f json
```
### Repo Structure Declaration (T51)
```bash
# Validate a .mosaic/repo.json declaration (schema v1/v2, host:/ grammar,
# ref grammar, cross-field rules, remote normalization; spec §5)
~/.config/mosaic/tools/structure/validate-repo-json.sh <repo>/.mosaic/repo.json
# CI authoring rule: new/edited declarations must be schema_version 2
~/.config/mosaic/tools/structure/validate-repo-json.sh <repo>/.mosaic/repo.json --require-v2
# Display mode (warns and omits root-dependent checks when MOSAIC_HOST_ROOT unset)
~/.config/mosaic/tools/structure/validate-repo-json.sh <repo>/.mosaic/repo.json --mode display
# Hermetic hostile-input suite (101 arms)
~/.config/mosaic/tools/structure/test-validate-repo-json.sh
```
### Shared Credential Loader
```bash
@@ -11,7 +11,17 @@ PartOf=mosaic-tmux-holder.service
# launcher would fail the unit. A skipped unit is the honest state for "enabled
# but not yet configured"; systemd re-evaluates the condition on every start, so
# the seat comes up on the next start once the reconciler has written env.
ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated
#
# #1408: the reconciler writes projections into the BRAIN home when one is
# active (~/.mosaic/fleet/agents, mirroring start-agent-session.sh's brain-home
# resolution), and into MOSAIC_HOME on a legacy single-tree host. A single
# config-home condition therefore skipped every seat on brain-home estates —
# measured on two estates: 27 projections vs 0, and 5 vs 0, gate never fired.
# Two TRIGGERING conditions (the `|` prefix ORs same-type conditions, which
# otherwise AND): either shape arms the unit; the launcher still resolves the
# authoritative copy itself.
ConditionPathExists=|%h/.config/mosaic/fleet/agents/%i.env.generated
ConditionPathExists=|%h/.mosaic/fleet/agents/%i.env.generated
[Service]
Type=oneshot
@@ -32,6 +32,17 @@ if grep -qF -- '/bin/bash -lc' "$HOLDER"; then
fail "holder must not start tmux through a login shell"
fi
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
# #1408: the projection condition must arm on EITHER home shape. Both lines must
# carry the `|` triggering prefix — same-type conditions without it AND together,
# which can never be true (one file cannot exist at two paths), so a bare-spelling
# regression would disable autostart everywhere while reading as "has a condition".
grep -qF 'ConditionPathExists=|%h/.config/mosaic/fleet/agents/%i.env.generated' "$AGENT" || \
fail "agent lacks triggering condition for the config home projection"
grep -qF 'ConditionPathExists=|%h/.mosaic/fleet/agents/%i.env.generated' "$AGENT" || \
fail "agent lacks triggering condition for the brain home projection (#1408)"
if grep -qE '^ConditionPathExists=[^|]' "$AGENT"; then
fail "agent has a non-triggering ConditionPathExists — same-type conditions AND, re-arming #1408"
fi
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
if grep -qE '^Environment(File)?=' "$AGENT" "$INTERACTION"; then
fail "agent units must not accept ambient or projection environment before strict parsing"
@@ -379,8 +379,54 @@ check_fleet_transport() {
fi
}
check_structure_anchor_provisioning() {
# T51 WP0b (spec §1.2a + PHASE2-MAP F7): audit the two declaration anchors.
# Doctor runs from operator shells and CI where the launcher exports do not
# exist, so this is an AUDIT ONLY — it never exports, writes, or fabricates
# values for consumption. Four states (charter):
# both present+nonempty PASS (values reported as paths only)
# one missing/empty WARN naming the var + the launcher as authority
# neither present INFORMATIONAL launcher-equivalent derivation,
# explicitly non-authoritative, + launcher warning;
# never an error by design (F7(b))
# Severity follows the doctor's existing conventions: pass/note are quiet
# (note unless --verbose), warn counts toward --fail-on-warn.
local host_root="${MOSAIC_HOST_ROOT:-}" brain_home="${MOSAIC_BRAIN_HOME:-}"
# T51P2WP0BRW B1: presence is tracked SEPARATELY from value — `${VAR:-}`
# collapses exported-empty into genuinely-unset, which mis-filed both-empty
# and the mixed empty/unset states as informational. Only BOTH-genuinely-
# absent may be informational (charter state 3); any present-but-empty or
# single-present state warns.
local host_set=0 brain_set=0
[[ -v MOSAIC_HOST_ROOT ]] && host_set=1
[[ -v MOSAIC_BRAIN_HOME ]] && brain_set=1
if [[ "$host_set" -eq 1 && "$brain_set" -eq 1 && -n "$host_root" && -n "$brain_home" ]]; then
pass "Structure anchors provisioned: MOSAIC_HOST_ROOT=$host_root MOSAIC_BRAIN_HOME=$brain_home (paths reported only; not expanded, not consumed)"
return
fi
if [[ "$host_set" -eq 0 && "$brain_set" -eq 0 ]]; then
note "Structure anchors not provisioned in this environment. Launcher-equivalent derivation (INFORMATIONAL, NON-AUTHORITATIVE — seats receive the authoritative values from the launchers): MOSAIC_HOST_ROOT would default to the operator home; MOSAIC_BRAIN_HOME would default to the brain tree resolved at launch. Doctor does not guess values for consumption; it audits provisioning."
note "Provision both anchors via the seat launchers (launch-seat.sh / launch-seat-claude.sh export them; see T51 spec §1.2a)."
return
fi
# At least one variable is present (possibly empty), or exactly one exists:
# every missing/empty anchor gets its own loud WARN naming the launchers.
if [[ "$host_set" -eq 0 ]]; then
warn "MOSAIC_HOST_ROOT is not set in this environment while MOSAIC_BRAIN_HOME is — declaration consumers fail closed without it (spec §1.2a). The seat launchers are the authoritative source."
elif [[ -z "$host_root" ]]; then
warn "MOSAIC_HOST_ROOT is present but EMPTY in this environment — declaration consumers fail closed without a usable value (spec §1.2a). The seat launchers are the authoritative source."
fi
if [[ "$brain_set" -eq 0 ]]; then
warn "MOSAIC_BRAIN_HOME is not set in this environment while MOSAIC_HOST_ROOT is — the projects/ mirror and brain declaration resolve from it (spec §1.2a). The seat launchers are the authoritative source."
elif [[ -z "$brain_home" ]]; then
warn "MOSAIC_BRAIN_HOME is present but EMPTY in this environment — the projects/ mirror and brain declaration resolve from it (spec §1.2a). The seat launchers are the authoritative source."
fi
}
check_fleet_transport
check_structure_anchor_provisioning
check_brain_home
# Legacy migration surfaces should no longer contain symlink trees.
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Covers the structure-anchor provisioning check in `mosaic-doctor` (T51 WP0b).
#
# Same discipline as test-brain-home-check.sh: functions are extracted from the
# shipped script (exact header + closing brace), never copied — a test carrying
# its own copy of the logic keeps passing after the shipped copy changes.
#
# Four contract states (charter T51P2WP0B-20260824):
# 1. both present+nonempty -> pass ([OK]), no warns, no notes
# 2a. host missing, brain set -> warn naming MOSAIC_HOST_ROOT + launchers
# 2b. brain missing, host set -> warn naming MOSAIC_BRAIN_HOME + launchers
# 2c. present-but-EMPTY counts as missing (warns; NEVER informational)
# 3. neither present -> informational notes, NON-AUTHORITATIVE, never warn
# Arms include genuinely-UNSET (env -u) forms, not only empty strings.
# Red control: empty-vs-unset distinction removed in a mutated copy -> suite red.
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname "$0")" && pwd)
DOCTOR="$SCRIPT_DIR/mosaic-doctor"
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR"
extract_function() {
local name="$1"
local extracted
extracted=$(sed -n "/^${name}() {/,/^}/p" "$DOCTOR")
[ -n "$extracted" ] || fail "could not extract ${name}() from mosaic-doctor — script reshaped?"
printf '%s\n' "$extracted"
}
for fn in check_structure_anchor_provisioning; do
extract_function "$fn" >/dev/null
done
# run_case LABEL EXPECT(ok|warn|note) [env assignments as args; -u VAR tokens for unset]
run_case() {
local label="$1" expect="$2"
shift 2
local envs=() unsets=()
local a
for a in "$@"; do
case "$a" in
-u:*) unsets+=("${a#-u:}") ;;
*) envs+=("$a") ;;
esac
done
local out warns notes oks
# build the env command with proper -u flags (array expansion must not
# glue '-u VAR' into one word)
local cmd=(env)
local e u
# env(1) parses options only before the first assignment — -u flags FIRST
for u in "${unsets[@]:-}"; do [ -n "$u" ] && cmd+=(-u "$u"); done
for e in "${envs[@]:-}"; do [ -n "$e" ] && cmd+=("$e"); done
cmd+=(bash -c "warn() { echo \"[WARN] \$*\"; }; note() { echo \"[NOTE] \$*\"; return 0; }; pass() { echo \"[OK] \$*\"; return 0; }; $(extract_function check_structure_anchor_provisioning); check_structure_anchor_provisioning")
out=$("${cmd[@]}" 2>&1)
warns=$(printf '%s\n' "$out" | grep -c '^\[WARN\]' || true)
notes=$(printf '%s\n' "$out" | grep -c '^\[NOTE\]' || true)
oks=$(printf '%s\n' "$out" | grep -c '^\[OK\]' || true)
if [[ "$expect" == ok && "$oks" -gt 0 && "$warns" -eq 0 && "$notes" -eq 0 ]]; then
echo "ok - $label"
elif [[ "$expect" == warn && "$warns" -ge 1 && "$notes" -eq 0 ]]; then
echo "ok - $label (warned x$warns)"
elif [[ "$expect" == note && "$notes" -gt 0 && "$warns" -eq 0 ]]; then
echo "ok - $label (noted)"
else
echo "output: $out" >&2
fail "$label: expected $expect (oks=$oks warns=$warns notes=$notes)"
fi
}
ROOT=$(mktemp -d)
trap 'rm -rf "$ROOT"' EXIT
HOST="$ROOT/host"
BRAIN="$ROOT/brain"
# ── state 1: both present + nonempty → pass ────────────────────────────────
run_case "both anchors present passes" ok \
MOSAIC_HOST_ROOT="$HOST" MOSAIC_BRAIN_HOME="$BRAIN"
# ── state 2a: host missing (unset), brain set → exactly one warn ───────────
run_case "unset host root warns" warn \
-u:MOSAIC_HOST_ROOT MOSAIC_BRAIN_HOME="$BRAIN"
# ── state 2b: brain missing (unset), host set → exactly one warn ───────────
run_case "unset brain home warns" warn \
MOSAIC_HOST_ROOT="$HOST" -u:MOSAIC_BRAIN_HOME
# ── state 2c-empty: present-but-empty counts as missing ────────────────────
run_case "empty-string host root warns (empty != set)" warn \
MOSAIC_HOST_ROOT= MOSAIC_BRAIN_HOME="$BRAIN"
run_case "empty-string brain home warns (empty != set)" warn \
MOSAIC_HOST_ROOT="$HOST" MOSAIC_BRAIN_HOME=
# ── state 3: neither present (genuinely unset) → notes, never warn ─────────
run_case "both unset yields non-authoritative notes" note \
-u:MOSAIC_HOST_ROOT -u:MOSAIC_BRAIN_HOME
run_case "both empty-string warns (empty is present, not absent)" warn \
MOSAIC_HOST_ROOT= MOSAIC_BRAIN_HOME=
run_case "host empty + brain unset warns" warn \
MOSAIC_HOST_ROOT= -u:MOSAIC_BRAIN_HOME
run_case "host unset + brain empty warns" warn \
-u:MOSAIC_HOST_ROOT MOSAIC_BRAIN_HOME=
# ── red control (mutation): presence tracking removed → red ────────────────
# Mutant regresses to the reviewed defect shape: presence derived from
# NONEMPTINESS (the `${VAR:-}` collapse) instead of true -v tracking. Both-empty
# then looks genuinely-absent and is mis-filed as informational; the both-empty
# warn arm above finds no WARN and the suite reds.
MUT="$ROOT/mosaic-doctor.mutant"
sed 's/\[\[ -v MOSAIC_HOST_ROOT \]\] \&\& host_set=1/[[ -n "${MOSAIC_HOST_ROOT:-}" ]] \&\& host_set=1/; s/\[\[ -v MOSAIC_BRAIN_HOME \]\] \&\& brain_set=1/[[ -n "${MOSAIC_BRAIN_HOME:-}" ]] \&\& brain_set=1/' \
"$DOCTOR" > "$MUT"
if cmp -s "$DOCTOR" "$MUT"; then
echo "SKIP red control (mutation anchor not found — sed pattern drifted)" >&2
else
mut_fn=$(sed -n "/^check_structure_anchor_provisioning() {/,/^}/p" "$MUT")
outm=$(env MOSAIC_HOST_ROOT= MOSAIC_BRAIN_HOME= bash -c \
"warn() { echo \"[WARN] \$*\"; }; note() { echo \"[NOTE] \$*\"; return 0; }; pass() { echo \"[OK] \$*\"; return 0; }; $mut_fn; check_structure_anchor_provisioning" 2>&1)
if printf '%s\n' "$outm" | grep -q '^\[NOTE\]'; then
echo "ok - red control bites (mutant collapses empty into informational; shipped does not)"
else
fail "red control did not reproduce the regression shape (mutant output unexpected)"
fi
fi
echo "structure anchor doctor check: all arms passed"
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
# seat-logins.sh — project seat credentials into tea's login config.
#
# Issue: mosaicstack/stack#1356 (tea login resolution fails open).
#
# WHY THIS EXISTS. tea 0.14.0 has no --token on its operations; it can only use a
# login already stored in ~/.config/tea/config.yml. So the wrappers cannot read the
# seat secrets dir on the tea path. The secrets dir stays authoritative and this
# script projects it into tea's config, which is a DERIVED CACHE: regenerate it,
# never hand-edit it. Same shape as the config-registry projector, same reason —
# a third-party tool that cannot read our store has to be fed.
#
# Canonical login name is "<instance>-<seat>", which is what the identity ladder in
# detect-platform.sh computes from the seat name. A login the ladder cannot compute
# is a fail-open surface, so an ad-hoc name is a defect, not a style.
#
# COLLISIONS. tea refuses to store one token under two names ("token already been
# used, delete login 'X' first"). A hand-made alias holding a seat's token there-
# fore BLOCKS its canonical name. Detected up front by hashing, so a dry run shows
# it; --adopt resolves it by deleting the alias and re-minting canonically. Same
# token, same access, only the label changes.
#
# Tokens are never printed, never logged, and never passed on a visible command
# line beyond tea's own --token, which is unavoidable with this client. tea's
# stderr is echoed on failure with any token-shaped string redacted.
#
# Usage:
# seat-logins.sh # dry run, all seats (default: changes nothing)
# seat-logins.sh --apply # mint/refresh all seats
# seat-logins.sh --seat <seat> # limit to one seat
# seat-logins.sh --apply --adopt # also rename ad-hoc aliases to canonical names
set -euo pipefail
BRAIN_HOME="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
TEA_CONFIG="${TEA_CONFIG:-$HOME/.config/tea/config.yml}"
APPLY=0
ADOPT=0
ONLY_SEAT=""
# Instance -> server URL.
#
# Instances are named here because there is no registry to read them from yet.
# Override per-instance without editing this file, which is how a deployment adds
# its own hosts: MOSAIC_GITEA_URL_<INSTANCE>=https://...
declare -A INSTANCE_URL=(
[mosaicstack]="https://git.mosaicstack.dev"
[usc]="https://git.uscllc.com"
)
while [ $# -gt 0 ]; do
case "$1" in
--apply) APPLY=1; shift ;;
--adopt) ADOPT=1; shift ;;
--seat) ONLY_SEAT="${2:?--seat needs a name}"; shift 2 ;;
-h|--help) sed -n '2,33p' "$0"; exit 0 ;;
*) echo "seat-logins.sh: unknown argument '$1'" >&2; exit 2 ;;
esac
done
command -v tea >/dev/null || { echo "seat-logins.sh: tea not on PATH" >&2; exit 1; }
url_for() {
local inst="$1" ovr
ovr="MOSAIC_GITEA_URL_$(printf '%s' "$inst" | tr '[:lower:]-' '[:upper:]_')"
if [ -n "${!ovr:-}" ]; then printf '%s' "${!ovr}"; return 0; fi
printf '%s' "${INSTANCE_URL[$inst]:-}"
}
# Redact anything token-shaped before any tea output reaches a log.
redact() { sed -E 's/[A-Za-z0-9]{30,}/<REDACTED>/g'; }
# token sha256 -> login name, for every login tea already holds. This is what
# makes collisions visible in a DRY RUN instead of only as an apply-time error.
declare -A TOKEN_OWNER=()
if [ -r "$TEA_CONFIG" ]; then
while read -r sha lname; do
[ -n "${sha:-}" ] && TOKEN_OWNER["$sha"]="$lname"
done < <(python3 - "$TEA_CONFIG" <<'PY'
import sys, yaml, hashlib
try:
cfg = yaml.safe_load(open(sys.argv[1])) or {}
except Exception:
sys.exit(0)
for l in (cfg.get('logins') or []):
t = l.get('token')
if t:
print(hashlib.sha256(t.encode()).hexdigest(), l.get('name'))
PY
)
fi
minted=0; refreshed=0; skipped=0; failed=0; planned=0; adopted=0; blocked=0
existing="$(tea login list --output simple 2>/dev/null | awk '{print $1}' || true)"
shopt -s nullglob
for tokfile in "$BRAIN_HOME"/fleet/agents/*/secrets/gitea-*.token; do
seat="${tokfile#"$BRAIN_HOME"/fleet/agents/}"; seat="${seat%%/*}"
[ -n "$ONLY_SEAT" ] && [ "$seat" != "$ONLY_SEAT" ] && continue
base="$(basename "$tokfile" .token)" # gitea-<instance>-<seat>
inst="${base#gitea-}"; inst="${inst%-"$seat"}"
name="${inst}-${seat}"
url="$(url_for "$inst")"
if [ -z "$url" ]; then
echo " SKIP $name — no URL known for instance '$inst' (set MOSAIC_GITEA_URL_${inst^^})"
skipped=$((skipped+1)); continue
fi
if [ ! -r "$tokfile" ]; then
echo " SKIP $name — token not readable"
skipped=$((skipped+1)); continue
fi
action="mint"
grep -qx "$name" <<<"$existing" && action="refresh"
# Is this exact token already stored under some OTHER name?
tsha="$(sha256sum < "$tokfile" | awk '{print $1}')"
owner="${TOKEN_OWNER[$tsha]:-}"
collision=""
[ -n "$owner" ] && [ "$owner" != "$name" ] && collision="$owner"
if [ "$APPLY" -eq 0 ]; then
if [ -n "$collision" ]; then
if [ "$ADOPT" -eq 1 ]; then
echo " PLAN adopt $collision -> $name ($url)"
else
echo " BLOCK $name — token already stored as '$collision'; re-run with --adopt"
blocked=$((blocked+1)); continue
fi
else
echo " PLAN $action $name -> $url"
fi
planned=$((planned+1)); continue
fi
if [ -n "$collision" ]; then
if [ "$ADOPT" -eq 0 ]; then
echo " BLOCK $name — token already stored as '$collision'; re-run with --adopt"
blocked=$((blocked+1)); continue
fi
tea login delete "$collision" >/dev/null 2>&1 || true
action="adopt"
fi
# tea has no idempotent add; refresh is delete-then-add so a rotated token lands.
[ "$action" = refresh ] && tea login delete "$name" >/dev/null 2>&1 || true
if err="$(tea login add --name "$name" --url "$url" \
--token "$(cat "$tokfile")" --no-version-check 2>&1 >/dev/null)"; then
case "$action" in
mint) minted=$((minted+1)) ;;
refresh) refreshed=$((refreshed+1)) ;;
adopt) adopted=$((adopted+1)) ;;
esac
if [ "$action" = adopt ]; then
echo " OK adopt $collision -> $name ($url)"
else
echo " OK $action $name -> $url"
fi
else
# A failure here is real information: the seat's token is dead, or the server
# refused it. Do not paper over it; the seat cannot act until it is reminted.
# tea's own words, redacted — a summarised FAIL hides whether the cause is the
# credential or the client, which cost a diagnosis on 2026-08-21.
echo " FAIL $action $name -> $url"
echo " tea: $(printf '%s' "$err" | redact | head -1)"
failed=$((failed+1))
fi
done
echo
if [ "$APPLY" -eq 0 ]; then
echo "dry run: $planned login(s) would be written, $skipped skipped, $blocked blocked."
[ "$blocked" -gt 0 ] && echo "re-run with --adopt to rename ad-hoc aliases to canonical names."
echo "no changes made. re-run with --apply."
else
echo "minted=$minted adopted=$adopted refreshed=$refreshed skipped=$skipped blocked=$blocked failed=$failed"
fi
[ "$failed" -eq 0 ] && [ "$blocked" -eq 0 ]
@@ -257,8 +257,36 @@ assert_owned_tmux_server() {
fail "tmux server ownership or environment validation failed"
}
# Validate exact server ownership before querying, cleaning, or creating any
# managed session. An unmanaged or contaminated named socket is never repaired.
# Lease-broker socket preflight (#1292). The gated runtime (`mosaic yolo …` →
# launch-runtime.py) registers with the broker or dies ~4 seconds in, with the
# diagnostic invisible because tmux destroys the dead pane. This check runs
# BEFORE any tmux effect — including the ownership probe below — so a host
# without a broker produces a named, surviving refusal instead of a doomed
# pane. Exit 75 (EX_TEMPFAIL), distinct from 64 (bad projection) and 69 (host
# not ready for other reasons); the agent@ unit is Type=oneshot with no
# Restart=, so the failed unit keeps its message instead of looping. Socket
# resolution matches launch.ts's defaultLeaseBrokerSocket precedence exactly.
# This preflight DETECTS and REFUSES — it never starts the broker (activation
# belongs to the fleet control plane; a component that both detects and fixes
# cannot be used to measure whether the fix worked).
broker_socket_path() {
if [ -n "${MOSAIC_LEASE_BROKER_SOCKET:-}" ]; then
printf '%s\n' "$MOSAIC_LEASE_BROKER_SOCKET"
return 0
fi
local runtime_dir="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
printf '%s\n' "${runtime_dir}/mosaic-lease/broker.sock"
}
if [ "$MODE" = "launch" ]; then
_broker_socket=$(broker_socket_path)
if [ ! -S "$_broker_socket" ]; then
echo "[fleet] FAIL_LAUNCH broker-absent: lease broker socket ${_broker_socket} missing; runtime launch denied (#1292)." >&2
echo "[fleet] remedy: systemctl --user enable --now mosaic-lease-broker.service (or reinstall via: mosaic fleet install)" >&2
exit 75
fi
fi
assert_owned_tmux_server
if [ "$MODE" = interaction ]; then
@@ -276,6 +304,17 @@ if [ "$MODE" = stop ]; then
exit 0
fi
# #1408 hazard: a seat still living on the DEFAULT tmux socket is invisible to the
# declared-socket guard below, and launching over it creates a same-name duplicate that
# name-addressed comms delivery cannot tell apart. Refuse with a distinct code (76,
# after 75 broker-absent) so a cutover wave script can branch on "seat still on legacy
# socket" vs "already running" (0) vs "broker absent" (75). Stopping the legacy session
# belongs to the cutover procedure, never to this launcher.
if [ -n "$MOSAIC_TMUX_SOCKET" ] && tmux has-session -t "=${AGENT_NAME}" 2>/dev/null; then
echo "[fleet] FAIL_LAUNCH seat-on-legacy-socket: session '${AGENT_NAME}' exists on the DEFAULT tmux socket; stop it before launching on '${MOSAIC_TMUX_SOCKET}'." >&2
exit 76
fi
if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}"
exit 0
@@ -393,9 +432,22 @@ if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
fi
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
# #1408 hazard: prefer the seat's own launch.sh when the brain provides one. It is the
# path that binds the auth profile (CLAUDE_SECURESTORAGE_CONFIG_DIR) and seeds the seat
# config; `mosaic yolo` relocates CLAUDE_CONFIG_DIR to the seat dir (launch.ts
# activeSeatDir/harnessEnv) but performs neither, so a yolo-launched seat points its
# config at a directory holding no credentials. The env -i allowlist below still
# applies: launch.sh reads its own launch.env.
SEAT_LAUNCH="${BRAIN_HOME}/fleet/agents/${AGENT_NAME}/launch.sh"
if [ -x "$SEAT_LAUNCH" ]; then
LAUNCH_COMMAND=("$SEAT_LAUNCH")
echo "[fleet] launch path: seat launch.sh ($SEAT_LAUNCH)"
else
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
echo "[fleet] launch path: mosaic yolo (no executable seat launch.sh)"
fi
# The tmux holder owns a named server. Explicitly clear the pane environment
# so server/session variables cannot cross the launch boundary; retain only
@@ -0,0 +1,222 @@
#!/usr/bin/env bash
# CI-fit regression suite for the #1292 lease-broker socket preflight in
# start-agent-session.sh.
#
# WHY THIS SUITE IS CI-FIT WHERE test-start-agent-session.sh IS NOT (#1017/#1270
# context): that older suite's precondition is "the host does not have the pi
# binary", which a CI image that ships pi violates — its guard correctly
# refuses to report a pass there, so it is excluded from the chain. THIS suite
# controls its own preconditions instead of inheriting them from the host: a
# fake tmux on PATH, a fake mosaic on PATH, a real unix socket created in a
# tmpdir, a hermetic env (env -i, fake HOME, GIT_CONFIG_GLOBAL severed). It
# never depends on what the host has installed, so a green here means the same
# thing on every host. Anyone adding cases: keep that property — no case may
# depend on host state.
#
# The failure this suite is written down to catch (#1292): a seat launched on a
# host with no lease broker dies ~4 seconds in at registration, with the
# diagnostic invisible because tmux destroys the dead pane. The preflight runs
# BEFORE any tmux effect and refuses with a NAMED code (exit 75, EX_TEMPFAIL)
# so the message survives. The agent@ unit is Type=oneshot with no Restart=,
# so a failed unit keeps its output instead of looping.
#
# Cases:
# 1. absent socket -> exit 75, message names broker-absent + socket path +
# remedy, and NO tmux session was ever created (the doomed-pane half).
# 2. present socket (real unix socket in tmpdir) -> proceeds PAST the
# preflight (the suite then stops at the next precondition, proving the
# preflight was not the refusal).
# 3. explicit MOSAIC_LEASE_BROKER_SOCKET wins over XDG_RUNTIME_DIR default.
# 4. --stop mode does NOT require the broker (teardown must not be fenced on
# a component whose absence is exactly what teardown may follow).
#
# Sabotage control, run by the developer (not in-suite): remove the preflight
# block from start-agent-session.sh, re-run — case 1 fails (a tmux session is
# created / exit is not 75), cases 2-4 still pass; restore byte-identically.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/agent-session-broker-preflight}"
FAKE_HOME="$WORK_DIR/home"
BIN_DIR="$WORK_DIR/bin"
ENV_DIR="$WORK_DIR/env"
SOCK_DIR="$WORK_DIR/sockets"
LOG_FILE="$WORK_DIR/tmux-calls.log"
rm -rf "$WORK_DIR"
# The script asserts a managed directory tree under MOSAIC_HOME: mosaic/,
# mosaic/fleet/, mosaic/fleet/agents/ — private (0700/0750-style) modes, no
# symlinks — plus a per-agent env projection. Build the full tree the launcher
# expects so the suite reaches the BROKER preflight rather than dying at
# environment validation.
mkdir -p "$FAKE_HOME/.config/mosaic/fleet/agents" "$BIN_DIR" "$SOCK_DIR"
chmod 700 "$FAKE_HOME/.config/mosaic" "$FAKE_HOME/.config/mosaic/fleet/agents"
chmod 750 "$FAKE_HOME/.config/mosaic/fleet"
cat > "$FAKE_HOME/.config/mosaic/fleet/agents/preflight-test.env.generated" <<'ENVEOF'
MOSAIC_AGENT_NAME=preflight-test
MOSAIC_GIT_IDENTITY=preflight-test
MOSAIC_AGENT_CLASS=worker
MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=
MOSAIC_AGENT_REASONING=
MOSAIC_AGENT_TOOL_POLICY=code
MOSAIC_AGENT_WORKDIR=/tmp
MOSAIC_TMUX_SOCKET=mosaic-fleet
ENVEOF
chmod 600 "$FAKE_HOME/.config/mosaic/fleet/agents/preflight-test.env.generated"
# ─── Fake tmux: records every invocation; new-session marks the marker. ────
: > "$LOG_FILE"
cat > "$BIN_DIR/tmux" <<SH
#!/usr/bin/env bash
printf 'tmux %s\n' "\$*" >> "$LOG_FILE"
if [[ "\$*" == *new-session* ]]; then
echo "TMUX-NEW-SESSION-INVOKED" >> "$LOG_FILE"
fi
exit 0
SH
chmod +x "$BIN_DIR/tmux"
# ─── Fake mosaic/pi binaries so the script proceeds past its own lookups. ───
for bin in mosaic pi claude; do
printf '#!/usr/bin/env bash\nexit 0\n' > "$BIN_DIR/$bin"
chmod +x "$BIN_DIR/$bin"
done
# ─── Minimal launch environment the script expects. ────────────────────────
# (Enough for the preflight to be reached; later stages will still fail in
# case 2 — that is expected and asserted.)
run_session_script() {
local mode="$1"; shift
(
cd "$WORK_DIR"
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:/usr/bin:/bin" \
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
MOSAIC_HOME="$FAKE_HOME/.config/mosaic" \
AGENT_NAME=preflight-test \
"$@" \
bash "$SCRIPT_DIR/start-agent-session.sh" $mode preflight-test
)
}
fail=0
assert() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" != "$actual" ]]; then
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
fail=1
fi
}
assert_contains() {
local desc="$1" haystack="$2" needle="$3"
[[ "$haystack" == *"$needle"* ]] || { echo "FAIL: $desc — missing '$needle' in: $haystack" >&2; fail=1; }
}
assert_not_contains() {
local desc="$1" haystack="$2" needle="$3"
if [[ "$haystack" == *"$needle"* ]]; then
echo "FAIL: $desc — must not contain '$needle'" >&2
fail=1
fi
return 0
}
# ─── 1. Absent socket → named refusal, NO tmux session. ────────────────────
: > "$LOG_FILE"
stderr_file="$WORK_DIR/stderr-1.tmp"
set +e
out=$(run_session_script "" MOSAIC_LEASE_BROKER_SOCKET="$SOCK_DIR/absent.sock" 2>"$stderr_file")
rc=$?
set -e
assert "absent socket exit code" "75" "$rc"
err=$(cat "$stderr_file")
assert_contains "absent socket names the failure" "$err" "FAIL_LAUNCH broker-absent"
assert_contains "absent socket names the socket path" "$err" "$SOCK_DIR/absent.sock"
assert_contains "absent socket names a remedy" "$err" "mosaic fleet install"
log1=$(cat "$LOG_FILE")
assert_not_contains "absent socket must not create a tmux session" "$log1" "TMUX-NEW-SESSION-INVOKED"
# ─── 2. Present socket → passes the preflight. ─────────────────────────────
# Expected: ownership/env checks AFTER the preflight may refuse (fixture is
# minimal by design); the assertion is only that the refusal is NOT
# broker-absent and the exit is NOT 75.
# Create a REAL unix socket: a detached python holder binds it and stays alive
# for the duration (bash cannot create sockets; a foreground python would
# close the socket on exit and -S on a closed-but-unlinked path fails). Written
# as a script file + setsid nohup so no job-control/heredoc interaction with
# set -e can silently kill the suite.
# AF_UNIX binds cap at 108 path bytes; the suite's workdir exceeds that, so
# the live socket lives at a SHORT path under /tmp (unique per run, cleaned
# with the suite). The preflight takes its socket path explicitly, so this
# stays fully controlled.
# A real unix socket at a SHORT absolute path (AF_UNIX limit is 108 bytes,
# so the repo-deep SOCK_DIR cannot host it). The name is composed, not
# `mktemp -u`: the CI image's mktemp dialect rejects that invocation
# (pipeline 2562: "mktemp: : Invalid argument"), and no pre-existing file is
# wanted anyway — the holder binds it fresh.
LIVE_SOCK="/tmp/mosaic-preflight-$RANDOM-$$.sock"
trap 'rm -f "$LIVE_SOCK"' EXIT
rm -f "$SOCK_DIR/live.sock" "$LIVE_SOCK"
cat > "$SOCK_DIR/holder.py" <<'PY'
import socket, sys, time
path = sys.argv[1]
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.bind(path)
s.listen(1)
time.sleep(120)
PY
python3 "$SOCK_DIR/holder.py" "$LIVE_SOCK" >/dev/null 2>"$SOCK_DIR/holder.err" &
HOLDER_PID=$!
# Wait for the socket object to exist (bind is near-instant, but do not race it).
for _ in $(seq 1 50); do
[ -S "$LIVE_SOCK" ] && break
sleep 0.1
done
if [ ! -S "$LIVE_SOCK" ]; then
echo "FAIL: could not create live socket fixture (holder pid $HOLDER_PID)" >&2
ps -p "$HOLDER_PID" -o pid,stat,cmd --no-headers >&2 || echo "(holder exited)" >&2
cat "$SOCK_DIR/holder.err" >&2 || true
exit 1
fi
: > "$LOG_FILE"
set +e
out=$(run_session_script "" MOSAIC_LEASE_BROKER_SOCKET="$LIVE_SOCK" 2>"$WORK_DIR/stderr-2.tmp")
rc=$?
set -e
# The preflight PASSED if the failure (whatever later stage refused) is NOT
# the broker refusal, and tmux was reached or a later precondition named
# something else.
err2=$(cat "$WORK_DIR/stderr-2.tmp")
assert_not_contains "live socket must not refuse broker-absent" "$err2" "broker-absent"
if [[ "$rc" == "75" ]]; then
echo "FAIL: live socket — preflight still refused (exit 75) with a live socket" >&2
fail=1
fi
# ─── 3. Explicit socket env wins over XDG default. ─────────────────────────
set +e
out=$(run_session_script "" XDG_RUNTIME_DIR="$SOCK_DIR/no-runtime-here" MOSAIC_LEASE_BROKER_SOCKET="$SOCK_DIR/absent2.sock" 2>"$WORK_DIR/stderr-3.tmp")
rc=$?
set -e
assert "explicit env wins (exit 75)" "75" "$rc"
assert_contains "explicit env path named" "$(cat "$WORK_DIR/stderr-3.tmp")" "$SOCK_DIR/absent2.sock"
# ─── 4. --stop is not fenced on the broker. ────────────────────────────────
: > "$LOG_FILE"
set +e
out=$(run_session_script "--stop" MOSAIC_LEASE_BROKER_SOCKET="$SOCK_DIR/absent3.sock" 2>"$WORK_DIR/stderr-4.tmp")
rc=$?
set -e
err4=$(cat "$WORK_DIR/stderr-4.tmp")
assert_not_contains "--stop must not refuse broker-absent" "$err4" "broker-absent"
if [[ "$rc" == "75" ]]; then
echo "FAIL: --stop — exit 75 means teardown was fenced on the broker" >&2
fail=1
fi
kill "$HOLDER_PID" 2>/dev/null || true
if [[ "$fail" -eq 0 ]]; then
echo "start-agent-session lease-broker preflight regression passed"
fi
exit "$fail"
@@ -0,0 +1,216 @@
#!/usr/bin/env bash
# CI-fit regression suite for the #1408 legacy-socket guard in
# start-agent-session.sh.
#
# Same hermeticity contract as test-agent-session-broker-preflight.sh: a fake
# tmux on PATH that scripts its own answers, a real unix socket in a tmpdir so
# the broker preflight passes, env -i with a fake HOME. No case depends on host
# state.
#
# The failure this suite is written down to catch: during a socket cutover a
# seat's session still lives on the DEFAULT tmux socket while the launcher
# targets the named one. The declared-socket has-session check cannot see the
# legacy session (measured 2026-08-24: rc=1, script proceeds), so launch
# creates a same-name duplicate — and comms delivery, which addresses sessions
# by NAME, cannot tell the two apart. The guard refuses with its own code
# (exit 76, after 75 broker-absent) BEFORE any tmux mutation.
#
# Cases:
# 1. legacy session present -> exit 76, message names seat-on-legacy-socket
# + both sockets' roles, and NO tmux session was created.
# 2. legacy session absent -> proceeds PAST the guard (the run then stops at
# a later precondition; asserted: exit != 76, stderr lacks the guard's
# code, proving the guard was not the refusal).
# 3. MOSAIC_TMUX_SOCKET empty (single-socket host) -> guard is inert: the
# default-socket probe must not fire at all.
#
# Sabotage control, run by the developer (not in-suite): remove the guard
# block, re-run — case 1 fails (exit is not 76), cases 2-3 still pass;
# restore byte-identically.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/agent-session-legacy-socket-guard}"
FAKE_HOME="$WORK_DIR/home"
BIN_DIR="$WORK_DIR/bin"
SOCK_DIR="$WORK_DIR/sockets"
LOG_FILE="$WORK_DIR/tmux-calls.log"
LEGACY_FLAG="$WORK_DIR/legacy-session-present"
rm -rf "$WORK_DIR"
mkdir -p "$FAKE_HOME/.config/mosaic/fleet/agents" "$BIN_DIR" "$SOCK_DIR"
chmod 700 "$FAKE_HOME/.config/mosaic" "$FAKE_HOME/.config/mosaic/fleet/agents"
chmod 750 "$FAKE_HOME/.config/mosaic/fleet"
cat > "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-test.env.generated" <<'ENVEOF'
MOSAIC_AGENT_NAME=lsguard-test
MOSAIC_GIT_IDENTITY=lsguard-test
MOSAIC_AGENT_CLASS=worker
MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=
MOSAIC_AGENT_REASONING=
MOSAIC_AGENT_TOOL_POLICY=code
MOSAIC_AGENT_WORKDIR=/tmp
MOSAIC_TMUX_SOCKET=mosaic-fleet
ENVEOF
chmod 600 "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-test.env.generated"
# A projection with NO named socket, for case 3. Same file minus the socket line.
sed '/^MOSAIC_TMUX_SOCKET=/d; s/lsguard-test/lsguard-nosock/' \
"$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-test.env.generated" \
> "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-nosock.env.generated"
echo 'MOSAIC_TMUX_SOCKET=' >> "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-nosock.env.generated"
chmod 600 "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-nosock.env.generated"
# Ownership identity the launcher validates before anything touches tmux:
# a 0600 uuid file plus a tmux global environment that matches it exactly.
mkdir -p "$FAKE_HOME/.config/mosaic/fleet/run"
chmod 750 "$FAKE_HOME/.config/mosaic/fleet/run"
OWNER_UUID="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
printf '%s' "$OWNER_UUID" > "$FAKE_HOME/.config/mosaic/fleet/run/holder-owner"
chmod 600 "$FAKE_HOME/.config/mosaic/fleet/run/holder-owner"
# The exact env block assert_owned_tmux_server expects; the socket value differs
# per case, so cases rewrite it via write_tmux_env before each run.
write_tmux_env() {
printf '%s\n' \
"HOME=$FAKE_HOME" \
'PATH=/usr/bin:/bin' \
"PWD=$FAKE_HOME" \
"MOSAIC_FLEET_OWNER=$OWNER_UUID" \
'MOSAIC_TMUX_HOLDER=_holder' \
"MOSAIC_TMUX_SOCKET=$1" > "$WORK_DIR/tmux-env"
}
# ─── Fake tmux ──────────────────────────────────────────────────────────────
# Scripted answers: a DEFAULT-socket has-session (argv carries no -L) answers
# by the flag file; every named-socket call succeeds (holder present, no
# existing session is fine for these cases since refusal happens first).
cat > "$BIN_DIR/tmux" <<SH
#!/usr/bin/env bash
printf 'tmux %s\n' "\$*" >> "$LOG_FILE"
if [[ "\$*" == *new-session* ]]; then
echo "TMUX-NEW-SESSION-INVOKED" >> "$LOG_FILE"
fi
if [[ "\$*" == *show-environment* ]]; then
cat "$WORK_DIR/tmux-env"
exit 0
fi
if [[ "\$*" == *has-session* ]]; then
# holder session always present; the seat's DEFAULT-socket presence is the
# flag file; the seat is never already-running on the NAMED socket.
[[ "\$*" == *_holder* ]] && exit 0
if [[ "\$1" == "-L" ]]; then exit 1; fi
[[ -e "$LEGACY_FLAG" ]] && exit 0 || exit 1
fi
exit 0
SH
chmod +x "$BIN_DIR/tmux"
for bin in mosaic pi claude; do
printf '#!/usr/bin/env bash\nexit 0\n' > "$BIN_DIR/$bin"
chmod +x "$BIN_DIR/$bin"
done
# Real socket so the #1292 broker preflight passes and the run reaches the guard.
# Same idiom as the broker-preflight suite: AF_UNIX binds cap at 108 path bytes,
# so the socket lives at a SHORT /tmp path held by a detached python holder (a
# foreground bind would close on exit; -S on a closed-but-unlinked path fails).
LIVE_SOCK="/tmp/mosaic-lsguard-$RANDOM-$$.sock"
trap 'rm -f "$LIVE_SOCK"' EXIT
rm -f "$LIVE_SOCK"
cat > "$SOCK_DIR/holder.py" <<'PY'
import socket, sys, time
path = sys.argv[1]
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.bind(path)
s.listen(1)
time.sleep(120)
PY
python3 "$SOCK_DIR/holder.py" "$LIVE_SOCK" >/dev/null 2>"$SOCK_DIR/holder.err" &
for _ in $(seq 1 50); do
[ -S "$LIVE_SOCK" ] && break
sleep 0.1
done
[ -S "$LIVE_SOCK" ] || { echo "FAIL: could not create live socket" >&2; exit 1; }
run_session_script() {
local agent="$1"; shift
(
cd "$WORK_DIR"
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:/usr/bin:/bin" \
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
MOSAIC_HOME="$FAKE_HOME/.config/mosaic" \
MOSAIC_LEASE_BROKER_SOCKET="$LIVE_SOCK" \
"$@" \
bash "$SCRIPT_DIR/start-agent-session.sh" "$agent"
)
}
fail=0
assert() {
local desc="$1" expected="$2" actual="$3"
[[ "$expected" == "$actual" ]] || { echo "FAIL: $desc — expected '$expected', got '$actual'" >&2; fail=1; }
}
assert_contains() {
local desc="$1" haystack="$2" needle="$3"
[[ "$haystack" == *"$needle"* ]] || { echo "FAIL: $desc — missing '$needle'" >&2; fail=1; }
}
assert_not_contains() {
local desc="$1" haystack="$2" needle="$3"
if [[ "$haystack" == *"$needle"* ]]; then
echo "FAIL: $desc — must not contain '$needle'" >&2
fail=1
fi
return 0
}
# ─── 1. Legacy session present → exit 76, no tmux mutation. ─────────────────
write_tmux_env "mosaic-fleet"
: > "$LOG_FILE"; touch "$LEGACY_FLAG"
stderr_file="$WORK_DIR/stderr-1.tmp"
set +e
run_session_script lsguard-test >/dev/null 2>"$stderr_file"
rc=$?
set -e
err=$(cat "$stderr_file")
assert "legacy present exit code" "76" "$rc"
assert_contains "names the failure" "$err" "FAIL_LAUNCH seat-on-legacy-socket"
assert_contains "names the agent" "$err" "lsguard-test"
assert_contains "names the target socket" "$err" "mosaic-fleet"
assert_not_contains "no session created" "$(cat "$LOG_FILE")" "TMUX-NEW-SESSION-INVOKED"
# ─── 2. Legacy session absent → guard is not the refusal. ───────────────────
write_tmux_env "mosaic-fleet"
: > "$LOG_FILE"; rm -f "$LEGACY_FLAG"
stderr_file="$WORK_DIR/stderr-2.tmp"
set +e
run_session_script lsguard-test >/dev/null 2>"$stderr_file"
rc=$?
set -e
err=$(cat "$stderr_file")
if [[ "$rc" == "76" ]]; then
echo "FAIL: legacy absent must not exit 76" >&2; fail=1
fi
assert_not_contains "guard code absent from stderr" "$err" "seat-on-legacy-socket"
# ─── 3. Empty MOSAIC_TMUX_SOCKET → guard inert, no default-socket probe. ────
write_tmux_env ""
: > "$LOG_FILE"; touch "$LEGACY_FLAG" # even with a legacy session present
stderr_file="$WORK_DIR/stderr-3.tmp"
set +e
run_session_script lsguard-nosock >/dev/null 2>"$stderr_file"
rc=$?
set -e
err=$(cat "$stderr_file")
if [[ "$rc" == "76" ]]; then
echo "FAIL: empty socket must never exit 76 (single-socket host)" >&2; fail=1
fi
assert_not_contains "guard code absent on single-socket host" "$err" "seat-on-legacy-socket"
rm -f "$LEGACY_FLAG"
if [[ "$fail" -ne 0 ]]; then
echo "start-agent-session legacy-socket guard regression FAILED" >&2
exit 1
fi
echo "start-agent-session legacy-socket guard regression passed"
+59 -1
View File
@@ -30,7 +30,9 @@ The Gitea API token is **never passed on a curl command line.** An `Authorizatio
### `--login` override
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`).
**With no `--login`, there is no tea lookup at all.** The acting credential is the calling identity's own, resolved by `get_gitea_token` (see "Per-agent Gitea identity" below), and there is deliberately no fallback from it. These wrappers previously _guessed_ a login from the repo host and looked that guess up in the tea config; on a shared-account host the guess resolved to the shared login, so an unqualified call authored its write as that account rather than as the caller. Since `get_gitea_token_for_login` matches by login **name** and performs no authentication check, a dead shared credential still resolved at rc=0 and the identity-aware resolver was never reached. A caller passing no `--login` is asking to act as itself, so `--login` is now the only route to the tea store (#1351). The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
@@ -100,6 +102,36 @@ of their own — `MOSAIC_GIT_IDENTITY=<id>` with a provisioned slot. There is de
environment variable that restores the fallback; one would reintroduce exactly the
substitution this removes.
### The tea path: login resolution (#1356)
The wrappers that go through `tea` (`issue-list.sh`, `pr-list.sh`, `pr-view.sh`,
`lane-brief.sh`, and the tea half of `issue-close.sh`) cannot use a token directly: tea
0.14 only acts as a **login** already stored in `~/.config/tea/config.yml`. Those wrappers
therefore resolve a login name, not a token, and the resolution follows the same identity
as above:
1. Resolve the identity (`MOSAIC_GIT_IDENTITY`, then `git config mosaic.gitIdentity`).
2. Derive the Gitea instance from the repo host (`git.mosaicstack.dev``mosaicstack`,
`git.uscllc.com``usc`), or from the owner when `--repo owner/name` is given.
3. The canonical login is `<instance>-<identity>`. If tea has it, that login acts.
4. If the identity is set but that login is missing, the wrapper **fails closed**: nonzero
exit, empty stdout, and a stderr line naming the login it wanted and the source of the
identity. When `tea` itself is not installed the message says so instead, since "no such
login" would send the reader to create a login they cannot create.
5. With **no identity set**, the old host-default behaviour is unchanged (first login
configured for that host, else the API fallback).
Step 4 replaced a fallback that picked any login configured for the host, which meant a
seat with no login of its own silently acted as whichever seat had configured one. That
satisfied the author≠reviewer gate on paper while one actor held both names.
**Provisioning the logins.** `tools/fleet/seat-logins.sh` projects each seat's token from
its secrets store into tea's config under the canonical name. tea's config is a derived
cache of the secrets store: regenerate it with the script, never hand-edit it. Run it with
`--seat <seat>` for one seat (all seats when omitted), dry-run by default, `--apply` to write. A hand-made
alias holding a seat's token blocks its canonical name (tea refuses one token under two
names); `--adopt` renames it.
### Enabling it for a clone
The framework installer syncs `git-credential-mosaic` to
@@ -125,6 +157,32 @@ otherwise careful never to touch. Because identity is already resolved per-workt
(`mosaic.gitIdentity`), the correct granularity for registering the helper is per-clone
too, so a documented manual step is the right shape here, not a global auto-write.
### Running these tests
`MOSAIC_GIT_IDENTITY` is inherited into each test's sandbox `HOME`, and **the tests disagree
about which value they need**, so no single ambient value passes all 29. Measured on `next` at
`a480ee83`, two full passes differing only in that variable:
| tests | identity exported | identity unset |
| ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | -------------- |
| `gitea-login-resolution`, `issue-comment-readback`, `issue-create-interactive-auth`, `pr-edit`, `pr-merge-gitea-empty-uid`, `pr-metadata-gitea` | **fail** | pass |
| `issue-close-fail-closed` | pass | **fail** |
| remaining 22 | pass | pass |
The six fail because inside a sandbox `HOME` the identity has no `fleet/agents/<id>/`
directory, so it is classified as a **service identity**, its store is unpopulated, and the
resolver correctly refuses with `Refusing to borrow another slot's token`. That is the
documented fail-closed behaviour above, reached from a state the test never intended.
`issue-close-fail-closed` is the mirror image: it asserts that no comment POST is attempted, so
it needs an identity resolving to an empty slot, and with the variable unset the shared account
answers and the POST goes through.
These read as wrapper regressions rather than as environment. Two seats independently
misdiagnosed them as a patch defect while reviewing #1352. Until each test controls its own
value (#1353), `env -u MOSAIC_GIT_IDENTITY` is the closest thing to a clean run at 28/29, with
`issue-close-fail-closed` the expected failure — and **"the suite passes" is not a statement
anyone can make here without naming the ambient value that produced it.**
### PowerShell parity
`detect-platform.ps1`'s Gitea wrappers authenticate through `tea` CLI logins
@@ -1,6 +1,6 @@
#!/bin/bash
# ci-queue-wait.sh - Wait until project CI queue is clear (no running/queued pipeline on branch head)
# Usage: ci-queue-wait.sh [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
# Usage: ci-queue-wait.sh [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status] [--no-ci-expected]
set -euo pipefail
@@ -14,10 +14,11 @@ TIMEOUT_SEC=900
INTERVAL_SEC=15
PURPOSE="merge"
REQUIRE_STATUS=0
NO_CI_EXPECTED=0
usage() {
cat <<EOF
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status] [--no-ci-expected]
Options:
-B, --branch BRANCH Branch head to inspect (default: current branch)
@@ -27,6 +28,7 @@ Options:
-i, --interval SECONDS Poll interval in seconds (default: 15)
--purpose VALUE Log context: push|merge (default: merge)
--require-status Fail if no CI status contexts are present
--no-ci-expected Assert this repository has no CI configured: a merge guard on a zero-context head becomes queue-clear (requires the acting token to hold repository admin); refused with exit 78 when MOSAIC_GIT_IDENTITY is unset or empty
-h, --help Show this help
Examples:
@@ -175,6 +177,50 @@ PY
return 0
}
# Durable audit record for an explicit no-CI assertion event (granted or
# refused). Same JSONL sink and field shape as record_cannot_assert so one
# reader covers all three outcomes; the outcome value distinguishes them.
# rc 70 on an unwritable sink: a merge pass that cannot be audited must not
# be reachable, mirroring record_cannot_assert's refusal of a degraded pass.
record_assertion_event() {
local outcome="$1" reason="$2" asserted_by="$3"
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
echo "Error: could not write ${outcome} audit record (audit directory unavailable at ${audit_log})." >&2
return 70
fi
if ! python3 - "$audit_log" "$outcome" "$reason" "$asserted_by" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
import datetime
import json
import os
import sys
path, outcome, reason, asserted_by, platform, purpose, branch, repo = sys.argv[1:]
record = {
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"outcome": outcome,
"reason": reason,
"platform": platform,
"purpose": purpose,
"branch": branch,
"repo": repo,
"asserted_by": asserted_by,
}
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
try:
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
finally:
os.close(fd)
PY
then
echo "Error: could not write ${outcome} audit record at ${audit_log}; refusing to proceed unaudited." >&2
return 70
fi
return 0
}
github_get_branch_head_sha() {
local owner="$1"
local repo="$2"
@@ -182,6 +228,24 @@ github_get_branch_head_sha() {
gh api "repos/${owner}/${repo}/branches/${branch}" --jq '.commit.sha'
}
# Repository-admin state for the acting credential, GitHub flavor. The
# repository object's permissions.admin is the field; read through the same
# gh CLI the guard already authenticates with. rc 0 = admin, 1 = not admin
# (or field absent), 2 = indeterminate (transport/API failure).
github_repo_admin_state() {
local owner="$1"
local repo="$2"
local perm
if ! perm=$(gh api "repos/${owner}/${repo}" --jq '.permissions.admin' 2>/dev/null); then
return 2
fi
case "$perm" in
true) return 0 ;;
false|null|"") return 1 ;;
*) return 2 ;;
esac
}
github_get_commit_status_json() {
local owner="$1"
local repo="$2"
@@ -306,6 +370,41 @@ gitea_get_commit_status_json() {
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
}
# Repository-admin state for the acting credential, Gitea flavor. The guard's
# existing fetches (branch head, combined status) carry no permissions object
# (measured: neither response includes one), so the elevation check reads the
# repository object's permissions.admin, the one documented carrier of that
# field. rc 0 = admin, 1 = not admin (or field absent), 2 = indeterminate
# (non-200 or unparseable).
gitea_repo_admin_state() {
local host="$1"
local repo="$2"
local token="$3"
local url="https://${host}/api/v1/repos/${repo}"
local resp code body
resp=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token ${token}" -w $'\n%{http_code}' "$url") || return 2
code="${resp##*$'\n'}"
body="${resp%$'\n'*}"
if [[ "$code" != "200" ]]; then
return 2
fi
printf '%s' "$body" | python3 -c '
import json
import sys
try:
payload = json.load(sys.stdin)
except Exception:
raise SystemExit(2)
if not isinstance(payload, dict):
raise SystemExit(2)
permissions = payload.get("permissions")
if not isinstance(permissions, dict) or permissions.get("admin") is not True:
raise SystemExit(1)
raise SystemExit(0)
'
}
while [[ $# -gt 0 ]]; do
case "$1" in
-B|--branch)
@@ -336,6 +435,10 @@ while [[ $# -gt 0 ]]; do
REQUIRE_STATUS=1
shift
;;
--no-ci-expected)
NO_CI_EXPECTED=1
shift
;;
-h|--help)
usage
exit 0
@@ -365,6 +468,10 @@ if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
echo "Error: --purpose must be push or merge." >&2
exit 1
fi
if [[ "$NO_CI_EXPECTED" -eq 1 && "$REQUIRE_STATUS" -eq 1 ]]; then
echo "Error: --no-ci-expected and --require-status contradict each other: one asserts the repository has no CI, the other demands status contexts. Pass at most one." >&2
exit 1
fi
OWNER="unknown"
REPO="unknown"
@@ -396,6 +503,28 @@ if [[ -z "$BRANCH" ]]; then
fi
fi
# T51 WP5b (spec 4.1, review ruling C4/F8): the declaration adds ROUTE CONTEXT only.
# Branch-selection semantics are UNCHANGED — the guard keeps inspecting the
# exact head above. No declaration dependency gates the wait (4.3/DR2 R9:
# blocking here adds a blocker with no safety gain); absence is silent.
# shellcheck source=packages/mosaic/framework/tools/git/ci-queue-wait.sh
if git rev-parse --show-toplevel >/dev/null 2>&1 \
&& [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/repo-decl.sh" ]; then
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/repo-decl.sh"
repo_decl_load
if [[ "$DECL_STATE" == invalid ]]; then
repo_decl_report_invalid
elif [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
route="feature"
if [[ "$BRANCH" == "$DECL_TRUNK" ]]; then
route="trunk (integration head)"
elif [[ "$BRANCH" == "$DECL_RELEASE" ]]; then
route="release branch"
fi
echo "repo-decl: route context flow=$DECL_FLOW trunk=$DECL_TRUNK release=$DECL_RELEASE; guarded head '$BRANCH' is a $route head (spec 4.1)" >&2
fi
fi
if [[ "$PLATFORM" == "github" ]]; then
if ! command -v gh >/dev/null 2>&1; then
record_cannot_assert "github-cli-unavailable"
@@ -484,6 +613,48 @@ while true; do
echo "[ci-queue-wait] queue-clear state=no-status purpose=push branch=${BRANCH}; no queued or running CI."
exit 0
fi
if [[ "$NO_CI_EXPECTED" -eq 1 ]]; then
# Explicit, elevated, audit-visible assertion that this
# repository has no CI to wait on. The zero-context case is
# the ONLY state the flag reclassifies: a pending or failed
# context still holds or fails exactly as without it, and a
# non-admin token is refused rather than trusted.
# The assertion must name an asserting identity: "unknown"
# attributes nothing, so a caller with MOSAIC_GIT_IDENTITY
# unset or empty is refused (exit 78) BEFORE the permission
# lookup -- an unattributable caller never triggers that
# network call.
if [[ -z "${MOSAIC_GIT_IDENTITY:-}" ]]; then
record_assertion_event "ASSERTION_UNATTRIBUTABLE" "actor-unattributable" "unknown" \
|| echo "Warning: could not write the ASSERTION_UNATTRIBUTABLE audit record; the refusal itself stands." >&2
echo "Error: ASSERTION_UNATTRIBUTABLE state=no-status purpose=merge asserted-by=unknown reason=no-ci-expected branch=${BRANCH}; --no-ci-expected requires MOSAIC_GIT_IDENTITY to name the asserting identity and it is unset or empty (exit 78)." >&2
exit 78
fi
ASSERTED_BY="${MOSAIC_GIT_IDENTITY}"
ADMIN_STATE=2
if [[ "$PLATFORM" == "github" ]]; then
if github_repo_admin_state "$OWNER" "$REPO"; then ADMIN_STATE=0; else ADMIN_STATE=$?; fi
else
if gitea_repo_admin_state "$HOST" "$OWNER/$REPO" "$TOKEN"; then ADMIN_STATE=0; else ADMIN_STATE=$?; fi
fi
case "$ADMIN_STATE" in
0)
record_assertion_event "NO_CI_ASSERTED" "no-ci-expected" "$ASSERTED_BY" || exit $?
echo "[ci-queue-wait] queue-clear state=no-status purpose=merge asserted-by=${ASSERTED_BY} reason=no-ci-expected branch=${BRANCH}"
exit 0
;;
1)
record_assertion_event "ASSERTION_REFUSED" "actor-not-repo-admin" "$ASSERTED_BY" \
|| echo "Warning: could not write the ASSERTION_REFUSED audit record; the refusal itself stands." >&2
echo "Error: ASSERTION_REFUSED state=no-status purpose=merge asserted-by=${ASSERTED_BY} reason=no-ci-expected branch=${BRANCH}; --no-ci-expected requires repository admin and the acting token is not an admin of ${OWNER}/${REPO} (exit 77)." >&2
exit 77
;;
*)
record_cannot_assert "repo-permissions-unavailable"
exit $?
;;
esac
fi
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 3
;;
@@ -180,6 +180,66 @@ raise SystemExit(1)
PY
}
# Map a host to the instance prefix used in canonical tea login names
# ("<instance>-<identity>"). This deliberately mirrors the _idpfx case in
# get_gitea_token(): the two credential paths must agree on what a host is called,
# or an agent authenticates as itself on one path and as somebody else on the other.
gitea_instance_for_host() {
case "${1:-}" in
git.uscllc.com) echo usc ;;
git.mosaicstack.dev) echo mosaicstack ;;
*) return 1 ;;
esac
}
# Resolve the acting git identity, same precedence as get_gitea_token() step 0.
# Prints "<identity>\t<source>" so the caller can name the source in an error.
resolve_git_identity() {
local ident src
ident="${MOSAIC_GIT_IDENTITY:-}"
src="MOSAIC_GIT_IDENTITY"
if [[ -z "$ident" ]]; then
ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
src="git config mosaic.gitIdentity"
fi
[[ -n "$ident" ]] || return 1
printf '%s\t%s\n' "$ident" "$src"
}
# Map a repo owner to an instance. Used only by the --repo override path, which
# has an owner and no host. Previously lived inline in lane-brief.sh; one copy so
# the two override callers cannot drift apart.
gitea_instance_for_owner() {
local owner="${1:-}"
owner="${owner%%/*}"
case "$owner" in
usc|USC) echo usc ;;
mosaicstack|mosaic) echo mosaicstack ;;
*) return 1 ;;
esac
}
# Does a login of this name exist at all? The --repo override path cannot check
# host agreement, because it has no host.
tea_login_exists() {
local login_name="$1"
local logins_json
command -v tea >/dev/null 2>&1 || return 1
logins_json=$(tea login list --output json 2>/dev/null) || return 1
TEA_LOGINS_JSON="$logins_json" python3 - "$login_name" <<'PY_INNER'
import json, os, sys
want = sys.argv[1]
try:
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
except Exception:
raise SystemExit(1)
for login in logins if isinstance(logins, list) else []:
if str(login.get("name") or login.get("Name") or "") == want:
raise SystemExit(0)
raise SystemExit(1)
PY_INNER
}
tea_login_matches_host() {
local login_name="$1" host="$2"
local logins_json
@@ -276,6 +336,40 @@ get_gitea_login_for_host() {
fi
fi
# IDENTITY LADDER (#1356). Below this point the old code took the FIRST login
# matching the host, which is not an identity — with 43 logins on a fleet host,
# ~22 match one server, so a seat with no login of its own silently acted as
# whichever happened to be first in ~/.config/tea/config.yml. Gate 16 depends on
# author != reviewer, and borrowing satisfies it mechanically while violating it
# in fact. The token path already refuses to borrow; this is the same refusal.
#
# Enforced ONLY when an identity is resolvable, exactly like get_gitea_token():
# no identity means a human at a terminal, and neither path enforces there.
local ident ident_src inst canon
if IFS=$'\t' read -r ident ident_src < <(resolve_git_identity); then
if inst=$(gitea_instance_for_host "$host"); then
canon="${inst}-${ident}"
if tea_login_matches_host "$canon" "$host"; then
echo "$canon"
return 0
fi
# Say which of the two it is. "No such login" when tea is simply not
# installed is a diagnosis of a cause that was never checked, and it
# sends the reader off to create a login they cannot create.
if ! command -v tea >/dev/null 2>&1; then
echo "Error: git identity '$ident' requested (via $ident_src) for host '$host', but tea is not installed," >&2
echo " so no login can be resolved. Refusing to guess an identity." >&2
return 1
fi
echo "Error: git identity '$ident' requested (via $ident_src) for host '$host', but no tea login named '$canon' exists." >&2
echo " Refusing to borrow another login. Acting as a different identity would satisfy gate 16 mechanically while violating it." >&2
echo " Create it with: ~/.config/mosaic/tools/fleet/seat-logins.sh --apply --seat $ident" >&2
return 1
fi
# Identity known but the host is not a Mosaic instance. Fall through: the
# canonical name is undefined for it, so there is nothing to enforce.
fi
login=$(find_tea_login_for_host "$host" || true)
if [[ -n "$login" ]]; then
echo "$login"
@@ -351,14 +445,49 @@ raise SystemExit(1)
PY
}
# Resolve a login for an explicit --repo override, which supplies an owner and no
# host. Takes "owner" or "owner/repo".
#
# The old body fell through to get_default_tea_login(), which returns the
# default-marked login or, failing that, the first login of ANY host — arbitrary
# identity, chosen by config file order. That is the #1356 fail-open in its worst
# form, because unlike the host path it does not even constrain the server.
get_gitea_login_for_repo_override() {
local login
local owner="${1:-}"
local login ident ident_src inst canon
if [[ -n "${GITEA_LOGIN:-}" ]]; then
echo "$GITEA_LOGIN"
return 0
fi
if IFS=$'\t' read -r ident ident_src < <(resolve_git_identity); then
if inst=$(gitea_instance_for_owner "$owner"); then
canon="${inst}-${ident}"
if tea_login_exists "$canon"; then
echo "$canon"
return 0
fi
# Same split as the host path above (#1357 S1): a missing tea binary
# is not a missing login, and the "create it with" advice cannot be
# followed without tea.
if ! command -v tea >/dev/null 2>&1; then
echo "Error: git identity '$ident' (via $ident_src) requested for owner '${owner%%/*}', but tea is not installed," >&2
echo " so no login can be resolved. Refusing to guess an identity." >&2
return 1
fi
echo "Error: git identity '$ident' (via $ident_src) has no tea login '$canon' for owner '${owner%%/*}'." >&2
echo " Create it with: ~/.config/mosaic/tools/fleet/seat-logins.sh --apply --seat $ident" >&2
return 1
fi
echo "Error: git identity '$ident' (via $ident_src) is set, but owner '${owner%%/*}' maps to no known instance," >&2
echo " so the login name cannot be derived. Refusing to fall back to an arbitrary login." >&2
echo " Set GITEA_LOGIN to name the login explicitly." >&2
return 1
fi
# No identity: a human at a terminal. Unchanged, and the same place the token
# path stops enforcing.
login=$(get_default_tea_login || true)
if [[ -n "$login" ]]; then
echo "$login"
+343
View File
@@ -0,0 +1,343 @@
#!/bin/bash
# grant-reviewer.sh - Grant a reviewer read + review access to an org-owned
# Gitea repository via an org team (default: fleet-reviewers).
#
# Usage: grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]
#
# The team carries `permission: read` with per-unit overrides
# {repo.code: read, repo.issues: write, repo.pulls: write}: the reviewer can
# read code and write issues/PR reviews, but cannot push. The grant is
# idempotent — the team is looked up before it is created, and member/repo
# additions are PUTs.
#
# KNOWN LIMITATION — branch protection counts these reviews as UNOFFICIAL.
# Gitea computes a review's `official` flag at SUBMISSION time, from write
# permission on the repo or from membership in the protected branch's
# approvals whitelist (disabled by default). A team granted through this
# script has read permission on code, so under branch protection with
# required_approvals the reviewer's approval shows but does NOT count toward
# the required total — the merge still fails with "not enough approvals".
# Enabling the approvals whitelist and adding this team to it is review
# policy (who counts as an official approver), an operator decision made in
# the repo's branch-protection settings, deliberately NOT automated here.
# Because `official` is fixed at submission, whitelisting after the fact
# requires the review to be re-submitted before it counts.
#
# Platform: Gitea only. On a GitHub-remoted repo this script refuses to run —
# GitHub review access is granted through collaborator/team facilities that
# have no equivalent to Gitea's org-team unit map.
#
# Identity: the acting credential resolves exactly as in issue-comment.sh —
# GITEA_LOGIN (when set) names a tea login whose token MUST resolve for the
# remote host (fail closed, never downgrade to the host default identity);
# otherwise the per-seat identity ladder in detect-platform.sh applies
# (MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity → per-slot token,
# fail-loud on fleet hosts). Managing org teams requires org owner/admin:
# an HTTP 403 from any step is reported as "org admin required on <org>",
# never as a silent partial grant.
#
# Verification is fail-closed: after the member and repo PUTs, the script
# GETs the single resources back (GET /teams/{id}/members/{user} and
# GET /teams/{id}/repos/{owner}/{repo}) and refuses to report success unless
# both confirm the grant. A PUT that returns success without persisting
# (the #865 defect class: an exit code is not evidence of a durable write)
# therefore fails the run instead of reporting a grant that does not exist.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
usage() {
echo "Usage: grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]"
echo ""
echo "Options:"
echo " -u, --user Gitea username to grant reviewer access (required)"
echo " -r, --repo Target repository as <owner>/<repo>; defaults to the"
echo " current repository's origin. The owner must be an"
echo " organization."
echo " -t, --team Org team to use/create (default: fleet-reviewers)"
echo " -h, --help Show this help"
echo ""
echo "Environment:"
echo " GITEA_LOGIN Override the acting identity with a named tea login"
echo " (must resolve for the remote host; fails closed)."
echo ""
echo "Grants: code read + issues/pulls write via an org team. Gitea only."
echo ""
echo "LIMITATION: under branch protection with required approvals, reviews"
echo "from a read-permission team are official=false and do not count"
echo "toward the required total. Making them count means enabling the"
echo "protected branch's approvals whitelist and adding the team — an"
echo "operator review-policy decision this script does not automate. The"
echo "official flag is computed at review submission, so a review made"
echo "before whitelisting must be re-submitted afterwards."
}
REVIEWER=""
REPO_OVERRIDE=""
TEAM="fleet-reviewers"
while [[ $# -gt 0 ]]; do
case $1 in
-u|--user)
REVIEWER="$2"
shift 2
;;
-r|--repo)
REPO_OVERRIDE="$2"
shift 2
;;
-t|--team)
TEAM="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
if [[ -z "$REVIEWER" ]]; then
echo "Error: reviewer username is required (-u)" >&2
exit 1
fi
# Gitea usernames and team names are AlphaDashDot. Validating here keeps the
# values safe to interpolate into API paths without URL-encoding.
NAME_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$'
if ! [[ "$REVIEWER" =~ $NAME_RE ]]; then
echo "Error: invalid reviewer username '$REVIEWER'" >&2
exit 1
fi
if ! [[ "$TEAM" =~ $NAME_RE ]]; then
echo "Error: invalid team name '$TEAM'" >&2
exit 1
fi
if [[ -n "$REPO_OVERRIDE" ]] && ! [[ "$REPO_OVERRIDE" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
echo "Error: -r expects <owner>/<repo>, got '$REPO_OVERRIDE'" >&2
exit 1
fi
detect_platform >/dev/null
if [[ "$PLATFORM" != "gitea" ]]; then
echo "Error: grant-reviewer.sh is Gitea only (detected platform: $PLATFORM)." >&2
echo " On GitHub, grant review access via repository collaborators or org teams in the GitHub UI/CLI." >&2
exit 1
fi
HOST=$(get_remote_host) || {
echo "Error: could not resolve the remote host from origin" >&2
exit 1
}
# Acting credential: GITEA_LOGIN (explicit, fail closed) or the identity
# ladder. Same ordering contract as issue-comment.sh — an explicit override is
# never silently downgraded to the host default identity.
if [[ -n "${GITEA_LOGIN:-}" ]]; then
GITEA_API_TOKEN=$(get_gitea_token_for_login "$GITEA_LOGIN" "$HOST") || {
echo "Error: could not resolve a host-matched Gitea token for GITEA_LOGIN '$GITEA_LOGIN' on host '$HOST'; refusing to fall back to the host default identity (reviewer grant)" >&2
exit 1
}
else
GITEA_API_TOKEN=$(get_gitea_token "$HOST") || {
echo "Error: no Gitea credential resolved for the acting identity on host '$HOST' (reviewer grant). Set MOSAIC_GIT_IDENTITY=<agent-id>, or set GITEA_LOGIN=<name> to use a named tea credential." >&2
exit 1
}
fi
CONFIGURED_URL=$(get_gitea_url_for_host "$HOST") || {
echo "Error: configured Gitea URL not found for host '$HOST'" >&2
exit 1
}
GITEA_API_ROOT="${CONFIGURED_URL%/}/api/v1"
if [[ -n "$REPO_OVERRIDE" ]]; then
REPO_SLUG="$REPO_OVERRIDE"
else
REPO_SLUG=$(get_gitea_repo_slug_for_url "$CONFIGURED_URL") || {
echo "Error: could not resolve <owner>/<repo> from origin; pass -r <owner>/<repo>" >&2
exit 1
}
fi
ORG="${REPO_SLUG%%/*}"
REPO_NAME="${REPO_SLUG#*/}"
RESPONSE_FILE=$(mktemp "${TMPDIR:-/tmp}/mosaic-grant-reviewer-resp.XXXXXX")
AUTH_CONFIG=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$RESPONSE_FILE"
echo "Error: could not stage Gitea credential for reviewer grant" >&2
exit 1
}
trap 'rm -f "$RESPONSE_FILE" "$AUTH_CONFIG"' EXIT
# gitea_api <step> <method> <path> [json-payload]
# Runs one API call with the staged credential (token never in argv). Sets
# GITEA_API_STATUS and leaves the body in $RESPONSE_FILE. Transport failure
# and HTTP 403 are terminal here: 403 on ANY step means the acting identity
# cannot manage org teams, and the run must stop rather than continue into a
# partial grant.
gitea_api() {
local step="$1" method="$2" path="$3" payload="${4:-}"
local -a payload_args=()
if [[ -n "$payload" ]]; then
payload_args=(-H 'Content-Type: application/json' -d "$payload")
fi
if ! GITEA_API_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
-X "$method" \
--config "$AUTH_CONFIG" \
"${payload_args[@]}" \
"$GITEA_API_ROOT$path"); then
echo "Error: Gitea transport failed during $step" >&2
return 1
fi
if [[ "$GITEA_API_STATUS" == "403" ]]; then
echo "Error: HTTP 403 during $step: org admin required on '$ORG' — managing org teams needs owner/admin on the organization. No grant was completed." >&2
return 1
fi
return 0
}
# json_field <file> <key> — print a top-level scalar field or fail.
json_field() {
python3 - "$1" "$2" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
data = json.load(response)
value = data.get(sys.argv[2]) if isinstance(data, dict) else None
if value is None or isinstance(value, (dict, list, bool)):
raise ValueError(f"missing or non-scalar field {sys.argv[2]!r}")
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Error: unusable Gitea response: {error}", file=sys.stderr)
raise SystemExit(1)
print(value)
PY
}
# 1. The owner must be an organization: teams are an org facility, and a
# user-owned repo would fail later with a misleading team error.
gitea_api "organization check" GET "/orgs/$ORG"
if [[ "$GITEA_API_STATUS" == "404" ]]; then
echo "Error: owner '$ORG' is not an organization on '$HOST'; grant-reviewer requires an org-owned repository" >&2
exit 1
fi
if [[ "$GITEA_API_STATUS" != "200" ]]; then
echo "Error: organization check for '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
exit 1
fi
# 2. Idempotent team resolution: exact-name lookup first, create only on miss.
# The search endpoint substring-matches, so the exact-name filter is done
# on the response, not trusted to the query.
gitea_api "team lookup" GET "/orgs/$ORG/teams/search?q=$TEAM"
if [[ "$GITEA_API_STATUS" != "200" ]]; then
echo "Error: team lookup for '$TEAM' on '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
exit 1
fi
TEAM_ID=$(TEAM_NAME="$TEAM" python3 - "$RESPONSE_FILE" <<'PY'
import json
import os
import sys
wanted = os.environ["TEAM_NAME"]
try:
with open(sys.argv[1], encoding="utf-8") as response:
result = json.load(response)
teams = result.get("data") if isinstance(result, dict) else None
if not isinstance(teams, list):
raise ValueError("team search response carried no data list")
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Error: unusable team search response: {error}", file=sys.stderr)
raise SystemExit(1)
for team in teams:
if isinstance(team, dict) and team.get("name") == wanted:
team_id = team.get("id")
if not isinstance(team_id, int) or team_id <= 0:
print("Error: matched team carried no positive id", file=sys.stderr)
raise SystemExit(1)
print(team_id)
raise SystemExit(0)
print("")
PY
)
if [[ -z "$TEAM_ID" ]]; then
CREATE_PAYLOAD=$(TEAM_NAME="$TEAM" python3 -c '
import json
import os
print(json.dumps({
"name": os.environ["TEAM_NAME"],
"description": "review seats: code read + issues/pulls write",
"permission": "read",
"includes_all_repositories": False,
"can_create_org_repo": False,
"units_map": {
"repo.code": "read",
"repo.issues": "write",
"repo.pulls": "write",
},
}))
')
gitea_api "team create" POST "/orgs/$ORG/teams" "$CREATE_PAYLOAD"
if [[ "$GITEA_API_STATUS" != "201" ]]; then
echo "Error: team create for '$TEAM' on '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
exit 1
fi
TEAM_ID=$(json_field "$RESPONSE_FILE" id) || {
echo "Error: team create returned no usable team id" >&2
exit 1
}
echo "Created team '$TEAM' (id $TEAM_ID) on org '$ORG'"
else
echo "Found existing team '$TEAM' (id $TEAM_ID) on org '$ORG'"
fi
# 3. Membership and repo attachment — both PUTs, both idempotent in Gitea.
gitea_api "member add" PUT "/teams/$TEAM_ID/members/$REVIEWER"
if [[ "$GITEA_API_STATUS" != "204" ]]; then
echo "Error: adding '$REVIEWER' to team '$TEAM' failed with HTTP $GITEA_API_STATUS" >&2
exit 1
fi
gitea_api "repo add" PUT "/teams/$TEAM_ID/repos/$ORG/$REPO_NAME"
if [[ "$GITEA_API_STATUS" != "204" ]]; then
echo "Error: adding repo '$REPO_SLUG' to team '$TEAM' failed with HTTP $GITEA_API_STATUS" >&2
exit 1
fi
# 4. Fail-closed read-back: a 204 from a PUT is an exit code, not evidence the
# grant persisted. GET the single resources back and require both.
gitea_api "member read-back" GET "/teams/$TEAM_ID/members/$REVIEWER"
if [[ "$GITEA_API_STATUS" != "200" ]]; then
echo "Error: reviewer grant NOT verified — GET /teams/$TEAM_ID/members/$REVIEWER returned HTTP $GITEA_API_STATUS after a successful PUT. Treat the grant as not made." >&2
exit 1
fi
READBACK_LOGIN=$(json_field "$RESPONSE_FILE" login) || exit 1
if [[ "${READBACK_LOGIN,,}" != "${REVIEWER,,}" ]]; then
echo "Error: reviewer grant NOT verified — member read-back returned login '$READBACK_LOGIN', expected '$REVIEWER'" >&2
exit 1
fi
gitea_api "repo read-back" GET "/teams/$TEAM_ID/repos/$ORG/$REPO_NAME"
if [[ "$GITEA_API_STATUS" != "200" ]]; then
echo "Error: reviewer grant NOT verified — GET /teams/$TEAM_ID/repos/$ORG/$REPO_NAME returned HTTP $GITEA_API_STATUS after a successful PUT. Treat the grant as not made." >&2
exit 1
fi
READBACK_FULL_NAME=$(json_field "$RESPONSE_FILE" full_name) || exit 1
if [[ "${READBACK_FULL_NAME,,}" != "${REPO_SLUG,,}" ]]; then
echo "Error: reviewer grant NOT verified — repo read-back returned '$READBACK_FULL_NAME', expected '$REPO_SLUG'" >&2
exit 1
fi
echo "Granted: '$REVIEWER' is a member of team '$TEAM' (id $TEAM_ID) with access to '$REPO_SLUG' (code read, issues/pulls write) — verified by read-back"
echo "Note: under branch protection with required approvals this reviewer's approvals are official=false unless the branch's approvals whitelist includes the team (operator decision; reviews submitted before whitelisting must be re-submitted)."
@@ -102,9 +102,17 @@ gitea_resolve_api_for_login() {
return 1
}
else
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for login '$effective_login' (comment write/read-back)" >&2
# NO --login: the acting credential is this identity's own token and there
# is deliberately no tea-config fallback. get_gitea_token_for_login matches
# by login NAME and performs no authentication check, and with no --login
# that name was a HOST GUESS resolving to a shared account. A live shared
# token would therefore have authored every seat's comment as that
# account, making Gate-16 author-is-not-reviewer unenforceable fleet-wide;
# a dead one is only what made the defect visible. get_gitea_token fails
# loud on a fleet host when no identity resolves, and that refusal is the
# correct outcome, not a case to fall back from.
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: no Gitea credential resolved for the acting identity on host '$host' (comment write/read-back). Set MOSAIC_GIT_IDENTITY=<agent-id>, or pass --login <name> to use a named tea credential." >&2
return 1
}
fi
@@ -335,15 +343,12 @@ if [[ "$PLATFORM" == "github" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
# Resolve the login this comment should be attributed to: the --login
# override when given, otherwise the detected default for this repo's host.
# A --login override always wins. Otherwise name this repo host's login only
# as a best effort: the login name merely selects a per-login token, and
# gitea_resolve_api_for_login falls back to the host credential
# (get_gitea_token) when no tea login is named, so the default credential
# still resolves even when the host tea has no matching login entry.
# A --login override selects a NAMED tea credential and is the only way to
# reach the tea store. With no --login there is deliberately no guess: the
# comment is attributed to this identity's own credential, resolved by
# gitea_resolve_api_for_login. The guess this replaced named a SHARED
# account, selecting an identity the caller never asked to act as.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login 2>/dev/null || true)
# Bind the REST endpoint + token to the effective login, then derive the
# acting identity from that SAME credential (GET /user). The write below and
@@ -99,8 +99,8 @@ case "$PLATFORM" in
;;
gitea)
if [[ -n "$REPO_OVERRIDE" ]]; then
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || {
echo "Error: could not resolve a Gitea login for the --repo override (the lines above say why). Set GITEA_LOGIN to name one explicitly." >&2
exit 1
}
else
@@ -1,5 +1,5 @@
#!/bin/bash
# issue-view.sh - View issue details on GitHub or Gitea
# issue-view.sh - View issue details, including comments, on GitHub or Gitea
# Usage: issue-view.sh -i <issue_number>
set -e
@@ -28,11 +28,47 @@ gitea_issue_view_api() {
}
url="https://${host}/api/v1/repos/${repo}/issues/${ISSUE_NUMBER}"
if command -v python3 >/dev/null 2>&1; then
curl -fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -m json.tool
else
curl -fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
local -a curl_args=(-fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}")
if ! command -v python3 >/dev/null 2>&1; then
# No renderer: raw JSON is all this path can give. Comments are a
# second resource, so fetch them too rather than only the count.
curl "${curl_args[@]}" "$url"
curl "${curl_args[@]}" "${url}/comments"
return
fi
# Render issue + comments as text (#1357 F2). The old fallback dumped the
# issue JSON, which carries only a comment COUNT, so every comment body was
# invisible on this path and the wrapper could never show what
# `tea issues --comments` shows.
{
curl "${curl_args[@]}" "$url"
echo
echo "__MOSAIC_COMMENTS__"
curl "${curl_args[@]}" "${url}/comments"
} | python3 -c '
import json, sys
raw = sys.stdin.read()
issue_raw, _, comments_raw = raw.partition("__MOSAIC_COMMENTS__")
issue = json.loads(issue_raw)
comments = json.loads(comments_raw) if comments_raw.strip() else []
print("#%s %s" % (issue["number"], issue["title"]))
print("State: %s Author: %s Created: %s" % (issue["state"], issue["user"]["login"], issue["created_at"]))
labels = ", ".join(l["name"] for l in issue.get("labels") or [])
if labels:
print("Labels: " + labels)
if issue.get("milestone"):
print("Milestone: " + issue["milestone"]["title"])
print("URL: " + issue["html_url"])
print()
print(issue.get("body") or "(no body)")
if comments:
print()
print("--- Comments (%d) ---" % len(comments))
for c in comments:
print()
print("[%s at %s]" % (c["user"]["login"], c["created_at"]))
print(c.get("body") or "")
'
}
while [[ $# -gt 0 ]]; do
@@ -46,6 +82,8 @@ while [[ $# -gt 0 ]]; do
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo ""
echo "Comments are always included (tea --comments / Gitea API /comments)."
echo " -h, --help Show this help"
exit 0
;;
@@ -67,11 +105,30 @@ if [[ "$PLATFORM" == "github" ]]; then
gh issue view "$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
if command -v tea >/dev/null 2>&1; then
if tea issue "$ISSUE_NUMBER" $(get_gitea_repo_args); then
# --comments is what makes tea print the comment bodies (#1357 F3).
# Without it tea prompts for them interactively, which in a
# non-interactive wrapper means they are silently never shown.
tea_err=$(mktemp)
if tea issue "$ISSUE_NUMBER" $(get_gitea_repo_args) --comments 2>"$tea_err"; then
rm -f "$tea_err"
exit 0
fi
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
# Name the cause tea actually reported, not a guessed one (#1357 F1/F4).
# tea reads the cwd's git config before honouring --repo; a repo with
# extensions.worktreeconfig=true makes it exit 1 with a
# repositoryformatversion error. That is a git-config condition, not a
# credential one. The old path printed the REVOKED OR STALE TOKEN note
# here unconditionally, which sent readers to rotate a token that was fine.
if grep -q 'repositoryformatversion' "$tea_err"; then
echo "Warning: tea cannot read this repo's git config (extensions.worktreeconfig); not a credential problem. Using Gitea API fallback." >&2
elif grep -q 'user does not exist' "$tea_err"; then
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
else
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
fi
sed 's/^/ tea: /' "$tea_err" >&2
rm -f "$tea_err"
fi
gitea_issue_view_api
else
@@ -49,11 +49,27 @@ if [[ -z "$LOGIN" ]]; then
if [[ -n "${GITEA_LOGIN:-}" ]]; then
LOGIN="$GITEA_LOGIN"
else
case "${REPO%%/*}" in
usc|USC) LOGIN=usc ;;
mosaicstack|mosaic) LOGIN=mosaicstack ;;
*) LOGIN="$(get_gitea_login_for_repo_override 2>/dev/null || true)" ;;
esac
# #1356: the owner-derived map below picks a SHARED login (bare `usc` /
# `mosaicstack`). On a seat that is borrowing another identity, which is
# exactly what gate 16 forbids. So the identity ladder goes first and the
# map is only the no-identity fallback (a human at a terminal), which is
# where the token path stops enforcing too.
if LOGIN="$(get_gitea_login_for_repo_override "$REPO")"; then
:
elif resolve_git_identity >/dev/null 2>&1; then
# A git identity IS set and the ladder still could not resolve a login.
# The named reason is already on stderr. Falling through to the map here
# would hand this seat a SHARED login (bare `usc` / `mosaicstack`) — the
# identity-borrowing #1356 exists to stop. Fail closed instead.
exit 2
else
# No identity: a human at a terminal. Owner-derived map, unchanged. This
# is the same point at which the token path stops enforcing.
case "${REPO%%/*}" in
usc|USC) LOGIN=usc ;;
mosaicstack|mosaic) LOGIN=mosaicstack ;;
esac
fi
fi
fi
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 2; }
@@ -192,6 +192,47 @@ cmd_new() {
local path; path="$(derive_path "$branch")"
assert_not_home "$path"
# T51 WP5b (spec 4.4 staged rule + 4.5 advisory policy): placement stays
# DERIVED; the declaration never moves the worktree. An INVALID declaration
# fails branch-creation loud (a broken structure file must not ride a new
# branch); an absent one warns (rollout window, Q-C); a valid one contributes
# policy ADVICE only. The advisory worktree_root comparison runs only when
# MOSAIC_HOST_ROOT is set (1.2a: warn-and-omit for advisory display).
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
_rd="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/repo-decl.sh"
if [ -f ""$_rd"" ]; then
source ""$_rd""
repo_decl_load
else
DECL_STATE=absent; DECL_SCHEMA=""
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
repo_decl_report_invalid() { :; }
repo_decl_warn_absent_reversible() { :; }
repo_decl_warn_absent_irreversible() { :; }
repo_decl_remote_matches() { return 0; }
repo_decl_check_transition() { return 2; }
fi
case "$DECL_STATE" in
invalid)
repo_decl_report_invalid
die "worktree branch-creation refused: the structure declaration is invalid (spec 4.4 — fix it before creating branches)"
;;
absent)
repo_decl_warn_absent_irreversible "worktree branch-creation"
;;
valid)
if [ "$DECL_SCHEMA" = 2 ] && [ "$DECL_POLICY" = "orchestrator-precreated" ]; then
echo "repo-decl: worktree_policy=orchestrator-precreated (TRANSITIONAL, spec 4.5): tasking pre-creates worktrees; creating one directly is the interim path until the J3/#1174 amendment unblocks the wrapper consumer." >&2
fi
if [ -n "${MOSAIC_HOST_ROOT:-}" ] && [ -n "$DECL_WT_ROOT" ]; then
dwt="$(repo_decl_path "$DECL_WT_ROOT" 2>/dev/null || true)"
if [ -n "$dwt" ] && [ "${dwt%/}" != "${WT_ROOT%/}" ]; then
echo "repo-decl: derived root $WT_ROOT diverges from the declared advisory worktree_root $dwt (advisory per spec 4.1/5.2 — placement stays derived)" >&2
fi
fi
;;
esac
if [ -e "$path" ]; then
echo "exists: $path"
echo "(already checked out — reuse it, or 'rm' it first)"
@@ -19,6 +19,41 @@ ISSUE=""
# get_remote_host, get_gitea_token, get_repo_info, and get_gitea_repo_args are provided by detect-platform.sh
gitea_default_branch() {
# Forge default branch for the current repo (T51-P2 WP5a / spec E4): the
# API fallback must not guess a base. Empty output or any lookup failure
# returns nonzero so the caller fails loud instead of mistargeting a PR.
local host repo token url body branch
host=$(get_remote_host) || return 1
repo=$(get_repo_info) || return 1
token=$(get_gitea_token "$host") || return 1
url="https://${host}/api/v1/repos/${repo}"
# Fetch and parse as separate steps (T51P2WP5AR B2): a piped
# `curl | python` reports only python's status, so an HTTP failure that
# still emits parseable JSON would masquerade as success. curl's own
# exit status is authoritative here.
if ! body=$(curl -fsS \
-H "User-Agent: curl/8" \
-H "Authorization: token ${token}" \
"$url" 2>/dev/null); then
return 1
fi
# A valid base is a NONBLANK JSON STRING (T51P2WP5AR B3): null, numbers,
# and whitespace-only values are failed resolution, never a POSTed base.
branch=$(printf '%s' "$body" | python3 -c '
import json, sys
try:
value = json.load(sys.stdin).get("default_branch")
except Exception:
sys.exit(1)
if not isinstance(value, str) or not value.strip():
sys.exit(1)
print(value.strip())
' 2>/dev/null) || return 1
[[ -n "$branch" ]] || return 1
printf '%s' "$branch"
}
gitea_pr_create_api() {
local host repo token url payload
host=$(get_remote_host) || {
@@ -38,14 +73,28 @@ gitea_pr_create_api() {
echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
fi
payload=$(TITLE="$TITLE" BODY="$BODY" HEAD_BRANCH="$HEAD_BRANCH" BASE_BRANCH="$BASE_BRANCH" python3 - <<'PY'
# Base resolution (spec E4): an explicit -B always wins; with none, the
# forge default branch is resolved from the provider API -- never the
# historical "main" literal, which mistargeted every fallback PR on
# repos whose trunk is not main (e.g. mosaicstack/stack -> next).
local api_base=""
if [[ -n "$EFFECTIVE_BASE" ]]; then
api_base="$EFFECTIVE_BASE"
else
api_base=$(gitea_default_branch) || {
echo "Error: could not resolve the forge default branch for the API-fallback base; pass -B <branch> explicitly" >&2
return 1
}
fi
payload=$(TITLE="$TITLE" BODY="$BODY" HEAD_BRANCH="$HEAD_BRANCH" API_BASE="$api_base" python3 - <<'PY'
import json
import os
payload = {
"title": os.environ["TITLE"],
"head": os.environ["HEAD_BRANCH"],
"base": os.environ["BASE_BRANCH"] or "main",
"base": os.environ["API_BASE"],
}
body = os.environ.get("BODY", "")
if body:
@@ -72,7 +121,7 @@ Create a pull request on the current repository (Gitea or GitHub).
Options:
-t, --title TITLE PR title (required, or use --issue)
-b, --body BODY PR description/body
-B, --base BRANCH Base branch to merge into (default: main/master)
-B, --base BRANCH Base branch to merge into (default: the forge repository's default branch)
-H, --head BRANCH Head branch with changes (default: current branch)
-l, --labels LABELS Comma-separated labels
-m, --milestone NAME Milestone name
@@ -149,6 +198,52 @@ if [[ -z "$HEAD_BRANCH" ]]; then
HEAD_BRANCH=$(git branch --show-current)
fi
# T51 WP5b: declaration-driven base resolution (spec 4.1). Precedence:
# explicit -B -> validated as an ALLOWED transition (4.2: a flag is input,
# not authority) when a consumable declaration exists
# declared trunk (v2 declarations only) -> used directly
# legacy -> WP5a forge-default floor (unmanaged/absent/v1, 4.3)
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
source "$SCRIPT_DIR/repo-decl.sh"
repo_decl_load
else
DECL_STATE=absent; DECL_SCHEMA=""
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
repo_decl_report_invalid() { :; }
repo_decl_warn_absent_reversible() { :; }
repo_decl_warn_absent_irreversible() { :; }
repo_decl_remote_matches() { return 0; }
repo_decl_check_transition() { return 2; }
fi
EFFECTIVE_BASE="$BASE_BRANCH"
case "$DECL_STATE" in
invalid) repo_decl_report_invalid ;;
esac
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" != 2 ]]; then
repo_decl_warn "declaration is v$DECL_SCHEMA — carries no consumable flow/trunk fields; legacy behavior"
fi
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
# Write path: a normalized-remote mismatch refuses (spec 5.3).
if ! repo_decl_remote_matches; then
echo "Error: origin remote does not match the declared canonical_remote (spec 5.3, write path) — refusing to create a PR against the wrong forge. Fix the origin remote or the declaration." >&2
exit 1
fi
if [[ -n "$BASE_BRANCH" ]]; then
trc=0
repo_decl_check_transition "$HEAD_BRANCH" "$BASE_BRANCH" || trc=$?
if [[ "$trc" == 1 ]]; then
echo "Error: -B '$BASE_BRANCH' is not an allowed transition for head '$HEAD_BRANCH' under the declared flow (spec 4.2). The declaration governs; supply an allowed base." >&2
exit 1
fi
# trc 2 cannot happen here (state=valid): 0 = allowed
else
EFFECTIVE_BASE="$DECL_TRUNK"
fi
elif [[ -z "$BASE_BRANCH" ]]; then
repo_decl_warn_absent_reversible "pr-create"
fi
# Add issue reference to body if provided
if [[ -n "$ISSUE" ]]; then
if [[ -n "$BODY" ]]; then
@@ -166,7 +261,7 @@ case "$PLATFORM" in
github)
CMD=(gh pr create --title "$TITLE")
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
[[ -n "$EFFECTIVE_BASE" ]] && CMD+=(--base "$EFFECTIVE_BASE")
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
@@ -191,7 +286,7 @@ case "$PLATFORM" in
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
[[ -n "$EFFECTIVE_BASE" ]] && CMD+=(--base "$EFFECTIVE_BASE")
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
# Handle labels for tea
@@ -94,8 +94,8 @@ case "$PLATFORM" in
;;
gitea)
if [[ -n "$REPO_OVERRIDE" ]]; then
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || {
echo "Error: could not resolve a Gitea login for the --repo override (the lines above say why). Set GITEA_LOGIN to name one explicitly." >&2
exit 1
}
else
+56 -10
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--no-ci-expected] [--co-author-trailers --escalate-to PRINCIPAL]
set -euo pipefail
@@ -16,6 +16,7 @@ DRY_RUN=false
EXPECT_HEAD=""
CO_AUTHOR_TRAILERS=false
ESCALATE_TO=""
NO_CI_EXPECTED=false
usage() {
cat <<EOF
@@ -29,6 +30,7 @@ Options:
-d, --delete-branch Delete the head branch after merge
--dry-run Run metadata/login preflight without merging
--expect-head SHA Refuse unless the PR head matches this full commit SHA
--no-ci-expected Assert the target repository has no CI: forward --no-ci-expected to the queue guard (requires repository admin)
--co-author-trailers Build verified trailers from linked PR commit authors
--escalate-to NAME Named principal for an unresolved-author BLOCK
-h, --help Show this help message
@@ -70,6 +72,10 @@ while [[ $# -gt 0 ]]; do
EXPECT_HEAD="$2"
shift 2
;;
--no-ci-expected)
NO_CI_EXPECTED=true
shift
;;
--co-author-trailers)
CO_AUTHOR_TRAILERS=true
shift
@@ -131,9 +137,44 @@ HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=js
BASE_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("baseRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
exit 1
# T51 WP5b: transition validation against the declaration (spec 4.1/4.2).
# Target branches are validated against the declaration, NEVER hardcoded;
# the legacy main/next check survives only for undeclared repos during the
# rollout window (4.3 irreversible class, loud warning).
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
source "$SCRIPT_DIR/repo-decl.sh"
repo_decl_load
else
DECL_STATE=absent; DECL_SCHEMA=""
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
repo_decl_report_invalid() { :; }
repo_decl_warn_absent_reversible() { :; }
repo_decl_warn_absent_irreversible() { :; }
repo_decl_remote_matches() { return 0; }
repo_decl_check_transition() { return 2; }
fi
if [[ "$DECL_STATE" == invalid ]]; then
repo_decl_report_invalid
fi
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
if ! repo_decl_remote_matches; then
echo "Error: origin remote does not match the declared canonical_remote (spec 5.3, write path) — refusing to merge against the wrong forge. Fix the origin remote or the declaration." >&2
exit 1
fi
trc=0
repo_decl_check_transition "$HEAD_BRANCH" "$BASE_BRANCH" || trc=$?
if [[ "$trc" == 1 ]]; then
echo "Error: PR '$HEAD_BRANCH' -> '$BASE_BRANCH' is not a declared transition (flow=$DECL_FLOW, trunk=$DECL_TRUNK, release=$DECL_RELEASE; spec 4.2)." >&2
exit 1
fi
echo "repo-decl: transition OK under flow=$DECL_FLOW (trunk=$DECL_TRUNK release=$DECL_RELEASE)" >&2
else
repo_decl_warn_absent_irreversible "pr-merge"
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
exit 1
fi
fi
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
@@ -154,13 +195,18 @@ if [[ "$DRY_RUN" != true ]]; then
if [[ -z "$BASE_REPO" ]]; then
BASE_REPO="$(get_repo_owner)/$(get_repo_name)"
fi
"$SCRIPT_DIR/ci-queue-wait.sh" \
--purpose merge \
-B "$HEAD_BRANCH" \
-R "$BASE_REPO" \
--sha "$HEAD_SHA" \
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
guard_args=(
--purpose merge
-B "$HEAD_BRANCH"
-R "$BASE_REPO"
--sha "$HEAD_SHA"
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}"
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
)
if [[ "$NO_CI_EXPECTED" == true ]]; then
guard_args+=(--no-ci-expected)
fi
"$SCRIPT_DIR/ci-queue-wait.sh" "${guard_args[@]}"
fi
PLATFORM=$(detect_platform)
@@ -12,7 +12,7 @@
# concurrent record cannot masquerade as this write and a no-op fails closed.
#
# --login override: the default login is resolved from the local tea login list
# for this repo's host (get_gitea_login_for_host). Pass --login <name> to
# for this repo's host from the acting identity's own credential. Pass --login <name> to
# override it for this invocation only. The REST write, the /user identity read,
# and every read-back are ALL performed with the token of the EFFECTIVE login,
# so the write and its verification bind to the same identity.
@@ -372,9 +372,17 @@ gitea_resolve_api_for_login() {
return 1
}
else
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for login '$effective_login' (review write/read-back)" >&2
# NO --login: the acting credential is this identity's own token and there
# is deliberately no tea-config fallback. get_gitea_token_for_login matches
# by login NAME and performs no authentication check, and with no --login
# that name was a HOST GUESS resolving to a shared account. A live shared
# token would therefore have authored every seat's review as that
# account, making Gate-16 author-is-not-reviewer unenforceable fleet-wide;
# a dead one is only what made the defect visible. get_gitea_token fails
# loud on a fleet host when no identity resolves, and that refusal is the
# correct outcome, not a case to fall back from.
GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: no Gitea credential resolved for the acting identity on host '$host' (review write/read-back). Set MOSAIC_GIT_IDENTITY=<agent-id>, or pass --login <name> to use a named tea credential." >&2
return 1
}
fi
@@ -698,7 +706,7 @@ if [[ "$PLATFORM" == "github" ]]; then
elif [[ "$PLATFORM" == "gitea" ]]; then
case $ACTION in
approve)
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# Best-effort host for credential resolution only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
@@ -706,15 +714,13 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
# that support running with no usable origin at all).
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
# A --login override selects a NAMED tea credential and is the only
# way to reach the tea store. With no --login there is deliberately no
# guess: gitea_resolve_api_for_login resolves this identity's own token.
# The guess this replaced named a SHARED account, selecting an identity
# the caller never asked to act as. The single resolved token is then
# used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
# Bind the REST endpoint + token to the effective login, then derive
# the acting identity from that SAME credential so the review submit
# and its read-back verify against the identity that performed them.
@@ -735,7 +741,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
echo "Error: Comment required for request-changes"
exit 1
fi
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# Best-effort host for credential resolution only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
@@ -743,15 +749,13 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
# that support running with no usable origin at all).
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
# A --login override selects a NAMED tea credential and is the only
# way to reach the tea store. With no --login there is deliberately no
# guess: gitea_resolve_api_for_login resolves this identity's own token.
# The guess this replaced named a SHARED account, selecting an identity
# the caller never asked to act as. The single resolved token is then
# used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
@@ -766,7 +770,7 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
echo "Error: Comment required"
exit 1
fi
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# Best-effort host for credential resolution only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
@@ -774,15 +778,13 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
# that support running with no usable origin at all).
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
# A --login override selects a NAMED tea credential and is the only
# way to reach the tea store. With no --login there is deliberately no
# guess: gitea_resolve_api_for_login resolves this identity's own token.
# The guess this replaced named a SHARED account, selecting an identity
# the caller never asked to act as. The single resolved token is then
# used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
@@ -59,8 +59,8 @@ if [[ "$PLATFORM" == "github" ]]; then
gh pr view "$PR_NUMBER" --repo "$REPO_INFO"
elif [[ "$PLATFORM" == "gitea" ]]; then
if [[ -n "$REPO_OVERRIDE" ]]; then
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override "$REPO_OVERRIDE") || {
echo "Error: could not resolve a Gitea login for the --repo override (the lines above say why). Set GITEA_LOGIN to name one explicitly." >&2
exit 1
}
else
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env bash
# repo-decl.sh — shared .mosaic/repo.json consumption for the git wrappers (T51 WP5b).
#
# Spec of record: docs/plans/2026-08-23_repo-structure-declaration.md (brain
# repo) sections 4 (consumption contract), 5.3 (normalization), 5.4
# (enforcement points), 1.2a (root anchoring). ALL consumers invoke the SAME
# WP1 validator (spec 5.1 — no in-process-only parsing of the declaration).
#
# Source this file, then call repo_decl_load once. It sets:
# DECL_STATE absent | invalid | valid
# DECL_FILE the declaration path that was inspected
# DECL_ERROR the validator's error line when DECL_STATE=invalid
# DECL_TRUNK / DECL_RELEASE / DECL_FLOW / DECL_REMOTE / DECL_POLICY /
# DECL_WT_ROOT / DECL_CLONE (populated only when DECL_STATE=valid)
# DECL_ORIGIN_N the normalized origin URL (when resolvable)
#
# Enforcement point 5.4(1): an invalid or unknown-version file counts as
# ABSENT for behavior, PLUS a loud error naming the file and the validator's
# key/reason — callers print DECL_ERROR (repo_decl_report_invalid) whenever
# they loaded something that failed validation; they do not silently ignore a
# broken file.
#
# Absence behavior (4.3) is the CALLER's policy (reversible vs irreversible;
# managed vs unmanaged — the adoption register is WP6, so during rollout every
# repo is unmanaged: warn + legacy). Helpers below provide the shared wordings.
#
# Root-dependent fields: consumers here read branch/flow/remote/policy only —
# NO path resolution happens in this library. The one helper that would
# resolve a host:/ path (repo_decl_path) fails closed while MOSAIC_HOST_ROOT
# is unset (1.2a: never guess a root), for any future caller that needs it.
#
# No output on success; diagnostics go to stderr.
REPO_DECL_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DECL_VALIDATOR="$REPO_DECL_SCRIPT_DIR/../structure/validate-repo-json.sh"
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
# Load and classify the declaration for the repo containing the current
# directory. Never fatal — classification is the product.
repo_decl_load() {
DECL_STATE=absent
DECL_FILE=""
DECL_ERROR=""
DECL_SCHEMA=""
DECL_TRUNK=""; DECL_RELEASE=""; DECL_FLOW=""; DECL_REMOTE=""
DECL_POLICY=""; DECL_WT_ROOT=""; DECL_CLONE=""
DECL_ORIGIN_N=""
local root
root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
repo_decl_warn "no git repository — declaration consumption skipped"
return 0
}
DECL_FILE="$root/.mosaic/repo.json"
[ -f "$DECL_FILE" ] || return 0
if [ ! -x "$REPO_DECL_VALIDATOR" ] && [ ! -f "$REPO_DECL_VALIDATOR" ]; then
# 5.1 mandates the shared validator; a missing validator is an
# infrastructure failure, not an absent declaration.
repo_decl_warn "validator not found at $REPO_DECL_VALIDATOR — treating declaration as invalid"
DECL_STATE=invalid
DECL_ERROR="VALIDATION_ERROR validator: the shared validator is missing"
return 0
fi
local vout
if ! vout="$("$REPO_DECL_VALIDATOR" "$DECL_FILE" --mode display 2>&1)"; then
DECL_STATE=invalid
DECL_ERROR="$(printf '%s\n' "$vout" | grep -m1 'VALIDATION_ERROR' || printf '%s\n' "$vout" | head -1)"
return 0
fi
# Valid: extract the consumed fields via the same python stdlib the
# ecosystem already uses. Field-level grammar was the validator's job.
eval "$(python3 - "$DECL_FILE" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
def q(k):
v = d.get(k, "")
return v if isinstance(v, str) else ""
sv = d.get("schema_version", 1)
print(f"DECL_SCHEMA={sv if isinstance(sv, int) and not isinstance(sv, bool) else 0!r}")
print(f"DECL_TRUNK={q('integration_trunk')!r}")
print(f"DECL_RELEASE={q('release_branch')!r}")
print(f"DECL_FLOW={q('flow')!r}")
print(f"DECL_REMOTE={q('canonical_remote')!r}")
print(f"DECL_POLICY={q('worktree_policy')!r}")
print(f"DECL_WT_ROOT={q('worktree_root')!r}")
print(f"DECL_CLONE={q('canonical_clone')!r}")
PY
)" || {
DECL_STATE=invalid
DECL_ERROR="VALIDATION_ERROR internal: field extraction failed"
return 0
}
DECL_STATE=valid
# 5.3: normalize origin once for remote comparisons (read callers warn,
# write callers refuse). An unresolvable origin is left empty — callers
# treat empty as "cannot compare" and act per their read/write policy.
local ourl
if ourl="$(git remote get-url origin 2>/dev/null)" && [ -n "$ourl" ]; then
DECL_ORIGIN_N="$("$REPO_DECL_VALIDATOR" --normalize-remote "$ourl" 2>/dev/null || true)"
fi
return 0
}
# The mandatory loud error for an invalid file (5.4 point 1). Callers invoke
# this whenever DECL_STATE=invalid, regardless of their proceed/refuse policy.
repo_decl_report_invalid() {
repo_decl_warn "declaration INVALID at $DECL_FILE$DECL_ERROR"
repo_decl_warn "treating the declaration as ABSENT (spec 5.4); legacy behavior follows"
}
# Shared absence wordings (4.3, rollout window: no adoption register yet, so
# every repo is unmanaged; warn + legacy per the Q-C ruling).
repo_decl_warn_absent_reversible() { # $1 = operation name
repo_decl_warn "no .mosaic/repo.json — $1 is unmanaged during rollout: legacy behavior, no declaration guarantees (spec 4.3)"
}
repo_decl_warn_absent_irreversible() { # $1 = operation name
repo_decl_warn "no .mosaic/repo.json — $1 proceeds on LEGACY assumptions during the rollout window; declaration-validated transitions unavailable (spec 4.3)"
}
# Remote comparison (5.3). rc 0 match/unknown, rc 1 mismatch.
repo_decl_remote_matches() {
[ "$DECL_STATE" = valid ] || return 0
[ -n "$DECL_ORIGIN_N" ] && [ -n "$DECL_REMOTE" ] || return 0
local want
want="$("$REPO_DECL_VALIDATOR" --normalize-remote "$DECL_REMOTE" 2>/dev/null || true)"
[ -n "$want" ] || return 0
[ "$DECL_ORIGIN_N" = "$want" ]
}
# Resolve a host:/-anchored declaration path (1.2a). Fails CLOSED while
# MOSAIC_HOST_ROOT is unset or empty — never guesses a root. No WP5b consumer
# calls this today; it exists so the first one that needs a path cannot
# silently guess.
repo_decl_path() { # $1 = host:/... value; prints the resolved absolute path
local v="${1:-}" root="${MOSAIC_HOST_ROOT:-}"
case "$v" in
host:/*) ;;
*) return 1 ;;
esac
if [ -z "$root" ]; then
repo_decl_warn "MOSAIC_HOST_ROOT is unset — refusing to resolve '$v' (spec 1.2a fail-closed; never guess a root)"
return 1
fi
printf '%s/%s\n' "${root%/}" "${v#host:/}"
}
# Transition validation (4.2: a CLI flag is input, not authority).
# rc 0 = allowed; rc 1 = forbidden (message on stderr); rc 2 = no valid
# declaration (caller applies its absence policy).
# flow=direct: base must be the trunk (trunk == release); head must
# differ from it.
# flow=trunk-release: feature->trunk allowed; trunk->release allowed (release
# promotion: head IS the trunk); anything else refused —
# feature->release explicitly REJECTED.
repo_decl_check_transition() { # $1 head, $2 base
[ "$DECL_STATE" = valid ] || return 2
local head="$1" base="$2"
if [ -z "$head" ] || [ -z "$base" ]; then
repo_decl_warn "transition check needs a head and a base (got head='$head' base='$base')"
return 1
fi
if [ "$head" = "$base" ]; then
repo_decl_warn "forbidden transition: head '$head' equals base '$base'"
return 1
fi
case "$DECL_FLOW" in
direct)
if [ "$base" = "$DECL_TRUNK" ]; then
return 0
fi
repo_decl_warn "forbidden transition (flow=direct): base must be the trunk '$DECL_TRUNK', got '$base'"
return 1
;;
trunk-release)
if [ "$base" = "$DECL_TRUNK" ] && [ "$head" != "$DECL_TRUNK" ] && [ "$head" != "$DECL_RELEASE" ]; then
return 0 # feature -> trunk
fi
if [ "$head" = "$DECL_TRUNK" ] && [ "$base" = "$DECL_RELEASE" ]; then
return 0 # release promotion: trunk -> release
fi
if [ "$base" = "$DECL_RELEASE" ] && [ "$head" != "$DECL_TRUNK" ]; then
repo_decl_warn "forbidden transition (flow=trunk-release): feature->release is REJECTED (head '$head' -> release '$DECL_RELEASE'); promote via $DECL_TRUNK"
return 1
fi
repo_decl_warn "forbidden transition (flow=trunk-release): '$head' -> '$base' is not a declared transition (feature->$DECL_TRUNK or $DECL_TRUNK->$DECL_RELEASE)"
return 1
;;
*)
repo_decl_warn "unknown declared flow '$DECL_FLOW'"
return 1
;;
esac
}
@@ -0,0 +1,323 @@
#!/usr/bin/env bash
# Regression harness for ci-queue-wait.sh's --no-ci-expected assertion:
# the sanctioned merge path for a repository with no CI configured at all.
#
# Zero status contexts ("no-status") stays fail-closed for --purpose merge
# by default, because at merge time no-status can also mean "CI has not
# reported yet". --no-ci-expected reclassifies ONLY that zero-context case
# as queue-clear, and only for a caller whose acting token holds repository
# admin. This harness pins:
# (a) merge + no-status + flag + admin -> exit 0, audit line + JSONL.
# (b) merge + no-status, no flag -> exit 3, existing text (unchanged).
# (c) merge + no-status + flag + non-admin -> exit 77 ASSERTION_REFUSED
# (distinct text, exit code NOT 3) + JSONL refusal record.
# (c2) flag + admin payload without the admin field -> fail closed as (c).
# (d) flag + --require-status -> usage error, before any network.
# (e) flag + a real pending context -> still holds (timeout 124),
# and the admin endpoint is never consulted.
# (f) push + no-status, with and without the flag -> push queue-clear
# unchanged; no admin consultation on push.
# (g) flag + admin lookup unreachable -> CANNOT_ASSERT hold (75),
# not a silent pass and not a refusal.
# (h) flag + admin stub + NO MOSAIC_GIT_IDENTITY -> refusal BEFORE
# queue-clear and BEFORE the admin lookup: exit 78, no queue-clear
# line, an ASSERTION_UNATTRIBUTABLE JSONL record, no repos/ call.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-no-ci-expected}"
REPO_DIR="$WORK_DIR/repo"
STUB_DIR="$WORK_DIR/stubs"
URL_LOG="$WORK_DIR/urls.log"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
# Same stub conventions as test-ci-queue-wait-no-status.sh; adds the
# repository-object endpoint (admin state) selected by MOSAIC_STUB_ADMIN_MODE.
cat > "$STUB_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
has_w=0
url=""
for arg in "$@"; do
case "$arg" in
-w) has_w=1 ;;
http://*|https://*) url="$arg" ;;
esac
done
printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
case "$url" in
*/branches/*)
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
if [[ "$has_w" == 1 ]]; then
printf '%s\n200' "$body"
else
printf '%s' "$body"
fi
exit 0
;;
*/status)
mode="${MOSAIC_STUB_STATUS_MODE:?MOSAIC_STUB_STATUS_MODE not set}"
case "$mode" in
no-status) body='{"state":"","statuses":[]}' ;;
real-pending) body='{"state":"pending","statuses":[{"context":"ci/woodpecker","status":"running","target_url":""}]}' ;;
*) echo "curl stub: unknown status mode=$mode" >&2; exit 2 ;;
esac
printf '%s' "$body"
exit 0
;;
*/repos/*)
mode="${MOSAIC_STUB_ADMIN_MODE:?MOSAIC_STUB_ADMIN_MODE not set}"
case "$mode" in
admin) body='{"permissions":{"admin":true,"push":true,"pull":true}}' ;;
non-admin) body='{"permissions":{"admin":false,"push":true,"pull":true}}' ;;
no-admin-field) body='{"permissions":{}}' ;;
unreachable) exit 7 ;;
*) echo "curl stub: unknown admin mode=$mode" >&2; exit 2 ;;
esac
if [[ "$has_w" == 1 ]]; then
printf '%s\n200' "$body"
else
printf '%s' "$body"
fi
exit 0
;;
*)
echo "curl stub: unrecognized URL: $url" >&2
exit 2
;;
esac
SH
chmod +x "$STUB_DIR/curl"
failures=0
run_guard() {
local name="$1"; shift
(
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit-$name.jsonl"
export MOSAIC_STUB_URL_LOG="$URL_LOG"
export GITEA_TOKEN="stub-token"
export GITEA_URL="https://git.example.test"
export MOSAIC_GIT_IDENTITY="test-identity"
"$SCRIPT_DIR/ci-queue-wait.sh" -B main -t 3 -i 1 "$@"
)
}
# The suite exports test-identity globally, so the unattributable-caller
# case must strip it from the child environment at invocation with env -u,
# not rely on the export order.
run_guard_no_identity() {
local name="$1"; shift
(
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit-$name.jsonl"
export MOSAIC_STUB_URL_LOG="$URL_LOG"
export GITEA_TOKEN="stub-token"
export GITEA_URL="https://git.example.test"
export MOSAIC_GIT_IDENTITY="test-identity"
env -u MOSAIC_GIT_IDENTITY \
"$SCRIPT_DIR/ci-queue-wait.sh" -B main -t 3 -i 1 "$@"
)
}
expect_rc() {
local name="$1" want="$2" got="$3"
if [[ "$want" == "not3" ]]; then
if [[ "$got" -eq 0 || "$got" -eq 3 ]]; then
echo "FAIL $name: expected a refusal rc (nonzero, not 3), got $got" >&2
failures=$((failures + 1))
return 1
fi
elif [[ "$got" -ne "$want" ]]; then
echo "FAIL $name: expected rc=$want, got rc=$got" >&2
failures=$((failures + 1))
return 1
fi
return 0
}
expect_text() {
local name="$1" want="$2" output="$3" polarity="${4:-present}"
if [[ "$polarity" == "present" && "$output" != *"$want"* ]]; then
echo "FAIL $name: output missing '$want'" >&2
printf '%s\n' "$output" >&2
failures=$((failures + 1))
elif [[ "$polarity" == "absent" && "$output" == *"$want"* ]]; then
echo "FAIL $name: output unexpectedly contains '$want'" >&2
printf '%s\n' "$output" >&2
failures=$((failures + 1))
fi
}
repo_root_fetched() {
grep -q 'repos/acme/widgets$' "$URL_LOG"
}
# (a) merge + no-status + flag + admin -> exit 0, assertion line, JSONL record.
: > "$URL_LOG"
set +e
out_a=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard a --purpose merge --no-ci-expected 2>&1)
rc_a=$?
set -u
if expect_rc a 0 "$rc_a"; then
expect_text a "queue-clear state=no-status purpose=merge asserted-by=test-identity reason=no-ci-expected branch=main" "$out_a"
expect_text a "ASSERTED_NOT_READY" "$out_a" absent
if ! grep -q '"outcome":"NO_CI_ASSERTED"' "$WORK_DIR/audit-a.jsonl" 2>/dev/null; then
echo "FAIL a: expected a NO_CI_ASSERTED JSONL audit record" >&2
failures=$((failures + 1))
elif ! grep -q '"asserted_by":"test-identity"' "$WORK_DIR/audit-a.jsonl"; then
echo "FAIL a: audit record does not name the asserting identity" >&2
failures=$((failures + 1))
fi
fi
# (b) merge + no-status, no flag -> exit 3, existing error text unchanged.
: > "$URL_LOG"
set +e
out_b=$(MOSAIC_STUB_STATUS_MODE=no-status run_guard b --purpose merge 2>&1)
rc_b=$?
set -u
if expect_rc b 3 "$rc_b"; then
expect_text b "Error: ASSERTED_NOT_READY state=no-status purpose=merge branch=main." "$out_b"
expect_text b "asserted-by" "$out_b" absent
fi
if repo_root_fetched; then
echo "FAIL b: admin endpoint consulted without the flag" >&2
failures=$((failures + 1))
fi
# (c) merge + no-status + flag + non-admin -> distinct refusal, rc NOT 3.
: > "$URL_LOG"
set +e
out_c=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=non-admin run_guard c --purpose merge --no-ci-expected 2>&1)
rc_c=$?
set -u
if expect_rc c not3 "$rc_c"; then
if [[ "$rc_c" -ne 77 ]]; then
echo "FAIL c: expected the documented refusal rc=77, got $rc_c" >&2
failures=$((failures + 1))
fi
expect_text c "ASSERTION_REFUSED state=no-status purpose=merge asserted-by=test-identity reason=no-ci-expected branch=main" "$out_c"
expect_text c "ASSERTED_NOT_READY" "$out_c" absent
if ! grep -q '"outcome":"ASSERTION_REFUSED"' "$WORK_DIR/audit-c.jsonl" 2>/dev/null; then
echo "FAIL c: expected an ASSERTION_REFUSED JSONL audit record" >&2
failures=$((failures + 1))
fi
fi
# (c2) admin payload with no admin field -> fail closed as non-admin.
: > "$URL_LOG"
set +e
out_c2=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=no-admin-field run_guard c2 --purpose merge --no-ci-expected 2>&1)
rc_c2=$?
set -u
if expect_rc c2 77 "$rc_c2"; then
expect_text c2 "ASSERTION_REFUSED" "$out_c2"
fi
# (d) flag + --require-status -> usage error before any network I/O.
: > "$URL_LOG"
set +e
out_d=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard d --purpose merge --no-ci-expected --require-status 2>&1)
rc_d=$?
set -u
if expect_rc d 1 "$rc_d"; then
expect_text d "--no-ci-expected and --require-status contradict" "$out_d"
fi
if [[ -s "$URL_LOG" ]]; then
echo "FAIL d: usage error must precede every network call" >&2
failures=$((failures + 1))
fi
# (e) flag + a real pending context -> still holds; admin endpoint never asked.
: > "$URL_LOG"
set +e
out_e=$(MOSAIC_STUB_STATUS_MODE=real-pending MOSAIC_STUB_ADMIN_MODE=admin run_guard e --purpose merge --no-ci-expected 2>&1)
rc_e=$?
set -u
if expect_rc e 124 "$rc_e"; then
expect_text e "ASSERTED_NOT_READY" "$out_e"
expect_text e "ci/woodpecker=running" "$out_e"
fi
if repo_root_fetched; then
echo "FAIL e: a pending context must not trigger the admin assertion" >&2
failures=$((failures + 1))
fi
# (f) push + no-status stays queue-clear, with and without the flag.
: > "$URL_LOG"
set +e
out_f=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=non-admin run_guard f --purpose push 2>&1)
rc_f=$?
set -u
if expect_rc f 0 "$rc_f"; then
expect_text f "queue-clear state=no-status purpose=push branch=main; no queued or running CI." "$out_f"
fi
: > "$URL_LOG"
set +e
out_f2=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=non-admin run_guard f2 --purpose push --no-ci-expected 2>&1)
rc_f2=$?
set -u
if expect_rc f2 0 "$rc_f2"; then
expect_text f2 "queue-clear state=no-status purpose=push branch=main; no queued or running CI." "$out_f2"
expect_text f2 "asserted-by" "$out_f2" absent
fi
if repo_root_fetched; then
echo "FAIL f: push must not consult the admin endpoint" >&2
failures=$((failures + 1))
fi
# (g) flag + admin lookup unreachable -> CANNOT_ASSERT hold (75), not a pass.
: > "$URL_LOG"
set +e
out_g=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=unreachable run_guard g --purpose merge --no-ci-expected 2>&1)
rc_g=$?
set -u
if expect_rc g 75 "$rc_g"; then
expect_text g "CANNOT_ASSERT reason=repo-permissions-unavailable" "$out_g"
fi
if ! grep -q '"outcome":"CANNOT_ASSERT"' "$WORK_DIR/audit-g.jsonl" 2>/dev/null; then
echo "FAIL g: expected a CANNOT_ASSERT JSONL audit record" >&2
failures=$((failures + 1))
fi
# (h) flag + admin stub + no asserting identity -> refusal before queue-clear
# and before the admin lookup: rc 78, no queue-clear line, an
# ASSERTION_UNATTRIBUTABLE JSONL record, and zero repos/ network calls.
: > "$URL_LOG"
set +e
out_h=$(MOSAIC_STUB_STATUS_MODE=no-status MOSAIC_STUB_ADMIN_MODE=admin run_guard_no_identity h --purpose merge --no-ci-expected 2>&1)
rc_h=$?
set -u
if expect_rc h 78 "$rc_h"; then
expect_text h "ASSERTION_UNATTRIBUTABLE state=no-status purpose=merge asserted-by=unknown reason=no-ci-expected branch=main" "$out_h"
expect_text h "queue-clear" "$out_h" absent
if ! grep -q '"outcome":"ASSERTION_UNATTRIBUTABLE"' "$WORK_DIR/audit-h.jsonl" 2>/dev/null; then
echo "FAIL h: expected an ASSERTION_UNATTRIBUTABLE JSONL audit record" >&2
failures=$((failures + 1))
fi
fi
if repo_root_fetched; then
echo "FAIL h: an unattributable caller must not trigger the permission lookup" >&2
failures=$((failures + 1))
fi
if [[ "$failures" -ne 0 ]]; then
echo "ci-queue-wait no-ci-expected regression failed ($failures assertions)" >&2
exit 1
fi
echo "ci-queue-wait no-ci-expected regression passed (all outcome classes)"
@@ -100,6 +100,15 @@ case "$url" in
*/commits/*/status)
printf '{"state":"success","statuses":[{"context":"ci/mock","status":"success"}]}'
;;
# Repo roots: the pr-create API fallback resolves its base from the forge
# default_branch (T51-P2 WP5a). Exact-suffix matches so the /pulls POST
# endpoint (no trailing path) still falls through to the catch-all.
*/api/v1/repos/USC/uconnect)
printf '{"default_branch":"main"}'
;;
*/api/v1/repos/mosaicstack/stack)
printf '{"default_branch":"next"}'
;;
*)
printf '{}'
;;
@@ -110,8 +119,20 @@ chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
run_in_repo() {
(
# HERMETICITY, second half (#1356). The empty repo-local `mosaic.gitIdentity`
# above pins the git-config route into identity resolution. It does NOT pin
# the environment route, and MOSAIC_GIT_IDENTITY is checked FIRST — so on any
# provisioned seat, where the launcher exports it, this suite failed before
# any change: rc=1 as-is, rc=0 under `env -u MOSAIC_GIT_IDENTITY`, one
# variable. A suite that cannot run on a seat cannot guard this code for the
# agents that actually run it.
#
# Unset rather than set empty: an empty MOSAIC_GIT_IDENTITY and an absent one
# take different branches in resolve_git_identity(), and the case under test
# is "no identity at all".
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
env -u MOSAIC_GIT_IDENTITY \
PATH="${_SANDBOX_BIN:-$BIN_DIR}:$PATH" \
HOME="$HOME_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" \
@@ -307,14 +328,11 @@ SH
chmod +x "$BIN_DIR2/tea"
run_in_repo2() {
(
cd "$REPO_DIR"
PATH="$BIN_DIR2:$PATH" \
HOME="$HOME_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" \
"$@"
)
# Same sandbox as run_in_repo, different mock tea (BIN_DIR2 defines a
# mosaicstack login). This MUST delegate rather than re-implement: it was a
# copy once, and the copy silently missed the MOSAIC_GIT_IDENTITY unset, so
# the suite kept failing on a seat after run_in_repo was already fixed.
_SANDBOX_BIN="$BIN_DIR2" run_in_repo "$@"
}
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
@@ -340,6 +358,151 @@ if [[ "$override_wins" != "mosaicstack" ]]; then
fi
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
# ---------------------------------------------------------------------------
# #1356: the git-identity ladder. A seat declares who it is (MOSAIC_GIT_IDENTITY
# or `git config mosaic.gitIdentity`); resolution must use THAT seat's login and
# must REFUSE to borrow another one when it is absent. Silently borrowing
# satisfies gate 16 mechanically (a review exists) while violating it (the
# reviewer and the author are the same actor under two names).
#
# BIN_DIR3 mocks a tea that holds a canonical per-seat login, which is what a
# projected seat looks like. BIN_DIR2 (mosaicstack only) is reused as the
# "seat has no login" case — no third mock needed for the negative branch.
# ---------------------------------------------------------------------------
BIN_DIR3="$WORK_DIR/bin3"
mkdir -p "$BIN_DIR3"
cp "$BIN_DIR/curl" "$BIN_DIR3/curl"
cat > "$BIN_DIR3/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == "login list --output json" ]]; then
cat <<'JSON'
[
{"name":"mosaicstack","url":"https://git.mosaicstack.dev","user":"ci-bot"},
{"name":"mosaicstack-testseat","url":"https://git.mosaicstack.dev","user":"testseat"},
{"name":"usc","url":"https://git.uscllc.com","user":"ci-bot"}
]
JSON
exit 0
fi
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
exit 0
SH
chmod +x "$BIN_DIR3/tea"
run_in_repo3() { _SANDBOX_BIN="$BIN_DIR3" run_in_repo "$@"; }
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
# Branch 1 (host path): identity set, canonical login PRESENT -> that login wins
# over the shared `mosaicstack` one, which is what host-matching alone would pick.
ladder_hit=$(run_in_repo3 env MOSAIC_GIT_IDENTITY=testseat bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_host git.mosaicstack.dev
')
if [[ "$ladder_hit" != "mosaicstack-testseat" ]]; then
echo "Expected identity ladder to select 'mosaicstack-testseat'; got '$ladder_hit'" >&2
exit 1
fi
# CONTROL for branch 1: the same mock, no identity, must still resolve by host.
# Without this, branch 1 passing proves nothing about the ladder specifically --
# it would also pass if the code just picked the last matching login.
ladder_none=$(run_in_repo3 bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_host git.mosaicstack.dev
')
if [[ "$ladder_none" != "mosaicstack" ]]; then
echo "Expected no-identity host resolution to stay 'mosaicstack'; got '$ladder_none'" >&2
exit 1
fi
# Branch 2 (host path): identity set, canonical login ABSENT -> fail closed with a
# named error. Two assertions, and they are not the same one twice: rc!=0 proves
# it refused, and the ABSENCE of any login on stdout proves it did not borrow the
# `mosaicstack` login that is sitting right there matching the host.
ladder_err=$(run_in_repo2 env MOSAIC_GIT_IDENTITY=testseat bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_host git.mosaicstack.dev
' 2>&1 1>/dev/null || true)
ladder_out=$(run_in_repo2 env MOSAIC_GIT_IDENTITY=testseat bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_host git.mosaicstack.dev
' 2>/dev/null || true)
if [[ -n "$ladder_out" ]]; then
echo "Identity ladder BORROWED login '$ladder_out' instead of failing closed" >&2
exit 1
fi
if ! grep -q "mosaicstack-testseat" <<<"$ladder_err"; then
echo "Expected the error to name the login it wanted; got: $ladder_err" >&2
exit 1
fi
# Branch 3: `git config mosaic.gitIdentity` is the second rung and must work when
# the environment variable is absent -- a seat may be configured either way.
git -C "$REPO_DIR" config mosaic.gitIdentity testseat
ladder_gitcfg=$(run_in_repo3 bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_host git.mosaicstack.dev
')
git -C "$REPO_DIR" config --unset mosaic.gitIdentity || true
if [[ "$ladder_gitcfg" != "mosaicstack-testseat" ]]; then
echo "Expected git-config identity rung to select 'mosaicstack-testseat'; got '$ladder_gitcfg'" >&2
exit 1
fi
# Branch 4 (--repo override path): same rule, owner-derived instead of host-derived.
override_ladder=$(run_in_repo3 env MOSAIC_GIT_IDENTITY=testseat bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_repo_override mosaicstack/stack
')
if [[ "$override_ladder" != "mosaicstack-testseat" ]]; then
echo "Expected --repo override ladder to select 'mosaicstack-testseat'; got '$override_ladder'" >&2
exit 1
fi
# Branch 5: explicit GITEA_LOGIN outranks the ladder. An operator naming a login
# by hand is a deliberate act, not an accident to be second-guessed.
override_explicit=$(run_in_repo3 env MOSAIC_GIT_IDENTITY=testseat GITEA_LOGIN=mosaicstack bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_repo_override mosaicstack/stack
')
if [[ "$override_explicit" != "mosaicstack" ]]; then
echo "Expected explicit GITEA_LOGIN to outrank the identity ladder; got '$override_explicit'" >&2
exit 1
fi
# Branch 6 (#1357 S1): with tea ABSENT from PATH, the override path must say tea is
# missing, not "no tea login named X exists" (a cause that was never checked) and
# not the seat-logins.sh advice, which cannot be followed without tea.
NOTEA_BIN="$WORK_DIR/notea-bin"; mkdir -p "$NOTEA_BIN"
for t in bash git python3 sed grep cat mktemp dirname basename readlink env sort head tr cut; do
_p="$(command -v "$t" 2>/dev/null || true)"; [[ -n "$_p" ]] && ln -sf "$_p" "$NOTEA_BIN/$t"
done
override_notea_rc=0
override_notea_err=$(cd "$REPO_DIR" && env -u GITEA_LOGIN \
PATH="$NOTEA_BIN" HOME="$HOME_DIR" MOSAIC_GIT_IDENTITY=testseat \
bash -c '
command -v tea >/dev/null 2>&1 && { echo "SETUP: tea still on PATH"; exit 99; }
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_login_for_repo_override mosaicstack/stack
' 2>&1 >/dev/null) || override_notea_rc=$?
if [[ "$override_notea_rc" != 1 ]]; then
echo "Expected --repo override path to fail (rc=1) with tea absent; got rc=$override_notea_rc: $override_notea_err" >&2
exit 1
fi
if ! grep -q 'tea is not installed' <<<"$override_notea_err"; then
echo "Expected --repo override path to name tea as absent; got: $override_notea_err" >&2
exit 1
fi
if grep -q 'has no tea login\|seat-logins.sh' <<<"$override_notea_err"; then
echo "Override path diagnosed a missing LOGIN while tea itself is absent: $override_notea_err" >&2
exit 1
fi
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
# ---------------------------------------------------------------------------
# #865 Blocker 1 & 2: get_gitea_token_for_login must resolve the SAME token as
# PyYAML would (or fail closed identically) even when PyYAML is ABSENT, and must
+616
View File
@@ -0,0 +1,616 @@
#!/usr/bin/env bash
# Regression harness for grant-reviewer.sh (#1415): org-team reviewer grant
# with fail-closed read-back verification.
#
# This harness models a REAL server: the curl stub keeps persistent team/
# member/repo state on disk, the POST actually CREATES and PERSISTS the team,
# the member/repo PUTs persist (except in the sabotage modes), and the
# read-back GETs answer from that same state. There is no fabricated record
# for the wrapper to "find" — verification passes only if the PUTs genuinely
# persisted what the read-back retrieves. It proves the wrapper:
# 1. creates the team with the EXACT reviewer payload (permission: read,
# units_map {repo.code: read, repo.issues: write, repo.pulls: write}) —
# the stub rejects any other payload;
# 2. is idempotent: an existing team is found by EXACT name (a decoy team
# whose name merely CONTAINS the wanted name is listed first and must
# not be matched) and no create POST is issued;
# 3. refuses to run against a GitHub-remoted repo (Gitea only);
# 4. refuses when the owner is not an organization;
# 5. maps HTTP 403 to "org admin required on <org>" and stops before any
# partial grant;
# 6. fails closed when the member PUT returns 204 without persisting (the
# #865 defect class: an exit code is not evidence of a durable write);
# 7. fails closed when the repo PUT returns 204 without persisting;
# 8. with GITEA_LOGIN set, performs EVERY request under that login's token
# (never the host default), and with an UNRESOLVABLE GITEA_LOGIN fails
# closed with ZERO API calls instead of downgrading;
# 9. never lets the bearer token ride in curl argv (curl --config only);
# 10. leaves no temp files behind on success or failure paths.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/grant-reviewer}"
REPO_DIR="$WORK_DIR/repo"
GH_REPO_DIR="$WORK_DIR/gh-repo"
BIN_DIR="$WORK_DIR/bin"
XDG_DIR="$WORK_DIR/xdg"
TEA_LOG="$WORK_DIR/tea.log"
CURL_LOG="$WORK_DIR/curl.log"
# Full curl argv per invocation — proves the bearer token never rides in argv.
CURL_ARGV_LOG="$WORK_DIR/curl-argv.log"
AUTH_LOG="$WORK_DIR/auth.log"
OUTPUT_FILE="$WORK_DIR/output.log"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
STATE_FILE="$WORK_DIR/grants.json"
PAYLOAD_VIOLATION_FILE="$WORK_DIR/payload-violation"
TMP_SCRATCH="$WORK_DIR/scratch"
HOME_DIR="$WORK_DIR/home"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$REPO_DIR" "$GH_REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH" "$HOME_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
git -C "$GH_REPO_DIR" init -q
git -C "$GH_REPO_DIR" remote add origin https://github.com/someorg/somerepo.git
# HERMETICITY (#1007): get_gitea_token() step 0 resolves a per-agent identity
# from `git config --get mosaic.gitIdentity`, which on a provisioned seat is
# set GLOBALLY and leaks into this fresh repo, after which a REAL per-slot
# token is read from $HOME and the fixture credential is silently ignored. An
# empty repo-local value shadows the global one and reads back empty at rc=0.
# (The env-var route does NOT neutralize step 0's git-config read — but the
# run env below still pins MOSAIC_GIT_IDENTITY= empty so the ENV rung of the
# ladder cannot resolve either: `${MOSAIC_GIT_IDENTITY:-}` treats set-but-empty
# as unset.)
git -C "$REPO_DIR" config mosaic.gitIdentity ""
git -C "$GH_REPO_DIR" config mosaic.gitIdentity ""
ORG="mosaicstack"
REPO_SLUG="mosaicstack/stack"
API_ROOT="https://git.mosaicstack.dev/api/v1"
REVIEWER="rev-user"
TEAM_NAME="fleet-reviewers"
TEAM_ID=42
DECOY_TEAM_ID=99
DEFAULT_TOKEN="test-only-placeholder"
DEFAULT_IDENTITY="seat-default"
OVERRIDE_LOGIN="granter"
OVERRIDE_TOKEN="override-token-placeholder"
# tea config: the GITEA_LOGIN override login has its own host-bound token here.
mkdir -p "$XDG_DIR/tea"
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
python3 - "$XDG_DIR/tea/config.yml" <<'PY'
import os
import sys
with open(sys.argv[1], "w", encoding="utf-8") as handle:
handle.write("logins:\n")
handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n")
handle.write(" url: https://git.mosaicstack.dev\n")
handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n")
PY
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
import json
import os
import sys
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
json.dump({
"gitea": {
"mosaicstack": {
"url": os.environ["CONFIGURED_GITEA_URL"],
"token": "test-only-placeholder",
}
}
}, credentials)
PY
# tea stub: grant-reviewer.sh must never shell out to tea at all.
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$GRANT_REVIEWER_TEA_LOG"
echo "Unexpected tea command (grant-reviewer must not use tea): $*" >&2
exit 92
SH
chmod +x "$BIN_DIR/tea"
# curl stub: a small REST server backed by persistent on-disk grant state.
# GET /orgs/{org} -> org existence (404 in not-an-org mode)
# GET /orgs/{org}/teams/search -> teams from state (decoy always listed FIRST)
# POST /orgs/{org}/teams -> validate EXACT payload, CREATE + PERSIST
# PUT /teams/{id}/members/{user} -> 204; persists unless member-put-noop
# PUT /teams/{id}/repos/{org}/{repo} -> 204; persists unless repo-put-noop
# GET /teams/{id}/members/{user} -> answers from persisted state only
# GET /teams/{id}/repos/{org}/{repo} -> answers from persisted state only
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
# Record the FULL argv exactly as spawned, before consumption. The bearer token
# must NOT appear here — it is delivered via a curl --config file, so only the
# config file PATH may show up.
printf '%s\n' "$*" >> "$GRANT_REVIEWER_CURL_ARGV_LOG"
output_file=""
method="GET"
url=""
data=""
auth_token=""
config_file=""
while [[ $# -gt 0 ]]; do
case "$1" in
-o) output_file="$2"; shift 2 ;;
-H)
[[ "$2" == Authorization:* ]] && auth_token="${2##* }"
shift 2 ;;
-K|--config) config_file="$2"; shift 2 ;;
-w) shift 2 ;;
-X) method="$2"; shift 2 ;;
-d|--data) data="$2"; shift 2 ;;
-s|-S|-sS) shift ;;
http://*|https://*) url="$1"; shift ;;
*) shift ;;
esac
done
# Resolve the bearer token from the curl --config file (its real, secure
# source). The config line is `header = "Authorization: token <value>"`.
if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then
config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)"
if [[ "$config_hdr" == *"token "* ]]; then
auth_token="${config_hdr##*token }"
auth_token="${auth_token%\"}"
fi
fi
path="${url%%\?*}"
printf '%s %s\n' "$method" "$url" >> "$GRANT_REVIEWER_CURL_LOG"
# Map the presented bearer token to the identity it authenticates as. Every
# request the wrapper makes must carry the SAME credential, so the identity
# recorded here reveals which credential actually performed each request.
acting_identity=""
case "$auth_token" in
"$GRANT_REVIEWER_DEFAULT_TOKEN") acting_identity="$GRANT_REVIEWER_DEFAULT_IDENTITY" ;;
"$GRANT_REVIEWER_OVERRIDE_TOKEN") acting_identity="$GRANT_REVIEWER_OVERRIDE_LOGIN" ;;
esac
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$GRANT_REVIEWER_AUTH_LOG"
write_response() {
local status="$1" body="$2"
[[ -n "$output_file" ]] || exit 96
printf '%s' "$body" > "$output_file"
printf '%s' "$status"
}
[[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
mode="$GRANT_REVIEWER_TEST_MODE"
org="$GRANT_REVIEWER_ORG"
api="$GRANT_REVIEWER_API_ROOT"
if [[ "$method" == "GET" && "$path" == "$api/orgs/$org" ]]; then
if [[ "$mode" == "not-an-org" ]]; then
write_response 404 '{"message":"not found"}'
else
write_response 200 "{\"username\":\"$org\"}"
fi
elif [[ "$method" == "GET" && "$path" == "$api/orgs/$org/teams/search" ]]; then
result=$(python3 - "$GRANT_REVIEWER_STATE" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
state = json.load(handle)
print(json.dumps({"ok": True, "data": state["teams"]}))
PY
)
write_response 200 "$result"
elif [[ "$method" == "POST" && "$path" == "$api/orgs/$org/teams" ]]; then
if [[ "$mode" == "create-403" ]]; then
write_response 403 '{"message":"forbidden"}'
exit 0
fi
result=$(GRANT_REVIEWER_DATA="$data" python3 - "$GRANT_REVIEWER_STATE" <<'PY'
import json
import os
import sys
payload = json.loads(os.environ["GRANT_REVIEWER_DATA"])
expected = {
"name": os.environ["GRANT_REVIEWER_TEAM_NAME"],
"description": "review seats: code read + issues/pulls write",
"permission": "read",
"includes_all_repositories": False,
"can_create_org_repo": False,
"units_map": {
"repo.code": "read",
"repo.issues": "write",
"repo.pulls": "write",
},
}
if payload != expected:
with open(os.environ["GRANT_REVIEWER_PAYLOAD_VIOLATION"], "w", encoding="utf-8") as handle:
json.dump({"got": payload, "expected": expected}, handle, indent=2)
print("422")
print(json.dumps({"message": "payload mismatch"}))
raise SystemExit(0)
state_path = sys.argv[1]
with open(state_path, encoding="utf-8") as handle:
state = json.load(handle)
team = {"id": int(os.environ["GRANT_REVIEWER_TEAM_ID"]), "name": payload["name"]}
state["teams"].append(team)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(state, handle)
print("201")
print(json.dumps(team))
PY
)
response_status="${result%%$'\n'*}"
response_body="${result#*$'\n'}"
write_response "$response_status" "$response_body"
elif [[ "$method" == "PUT" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/members/$GRANT_REVIEWER_REVIEWER" ]]; then
# Sabotage mode member-put-noop: 204 WITHOUT persisting — the exit-code lie.
if [[ "$mode" != "member-put-noop" ]]; then
python3 - "$GRANT_REVIEWER_STATE" <<'PY'
import json
import os
import sys
state_path = sys.argv[1]
with open(state_path, encoding="utf-8") as handle:
state = json.load(handle)
member = os.environ["GRANT_REVIEWER_REVIEWER"]
if member not in state["members"]:
state["members"].append(member)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(state, handle)
PY
fi
write_response 204 ''
elif [[ "$method" == "PUT" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/repos/$GRANT_REVIEWER_REPO_SLUG" ]]; then
# Sabotage mode repo-put-noop: 204 WITHOUT persisting.
if [[ "$mode" != "repo-put-noop" ]]; then
python3 - "$GRANT_REVIEWER_STATE" <<'PY'
import json
import os
import sys
state_path = sys.argv[1]
with open(state_path, encoding="utf-8") as handle:
state = json.load(handle)
slug = os.environ["GRANT_REVIEWER_REPO_SLUG"]
if slug not in state["repos"]:
state["repos"].append(slug)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(state, handle)
PY
fi
write_response 204 ''
elif [[ "$method" == "GET" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/members/$GRANT_REVIEWER_REVIEWER" ]]; then
if python3 - "$GRANT_REVIEWER_STATE" <<'PY'
import json
import os
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
state = json.load(handle)
raise SystemExit(0 if os.environ["GRANT_REVIEWER_REVIEWER"] in state["members"] else 1)
PY
then
write_response 200 "{\"login\":\"$GRANT_REVIEWER_REVIEWER\"}"
else
write_response 404 '{"message":"not a member"}'
fi
elif [[ "$method" == "GET" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/repos/$GRANT_REVIEWER_REPO_SLUG" ]]; then
if python3 - "$GRANT_REVIEWER_STATE" <<'PY'
import json
import os
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
state = json.load(handle)
raise SystemExit(0 if os.environ["GRANT_REVIEWER_REPO_SLUG"] in state["repos"] else 1)
PY
then
write_response 200 "{\"full_name\":\"$GRANT_REVIEWER_REPO_SLUG\"}"
else
write_response 404 '{"message":"repo not on team"}'
fi
else
echo "Unexpected curl request: $method $url" >&2
exit 97
fi
SH
chmod +x "$BIN_DIR/curl"
# Seed persistent server state for a mode: fresh (no team yet) or a pre-seeded
# team. The DECOY team — whose name CONTAINS the wanted name — is always listed
# FIRST, so a first-result or substring match would grab the wrong team.
seed_state() {
local seeded_team="$1"
GRANT_REVIEWER_SEEDED_TEAM="$seeded_team" GRANT_REVIEWER_TEAM_NAME="$TEAM_NAME" \
GRANT_REVIEWER_TEAM_ID="$TEAM_ID" GRANT_REVIEWER_DECOY_TEAM_ID="$DECOY_TEAM_ID" \
python3 - "$STATE_FILE" <<'PY'
import json
import os
import sys
wanted = os.environ["GRANT_REVIEWER_TEAM_NAME"]
teams = [{"id": int(os.environ["GRANT_REVIEWER_DECOY_TEAM_ID"]), "name": wanted + "-archive"}]
if os.environ["GRANT_REVIEWER_SEEDED_TEAM"] == "yes":
teams.append({"id": int(os.environ["GRANT_REVIEWER_TEAM_ID"]), "name": wanted})
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump({"teams": teams, "members": [], "repos": []}, handle)
PY
}
# run_grant <mode> <seeded-team yes|no> [extra env VAR=value ...] -- [wrapper args ...]
run_grant() {
local mode="$1" seeded="$2"
shift 2
local -a extra_env=()
while [[ $# -gt 0 && "$1" != "--" ]]; do
extra_env+=("$1")
shift
done
[[ $# -gt 0 ]] && shift
: > "$TEA_LOG"
: > "$CURL_LOG"
: > "$CURL_ARGV_LOG"
: > "$AUTH_LOG"
: > "$OUTPUT_FILE"
rm -f "$PAYLOAD_VIOLATION_FILE"
seed_state "$seeded"
(
cd "$RUN_REPO_DIR"
env \
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
HOME="$HOME_DIR" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" \
MOSAIC_GIT_IDENTITY= \
GITEA_LOGIN= \
GITEA_TOKEN= \
GITEA_URL= \
GRANT_REVIEWER_TEA_LOG="$TEA_LOG" \
GRANT_REVIEWER_CURL_LOG="$CURL_LOG" \
GRANT_REVIEWER_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
GRANT_REVIEWER_AUTH_LOG="$AUTH_LOG" \
GRANT_REVIEWER_STATE="$STATE_FILE" \
GRANT_REVIEWER_TEST_MODE="$mode" \
GRANT_REVIEWER_ORG="$ORG" \
GRANT_REVIEWER_API_ROOT="$API_ROOT" \
GRANT_REVIEWER_TEAM_NAME="$TEAM_NAME" \
GRANT_REVIEWER_TEAM_ID="$TEAM_ID" \
GRANT_REVIEWER_REVIEWER="$REVIEWER" \
GRANT_REVIEWER_REPO_SLUG="$REPO_SLUG" \
GRANT_REVIEWER_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
GRANT_REVIEWER_DEFAULT_IDENTITY="$DEFAULT_IDENTITY" \
GRANT_REVIEWER_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
GRANT_REVIEWER_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
GRANT_REVIEWER_PAYLOAD_VIOLATION="$PAYLOAD_VIOLATION_FILE" \
"${extra_env[@]}" \
"$SCRIPT_DIR/grant-reviewer.sh" -u "$REVIEWER" "$@"
) > "$OUTPUT_FILE" 2>&1
}
assert_no_temp_leak() {
local context="$1" leaked
# Includes the curl auth-config files (mosaic-gitea-auth-*), which carry the
# bearer token and must be unlinked on every exit path.
leaked=$(find "$TMP_SCRATCH" -type f \( -name 'mosaic-grant-reviewer-*' -o -name 'mosaic-gitea-auth-*' \) 2>/dev/null || true)
if [[ -n "$leaked" ]]; then
echo "FAIL: grant-reviewer temp files leaked ($context):" >&2
printf '%s\n' "$leaked" >&2
exit 1
fi
}
assert_token_not_in_argv() {
local context="$1"
if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" "$CURL_ARGV_LOG"; then
echo "FAIL: a Gitea bearer token leaked into curl argv ($context)" >&2
exit 1
fi
if ! grep -q -- '--config' "$CURL_ARGV_LOG"; then
echo "FAIL: curl was not invoked with --config file auth ($context)" >&2
exit 1
fi
}
assert_no_payload_violation() {
local context="$1"
if [[ -f "$PAYLOAD_VIOLATION_FILE" ]]; then
echo "FAIL: team create payload deviated from the reviewer contract ($context):" >&2
cat "$PAYLOAD_VIOLATION_FILE" >&2
exit 1
fi
}
RUN_REPO_DIR="$REPO_DIR"
# Case 1: fresh grant — team absent, created with the exact reviewer payload,
# member + repo PUTs persist, both read-backs verify against server state.
run_grant normal no -- || {
echo "FAIL: fresh grant exited nonzero" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
grep -q "Created team '$TEAM_NAME' (id $TEAM_ID) on org '$ORG'" "$OUTPUT_FILE" || {
echo "FAIL: fresh grant did not create the team" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
grep -q "Granted: '$REVIEWER' is a member of team '$TEAM_NAME' (id $TEAM_ID) with access to '$REPO_SLUG'" "$OUTPUT_FILE" || {
echo "FAIL: fresh grant did not report a verified grant" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
assert_no_payload_violation "fresh"
assert_token_not_in_argv "fresh"
assert_no_temp_leak "fresh"
# The default path must have acted as the host-default identity on EVERY request.
if grep -qv " $DEFAULT_IDENTITY\$" "$AUTH_LOG"; then
echo "FAIL: fresh grant made a request under an unexpected identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
# grant-reviewer must never shell out to tea.
if [[ -s "$TEA_LOG" ]]; then
echo "FAIL: grant-reviewer invoked tea" >&2
cat "$TEA_LOG" >&2
exit 1
fi
# Case 2: idempotent — the team already exists. It must be found by EXACT name
# (the decoy is listed first), no create POST issued, and the decoy team must
# never be touched.
run_grant normal yes -- || {
echo "FAIL: idempotent grant exited nonzero" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
grep -q "Found existing team '$TEAM_NAME' (id $TEAM_ID) on org '$ORG'" "$OUTPUT_FILE" || {
echo "FAIL: idempotent grant did not find the existing team" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
grep -q "Granted: '$REVIEWER'" "$OUTPUT_FILE" || {
echo "FAIL: idempotent grant did not report a verified grant" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
if grep -q "^POST " "$CURL_LOG"; then
echo "FAIL: idempotent grant issued a create POST for an existing team" >&2
cat "$CURL_LOG" >&2
exit 1
fi
if grep -q "/teams/$DECOY_TEAM_ID/" "$CURL_LOG"; then
echo "FAIL: substring-named decoy team was operated on" >&2
cat "$CURL_LOG" >&2
exit 1
fi
assert_no_temp_leak "idempotent"
# Case 3: GITEA_LOGIN override — every request must carry the override login's
# token, never the host default credential.
run_grant normal no GITEA_LOGIN="$OVERRIDE_LOGIN" -- || {
echo "FAIL: GITEA_LOGIN override grant exited nonzero" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
grep -q "Granted: '$REVIEWER'" "$OUTPUT_FILE" || {
echo "FAIL: GITEA_LOGIN override grant did not succeed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
if grep -qv " $OVERRIDE_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: GITEA_LOGIN override made a request under a different identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
assert_token_not_in_argv "override"
assert_no_temp_leak "override"
# Case 4: unresolvable GITEA_LOGIN — fail closed BEFORE any API call; no
# downgrade to the host default identity.
if run_grant normal no GITEA_LOGIN="no-such-login" --; then
echo "FAIL: unresolvable GITEA_LOGIN did not fail" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
grep -q "refusing to fall back to the host default identity" "$OUTPUT_FILE" || {
echo "FAIL: unresolvable GITEA_LOGIN missing the fail-closed message" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
if [[ -s "$CURL_LOG" ]]; then
echo "FAIL: unresolvable GITEA_LOGIN still made API calls" >&2
cat "$CURL_LOG" >&2
exit 1
fi
assert_no_temp_leak "unresolvable-login"
# Case 5: GitHub-remoted repo — refuse before any API call.
RUN_REPO_DIR="$GH_REPO_DIR"
if run_grant normal no --; then
echo "FAIL: GitHub repo was not refused" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
grep -q "Gitea only" "$OUTPUT_FILE" || {
echo "FAIL: GitHub refusal missing the 'Gitea only' message" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
if [[ -s "$CURL_LOG" ]]; then
echo "FAIL: GitHub refusal still made API calls" >&2
cat "$CURL_LOG" >&2
exit 1
fi
RUN_REPO_DIR="$REPO_DIR"
# Case 6: owner is not an organization — clear refusal.
if run_grant not-an-org no --; then
echo "FAIL: non-org owner was not refused" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
grep -q "is not an organization" "$OUTPUT_FILE" || {
echo "FAIL: non-org refusal missing its message" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
assert_no_temp_leak "not-an-org"
# Case 7: HTTP 403 on team create — reported as an org-admin requirement, and
# the run stops before any member/repo PUT (no partial grant).
if run_grant create-403 no --; then
echo "FAIL: 403 on team create did not fail the run" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
grep -q "org admin required on '$ORG'" "$OUTPUT_FILE" || {
echo "FAIL: 403 was not mapped to the org-admin message" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
if grep -q "^PUT " "$CURL_LOG"; then
echo "FAIL: run continued into PUTs after a 403 (partial grant)" >&2
cat "$CURL_LOG" >&2
exit 1
fi
assert_no_temp_leak "create-403"
# Cases 8-9: the exit-code lie — a PUT answers 204 without persisting. The
# read-back must fail closed; no success line may appear.
for noop_mode in member-put-noop repo-put-noop; do
if run_grant "$noop_mode" no --; then
echo "FAIL: $noop_mode was reported as success" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
grep -q "NOT verified" "$OUTPUT_FILE" || {
echo "FAIL: $noop_mode missing the fail-closed verification message" >&2
cat "$OUTPUT_FILE" >&2
exit 1
}
if grep -q "^Granted:" "$OUTPUT_FILE"; then
echo "FAIL: $noop_mode still printed the success line" >&2
exit 1
fi
assert_no_temp_leak "$noop_mode"
done
echo "grant-reviewer.sh org-team grant + fail-closed read-back regression passed"
@@ -76,7 +76,22 @@ exit 0
EOF
chmod +x "$MOCK_BIN/tea"
}
# #1356: login resolution is now identity-aware, so the tea-branch fixture must
# offer the login the RUNNER's identity resolves to; otherwise every case below
# fails closed before reaching the branch under test.
#
# This does NOT make the suite hermetic, and it is not trying to. The API-path
# cases (5-7) need a usable Gitea token, and with an identity set the token path
# reads that seat's credential file rather than the GITEA_TOKEN exported above.
# So this suite passes only where the runner owns a real credential for its own
# identity, and fails with no identity at all -- on this branch and on its base
# alike. That is a pre-existing hole in the fixture, filed separately; pinning a
# synthetic identity here would only convert it into a confident-looking green.
_LOGIN_IDENT="${MOSAIC_GIT_IDENTITY:-}"
LOGIN_JSON='[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
if [[ -n "$_LOGIN_IDENT" ]]; then
LOGIN_JSON='[{"name":"mosaicstack-'"$_LOGIN_IDENT"'","url":"https://git.mosaicstack.dev"},{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
fi
# The mocks must be the ones that run. Without this, a failed setup silently falls through
# to the real tea/curl and the "test" mutates the real provider.
@@ -10,6 +10,12 @@
set -euo pipefail
# HERMETICITY (#1356): this suite's subject is body quoting, not identity. An
# ambient MOSAIC_GIT_IDENTITY (every provisioned seat exports one) would make the
# identity ladder demand a per-seat login this fixture does not define, and the
# suite would fail for a reason it is not testing.
unset MOSAIC_GIT_IDENTITY
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-body-safety}"
REPO_DIR="$WORK_DIR/repo"
@@ -67,7 +67,18 @@ cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
printf '%s\n' '{"number":703}'
# Repo roots: the pr-create API fallback resolves its base from the forge
# default_branch (T51-P2 WP5a). Exact-suffix so every other endpoint keeps
# the historical answer below.
url="${*: -1}"
case "$url" in
*/api/v1/repos/mosaicstack/stack)
printf '%s\n' '{"default_branch":"next"}'
;;
*)
printf '%s\n' '{"number":703}'
;;
esac
SH
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# Regression: issue-view.sh must show comment BODIES, on both paths, and must name
# the failure tea actually reported instead of guessing a credential cause (#1357).
#
# Four defects, each with its own case below:
# F1 tea exits 1 in any repo with extensions.worktreeconfig=true; the wrapper must
# say so (git-config condition) and fall back to the API.
# F2 the API fallback dumped raw issue JSON, which carries only a comment COUNT.
# F3 the tea path never passed --comments, so tea prompted (non-interactively: nothing).
# F4 on ANY tea failure the wrapper printed the REVOKED OR STALE TOKEN note.
#
# Verification bar (plan §6): assert a real comment BODY appears, not a count and not
# `grep -c comment` (that instrument matched the issue title and read inverted).
#
# Hermetic: mock tea and curl on PATH, sandboxed repo. Resolves no real credentials.
set -euo pipefail
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
SANDBOX="$WORK_ROOT/issue-view-comments-test-$$"
MOCK_BIN="$SANDBOX/bin"; REPO_DIR="$SANDBOX/repo"; CALLS="$SANDBOX/calls.log"
cleanup() { rm -rf "$SANDBOX"; }
trap cleanup EXIT
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET="$SCRIPT_DIR/issue-view.sh"
[ -f "$TARGET" ] || { echo "FAIL: issue-view.sh not found beside this test"; exit 1; }
fail() { echo "FAIL: $*"; exit 1; }
mkdir -p "$MOCK_BIN" "$REPO_DIR" || fail "setup: cannot create sandbox under $WORK_ROOT"
: > "$CALLS" || fail "setup: cannot write calls log at $CALLS"
cd "$REPO_DIR" || fail "setup: cannot cd into $REPO_DIR"
git init -q || fail "setup: git init failed"
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git || fail "setup: git remote add failed"
export PATH="$MOCK_BIN:$PATH" CALLS
export GITEA_URL="https://git.mosaicstack.dev"
export GITEA_TOKEN="redacted-test-token"
# The identity ladder must not reach for this seat's real login; the mock tea below
# defines the only login that exists in this sandbox.
unset MOSAIC_GIT_IDENTITY
# No fleet in the sandbox: on a host that runs one, get_gitea_token fails closed for an
# identity-less caller (by design), which would make this test measure the host, not
# the wrapper. An empty brain home makes the sandbox the same on every host.
export MOSAIC_BRAIN_HOME="$SANDBOX/brain"
mkdir -p "$MOSAIC_BRAIN_HOME" || fail "setup: cannot create sandbox brain home"
# Distinctive strings: a comment body that appears nowhere else, and an issue title
# that contains the word "comment" so a count-of-the-word instrument would misread.
BODY_MARKER="zebra-quill-comment-body-7731"
ISSUE_TITLE="wrapper never shows a comment"
# --- mock curl: serves the issue and its comments; logs every call --------------
cat > "$MOCK_BIN/curl" <<EOF
#!/bin/bash
url=""
while [ \$# -gt 0 ]; do
case "\$1" in
http*) url="\$1"; shift ;;
*) shift ;;
esac
done
printf 'curl %s\n' "\$url" >> "$CALLS"
case "\$url" in
*/issues/77/comments)
if [ "\${MOCK_NO_COMMENTS:-}" = "1" ]; then echo '[]'; else
echo '[{"id":1,"user":{"login":"alice"},"created_at":"2026-08-21T00:00:00Z","body":"$BODY_MARKER"}]'; fi ;;
*/issues/77)
echo '{"number":77,"title":"$ISSUE_TITLE","state":"open","user":{"login":"bob"},"created_at":"2026-08-21T00:00:00Z","labels":[],"milestone":null,"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/issues/77","body":"issue body","comments":1}' ;;
*) echo '{}' ;;
esac
exit 0
EOF
chmod +x "$MOCK_BIN/curl"
# --- mock tea: MOCK_TEA_MODE selects the behaviour under test --------------------
# ok : prints the issue, and the comment body ONLY when --comments is passed (F3)
# wtconfig : exits 1 with the repositoryformatversion error (F1/F4)
# badtoken : exits 1 with tea's credential error (F4 control: credential wording allowed)
cat > "$MOCK_BIN/tea" <<EOF
#!/bin/bash
printf 'tea %s\n' "\$*" >> "$CALLS"
if [[ "\$*" == *"login list"* ]]; then
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'; exit 0
fi
case "\${MOCK_TEA_MODE:-ok}" in
wtconfig) echo 'Error: core.repositoryformatversion does not support extension: worktreeconfig' >&2; exit 1 ;;
badtoken) echo 'Failed to create Gitea client: invalid username, password or token' >&2; exit 1 ;;
esac
echo "# #77 $ISSUE_TITLE (open)"
echo "issue body"
if [[ "\$*" == *"--comments"* ]]; then echo "$BODY_MARKER"; fi
exit 0
EOF
chmod +x "$MOCK_BIN/tea"
[ "$(command -v tea)" = "$MOCK_BIN/tea" ] || fail "setup: tea does not resolve inside the sandbox"
[ "$(command -v curl)" = "$MOCK_BIN/curl" ] || fail "setup: curl does not resolve inside the sandbox"
run() { bash "$TARGET" -i 77 >"$SANDBOX/out" 2>"$SANDBOX/err"; echo $?; }
# F3: tea path shows the comment body, which the mock emits only under --comments.
: > "$CALLS"
rc=$(MOCK_TEA_MODE=ok run)
[ "$rc" = 0 ] || fail "F3: expected rc=0 on the tea path, got $rc: $(cat "$SANDBOX/err")"
grep -q -- '--comments' "$CALLS" || fail "F3: tea was not invoked with --comments: $(cat "$CALLS")"
grep -q "$BODY_MARKER" "$SANDBOX/out" || fail "F3: comment body missing from tea-path output"
if grep -q '^curl' "$CALLS"; then fail "F3: tea path succeeded but the API fallback ran anyway"; fi
# F1 + F2: worktreeconfig failure is named as a git-config condition, falls back to
# the API, and the API rendering includes the comment BODY.
: > "$CALLS"
rc=$(MOCK_TEA_MODE=wtconfig run)
[ "$rc" = 0 ] || fail "F1: expected rc=0 via API fallback, got $rc: $(cat "$SANDBOX/err")"
grep -q 'worktreeconfig' "$SANDBOX/err" || fail "F1: stderr does not name the worktreeconfig cause: $(cat "$SANDBOX/err")"
grep -q 'not a credential problem' "$SANDBOX/err" || fail "F1: stderr does not rule out the credential cause"
grep -q 'issues/77/comments' "$CALLS" || fail "F2: API fallback never fetched /comments: $(cat "$CALLS")"
grep -q "$BODY_MARKER" "$SANDBOX/out" || fail "F2: comment body missing from API-path output"
grep -q "$ISSUE_TITLE" "$SANDBOX/out" || fail "F2: issue title missing from API-path output"
if grep -q 'REVOKED OR STALE' "$SANDBOX/err"; then fail "F4: stale-token note printed for a git-config failure"; fi
if grep -q '"comments": 1' "$SANDBOX/out"; then fail "F2: output is still raw JSON (comment count instead of bodies)"; fi
# F4 control: a real credential error from tea may still carry the credential note,
# and tea's own line must be relayed so the reader sees the actual cause.
: > "$CALLS"
rc=$(MOCK_TEA_MODE=badtoken run)
[ "$rc" = 0 ] || fail "F4 control: expected rc=0 via API fallback, got $rc"
grep -q 'invalid username, password or token' "$SANDBOX/err" || fail "F4: tea's own error line was not relayed"
if grep -q 'worktreeconfig' "$SANDBOX/err"; then fail "F4: git-config wording printed for a credential failure"; fi
# Negative control: an issue with no comments prints no comment section on the API
# path. Without this, a renderer that always prints a section would pass F2.
: > "$CALLS"
rc=$(MOCK_TEA_MODE=wtconfig MOCK_NO_COMMENTS=1 run)
[ "$rc" = 0 ] || fail "negative control: expected rc=0, got $rc"
if grep -q -- '--- Comments' "$SANDBOX/out"; then fail "negative control: comment section printed for an issue with no comments"; fi
if grep -q "$BODY_MARKER" "$SANDBOX/out"; then fail "negative control: a comment body appeared for an issue with no comments"; fi
echo "issue-view comments regression harness passed"
@@ -0,0 +1,178 @@
#!/usr/bin/env bash
# test-pr-create-fallback-default-base.sh — hermetic test for the API-fallback
# base resolution in pr-create.sh (T51-P2 WP5a / spec E4).
#
# The API-fallback payload historically hardcoded "base": "main", mistargeting
# every fallback PR on repos whose trunk is not main (e.g. mosaicstack/stack,
# default branch "next"). The fix: an explicit -B always wins; with none, the
# base is resolved from the provider API default_branch, and a failed
# resolution fails loud instead of guessing.
#
# Hermetic by construction: every curl invocation is a PATH-first stub; the
# fixture repo's remote is git.example.test (never dialed); HOME is a sandbox
# with no tea config (so the wrapper takes the API fallback path); GITEA_TOKEN
# comes from the environment. No real forge is contacted.
# shellcheck disable=SC2317
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-create-fallback-base}"
PASS=0 FAIL=0 FAILED_CASES=""
ok() { PASS=$((PASS + 1)); }
bad() { FAIL=$((FAIL + 1)); FAILED_CASES="$FAILED_CASES $1"; printf 'FAIL: %s\n' "$1" >&2; }
assert_rc() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected rc=$e got rc=$a)"; }
assert_eq() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected [$e] got [$a])"; }
assert_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) ok ;; *) bad "$d (missing [$n])" ;; esac; }
json_field() { # $1 payload file, $2 field
python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2], ""))' "$1" "$2"
}
rm -rf "$WORK_DIR"
# ---- fixture -----------------------------------------------------------------
ROOT="$WORK_DIR/fixture"
TOOLS="$ROOT/tools/git"
mkdir -p "$TOOLS" "$ROOT/repo" "$ROOT/home" "$ROOT/stub"
cp "$SCRIPT_DIR/pr-create.sh" "$TOOLS/pr-create.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$TOOLS/detect-platform.sh"
git -C "$ROOT/repo" init -q -b fix/e4
git -C "$ROOT/repo" -c user.name=fixture -c user.email=fixture@test commit -q --allow-empty -m base
git -C "$ROOT/repo" remote add origin https://git.example.test/acme/widgets.git
# curl stub: GET repo -> default_branch JSON (or failure mode); POST pulls ->
# capture payload, answer with a minimal PR JSON. Every call is logged.
cat > "$ROOT/stub/curl" <<STUB
#!/usr/bin/env bash
set -u
mode="\${CURL_STUB_GET_MODE:-ok}"
printf '%s\n' "\$*" >> "$ROOT/curl-calls.log"
url="\${!#}"
if [[ "\$url" == */api/v1/repos/acme/widgets ]]; then
# repo GET (default-branch resolution). Modes cover the value shapes the
# resolver must accept or refuse (T51P2WP5AR B2/B3).
case "\$mode" in
ok) printf '%s\n' '{"id":1,"default_branch":"next","full_name":"acme/widgets"}' ;;
fail) echo "curl stub: simulated repo lookup failure" >&2; exit 1 ;;
fail-json) printf '%s\n' '{"default_branch":"next"}'; exit 22 ;;
null) printf '%s\n' '{"default_branch":null}' ;;
numeric) printf '%s\n' '{"default_branch":7}' ;;
blank) printf '%s\n' '{"default_branch":" "}' ;;
*) echo "curl stub: unknown GET mode \$mode" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "\$url" == */api/v1/repos/acme/widgets/pulls ]]; then
# PR POST: capture the payload, emit a PR-shaped answer
while [[ \$# -gt 0 ]]; do
case "\$1" in
-d) printf '%s' "\$2" > "$ROOT/payload.json"; shift 2 ;;
*) shift ;;
esac
done
printf '%s\n' '{"number":42,"html_url":"https://git.example.test/acme/widgets/pulls/42"}'
exit 0
fi
echo "curl stub: unexpected URL \$url" >&2
exit 1
STUB
chmod +x "$ROOT/stub/curl"
run_pr_create() { # args... -> sets RC/OUT/ERR
RC=0
OUT=$(cd "$ROOT/repo" && env -i \
PATH="$ROOT/stub:/usr/bin:/bin" \
HOME="$ROOT/home" \
GITEA_TOKEN=stub-token \
bash "$TOOLS/pr-create.sh" "$@" 2>"$ROOT/err.txt")
RC=$?
ERR="$(cat "$ROOT/err.txt")"
}
calls_matching() { grep -c -- "$1" "$ROOT/curl-calls.log" 2>/dev/null || true; }
echo "== (1) no -B: fallback base resolves to the forge default branch, not main =="
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
run_pr_create -t "fix thing"
assert_rc "rc" 0 "$RC"
assert_contains "API fallback path taken (tea login unresolvable in fixture)" "$ERR" "trying Gitea API fallback"
assert_eq "repo GET performed" 1 "$(calls_matching '/api/v1/repos/acme/widgets$')"
assert_eq "POST performed" 1 "$(calls_matching '/pulls$')"
assert_eq "payload base is forge default (next)" "next" "$(json_field "$ROOT/payload.json" base)"
assert_eq "payload head" "fix/e4" "$(json_field "$ROOT/payload.json" head)"
assert_eq "payload title" "fix thing" "$(json_field "$ROOT/payload.json" title)"
echo "== (2) explicit -B wins; the default branch is not consulted =="
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
run_pr_create -t "fix thing" -B release/1.x
assert_rc "rc" 0 "$RC"
assert_eq "repo GET not consulted for explicit base" 0 "$(calls_matching '/api/v1/repos/acme/widgets$')"
assert_eq "payload base is the explicit -B" "release/1.x" "$(json_field "$ROOT/payload.json" base)"
echo "== (3) default-branch lookup failure: loud refusal, no POST =="
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
RC=0
OUT=$(cd "$ROOT/repo" && env -i \
PATH="$ROOT/stub:/usr/bin:/bin" \
HOME="$ROOT/home" \
GITEA_TOKEN=stub-token \
CURL_STUB_GET_MODE=fail \
bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt")
RC=$?
ERR="$(cat "$ROOT/err.txt")"
assert_rc "nonzero rc on unresolvable base" 1 "$RC"
assert_contains "loud error names -B" "$ERR" "could not resolve the forge default branch"
assert_contains "error names the remedy" "$ERR" "pass -B <branch> explicitly"
assert_eq "no POST issued" 0 "$(calls_matching '/pulls$')"
echo "== (4) payload never contains the literal fallback main =="
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
run_pr_create -t "fix thing"
assert_rc "rc" 0 "$RC"
assert_eq "base field is next, never main" "next" "$(json_field "$ROOT/payload.json" base)"
echo "== (5) B2: HTTP failure with parseable JSON on stdout is a FAILED resolution =="
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
RC=0
OUT=$(cd "$ROOT/repo" && env -i \
PATH="$ROOT/stub:/usr/bin:/bin" \
HOME="$ROOT/home" \
GITEA_TOKEN=stub-token \
CURL_STUB_GET_MODE=fail-json \
bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt")
RC=$?
ERR="$(cat "$ROOT/err.txt")"
assert_rc "nonzero rc on HTTP failure despite valid JSON" 1 "$RC"
assert_contains "loud error names -B" "$ERR" "could not resolve the forge default branch"
assert_contains "error names the remedy" "$ERR" "pass -B <branch> explicitly"
assert_eq "no POST issued" 0 "$(calls_matching '/pulls$')"
echo "== (6) B3: null / numeric / blank default_branch are failed resolutions =="
for bad in null numeric blank; do
: > "$ROOT/curl-calls.log"; rm -f "$ROOT/payload.json"
RC=0
OUT=$(cd "$ROOT/repo" && env -i \
PATH="$ROOT/stub:/usr/bin:/bin" \
HOME="$ROOT/home" \
GITEA_TOKEN=stub-token \
CURL_STUB_GET_MODE="$bad" \
bash "$TOOLS/pr-create.sh" -t "fix thing" 2>"$ROOT/err.txt")
RC=$?
ERR="$(cat "$ROOT/err.txt")"
assert_rc "B3 $bad: nonzero rc" 1 "$RC"
assert_contains "B3 $bad: loud error" "$ERR" "could not resolve the forge default branch"
assert_eq "B3 $bad: no POST issued" 0 "$(calls_matching '/pulls$')"
done
echo
echo "pass=$PASS fail=$FAIL"
if [ "$FAIL" -gt 0 ]; then
echo "FAILED CASES:$FAILED_CASES"
exit 1
fi
echo "ALL GREEN"
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# pr-merge must forward --no-ci-expected to the queue guard, and only then.
# The flag is the sanctioned merge path for a repository with no CI configured
# (see test-ci-queue-wait-no-ci-expected.sh for the guard-side semantics);
# this harness pins only the pass-through: present when requested, absent when
# not, with the rest of the guard invocation unchanged.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-no-ci-expected}"
FIXTURE_DIR="$WORK_DIR/tools/git"
CALL_LOG="$WORK_DIR/queue-call.log"
rm -rf "$WORK_DIR"
mkdir -p "$FIXTURE_DIR"
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"main","baseRepository":"mosaicstack/stack","headRefName":"fix/no-ci-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"mosaicstack/stack"}'
SH
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
exit 42
SH
chmod +x "$FIXTURE_DIR"/*.sh
run_merge() {
(
cd "$WORK_DIR"
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
"$FIXTURE_DIR/pr-merge.sh" -n 123 "$@"
) >/dev/null 2>&1
}
fail=0
# With the flag: it must reach the guard invocation.
: > "$CALL_LOG"
set +e
run_merge --no-ci-expected
rc_with=$?
set -e
if [[ "$rc_with" -ne 42 ]]; then
echo "FAIL(with): expected queue stub rc=42 to propagate, got $rc_with" >&2
fail=1
elif ! grep -q -- '--no-ci-expected' "$CALL_LOG"; then
echo "FAIL(with): --no-ci-expected did not reach the queue guard" >&2
cat "$CALL_LOG" >&2
fail=1
fi
# The rest of the guard invocation is unchanged by the flag.
for required in '--purpose merge' '-B fix/no-ci-fixture' '-R mosaicstack/stack' \
'--sha 0123456789abcdef0123456789abcdef01234567'; do
if ! grep -qF -- "$required" "$CALL_LOG"; then
echo "FAIL(with): guard invocation lost '$required'" >&2
cat "$CALL_LOG" >&2
fail=1
fi
done
# Without the flag: it must NOT appear in the guard invocation.
: > "$CALL_LOG"
set +e
run_merge
rc_without=$?
set -e
if [[ "$rc_without" -ne 42 ]]; then
echo "FAIL(without): expected queue stub rc=42 to propagate, got $rc_without" >&2
fail=1
elif grep -q -- '--no-ci-expected' "$CALL_LOG"; then
echo "FAIL(without): --no-ci-expected reached the guard without being requested" >&2
cat "$CALL_LOG" >&2
fail=1
fi
if [[ "$fail" -eq 0 ]]; then
echo "pr-merge no-ci-expected pass-through regression passed"
fi
exit "$fail"
@@ -0,0 +1,333 @@
#!/usr/bin/env bash
# test-repo-decl-consumption.sh — hermetic declaration-consumption suite for the
# git wrappers (T51 WP5b).
#
# Spec of record (brain repo): docs/plans/2026-08-23_repo-structure-declaration.md
# sections 4 (consumption), 5.3 (normalization), 5.4 (hostile-input classes),
# 1.2a (root anchoring). Covers every §5.4 class applicable to consumed fields
# plus the per-tool behaviors (base precedence, transition validation, remote
# fail-closed, absence policy, route context, staged worktree rule).
#
# Red-first usage: WP5B_TOOLS=<dir with PRE-change tool copies> bash $0
# exits nonzero — the declaration-driven arms fail against tools that predate
# the change (evidence captured in the WP5b report).
#
# Hermetic: scratch repos under $TMPDIR, PATH-stubbed curl, sandboxed HOME; no
# network, no live forge, no writes outside the sandbox.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOOLS_SRC="${WP5B_TOOLS:-$SCRIPT_DIR}"
TROOT="${TMPDIR:-/tmp}"
PASS=0 FAIL=0 FAILED_CASES=""
FIXTURES=()
cleanup_all() { local f; for f in "${FIXTURES[@]:-}"; do rm -rf -- "$f"; done; }
trap cleanup_all EXIT INT TERM
ok() { PASS=$((PASS + 1)); }
bad() { FAIL=$((FAIL + 1)); FAILED_CASES="$FAILED_CASES $1"; printf 'FAIL: %s\n' "$1" >&2; }
assert_rc() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected rc=$e got rc=$a)"; }
assert_eq() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected [$e] got [$a])"; }
assert_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) ok ;; *) bad "$d (missing [$n])" ;; esac; }
assert_not_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) bad "$d (unexpected [$n])" ;; *) ok ;; esac; }
assert_count() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected $e got $a)"; }
new_sb() { SB="$(mktemp -d "$TROOT/wp5b-test.XXXXXX")"; FIXTURES+=("$SB"); }
# A fixture repo with the (possibly pre-change) tools + validator installed.
# $1 dir, stdin = declaration JSON ("" = none), $2 = origin url ("" = none)
mkrepo() {
local d="$1" decl_origin="${2:-}"
mkdir -p "$d/tools/git" "$d/tools/structure" "$d/home"
cp "$TOOLS_SRC/repo-decl.sh" "$d/tools/git/" 2>/dev/null || true
cp "$TOOLS_SRC/pr-create.sh" "$TOOLS_SRC/pr-merge.sh" "$TOOLS_SRC/mosaic-worktree.sh" "$TOOLS_SRC/ci-queue-wait.sh" "$TOOLS_SRC/detect-platform.sh" "$d/tools/git/" 2>/dev/null || true
cp "$SCRIPT_DIR/../structure/validate-repo-json.sh" "$d/tools/structure/"
git -C "$d" init -q -b feature/x
git -C "$d" config user.name fixture; git -C "$d" config user.email fixture@test
mkdir -p "$d/.mosaic" "$d/sites"
printf 'base\n' > "$d/f"; git -C "$d" add -A; git -C "$d" commit -q -m base
[ -n "$decl_origin" ] && git -C "$d" remote add origin "$decl_origin"
cat > "$d/.mosaic/repo.json"
# whitespace-only input (the absent-decl arms use <<< "") must mean ABSENT,
# not an invalid-file fixture: strip it to no file.
grep -q '[^[:space:]]' "$d/.mosaic/repo.json" 2>/dev/null || rm -f "$d/.mosaic/repo.json"
}
# The canonical v2 declaration used by default (custom branch names prove the
# tools never hardcode): trunk=dev-trunk release=prod-rel flow=trunk-release.
DECL_TR='{
"schema_version": 2,
"integration_trunk": "dev-trunk",
"release_branch": "prod-rel",
"flow": "trunk-release",
"canonical_remote": "https://git.example.test/acme/widgets",
"canonical_clone": "host:/src/widgets",
"worktree_root": "host:/src/widgets-worktrees",
"worktree_policy": "orchestrator-precreated"
}'
decl_direct() { printf '{\n "schema_version": 2,\n "integration_trunk": "mainline",\n "release_branch": "mainline",\n "flow": "direct",\n "canonical_remote": "https://git.example.test/acme/widgets",\n "canonical_clone": "host:/src/widgets"\n}\n'; }
load_decl_in() { # $1 dir -> runs repo_decl_load in a subshell, prints STATE etc.
( cd "$1" && source "$1/tools/git/repo-decl.sh" && repo_decl_load \
&& printf 'STATE=%s SCHEMA=%s TRUNK=%s RELEASE=%s FLOW=%s ERROR=%s\n' \
"$DECL_STATE" "${DECL_SCHEMA:-}" "${DECL_TRUNK:-}" "${DECL_RELEASE:-}" "${DECL_FLOW:-}" "${DECL_ERROR:-}" )
}
echo "== A. lib classification + hostile inputs (spec 5.4 classes) =="
new_sb; mkrepo "$SB/r1" <<< "$DECL_TR" "https://git.example.test/acme/widgets.git"
A="$(load_decl_in "$SB/r1")"
assert_contains "A1 valid v2 state" "$A" "STATE=valid"
assert_contains "A1 trunk" "$A" "TRUNK=dev-trunk"
assert_contains "A1 release" "$A" "RELEASE=prod-rel"
assert_contains "A1 flow" "$A" "FLOW=trunk-release"
assert_contains "A1 schema" "$A" "SCHEMA=2"
new_sb; mkrepo "$SB/r2" <<< "" "https://git.example.test/acme/widgets"
A="$(load_decl_in "$SB/r2")"
assert_contains "A2 missing file = absent" "$A" "STATE=absent"
new_sb; mkrepo "$SB/r3" <<< '{ not json'
A="$(load_decl_in "$SB/r3")"
assert_contains "A3 malformed JSON = invalid" "$A" "STATE=invalid"
assert_contains "A3 error names the failure" "$A" "VALIDATION_ERROR"
new_sb; mkrepo "$SB/r4" <<< '{"schema_version": 99, "integration_trunk": "x", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b"}'
A="$(load_decl_in "$SB/r4")"
assert_contains "A4 unknown schema_version = invalid" "$A" "STATE=invalid"
new_sb; mkrepo "$SB/r5" <<< '{"integration_trunk": "x", "release_branch": "y"}'
A="$(load_decl_in "$SB/r5")"
assert_contains "A5 v1 validates" "$A" "STATE=valid"
assert_contains "A5 v1 schema recorded" "$A" "SCHEMA=1"
new_sb; mkrepo "$SB/r6" <<< '{"schema_version": 2, "integration_trunk": "x", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b", "surprise": 1}'
A="$(load_decl_in "$SB/r6")"
assert_contains "A6 unknown top-level key = invalid" "$A" "STATE=invalid"
new_sb; mkrepo "$SB/r7" <<< '{"schema_version": 2, "integration_trunk": "bad..name", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b"}'
A="$(load_decl_in "$SB/r7")"
assert_contains "A7 bad ref name = invalid" "$A" "STATE=invalid"
new_sb; mkrepo "$SB/r8" <<< '{"schema_version": 2, "integration_trunk": "a", "release_branch": "b", "flow": "direct", "canonical_remote": "https://a/b"}'
A="$(load_decl_in "$SB/r8")"
assert_contains "A8 cross-field violation = invalid" "$A" "STATE=invalid"
new_sb; mkrepo "$SB/r9" <<< '{"schema_version": 2, "integration_trunk": "a", "release_branch": "b", "flow": "trunk-release", "canonical_remote": "https://user:pw@a/b"}'
A="$(load_decl_in "$SB/r9")"
assert_contains "A9 userinfo URL = invalid" "$A" "STATE=invalid"
echo "== A2. transitions + remote + path anchoring =="
new_sb; mkrepo "$SB/t1" <<< "$DECL_TR"
T=(); rc=0
T_out="$( cd "$SB/t1" && source tools/git/repo-decl.sh && repo_decl_load
repo_decl_check_transition feat dev-trunk && echo "feat->trunk:ALLOWED"
repo_decl_check_transition dev-trunk prod-rel && echo "trunk->rel:ALLOWED"
repo_decl_check_transition feat prod-rel || echo "feat->rel:REFUSED"
repo_decl_check_transition other other || echo "same:REFUSED"
repo_decl_check_transition feat elsewhere || echo "arbitrary:REFUSED" )"
assert_contains "T1 feature->trunk allowed" "$T_out" "feat->trunk:ALLOWED"
assert_contains "T1 trunk->release allowed" "$T_out" "trunk->rel:ALLOWED"
assert_contains "T1 feature->release refused" "$T_out" "feat->rel:REFUSED"
assert_contains "T1 head==base refused" "$T_out" "same:REFUSED"
assert_contains "T1 arbitrary target refused" "$T_out" "arbitrary:REFUSED"
new_sb; mkrepo "$SB/t2" <<< "$(decl_direct)"
T_out="$( cd "$SB/t2" && source tools/git/repo-decl.sh && repo_decl_load
repo_decl_check_transition feat mainline && echo "direct-ok:ALLOWED"
repo_decl_check_transition feat other || echo "direct-other:REFUSED" )"
assert_contains "T2 direct feature->trunk allowed" "$T_out" "direct-ok:ALLOWED"
assert_contains "T2 direct other base refused" "$T_out" "direct-other:REFUSED"
new_sb; mkrepo "$SB/t3" <<< "$DECL_TR" "https://Git.Example.Test/acme/widgets.git/"
M="$( cd "$SB/t3" && source tools/git/repo-decl.sh && repo_decl_load && repo_decl_remote_matches && echo MATCH )"
assert_contains "T3 normalization: .git/case differences still MATCH" "$M" "MATCH"
new_sb; mkrepo "$SB/t4" <<< "$DECL_TR" "https://git.example.test/acme/OTHER"
M="$( cd "$SB/t4" && source tools/git/repo-decl.sh && repo_decl_load && { repo_decl_remote_matches && echo MATCH; } || echo MISMATCH )"
assert_contains "T4 remote mismatch detected" "$M" "MISMATCH"
new_sb; mkrepo "$SB/t5" <<< "$DECL_TR"
P="$( cd "$SB/t5" && source tools/git/repo-decl.sh && { repo_decl_path "host:/src/x" 2>/dev/null && echo RESOLVED; } || echo FAILCLOSED )"
assert_contains "T5 host:/ resolution fails closed (root unset)" "$P" "FAILCLOSED"
echo "== B. pr-create consumption =="
mkpr() { # $1 dir: install the curl stub + run env; sets PR_RC/PR_OUT/PR_ERR/PR_PAYLOAD
mkdir -p "$1/stub"
cat > "$1/stub/curl" <<'STUB'
#!/usr/bin/env bash
url="${*: -1}"
printf 'curl %s\n' "$*" >> "${STUB_DIR:?}/calls.log"
case "$url" in
*/api/v1/repos/acme/widgets) printf '%s\n' '{"default_branch":"forge-default"}'; exit 0 ;;
*/pulls)
while [[ $# -gt 0 ]]; do
case "$1" in -d) printf '%s' "$2" > "${STUB_DIR:?}/payload.json"; shift 2 ;; *) shift ;; esac
done
printf '%s\n' '{"number":42}'; exit 0 ;;
*) printf '%s\n' '{}'; exit 0 ;;
esac
STUB
chmod +x "$1/stub/curl"
}
run_pr() { # $1 dir, rest args -> pr-create
local prdir="$1"; shift
PR_RC=0
PR_OUT="$(cd "$prdir" && env -i PATH="$prdir/stub:/usr/bin:/bin" HOME="$prdir/home" \
GITEA_TOKEN=stub-token STUB_DIR="$prdir" \
bash "$prdir/tools/git/pr-create.sh" "$@" < /dev/null 2>"$prdir/err.txt")" || PR_RC=$?
PR_ERR="$(cat "$prdir/err.txt")"
PR_PAYLOAD="$(cat "$prdir/payload.json" 2>/dev/null || true)"
PR_GETS="$(grep -c 'repos/acme/widgets$' "$prdir/calls.log" 2>/dev/null || true)"; PR_GETS="${PR_GETS:-0}"
PR_POSTS="$(grep -c '/pulls$' "$prdir/calls.log" 2>/dev/null || true)"; PR_POSTS="${PR_POSTS:-0}"
: > "$prdir/calls.log" 2>/dev/null || true
rm -f "$prdir/payload.json"
}
new_sb; mkrepo "$SB/b1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"; mkpr "$SB/b1"
git -C "$SB/b1" checkout -q -b feature/x 2>/dev/null || true
run_pr "$SB/b1" -t "T"
assert_rc "B1 declared trunk base rc 0" 0 "$PR_RC"
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
assert_eq "B1 payload base = declared trunk (no -B)" "dev-trunk" "$base"
assert_count "B1 zero repo GETs (declared trunk consulted, not the forge default)" 0 "$PR_GETS"
run_pr "$SB/b1" -t "T" -B prod-rel
assert_rc "B2 -B feature->release REFUSED (4.2)" 1 "$PR_RC"
assert_contains "B2 names the transition rule" "$PR_ERR" "not an allowed transition"
assert_count "B2 no POST issued" 0 "$PR_POSTS"
run_pr "$SB/b1" -t "T" -B dev-trunk
assert_rc "B3 -B feature->trunk allowed" 0 "$PR_RC"
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
assert_eq "B3 payload base = explicit allowed -B" "dev-trunk" "$base"
run_pr "$SB/b1" -t "T" -B dev-trunk --head dev-trunk
assert_rc "B4 -B trunk->trunk (head==base) refused" 1 "$PR_RC"
new_sb; mkrepo "$SB/b5" <<< "$DECL_TR" "https://git.example.test/acme/wrong"; mkpr "$SB/b5"
run_pr "$SB/b5" -t "T"
assert_rc "B5 remote mismatch on write path refuses" 1 "$PR_RC"
assert_contains "B5 names the 5.3 rule" "$PR_ERR" "canonical_remote"
assert_count "B5 no POST" 0 "$PR_POSTS"
new_sb; mkrepo "$SB/b6" <<< "" "https://git.example.test/acme/widgets"; mkpr "$SB/b6"
run_pr "$SB/b6" -t "T"
assert_rc "B6 absent decl: legacy forge default rc 0" 0 "$PR_RC"
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
assert_eq "B6 payload base = forge default" "forge-default" "$base"
assert_count "B6 repo GET performed (WP5a floor preserved)" 1 "$PR_GETS"
assert_contains "B6 absence warning present" "$PR_ERR" "unmanaged during rollout"
new_sb; mkrepo "$SB/b7" <<< '{ not json' "https://git.example.test/acme/widgets"; mkpr "$SB/b7"
run_pr "$SB/b7" -t "T"
assert_rc "B7 invalid decl: loud report + legacy proceed" 0 "$PR_RC"
assert_contains "B7 validation error reported" "$PR_ERR" "declaration INVALID"
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
assert_eq "B7 legacy forge default used" "forge-default" "$base"
new_sb; mkrepo "$SB/b8" <<< '{"integration_trunk": "x", "release_branch": "y"}' "https://git.example.test/acme/widgets"; mkpr "$SB/b8"
run_pr "$SB/b8" -t "T"
assert_rc "B8 v1 decl: legacy proceed rc 0" 0 "$PR_RC"
assert_contains "B8 v1 note present" "$PR_ERR" "no consumable flow/trunk fields"
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
assert_eq "B8 legacy forge default used" "forge-default" "$base"
echo "== C. pr-merge transition validation (no hardcoded targets) =="
mkmerge() { # $1 dir, $2 base, $3 head -> stubs pr-metadata; runs pr-merge; sets M_RC/M_OUT/M_ERR
cp "$SCRIPT_DIR/pr-merge.sh" "$1/tools/git/pr-merge.sh" 2>/dev/null || true
cat > "$1/tools/git/pr-metadata.sh" <<EOF
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"$2","headRefName":"$3","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"acme/widgets","title":"t","author":{"login":"a"}}'
EOF
chmod +x "$1/tools/git/pr-metadata.sh"
cat > "$1/tools/git/ci-queue-wait.sh" <<'EOF'
#!/usr/bin/env bash
exit 0
EOF
chmod +x "$1/tools/git/ci-queue-wait.sh"
mkdir -p "$1/stub"
printf '#!/usr/bin/env bash\nexit 0\n' > "$1/stub/curl"; chmod +x "$1/stub/curl"
M_RC=0
M_OUT="$(cd "$1" && env -i PATH="$1/stub:/usr/bin:/bin" HOME="$1/home" GITEA_TOKEN=stub-token \
bash "$1/tools/git/pr-merge.sh" -n 7 --dry-run < /dev/null 2>"$1/merr.txt")" || M_RC=$?
M_ERR="$(cat "$1/merr.txt")"
}
new_sb; mkrepo "$SB/c1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"
mkmerge "$SB/c1" dev-trunk feature/x
assert_rc "C1 feature->trunk under decl (custom trunk name, no hardcode)" 0 "$M_RC"
assert_contains "C1 transition context printed" "$M_ERR" "transition OK under flow=trunk-release"
mkmerge "$SB/c1" prod-rel feature/x
assert_rc "C2 feature->release REJECTED under decl" 1 "$M_RC"
assert_contains "C2 names the declared transition rule" "$M_ERR" "not a declared transition"
mkmerge "$SB/c1" prod-rel dev-trunk
assert_rc "C3 trunk->release promotion allowed" 0 "$M_RC"
new_sb; mkrepo "$SB/c4" <<< "" "https://git.example.test/acme/widgets"
mkmerge "$SB/c4" main feature/x
assert_rc "C4 absent decl: legacy main/next check still enforced (main ok)" 0 "$M_RC"
assert_contains "C4 legacy warning present" "$M_ERR" "LEGACY assumptions"
mkmerge "$SB/c4" trunk-x feature/x
assert_rc "C4 absent decl: unknown target rejected by legacy check" 1 "$M_RC"
new_sb; mkrepo "$SB/c5" <<< "$DECL_TR" "https://git.example.test/acme/wrong"
mkmerge "$SB/c5" dev-trunk feature/x
assert_rc "C5 remote mismatch refuses the merge" 1 "$M_RC"
assert_contains "C5 names 5.3" "$M_ERR" "canonical_remote"
echo "== D. mosaic-worktree staged rule (4.4/4.5) =="
mkwt() { # $1 dir: home outside the repo so derivation passes assert_not_home
mv "$1/home" "$SB/wthome" 2>/dev/null || true
}
new_sb; mkrepo "$SB/d1" <<< '{ not json'; mkwt "$SB/d1"
WT_RC=0
WT_OUT="$(cd "$SB/d1" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
bash "$SB/d1/tools/git/mosaic-worktree.sh" new feat2 < /dev/null 2>"$SB/d1/wterr.txt")" || WT_RC=$?
assert_rc "D1 invalid decl fails branch-creation loud" 1 "$WT_RC"
assert_contains "D1 names the invalid declaration" "$(cat "$SB/d1/wterr.txt")" "INVALID"
new_sb; mkrepo "$SB/d2" <<< ""; mkwt "$SB/d2"
WT_RC=0
WT_OUT="$(cd "$SB/d2" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
bash "$SB/d2/tools/git/mosaic-worktree.sh" new feat3 < /dev/null 2>"$SB/d2/wterr.txt")" || WT_RC=$?
assert_rc "D2 absent decl: warn + proceed" 0 "$WT_RC"
assert_contains "D2 loud warning present" "$(cat "$SB/d2/wterr.txt")" "LEGACY assumptions"
new_sb; mkrepo "$SB/d3" <<< "$DECL_TR"; mkwt "$SB/d3"
WT_RC=0
WT_OUT="$(cd "$SB/d3" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
bash "$SB/d3/tools/git/mosaic-worktree.sh" new feat4 < /dev/null 2>"$SB/d3/wterr.txt")" || WT_RC=$?
assert_rc "D3 valid decl: policy advisory + proceed" 0 "$WT_RC"
assert_contains "D3 precreated policy note" "$(cat "$SB/d3/wterr.txt")" "orchestrator-precreated"
assert_contains "D3 placement stays derived (no decl path used)" "$(cat "$SB/d3/wterr.txt")" "TRANSITIONAL"
echo "== E. ci-queue-wait route context (C4: context only, never a gate) =="
new_sb; mkrepo "$SB/e1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"
git -C "$SB/e1" checkout -q -b dev-trunk 2>/dev/null || { git -C "$SB/e1" branch -q dev-trunk; git -C "$SB/e1" checkout -q dev-trunk; }
E_RC=0
E_OUT="$(cd "$SB/e1" && env -i PATH="/usr/bin:/bin" HOME="$SB/e1/home" \
bash "$SB/e1/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e1/eerr.txt")" || E_RC=$?
E_ERR="$(cat "$SB/e1/eerr.txt")"
assert_contains "E1 route context names trunk head" "$E_ERR" "'dev-trunk' is a trunk (integration head) head"
assert_contains "E1 context names the flow" "$E_ERR" "flow=trunk-release"
new_sb; mkrepo "$SB/e2" <<< "" "https://git.example.test/acme/widgets"
E_RC=0
E_OUT="$(cd "$SB/e2" && env -i PATH="/usr/bin:/bin" HOME="$SB/e2/home" \
bash "$SB/e2/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e2/eerr.txt")" || E_RC=$?
assert_not_contains "E2 absence is SILENT for the guard (N4)" "$(cat "$SB/e2/eerr.txt")" "repo-decl"
cleanup_all
assert_count "final: zero scratch residue" 0 "$(ls -d "$TROOT"/wp5b-test.* 2>/dev/null | wc -l | tr -d ' ')"
echo
echo "pass=$PASS fail=$FAIL"
if [ "$FAIL" -gt 0 ]; then
echo "FAILED CASES:$FAILED_CASES"
exit 1
fi
echo "ALL GREEN"
@@ -515,12 +515,40 @@ FIXTURES="$TMP/fixtures.tsv"
printf '0\t{"tool_input":{"command":"curl -s https://git.example.invalid/api/v1/repos/a/b/issues?q=a%%20b"}}\ta percent-escape in a READ is not this hook'"'"'s business\n'
} > "$FIXTURES"
# Flake containment (#1380-FF, pipelines 2635/2637): a transient failure of the
# ASSERTION TOOLING (grep rc>=2 — fork/alloc error under node pressure) is not a
# contract drift, but the `if ! ... | grep -Fq` shape printed the identical
# FAIL line for both, failing merge verifies on correct guard output. Classify
# instead: retry tool errors 3x; rc=1 is the real mismatch; persistent tool
# failure reports TOOL-ERROR (fail stays 1 — never green on infra noise — but
# the line names the class so a re-run can be judged, not debugged).
assert_out_contains() { # needle why
local attempt rc1
for attempt in 1 2 3; do
printf '%s' "$out" | grep -Fq -- "$1" && return 0
rc1=$?
[ "$rc1" -eq 1 ] && return 1 # grep answered NO — real mismatch
sleep 0.2 # rc>=2: grep itself errored — retry
done
printf 'TOOL-ERROR %s (assertion grep failed 3x — infra/tooling, not guard drift)\n' "$2" >&2
return 2
}
fail=0 n=0
while IFS=$'\t' read -r want payload why remedy; do
[ -n "${want:-}" ] || continue
n=$((n + 1))
out="$(printf '%s' "$payload" | "$GUARD" 2>&1)"
got=$?
# Flake containment (stack#1380-FF, pipeline 2635): a transient failure of
# the ASSERTION TOOLING (grep/fork/alloc error under node pressure) is not a
# contract drift, but this loop's `if !` shape made it indistinguishable
# from one — the harness printed the advice-mismatch FAIL and failed a merge
# verify on a run whose guard output was correct. Distinguish the two: an
# assertion tool that itself fails is retried a bounded number of times, and
# if it never succeeds the case reports a TOOL-ERROR line (fail=1 stays, so
# the run is never green-on-infra-noise, but the line names the real class
# and a re-run can be judged instead of debugged as a guard defect).
if [ "$got" != "$want" ]; then
printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got"
fail=1
@@ -530,10 +558,15 @@ while IFS=$'\t' read -r want payload why remedy; do
# now it was invisible here: the harness read the exit code and nothing else,
# so /issues/1/labels blocking with "use issue-create.sh" passed every run for
# six rounds. Where a fixture states the remediation it expects, assert it.
if [ -n "${remedy:-}" ] && ! printf '%s' "$out" | grep -Fq -- "$remedy"; then
printf 'FAIL %s (blocked, but the advice does not name %s)\n' "$why" "$remedy"
fail=1
continue
if [ -n "${remedy:-}" ]; then
assert_out_contains "$remedy" "$why"
arcret=$?
if [ "$arcret" -eq 1 ]; then
printf 'FAIL %s (blocked, but the advice does not name %s)\n' "$why" "$remedy"
fail=1
continue
fi
[ "$arcret" -eq 0 ] || { fail=1; continue; }
fi
printf 'ok %s\n' "$why"
done < "$FIXTURES"
@@ -572,10 +605,15 @@ home_case() {
fail=1
return
fi
if [ -n "$needle" ] && ! printf '%s' "$out" | grep -Fq -- "$needle"; then
printf 'FAIL %s (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
fail=1
return
if [ -n "$needle" ]; then
assert_out_contains "$needle" "$why"
arcret=$?
if [ "$arcret" -eq 1 ]; then
printf 'FAIL %s (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
fail=1
return
fi
[ "$arcret" -eq 0 ] || { fail=1; return; }
fi
printf 'ok %s\n' "$why"
}
@@ -665,10 +703,15 @@ lone_case() {
fail=1
return
fi
if [ -n "$needle" ] && ! printf '%s' "$out" | grep -Fq -- "$needle"; then
printf 'FAIL %s [standalone] (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
fail=1
return
if [ -n "$needle" ]; then
assert_out_contains "$needle" "$why"
arcret=$?
if [ "$arcret" -eq 1 ]; then
printf 'FAIL %s [standalone] (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
fail=1
return
fi
[ "$arcret" -eq 0 ] || { fail=1; return; }
fi
printf 'ok %s [standalone]\n' "$why"
}
@@ -13,7 +13,6 @@
# --- tools/git: the #1007 five — non-hermetic, resolve real credentials ---
packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix (git -C scoping)
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh | resolves real credentials (#1007 census); joins CI after the wrapper-half hermeticity fix
packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh | resolves real credentials (#1007 census, fourth entry via family-grep); joins CI after the wrapper-half hermeticity fix
packages/mosaic/framework/tools/git/test-issue-comment-readback.sh | resolves real credentials (#1007 census, fifth entry); joins CI after the wrapper-half hermeticity fix
@@ -0,0 +1,289 @@
#!/usr/bin/env bash
# test-validate-repo-json.sh — hostile-input suite for the T51 declaration validator.
# (Vendored with validate-repo-json.sh from mosaic-brain @ 515bcbab — see the
# validator header for provenance.)
#
# Hermetic: all fixtures in a tracked mktemp sandbox removed by an EXIT trap
# (pass and fail paths both — zero residue). No network, no real repos, no host
# state mutated. MOSAIC_HOST_ROOT is set/unset per arm via env only.
#
# T51P2RW1: arms extended per review T51P2R1 (F1-F5): root gate for ordinary
# v2 declarations (unset AND explicitly empty; display warns), git-grammar
# branch arms (double slash, dot component, control byte), contract-escape
# arms (list enums, invalid UTF-8, NaN — one VALIDATION_ERROR line, never a
# traceback), remote normalization (.git/ ordering, port preservation), and
# mirror-component fullmatch arms (trailing newline, control bytes).
set -u
HERE=$(cd "$(dirname "$0")" && pwd)
V="$HERE/validate-repo-json.sh"
PASS=0; FAIL=0; FAILED=""
ok() { PASS=$((PASS+1)); }
bad() { FAIL=$((FAIL+1)); FAILED="$FAILED $1"; printf 'FAIL: %s\n' "$1" >&2; }
SB=$(mktemp -d "${TMPDIR:-/tmp}/vrj-test.XXXXXX")
trap 'rm -rf "$SB"' EXIT
fx() { printf '%s' "$2" > "$SB/$1"; }
run() { # [env KV=V ...] -- args...
local envs=()
while [ "$1" != "--" ]; do envs+=("$1"); shift; done; shift
OUT=$(env "${envs[@]:-_=_}" bash "$V" "$@" 2>&1 < /dev/null; echo "__RC__$?")
RC=${OUT##*__RC__}; OUT=${OUT%__RC__*}; OUT=${OUT%$'\n'}
}
expect_ok() { local d="$1"; shift; run "$@"; if [ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q '^OK'; then ok; else bad "$d (rc=$RC out=$(printf '%s' "$OUT" | head -1))"; fi; }
expect_err() { # desc expected-substring [env... -- args...]
local d="$1" sub="$2"; shift 2
run "$@"
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR.*$sub"; then ok
else bad "$d (rc=$RC, wanted error ~$sub, got: ${OUT%%$'\n'*})"; fi
}
expect_err_notrace() { # like expect_err, plus no traceback anywhere in output
local d="$1" sub="$2"; shift 2
run "$@"
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR.*$sub" && ! printf '%s' "$OUT" | grep -q "Traceback"; then ok
else bad "$d (rc=$RC, wanted clean error ~$sub, got: ${OUT%%$'\n'*})"; fi
}
STACK='{"schema_version":2,"integration_trunk":"next","release_branch":"main","flow":"trunk-release","canonical_remote":"https://git.mosaicstack.dev/mosaicstack/stack","canonical_clone":"host:/src/mosaic-stack","worktree_root":"host:/src/mosaic-stack-worktrees","worktree_policy":"orchestrator-precreated","notes":"x"}'
BRAIN='{"schema_version":2,"integration_trunk":"main","release_branch":"main","flow":"direct","canonical_remote":"https://git.example.invalid/acme/brain","canonical_clone":"host:/.mosaic","worktree_root":"host:/.mosaic-worktrees","worktree_policy":"orchestrator-precreated"}'
ROOT="$SB/hostroot"; mkdir -p "$ROOT"
echo "== (0) syntax + version =="
bash -n "$V" && ok || bad "bash -n"
run -- --version; [ "$RC" = 0 ] && case "$OUT" in validate-repo-json\ *) ok ;; *) bad "version output" ;; esac || bad "version rc"
echo "== (1) spec examples: stack + brain OK (root set) =="
fx stack.json "$STACK"; fx brain.json "$BRAIN"
expect_ok a1 MOSAIC_HOST_ROOT=$ROOT -- "$SB/stack.json"
expect_ok a2 MOSAIC_HOST_ROOT=$ROOT -- "$SB/brain.json"
echo "== (2) malformed JSON (stable contract, no traceback) =="
fx bad.json '{"schema_version": 2, '
expect_err_notrace b1 "json:" -- "$SB/bad.json"
fx arr.json '[1,2]'
expect_err_notrace b2 "top level" -- "$SB/arr.json"
printf '\xff\xfe{"schema_version":2}' > "$SB/utf8.json"
expect_err_notrace b3 "UTF-8" MOSAIC_HOST_ROOT=$ROOT -- "$SB/utf8.json"
fx nan.json '{"schema_version":NaN}'
expect_err_notrace b4 "malformed JSON" MOSAIC_HOST_ROOT=$ROOT -- "$SB/nan.json"
echo "== (3) unknown schema_version = ABSENT-loud =="
fx v3.json "${STACK/schema_version\":2/schema_version\":3}"
expect_err c1 "schema_version" MOSAIC_HOST_ROOT=$ROOT -- "$SB/v3.json"
echo "== (4) v1 mode + authoring rule (v1 consumes no paths: no root needed) =="
fx v1.json '{"integration_trunk":"next","release_branch":"main"}'
expect_ok d1 -- "$SB/v1.json"
expect_err d2 "schema_version" -- --require-v2 "$SB/v1.json"
fx v1x.json '{"integration_trunk":"next","release_branch":"main","notes":"no"}'
expect_err d3 "x_extensions" -- "$SB/v1x.json"
echo "== (5) unknown top-level key rejected; x_extensions home OK =="
fx unk.json "${STACK%\}*},\"typo_key\":1}"
expect_err e1 "typo_key" MOSAIC_HOST_ROOT=$ROOT -- "$SB/unk.json"
fx ext.json "${STACK%\}*},\"x_extensions\":{\"future\":true}}"
expect_ok e2 MOSAIC_HOST_ROOT=$ROOT -- "$SB/ext.json"
echo "== (6) flow: required (no defaulting) + cross-field =="
fx noflow.json "$(printf '%s' "$STACK" | python3 -c 'import json,sys; d=json.load(sys.stdin); del d["flow"]; print(json.dumps(d))')"
expect_err f1 "flow" MOSAIC_HOST_ROOT=$ROOT -- "$SB/noflow.json"
fx xdirect.json "${STACK/\"trunk-release\"/\"direct\"}"
expect_err f2 "direct" MOSAIC_HOST_ROOT=$ROOT -- "$SB/xdirect.json"
fx xtr.json "${BRAIN/\"direct\"/\"trunk-release\"}"
expect_err f3 "trunk-release" MOSAIC_HOST_ROOT=$ROOT -- "$SB/xtr.json"
echo "== (7) dot-segment / empty-segment / tilde escapes =="
fx dots.json "${STACK/host:\/src\/mosaic-stack\"/host:/src/../secrets\"}"
expect_err g1 "dot segment" MOSAIC_HOST_ROOT=$ROOT -- "$SB/dots.json"
fx dot1.json "${STACK/host:\/src\/mosaic-stack\"/host:/src/./mosaic-stack\"}"
expect_err g2 "dot segment" MOSAIC_HOST_ROOT=$ROOT -- "$SB/dot1.json"
fx empty.json "${STACK/host:\/src\/mosaic-stack\"/host://src/mosaic-stack\"}"
expect_err g3 "empty segment" MOSAIC_HOST_ROOT=$ROOT -- "$SB/empty.json"
fx tild.json "${STACK/host:\/src\/mosaic-stack\"/~jw/src/mosaic-stack\"}"
expect_err g4 "tilde" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tild.json"
fx tailslash.json "${STACK/host:\/src\/mosaic-stack\"/host:/src/mosaic-stack/\"}"
expect_err g5 "empty segment" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tailslash.json"
fx noanchor.json "${STACK/host:\/src\/mosaic-stack\"//src/mosaic-stack\"}"
expect_err g6 "host:/" MOSAIC_HOST_ROOT=$ROOT -- "$SB/noanchor.json"
echo "== (8) branch-name grammar (delegated to git check-ref-format, F2) =="
fx badbr.json "${STACK/\"next\"/\"bad..name\"}"
expect_err h1 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/badbr.json"
fx sp.json "${STACK/\"next\"/\"fea ture\"}"
expect_err h2 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/sp.json"
fx lock.json "${STACK/\"next\"/\"feature/x.lock\"}"
expect_err h3 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/lock.json"
fx slash.json "${STACK/\"next\"/\"feature/x\"}"
expect_ok h4 MOSAIC_HOST_ROOT=$ROOT -- "$SB/slash.json"
fx dslash.json "${STACK/\"next\"/\"feature//x\"}"
expect_err h5 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/dslash.json"
fx hidden.json "${STACK/\"next\"/\"feature/.hidden\"}"
expect_err h6 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/hidden.json"
fx ctrl.json "$(printf '%s' "$STACK" | python3 -c 'import json,sys; d=json.load(sys.stdin); d["integration_trunk"]="feature/\x01x"; print(json.dumps(d))')"
expect_err h7 "branch name" MOSAIC_HOST_ROOT=$ROOT -- "$SB/ctrl.json"
echo "== (8b) reflog shorthand rejected independent of ambient checkout history (B1) =="
# Hermetic repo WITH checkout history: proves '@{-1}' (which git would expand to
# 'main' from THIS repo's reflog) is still refused by the pre-delegation gate.
HISTREPO="$SB/histrepo"; mkdir -p "$HISTREPO"
(cd "$HISTREPO" && git init -q -b main . \
&& git -c user.name=t -c user.email=t@t commit -q --allow-empty -m m \
&& git checkout -q -b feature/x \
&& git checkout -q main \
&& git check-ref-format --branch "@{-1}" >/dev/null 2>&1 && echo "ambient-expandable" || echo "not-expandable") \
| grep -q ambient-expandable && ok || bad "fixture repo failed to make @{-1} expandable"
fx atminus1.json "$(printf '%s' "$STACK" | python3 -c 'import json,sys; d=json.load(sys.stdin); d["integration_trunk"]="@{-1}"; print(json.dumps(d))')"
# Run the validator from INSIDE the history repo via command substitution so the
# assertion runs in the PARENT shell (T51P2R3 B1: the previous ( subshell ) form
# mutated ok/bad counters only in a dead subshell — FAIL printed, suite rc 0).
OUTX=$(cd "$HISTREPO" && MOSAIC_HOST_ROOT=$ROOT bash "$V" "$SB/atminus1.json" 2>&1 </dev/null; echo "__RC__$?")
RCX=${OUTX##*__RC__}
if [ "$RCX" = 1 ] && printf '%s' "$OUTX" | grep -q "VALIDATION_ERROR.*@{"; then ok
else bad "@{-1} must be rejected inside a repo with checkout history (got rc=$RCX)"; fi
fx atbrace.json "$(printf '%s' "$STACK" | python3 -c 'import json,sys; d=json.load(sys.stdin); d["integration_trunk"]="@{u}"; print(json.dumps(d))')"
expect_err h9 "@{" MOSAIC_HOST_ROOT=$ROOT -- "$SB/atbrace.json"
echo "== (9) canonical_remote: userinfo, list-type, normalization (F3/F4) =="
fx user.json "${STACK/https:\/\/git.mosaicstack.dev/https:\/\/bot:s3cret@git.mosaicstack.dev}"
expect_err_notrace i1 "userinfo" MOSAIC_HOST_ROOT=$ROOT -- "$SB/user.json"
fx listflow.json "${STACK/\"trunk-release\"/[\"trunk-release\"]}"
expect_err_notrace i2 "flow" MOSAIC_HOST_ROOT=$ROOT -- "$SB/listflow.json"
fx listpol.json "${STACK/\"orchestrator-precreated\"/[\"tool-managed\"]}"
expect_err_notrace i3 "worktree_policy" MOSAIC_HOST_ROOT=$ROOT -- "$SB/listpol.json"
run -- --normalize-remote "HTTPS://Git.Example.Invalid/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://git.example.invalid/o/r" ] && ok || bad "norm .git/case ($OUT)"
run -- --normalize-remote "https://git.mosaicstack.dev/mosaicstack/stack/"
[ "$RC" = 0 ] && [ "$OUT" = "https://git.mosaicstack.dev/mosaicstack/stack" ] && ok || bad "norm trailing slash ($OUT)"
run -- --normalize-remote "git.mosaicstack.dev/mosaicstack/stack"
[ "$RC" = 1 ] && ok || bad "schemeless must fail"
run -- --normalize-remote "HTTPS://Git.Example.Invalid/o/r.git/"
[ "$RC" = 0 ] && [ "$OUT" = "https://git.example.invalid/o/r" ] && ok || bad "norm .git-then-slash ($OUT)"
run -- --normalize-remote "https://Git.Example.Invalid:8443/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://git.example.invalid:8443/o/r" ] && ok || bad "port must be preserved ($OUT)"
run -- --normalize-remote "https://[2001:db8::1]:8443/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://[2001:db8::1]:8443/o/r" ] && ok || bad "IPv6 must stay bracketed with port ($OUT)"
run -- --normalize-remote "https://[2001:db8::1]/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://[2001:db8::1]/o/r" ] && ok || bad "IPv6 must stay bracketed ($OUT)"
run -- --normalize-remote "https://Git.Example.Invalid:0/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://git.example.invalid:0/o/r" ] && ok || bad "explicit port 0 must be preserved ($OUT)"
run -- --normalize-remote "https://[::1].evil.example/o/r.git"
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "suffix after ] must be rejected (.evil.example)"
run -- --normalize-remote "https://[::1]x:8443/o/r.git"
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "suffix after ] must be rejected (x:8443)"
run -- --normalize-remote "https://[::1]x/o/r.git"
[ "$RC" = 1 ] && ok || bad "suffix after ] must be rejected (x)"
echo "== (9b) IPvFuture bracketed authorities (R4-B1: guard keys off raw netloc) =="
run -- --normalize-remote "https://[v1.fe80]/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://[v1.fe80]/o/r" ] && ok || bad "valid IPvFuture must keep brackets ($OUT)"
run -- --normalize-remote "https://[vF.foo]:8443/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://[vf.foo]:8443/o/r" ] && ok || bad "valid IPvFuture+port must keep brackets ($OUT)"
run -- --normalize-remote "https://[v1.fe80]evil/o/r.git"
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "IPvFuture suffix must be rejected (evil)"
run -- --normalize-remote "https://[v1.fe80].evil.example/o/r.git"
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "IPvFuture suffix must be rejected (.evil.example)"
run -- --normalize-remote "https://[vF.foo]x:8443/o/r.git"
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "IPvFuture suffix must be rejected (x:8443)"
echo "== (9d) non-bracketed authority grammar (R6-B1) =="
for U in "https://:8443/o/r.git" "https://bad host/o/r.git" "https://bad^host/o/r.git" "https://bad\\host/o/r.git" "https://bad%zz/o/r.git" "https://bad%2/o/r.git" "https://bad%/o/r.git"; do
run -- --normalize-remote "$U"
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR canonical_remote"; then ok
else bad "non-bracketed authority must be rejected: $U (rc=$RC out=$OUT)"; fi
done
run -- --normalize-remote "https://git.mosaicstack.dev:9000/mosaicstack/stack"
[ "$RC" = 0 ] && [ "$OUT" = "https://git.mosaicstack.dev:9000/mosaicstack/stack" ] && ok || bad "valid host:port unchanged ($OUT)"
run -- --normalize-remote "https://192.168.1.10:8443/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://192.168.1.10:8443/o/r" ] && ok || bad "IPv4 reg-name stays valid ($OUT)"
run -- --normalize-remote "https://bad%2Fx/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://bad%2fx/o/r" ] && ok || bad "complete %HH must stay legal, case-normalized ($OUT)"
echo "== (9e) ASCII-only authority bytes (R7-B1) =="
# isolated port arm (R8): VALID ASCII host + full-width-digit port ONLY —
# unconfounded, so restoring Unicode-aware isdigit() goes red right here.
run -- --normalize-remote "https://git.example.invalid:443/o/r.git"
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "ASCII digits only"; then ok
else bad "full-width-digit port on a VALID host must be rejected with the port reason (rc=$RC out=$OUT)"; fi
for U in "https://éxample.invalid/o/r.git" "https://例え.テスト/o/r.git" "https://fullwidth.invalid/o/r.git" "https://mosaicstack.dev:443/o/r.git"; do
run -- --normalize-remote "$U"
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR canonical_remote"; then ok
else bad "non-ASCII authority must be rejected: $U (rc=$RC out=$OUT)"; fi
done
run -- --normalize-remote "https://xn--xample-9ua.invalid/o/r.git"
[ "$RC" = 0 ] && [ "$OUT" = "https://xn--xample-9ua.invalid/o/r" ] && ok || bad "punycode xn-- host must stay legal ($OUT)"
echo "== (9c) bracket-payload grammar + raw control bytes (R5-B1) =="
for P in "v1. " "v1.a b" "v1.a^b" "v1.a\\b" "v1.%20" "not-an-ip" "::gg::1"; do
run -- --normalize-remote "https://[$P]/o/r.git"
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "VALIDATION_ERROR canonical_remote"; then ok
else bad "bracket payload [$P] must be rejected (rc=$RC out=$OUT)"; fi
done
for CB in $'\t' $'\n' $'\r'; do
run -- --normalize-remote "https://[v1.a${CB}b]/o/r.git"
if [ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "control byte"; then ok
else bad "raw control byte must be rejected before urlsplit (rc=$RC out=$OUT)"; fi
done
run -- --normalize-remote "https://[v1.fe80%zone]/o/r.git"
[ "$RC" = 1 ] && ok || bad "percent (not in RFC host grammar) must be rejected ($OUT)"
run -- --normalize-remote "https://[fe80::1%eth0]/o/r.git"
[ "$RC" = 1 ] && printf '%s' "$OUT" | grep -q "canonical_remote" && ok || bad "IPv6 zone-id (not RFC host grammar) must be rejected ($OUT)"
echo "== (10) root gate: every v2 managed validation fails closed (F1) =="
# NOTE (spec §4.5): host:/ paths resolve UNDER MOSAIC_HOST_ROOT by construction,
# so tool-managed is not declarable today — the cross-check fails for every
# host:/ root until the anchor scheme grows an outside-root form (J3 era).
expect_err j1 "MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT= -- "$SB/stack.json"
expect_err j2 "MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT= -- "$SB/brain.json"
expect_err j3 "MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT= -- "$SB/stack.json"
# rider (T51P2R2): genuine ABSENCE, not just explicitly empty — captured via
# command substitution, asserted in the parent shell (no subshell-counter shape).
OUTU=$(cd "$SB" && env -u MOSAIC_HOST_ROOT bash "$V" "$SB/stack.json" 2>&1 </dev/null; echo "__RC__$?")
RCU=${OUTU##*__RC__}
if [ "$RCU" = 1 ] && printf '%s' "$OUTU" | grep -q "VALIDATION_ERROR.*MOSAIC_HOST_ROOT"; then ok
else bad "unset-by-absence root must fail closed in managed mode (rc=$RCU)"; fi
run MOSAIC_HOST_ROOT= -- --mode display "$SB/stack.json"
if [ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q '^OK' && printf '%s' "$OUT" | grep -q "host root unset"; then ok
else bad "display-mode unset must pass with the specified warning (rc=$RC)"; fi
run MOSAIC_HOST_ROOT= -- --mode display "$SB/brain.json"
if [ "$RC" = 0 ] && printf '%s' "$OUT" | grep -q "host root unset"; then ok
else bad "display-mode warn missing for brain fixture"; fi
TM='{"schema_version":2,"integration_trunk":"next","release_branch":"main","flow":"trunk-release","canonical_remote":"https://git.mosaicstack.dev/mosaicstack/stack","canonical_clone":"host:/src/mosaic-stack","worktree_root":"host:/src/mosaic-stack-worktrees","worktree_policy":"tool-managed"}'
fx tm.json "$TM"; mkdir -p "$ROOT/src"
expect_err j4 "inside MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tm.json"
fx tm_noroot.json "$(printf '%s' "$TM" | python3 -c 'import json,sys; d=json.load(sys.stdin); del d["worktree_root"]; print(json.dumps(d))')"
expect_err j5 "worktree_root" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tm_noroot.json"
echo "== (10b) symlink escape cannot fake outside-ness (B3 fix: lexical containment) =="
OUTSIDE="$SB/outside-target"; mkdir -p "$OUTSIDE"
ln -s "$OUTSIDE" "$ROOT/escape"
TM_ESC="${TM/host:\/src\/mosaic-stack-worktrees/host:/escape/worktrees}"
fx tmsym.json "$TM_ESC"
expect_err j6 "inside MOSAIC_HOST_ROOT" MOSAIC_HOST_ROOT=$ROOT -- "$SB/tmsym.json"
echo "== (11) mirror-path components: collision/delimiter/control fixtures (F5) =="
run -- --mirror-path git.mosaicstack.dev mosaicstack stack
[ "$RC" = 0 ] && [ "$OUT" = "projects/git.mosaicstack.dev/mosaicstack/stack/repo.json" ] && ok || bad "mirror path ok ($OUT)"
expect_err k1 "mirror-component" -- --mirror-path "git.mosaicstack.dev" "a__b" "c"
expect_err k2 "mirror-component" -- --mirror-path "git.mosaicstack.dev" "a" "b__c"
expect_err k3 "mirror-component" -- --mirror-path "git.mosaicstack.dev/x" "a" "b"
expect_err k4 "mirror-component" -- --mirror-path "git.mosaicstack.dev" "A" "B"
expect_err k5 "mirror-component" -- --mirror-path "git.mosaicstack.dev" "" "stack"
run -- --mirror-path git.mosaicstack.dev a b.c
[ "$RC" = 0 ] && [ "$OUT" = "projects/git.mosaicstack.dev/a/b.c/repo.json" ] && ok || bad "distinct path ($OUT)"
run -- --mirror-path $'git.example.invalid\n' owner repo
[ "$RC" = 1 ] && ok || bad "trailing-newline host must be rejected (fullmatch)"
run -- --mirror-path $'git.\texample' owner repo
[ "$RC" = 1 ] && ok || bad "control-byte host must be rejected"
echo "== (12) missing required keys =="
for key in release_branch canonical_clone; do
fx miss.json "$(printf '%s' "$STACK" | python3 -c "import json,sys; d=json.load(sys.stdin); del d['$key']; print(json.dumps(d))")"
expect_err "l-$key" "$key" MOSAIC_HOST_ROOT=$ROOT -- "$SB/miss.json"
done
echo "== (13) absent file =="
expect_err m1 "file" -- "$SB/nonexistent.json"
echo
echo "pass=$PASS fail=$FAIL"
if [ "$FAIL" -gt 0 ]; then echo "FAILED:$FAILED"; exit 1; fi
echo "ALL GREEN"
@@ -0,0 +1,386 @@
#!/usr/bin/env bash
# validate-repo-json.sh — declaration validator for T51 repo structure declarations.
#
# Spec of record: docs/plans/2026-08-23_repo-structure-declaration.md @ 1896adc1
# (R3). Implements the spec's validation surface: schema v1/v2 (§1.2), host:/
# path grammar with canonical segment normalization — empty/./.. rejected
# BEFORE resolution — and tilde rejection (§1.2a), mirror path component
# validation (§3.1), cross-field rules (§5.2), remote normalization (§5.3).
#
# PROVENANCE (T51 WP5c vendoring): ported verbatim from the mosaic-brain tree —
# tools/repo-structure-decl/validate-repo-json.sh @ brain main merge 515bcbab
# (PR 28, wave-1 R9 PASS, 101-arm suite green). This file is now the framework
# home per spec §5.1 ("shipped in the framework package"); the brain copy is the
# development origin. Re-sync rule: changes land here via reviewed PR and are
# back-ported to the brain tree (or the brain copy retires) — never fork silently.
# Validator version at port: 1.1.0+t51spec-r3+t51p2rw1 (R2-R8 rework included).
# No operator literal appears in this file; the host root is read from
# MOSAIC_HOST_ROOT configuration only.
#
# Unset-root semantics (spec §1.2a, fail-closed; T51P2R1 F1): v2 declarations
# always consume a path (canonical_clone is required), so in --mode managed an
# unset OR EMPTY MOSAIC_HOST_ROOT is a VALIDATION_ERROR for every v2 file —
# not only tool-managed. In --mode display it warns and omits root-dependent
# resolution; grammar checks still run.
#
# Error contract (T51P2R1 F3): every malformed input — bad UTF-8, non-RFC JSON
# constants (NaN/Infinity), wrong-typed enums, anything unexpected — yields
# exactly one stable VALIDATION_ERROR line and exit 1. No traceback ever
# escapes. Branch names are validated by delegating to `git check-ref-format
# --branch` (F2), translated into this contract.
#
# Usage:
# validate-repo-json.sh <repo.json> [--mode managed|display] [--require-v2]
# validate-repo-json.sh --mirror-path <host> <owner> <repo> # §3.1 component check
# validate-repo-json.sh --normalize-remote <url> # §5.3, prints normalized
# validate-repo-json.sh --version
#
# Output: OK (exit 0) | VALIDATION_ERROR <key>: <reason> (exit 1) | warnings on stderr.
set -euo pipefail
VERSION="1.1.0+t51spec-r3+t51p2rw1"
if [ "${1:-}" = "--version" ]; then echo "validate-repo-json $VERSION"; exit 0; fi
exec python3 - "$@" <<'PYEOF'
import json, os, re, subprocess, sys, urllib.parse
def err(key, reason):
print(f"VALIDATION_ERROR {key}: {reason}")
sys.exit(1)
def warn(msg):
print(f"warning: {msg}", file=sys.stderr)
def main():
ARGS = sys.argv[1:]
MODE = "managed"
REQUIRE_V2 = False
# ---- subcommands first (they take no file argument) ----
def check_mirror_components(host, owner, repo):
# fullmatch: '$' must bind at true end (F5 — a trailing newline must
# NOT pass); the charset excludes control bytes outright.
comp_re = re.compile(r"[a-z0-9][a-z0-9.-]*")
for label, value in (("host", host), ("owner", owner), ("repo", repo)):
if not isinstance(value, str) or not value or not comp_re.fullmatch(value):
err("mirror-component", f"{label} {value!r} fails §3.1 charset ^[a-z0-9][a-z0-9.-]*$ (fullmatch, no '/', no delimiter, no control bytes)")
return f"projects/{host}/{owner}/{repo}/repo.json"
def normalize_remote(url):
# R5-B1 part 1: reject raw control bytes BEFORE urlsplit — urlsplit
# silently strips TAB/LF/CR, so different input bytes would normalize
# to a different host. The raw bytes ARE the input; nothing may rewrite them.
for ch in url:
if ord(ch) < 0x20 or ord(ch) == 0x7F:
err("canonical_remote", "control byte in URL rejected before parsing (urlsplit would strip it and change the host)")
try:
p = urllib.parse.urlsplit(url)
except ValueError as e:
# py3.12 urlsplit itself validates bracketed hosts (ipaddress) and
# raises for garbage authorities — translate, never traceback.
err("canonical_remote", f"invalid URL authority: {e}")
if not p.scheme or not p.netloc:
err("canonical_remote", f"not a URL with scheme+host: {url!r}")
if p.username or p.password or "@" in (p.netloc or ""):
err("canonical_remote", "userinfo in URL is rejected (§5.3)")
scheme = p.scheme.lower()
# T51P2R4 B1: bracketing is detected from the RAW netloc ('[' prefix),
# not from a ':' in the parsed hostname — IPvFuture literals ([v1.fe80])
# contain no colon and must not bypass the raw-authority proof.
bracketed = p.netloc.startswith("[")
if bracketed:
# Prove the RAW authority is exactly '[host]' + optional ':port'
# (case-normalized); any text after ']' is hostile/truncated input,
# rejected — never silently rewritten.
import re as _re
import ipaddress as _ip
m = _re.fullmatch(r"\[([^\]]*)\](?::([0-9]+))?", p.netloc)
if not m:
err("canonical_remote", f"malformed bracketed authority {p.netloc!r}: text after ']' is rejected (no silent truncation)")
payload = m.group(1)
# R5-B1 part 2: the bracket payload must be a REAL RFC literal —
# an IPv6 address (ipaddress parse) or an IPvFuture literal
# ("v" + HEXDIG+ + "." + unreserved / sub-delims / ":" only).
# Anything else inside brackets is rejected, closing the payload
# grammar as a class.
if _re.fullmatch(r"v[0-9A-Fa-f]+\.[A-Za-z0-9._~!$&'()*+,;=:-]*", payload):
pass # IPvFuture (case-normalized below)
elif _re.fullmatch(r"[0-9A-Fa-f:.]+", payload):
# strict IPv6 lexical form (hex/colon/dot only — ipaddress alone
# would also accept scoped zone-ids like fe80::1%eth0, which are
# not valid URI host grammar unless %25-encoded)
try:
_ip.IPv6Address(payload)
except ValueError:
err("canonical_remote",
f"bracket payload {payload!r} is not a valid IPv6 address")
else:
err("canonical_remote",
f"bracket payload {payload!r} is neither a valid IPv6 address nor an IPvFuture literal (v+HEXDIG+.+unreserved/sub-delims/colon)")
host = f"[{payload.lower()}]" # brackets preserved (IPv6 + IPvFuture)
else:
# R6-B1: the non-bracketed branch — urlsplit PARSES but does not
# VALIDATE reg-name, and netloc-nonempty is not host presence.
# Split the raw authority ourselves (host[:port]) and validate the
# raw host against real grammar: unreserved / sub-delims / complete
# %HH octets (reg-name), or IPv4 dotted-quad (reg-name's numeric
# case). Port must be all digits. No branch trusts urlsplit alone.
import re as _re
raw_host, sep, raw_port = p.netloc.rpartition(":")
if sep and _re.fullmatch(r"[0-9]+", raw_port):
pass # host:port split
elif sep:
err("canonical_remote", f"invalid port {raw_port!r} in authority {p.netloc!r} (ASCII digits only)")
else:
raw_host, raw_port = p.netloc, None
if not raw_host:
err("canonical_remote", f"empty host in authority {p.netloc!r}")
# strict reg-name / IPv4 scan: unreserved + sub-delims, with '%'
# only inside complete %HH octets (IPv4 dotted-quad is a subset of
# this charset — digits and dots — so one scan covers both).
import re as _re
i = 0
ok_host = True
while i < len(raw_host):
c = raw_host[i]
if c == "%":
if i + 2 >= len(raw_host) or not _re.fullmatch(r"[0-9A-Fa-f]{2}", raw_host[i+1:i+3]):
ok_host = False; break
i += 3
elif c in "!$&'()*+,;=-._~" or ("a" <= c <= "z") or ("A" <= c <= "Z") or ("0" <= c <= "9"):
# R7-B1: EXPLICIT ASCII only — str.isalnum() is Unicode-aware
# and admits non-ASCII letters/digits (é, full-width ). Policy
# is ASCII-only reg-name; punycode xn-- is the sanctioned
# Unicode spelling and remains legal under this charset.
i += 1
else:
ok_host = False; break
if not ok_host:
err("canonical_remote", f"host {raw_host!r} is not valid reg-name/IPv4 grammar (unreserved/sub-delims/complete %HH only)")
host = raw_host.lower()
port = p.port # None when absent; preserved whenever explicitly present (B2: incl. 0)
authority = host + (f":{port}" if port is not None else "")
path = p.path or "/"
# canonical trailing-slash + .git strip as ONE operation (F4): slash
# first, then .git, then any slash exposed by that strip.
path = path.rstrip("/")
if path.endswith(".git"):
path = path[:-4].rstrip("/")
return f"{scheme}://{authority}{path or ''}"
if "--mirror-path" in ARGS:
idx = ARGS.index("--mirror-path")
parts = ARGS[idx + 1:]
if len(parts) != 3:
err("usage", "--mirror-path takes <host> <owner> <repo>")
print(check_mirror_components(*parts))
sys.exit(0)
if "--normalize-remote" in ARGS:
idx = ARGS.index("--normalize-remote")
vals = ARGS[idx + 1:]
if len(vals) != 1:
err("usage", "--normalize-remote takes <url>")
print(normalize_remote(vals[0]))
sys.exit(0)
# ---- arg parsing ----
if not ARGS:
err("usage", "a repo.json path is required")
path = None
i = 0
while i < len(ARGS):
a = ARGS[i]
if a == "--mode":
i += 1
if i >= len(ARGS) or ARGS[i] not in ("managed", "display"):
err("usage", "--mode takes managed|display")
MODE = ARGS[i]
elif a == "--require-v2":
REQUIRE_V2 = True
elif a.startswith("--"):
err("usage", f"unknown option {a}")
else:
if path is not None:
err("usage", "multiple file arguments")
path = a
i += 1
if path is None:
err("usage", "a repo.json path is required")
# ---- load: strict UTF-8, strict RFC JSON (F3) ----
try:
with open(path, "rb") as fh:
raw_bytes = fh.read()
except OSError as e:
err("file", str(e))
try:
raw = raw_bytes.decode("utf-8")
except UnicodeDecodeError as e:
err("json", f"invalid UTF-8: {e}")
def _reject_constant(name):
raise ValueError(f"non-RFC JSON constant {name}")
try:
doc = json.loads(raw, parse_constant=_reject_constant)
except (json.JSONDecodeError, ValueError) as e:
err("json", f"malformed JSON: {e}")
if not isinstance(doc, dict):
err("json", "top level must be an object")
HOST_ROOT = os.environ.get("MOSAIC_HOST_ROOT", "")
V1_KEYS = {"integration_trunk", "release_branch"}
V2_REQUIRED = ["schema_version", "integration_trunk", "release_branch", "flow",
"canonical_remote", "canonical_clone"]
V2_OPTIONAL = {"worktree_root", "worktree_policy", "notes", "x_extensions"}
ENUM_FLOW = {"direct", "trunk-release"}
ENUM_POLICY = {"tool-managed", "orchestrator-precreated"}
def check_branch(key, value):
# Delegate the full git branch grammar to git itself (F2). B1: reject
# reflog shorthand BEFORE delegation — `git check-ref-format --branch
# '@{-n}'` expands from the CALLER repo's checkout history, making
# validation cwd-dependent; a persistent declaration must never bind
# to ambient reflog state.
if not isinstance(value, str) or not value:
err(key, "must be a non-empty string")
if "@{" in value:
err(key, f"{value!r} contains '@{{' reflog/namespace shorthand — declarations must be literal branch names (B1)")
if value.startswith("refs/heads/"): # check-ref-format --branch strips this; we do not allow it
err(key, "bare branch name expected, not a full ref")
try:
r = subprocess.run(["git", "check-ref-format", "--branch", value],
capture_output=True)
except OSError as e:
err(key, f"cannot invoke git check-ref-format: {e}")
if r.returncode != 0:
err(key, f"{value!r} is not a valid git branch name (git check-ref-format, §5.2)")
def check_host_path(key, value):
# §1.2a: host:/-anchored; canonical segment normalization; empty/./.. rejected
# BEFORE resolution; tilde rejected outright.
if not isinstance(value, str) or not value:
err(key, "must be a non-empty string")
if "~" in value:
err(key, "tilde-anchored path rejected (§1.2a: ~ binds to caller HOME)")
if not value.startswith("host:/"):
err(key, "must be host:/-anchored (§1.2a)")
rest = value[len("host:/"):]
if rest == "":
err(key, "no segments after host:/")
segments = rest.split("/")
for seg in segments:
if seg == "":
err(key, f"empty segment in {value!r} (canonical normalization, §1.2a)")
if seg in (".", ".."):
err(key, f"dot segment {seg!r} rejected before resolution (§1.2a)")
return segments
# ---- version ----
sv = doc.get("schema_version")
if "schema_version" in doc:
if not isinstance(sv, int) or isinstance(sv, bool):
err("schema_version", "must be an integer")
if sv not in (1, 2):
err("schema_version", f"unknown schema_version {sv} — treated as ABSENT per keep-list K3; update tooling")
version = sv
else:
version = 1
warn("schema_version absent → v1 compatibility mode (two keys only)")
if REQUIRE_V2 and version != 2:
err("schema_version", "CI authoring rule: new or edited declarations must declare schema_version 2")
# ---- v1 ----
if version == 1:
for k in V1_KEYS:
check_branch(k, doc.get(k))
extra = set(doc) - V1_KEYS
if extra:
err("x_extensions", f"unknown top-level keys in v1: {sorted(extra)}")
print("OK (v1)")
sys.exit(0)
# ---- v2 required ----
for k in V2_REQUIRED:
if k not in doc:
err(k, "required for v2 (§1.2)")
check_branch("integration_trunk", doc["integration_trunk"])
check_branch("release_branch", doc["release_branch"])
# type-check BEFORE membership (F3: list-typed enums must not traceback)
if not isinstance(doc["flow"], str) or doc["flow"] not in ENUM_FLOW:
err("flow", f"must be one of {sorted(ENUM_FLOW)} (§1.2, required — no defaulting, R7)")
unknown = set(doc) - set(V2_REQUIRED) - V2_OPTIONAL
if unknown:
err("x_extensions", f"unknown top-level keys {sorted(unknown)} — place extensions inside x_extensions")
if "worktree_policy" in doc and (not isinstance(doc["worktree_policy"], str)
or doc["worktree_policy"] not in ENUM_POLICY):
err("worktree_policy", f"must be one of {sorted(ENUM_POLICY)}")
if "notes" in doc and not isinstance(doc["notes"], str):
err("notes", "must be a string")
if "x_extensions" in doc and not isinstance(doc["x_extensions"], dict):
err("x_extensions", "must be an object")
for strkey in ("canonical_remote", "canonical_clone", "worktree_root"):
if strkey in doc and not isinstance(doc[strkey], str):
err(strkey, "must be a string")
# ---- remote (§5.3) ----
if not isinstance(doc["canonical_remote"], str):
err("canonical_remote", "must be a string")
else:
normalize_remote(doc["canonical_remote"])
# ---- paths (§1.2a) ----
canonical_segments = check_host_path("canonical_clone", doc["canonical_clone"])
wt_segments = None
if "worktree_root" in doc:
wt_segments = check_host_path("worktree_root", doc["worktree_root"])
# ---- cross-field (§5.2) ----
trunk, rel, flow = doc["integration_trunk"], doc["release_branch"], doc["flow"]
if flow == "direct" and trunk != rel:
err("flow", "direct requires integration_trunk == release_branch (§5.2)")
if flow == "trunk-release" and trunk == rel:
err("flow", "trunk-release requires integration_trunk != release_branch (§5.2)")
# ---- root gate (§1.2a fail-closed; T51P2R1 F1) ----
# Every v2 declaration consumes a path (canonical_clone is required), so
# managed mode cannot proceed without a provable host anchor. Display mode
# warns and omits root-dependent resolution only.
if not HOST_ROOT:
if MODE == "managed":
err("MOSAIC_HOST_ROOT",
"unset or empty — managed validation of a v2 declaration consumes paths "
"(canonical_clone required); fail closed (§1.2a, DR3 X2b)")
else:
warn("host root unset; root-dependent resolution omitted (display mode, §1.2a)")
if doc.get("worktree_policy") == "tool-managed":
if "worktree_root" not in doc:
err("worktree_policy", "tool-managed requires worktree_root (containment provable, §4.5)")
if HOST_ROOT:
root_real = os.path.realpath(HOST_ROOT)
# B3 fix: containment is tested on the LEXICAL normalized path, not
# on realpath of the joined result — a child symlink under the host
# root can no longer fake outside-ness. host:/ segments are always
# lexically under the root, so tool-managed fails universally until
# the anchor scheme grows a real outside-root form (J3 charter).
resolved = os.path.normpath(os.path.join(root_real, *wt_segments))
if resolved == root_real or resolved.startswith(root_real + os.sep):
err("worktree_policy",
f"tool-managed worktree_root resolves inside MOSAIC_HOST_ROOT (§4.5: outside-root requirement)")
# no-root case: managed mode already failed at the gate above; display warned
print("OK")
try:
main()
except SystemExit:
raise
except Exception as e: # F3: no traceback may ever escape the contract
err("internal", f"input rejected (unexpected condition: {type(e).__name__})")
PYEOF
@@ -32,7 +32,9 @@
# 0 delivered (submitted) or queued (agent busy; will process when free)
# 1 tmux target not found
# 2 submission NOT confirmed — either still an unsubmitted draft, or the REPL
# input prompt could not be located to confirm the message actually landed.
# input box could not be located to confirm the message actually landed.
# Locating the box is runtime-specific; see locate_input_box() below, and
# add a shape there before pointing this tool at a new runtime.
# Delivery is NEVER inferred from absence of evidence: if we cannot positively
# see the input box clear of the message (or the queued banner), we fail loud
# so the sender learns immediately instead of a silent worker->lead stall.
@@ -97,10 +99,50 @@ printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
# would otherwise accumulate forever.
sleep 0.5
# Locate the REPL input box in a captured pane. Prints the box's contents on
# stdout and returns 0 when the box was FOUND; returns 1 when it could not be
# located at all. Found-but-empty is a real, distinct answer (an empty input box
# is what a submitted message leaves behind), so the caller must branch on the
# return code, never on whether the output is empty.
#
# Two REPL shapes are recognised:
# * a prompt-glyph line — ``, a leading `>`, or `│ >`. Claude Code and most
# readline REPLs.
# * a box drawn as two horizontal `─` rules with the input between them and NO
# prompt glyph anywhere. pi renders this. Anchoring on the LAST rule pair is
# what makes it safe: agent output can contain its own rules, but nothing is
# drawn below the input box except the status line.
#
# Adding a runtime means adding its shape HERE. A shape that is missing does not
# degrade gracefully: it turns every send to that runtime into a false
# "may be UNDELIVERED", which is what #1362 measured on pi and #1257 on another
# arm of the same probe.
locate_input_box() {
local pane=$1 glyph_line rule_lines top bottom
glyph_line=$(printf '%s\n' "$pane" | grep -E '|^>|│ >' | tail -1)
if [ -n "$glyph_line" ]; then printf '%s\n' "$glyph_line"; return 0; fi
rule_lines=$(printf '%s\n' "$pane" | grep -nE '^[[:space:]]*─{4,}[[:space:]]*$' | cut -d: -f1 | tail -2)
[ -n "$rule_lines" ] || return 1
# Split the (at most two) captured line numbers with parameter expansion. Not
# `head -1`: piping into an early-exiting consumer SIGPIPEs the producer, which
# under `set -euo pipefail` aborts the caller with rc=141 and no output. The
# scripts/pipefail-early-exit.test.mjs guard reds on that shape, correctly.
# With one rule captured both halves resolve to the same value and the
# ordering test below rejects it, which is the answer we want anyway.
top=${rule_lines%%$'\n'*}
bottom=${rule_lines##*$'\n'}
[ "$top" != "$bottom" ] || return 1
[ "$bottom" -gt "$top" ] || return 1
# An empty range (adjacent rules) prints nothing and still returns 0: found,
# empty, which is the delivered shape.
printf '%s\n' "$pane" | sed -n "$((top + 1)),$((bottom - 1))p"
return 0
}
# 2) Submit, then POSITIVELY confirm submission; flush with another Enter if it is
# still a draft. Success requires positive evidence — the queued banner, OR the
# REPL input box located AND clear of our message tail. The historical bug was
# treating ABSENCE of a draft as delivery: if the prompt glyph was never matched
# treating ABSENCE of a draft as delivery: if the input box was never located
# (wrong pane / prompt-glyph drift), an unsubmitted message read as "delivered"
# and worker->lead relays stalled silently. We now default to UNCONFIRMED and only
# upgrade to delivered on positive evidence; anything we cannot confirm fails loud.
@@ -113,15 +155,14 @@ for attempt in $(seq 1 $((RETRIES + 1))); do
if grep -qF "$QUEUED_RE" <<<"$pane"; then
status="queued"; break
fi
# Locate the REPL input box (prompt glyph). If we cannot see it, we have NO
# evidence of submission state — stay UNCONFIRMED and retry; never infer delivery.
promptline=$(printf '%s' "$pane" | grep -E '|^>|│ >' | tail -1)
if [ -z "$promptline" ]; then
# If we cannot see the input box, we have NO evidence of submission state —
# stay UNCONFIRMED and retry; never infer delivery.
if ! inputbox=$(locate_input_box "$pane"); then
status="unconfirmed"; continue
fi
# Input box located AND still carrying our tail => unsubmitted draft. Flush + retry.
# (Submitted messages scroll up into history; a draft stays on the line.)
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$promptline"; then
# (Submitted messages scroll up into history; a draft stays in the box.)
if [ -n "$snippet" ] && grep -qF "$snippet" <<<"$inputbox"; then
status="draft"; continue
fi
# Input box located AND clear of our tail => positively submitted. This is the
@@ -135,6 +176,6 @@ case "$status" in
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;;
draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;;
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input prompt not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
unconfirmed) echo "✗ could not confirm submission on $TARGET: REPL input box not locatable after $((RETRIES + 1)) attempts — message may be UNDELIVERED (check target/pane, retry, or escalate)" >&2; exit 2 ;;
*) echo "✗ could not confirm submission on $TARGET (unexpected state '$status')" >&2; exit 2 ;;
esac
@@ -10,6 +10,13 @@
# "could not confirm submission").
# 3. DRAFT — a ` `-prompt pane that never submits (message stays on the
# input line) => exit 2, stderr "unsubmitted draft".
# 4. DELIVERED — a pane whose input box is two `─` rules with NO prompt glyph
# (box shape) anywhere (pi's shape) and which submits => exit 0. Pre-#1362
# the glyph probe could not see this box at all, so EVERY send
# to such a pane reported "may be UNDELIVERED" while landing.
# 5. DRAFT — the same glyphless box, holding our tail across every flush
# (box shape) Enter => exit 2, stderr "unsubmitted draft". Pre-#1362 this
# also reported unconfirmed, so the true state was invisible.
set -uo pipefail
HERE=$(cd -- "$(dirname -- "$0")" && pwd)
@@ -69,6 +76,56 @@ else
fi
fi
# --- Fixtures 4 and 5: a pi-shaped pane. The input box is two `─` rules with the
# text between them and NO prompt glyph anywhere, so the glyph probe alone can
# never locate it and every send reports "may be UNDELIVERED" (#1362). The
# renderer below is the shape, not the runtime: MODE=clear submits (box empties),
# MODE=keep leaves the text sitting in the box.
cat > "$TMP/pibox.sh" <<'PIBOX'
#!/usr/bin/env bash
MODE=${1:-clear}
RULE=$(printf '─%.0s' $(seq 1 60))
buf=""
draw() {
printf '\033[H\033[2J'
printf 'fixture output line\n\n'
printf '%s\n' "$RULE"
printf '%s\n' "$buf"
printf '%s\n' "$RULE"
printf '~/fixture (main)\n'
printf 'tok 0 model fixture\n'
}
draw
while IFS= read -r line; do
# keep: hold the tail across every flush Enter, which is what a stuck draft does.
if [ "$MODE" = keep ]; then [ -n "$line" ] && buf=$line; else buf=""; fi
draw
done
PIBOX
chmod +x "$TMP/pibox.sh"
tmux -L "$SOCKET" new-session -d -s pibox -c "$TMP" "exec bash '$TMP/pibox.sh' clear"
sleep 0.3
out=$("$SEND" -L "$SOCKET" -t "=pibox" -m "pi fixture four delivered ok" 2>"$TMP/e4"); rc=$?
if [ "$rc" -eq 0 ] && grep -qF "✓ delivered" <<<"$out"; then
ok "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered"
else
no "delivered: glyphless box-drawn REPL that submits => exit 0 ✓ delivered" "rc=$rc out=[$out] err=[$(cat "$TMP/e4")]"
fi
tmux -L "$SOCKET" new-session -d -s piboxdraft -c "$TMP" "exec bash '$TMP/pibox.sh' keep"
sleep 0.3
if out=$("$SEND" -L "$SOCKET" -t "=piboxdraft" -r 1 -m "pi fixture five stuck in the box" 2>"$TMP/e5"); then
no "draft: glyphless box-drawn pane holding our tail must NOT report success" "expected exit 2, got 0 (out=[$out])"
else
rc=$?
if [ "$rc" -eq 2 ] && grep -qF "unsubmitted draft" "$TMP/e5"; then
ok "draft: message left in a glyphless box => exit 2 + 'unsubmitted draft'"
else
no "draft: message left in a glyphless box => exit 2 + stderr" "rc=$rc err=[$(cat "$TMP/e5")]"
fi
fi
echo "---"
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -0,0 +1,177 @@
import { lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { placeUnitFile, resolveLeaseBrokerSocketForPreflight } from './fleet.js';
/**
* Unit-placement regression harness for #1292.
*
* The two measured defects this suite pins:
* 1. `systemctl enable <name>` does NOT rewrite an existing by-path
* wants-symlink so placement must remove stale residue explicitly, and
* acceptance asserts on the RESULTING SYMLINK TARGET, never on the enable
* call's argument (asserting the call cannot see where the link ended up).
* 2. Node's copyFile FOLLOWS a by-path symlink at the destination and
* overwrites the SEED template. Acceptance asserts on the SEED's bytes
* AND mtime unchanged which is the only check that can redden for
* finding 2. The symlink-target assertion catches finding 1; these are
* different defects with different failure modes.
*
* Fixtures are entirely inside tmpdirs (source template, active systemd dir,
* wants dir) no real host paths are touched by this suite.
*/
describe('placeUnitFile (#1292 unit placement)', () => {
const cleanup: string[] = [];
afterEach(async () => {
while (cleanup.length > 0) {
await rm(cleanup.pop()!, { recursive: true, force: true });
}
});
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'place-unit-'));
cleanup.push(root);
const seedDir = join(root, 'seed');
const activeDir = join(root, 'active');
await mkdir(seedDir, { recursive: true });
await mkdir(activeDir, { recursive: true });
const seedTemplate = join(seedDir, 'unit-under-test.service');
await writeFile(
seedTemplate,
'[Unit]\nDescription=seed template\n[Service]\nType=oneshot\nExecStart=/bin/true\n[Install]\nWantedBy=default.target\n',
);
const activeSource = join(root, 'active-source.service');
await writeFile(
activeSource,
'[Unit]\nDescription=active copy v2\n[Service]\nType=oneshot\nExecStart=/bin/true\n[Install]\nWantedBy=default.target\n',
);
return { root, seedDir, activeDir, seedTemplate, activeSource };
}
it('places a regular file on a clean host (negative control: no residue anywhere)', async () => {
const f = await fixture();
const result = await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
expect(result.unlinkedDestinationSymlink).toBe(false);
expect(result.removedStaleWantsSymlink).toBe(false);
const info = await lstat(join(f.activeDir, 'unit-under-test.service'));
expect(info.isSymbolicLink()).toBe(false);
expect(await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8')).toContain(
'active copy v2',
);
// Seed untouched by construction — but assert it, so the clean-host case
// cannot silently regress into seed-mutation.
expect(await readFile(f.seedTemplate, 'utf8')).toContain('seed template');
});
it('by-path residue: unlinks destination symlink, places the file, seed bytes AND mtime unchanged (finding 2)', async () => {
const f = await fixture();
const seedBefore = await readFile(f.seedTemplate, 'utf8');
const mtimeBefore = (await lstat(f.seedTemplate)).mtimeMs;
// The fomo-lin convention: by-path enable left a symlink AT the unit name
// pointing at the seed template, plus a wants-symlink doing the same.
await symlink(f.seedTemplate, join(f.activeDir, 'unit-under-test.service'));
const wantsDir = join(f.activeDir, 'default.target.wants');
await mkdir(wantsDir, { recursive: true });
await symlink(f.seedTemplate, join(wantsDir, 'unit-under-test.service'));
const result = await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
expect(result.unlinkedDestinationSymlink).toBe(true);
expect(result.removedStaleWantsSymlink).toBe(true);
// FINDING 2's check: the seed is byte-identical and its mtime did not move.
expect(await readFile(f.seedTemplate, 'utf8')).toBe(seedBefore);
expect((await lstat(f.seedTemplate)).mtimeMs).toBe(mtimeBefore);
// The destination is now a regular file carrying the ACTIVE content.
const destInfo = await lstat(join(f.activeDir, 'unit-under-test.service'));
expect(destInfo.isSymbolicLink()).toBe(false);
expect(await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8')).toContain(
'active copy v2',
);
});
it('by-path residue: no wants-symlink remains pointing at the seed (finding 1 residue cleared)', async () => {
const f = await fixture();
await symlink(f.seedTemplate, join(f.activeDir, 'unit-under-test.service'));
const wantsDir = join(f.activeDir, 'default.target.wants');
await mkdir(wantsDir, { recursive: true });
await symlink(f.seedTemplate, join(wantsDir, 'unit-under-test.service'));
await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
// After placement the stale wants link is GONE (enable-by-name recreates
// it correctly). A link still present must not point at the seed.
try {
const link = await lstat(join(wantsDir, 'unit-under-test.service'));
if (link.isSymbolicLink()) {
const target = await readFile(join(wantsDir, 'unit-under-test.service'), 'utf8').catch(
async () => '',
);
expect(target).not.toContain('seed template');
}
} catch {
// absent wants link — the expected post-placement state
}
});
it('idempotence: second placement on a reconciled host is a no-op producing the identical final state', async () => {
const f = await fixture();
// Reconciled starting state: regular file at the name, wants link to the active copy.
await writeFile(
join(f.activeDir, 'unit-under-test.service'),
await readFile(f.activeSource, 'utf8'),
);
const wantsDir = join(f.activeDir, 'default.target.wants');
await mkdir(wantsDir, { recursive: true });
await symlink(
join(f.activeDir, 'unit-under-test.service'),
join(wantsDir, 'unit-under-test.service'),
);
const before = await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8');
const result = await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
// No destructive step fired: no unlink, no wants removal.
expect(result.unlinkedDestinationSymlink).toBe(false);
expect(result.removedStaleWantsSymlink).toBe(false);
// Identical final state.
expect(await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8')).toBe(before);
const link = await lstat(join(wantsDir, 'unit-under-test.service'));
expect(link.isSymbolicLink()).toBe(true);
});
it('double install on by-path residue converges to the identical reconciled state', async () => {
const f = await fixture();
await symlink(f.seedTemplate, join(f.activeDir, 'unit-under-test.service'));
const wantsDir = join(f.activeDir, 'default.target.wants');
await mkdir(wantsDir, { recursive: true });
await symlink(f.seedTemplate, join(wantsDir, 'unit-under-test.service'));
await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
const first = await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8');
const secondRun = await placeUnitFile(f.activeSource, f.activeDir, 'unit-under-test.service');
const second = await readFile(join(f.activeDir, 'unit-under-test.service'), 'utf8');
expect(secondRun.unlinkedDestinationSymlink).toBe(false);
expect(second).toBe(first);
});
});
describe('resolveLeaseBrokerSocketForPreflight (#1292 preflight resolution)', () => {
it('explicit MOSAIC_LEASE_BROKER_SOCKET wins', () => {
expect(
resolveLeaseBrokerSocketForPreflight({ MOSAIC_LEASE_BROKER_SOCKET: '/custom/sock' }, 1000),
).toBe('/custom/sock');
});
it('XDG_RUNTIME_DIR next', () => {
expect(resolveLeaseBrokerSocketForPreflight({ XDG_RUNTIME_DIR: '/run/user/1001' }, 1000)).toBe(
'/run/user/1001/mosaic-lease/broker.sock',
);
});
it('falls back to /run/user/<uid>', () => {
expect(resolveLeaseBrokerSocketForPreflight({}, 1002)).toBe(
'/run/user/1002/mosaic-lease/broker.sock',
);
});
});
@@ -73,6 +73,10 @@ function program(
runner,
reconcileDeps: {
homeDirectory: '/home/mosaic',
// Deterministic broker presence: without a seam the reconciler probes the
// REAL host socket (#1297 F3), making every CLI start test answer the
// host's broker state instead of its own property.
checkBrokerSocket: async () => true,
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
validateRoster: async () => undefined,
prepareProjections: async () => [{ agentName: 'coder0' }],
@@ -211,12 +211,23 @@ describe('mosaic fleet install — roster v2', (): void => {
describe('[email protected]', (): void => {
const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', '[email protected]');
/** The single `ConditionPathExists=` value declared by the unit template. */
async function conditionPath(): Promise<string> {
/**
* Every `ConditionPathExists=` value declared by the unit template, in file
* order, `|` triggering prefix included.
*
* Until #1410 this helper pinned `toHaveLength(1)` a count assertion, not
* a content assertion, and the count pin was itself the defect: when #1408
* required a second triggering line (the `%h/.mosaic` brain-home shape),
* this spec was a second consumer of the unit template that the shell suite
* and the enumeration guard could not see, so the fix failed here first
* (CI 2651 the installation-documentation.spec.ts lesson again). Assert
* content per line, never count.
*/
async function conditionPaths(): Promise<string[]> {
const unit = await readFile(unitPath, 'utf8');
const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? [];
expect(matches).toHaveLength(1);
return matches[0]!.slice('ConditionPathExists='.length).trim();
expect(matches.length).toBeGreaterThan(0);
return matches.map((line) => line.slice('ConditionPathExists='.length).trim());
}
it('will not attempt a seat before the reconciler has written its env', async (): Promise<void> => {
@@ -224,7 +235,26 @@ describe('[email protected]', (): void => {
// unit (WantedBy=default.target) but does not start it, so without this
// condition a reboot between `install` and the first `apply` would run
// ExecStart against an absent env file and fail every seat unit.
expect(await conditionPath()).toBe('%h/.config/mosaic/fleet/agents/%i.env.generated');
//
// Two lines since #1408: the env projection lives under %h/.config/mosaic
// on framework-home hosts and under %h/.mosaic on brain-home hosts. Both
// carry the `|` triggering prefix — systemd ANDs same-type conditions
// unless every line is triggering (then they OR), and a bare spelling
// would demand BOTH home shapes on one host, which is never true, so
// every seat would silently skip.
expect(await conditionPaths()).toEqual([
'|%h/.config/mosaic/fleet/agents/%i.env.generated',
'|%h/.mosaic/fleet/agents/%i.env.generated',
]);
});
it('refuses the bare ANDed spelling on every condition line', async (): Promise<void> => {
// Invariant ported from test-fleet-units.sh, held separately from the
// literal pin above so it survives future edits to the path set: every
// ConditionPathExists line must stay triggering (`|`).
for (const value of await conditionPaths()) {
expect(value.startsWith('|')).toBe(true);
}
});
/**
@@ -246,10 +276,18 @@ describe('[email protected]', (): void => {
*/
it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise<void> => {
const mosaicHome = await v2Home();
const rendered = (await conditionPath()).replace('%h', tempHome!).replace('%i', 'coder0');
const rendered = (await conditionPaths()).map((value) =>
value.replace(/^\|/, '').replace('%h', tempHome!).replace('%i', 'coder0'),
);
// The path an installed fleet actually places for this agent.
expect(rendered).toBe(join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'));
// Per home shape, the guard must render to exactly the file the
// reconciler writes there: mosaicHome (%h/.config/mosaic) on
// framework-home hosts, %h/.mosaic on brain-home hosts (#1408) — each
// line pinned to its file, not merely present.
expect(rendered).toEqual([
join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'),
join(tempHome!, '.mosaic', 'fleet', 'agents', 'coder0.env.generated'),
]);
});
it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => {
+193 -2
View File
@@ -1,11 +1,24 @@
import { chmod, lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import {
chmod,
lstat,
mkdir,
mkdtemp,
readFile,
readlink,
rm,
stat,
symlink,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { createServer } from 'node:net';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
acquireRestartLock,
addAgentToRoster,
brokerSocketPresent,
buildAgentSendCommand,
buildAgentWatchAttachCommand,
buildAgentWatchCommand,
@@ -42,6 +55,7 @@ import {
parseSystemdShow,
parseTmuxListPanes,
parseTmuxListSessions,
placeUnitFile,
registerFleetCommand,
removeAgentFromRoster,
resolveFleetPaths,
@@ -50,6 +64,7 @@ import {
RESTART_LOCK_STALE_MS,
RUNTIME_ACCEPTABLE_COMMANDS,
serializeRosterToYaml,
UnitPlacementError,
VERIFY_DEFAULT_TIMEOUT_MS,
VERIFY_POLL_INTERVAL_MS,
type AgentPsRow,
@@ -836,13 +851,25 @@ describe('fleet command construction', () => {
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, mosaicHome: home });
// #1292: inject a present broker socket so the preflight passes and this
// spec keeps testing its ORIGINAL property (holder-before-agent ordering).
// The preflight's own refusal behavior has dedicated specs below.
registerFleetCommand(program, {
runner,
mosaicHome: home,
checkBrokerSocket: async () => true,
});
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'start']);
await program.parseAsync(['node', 'mosaic', 'fleet', 'stop']);
expect(calls).toEqual([
// #1292: fleet start enables + starts the broker FIRST (enable is
// idempotent; the unit exists after install), re-checking the socket
// before any holder/agent lifecycle effect.
['systemctl', '--user', 'enable', 'mosaic-lease-broker.service'],
['systemctl', '--user', 'start', 'mosaic-lease-broker.service'],
['systemctl', '--user', 'start', 'mosaic-tmux-holder.service'],
['systemctl', '--user', 'start', '[email protected]'],
['systemctl', '--user', 'stop', '[email protected]'],
@@ -853,6 +880,92 @@ describe('fleet command construction', () => {
}
});
it('fleet start refuses with a named error when the broker socket does not appear (#1292)', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
const errors: string[] = [];
const origError = console.error;
console.error = (...args: unknown[]) => {
errors.push(args.join(' '));
};
registerFleetCommand(program, {
runner,
mosaicHome: home,
checkBrokerSocket: async () => false,
});
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'start']);
// Refused: no holder/agent starts were issued after the broker attempt.
expect(calls).toEqual([
['systemctl', '--user', 'enable', 'mosaic-lease-broker.service'],
['systemctl', '--user', 'start', 'mosaic-lease-broker.service'],
]);
expect(errors.join('\n')).toContain('broker-absent');
expect(errors.join('\n')).toContain('mosaic fleet install');
} finally {
console.error = origError;
await rm(home, { recursive: true, force: true });
}
});
it('fleet start re-probes the broker on the SECOND invocation — no ActiveState trust (#1292 sticky half)', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const program = new Command();
program.exitOverride();
// Broker socket NEVER appears — the second start must refuse exactly like
// the first; RemainAfterExit-style stale unit state changes nothing
// because the check is the socket, not systemctl.
registerFleetCommand(program, {
runner,
mosaicHome: home,
checkBrokerSocket: async () => false,
});
const errors: string[] = [];
const origError = console.error;
console.error = (...args: unknown[]) => {
errors.push(args.join(' '));
};
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'start']);
await program.parseAsync(['node', 'mosaic', 'fleet', 'start']);
// Two invocations, each refusing after its own broker attempt:
expect(
calls.filter((c) => c.join(' ') === 'systemctl --user start [email protected]'),
).toHaveLength(0);
expect(errors.filter((e) => e.includes('broker-absent')).length).toBeGreaterThanOrEqual(2);
} finally {
console.error = origError;
await rm(home, { recursive: true, force: true });
}
});
it('waits for an in-flight restart to clear before relaunching (re-entry guard)', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
@@ -2066,8 +2179,19 @@ describe('fleet install — auto-enable units for boot-survival', () => {
await enableFleetUnits(runner, minimalRoster, {});
expect(calls).toContainEqual(['systemctl', '--user', 'enable', 'mosaic-lease-broker.service']);
expect(calls).toContainEqual(['systemctl', '--user', 'enable', 'mosaic-tmux-holder.service']);
expect(calls).toContainEqual(['systemctl', '--user', 'enable', '[email protected]']);
// The broker must be enabled BEFORE the holder and agents: a start of any
// gated runtime without the broker is exactly the #1292 4-second death.
const brokerIndex = calls.findIndex(
(c) => c.join(' ') === 'systemctl --user enable mosaic-lease-broker.service',
);
const holderIndex = calls.findIndex(
(c) => c.join(' ') === 'systemctl --user enable mosaic-tmux-holder.service',
);
expect(brokerIndex).toBeGreaterThanOrEqual(0);
expect(brokerIndex).toBeLessThan(holderIndex);
});
it('install still succeeds when systemctl enable returns non-zero (non-fatal)', async () => {
@@ -4362,3 +4486,70 @@ describe('fleet ps — heartbeat path resolution', () => {
);
});
});
describe('#1297 review: the real broker probe, exercised without any seam', () => {
it('brokerSocketPresent answers a REAL unix socket via stat().isSocket() (access(S_IFSOCK) threw ERR_OUT_OF_RANGE)', async () => {
const dir = await tempDir();
const sockPath = join(dir, 'broker.sock');
const server = createServer();
await new Promise<void>((resolve) => {
server.listen(sockPath, resolve);
});
try {
// A live unix socket answers true through the REAL probe — no seam.
expect(await brokerSocketPresent({}, { MOSAIC_LEASE_BROKER_SOCKET: sockPath })).toBe(true);
// Discrimination is by file type: a regular file that EXISTS is not a
// socket. The old implementation could not reach either verdict — it
// threw ERR_OUT_OF_RANGE (node >= 24) and the catch answered false.
const notASocket = join(dir, 'not-a-sock');
await writeFile(notASocket, 'x');
expect(await brokerSocketPresent({}, { MOSAIC_LEASE_BROKER_SOCKET: notASocket })).toBe(false);
// Absent path: false, not a throw.
expect(
await brokerSocketPresent({}, { MOSAIC_LEASE_BROKER_SOCKET: join(dir, 'gone.sock') }),
).toBe(false);
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
await rm(dir, { recursive: true, force: true });
});
// EACCES-based unlink failure requires a non-root uid: root bypasses
// directory mode bits (CAP_DAC_OVERRIDE), so the abort path cannot be
// triggered this way under CI's root runner. Skipped there, exercised on
// every non-root dev host.
const itUnlessRoot =
typeof process.getuid === 'function' && process.getuid() === 0 ? it.skip : it;
itUnlessRoot(
'placeUnitFile aborts with UnitPlacementError when unlink fails — never copies through a live symlink',
async () => {
const dir = await tempDir();
const unitDir = join(dir, 'systemd', 'user');
await mkdir(unitDir, { recursive: true });
// By-path residue: destination is a symlink pointing somewhere else.
const residueTarget = join(dir, 'residue-target');
await writeFile(residueTarget, 'RESIDUE-BYTES');
const destination = join(unitDir, 'x.service');
await symlink(residueTarget, destination);
const source = join(dir, 'seed.service');
await writeFile(source, 'UNIT-BYTES');
// Read-only unit dir: unlink now fails EACCES (test runs as the owner,
// not root, so mode bits are enforced).
await chmod(unitDir, 0o500);
try {
await expect(placeUnitFile(source, unitDir, 'x.service')).rejects.toThrow(
UnitPlacementError,
);
} finally {
await chmod(unitDir, 0o700);
}
// The copy-through never happened: residue bytes intact, destination
// still the symlink (abort, not overwrite-through).
expect(await readFile(residueTarget, 'utf8')).toBe('RESIDUE-BYTES');
expect(await readlink(destination)).toBe(residueTarget);
await rm(dir, { recursive: true, force: true });
},
);
});
+259 -14
View File
@@ -1,11 +1,13 @@
import { constants } from 'node:fs';
import { constants, type Stats } from 'node:fs';
import {
access,
chmod,
copyFile,
lstat,
mkdir,
open,
readFile,
readlink,
stat,
unlink,
writeFile,
@@ -90,6 +92,8 @@ export type SleepFn = (ms: number) => Promise<void>;
export interface FleetCommandDeps {
runner?: CommandRunner;
/** Test seam for the #1292 fleet-start broker preflight (socket presence). */
checkBrokerSocket?: (path: string) => Promise<boolean> | boolean;
/** Injectable interactive runner for commands needing inherited TTY (e.g., `tmux attach`). */
interactiveRunner?: InteractiveRunner;
/**
@@ -815,6 +819,135 @@ export function buildSystemdEnableCommand(unit: string): string[] {
return ['systemctl', '--user', 'enable', unit];
}
/**
* Place a unit file into the ACTIVE systemd user directory, never through a
* symlink (#1292, measured 2026-08-17).
*
* SET-INDEPENDENCE (fomo-lin, 2026-08-17): the set of unit names carrying
* by-path residue and the set of unit names this install copies are
* INDEPENDENT. Until 0.0.50 they were disjoint only by accident of which
* units the install happened to name fomo-lin survived copy-through solely
* because its one by-path symlink (the broker) was the one unit the install
* did NOT copy. Adding the broker to the copy set made the intersection
* non-empty on the first run. Whoever adds a fifth unit to the placement
* list inherits this helper and its unlink step; do not place units with a
* bare copyFile.
*
* A host provisioned by the enable-by-path convention carries a symlink AT
* the unit-name path in ~/.config/systemd/user/ pointing at the shipped
* template under ~/.config/mosaic/systemd/user/. Node's copyFile FOLLOWS
* that link and overwrites the SEED template instead of placing the active
* unit (verified with fs.copyFile on a throwaway systemd user instance)
* silent, rc=0, and it mutates the directory every later reseed reads from.
* The same measurement showed `systemctl enable <name>` does NOT rewrite an
* existing by-path wants-symlink, so reconciliation must be explicit.
*
* Placement therefore: if the destination is a symlink, unlink it first
* (unlink copy copy-then-unlink would mutate the seed and then destroy
* the evidence that it did); then copy. Also removes a stale
* `default.target.wants/<name>` symlink that points outside the active
* directory (readlink NOT readFile, which follows the link and returns the
* target's CONTENT), so the subsequent enable-by-name recreates it against
* the active copy. Idempotent: on a clean or already-reconciled destination
* every step is a no-op (the copy rewrites identical bytes).
*
* Returns what was done, for assertions and install reporting.
*/
export interface PlaceUnitResult {
readonly unit: string;
readonly destination: string;
/** A symlink at the unit-name path was unlinked (by-path residue). */
readonly unlinkedDestinationSymlink: boolean;
/** A stale wants-symlink pointing outside the active dir was removed. */
readonly removedStaleWantsSymlink: boolean;
}
/**
* placeUnitFile failed. Thrown BEFORE any copy: no destination bytes were
* written, so a residue target cannot have been clobbered by a copy-through
* (#1297 review F2).
*/
export class UnitPlacementError extends Error {
constructor(
readonly unit: string,
message: string,
) {
super(message);
this.name = UnitPlacementError.name;
}
}
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error && typeof error.code === 'string';
}
export async function placeUnitFile(
source: string,
systemdUserDir: string,
unit: string,
): Promise<PlaceUnitResult> {
const destination = join(systemdUserDir, unit);
let unlinkedDestinationSymlink = false;
// Destination-absent and unlink-FAILED are different outcomes and must not
// share a catch (#1297 review F2): a swallowed unlink error used to fall
// through to copyFile through the still-live symlink, silently reintroducing
// the exact copy-through this helper exists to prevent.
let destinationInfo: Stats | undefined;
try {
destinationInfo = await lstat(destination);
} catch (error) {
if (!isErrnoException(error) || error.code !== 'ENOENT') {
throw new UnitPlacementError(
unit,
`cannot inspect destination ${destination}: ${error instanceof Error ? error.message : String(error)}`,
);
}
// ENOENT: absent destination — nothing to unlink, copy below is safe.
}
if (destinationInfo?.isSymbolicLink()) {
try {
await unlink(destination);
} catch (error) {
// Abort BEFORE the copy: proceeding would run copyFile through the
// still-live symlink and overwrite the residue target's bytes.
throw new UnitPlacementError(
unit,
`cannot unlink destination symlink ${destination}: ${error instanceof Error ? error.message : String(error)}`,
);
}
unlinkedDestinationSymlink = true;
}
await copyFile(source, destination);
let removedStaleWantsSymlink = false;
const wantsLink = join(systemdUserDir, 'default.target.wants', unit);
try {
const wantsInfo = await lstat(wantsLink);
if (wantsInfo.isSymbolicLink()) {
// readlink — NOT readFile: readFile FOLLOWS the link and returns the
// target file's CONTENT, which is not the question being asked.
let target: string | undefined;
try {
target = await readlink(wantsLink);
} catch {
target = undefined;
}
// Normalize (systemctl writes absolute targets; a relative one resolves
// against the wants dir). A wants-symlink pointing anywhere other than
// the active copy (the by-path convention points at the seed template)
// survives enable-by-name unchanged — remove it so enable recreates it.
if (target !== undefined && resolve(dirname(wantsLink), target) !== destination) {
await unlink(wantsLink);
removedStaleWantsSymlink = true;
}
}
} catch {
// absent wants link — nothing to reconcile
}
return { unit, destination, unlinkedDestinationSymlink, removedStaleWantsSymlink };
}
/**
* Returns the systemctl --user disable command for a given unit.
* Used by `fleet remove` so a removed agent's enabled unit cannot resurrect on
@@ -849,6 +982,22 @@ export async function enableFleetUnits(
let succeeded = 0;
let failed = 0;
// The lease broker ships with the fleet and every gated runtime needs it
// (#1292): seats die at lease registration without it, and no documented
// path ever enabled it. Enabled first — alongside the holder — and the
// unit must have been placed by installFleet's placeUnitFile step.
const brokerResult = await runner(
...splitCommand(buildSystemdEnableCommand('mosaic-lease-broker.service')),
);
if (brokerResult.exitCode === 0) {
succeeded++;
} else {
failed++;
process.stderr.write(
`Warning: could not enable mosaic-lease-broker.service: ${brokerResult.stderr || brokerResult.stdout || 'non-zero exit'}\n`,
);
}
const holderResult = await runner(
...splitCommand(buildSystemdEnableCommand('mosaic-tmux-holder.service')),
);
@@ -1545,7 +1694,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.description('Install local fleet tools and user systemd units')
.option('--no-enable', 'Skip enabling units for boot-survival')
.action(async (opts: { enable?: boolean }) => {
await installFleet(cmd, frameworkRoot);
await installFleet(cmd, frameworkRoot, runner);
// Unit enablement needs agent names only, so it reads either version.
const roster = await loadRosterReadModel(cmd);
await enableFleetUnits(runner, roster, opts);
@@ -1556,7 +1705,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.description('Install local fleet tools and user systemd units')
.option('--no-enable', 'Skip enabling units for boot-survival')
.action(async (opts: { enable?: boolean }) => {
await installFleet(cmd, frameworkRoot);
await installFleet(cmd, frameworkRoot, runner);
// Unit enablement needs agent names only, so it reads either version.
const roster = await loadRosterReadModel(cmd);
await enableFleetUnits(runner, roster, opts);
@@ -1609,6 +1758,37 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
);
return;
}
if (action === 'start') {
// Broker preflight (#1292), re-probed on EVERY invocation: a
// gated runtime started without a live lease broker dies ~4s in
// while the unit reports active (RemainAfterExit) — enabling +
// starting here and then RE-CHECKING the socket refuses loudly
// instead of reporting rc0 over a doomed start. This is the
// second-start check as much as the first: it never trusts unit
// ActiveState.
await runChecked(runner, [
'systemctl',
'--user',
'enable',
'mosaic-lease-broker.service',
]);
await runChecked(runner, [
'systemctl',
'--user',
'start',
'mosaic-lease-broker.service',
]);
if (!(await brokerSocketPresent(deps))) {
console.error(
'[fleet] broker-absent: lease broker socket did not appear after enable+start (#1292).',
);
console.error(
'[fleet] remedy: mosaic fleet install (it reconciles either enable convention)',
);
process.exitCode = 1;
return;
}
}
if (action === 'restart') {
// Serialize the holder+agents teardown/relaunch behind the restart lock
// so a re-entrant restart waits for clean shutdown before relaunching,
@@ -2367,7 +2547,11 @@ export function registerFleetAgentCommands(
});
}
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
async function installFleet(
cmd: Command,
frameworkRoot: string,
runner: CommandRunner,
): Promise<void> {
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
// Read model first: every file this function places is roster-independent, and
@@ -2419,18 +2603,40 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
for (const toolPath of executableToolPaths) {
await chmod(toolPath, 0o755);
}
await copyFile(
join(frameworkRoot, 'systemd', 'user', 'mosaic-tmux-holder.service'),
join(activePaths.systemdUserDir, 'mosaic-tmux-holder.service'),
// Unit placement (#1292): every unit goes through placeUnitFile — never a
// bare copyFile — so a by-path-enable symlink at the destination is
// unlinked rather than written through (copy-through would silently
// overwrite the SEED template, measured 2026-08-17). The lease broker unit
// is placed here too: previously the install named three units and omitted
// the broker entirely, which is why no documented path ever enabled it.
const placedUnits = await Promise.all(
[
'mosaic-tmux-holder.service',
'[email protected]',
'[email protected]',
'mosaic-lease-broker.service',
].map((unit) =>
placeUnitFile(join(frameworkRoot, 'systemd', 'user', unit), activePaths.systemdUserDir, unit),
),
);
await copyFile(
join(frameworkRoot, 'systemd', 'user', '[email protected]'),
join(activePaths.systemdUserDir, '[email protected]'),
);
await copyFile(
join(frameworkRoot, 'systemd', 'user', '[email protected]'),
join(activePaths.systemdUserDir, '[email protected]'),
const reconciled = placedUnits.filter(
(result) => result.unlinkedDestinationSymlink || result.removedStaleWantsSymlink,
);
if (reconciled.length > 0) {
console.log(
`Reconciled ${reconciled.length} unit placement(s) from by-path enable residue: ${reconciled.map((r) => r.unit).join(', ')}`,
);
}
// systemd will not see a replaced unit file without a reload; do it once
// after all placements, before any enable call below. runCommand never
// rejects (it resolves exitCode 127 on spawn error), so a plain await with
// an exitCode check matches the rest of this file's systemctl handling.
const reloadResult = await runner(...splitCommand(['systemctl', '--user', 'daemon-reload']));
if (reloadResult.exitCode !== 0) {
process.stderr.write(
`Warning: systemctl --user daemon-reload after unit placement failed (non-systemd host?): ${reloadResult.stderr || reloadResult.stdout || 'non-zero exit'}\n`,
);
}
// On roster v2 the reconciler owns the generated env: `apply` writes it and
// `regen` rebuilds it, both from projectRosterV2AgentGeneratedEnv. Writing it
@@ -2627,6 +2833,45 @@ function splitCommand(command: string[]): [string, string[]] {
return [bin, args];
}
/**
* Lease-broker socket presence for the fleet-start preflight (#1292).
* Resolution precedence matches launch.ts's defaultLeaseBrokerSocket and
* start-agent-session.sh's broker_socket_path: explicit
* MOSAIC_LEASE_BROKER_SOCKET, else $XDG_RUNTIME_DIR/mosaic-lease/broker.sock,
* else /run/user/<uid>/mosaic-lease/broker.sock. Pure filesystem check this
* deliberately does NOT consult systemd state: a unit can be active
* (RemainAfterExit) with no live socket, and the socket is the thing the
* gated runtime connects to. Injectable via deps for tests.
*/
export function resolveLeaseBrokerSocketForPreflight(
env: NodeJS.ProcessEnv = process.env,
uid: number = typeof process.getuid === 'function' ? process.getuid() : 0,
): string {
if (env['MOSAIC_LEASE_BROKER_SOCKET']) return env['MOSAIC_LEASE_BROKER_SOCKET'];
const runtimeDir = env['XDG_RUNTIME_DIR'] ?? `/run/user/${uid}`;
return join(runtimeDir, 'mosaic-lease', 'broker.sock');
}
export async function brokerSocketPresent(
deps: FleetCommandDeps,
env: NodeJS.ProcessEnv = process.env,
): Promise<boolean> {
const check = deps.checkBrokerSocket;
const socketPath = resolveLeaseBrokerSocketForPreflight(env);
if (check) return check(socketPath);
// S_IFSOCK (0xC000) is a file-TYPE constant, not an access() mode (0-7):
// access(path, S_IFSOCK) throws ERR_OUT_OF_RANGE on node >= 24 (measured on
// v24.18.0, #1297 review F1) and cannot succeed on any node — the old catch
// swallowed the throw, so this probe could NEVER return true and every
// un-seamed call reported the broker absent. stat() + isSocket() is the real
// check and matches the bash side's [ -S ].
try {
return (await stat(socketPath)).isSocket();
} catch {
return false;
}
}
/** All supported fleet profile names. */
export type FleetProfile =
| 'general'
+10 -4
View File
@@ -172,9 +172,10 @@ export function registerGatewayCommand(program: Command): void {
.command('recover-token')
.description('Recover an admin token — prompts for login if no valid session exists')
.option('-g, --gateway <url>', 'Gateway URL (overrides meta.json)')
.action(async (cmdOpts: { gateway?: string }) => {
.option('-e, --email <email>', 'Headless: account email (password read from stdin line 2)')
.action(async (cmdOpts: { gateway?: string; email?: string }) => {
const { runRecoverToken } = await import('./gateway/token-ops.js');
await runRecoverToken(cmdOpts.gateway);
await runRecoverToken(cmdOpts.gateway, cmdOpts.email);
});
// ─── logs ───────────────────────────────────────────────────────────────
@@ -202,9 +203,14 @@ export function registerGatewayCommand(program: Command): void {
gw.command('uninstall')
.description('Uninstall the gateway daemon and optionally remove data')
.action(async () => {
.option(
'-y, --yes',
'Headless: skip the confirmation prompt (required when stdin is not a TTY)',
)
.option('--remove-data', 'Also remove all gateway data (never implied by --yes)')
.action(async (cmdOpts: { yes?: boolean; removeData?: boolean }) => {
const { runUninstall } = await import('./gateway/uninstall.js');
await runUninstall();
await runUninstall(cmdOpts);
});
// ─── doctor ─────────────────────────────────────────────────────────────────
@@ -15,6 +15,7 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
import { ClackPrompter } from '../../prompter/clack-prompter.js';
import type { VerifyResult } from './verify.js';
import type { WizardState } from '../../types.js';
interface InstallOpts {
@@ -89,12 +90,34 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
prompter.log(` Logs: mosaic gateway logs`);
prompter.log(` Status: mosaic gateway status`);
// Post-install verification (CU-07-03) — non-fatal.
// Post-install verification (CU-07-03). Health/token/bootstrap failures
// stay non-fatal (courtesy checks), but a FAILED database schema check is
// fatal (#1392): an install that reports success over an empty/partial
// database is the exact T63 failure this command must never reproduce.
let verifyResult: VerifyResult | undefined;
let verificationThrew = false;
try {
const { runPostInstallVerification } = await import('./verify.js');
await runPostInstallVerification(configResult.host, configResult.port);
} catch {
// Non-fatal — verification is a courtesy
verifyResult = await runPostInstallVerification(configResult.host, configResult.port);
} catch (err) {
// Health/token/bootstrap courtesy failures are non-fatal, but a THROWN
// schema verification must not let install report success either (N2,
// rev-code-02 review 285): mark it and treat as fatal below.
verificationThrew = true;
const msg = err instanceof Error ? err.message : String(err);
prompter.warn(`Post-install verification errored: ${msg}`);
}
if (verifyResult && verifyResult.schemaMigrated === false) {
prompter.warn(
'Gateway install ABORTED: database schema verification failed (remediation above).',
);
process.exit(1);
}
if (verificationThrew) {
prompter.warn(
'Gateway install ABORTED: post-install verification errored (see above); refusing to report success on an unverified database.',
);
process.exit(1);
}
} catch (err) {
// Stages normally return structured results for expected failures.
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { Readable } from 'node:stream';
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
describe('readCredentialsFromPipedStdin — #1394 stdin dual path (real streams)', () => {
it('reads exactly two lines; email trimmed, password as-is', async () => {
const r = await readCredentialsFromPipedStdin(
Readable.from([' [email protected] \n', 'pw with spaces \n']),
);
expect(r.email).toBe('[email protected]');
expect(r.password).toBe('pw with spaces ');
});
it('empty stdin → nulls (the headless-no-credentials shape)', async () => {
const r = await readCredentialsFromPipedStdin(Readable.from(['']));
expect(r).toEqual({ email: null, password: null });
});
it('single line only → email set, password null', async () => {
const r = await readCredentialsFromPipedStdin(Readable.from(['only-email\n']));
expect(r.email).toBe('only-email');
expect(r.password).toBeNull();
});
it('stops after two lines even if more follow', async () => {
const r = await readCredentialsFromPipedStdin(
Readable.from(['[email protected]\n', 'pw\n', 'extra\n', 'more\n']),
);
expect(r).toEqual({ email: '[email protected]', password: 'pw' });
});
});
@@ -0,0 +1,26 @@
import { createInterface } from 'node:readline';
/**
* Read email + password as two lines from non-TTY stdin (the headless dual
* path for callers that cannot pass argv: printf 'email\npassword\n' | ).
* Caller gates on !isTTY; the password line is kept as-is (no trim
* whitespace may be intentional).
*
* Separate module (not login.ts) so tests can exercise the REAL reader
* against real streams while token-ops specs mock this seam cleanly.
*/
export function readCredentialsFromPipedStdin(
input: NodeJS.ReadableStream = process.stdin,
): Promise<{ email: string | null; password: string | null }> {
return new Promise((resolve) => {
const lines: string[] = [];
const rl = createInterface({ input });
rl.on('line', (l) => {
lines.push(l);
if (lines.length >= 2) rl.close();
});
rl.on('close', () => {
resolve({ email: (lines[0] ?? '').trim() || null, password: lines[1] ?? null });
});
});
}
@@ -16,11 +16,20 @@ vi.mock('./daemon.js', () => ({
vi.mock('./login.js', () => ({
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
// promptLine/promptSecret are used by ensureSession; return fixed values so tests don't block on stdin
// promptLine/promptSecret are used by ensureSession on the TTY path; return fixed
// values so tests never block on stdin.
promptLine: vi.fn().mockResolvedValue('[email protected]'),
promptSecret: vi.fn().mockResolvedValue('test-password'),
}));
// #1394: non-TTY runs resolve credentials from piped stdin instead of prompts.
vi.mock('./piped-credentials.js', () => ({
readCredentialsFromPipedStdin: vi.fn().mockResolvedValue({
email: '[email protected]',
password: 'test-password',
}),
}));
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
@@ -65,7 +74,7 @@ describe('ensureSession', () => {
expect(mockSignIn).not.toHaveBeenCalled();
});
it('prompts for credentials and signs in when stored session is invalid', async () => {
it('resolves piped-stdin credentials and signs in when stored session is invalid', async () => {
mockLoadSession.mockReturnValueOnce({ cookie: 'old-cookie', userId: 'u1', email: '[email protected]' });
mockValidateSession.mockResolvedValueOnce(false);
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
@@ -76,7 +85,7 @@ describe('ensureSession', () => {
expect(mockSaveSession).toHaveBeenCalledWith(baseUrl, newAuth);
});
it('prompts for credentials when no session exists', async () => {
it('resolves piped-stdin credentials when no session exists', async () => {
mockLoadSession.mockReturnValueOnce(null);
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
mockSignIn.mockResolvedValueOnce(newAuth);
@@ -84,6 +93,10 @@ describe('ensureSession', () => {
const cookie = await ensureSession(baseUrl);
expect(cookie).toBe(fakeCookie);
expect(mockSignIn).toHaveBeenCalled();
// The non-TTY path resolves credentials from the piped-stdin seam, not prompts.
expect(
vi.mocked(await import('./piped-credentials.js')).readCredentialsFromPipedStdin,
).toHaveBeenCalled();
});
it('exits non-zero when signIn fails', async () => {
@@ -111,7 +124,7 @@ describe('runRecoverToken', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('prompts for login, mints a token, and persists it when no session exists', async () => {
it('signs in via piped stdin, mints a token, and persists it when no session exists', async () => {
mockLoadSession.mockReturnValueOnce(null);
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
mockSignIn.mockResolvedValueOnce(newAuth);
@@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
checkDatabaseSchema,
SCHEMA_FAIL_REMEDIATION,
type SchemaCheckDeps,
} from './schema-check.js';
/* ------------------------------------------------------------------ */
/* Fixture config */
/* ------------------------------------------------------------------ */
const tmpDirs: string[] = [];
function writeConfig(cfg: Record<string, unknown>): string {
const dir = mkdtempSync(join(tmpdir(), 'schema-check-'));
tmpDirs.push(dir);
const path = join(dir, 'mosaic.config.json');
writeFileSync(path, JSON.stringify(cfg));
return path;
}
const STANDALONE_CFG = {
tier: 'standalone',
storage: { type: 'postgres', url: 'postgresql://u:p@localhost:5434/x' },
queue: { type: 'bullmq', url: 'redis://localhost:6380' },
memory: { type: 'keyword' },
};
const LOCAL_CFG = {
tier: 'local',
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
queue: { type: 'local', dataDir: '.mosaic/queue' },
memory: { type: 'keyword' },
};
function deps(overrides: Partial<SchemaCheckDeps> = {}): SchemaCheckDeps {
return {
runMigrations: vi.fn().mockResolvedValue(undefined),
getMigrationStatus: vi.fn().mockResolvedValue({
appliedCount: 17,
expectedCount: 17,
expectedLastTag: '0016_x',
complete: true,
}),
...overrides,
};
}
/* ------------------------------------------------------------------ */
/* Tests */
/* ------------------------------------------------------------------ */
describe('checkDatabaseSchema', () => {
beforeEach(() => {
process.env['MOSAIC_CONFIG'] = '';
vi.stubEnv('DATABASE_URL', 'postgresql://env:env@localhost:9999/env');
});
afterEach(() => {
vi.unstubAllEnvs();
for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
it('passes when the ledger matches the shipped journal', async () => {
const cfg = writeConfig(STANDALONE_CFG);
const d = deps();
const result = await checkDatabaseSchema(d, cfg);
expect(result.status).toBe('ok');
expect(result.detail).toContain('17/17');
expect(d.runMigrations).toHaveBeenCalledWith(STANDALONE_CFG.storage.url);
expect(d.getMigrationStatus).toHaveBeenCalledWith(STANDALONE_CFG.storage.url);
});
it('FAILS when the ledger is incomplete — the #1389 empty-database signature', async () => {
const cfg = writeConfig(STANDALONE_CFG);
const d = deps({
getMigrationStatus: vi.fn().mockResolvedValue({
appliedCount: 0,
expectedCount: 17,
expectedLastTag: '0016_x',
complete: false,
}),
});
const result = await checkDatabaseSchema(d, cfg);
expect(result.status).toBe('fail');
if (result.status !== 'fail') throw new Error('expected fail');
expect(result.detail).toContain('0/17');
expect(result.remediation).toBe(SCHEMA_FAIL_REMEDIATION);
expect(result.remediation).toContain('#1389');
});
it('FAILS when the ledger is only PARTIALLY migrated (#1402 upgrade case)', async () => {
const cfg = writeConfig(STANDALONE_CFG);
const d = deps({
getMigrationStatus: vi.fn().mockResolvedValue({
appliedCount: 9,
expectedCount: 17,
expectedLastTag: '0016_x',
complete: false,
}),
});
const result = await checkDatabaseSchema(d, cfg);
expect(result.status).toBe('fail');
expect(result.detail).toContain('9/17');
});
it('FAILS (never crashes) when the migration run itself throws', async () => {
const cfg = writeConfig(STANDALONE_CFG);
const d = deps({
runMigrations: vi.fn().mockRejectedValue(new Error('connection refused')),
});
const result = await checkDatabaseSchema(d, cfg);
expect(result.status).toBe('fail');
expect(result.detail).toContain('connection refused');
});
it('skips the local tier (gateway migrates its own PGlite at startup)', async () => {
const cfg = writeConfig(LOCAL_CFG);
const d = deps();
const result = await checkDatabaseSchema(d, cfg);
expect(result.status).toBe('skipped');
expect(d.runMigrations).not.toHaveBeenCalled();
});
});
/* ------------------------------------------------------------------ */
/* Config resolution priority */
/* ------------------------------------------------------------------ */
describe('resolveSchemaCheckConfigPath', () => {
it('prefers the daemon-written gateway config over cwd copies', async () => {
const { resolveSchemaCheckConfigPath } = await import('./schema-check.js');
const cwdCfg = writeConfig(STANDALONE_CFG); // in a temp dir
const daemonDir = mkdtempSync(join(tmpdir(), 'schema-check-daemon-'));
tmpDirs.push(daemonDir);
const daemonHome = join(daemonDir, '.config', 'mosaic', 'gateway');
mkdirSync(daemonHome, { recursive: true });
writeFileSync(join(daemonHome, 'mosaic.config.json'), JSON.stringify(LOCAL_CFG));
const prevHome = process.env['HOME'];
vi.stubEnv('HOME', daemonDir);
vi.stubEnv('MOSAIC_CONFIG', '');
try {
const resolved = resolveSchemaCheckConfigPath();
// Must NOT pick the cwd copy (cwd is the vitest project dir, not our
// temp dir — so the only resolvable candidates are daemon + $HOME/.mosaic).
expect(resolved).toBe(join(daemonHome, 'mosaic.config.json'));
void cwdCfg;
} finally {
if (prevHome !== undefined) vi.stubEnv('HOME', prevHome);
}
});
it('gives MOSAIC_CONFIG NO authority (N1, review 285): env never overrides file resolution', async () => {
const { resolveSchemaCheckConfigPath } = await import('./schema-check.js');
const daemonDir = mkdtempSync(join(tmpdir(), 'schema-check-env-'));
tmpDirs.push(daemonDir);
const daemonHome = join(daemonDir, '.config', 'mosaic', 'gateway');
mkdirSync(daemonHome, { recursive: true });
writeFileSync(join(daemonHome, 'mosaic.config.json'), JSON.stringify(LOCAL_CFG));
// A stale env var pointing at a DIFFERENT file must be ignored entirely:
const decoyDir = mkdtempSync(join(tmpdir(), 'schema-check-decoy-'));
tmpDirs.push(decoyDir);
const decoyPath = join(decoyDir, 'mosaic.config.json');
writeFileSync(decoyPath, JSON.stringify(STANDALONE_CFG));
const prevHome = process.env['HOME'];
vi.stubEnv('HOME', daemonDir);
vi.stubEnv('MOSAIC_CONFIG', decoyPath);
try {
const resolved = resolveSchemaCheckConfigPath();
expect(resolved).toBe(join(daemonHome, 'mosaic.config.json'));
expect(resolved).not.toBe(decoyPath);
} finally {
if (prevHome !== undefined) vi.stubEnv('HOME', prevHome);
}
});
});
@@ -0,0 +1,110 @@
/**
* Install-time database schema verification (#1392).
*
* Fresh standalone (postgres) installs once ended "healthy" with a completely
* empty database: the resolved dependency set shipped no migrations at all
* (#1389), the gateway started fine, and nothing failed until the first real
* query. The issue's own conclusion: the installer must "instruct and verify".
*
* This check runs AFTER migrations have been (re-)applied, so the only way it
* fails is a genuinely broken migration set or database which is exactly
* when install must not report success. Failure is fatal by design (fast-fail
* STANDARDS); callers print the remediation text and exit non-zero.
*/
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { loadConfig } from '@mosaicstack/config';
export interface SchemaStatusCounts {
appliedCount: number;
expectedCount: number;
expectedLastTag: string;
complete: boolean;
}
/** Injectable migration surface — keeps this unit-testable without a DB. */
export interface SchemaCheckDeps {
runMigrations(url: string): Promise<void>;
getMigrationStatus(url: string): Promise<SchemaStatusCounts>;
}
export type SchemaCheckResult =
| { status: 'ok'; detail: string }
| { status: 'skipped'; detail: string }
| { status: 'fail'; detail: string; remediation: string };
export const SCHEMA_FAIL_REMEDIATION = [
'The gateway database does not carry the full schema.',
'Causes seen in the wild: dependency set resolved without migrations (#1389), or a partially-migrated database (#1402).',
'Remediation:',
' 1. Re-run: mosaic gateway install (applies migrations and verifies again)',
' 2. Check the resolved @mosaicstack/db version is the same pipeline as the gateway (npm ls -g @mosaicstack/db)',
' 3. Manual apply: run runMigrations() from @mosaicstack/db against the storage URL, then re-verify',
].join('\n');
/**
* Resolve the config the INSTALLED gateway would use same priority the
* daemon applies (apps/gateway/src/env.ts resolveGatewayConfigPath), minus
* the source-tree anchors that do not exist on an installed host. Verifying
* against any other config could green-light a database the daemon never
* reads (#1392: verify what runs, not what happens to lie in cwd).
*/
export function resolveSchemaCheckConfigPath(explicit?: string): string | undefined {
if (explicit) return resolve(explicit);
// NOTE: no env-var candidate, deliberately. apps/gateway/src/env.ts gives env
// NO config authority (a stale MOSAIC_CONFIG could verify a database the
// daemon never reads — rev-code-02 review 285, note N1). Resolution order
// mirrors the daemon's file priorities only.
const candidates = [
join(homedir(), '.config', 'mosaic', 'gateway', 'mosaic.config.json'), // daemon-written
resolve(process.cwd(), 'mosaic.config.json'),
join(homedir(), '.mosaic', 'mosaic.config.json'),
];
for (const c of candidates) {
if (c && existsSync(c)) return c;
}
return undefined; // loadConfig falls back to env-var detection
}
export async function checkDatabaseSchema(
deps: SchemaCheckDeps,
configPath?: string,
): Promise<SchemaCheckResult> {
const config = loadConfig(resolveSchemaCheckConfigPath(configPath));
// Local tier: the gateway itself runs PGlite migrations at startup (see
// DatabaseModule.onModuleInit), and a broken local tier fails the health
// check instead. Nothing for the installer to verify here.
if (config.storage.type !== 'postgres') {
return {
status: 'skipped',
detail: 'database schema (local tier — migrated by gateway at startup)',
};
}
const url = config.storage.url;
try {
await deps.runMigrations(url);
const status = await deps.getMigrationStatus(url);
if (status.complete) {
return {
status: 'ok',
detail: `database schema (${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations)`,
};
}
return {
status: 'fail',
detail: `database schema incomplete (${status.appliedCount.toString()}/${status.expectedCount.toString()} applied, last expected: ${status.expectedLastTag})`,
remediation: SCHEMA_FAIL_REMEDIATION,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
status: 'fail',
detail: `database schema check errored: ${msg}`,
remediation: SCHEMA_FAIL_REMEDIATION,
};
}
}
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../auth.js', () => ({
loadSession: vi.fn(),
validateSession: vi.fn(),
signIn: vi.fn(),
saveSession: vi.fn(),
}));
vi.mock('./login.js', () => ({
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
promptLine: vi.fn(),
promptSecret: vi.fn(),
}));
vi.mock('./piped-credentials.js', () => ({
readCredentialsFromPipedStdin: vi.fn(),
}));
vi.mock('./daemon.js', () => ({
readMeta: vi.fn(),
writeMeta: vi.fn(),
}));
import { ensureSession } from './token-ops.js';
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
import { promptLine, promptSecret } from './login.js';
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
const URL = 'http://localhost:14242';
function asNonTTY(): void {
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
}
describe('ensureSession — #1394 credential precedence (flag > piped stdin > prompt)', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(loadSession).mockReturnValue(null);
asNonTTY();
});
it('stored valid session wins; no credentials touched', async () => {
vi.mocked(loadSession).mockReturnValue({ cookie: 'SESS', email: '[email protected]' } as never);
vi.mocked(validateSession).mockResolvedValue(true);
await expect(ensureSession(URL)).resolves.toBe('SESS');
expect(signIn).not.toHaveBeenCalled();
});
it('flag email + stdin password: FLAG wins for email, stdin supplies the password', async () => {
vi.mocked(signIn).mockResolvedValue({ cookie: 'NEW', email: '[email protected]' } as never);
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
email: '[email protected]',
password: 'stdin-pw',
});
await ensureSession(URL, { email: '[email protected]' });
expect(signIn).toHaveBeenCalledWith(URL, '[email protected]', 'stdin-pw');
expect(promptLine).not.toHaveBeenCalled();
expect(promptSecret).not.toHaveBeenCalled();
});
it('stdin-only path (no flag): both credentials from piped lines', async () => {
vi.mocked(signIn).mockResolvedValue({ cookie: 'NEW2', email: '[email protected]' } as never);
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
email: '[email protected]',
password: 'spw',
});
await ensureSession(URL);
expect(signIn).toHaveBeenCalledWith(URL, '[email protected]', 'spw');
});
it('no credentials headless → exit(2) with --email guidance; signIn untouched', async () => {
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({ email: null, password: null });
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('EXIT');
}) as never);
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(ensureSession(URL)).rejects.toThrow('EXIT');
expect(exit).toHaveBeenCalledWith(2);
expect(err).toHaveBeenCalledWith(expect.stringContaining('--email'));
expect(signIn).not.toHaveBeenCalled();
exit.mockRestore();
err.mockRestore();
});
it('successful sign-in persists the session', async () => {
vi.mocked(signIn).mockResolvedValue({ cookie: 'C', email: '[email protected]' } as never);
vi.mocked(readCredentialsFromPipedStdin).mockResolvedValue({
email: '[email protected]',
password: 'pw',
});
await ensureSession(URL);
expect(saveSession).toHaveBeenCalledWith(URL, expect.anything());
});
});
@@ -1,6 +1,7 @@
import { loadSession, validateSession, signIn, saveSession } from '../../auth.js';
import { readMeta, writeMeta } from './daemon.js';
import { getGatewayUrl, promptLine, promptSecret } from './login.js';
import { readCredentialsFromPipedStdin } from './piped-credentials.js';
interface MintedToken {
id: string;
@@ -107,8 +108,24 @@ export async function requireSession(gatewayUrl: string): Promise<string> {
* Ensure a valid session for the gateway, prompting for credentials if needed.
* On sign-in failure, prints the error and exits non-zero.
* Returns the session cookie.
*
* Credential precedence when sign-in is needed (#1394):
* 1. explicit opts (--email flag; highest)
* 2. non-TTY stdin first line email, second line password (headless dual
* path for callers without argv access: printf 'email\npassword\n' | )
* 3. interactive prompt (TTY only)
*/
export async function ensureSession(gatewayUrl: string): Promise<string> {
export interface SessionCredentialOptions {
/** Email from an explicit flag (argv). Highest precedence. */
email?: string;
/** Password from an explicit source. Rare; passwords normally come via stdin/prompt. */
password?: string;
}
export async function ensureSession(
gatewayUrl: string,
opts: SessionCredentialOptions = {},
): Promise<string> {
// Try the stored session first
const session = loadSession(gatewayUrl);
if (session) {
@@ -119,10 +136,25 @@ export async function ensureSession(gatewayUrl: string): Promise<string> {
console.log(`No session found for ${gatewayUrl}. Please sign in.`);
}
// Prompt for credentials — password must not be echoed to the terminal
const email = await promptLine('Email: ');
// Do not trim password — it may contain intentional leading/trailing whitespace
const password = await promptSecret('Password: ');
let email = opts.email;
let password = opts.password;
if ((!email || !password) && !process.stdin.isTTY) {
const piped = await readCredentialsFromPipedStdin();
email = email ?? piped.email ?? undefined;
password = password ?? piped.password ?? undefined;
}
if (!email || !password) {
if (!process.stdin.isTTY) {
console.error(
'No valid session and no credentials available headlessly. Provide --email plus ' +
"a password line on stdin (printf 'email\\npassword\\n' | …), or run interactively.",
);
process.exit(2);
}
email = await promptLine('Email: ');
// Do not trim password — it may contain intentional leading/trailing whitespace
password = await promptSecret('Password: ');
}
const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => {
console.error(err instanceof Error ? err.message : String(err));
@@ -146,11 +178,12 @@ export async function runRotateToken(gatewayUrl?: string): Promise<void> {
}
/**
* `mosaic gateway config recover-token` prompts for login if no session exists.
* `mosaic gateway config recover-token` signs in if no session exists.
* Passes the --email flag through to ensureSession (#1394 dual path).
*/
export async function runRecoverToken(gatewayUrl?: string): Promise<void> {
export async function runRecoverToken(gatewayUrl?: string, email?: string): Promise<void> {
const url = getGatewayUrl(gatewayUrl);
const cookie = await ensureSession(url);
const cookie = await ensureSession(url, { email });
const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`;
const minted = await mintAdminToken(url, cookie, label);
persistToken(url, minted);
@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mkdirSync } from 'node:fs';
vi.mock('./daemon.js', () => ({
GATEWAY_HOME: '/tmp/u-test-gateway-home',
getDaemonPid: vi.fn().mockReturnValue(null),
readMeta: vi.fn(),
stopDaemon: vi.fn(),
uninstallGatewayPackage: vi.fn(),
}));
import { runUninstall } from './uninstall.js';
import { readMeta, uninstallGatewayPackage } from './daemon.js';
describe('gateway uninstall — #1390 headless semantics', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('non-TTY without --yes FAILS LOUD (exit 1, nothing touched)', async () => {
vi.mocked(readMeta).mockReturnValue({
version: '0.0.7',
installedAt: '',
entryPoint: '',
host: 'localhost',
port: 14242,
});
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('EXIT');
}) as never);
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
await expect(runUninstall()).rejects.toThrow('EXIT');
expect(exit).toHaveBeenCalledWith(1);
expect(err).toHaveBeenCalledWith(expect.stringContaining('stdin is not a TTY'));
expect(uninstallGatewayPackage).not.toHaveBeenCalled();
exit.mockRestore();
err.mockRestore();
});
it('--yes proceeds headlessly WITHOUT removing data (never implied)', async () => {
const meta = {
version: '0.0.7',
installedAt: '',
entryPoint: '',
host: 'localhost',
port: 14242,
};
vi.mocked(readMeta).mockReturnValue(meta);
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
await runUninstall({ yes: true });
expect(uninstallGatewayPackage).toHaveBeenCalledTimes(1);
expect(log).toHaveBeenCalledWith(expect.stringContaining('Gateway data kept'));
log.mockRestore();
});
it('--yes --remove-data removes data headlessly', async () => {
vi.mocked(readMeta).mockReturnValue({
version: '0.0.7',
installedAt: '',
entryPoint: '',
host: 'localhost',
port: 14242,
});
mkdirSync('/tmp/u-test-gateway-home', { recursive: true }); // existsSync gate
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
await runUninstall({ yes: true, removeData: true });
expect(uninstallGatewayPackage).toHaveBeenCalledTimes(1);
expect(log).toHaveBeenCalledWith(expect.stringContaining('Gateway data removed'));
log.mockRestore();
});
it('no meta → clean no-op even with --yes', async () => {
vi.mocked(readMeta).mockReturnValue(null);
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
await runUninstall({ yes: true });
expect(log).toHaveBeenCalledWith('Gateway is not installed.');
expect(uninstallGatewayPackage).not.toHaveBeenCalled();
log.mockRestore();
});
});
@@ -8,30 +8,65 @@ import {
uninstallGatewayPackage,
} from './daemon.js';
export async function runUninstall(): Promise<void> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
export interface UninstallOptions {
/** Skip the confirmation prompt (headless/scripted uninstall). */
yes?: boolean;
/** Also remove all gateway data at GATEWAY_HOME (never implied by --yes). */
removeData?: boolean;
}
export async function runUninstall(opts: UninstallOptions = {}): Promise<void> {
const nonInteractive = Boolean(opts.yes) || process.env['MOSAIC_ASSUME_YES'] === '1';
// Non-TTY without explicit consent must FAIL LOUD, not quietly do nothing:
// the pre-fix behavior (prompt on a closed stdin → default No → exit 0,
// gateway untouched) reported success-by-silence to every scripted caller
// (#1390). An explicit refusal beats a silent no-op.
if (!nonInteractive && !process.stdin.isTTY) {
console.error(
'gateway uninstall: stdin is not a TTY and no --yes was given — refusing to ' +
'run an interactive uninstall headlessly (nothing was changed). ' +
'Use --yes (and --remove-data to also delete gateway data), or run from a terminal.',
);
process.exit(1);
}
const rl = nonInteractive
? null
: createInterface({ input: process.stdin, output: process.stdout });
try {
await doUninstall(rl);
await doUninstall(rl as NonNullable<typeof rl>, opts, nonInteractive);
} finally {
rl.close();
rl?.close();
}
}
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
function prompt(
rl: NonNullable<ReturnType<typeof createInterface>>,
question: string,
): Promise<string> {
return new Promise((resolve) => rl.question(question, resolve));
}
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
async function doUninstall(
rl: ReturnType<typeof createInterface>,
opts: UninstallOptions,
nonInteractive: boolean,
): Promise<void> {
const meta = readMeta();
if (!meta) {
console.log('Gateway is not installed.');
return;
}
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
if (answer.toLowerCase() !== 'y') {
console.log('Aborted.');
return;
if (nonInteractive) {
console.log(`Uninstalling Mosaic Gateway (--yes${opts.removeData ? ' --remove-data' : ''})...`);
} else {
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
if (answer.toLowerCase() !== 'y') {
console.log('Aborted.');
return;
}
}
// Stop if running
@@ -45,13 +80,20 @@ async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void
}
}
// Remove config/data
const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
if (removeData.toLowerCase() === 'y') {
// Remove config/data. Interactive: ask. Headless: only with the explicit
// flag — destructive recursion is never implied by --yes alone (#1390).
let removeData = Boolean(opts.removeData);
if (!nonInteractive) {
const answer = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
removeData = answer.toLowerCase() === 'y';
}
if (removeData) {
if (existsSync(GATEWAY_HOME)) {
rmSync(GATEWAY_HOME, { recursive: true, force: true });
console.log('Gateway data removed.');
}
} else {
console.log(`Gateway data kept at ${GATEWAY_HOME}.`);
}
// Uninstall npm package
+33 -2
View File
@@ -44,6 +44,8 @@ export interface VerifyResult {
gatewayHealthy: boolean;
adminTokenOnFile: boolean;
bootstrapReachable: boolean;
/** False only on a FAILED postgres schema check; true when ok or skipped. */
schemaMigrated: boolean;
allPassed: boolean;
}
@@ -89,7 +91,36 @@ export async function runPostInstallVerification(
fail('bootstrap endpoint reach', 'Run: mosaic gateway status / mosaic gateway logs');
}
const allPassed = gatewayHealthy && adminTokenOnFile && bootstrapReachable;
// ─── Check 4: Database schema migrated (#1392) ────────────────────────────
// Fatal-on-failure for install: the #1389 failure mode was an install that
// reported success over an empty database. Local tiers skip (the gateway
// migrates its own PGlite at startup and would fail health if it couldn't).
let schemaMigrated = true;
try {
const { checkDatabaseSchema } = await import('./schema-check.js');
const { runMigrations, getMigrationStatus } = await import('@mosaicstack/db');
const result = await checkDatabaseSchema(
{ runMigrations, getMigrationStatus },
undefined, // resolver mirrors daemon file priorities; env has no config authority (N1)
);
if (result.status === 'ok') {
ok(result.detail);
} else if (result.status === 'skipped') {
ok(result.detail);
} else {
fail(result.detail, result.remediation);
schemaMigrated = false;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
fail(
`database schema check errored: ${msg}`,
'See #1392/#1389; re-run: mosaic gateway install',
);
schemaMigrated = false;
}
const allPassed = gatewayHealthy && adminTokenOnFile && bootstrapReachable && schemaMigrated;
if (!allPassed) {
console.log(
@@ -98,7 +129,7 @@ export async function runPostInstallVerification(
);
}
return { gatewayHealthy, adminTokenOnFile, bootstrapReachable, allPassed };
return { gatewayHealthy, adminTokenOnFile, bootstrapReachable, schemaMigrated, allPassed };
}
/**
@@ -205,6 +205,12 @@ export async function runLeaseEnforcementDoctorCheck(
message:
`Lease-enforcement hooks (${matchedMarkers.join(', ')}) are wired in ~/.claude/settings.json, but ${reasons.join(' and ')}. ` +
'Every gated tool call will fail closed and BRICK this agent (see #869). ' +
'Remediate by activating the lease-broker supervisor (systemd unit + socket) or by removing the enforcement hooks from ~/.claude/settings.json.',
// #1292: one remedy, correct under BOTH enable conventions (by-path on
// the seed template, and copy-then-enable in the active dir). Written
// from the 2026-08-17 symlink measurement: `systemctl enable` by name
// does NOT rewrite an existing by-path wants-symlink, so teaching a
// manual systemctl line here could leave a host with two competing
// wants links. fleet install reconciles either shape.
'Remedy: run `mosaic fleet install` (it reconciles either enable convention), or remove the enforcement hooks from ~/.claude/settings.json.',
};
}
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
chmodSync,
mkdtempSync,
@@ -16,6 +16,7 @@ import {
renderPeerReach,
readFleetCommsBlock,
resolveCommsBlock,
resolveFleetIdentity,
resolvePeerCommand,
renderToolsContractStatus,
} from './comms-onboarding.js';
@@ -61,6 +62,68 @@ describe('shared fleet roster v1 resolver', () => {
});
});
// stack#1380 verification unblock: the fleet's own roster-v2 tooling writes
// the v1 body plus a generation fence and seat lifecycle/launch envelopes.
// The parser tolerates exactly that envelope (validated, opaque to comms).
const V2_ROSTER = [
'version: 2',
'generation: 8',
'transport: tmux',
'tmux:',
' socket_name: mosaic-fleet',
'defaults:',
' working_directory: ~/.mosaic',
' runtime: claude',
'agents:',
' - name: orch-01',
' runtime: claude',
' class: orchestrator',
' model: opus',
' reasoning: high',
' lifecycle:',
' enabled: true',
' desired_state: running',
' launch:',
' yolo: false',
'',
].join('\n');
it('accepts the roster-v2 envelope (generation + lifecycle/launch) on the v1 body', () => {
const resolved = parseFleetRosterV1(V2_ROSTER, 'yaml');
expect(resolved.tmux.socketName).toBe('mosaic-fleet');
expect(resolved.agents[0]?.name).toBe('orch-01');
});
it('rejects a non-integer generation', () => {
expect(() =>
parseFleetRosterV1(V2_ROSTER.replace('generation: 8', 'generation: eight'), 'yaml'),
).toThrow(/generation must be a non-negative integer/);
});
it('rejects an invalid lifecycle desired_state', () => {
expect(() =>
parseFleetRosterV1(
V2_ROSTER.replace('desired_state: running', 'desired_state: paused'),
'yaml',
),
).toThrow(/desired_state must be running\|stopped/);
});
it('rejects unknown fields inside the lifecycle envelope', () => {
expect(() =>
parseFleetRosterV1(
V2_ROSTER.replace(' enabled: true', ' enabled: true\n surprise: 1'),
'yaml',
),
).toThrow(/lifecycle has unknown field/);
});
it('rejects a non-boolean launch.yolo', () => {
expect(() =>
parseFleetRosterV1(V2_ROSTER.replace('yolo: false', 'yolo: sometimes'), 'yaml'),
).toThrow(/launch\.yolo must be a boolean/);
});
it('rejects unknown fields instead of leniently constructing a second roster view', () => {
expect(() => parseFleetRosterV1(`${ROSTER}\nunknown: value\n`, 'yaml')).toThrow(
/unknown field/i,
@@ -493,6 +556,11 @@ describe('resolvePeerCommand', () => {
describe('readFleetCommsBlock — spawned-agent context', () => {
let home: string;
beforeEach(() => {
// Hermetic helper fallback (stack#1380): the resolver probes
// $HOME/.config/mosaic when mosaicHome itself carries no helper — point
// HOME at a sandbox parent so tests never see the real host install.
vi.stubEnv('HOME', mkdtempSync(join(tmpdir(), 'mosaic-homeless-')));
vi.stubEnv('MOSAIC_HOME', '');
home = mkdtempSync(join(tmpdir(), 'mosaic-comms-'));
mkdirSync(join(home, 'fleet'), { recursive: true });
mkdirSync(join(home, 'tools', 'tmux'), { recursive: true });
@@ -501,7 +569,10 @@ describe('readFleetCommsBlock — spawned-agent context', () => {
writeFileSync(helper, '#!/bin/sh\n');
chmodSync(helper, 0o755);
});
afterEach(() => rmSync(home, { recursive: true, force: true }));
afterEach(() => {
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
});
it('uses the authoritative self host and global socket from the shared roster resolver', () => {
const result = readFleetCommsBlock(home, 'enhancer', 'process-host-must-not-win');
@@ -564,23 +635,27 @@ describe('readFleetCommsBlock — spawned-agent context', () => {
},
],
[
'symlink',
'symlink escaping the install home',
() => {
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
rmSync(helper);
writeFileSync(join(home, 'real-send.sh'), '#!/bin/sh\n');
symlinkSync(join(home, 'real-send.sh'), helper);
const outside = mkdtempSync(join(tmpdir(), 'mosaic-helper-outside-'));
writeFileSync(join(outside, 'real-send.sh'), '#!/bin/sh\n', { mode: 0o755 });
symlinkSync(join(outside, 'real-send.sh'), helper);
},
],
['non-executable', () => chmodSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 0o644)],
])('fails closed for a %s helper with deterministic repair guidance', (_case, mutate) => {
mutate();
const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
expect(result.ok).toBe(false);
expect(result.output).toBe('');
expect(result.error).toContain('mosaic update --repair-tools');
expect(result.error).toContain('no active context or session was rewritten');
});
])(
'fails closed for a %s helper with deterministic guidance (no forbidden remedy)',
(_case, mutate) => {
mutate();
const result = readFleetCommsBlock(home, 'enhancer', 'w-jarvis');
expect(result.ok).toBe(false);
expect(result.output).toBe('');
expect(result.error).not.toContain('--repair-tools'); // stack#1380 M5a
expect(result.error).toContain('no active context or session was rewritten');
},
);
it('does not rewrite the roster while resolving context', () => {
const path = join(home, 'fleet', 'roster.yaml');
@@ -603,11 +678,11 @@ describe('renderToolsContractStatus — non-mutating install drift', () => {
});
afterEach(() => rmSync(home, { recursive: true, force: true }));
it('uses the supported repair command when installed TOOLS.md is missing', () => {
it('names operator-verified recovery instead of a forbidden remedy when installed TOOLS.md is missing', () => {
const status = renderToolsContractStatus(home);
expect(status).toContain('mosaic update --repair-tools');
expect(status).not.toContain('--reseed');
expect(status).toContain('authorized operator');
expect(status).not.toContain('--repair-tools'); // stack#1380 M5a
expect(status).not.toContain('--reseed');
});
it('reports stale preserved content without rewriting it', () => {
@@ -616,8 +691,8 @@ describe('renderToolsContractStatus — non-mutating install drift', () => {
writeFileSync(path, stale);
const status = renderToolsContractStatus(home);
expect(status).toContain('fleet-comms-contract: 1');
expect(status).toContain('digest-qualified backup');
expect(status).toContain('mosaic update --repair-tools');
expect(status).toContain('authorized operator');
expect(status).not.toContain('--repair-tools'); // stack#1380 M5a
expect(status).toContain('active context was not rewritten');
expect(readFileSync(path, 'utf8')).toBe(stale);
});
@@ -648,7 +723,7 @@ describe('renderToolsContractStatus — non-mutating install drift', () => {
expect(renderToolsContractStatus(home)).not.toBe('');
});
it('treats installed TOOLS.md symlinks as stale without following or rewriting them', () => {
it('reads an installed TOOLS.md symlink whose validated target diverges (stack#1380 resolve-then-validate)', () => {
const external = join(home, 'external-tools.md');
const externalContent = '# external\n<!-- fleet-comms-contract: 1 -->\n';
writeFileSync(external, externalContent);
@@ -656,24 +731,23 @@ describe('renderToolsContractStatus — non-mutating install drift', () => {
const status = renderToolsContractStatus(home);
expect(status).toContain('unavailable');
expect(status).toContain('mosaic update --repair-tools');
expect(status).toContain('does not byte-match');
expect(readFileSync(external, 'utf8')).toBe(externalContent);
});
it('treats source TOOLS.md symlinks as unavailable without following them', () => {
const external = join(home, 'external-source.md');
it('treats a source TOOLS.md symlink escaping the install home as unavailable', () => {
const outside = mkdtempSync(join(tmpdir(), 'mosaic-source-outside-'));
const content = '# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n';
writeFileSync(external, content);
writeFileSync(join(outside, 'external-source.md'), content);
rmSync(join(home, 'defaults', 'TOOLS.md'));
symlinkSync(external, join(home, 'defaults', 'TOOLS.md'));
symlinkSync(join(outside, 'external-source.md'), join(home, 'defaults', 'TOOLS.md'));
writeFileSync(join(home, 'TOOLS.md'), content);
const status = renderToolsContractStatus(home);
expect(status).toContain('source contract');
expect(status).toContain('unavailable');
expect(readFileSync(external, 'utf8')).toBe(content);
expect(readFileSync(join(outside, 'external-source.md'), 'utf8')).toBe(content);
});
it('accepts byte-equal bounded source and installed contracts', () => {
@@ -730,3 +804,91 @@ describe('resolveCommsBlock — mosaic agent comms-block', () => {
expect(result.error).toContain('requires');
});
});
describe('resolveFleetIdentity — stack#1380 split-home layouts', () => {
// Brain-shaped mosaicHome (fleet state, NO tools/tmux) + framework config
// home carrying the helper, roster unified by the framework-created symlink
// <configHome>/fleet/roster.yaml -> <brain>/fleet/roster.yaml. This is the
// host layout that was down; all probes are POSITIONAL per the #1380
// verification protocol (an object arg proves nothing — M5b). HOME is
// stubbed so the config-default fallback stays inside the sandbox.
let brain: string;
let configHome: string;
beforeEach(() => {
const parent = mkdtempSync(join(tmpdir(), 'mosaic-i1380-parent-'));
brain = join(parent, 'brain');
// Framework home at the stubbed DEFAULT location so the fallback derives
// exactly as in production ($HOME/.config/mosaic), not by coincidence.
configHome = join(parent, 'home', '.config', 'mosaic');
vi.stubEnv('HOME', join(parent, 'home'));
vi.stubEnv('MOSAIC_HOME', '');
mkdirSync(join(brain, 'fleet'), { recursive: true });
writeFileSync(join(brain, 'fleet', 'roster.yaml'), ROSTER, { mode: 0o600 });
mkdirSync(join(configHome, 'fleet'), { recursive: true });
symlinkSync(join(brain, 'fleet', 'roster.yaml'), join(configHome, 'fleet', 'roster.yaml'));
mkdirSync(join(configHome, 'tools', 'tmux'), { recursive: true });
writeFileSync(join(configHome, 'tools', 'tmux', 'agent-send.sh'), '#!/bin/sh\n', {
mode: 0o755,
});
process.env['MOSAIC_BRAIN_HOME'] = brain;
});
afterEach(() => {
delete process.env['MOSAIC_BRAIN_HOME'];
vi.unstubAllEnvs();
rmSync(join(brain, '..'), { recursive: true, force: true });
});
it('resolves a member through the roster symlink under the config home', () => {
const result = resolveFleetIdentity(configHome, 'orchestrator', 'w-jarvis');
expect(result.ok).toBe(true);
expect(result.identity?.member.name).toBe('orchestrator');
expect(result.identity?.agentSendPath).toBe(join(configHome, 'tools', 'tmux', 'agent-send.sh'));
});
it('resolves a member when mosaicHome is the brain (helper found under the framework home)', () => {
const result = resolveFleetIdentity(brain, 'enhancer', 'w-jarvis');
expect(result.ok).toBe(true);
expect(result.identity?.member.name).toBe('enhancer');
expect(result.identity?.agentSendPath).toBe(join(configHome, 'tools', 'tmux', 'agent-send.sh'));
});
it('no-name control stays a quiet no-op', () => {
expect(resolveFleetIdentity(configHome, undefined, 'w-jarvis')).toEqual({ ok: true });
expect(resolveFleetIdentity(brain, undefined, 'w-jarvis')).toEqual({ ok: true });
});
it('a nonce name fails naming membership, not the symlink or the helper', () => {
const result = resolveFleetIdentity(configHome, 'nonce-' + Date.now(), 'w-jarvis');
expect(result.ok).toBe(false);
expect(result.error).not.toContain('symbolic link');
expect(result.error).not.toContain('helper');
expect(result.error).toContain('nonce-');
});
it('a non-member failure names membership, not the symlink (protocol control)', () => {
const result = resolveFleetIdentity(configHome, 'jarvis', 'w-jarvis');
expect(result.ok).toBe(false);
expect(result.error).not.toContain('symbolic link');
expect(result.error).not.toContain('helper is unavailable');
expect(result.error).toContain('orchestrator'); // known-member listing
});
it('names every searched framework home when the helper is missing everywhere', () => {
rmSync(join(configHome, 'tools'), { recursive: true, force: true });
const result = resolveFleetIdentity(brain, 'orchestrator', 'w-jarvis');
expect(result.ok).toBe(false);
expect(result.error).toContain('agent-send.sh');
expect(result.error).toContain(join(brain, 'tools', 'tmux', 'agent-send.sh'));
expect(result.error).not.toContain('--repair-tools');
});
it('readFleetCommsBlock composes the full contract on the split-home layout', () => {
const result = readFleetCommsBlock(configHome, 'orchestrator', 'w-jarvis');
expect(result.ok).toBe(true);
expect(result.output).toContain(
'Helper: `' + join(configHome, 'tools', 'tmux', 'agent-send.sh'),
);
});
});
+66 -8
View File
@@ -9,8 +9,9 @@
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { homedir, hostname } from 'node:os';
import { join } from 'node:path';
import { join, resolve } from 'node:path';
import { readRegularFileSecure } from './secure-file.js';
import { resolveBrainHome } from './brain-home.js';
import {
parseFleetRosterV1,
resolveInstalledFleetRosterPath,
@@ -260,7 +261,13 @@ context and have an authorized operator relaunch only this exact roster member w
function validateAgentSendHelper(path: string, mosaicHome: string): string | undefined {
try {
readRegularFileSecure(path, { root: mosaicHome, executable: true });
readRegularFileSecure(path, {
root: mosaicHome,
executable: true,
// The helper tree may live under the framework config home while this
// caller's mosaicHome is the brain; both are framework-owned roots.
symlinkTargetRoots: [resolveBrainHome(mosaicHome)],
});
return undefined;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
@@ -268,8 +275,48 @@ function validateAgentSendHelper(path: string, mosaicHome: string): string | und
}
}
/**
* Framework install homes probed for tools/tmux/agent-send.sh (stack#1380 M2).
* The helper ships with the FRAMEWORK install, which on split-home layouts is
* the config home not the brain (~/.mosaic carries fleet state, no tools).
*/
function frameworkHelperHomes(mosaicHome: string): string[] {
const homes = [resolve(mosaicHome)];
const envHome = process.env['MOSAIC_HOME'];
if (envHome && envHome.trim() !== '' && resolve(envHome) !== resolve(mosaicHome)) {
homes.push(resolve(envHome));
}
const configDefault = join(homedir(), '.config', 'mosaic');
if (resolve(configDefault) !== resolve(mosaicHome)) homes.push(configDefault);
return homes;
}
function resolveAgentSendHelper(mosaicHome: string): { path: string; error?: string } {
const homes = frameworkHelperHomes(mosaicHome);
for (const home of homes) {
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
if (!existsSync(helper)) continue;
const error = validateAgentSendHelper(helper, home);
if (!error) return { path: helper };
// Present but unsafe: surface that verdict instead of silently probing on.
return { path: helper, error };
}
return {
path: join(resolve(mosaicHome), 'tools', 'tmux', 'agent-send.sh'),
error:
`fleet helper agent-send.sh was not found under any framework install home ` +
`(${homes.map((h) => join(h, 'tools', 'tmux', 'agent-send.sh')).join('; ')}). ` +
`Verify the framework install for this host (the helper ships with the framework ` +
`config home; the brain home carries fleet state, not tools) and have an authorized ` +
`operator restore it if missing.`,
};
}
function helperFailureGuidance(reason: string): string {
return `${reason}. Run \`mosaic update --repair-tools\` to restore the supported current-version helper and TOOLS contract, then retry exact-member composition; no active context or session was rewritten.`;
// stack#1380 M5a: `mosaic update --repair-tools` is a forbidden remedy on the
// affected estate (and wrong for a layout/missing-helper failure). Name the
// actual recovery shape instead.
return `${reason}. Verify the framework install provides tools/tmux/agent-send.sh under the framework config home and that the roster resolves (split-home layouts symlink the roster into the brain); contact the operator if it persists; no active context or session was rewritten.`;
}
export function resolveFleetIdentity(
@@ -278,9 +325,15 @@ export function resolveFleetIdentity(
localHost: string = shortHostname(),
): FleetIdentityResult {
if (!requestedName) return { ok: true };
const agentSendPath = join(mosaicHome, 'tools', 'tmux', 'agent-send.sh');
const helperError = validateAgentSendHelper(agentSendPath, mosaicHome);
if (helperError) return { ok: false, error: helperFailureGuidance(helperError) };
const helper = resolveAgentSendHelper(mosaicHome);
if (helper.error) return { ok: false, error: helperFailureGuidance(helper.error) };
const agentSendPath = helper.path;
// Split-home layouts unify the roster by symlinking
// <configHome>/fleet/roster.yaml -> <brain>/fleet/roster.yaml. The secure
// read resolves that framework-created symlink when the brain is a
// sanctioned target root (stack#1380 M1).
const rosterSymlinkRoots = [resolveBrainHome(mosaicHome)];
let rosterPath: string;
try {
@@ -301,7 +354,10 @@ export function resolveFleetIdentity(
let roster: FleetRoster;
try {
roster = parseFleetRosterV1(
readRegularFileSecure(rosterPath, { root: mosaicHome }).content.toString('utf8'),
readRegularFileSecure(rosterPath, {
root: mosaicHome,
symlinkTargetRoots: rosterSymlinkRoots,
}).content.toString('utf8'),
rosterPath.endsWith('.json') ? 'json' : 'yaml',
);
} catch (error) {
@@ -399,7 +455,9 @@ function boundedContractDigest(
}
function replacementGuidance(): string {
return `Run \`mosaic update --repair-tools\` to make a digest-qualified backup and restore the supported current-version TOOLS contract, then have an authorized operator explicitly relaunch the exact roster member. The active context was not rewritten.`;
// stack#1380 M5a: never recommend the forbidden --repair-tools remedy from
// error text; name the operator-verified recovery shape instead.
return `Verify the installed TOOLS contract against the framework source with an authorized operator (the installed file must byte-match the supported current version) and have the operator explicitly relaunch the exact roster member. The active context was not rewritten.`;
}
/** Detect preserved installed TOOLS.md drift without changing it. */
@@ -169,6 +169,16 @@ function reconcileDeps(host: FakeLifecycleHost): FleetReconcileDeps {
applyProjection: async () => undefined,
readRoster: async () => host.roster,
acquireMutationLock: async () => async () => undefined,
// Hermetic broker observation (#1297 F3): without this, the plan probes
// the REAL host filesystem, so the "stable JSON" fixtures answered true
// on any machine with a live lease broker and false elsewhere. Pointing
// both paths at fixtures that do not exist pins socketPresent:false and
// unitInstalled:false on every host, which is what these fixtures assert.
brokerSocketEnv: {
MOSAIC_LEASE_BROKER_SOCKET: '/nonexistent/mosaic-lease/broker.sock',
XDG_CONFIG_HOME: '/nonexistent/mosaic-config',
XDG_RUNTIME_DIR: '/nonexistent/run',
},
};
}
@@ -459,6 +469,7 @@ describe('FCM-M3-002 reconciler lifecycle acceptance', (): void => {
plan: {
generation: 7,
holder: 'owned',
broker: { unitInstalled: false, socketPresent: false },
agents: [
{
name: 'coder0',
@@ -1,6 +1,7 @@
import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createServer } from 'node:net';
import { afterEach, describe, expect, it } from 'vitest';
import {
acquirePrivateReconcileLock,
@@ -92,6 +93,179 @@ async function run(command: FleetReconcileCommand, overrides: Partial<FleetRecon
}
describe('fleet roster-owned reconciler', (): void => {
// ── #1292: broker as first-class plan member + broker-first start ordering ──
it('reports broker unit and socket state in the plan (socket is the signal, not unit state)', async (): Promise<void> => {
const result = await run('status', {
statPath: async () => true,
checkBrokerSocket: async () => true,
});
expect(result.plan.broker).toEqual({ unitInstalled: true, socketPresent: true });
});
it('reports a dead broker as socketPresent=false even when the unit is installed (enabled-but-dead is the #1292 shape)', async (): Promise<void> => {
const result = await run('status', {
statPath: async () => true,
checkBrokerSocket: async () => false,
});
expect(result.plan.broker).toEqual({ unitInstalled: true, socketPresent: false });
});
it('probes the REAL filesystem when no seam is injected — live socket and unit report healthy, absent paths report absent (#1297 F3)', async (): Promise<void> => {
const dir = await mkdtemp(join(tmpdir(), 'mosaic-broker-probe-'));
cleanup = dir;
const configHome = join(dir, 'config');
const unitDir = join(configHome, 'systemd', 'user');
await mkdir(unitDir, { recursive: true });
await writeFile(join(unitDir, 'mosaic-lease-broker.service'), '[Unit]\n');
const sockPath = join(dir, 'broker.sock');
const server = createServer();
await new Promise<void>((resolve) => {
server.listen(sockPath, resolve);
});
try {
const result = await run('status', {
brokerSocketEnv: {
MOSAIC_LEASE_BROKER_SOCKET: sockPath,
XDG_CONFIG_HOME: configHome,
XDG_RUNTIME_DIR: dir,
},
});
expect(result.plan.broker).toEqual({ unitInstalled: true, socketPresent: true });
// Absent paths through the SAME seam-less path answer false — this is
// the half the old default got right; healthy is the half it got wrong.
const absent = await run('status', {
brokerSocketEnv: {
MOSAIC_LEASE_BROKER_SOCKET: join(dir, 'gone.sock'),
XDG_CONFIG_HOME: join(dir, 'gone-config'),
},
});
expect(absent.plan.broker).toEqual({ unitInstalled: false, socketPresent: false });
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it('command start refuses with a named error when the broker socket does not appear after enable+start (#1297 F3)', async (): Promise<void> => {
const calls: string[][] = [];
await expect(
run('start', {
checkBrokerSocket: async () => false,
runner: async (command, args) => {
calls.push([command, ...args]);
if (command === 'tmux' && args.includes('list-sessions')) {
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
}
if (command === 'tmux' && args.includes('show-environment')) {
return {
stdout:
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
},
}),
).rejects.toThrow(/broker-absent/);
// Refused: broker enable+start attempted, no holder/agent unit touched.
const agentStarts = calls.filter(
(c) => c.join(' ') === 'systemctl --user start [email protected]',
);
expect(agentStarts).toHaveLength(0);
});
it('command start enables and starts the broker BEFORE the holder and any agent unit', async (): Promise<void> => {
const calls: string[][] = [];
const result = await run('start', {
// Deterministic broker presence: without the seam this test answers the
// HOST's broker state (passes on a machine with a live broker, refuses
// on CI), not the ordering property it exists for (#1297 follow-up).
checkBrokerSocket: async () => true,
runner: async (command, args) => {
calls.push([command, ...args]);
if (command === 'tmux' && args.includes('list-sessions')) {
return { stdout: '_holder\ncoder0\n', stderr: '', exitCode: 0 };
}
if (command === 'tmux' && args.includes('show-environment')) {
return {
stdout:
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
},
});
expect(result.lifecycle).toBe('complete');
const brokerEnable = calls.findIndex(
(c) => c.join(' ') === 'systemctl --user enable mosaic-lease-broker.service',
);
const brokerStart = calls.findIndex(
(c) => c.join(' ') === 'systemctl --user start mosaic-lease-broker.service',
);
const holderStart = calls.findIndex(
(c) => c.join(' ') === 'systemctl --user start mosaic-tmux-holder.service',
);
const agentStart = calls.findIndex(
(c) => c.join(' ') === 'systemctl --user start [email protected]',
);
expect(brokerEnable).toBeGreaterThanOrEqual(0);
expect(brokerStart).toBeGreaterThan(brokerEnable);
// Holder start may be absent (holder 'owned' in this fixture); if present it must follow the broker.
if (holderStart >= 0) expect(holderStart).toBeGreaterThan(brokerStart);
expect(agentStart).toBeGreaterThan(brokerStart);
});
it('apply with a running desired agent also enables and starts the broker first', async (): Promise<void> => {
const calls: string[][] = [];
const runningRoster: FleetRosterV2 = {
...roster,
agents: roster.agents.map((agent) =>
agent.name === 'coder0'
? { ...agent, lifecycle: { enabled: true, desiredState: 'running' as const } }
: agent,
),
};
const result = await executeFleetReconcile({
roster: runningRoster,
command: 'apply',
expectedGeneration: 7,
deps: deps({
readRoster: async () => runningRoster,
// Deterministic broker presence (see start-ordering test note).
checkBrokerSocket: async () => true,
runner: async (command, args) => {
calls.push([command, ...args]);
if (command === 'tmux' && args.includes('list-sessions')) {
return { stdout: '_holder\n', stderr: '', exitCode: 0 };
}
if (command === 'tmux' && args.includes('show-environment')) {
return {
stdout:
'HOME=/home/mosaic\nMOSAIC_FLEET_OWNER=11111111-1111-4111-8111-111111111111\nMOSAIC_TMUX_HOLDER=_holder\nMOSAIC_TMUX_SOCKET=mosaic-fleet\nPATH=/usr/bin:/bin\nPWD=/home/mosaic\n',
stderr: '',
exitCode: 0,
};
}
return { stdout: '', stderr: '', exitCode: 0 };
},
}),
});
expect(result.applied).toBe(true);
const brokerStart = calls.findIndex(
(c) => c.join(' ') === 'systemctl --user start mosaic-lease-broker.service',
);
const agentStart = calls.findIndex(
(c) => c.join(' ') === 'systemctl --user start [email protected]',
);
expect(brokerStart).toBeGreaterThanOrEqual(0);
expect(agentStart).toBeGreaterThan(brokerStart);
});
it('fails closed on a symlinked fleet ancestor without touching its target', async (): Promise<void> => {
const home = await lockHome();
const fleet = join(home, 'fleet');
@@ -375,6 +549,8 @@ describe('fleet roster-owned reconciler', (): void => {
expectedGeneration: 7,
deps: deps({
readRoster: async () => runningRoster,
// Deterministic broker presence (see start-ordering test note).
checkBrokerSocket: async () => true,
runner: async (command, args) => {
calls.push([command, ...args]);
if (command === 'tmux' && args.includes('list-sessions')) {
+149 -13
View File
@@ -1,5 +1,5 @@
import { constants } from 'node:fs';
import { lstat, open, readFile, unlink, type FileHandle } from 'node:fs/promises';
import { lstat, open, readFile, stat, unlink, type FileHandle } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { homedir } from 'node:os';
import { join } from 'node:path';
@@ -44,6 +44,10 @@ export interface FleetReconcileDeps {
readonly overrideDir?: string;
readonly homeDirectory?: string;
readonly readHolderIdentity?: () => Promise<string>;
/** Test/observation seams for the lease-broker plan member (#1292). */
readonly statPath?: (path: string) => Promise<boolean> | boolean;
readonly checkBrokerSocket?: (path: string) => Promise<boolean> | boolean;
readonly brokerSocketEnv?: NodeJS.ProcessEnv;
readonly validateRoster?: (roster: FleetRosterV2) => Promise<void>;
readonly prepareProjections?: (roster: FleetRosterV2) => Promise<readonly unknown[]>;
readonly applyProjection?: (prepared: unknown) => Promise<unknown>;
@@ -75,6 +79,17 @@ export interface FleetReconcileObservedAgent {
export interface FleetReconcilePlan {
readonly generation: number;
readonly holder: 'owned' | 'missing' | 'ownership-mismatch';
/**
* Lease broker observation (#1292): every gated runtime registers with the
* broker or dies ~4s in a broker not in the plan cannot be reported as
* drifted, which made "broker died an hour ago" and "broker fine"
* produce identical output. `unitInstalled` = unit file present in the
* active dir; `socketPresent` = live broker at the resolved socket path.
*/
readonly broker: {
readonly unitInstalled: boolean;
readonly socketPresent: boolean;
};
readonly agents: readonly FleetReconcileObservedAgent[];
readonly unmanagedSessions: readonly string[];
}
@@ -246,7 +261,17 @@ export async function executeFleetReconcile(
lifecycle: 'complete',
plan,
};
} catch {
} catch (error: unknown) {
// A named lifecycle precondition (broker-absent after enable+start,
// #1297 F3) must surface as itself — converting it to the generic
// recoverable result would hide the diagnosis and report a clean
// refusal where a loud one is the point.
if (
error instanceof FleetReconcileError &&
error.code === 'lifecycle-precondition-failed'
) {
throw error;
}
result = {
applied: false,
authoritativeRoster: 'unchanged',
@@ -315,6 +340,63 @@ function isObservational(command: FleetReconcileCommand): boolean {
return command === 'plan' || command === 'status' || command === 'verify' || command === 'doctor';
}
/**
* Observe the lease broker for the plan (#1292). Unit presence via systemctl
* is-system-running is NOT the signal a unit can be enabled-but-dead. The
* authoritative signal is the socket the gated runtimes connect to, matching
* broker-supervisor.ts's `checkBrokerSupervisorHealth` (healthy ===
* socketPresent). Injectable so tests drive every branch without a broker.
*/
function resolveBrokerSocketPath(env: NodeJS.ProcessEnv): string {
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
const runtimeDir = env['XDG_RUNTIME_DIR'] ?? `/run/user/${uid}`;
return env['MOSAIC_LEASE_BROKER_SOCKET'] ?? join(runtimeDir, 'mosaic-lease', 'broker.sock');
}
/**
* Probe the broker socket. Seams take precedence, but with no seam injected
* the REAL stat().isSocket() runs (#1297 review F3): production passes no
* seams, and defaulting to false made plan/status/doctor report a healthy
* broker as absent a dead broker was indistinguishable from noise.
*/
async function brokerSocketPresent(
deps: FleetReconcileDeps,
env: NodeJS.ProcessEnv,
): Promise<boolean> {
const socketPath = resolveBrokerSocketPath(env);
const check = deps.checkBrokerSocket;
if (check) return check(socketPath);
try {
return (await stat(socketPath)).isSocket();
} catch {
return false;
}
}
async function observeBroker(deps: FleetReconcileDeps): Promise<FleetReconcilePlan['broker']> {
const homeDirectory = deps.homeDirectory ?? homedir();
const env = (deps.brokerSocketEnv ?? process.env) as NodeJS.ProcessEnv;
const configHome = env['XDG_CONFIG_HOME'] ?? join(homeDirectory, '.config');
const unitPath = join(configHome, 'systemd', 'user', 'mosaic-lease-broker.service');
const statPath = deps.statPath;
let unitInstalled = false;
let socketPresent = false;
try {
// Same principle as the socket probe: no seam → look at the real
// filesystem. A unit file placed by installFleet (or a by-path residue
// symlink resolving to it) satisfies stat().isFile().
unitInstalled = statPath ? await statPath(unitPath) : (await stat(unitPath)).isFile();
} catch {
unitInstalled = false;
}
try {
socketPresent = await brokerSocketPresent(deps, env);
} catch {
socketPresent = false;
}
return { unitInstalled, socketPresent };
}
async function observeFleet(
roster: FleetRosterV2,
deps: FleetReconcileDeps,
@@ -325,10 +407,12 @@ async function observeFleet(
'-F',
'#{session_name}',
]);
const broker = await observeBroker(deps);
if (sessionsResult.exitCode !== 0) {
return {
generation: roster.generation,
holder: 'missing',
broker,
agents: await observeAgents(roster, deps, new Set<string>()),
unmanagedSessions: [],
};
@@ -351,6 +435,7 @@ async function observeFleet(
return {
generation: roster.generation,
holder,
broker,
agents: await observeAgents(roster, deps, sessions),
unmanagedSessions: Object.freeze(unmanagedSessions.sort()),
};
@@ -507,6 +592,17 @@ async function executeExplicitLifecycle(
plan: FleetReconcilePlan,
agents: readonly FleetRosterV2Agent[],
): Promise<FleetReconcileResult> {
const lifecycleApplyFailed = (): FleetReconcileResult => ({
applied: false,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
lifecycle: 'incomplete',
plan,
recovery: {
code: 'lifecycle-apply-failed',
action: 'rerun-after-inspecting-owned-resources',
},
});
if (request.command === 'start') {
for (const agent of agents) {
if (!agent.lifecycle.enabled) {
@@ -517,6 +613,40 @@ async function executeExplicitLifecycle(
}
}
}
// Broker FIRST (#1292): a gated runtime started without a running lease
// broker dies ~4 seconds in at registration — enable the unit (install
// places it) and start it before any holder/agent lifecycle effect.
try {
if (request.command === 'start') {
await runChecked(request.deps, 'systemctl', [
'--user',
'enable',
'mosaic-lease-broker.service',
]);
await runChecked(request.deps, 'systemctl', [
'--user',
'start',
'mosaic-lease-broker.service',
]);
}
} catch {
return lifecycleApplyFailed();
}
if (request.command === 'start') {
// Socket re-check after start, as a NAMED precondition (#1297 review
// F3) — the same protection the v1 path in commands/fleet.ts has had all
// along: the unit reporting active is not the signal; the socket is.
// Deliberately outside the try/catch above: a swallowed FleetReconcileError
// here read as a generic recoverable failure, hiding the named refusal.
// Runs BEFORE any holder/agent unit is touched so nothing doomed starts.
const env = (request.deps.brokerSocketEnv ?? process.env) as NodeJS.ProcessEnv;
if (!(await brokerSocketPresent(request.deps, env))) {
throw new FleetReconcileError(
'lifecycle-precondition-failed',
'broker-absent: lease broker socket did not appear after enable+start (#1292; #1297 F3). Remedy: mosaic fleet install.',
);
}
}
try {
if (request.command === 'start' && plan.holder === 'missing') {
await runChecked(request.deps, 'systemctl', [
@@ -533,17 +663,7 @@ async function executeExplicitLifecycle(
]);
}
} catch {
return {
applied: false,
authoritativeRoster: 'unchanged',
projections: 'not-applied',
lifecycle: 'incomplete',
plan,
recovery: {
code: 'lifecycle-apply-failed',
action: 'rerun-after-inspecting-owned-resources',
},
};
return lifecycleApplyFailed();
}
return {
applied: true,
@@ -563,6 +683,22 @@ async function applyDesiredLifecycle(
(agent: FleetRosterV2Agent): boolean =>
agent.lifecycle.enabled && agent.lifecycle.desiredState === 'running',
);
// Broker before any running agent, same ordering and reason as the
// command-driven path above (#1292).
if (needsRunningAgent) {
await runChecked(deps, 'systemctl', ['--user', 'enable', 'mosaic-lease-broker.service']);
await runChecked(deps, 'systemctl', ['--user', 'start', 'mosaic-lease-broker.service']);
// Same socket re-check as the explicit start path (#1297 F3): apply with
// running desired agents starts gated runtimes too, and a broker that
// starts but never binds dooms them the same way.
const env = (deps.brokerSocketEnv ?? process.env) as NodeJS.ProcessEnv;
if (!(await brokerSocketPresent(deps, env))) {
throw new FleetReconcileError(
'lifecycle-precondition-failed',
'broker-absent: lease broker socket did not appear after enable+start (#1292; #1297 F3). Remedy: mosaic fleet install.',
);
}
}
if (needsRunningAgent && plan.holder === 'missing') {
await runChecked(deps, 'systemctl', ['--user', 'start', 'mosaic-tmux-holder.service']);
}
+65 -1
View File
@@ -7,6 +7,7 @@ import { canonicalizeRoleClass } from '../commands/fleet-personas.js';
interface RawFleetRoster {
version?: unknown;
transport?: unknown;
generation?: unknown;
tmux?: {
socket_name?: unknown;
socketName?: unknown;
@@ -41,6 +42,10 @@ interface RawFleetRoster {
resetBetweenTasks?: unknown;
kickstart_template?: unknown;
kickstartTemplate?: unknown;
model?: unknown;
reasoning?: unknown;
lifecycle?: { enabled?: unknown; desired_state?: unknown };
launch?: { yolo?: unknown };
}>;
connector?: {
kind?: unknown;
@@ -216,7 +221,17 @@ function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster {
'runtimes',
'agents',
'connector',
// stack#1380 verification unblock: the fleet's own roster-v2 mutation
// tooling writes a `generation` fence on the same v1 body. Tolerated here
// as an opaque non-negative integer; comms semantics are unchanged.
'generation',
]);
if (
raw.generation !== undefined &&
(typeof raw.generation !== 'number' || !Number.isInteger(raw.generation) || raw.generation < 0)
) {
throw new Error('Fleet roster generation must be a non-negative integer.');
}
if (raw.tmux !== undefined) {
assertObject(raw.tmux, 'Fleet roster tmux');
assertKnownKeys(raw.tmux, 'Fleet roster tmux', [
@@ -231,6 +246,8 @@ function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster {
assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
'working_directory',
'workingDirectory',
// stack#1380 verification unblock: roster-v2 default runtime hint.
'runtime',
]);
}
if (raw.runtimes !== undefined) {
@@ -243,7 +260,9 @@ function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster {
]);
}
}
if (raw.version !== 1) throw new Error('Fleet roster version must be 1.');
if (raw.version !== 1 && raw.version !== 2) {
throw new Error('Fleet roster version must be 1 or 2.');
}
if (raw.transport !== 'tmux') throw new Error('Fleet roster transport must be "tmux".');
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
throw new Error('Fleet roster must define at least one agent.');
@@ -318,7 +337,52 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
'resetBetweenTasks',
'kickstart_template',
'kickstartTemplate',
// stack#1380 verification unblock: roster-v2 envelope fields written by
// the fleet's own mutation tooling. Validated, then opaque to comms.
'model',
'reasoning',
'lifecycle',
'launch',
]);
if (raw.model !== undefined && typeof raw.model !== 'string') {
throw new Error('Fleet roster agent model must be a string.');
}
if (raw.reasoning !== undefined && typeof raw.reasoning !== 'string') {
throw new Error('Fleet roster agent reasoning must be a string.');
}
const lifecycle = raw.lifecycle as { enabled?: unknown; desired_state?: unknown } | undefined;
if (lifecycle !== undefined) {
if (typeof lifecycle !== 'object' || lifecycle === null) {
throw new Error('Fleet roster agent lifecycle must be an object.');
}
const lifecycleKeys = Object.keys(lifecycle);
if (!lifecycleKeys.every((key) => key === 'enabled' || key === 'desired_state')) {
throw new Error('Fleet roster agent lifecycle has unknown field(s).');
}
if (lifecycle.enabled !== undefined && typeof lifecycle.enabled !== 'boolean') {
throw new Error('Fleet roster agent lifecycle.enabled must be a boolean.');
}
if (
lifecycle.desired_state !== undefined &&
(typeof lifecycle.desired_state !== 'string' ||
!['running', 'stopped'].includes(lifecycle.desired_state))
) {
throw new Error('Fleet roster agent lifecycle.desired_state must be running|stopped.');
}
}
const launch = raw.launch as { yolo?: unknown } | undefined;
if (launch !== undefined) {
if (typeof launch !== 'object' || launch === null) {
throw new Error('Fleet roster agent launch must be an object.');
}
const launchKeys = Object.keys(launch);
if (!launchKeys.every((key) => key === 'yolo')) {
throw new Error('Fleet roster agent launch has unknown field(s).');
}
if (launch.yolo !== undefined && typeof launch.yolo !== 'boolean') {
throw new Error('Fleet roster agent launch.yolo must be a boolean.');
}
}
const name = stringValue(raw.name, '', 'Fleet roster agent name');
const runtime = stringValue(
raw.runtime,
+79 -9
View File
@@ -10,12 +10,14 @@ import {
type PathLike,
} from 'node:fs';
import type * as NodeFs from 'node:fs';
import type { Stats } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { join, resolve } from 'node:path';
interface FilesystemRaceState {
afterLstat?: (path: string) => void;
afterOpen?: (path: string) => void;
afterStat?: (path: string, stats: Stats) => Stats;
}
const filesystemRaceState = vi.hoisted<FilesystemRaceState>(() => ({}));
@@ -29,6 +31,10 @@ vi.mock('node:fs', async (importOriginal) => {
filesystemRaceState.afterLstat?.(String(path));
return result;
},
statSync: (path: PathLike) => {
const result = actual.statSync(path);
return filesystemRaceState.afterStat?.(String(path), result) ?? result;
},
openSync: (path: PathLike, flags: string | number, mode?: number) => {
const fd = actual.openSync(path, flags, mode);
filesystemRaceState.afterOpen?.(String(path));
@@ -46,11 +52,13 @@ describe('secure file reads', () => {
root = mkdtempSync(join(tmpdir(), 'mosaic-secure-file-'));
filesystemRaceState.afterLstat = undefined;
filesystemRaceState.afterOpen = undefined;
filesystemRaceState.afterStat = undefined;
});
afterEach(() => {
filesystemRaceState.afterLstat = undefined;
filesystemRaceState.afterOpen = undefined;
filesystemRaceState.afterStat = undefined;
rmSync(root, { recursive: true, force: true });
});
@@ -60,27 +68,89 @@ describe('secure file reads', () => {
);
});
it('rejects a symlink in a file ancestor', () => {
// stack#1380: the guard resolves symlinks and validates the resolved target
// instead of refusing any symlink component.
it('permits a symlink ancestor whose resolved target is inside the root', () => {
const external = join(root, 'external');
mkdirSync(external);
writeFileSync(join(external, 'file'), 'external\n');
symlinkSync(external, join(root, 'linked'));
expect(() => readRegularFileSecure(join(root, 'linked', 'file'), { root })).toThrow(
'path ancestor is a symbolic link',
);
const snapshot = readRegularFileSecure(join(root, 'linked', 'file'), { root });
expect(snapshot.content.toString('utf8')).toBe('external\n');
});
it('rejects a symlink target', () => {
const external = join(root, 'external');
it('permits a symlinked file whose resolved target is inside the root', () => {
const external = join(root, 'external-file');
writeFileSync(external, 'external\n');
symlinkSync(external, join(root, 'linked-file'));
expect(() => readRegularFileSecure(join(root, 'linked-file'), { root })).toThrow(
'file is a symbolic link',
const snapshot = readRegularFileSecure(join(root, 'linked-file'), { root });
expect(snapshot.content.toString('utf8')).toBe('external\n');
});
it('permits a symlink resolving into an additional sanctioned root (split-home roster shape)', () => {
const brain = `${root}-brain`;
mkdirSync(join(brain, 'fleet'), { recursive: true });
writeFileSync(join(brain, 'fleet', 'roster.yaml'), 'roster\n', { mode: 0o600 });
mkdirSync(join(root, 'fleet'));
symlinkSync(join(brain, 'fleet', 'roster.yaml'), join(root, 'fleet', 'roster.yaml'));
const snapshot = readRegularFileSecure(join(root, 'fleet', 'roster.yaml'), {
root,
symlinkTargetRoots: [brain],
});
expect(snapshot.content.toString('utf8')).toBe('roster\n');
});
it('refuses a symlink whose resolved target escapes every sanctioned root', () => {
const outside = mkdtempSync(join(tmpdir(), 'mosaic-secure-outside-'));
try {
mkdirSync(join(root, 'fleet'), { recursive: true });
writeFileSync(join(outside, 'roster.yaml'), 'escaped\n', { mode: 0o600 });
symlinkSync(join(outside, 'roster.yaml'), join(root, 'fleet', 'roster.yaml'));
expect(() => readRegularFileSecure(join(root, 'fleet', 'roster.yaml'), { root })).toThrow(
/symlink target escapes managed roots/,
);
} finally {
rmSync(outside, { recursive: true, force: true });
}
});
it('refuses a group-writable symlink target', () => {
const loose = join(root, 'loose');
mkdirSync(loose);
chmodSync(loose, 0o770); // group-writable bit survives umask via explicit chmod
writeFileSync(join(loose, 'file'), 'loose\n');
symlinkSync(loose, join(root, 'linked-loose'));
expect(() => readRegularFileSecure(join(root, 'linked-loose', 'file'), { root })).toThrow(
/group- or world-writable/,
);
});
it('refuses a symlink target owned by another user', () => {
const external = join(root, 'foreign');
mkdirSync(external);
writeFileSync(join(external, 'file'), 'foreign\n');
symlinkSync(external, join(root, 'linked-foreign'));
filesystemRaceState.afterStat = (path, stats): Stats => {
if (resolve(path) === resolve(external)) {
return { ...stats, uid: stats.uid + 4242 } as Stats;
}
return stats;
};
try {
expect(() => readRegularFileSecure(join(root, 'linked-foreign', 'file'), { root })).toThrow(
/not owned by the current user/,
);
} finally {
filesystemRaceState.afterStat = undefined;
}
});
it('keeps ancestor traversal bound when an opened directory is substituted', () => {
const tools = join(root, 'tools');
const displacedTools = join(root, 'tools.displaced');
+100 -26
View File
@@ -7,14 +7,25 @@ import {
mkdirSync,
openSync,
readFileSync,
readlinkSync,
statSync,
} from 'node:fs';
import { platform } from 'node:os';
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export interface SecureFileReadOptions {
root: string;
maxBytes?: number;
executable?: boolean;
/**
* Additional roots a symlink component may resolve into (stack#1380).
* Default: only the managed root itself. Every symlink hop is validated
* containment under the root or one of these roots, current-user ownership,
* no group/world-writable mode and refusal stays the default for anything
* else. Callers that operate the split-home layout pass the brain home so
* the framework-created roster symlink resolves.
*/
symlinkTargetRoots?: string[];
}
export interface SecureFileSnapshot {
@@ -88,7 +99,57 @@ function openDirectoryChain(absoluteDirectory: string): { fd: number; descriptor
}
}
function openFileBeneathRoot(root: string, target: string): { fd: number; descriptors: number[] } {
function containedUnder(root: string, target: string): boolean {
const rel = relative(resolve(root), resolve(target));
return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) && rel !== '';
}
const MAX_SYMLINK_HOPS = 40;
/**
* Resolve every symlink on `lexical` component-wise, validating each hop
* (stack#1380 resolve-then-validate): the hop target must stay under one of
* the sanctioned roots, must be owned by the current user (or root), and must
* not be group- or world-writable. Returns a symlink-free absolute path.
*/
function resolveRealPath(lexical: string, sanctionedRoots: string[]): string {
const hopTargets: string[] = [];
let current: string = sep;
for (const piece of resolve(lexical).split(sep).filter(Boolean)) {
current = resolve(current, piece);
for (let hops = 0; lstatSync(current).isSymbolicLink(); ) {
if (++hops > MAX_SYMLINK_HOPS) {
throw new Error(`symlink chain exceeds ${MAX_SYMLINK_HOPS} hops: ${lexical}`);
}
const linkTarget = readlinkSync(current);
const absolute = resolve(dirname(current), linkTarget);
if (!sanctionedRoots.some((root) => containedUnder(root, absolute))) {
throw new Error(
`symlink target escapes managed roots [${sanctionedRoots.join(', ')}]: ${absolute}`,
);
}
hopTargets.push(absolute);
current = absolute;
}
}
const uid = typeof process.getuid === 'function' ? process.getuid() : 0;
for (const hop of hopTargets) {
const stat = statSync(hop);
if (stat.uid !== uid && stat.uid !== 0) {
throw new Error(`symlink target is not owned by the current user: ${hop}`);
}
if (stat.mode & 0o022) {
throw new Error(`symlink target is group- or world-writable: ${hop}`);
}
}
return current;
}
function openFileBeneathRoot(
root: string,
target: string,
symlinkTargetRoots: string[] = [],
): { fd: number; descriptors: number[] } {
const canonicalRoot = resolve(root);
const canonicalTarget = resolve(target);
assertCanonicalContainment(canonicalRoot, canonicalTarget);
@@ -96,39 +157,52 @@ function openFileBeneathRoot(root: string, target: string): { fd: number; descri
const fileName = components.pop();
if (fileName === undefined) throw new Error('managed file path names the managed root');
const rootChain = openDirectoryChain(canonicalRoot);
// stack#1380: resolve-then-validate. The lexical path must name the managed
// root (above); symlink components are then resolved hop-by-hop under the
// sanctioned roots (validated per hop), and the descriptor traversal walks
// the symlink-free real path — keeping the O_NOFOLLOW chain as the race
// guard for anything substituted after resolution.
let realRoot: string;
try {
realRoot = resolveRealPath(canonicalRoot, [canonicalRoot]);
} catch (error) {
throw secureFilesystemError(
'secure descriptor traversal failed: symbolic link, unavailable, or not a directory',
error,
);
}
const sanctioned = [realRoot, ...symlinkTargetRoots.map((extra) => resolve(extra))];
let realTarget: string;
try {
realTarget = resolveRealPath(canonicalTarget, sanctioned);
} catch (error) {
if (error instanceof Error && !('code' in error)) throw error;
throw secureFilesystemError(
'secure descriptor traversal failed: symbolic link, unavailable, or not a directory',
error,
);
}
if (!sanctioned.some((sr) => containedUnder(sr, realTarget) || resolve(sr) === realTarget)) {
throw new Error(
`resolved path escapes managed roots [${sanctioned.join(', ')}]: ${realTarget}`,
);
}
const chain = openDirectoryChain(dirname(realTarget));
try {
let parentFd = rootChain.fd;
for (const component of components) {
try {
parentFd = openSync(
procDescriptorPath(parentFd, component),
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW,
);
} catch (error) {
throw secureFilesystemError(
'path ancestor is a symbolic link, unavailable, or not a directory',
error,
);
}
rootChain.descriptors.push(parentFd);
if (!fstatSync(parentFd).isDirectory()) {
throw new Error('path ancestor is a symbolic link or not a directory');
}
}
let fd: number;
try {
fd = openSync(
procDescriptorPath(parentFd, fileName),
procDescriptorPath(chain.fd, basename(realTarget)),
constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW,
);
} catch (error) {
throw secureFilesystemError('file is a symbolic link or unavailable', error);
}
rootChain.descriptors.push(fd);
return { fd, descriptors: rootChain.descriptors };
chain.descriptors.push(fd);
return { fd, descriptors: chain.descriptors };
} catch (error) {
closeDescriptors(rootChain.descriptors);
closeDescriptors(chain.descriptors);
if (error instanceof Error) throw error;
throw new Error('secure managed file open failed');
}
@@ -203,7 +277,7 @@ export function readRegularFileSecure(
path: string,
options: SecureFileReadOptions,
): SecureFileSnapshot {
const openedFile = openFileBeneathRoot(options.root, path);
const openedFile = openFileBeneathRoot(options.root, path, options.symlinkTargetRoots ?? []);
try {
const opened = fstatSync(openedFile.fd);
if (!opened.isFile()) throw new Error('managed file is not a regular file');
@@ -168,7 +168,9 @@ describe('repairFleetCommsTools', () => {
const result = repairFleetCommsTools(framework, home);
expect(result).toMatchObject({ ok: false, changed: false });
expect(result.reason).toContain('symbolic link');
// stack#1380: resolve-then-validate — an escaping symlink is still
// refused, with the new escape diagnostic.
expect(result.reason).toContain('symlink target escapes managed roots');
expect(readFileSync(target, 'utf8')).toBe('do not touch\n');
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).isSymbolicLink()).toBe(true);
});
@@ -222,7 +224,10 @@ describe('repairFleetCommsTools', () => {
const result = repairFleetCommsTools(framework, targetHome);
expect(result, testCase.name).toMatchObject({ ok: false, changed: false });
expect(result.reason, testCase.name).toContain('symbolic link');
// stack#1380: escaping ancestor symlinks stay refused. The home case is
// caught by the managed-root guard ('is a symbolic link'); deeper
// components by resolve-then-validate ('symlink target escapes').
expect(result.reason, testCase.name).toMatch(/symbolic link|symlink target escapes/);
expect(readdirSync(external), testCase.name).toEqual([]);
}
});
+2
View File
@@ -61,6 +61,8 @@ export const STAGES = [
'bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test',
'bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh',
'bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh',
'bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh',
'bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh',
'bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh',
'bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh',
],