Compare commits

..
Author SHA1 Message Date
fred 5aac155ab6 fix(docker): copy scripts/ into appservice builder so the root prepare hook can run (#1446)
ci/woodpecker/pr/ci Pipeline failed
2026-08-26 22:39:39 -05:00
fred a3b0770205 docs: roll-up projection contract (S2 contract 8) (#1442)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-27 02:10:12 +00:00
fred 2a30c68b84 docs: identity account-lifecycle contract (S2 contract 4) (#1433)
ci/woodpecker/push/publish Pipeline is running
2026-08-27 00:13:49 +00:00
fred f8f8f97be7 feat(web): port settings and admin surfaces into the SPA (Phase P4-2) (#1434)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-27 00:03:38 +00: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
78 changed files with 14691 additions and 1283 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."
}
+40 -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:
@@ -113,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.
*/
+8 -4
View File
@@ -13,8 +13,9 @@ import {
TasksRouteErrorBoundary,
} from '@/spa/pages/resource-route-error-boundaries';
import { TasksPage } from '@/spa/pages/tasks';
import { AuthGuard, GuestGuard } from '@/spa/guards';
import { Placeholder } from '@/spa/placeholder';
import { SettingsPage } from '@/spa/pages/settings';
import { AdminPage } from '@/spa/pages/admin';
import { AdminGuard, AuthGuard, GuestGuard } from '@/spa/guards';
function GuestLayout(): ReactElement {
return (
@@ -56,8 +57,11 @@ export const routes: RouteObject[] = [
errorElement: <ProjectDetailRouteErrorBoundary />,
},
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
{ path: '/settings', element: <Placeholder title="Settings" /> },
{ path: '/admin', element: <Placeholder title="Admin" /> },
{ path: '/settings', element: <SettingsPage /> },
{
element: <AdminGuard />,
children: [{ path: '/admin', element: <AdminPage /> }],
},
],
},
];
+20
View File
@@ -21,3 +21,23 @@ export function AuthGuard(): ReactElement {
return session ? <Outlet /> : <Navigate to="/login" replace />;
}
export function AdminGuard(): ReactElement {
const { data: session, isPending } = useSession();
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-sm text-text-muted">Loading...</div>
</div>
);
}
if (!session) {
return <Navigate to="/login" replace />;
}
const user = session.user as typeof session.user & { role?: string };
return user.role === 'admin' ? <Outlet /> : <Navigate to="/" replace />;
}
+206
View File
@@ -0,0 +1,206 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
const { apiMock, useSessionMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
useSessionMock: vi.fn(),
}));
vi.mock('@/lib/api', () => ({
api: apiMock,
}));
vi.mock('@/lib/auth-client', () => ({
useSession: useSessionMock,
authClient: {},
}));
import { AdminPage } from './admin';
import { AdminGuard } from '@/spa/guards';
const userFixtures = {
users: [
{
id: 'u-admin',
name: 'Ada Admin',
email: '[email protected]',
role: 'admin',
banned: false,
banReason: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
{
id: 'u-member',
name: 'Mel Member',
email: '[email protected]',
role: 'member',
banned: true,
banReason: 'spam',
createdAt: '2026-08-02T00:00:00.000Z',
updatedAt: '2026-08-02T00:00:00.000Z',
},
],
total: 2,
};
let root: Root | null = null;
let container: HTMLDivElement;
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
apiMock.mockReset();
useSessionMock.mockReset();
});
async function renderAdminRoute(): Promise<void> {
const routes: RouteObject[] = [
{
element: <AdminGuard />,
children: [{ path: '/admin', element: <AdminPage /> }],
},
{ path: '/', element: <div>home page</div> },
{ path: '/login', element: <div>login page</div> },
];
const router = createMemoryRouter(routes, { initialEntries: ['/admin'] });
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<RouterProvider router={router} />);
});
}
function sessionWithRole(role: string | undefined): { data: unknown; isPending: boolean } {
return {
data: { user: { id: 'u-1', name: 'Test', email: '[email protected]', role } },
isPending: false,
};
}
describe('AdminGuard', () => {
it('redirects unauthenticated visitors to /login', async () => {
useSessionMock.mockReturnValue({ data: null, isPending: false });
await renderAdminRoute();
expect(container.textContent).toContain('login page');
expect(apiMock).not.toHaveBeenCalled();
});
it('redirects non-admin users to /', async () => {
useSessionMock.mockReturnValue(sessionWithRole('member'));
await renderAdminRoute();
expect(container.textContent).toContain('home page');
expect(apiMock).not.toHaveBeenCalled();
});
it('renders the admin page for admin users', async () => {
useSessionMock.mockReturnValue(sessionWithRole('admin'));
apiMock.mockResolvedValueOnce(userFixtures);
await renderAdminRoute();
expect(container.textContent).toContain('Admin Panel');
});
});
describe('AdminPage users tab', () => {
it('lists users with role and ban status after load', async () => {
useSessionMock.mockReturnValue(sessionWithRole('admin'));
apiMock.mockResolvedValueOnce(userFixtures);
await renderAdminRoute();
expect(apiMock).toHaveBeenCalledWith('/api/admin/users');
expect(container.textContent).toContain('Ada Admin');
expect(container.textContent).toContain('Mel Member');
expect(container.textContent).toContain('Banned');
expect(container.textContent).toContain('2 user(s)');
});
it('shows the load error with a retry control', async () => {
useSessionMock.mockReturnValue(sessionWithRole('admin'));
apiMock.mockRejectedValueOnce(new Error('gateway unavailable'));
await renderAdminRoute();
expect(container.textContent).toContain('gateway unavailable');
apiMock.mockResolvedValueOnce(userFixtures);
const retry = [...container.querySelectorAll('button')].find((b) =>
b.textContent?.includes('Retry'),
);
expect(retry).toBeTruthy();
await act(async () => {
retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(container.textContent).toContain('Ada Admin');
});
it('posts to the ban endpoint and reloads on Ban', async () => {
useSessionMock.mockReturnValue(sessionWithRole('admin'));
apiMock.mockResolvedValue(userFixtures);
await renderAdminRoute();
const banButton = [...container.querySelectorAll('button')].find(
(b) => b.textContent === 'Ban',
);
expect(banButton).toBeTruthy();
await act(async () => {
banButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(apiMock).toHaveBeenCalledWith('/api/admin/users/u-admin/ban', { method: 'POST' });
});
});
describe('AdminPage health tab', () => {
it('loads health status when the tab is opened', async () => {
useSessionMock.mockReturnValue(sessionWithRole('admin'));
apiMock.mockResolvedValueOnce(userFixtures).mockResolvedValueOnce({
status: 'ok',
database: { status: 'ok', latencyMs: 3 },
cache: { status: 'ok', latencyMs: 1 },
agentPool: { activeSessions: 2 },
providers: [{ id: 'ollama', name: 'Ollama', available: true, modelCount: 4 }],
checkedAt: '2026-08-26T00:00:00.000Z',
});
await renderAdminRoute();
const healthTab = [...container.querySelectorAll('button')].find((b) =>
b.textContent?.includes('System Health'),
);
await act(async () => {
healthTab?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(apiMock).toHaveBeenCalledWith('/api/admin/health');
expect(container.textContent).toContain('Database (PostgreSQL)');
expect(container.textContent).toContain('Active sessions: 2');
expect(container.textContent).toContain('4 models');
});
});
+522
View File
@@ -0,0 +1,522 @@
import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
// ── Types ──────────────────────────────────────────────────────────────────────
interface UserDto {
id: string;
name: string;
email: string;
role: string;
banned: boolean;
banReason: string | null;
createdAt: string;
updatedAt: string;
}
interface UserListDto {
users: UserDto[];
total: number;
}
interface ServiceStatusDto {
status: 'ok' | 'error';
latencyMs?: number;
error?: string;
}
interface ProviderStatusDto {
id: string;
name: string;
available: boolean;
modelCount: number;
}
interface HealthStatusDto {
status: 'ok' | 'degraded' | 'error';
database: ServiceStatusDto;
cache: ServiceStatusDto;
agentPool: { activeSessions: number };
providers: ProviderStatusDto[];
checkedAt: string;
}
// ── Admin Page ─────────────────────────────────────────────────────────────────
// Route-level access control lives in AdminGuard (spa/guards.tsx); this page
// assumes an authenticated admin session.
export function AdminPage(): React.ReactElement {
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
return (
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-text-primary">Admin Panel</h1>
</div>
<div className="flex gap-1 border-b border-surface-border">
{(['users', 'health'] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={cn(
'px-4 py-2 text-sm font-medium capitalize transition-colors',
activeTab === tab
? 'border-b-2 border-blue-500 text-blue-400'
: 'text-text-secondary hover:text-text-primary',
)}
>
{tab === 'users' ? 'User Management' : 'System Health'}
</button>
))}
</div>
{activeTab === 'users' ? <UsersTab /> : <HealthTab />}
</div>
);
}
// ── Users Tab ──────────────────────────────────────────────────────────────────
function UsersTab(): React.ReactElement {
const [users, setUsers] = useState<UserDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const loadUsers = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api<UserListDto>('/api/admin/users');
setUsers(data.users);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load users');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadUsers();
}, [loadUsers]);
async function handleRoleToggle(user: UserDto): Promise<void> {
const newRole = user.role === 'admin' ? 'member' : 'admin';
try {
await api(`/api/admin/users/${user.id}/role`, {
method: 'PATCH',
body: { role: newRole },
});
await loadUsers();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to update role');
}
}
async function handleBanToggle(user: UserDto): Promise<void> {
const endpoint = user.banned ? 'unban' : 'ban';
try {
await api(`/api/admin/users/${user.id}/${endpoint}`, { method: 'POST' });
await loadUsers();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to update ban status');
}
}
async function handleDelete(user: UserDto): Promise<void> {
if (!confirm(`Delete user ${user.email}? This cannot be undone.`)) return;
try {
await api(`/api/admin/users/${user.id}`, { method: 'DELETE' });
await loadUsers();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to delete user');
}
}
if (loading) {
return <p className="text-sm text-text-muted">Loading users...</p>;
}
if (error) {
return (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
<p className="text-sm text-red-400">{error}</p>
<button
type="button"
onClick={() => void loadUsers()}
className="mt-2 text-xs text-red-300 underline hover:no-underline"
>
Retry
</button>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-text-muted">{users.length} user(s)</p>
<button
type="button"
onClick={() => setShowCreate(true)}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-700"
>
+ New User
</button>
</div>
{showCreate && (
<CreateUserForm
onCancel={() => setShowCreate(false)}
onCreated={() => {
setShowCreate(false);
void loadUsers();
}}
/>
)}
{users.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
<p className="text-sm text-text-muted">No users found</p>
</div>
) : (
<div className="overflow-hidden rounded-lg border border-surface-border">
<table className="w-full">
<thead>
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
<th className="px-4 py-2 font-medium">Name / Email</th>
<th className="px-4 py-2 font-medium">Role</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Status</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Created</th>
<th className="px-4 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-b border-surface-border last:border-b-0">
<td className="px-4 py-3">
<div className="text-sm font-medium text-text-primary">{user.name}</div>
<div className="text-xs text-text-muted">{user.email}</div>
</td>
<td className="px-4 py-3">
<span
className={cn(
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
user.role === 'admin'
? 'bg-purple-500/20 text-purple-400'
: 'bg-surface-elevated text-text-secondary',
)}
>
{user.role}
</span>
</td>
<td className="hidden px-4 py-3 md:table-cell">
{user.banned ? (
<span className="inline-flex rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium text-red-400">
Banned
</span>
) : (
<span className="inline-flex rounded-full bg-green-500/20 px-2 py-0.5 text-xs font-medium text-green-400">
Active
</span>
)}
</td>
<td className="hidden px-4 py-3 text-xs text-text-muted md:table-cell">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleRoleToggle(user)}
className="text-xs text-blue-400 hover:text-blue-300"
title={user.role === 'admin' ? 'Demote to member' : 'Promote to admin'}
>
{user.role === 'admin' ? 'Demote' : 'Promote'}
</button>
<button
type="button"
onClick={() => void handleBanToggle(user)}
className={cn(
'text-xs',
user.banned
? 'text-green-400 hover:text-green-300'
: 'text-yellow-400 hover:text-yellow-300',
)}
>
{user.banned ? 'Unban' : 'Ban'}
</button>
<button
type="button"
onClick={() => void handleDelete(user)}
className="text-xs text-red-400 hover:text-red-300"
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ── Create User Form ──────────────────────────────────────────────────────────
interface CreateUserFormProps {
onCancel: () => void;
onCreated: () => void;
}
function CreateUserForm({ onCancel, onCreated }: CreateUserFormProps): React.ReactElement {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState('member');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
await api('/api/admin/users', {
method: 'POST',
body: { name, email, password, role },
});
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create user');
} finally {
setSubmitting(false);
}
}
return (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<h3 className="mb-3 text-sm font-medium text-text-primary">Create New User</h3>
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-3">
{error && <p className="text-xs text-red-400">{error}</p>}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs text-text-muted">Name</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Email</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Password</label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Role</label>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="member">member</option>
<option value="admin">admin</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-md px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{submitting ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
);
}
// ── Health Tab ────────────────────────────────────────────────────────────────
function HealthTab(): React.ReactElement {
const [health, setHealth] = useState<HealthStatusDto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadHealth = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api<HealthStatusDto>('/api/admin/health');
setHealth(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load health');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadHealth();
}, [loadHealth]);
if (loading) {
return <p className="text-sm text-text-muted">Loading health status...</p>;
}
if (error) {
return (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
<p className="text-sm text-red-400">{error}</p>
<button
type="button"
onClick={() => void loadHealth()}
className="mt-2 text-xs text-red-300 underline hover:no-underline"
>
Retry
</button>
</div>
);
}
if (!health) return <></>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<StatusBadge status={health.status} />
<span className="text-sm text-text-muted">
Last checked: {new Date(health.checkedAt).toLocaleTimeString()}
</span>
</div>
<button
type="button"
onClick={() => void loadHealth()}
className="text-xs text-blue-400 hover:text-blue-300"
>
Refresh
</button>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Database */}
<HealthCard title="Database (PostgreSQL)" status={health.database.status}>
{health.database.latencyMs !== undefined && (
<p className="text-xs text-text-muted">Latency: {health.database.latencyMs}ms</p>
)}
{health.database.error && <p className="text-xs text-red-400">{health.database.error}</p>}
</HealthCard>
{/* Cache */}
<HealthCard title="Cache (Valkey)" status={health.cache.status}>
{health.cache.latencyMs !== undefined && (
<p className="text-xs text-text-muted">Latency: {health.cache.latencyMs}ms</p>
)}
{health.cache.error && <p className="text-xs text-red-400">{health.cache.error}</p>}
</HealthCard>
{/* Agent Pool */}
<HealthCard title="Agent Pool" status="ok">
<p className="text-xs text-text-muted">
Active sessions: {health.agentPool.activeSessions}
</p>
</HealthCard>
{/* Providers */}
<HealthCard
title="LLM Providers"
status={health.providers.some((p) => p.available) ? 'ok' : 'error'}
>
{health.providers.length === 0 ? (
<p className="text-xs text-text-muted">No providers configured</p>
) : (
<ul className="space-y-1">
{health.providers.map((p) => (
<li key={p.id} className="flex items-center justify-between text-xs">
<span className="text-text-secondary">{p.name}</span>
<span
className={cn(
'rounded-full px-1.5 py-0.5',
p.available ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400',
)}
>
{p.available ? `${p.modelCount} models` : 'unavailable'}
</span>
</li>
))}
</ul>
)}
</HealthCard>
</div>
</div>
);
}
// ── Helper Components ─────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: 'ok' | 'degraded' | 'error' }): React.ReactElement {
const map = {
ok: 'bg-green-500/20 text-green-400',
degraded: 'bg-yellow-500/20 text-yellow-400',
error: 'bg-red-500/20 text-red-400',
};
return (
<span className={cn('rounded-full px-2 py-0.5 text-xs font-medium capitalize', map[status])}>
{status}
</span>
);
}
interface HealthCardProps {
title: string;
status: 'ok' | 'error';
children?: React.ReactNode;
}
function HealthCard({ title, status, children }: HealthCardProps): React.ReactElement {
return (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-medium text-text-primary">{title}</h3>
<span
className={cn('h-2 w-2 rounded-full', status === 'ok' ? 'bg-green-400' : 'bg-red-400')}
/>
</div>
{children}
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
const { apiMock, useSessionMock, updateUserMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
useSessionMock: vi.fn(),
updateUserMock: vi.fn(),
}));
vi.mock('@/lib/api', () => ({
api: apiMock,
}));
vi.mock('@/lib/auth-client', () => ({
useSession: useSessionMock,
authClient: { updateUser: updateUserMock },
}));
import { SettingsPage } from './settings';
let root: Root | null = null;
let container: HTMLDivElement;
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
afterAll(() => {
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
apiMock.mockReset();
useSessionMock.mockReset();
updateUserMock.mockReset();
});
async function renderSettingsPage(): Promise<void> {
const routes: RouteObject[] = [{ path: '/settings', element: <SettingsPage /> }];
const router = createMemoryRouter(routes, { initialEntries: ['/settings'] });
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<RouterProvider router={router} />);
});
}
function clickButtonByText(text: string): Promise<void> {
const button = [...container.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes(text),
);
if (!button) {
throw new Error(`Button containing "${text}" not found`);
}
return act(async () => {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
}
const session = {
user: { id: 'u-1', name: 'Test User', email: '[email protected]', image: null },
};
describe('SettingsPage profile tab', () => {
it('renders the profile form from the session and saves via authClient', async () => {
useSessionMock.mockReturnValue({ data: session, isPending: false });
updateUserMock.mockResolvedValue({});
await renderSettingsPage();
const nameInput = container.querySelector<HTMLInputElement>('#profile-name');
const emailInput = container.querySelector<HTMLInputElement>('#profile-email');
expect(nameInput?.value).toBe('Test User');
expect(emailInput?.value).toBe('[email protected]');
expect(emailInput?.disabled).toBe(true);
await clickButtonByText('Save changes');
expect(updateUserMock).toHaveBeenCalledWith({ name: 'Test User', image: null });
expect(container.textContent).toContain('Saved!');
});
it('surfaces an update failure without clearing the form', async () => {
useSessionMock.mockReturnValue({ data: session, isPending: false });
updateUserMock.mockResolvedValue({ error: { message: 'name rejected' } });
await renderSettingsPage();
await clickButtonByText('Save changes');
expect(container.textContent).toContain('name rejected');
expect(container.querySelector<HTMLInputElement>('#profile-name')?.value).toBe('Test User');
});
});
describe('SettingsPage appearance tab', () => {
it('loads preferences and posts each changed preference on save', async () => {
useSessionMock.mockReturnValue({ data: session, isPending: false });
apiMock.mockImplementation((path: string) =>
path.startsWith('/api/memory/preferences?')
? Promise.resolve([{ key: 'ui.theme', value: 'dark', category: 'appearance' }])
: Promise.resolve({}),
);
await renderSettingsPage();
await clickButtonByText('Appearance');
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences?category=appearance');
await clickButtonByText('Save changes');
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences', {
method: 'POST',
body: { key: 'ui.theme', value: 'dark', category: 'appearance', source: 'user' },
});
});
});
describe('SettingsPage providers tab', () => {
it('loads LLM and SSO providers and runs a connection test', async () => {
useSessionMock.mockReturnValue({ data: session, isPending: false });
apiMock.mockImplementation((path: string, opts?: { method?: string }) => {
if (path === '/api/providers' && opts === undefined) {
return Promise.resolve([
{
id: 'ollama',
name: 'Ollama',
available: true,
models: [
{
id: 'llama3.2',
provider: 'ollama',
name: 'Llama 3.2',
reasoning: false,
contextWindow: 128_000,
maxTokens: 4096,
inputTypes: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
],
},
]);
}
if (path === '/api/sso/providers') {
return Promise.resolve([]);
}
if (path === '/api/providers/test') {
return Promise.resolve({ providerId: 'ollama', reachable: true, latencyMs: 12 });
}
return Promise.resolve([]);
});
await renderSettingsPage();
await clickButtonByText('Providers');
expect(container.textContent).toContain('Ollama');
expect(container.textContent).toContain('1 model');
await clickButtonByText('Test');
expect(apiMock).toHaveBeenCalledWith('/api/providers/test', {
method: 'POST',
body: { providerId: 'ollama' },
});
expect(container.textContent).toContain('Reachable');
});
});
+826
View File
@@ -0,0 +1,826 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '@/lib/api';
import { authClient, useSession } from '@/lib/auth-client';
import type { SsoProviderDiscovery } from '@/lib/sso';
import { SsoProviderSection } from '@/components/settings/sso-provider-section';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ModelInfo {
id: string;
provider: string;
name: string;
reasoning: boolean;
contextWindow: number;
maxTokens: number;
inputTypes: ('text' | 'image')[];
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
}
interface ProviderInfo {
id: string;
name: string;
available: boolean;
models: ModelInfo[];
}
interface TestConnectionResult {
providerId: string;
reachable: boolean;
latencyMs?: number;
error?: string;
discoveredModels?: string[];
}
type TestState = 'idle' | 'testing' | 'success' | 'error';
interface ProviderTestStatus {
state: TestState;
result?: TestConnectionResult;
}
interface Preference {
key: string;
value: unknown;
category: string;
}
type Theme = 'light' | 'dark' | 'system';
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
type Tab = 'profile' | 'appearance' | 'notifications' | 'providers';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function prefValue<T>(prefs: Preference[], key: string, fallback: T): T {
const p = prefs.find((x) => x.key === key);
if (p === undefined) return fallback;
return p.value as T;
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function SettingsPage(): React.ReactElement {
const { data: session } = useSession();
const [activeTab, setActiveTab] = useState<Tab>('profile');
const tabs: { id: Tab; label: string }[] = [
{ id: 'profile', label: 'Profile' },
{ id: 'appearance', label: 'Appearance' },
{ id: 'notifications', label: 'Notifications' },
{ id: 'providers', label: 'Providers' },
];
return (
<div className="mx-auto max-w-3xl space-y-6">
<h1 className="text-2xl font-semibold">Settings</h1>
{/* Tab bar */}
<div className="flex gap-1 border-b border-surface-border">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'border-b-2 border-accent text-accent'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
{activeTab === 'profile' && <ProfileTab session={session} />}
{activeTab === 'appearance' && <AppearanceTab />}
{activeTab === 'notifications' && <NotificationsTab />}
{activeTab === 'providers' && <ProvidersTab />}
</div>
);
}
// ─── Profile Tab ──────────────────────────────────────────────────────────────
function ProfileTab({
session,
}: {
session: { user: { id: string; name: string; email: string; image?: string | null } } | null;
}): React.ReactElement {
const [name, setName] = useState(session?.user.name ?? '');
const [image, setImage] = useState(session?.user.image ?? '');
const [saveState, setSaveState] = useState<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
// Sync from session when it loads
useEffect(() => {
if (session?.user) {
setName(session.user.name ?? '');
setImage(session.user.image ?? '');
}
}, [session]);
const handleSave = async (): Promise<void> => {
setSaveState('saving');
setErrorMsg('');
try {
const result = await authClient.updateUser({ name, image: image || null });
if (result.error) {
setErrorMsg(result.error.message ?? 'Failed to update profile');
setSaveState('error');
return;
}
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update profile';
setErrorMsg(message);
setSaveState('error');
}
};
return (
<section className="space-y-4">
<h2 className="text-lg font-medium text-text-secondary">Profile</h2>
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-4">
<FormField label="Display Name" id="profile-name">
<input
id="profile-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
/>
</FormField>
<FormField label="Email" id="profile-email">
<input
id="profile-email"
type="email"
value={session?.user.email ?? ''}
disabled
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-muted opacity-60 cursor-not-allowed"
/>
<p className="mt-1 text-xs text-text-muted">Email cannot be changed here.</p>
</FormField>
<FormField label="Avatar URL" id="profile-image">
<input
id="profile-image"
type="url"
value={image}
onChange={(e) => setImage(e.target.value)}
placeholder="https://example.com/avatar.png"
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
/>
</FormField>
<div className="flex items-center gap-3 pt-2">
<SaveButton state={saveState} onClick={handleSave} />
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
</div>
</div>
</section>
);
}
// ─── Appearance Tab ───────────────────────────────────────────────────────────
function AppearanceTab(): React.ReactElement {
const [loading, setLoading] = useState(true);
const [theme, setTheme] = useState<Theme>('system');
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [defaultModel, setDefaultModel] = useState('');
const [saveState, setSaveState] = useState<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
api<Preference[]>('/api/memory/preferences?category=appearance')
.catch(() => [] as Preference[])
.then((p) => {
setTheme(prefValue<Theme>(p, 'ui.theme', 'system'));
setSidebarCollapsed(prefValue<boolean>(p, 'ui.sidebar_collapsed', false));
setDefaultModel(prefValue<string>(p, 'ui.default_model', ''));
})
.finally(() => setLoading(false));
}, []);
const handleSave = async (): Promise<void> => {
setSaveState('saving');
setErrorMsg('');
try {
await Promise.all([
api('/api/memory/preferences', {
method: 'POST',
body: { key: 'ui.theme', value: theme, category: 'appearance', source: 'user' },
}),
api('/api/memory/preferences', {
method: 'POST',
body: {
key: 'ui.sidebar_collapsed',
value: sidebarCollapsed,
category: 'appearance',
source: 'user',
},
}),
...(defaultModel
? [
api('/api/memory/preferences', {
method: 'POST',
body: {
key: 'ui.default_model',
value: defaultModel,
category: 'appearance',
source: 'user',
},
}),
]
: []),
]);
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save preferences';
setErrorMsg(message);
setSaveState('error');
}
};
if (loading) {
return (
<section>
<h2 className="mb-4 text-lg font-medium text-text-secondary">Appearance</h2>
<p className="text-sm text-text-muted">Loading preferences...</p>
</section>
);
}
return (
<section className="space-y-4">
<h2 className="text-lg font-medium text-text-secondary">Appearance</h2>
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
{/* Theme */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">Theme</label>
<div className="flex gap-3">
{(['system', 'light', 'dark'] as Theme[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setTheme(t)}
className={`rounded-lg border px-4 py-2 text-sm capitalize transition-colors ${
theme === t
? 'border-accent bg-accent/10 text-accent'
: 'border-surface-border bg-surface-elevated text-text-secondary hover:border-accent/50'
}`}
>
{t}
</button>
))}
</div>
</div>
{/* Sidebar collapsed default */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-text-primary">Collapse sidebar by default</p>
<p className="text-xs text-text-muted">Start with sidebar collapsed on page load</p>
</div>
<Toggle checked={sidebarCollapsed} onChange={setSidebarCollapsed} />
</div>
{/* Default model */}
<FormField label="Default Model" id="default-model">
<input
id="default-model"
type="text"
value={defaultModel}
onChange={(e) => setDefaultModel(e.target.value)}
placeholder="e.g. ollama/llama3.2"
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
/>
<p className="mt-1 text-xs text-text-muted">
Model ID to pre-select for new conversations.
</p>
</FormField>
<div className="flex items-center gap-3 pt-2">
<SaveButton state={saveState} onClick={handleSave} />
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
</div>
</div>
</section>
);
}
// ─── Notifications Tab ────────────────────────────────────────────────────────
function NotificationsTab(): React.ReactElement {
const [loading, setLoading] = useState(true);
const [emailAgentComplete, setEmailAgentComplete] = useState(false);
const [emailMentions, setEmailMentions] = useState(true);
const [emailDigest, setEmailDigest] = useState(false);
const [saveState, setSaveState] = useState<SaveState>('idle');
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
api<Preference[]>('/api/memory/preferences?category=communication')
.catch(() => [] as Preference[])
.then((p) => {
setEmailAgentComplete(prefValue<boolean>(p, 'notify.email_agent_complete', false));
setEmailMentions(prefValue<boolean>(p, 'notify.email_mentions', true));
setEmailDigest(prefValue<boolean>(p, 'notify.email_digest', false));
})
.finally(() => setLoading(false));
}, []);
const handleSave = async (): Promise<void> => {
setSaveState('saving');
setErrorMsg('');
try {
await Promise.all([
api('/api/memory/preferences', {
method: 'POST',
body: {
key: 'notify.email_agent_complete',
value: emailAgentComplete,
category: 'communication',
source: 'user',
},
}),
api('/api/memory/preferences', {
method: 'POST',
body: {
key: 'notify.email_mentions',
value: emailMentions,
category: 'communication',
source: 'user',
},
}),
api('/api/memory/preferences', {
method: 'POST',
body: {
key: 'notify.email_digest',
value: emailDigest,
category: 'communication',
source: 'user',
},
}),
]);
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save preferences';
setErrorMsg(message);
setSaveState('error');
}
};
if (loading) {
return (
<section>
<h2 className="mb-4 text-lg font-medium text-text-secondary">Notifications</h2>
<p className="text-sm text-text-muted">Loading preferences...</p>
</section>
);
}
return (
<section className="space-y-4">
<h2 className="text-lg font-medium text-text-secondary">Notifications</h2>
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
<p className="text-xs text-text-muted">Configure when you receive email notifications.</p>
<NotifyRow
label="Agent task completed"
description="Email when an agent finishes a task"
checked={emailAgentComplete}
onChange={setEmailAgentComplete}
/>
<NotifyRow
label="Mentions"
description="Email when you are mentioned in a conversation"
checked={emailMentions}
onChange={setEmailMentions}
/>
<NotifyRow
label="Weekly digest"
description="Weekly summary of activity"
checked={emailDigest}
onChange={setEmailDigest}
/>
<div className="flex items-center gap-3 pt-2">
<SaveButton state={saveState} onClick={handleSave} />
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
</div>
</div>
</section>
);
}
// ─── Providers Tab ────────────────────────────────────────────────────────────
function ProvidersTab(): React.ReactElement {
const [providers, setProviders] = useState<ProviderInfo[]>([]);
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
const [loading, setLoading] = useState(true);
const [ssoLoading, setSsoLoading] = useState(true);
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
useEffect(() => {
api<ProviderInfo[]>('/api/providers')
.catch(() => [] as ProviderInfo[])
.then((p) => setProviders(p))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
api<SsoProviderDiscovery[]>('/api/sso/providers')
.catch(() => [] as SsoProviderDiscovery[])
.then((providers) => setSsoProviders(providers))
.finally(() => setSsoLoading(false));
}, []);
const testConnection = useCallback(async (providerId: string): Promise<void> => {
setTestStatuses((prev) => ({
...prev,
[providerId]: { state: 'testing' },
}));
try {
const result = await api<TestConnectionResult>('/api/providers/test', {
method: 'POST',
body: { providerId },
});
setTestStatuses((prev) => ({
...prev,
[providerId]: { state: result.reachable ? 'success' : 'error', result },
}));
} catch {
setTestStatuses((prev) => ({
...prev,
[providerId]: {
state: 'error',
result: { providerId, reachable: false, error: 'Request failed' },
},
}));
}
}, []);
const defaultModel: ModelInfo | undefined = providers
.flatMap((p) => p.models)
.find((m) => providers.find((p) => p.id === m.provider)?.available);
return (
<section className="space-y-6">
<div className="space-y-4">
<h2 className="text-lg font-medium text-text-secondary">SSO Providers</h2>
<SsoProviderSection providers={ssoProviders} loading={ssoLoading} />
</div>
<div className="space-y-4">
<h2 className="text-lg font-medium text-text-secondary">LLM Providers</h2>
{loading ? (
<p className="text-sm text-text-muted">Loading providers...</p>
) : providers.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
<p className="text-sm text-text-muted">
No providers configured. Set{' '}
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
OLLAMA_BASE_URL
</code>{' '}
or{' '}
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
MOSAIC_CUSTOM_PROVIDERS
</code>{' '}
to add providers.
</p>
</div>
) : (
<div className="space-y-4">
{providers.map((provider) => (
<ProviderCard
key={provider.id}
provider={provider}
defaultModel={defaultModel}
testStatus={testStatuses[provider.id] ?? { state: 'idle' }}
onTest={() => void testConnection(provider.id)}
/>
))}
</div>
)}
</div>
</section>
);
}
// ─── Shared UI Components ─────────────────────────────────────────────────────
function FormField({
label,
id,
children,
}: {
label: string;
id: string;
children: React.ReactNode;
}): React.ReactElement {
return (
<div>
<label htmlFor={id} className="block text-sm font-medium text-text-primary">
{label}
</label>
{children}
</div>
);
}
function Toggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}): React.ReactElement {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-surface-card ${
checked ? 'bg-accent' : 'bg-surface-border'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
checked ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
);
}
function NotifyRow({
label,
description,
checked,
onChange,
}: {
label: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
}): React.ReactElement {
return (
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-text-primary">{label}</p>
<p className="text-xs text-text-muted">{description}</p>
</div>
<Toggle checked={checked} onChange={onChange} />
</div>
);
}
function SaveButton({
state,
onClick,
}: {
state: SaveState;
onClick: () => void;
}): React.ReactElement {
return (
<button
type="button"
onClick={onClick}
disabled={state === 'saving'}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{state === 'saving' ? 'Saving...' : state === 'saved' ? 'Saved!' : 'Save changes'}
</button>
);
}
// ─── Provider Card (from original page) ──────────────────────────────────────
interface ProviderCardProps {
provider: ProviderInfo;
defaultModel: ModelInfo | undefined;
testStatus: ProviderTestStatus;
onTest: () => void;
}
function ProviderCard({
provider,
defaultModel,
testStatus,
onTest,
}: ProviderCardProps): React.ReactElement {
const [expanded, setExpanded] = useState(false);
return (
<div className="rounded-lg border border-surface-border bg-surface-card">
{/* Header row */}
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<ProviderAvatar id={provider.id} />
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary">{provider.name}</span>
<ProviderStatusBadge available={provider.available} />
</div>
<p className="text-xs text-text-muted">
{provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<TestConnectionButton status={testStatus} onTest={onTest} />
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="rounded px-2 py-1 text-xs text-text-muted transition-colors hover:bg-surface-elevated hover:text-text-primary"
aria-expanded={expanded}
aria-label={expanded ? 'Collapse models' : 'Expand models'}
>
{expanded ? '▲ Hide' : '▼ Models'}
</button>
</div>
</div>
{/* Test result banner */}
{testStatus.state !== 'idle' && testStatus.state !== 'testing' && testStatus.result && (
<TestResultBanner result={testStatus.result} />
)}
{/* Model list */}
{expanded && (
<div className="border-t border-surface-border">
<table className="w-full">
<thead>
<tr className="bg-surface-elevated text-left text-xs text-text-muted">
<th className="px-4 py-2 font-medium">Model</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Capabilities</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Context</th>
<th className="hidden px-4 py-2 font-medium md:table-cell">Cost (in/out)</th>
<th className="px-4 py-2 font-medium">Default</th>
</tr>
</thead>
<tbody>
{provider.models.map((model) => (
<ModelRow
key={model.id}
model={model}
isDefault={
defaultModel?.id === model.id && defaultModel?.provider === model.provider
}
/>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
interface ModelRowProps {
model: ModelInfo;
isDefault: boolean;
}
function ModelRow({ model, isDefault }: ModelRowProps): React.ReactElement {
return (
<tr className="border-t border-surface-border">
<td className="px-4 py-2">
<span className="text-sm text-text-primary">{model.name}</span>
</td>
<td className="hidden px-4 py-2 md:table-cell">
<div className="flex flex-wrap gap-1">
<CapabilityBadge label="chat" />
{model.reasoning && <CapabilityBadge label="reasoning" color="purple" />}
{model.inputTypes.includes('image') && <CapabilityBadge label="vision" color="blue" />}
</div>
</td>
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
{formatContext(model.contextWindow)}
</td>
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
{model.cost.input === 0 && model.cost.output === 0
? 'free'
: `$${model.cost.input} / $${model.cost.output}`}
</td>
<td className="px-4 py-2 text-center">
{isDefault && (
<span
className="inline-block rounded-full bg-accent/20 px-2 py-0.5 text-xs font-medium text-accent"
title="Default model used for new sessions"
>
default
</span>
)}
</td>
</tr>
);
}
function ProviderAvatar({ id }: { id: string }): React.ReactElement {
const letter = id.charAt(0).toUpperCase();
return (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-elevated text-sm font-semibold text-text-secondary">
{letter}
</div>
);
}
function ProviderStatusBadge({ available }: { available: boolean }): React.ReactElement {
return (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
available ? 'bg-success/20 text-success' : 'bg-surface-elevated text-text-muted'
}`}
>
{available ? 'Active' : 'Inactive'}
</span>
);
}
interface TestConnectionButtonProps {
status: ProviderTestStatus;
onTest: () => void;
}
function TestConnectionButton({ status, onTest }: TestConnectionButtonProps): React.ReactElement {
const isTesting = status.state === 'testing';
return (
<button
type="button"
onClick={onTest}
disabled={isTesting}
className="rounded px-2 py-1 text-xs transition-colors hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-50"
title="Test connection"
>
{isTesting ? (
<span className="text-text-muted">Testing</span>
) : status.state === 'success' ? (
<span className="text-success"> Reachable</span>
) : status.state === 'error' ? (
<span className="text-error"> Unreachable</span>
) : (
<span className="text-text-muted">Test</span>
)}
</button>
);
}
function TestResultBanner({ result }: { result: TestConnectionResult }): React.ReactElement {
return (
<div
className={`px-4 py-2 text-xs ${
result.reachable ? 'bg-success/10 text-success' : 'bg-error/10 text-error'
}`}
>
{result.reachable ? (
<>
Connected
{result.latencyMs !== undefined && (
<span className="ml-1 opacity-70">({result.latencyMs}ms)</span>
)}
{result.discoveredModels && result.discoveredModels.length > 0 && (
<span className="ml-2 opacity-70">
{result.discoveredModels.length} model
{result.discoveredModels.length !== 1 ? 's' : ''} discovered
</span>
)}
</>
) : (
<>Connection failed{result.error ? `: ${result.error}` : ''}</>
)}
</div>
);
}
function CapabilityBadge({
label,
color = 'default',
}: {
label: string;
color?: 'default' | 'purple' | 'blue';
}): React.ReactElement {
const colorClass =
color === 'purple'
? 'bg-purple-500/20 text-purple-400'
: color === 'blue'
? 'bg-blue-500/20 text-blue-400'
: 'bg-surface-elevated text-text-muted';
return <span className={`rounded px-1.5 py-0.5 text-xs ${colorClass}`}>{label}</span>;
}
function formatContext(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
return String(tokens);
}
+2
View File
@@ -10,6 +10,8 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/appservice/package.json ./apps/appservice/
COPY packages/ ./packages/
COPY plugins/ ./plugins/
# the root prepare script runs scripts/install-hooks.mjs on install
COPY scripts/ ./scripts/
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm turbo run build --filter @mosaicstack/mosaic-as...
+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
+328
View File
@@ -0,0 +1,328 @@
# Identity Account-Lifecycle Contract
Status: DRAFT — awaiting ratification (webui-audit S2, contract 4 of 9).
Authority: PRD D10 (better-auth is the account system of record), Q1 ruled O1
by Jason 2026-08-26 (webui-audit T10). This document turns that ruling into
enforceable policy. It also carries the bootstrap/first-admin invariant from
issue #1430, folded in here after PR #1431's independent review showed the
quick-fix approach was insufficient.
Revision 2: addresses the 9 findings of the independent review
(`fleet/lanes/webui-audit/findings/pr1433-review.md`) — epoch enforcement
tightened (§3), canonical email split from provider claims (§5), external
principal keyed by issuer+subject with DB uniqueness and link step-up (§6),
JIT default precedence and first-admin SSO path defined (§2, §4),
deactivation made measurable (§7.1), deletion kept in scope and the existing
hard-delete endpoint required to fail closed (§7.3), workspace identity
reconciled with the native-kanban SOT (§1.4), verification matrix expanded
(§8), factual labels corrected (§7.3, §8.1).
Revision 3: addresses the residuals and new findings of the revision-2
re-review (`fleet/lanes/webui-audit/findings/pr1433-review-r2.md`) —
`users.emailVerified` added to the canonical set with a defined reset rule on
email change (§5.15.2), external-principal uniqueness moved to
(issuer, subject) (§6.1), "can actually use" defined (§6.5), the shipped
delete affordances (web admin page, `mosaic auth users delete`) required to
be removed or disabled with a defined user-visible state (§7.3), the
admin-creation switch removed in favor of plain admin authorization (§2.3),
and §8 extended with observables for IdP removal, forward-auth non-use,
first-admin SSO, wizard-recorded JIT choice, the admin-guide statement, and
positive/expiry-bound step-up cases.
Scope: account creation, bootstrap, federated login, account linking, claim
mapping, deactivation, and (minimally) deletion gating. Out of scope: RBAC
grant semantics (contract 2), wizard UX flow (contract 3), hierarchy schema
(contract 1), sensitive-data custody (contract 7 / D14).
## 1. System of record
1. better-auth's tables (`users`, `accounts`, `sessions`, `verifications`) are
the only account system of record. All foreign keys reference `users.id`.
2. External IdPs (Authentik or any OIDC provider) are login methods, attached
through better-auth's generic-OAuth plugin (`packages/auth/src/sso.ts`).
They never own accounts. Removing an IdP removes a login method, not users.
3. The forward-auth perimeter shim is a deployment measure. Once in-app OIDC
is configured for a deployment, the shim is demoted: it may stay as network
perimeter, but no application code may read identity from its headers.
4. **Account ≠ workspace membership.** Creating an account (by any path:
bootstrap, sign-up, invite, JIT, admin creation) creates no workspace, no
hierarchy grant, and no workspace-scoped authority (native-kanban SOT
REQ-TEN-001 / REQ-ID-001). The better-auth `role` field is a platform/auth
role (`member` | `admin`), not workspace membership. Workspace grants are
defined by contract 2; until then a fresh account can authenticate and
holds no workspace authority.
## 2. Registration gating
Measured current state on `next`: `emailAndPassword.enabled: true` with no
gating — anyone who can reach the Gateway can create an account via
`POST /api/auth/sign-up/email` and receives role `member`.
Contract:
1. A single server-side setting `registration_mode` with values
`open | invite | closed`. It lives in the database (admin-mutable at
runtime), not in env config.
2. Default after bootstrap: `closed`. The wizard (contract 3) may set a
different mode during setup, recorded as an explicit operator choice.
While the bootstrap epoch is open (§3), the effective mode is `closed`
regardless of any stored value: the setting takes effect only after the
epoch completes.
3. `closed` blocks self-service email/password sign-up. It does not block
admin-created users or OIDC JIT (§4). Post-bootstrap admin creation is
gated by admin authorization alone — there is no separate switch for it.
JIT is gated by its per-provider flag (§4.1). All user-creating paths are
closed while the bootstrap epoch is open (§3).
4. `invite` requires a single-use, expiring invite token bound to an email
address. Invite issuance is an admin operation and is audit-logged.
5. Enforcement point: a better-auth hook (or equivalent middleware executed
inside the auth handler path), not a Gateway route guard in front of it —
the raw `/api/auth/*` handler must be incapable of bypassing the gate.
## 3. Bootstrap / first-admin invariant (from #1430)
Invariant: **the system transitions from zero users to one admin user exactly
once per bootstrap epoch, atomically, regardless of concurrency or which code
path writes users.**
Constraints any implementation MUST satisfy (each traces to a verified defect
in PR #1431's review, `fleet/lanes/webui-audit/findings/pr1431-review.md`):
1. **Durable fail-closed epoch state, obeyed by every writer.** The epoch
lives in a constraint-backed one-row `bootstrap_state` table. While the
epoch is open, every non-bootstrap user-creating writer — better-auth
sign-up, OIDC JIT, admin creation — refuses, fail-closed, enforced inside
the writer's own path (better-auth hook for the raw handler; guard for
admin routes). A partial unique index or a winning epoch-transition row is
necessary but not sufficient on its own: neither stops an untagged insert
from a writer that never consulted the epoch. Both layers are required:
database-level transition safety (the epoch-completing write races safely
and at most one wins) and writer-level refusal (no path can create a user
without reading epoch state).
2. **Atomic first-admin transition.** The admin user, its credential account,
the initial admin token, and the epoch-completed transition commit in one
database transaction or not at all. A better-auth call through
`drizzleAdapter(db)` runs on the root pool and is NOT part of any caller
transaction; it may be used inside the bootstrap transition only if the
adapter is explicitly bound to the transaction handle. Otherwise the
bootstrap writer must create the user rows itself within the transaction.
3. **Pool safety.** No design may hold a pooled connection inside a
transaction while awaiting a write that acquires a second connection from
the same pool (`DB_POOL_MAX=1` is a supported configuration).
4. **Re-runnability (D4).** Bootstrap is not a one-shot: after the first-admin
epoch completes, re-running the wizard reconfigures the system but never
re-opens the zero-user transition. "Setup already completed" is a stable,
testable state, and factory-reset (a future, explicitly destructive
operation) is the only way to open a new epoch.
5. **No stranded partial outcome.** A failure at any point in the transition
leaves nothing observable (no admin user without its token, no completed
epoch without an admin) and setup remains retryable — this follows from
§3.2 and is stated separately because it is the pre-existing failure mode
the #1431 review verified.
6. **First-admin via SSO (D4).** When the operator chooses SSO for the
initial user, the wizard executes the OIDC login as part of the bootstrap
transition itself: the bootstrap writer creates the account from the
asserted identity inside the §3.2 transaction. This path is the bootstrap
writer, not JIT — §4's JIT gate stays closed during the epoch and is not
an obstacle to D4.
## 4. JIT provisioning (OIDC first login)
1. A successful OIDC login with no matching account creates a user
just-in-time only when `jit_provisioning` is enabled for that provider.
The flag is per-provider and defaults off, always. There is no
mode-implied default: Enterprise setup enables JIT only when the wizard
records it as an explicit operator choice for a named provider (this
replaces revision 1's "Enterprise mode defaults to closed with OIDC JIT
enabled", which contradicted the per-provider default).
2. JIT users receive platform role `member`, never an elevated role,
regardless of IdP claims (§5), and no workspace authority (§1.4).
3. An optional per-provider email-domain allowlist constrains JIT. The
allowlist matches only when the IdP asserts the email with
`email_verified: true`; an unverified address never satisfies the
allowlist. Empty allowlist with JIT on means any authenticated subject at
that IdP gets an account — permitted, but the wizard must present it as an
explicit choice.
4. JIT is disabled while the bootstrap epoch is open (§3.1). The first-admin
SSO path is §3.6, not JIT.
## 5. Claim mapping
1. **Two stores, not one.** Provider-observed claims (`email`,
`email_verified`, display name, avatar) are recorded per external
principal — keyed by issuer + subject (§6.1) — at first login and
refreshed at each login. The canonical account fields (`users.email`,
`users.emailVerified`, `users.name`, `users.image`) are set exactly once
at account creation and are never silently overwritten by a later login.
For SSO-created accounts (JIT or first-admin SSO), `users.emailVerified`
is set from the provider's `email_verified` claim at creation; for
password-created accounts it is false until the address completes
verification.
2. **Canonical email changes only through an explicit workflow.** Either the
user-initiated email change (with verification of the new address) or an
admin edit. Any canonical email change — user- or admin-initiated — sets
`users.emailVerified` to false until the new address completes
verification; an admin may instead explicitly attest the address as
verified in the same operation, and that attestation is audit-logged. A
provider-claim refresh never rebinds `users.email` or
`users.emailVerified`; a divergence between canonical email and the latest
provider-observed email is surfaced per §6.4.
3. Never mapped from IdP claims: `role` and any future authorization
attribute. Authorization lives in the system of record and in the RBAC
layer (contract 2). An IdP group/role claim may at most be recorded for
audit; it grants nothing.
## 6. Account linking trust
1. **External principal identity is issuer + subject.** A linked identity is
keyed by the OIDC issuer and subject claims, not by an unqualified
provider subject id and not by email. The linked-identity row stores the
issuer, and the database enforces at most one local account per
**(issuer, subject)** with a unique constraint on those stored columns —
uniqueness on (provider, subject) is insufficient because provider →
issuer is not one-to-one: two provider configurations can point at the
same issuer, and the identity must not alias across them. The current
non-unique `(provider_id, account_id)` index satisfies neither;
application-level checks without a uniqueness witness lose
concurrent-callback races. Each configured provider additionally binds to
exactly one issuer, immutable after creation (changing the issuer means
creating a new provider).
2. Linking an OIDC identity to an existing account happens only in one of two
ways: (a) explicit link initiated by the logged-in user from settings,
which requires step-up: a fresh reauthentication (password or existing
linked method) no older than a short bound the implementation defines
(≤ 10 minutes) — a session cookie alone is insufficient, so a stolen
session cannot quietly attach a durable login method; or (b) automatic
link when the IdP asserts a verified email exactly matching an existing
account **and** the provider is marked `trusted_for_linking`
(per-provider flag, default off).
3. Untrusted-provider email collision produces a login error naming the
conflict, not an auto-link and not a duplicate account.
4. A linked identity whose IdP-observed email later diverges from the
canonical account email keeps working (the link is by issuer + subject,
§6.1) but the divergence is surfaced in the user's settings and audit log
(the per-principal claim store in §5.1 is what makes the divergence
representable).
5. Unlinking a login method is refused when it would leave the account with
no **usable** login method. Usable means: a set password, or a linked
identity whose provider is currently configured and enabled on this
deployment. A linked identity whose provider has been removed or disabled
(§1.2) is not usable and does not count; setting a password first lifts
the refusal.
## 7. Deactivation propagation
1. **Deactivation (better-auth admin ban) is authoritative and bounded.**
Concretely:
- Ban and session revocation are one operation: the ban commit revokes all
better-auth sessions for the user. If revocation partially fails, the
ban itself must already be committed and every guard denies from that
point (fail closed); the operation is retryable.
- Every authenticated entry path checks banned state: HTTP session guards,
the admin bearer-token path (which today does not test `banned` — an
implementation defect this contract makes non-conformant), MCP, and
Socket.IO.
- Active socket connections are terminated or denied within 30 seconds of
the ban commit, or at the next inbound message on that socket, whichever
comes first (socket auth at connect-time only, as today, does not
satisfy this).
- The current admin ban route updates only the user row; it does not
conform to this section until revocation and guard coverage land.
- Admin tokens owned by the banned user are revoked in the same operation.
2. Deactivation at an external IdP does not propagate automatically in this
contract's scope (no SCIM). Operational rule: removing a user from the IdP
without banning them in Mosaic leaves any password or other linked login
method usable — the admin guide must state this. SCIM/webhook-driven
propagation is future work and out of scope here.
3. **Deletion is not deactivation, and deletion is gated here.** Account
deletion semantics (FK fan-out across the 21 foreign-key constraints to
`users.id`, spread over 19 referencing tables) require their own
deletion-and-retention contract, chartered as an addition to the S2 list —
contract 7 is the D14 sensitive-data custody contract and does not cover
account deletion. Until that deletion contract is ratified: the existing
hard-delete endpoint (`DELETE /api/admin/users/:id`) is disabled and fails
closed, and deactivation is the only supported removal operation. A
contract that merely declared deactivation "the only supported removal"
while the endpoint stayed live would be false on its face.
Disabling the endpoint alone is insufficient — its shipped callers must
not be left as advertised operations that now fail generically:
- The admin web UI delete action (`apps/web/src/app/(dashboard)/admin/page.tsx`
and any SPA port of it) is removed, or replaced by a disabled control
whose visible text states that deletion is unavailable pending the
deletion-and-retention contract and points at deactivation.
- The CLI command `mosaic auth users delete`
(`packages/mosaic/src/commands/auth.ts`) is removed, or exits non-zero
with a message stating the same and naming the deactivation command.
- Both surfaces expose deactivation as the supported operation.
## 8. Verification requirements
Every MUST above needs a bounded observable. The matrix:
1. **Bootstrap invariant (§3).** Real-PostgreSQL concurrency tests using two
distinct physical connections (pattern:
`apps/gateway/src/agent/connector-lease.postgres.integration.test.ts`,
which runs in the `test` CI step against the `ci-postgres` PostgreSQL
service — note that pattern multiplexes one pooled handle, so the tests
here must explicitly open separate connections). Races to cover:
setup-vs-setup, setup-vs-raw-sign-up, setup-vs-JIT, setup-vs-admin-create.
Plus: liveness under `DB_POOL_MAX=1`; fault injection after each write in
the transition (user, credential, token, epoch) proving nothing observable
leaks and setup retries; wizard re-run after completion proving the
zero-user transition never re-opens. Mocked-transaction specs are
supplementary; they cannot prove serialization.
2. **Registration gating (§2).** Spec coverage of all three modes against the
raw `/api/auth/` handler path, not only Gateway controllers; invite
lifecycle (single-use, expiry, email binding); effective-`closed` while
the epoch is open regardless of stored mode.
3. **JIT (§4).** Provider flag off → no account on first OIDC login; on →
account with platform role `member` and no workspace grant; domain
allowlist rejects an unverified email claim even when the domain matches;
JIT refused while the epoch is open.
4. **Claim mapping (§5).** Login refresh updates the per-principal claim
store and touches none of the canonical fields (`users.email`,
`users.emailVerified`, name, image); explicit email-change workflow is the
only path that rebinds canonical email; every canonical email change
resets `users.emailVerified` to false unless the admin attestation path
is taken, and that attestation appears in the audit log.
5. **Linking (§6).** Unique-constraint witness: concurrent first-login
callbacks for the same (issuer, subject) yield exactly one account, and
two provider configurations sharing one issuer cannot create two accounts
for the same subject; trusted auto-link; untrusted collision error;
step-up both ways: an explicit link succeeds immediately after a fresh
reauthentication and is refused once the implementation's chosen bound
(≤ 10 minutes) has elapsed, and refused with no reauthentication at all;
unlink refusal when no remaining method is usable per §6.5, including the
removed-provider case, and acceptance after a password is set; divergence
surfaced after IdP email change.
6. **Deactivation (§7).** Ban revokes sessions atomically or fails closed
(partial-failure injection); guard denial post-ban on each transport:
HTTP session, admin bearer token, MCP, Socket.IO; active socket terminated
within the 30-second/next-message bound; banned user's admin tokens
unusable; hard-delete endpoint returns a fail-closed error while the
deletion contract is unratified; the admin web UI renders no live delete
action (absent, or disabled with the §7.3 text) and `mosaic auth users
delete` exits non-zero with the §7.3 message — both asserted by spec.
7. **System of record and bootstrap edges (§1, §3.6, §4.3).** IdP removal:
deleting a provider configuration leaves every user row intact and every
other login method working (spec over the provider-config removal path).
Forward-auth non-use: with in-app OIDC configured, a request carrying
forward-auth identity headers and no session is treated as anonymous —
no code path derives identity from those headers (negative spec at the
Gateway entry). First-admin SSO: the §3.6 transition commits account,
token, and epoch atomically from the asserted identity, and fault
injection mid-transition leaves nothing observable (same harness as §8.1).
Wizard-recorded JIT choice: enabling JIT for a provider writes an
explicit per-provider operator-choice record, and no mode selection
enables it implicitly (assert the stored record, not UI behavior).
8. **Documentation observable (§7.2).** The admin guide contains the
IdP-removal-does-not-deactivate statement; verified by a docs assertion
(content check in CI or an enumerated review-checklist item on the
implementing PR) — a MUST about documentation needs a checkable artifact,
not intent.
## Ruling request
Ratify sections 18 as written, with one decision embedded: registration
defaults to `closed` after bootstrap (§2.2) — say "agreed" or name the mode
you want as the default.
+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).
+351
View File
@@ -0,0 +1,351 @@
# Roll-up Projection Contract (S2 contract 8)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 8 of 9).
Authority: `native-kanban-sot.md` §8 (A1 amendment) — "task and status
visualization bubbles up the hierarchy as aggregation over workspaces
the reader is authorized on" (§8.1.3); roll-up is never a write and
bubble-up views are generated projections, non-authoritative and never
import sources (§8.2.2); the express, narrow carve-out from the
portfolio-analytics non-goal covers per-workspace task counts and
statuses aggregated up the parent chain over readable workspaces, and
nothing beyond that boundary (§8.2.4); acceptance requires that roll-up
endpoints cannot mutate state and that a reader sees aggregates only
over workspaces they are authorized on, with no cross-tenant existence
oracles (§8.3). A5 rank 5 names the deliverable: an authorized
read-only roll-up query over only readable workspaces, at every
hierarchy level, as its own non-mutating query tool, dependent on ranks
13.
Revision 2 (terra review F1F7): membership-only readability is now
workspace-local — it contributes at the workspace node only and never
promotes ancestor visibility; upward aggregation requires an effective
chain role, and §1 defines direct vs effective grants in contract 2's
terms (F1). The no-oracle rule gains a defined equivalence predicate
(normalized byte equality with an enumerated volatile-field set) and a
partial-scope hidden-sibling witness (F2). §2.5 enumerates the closed
semantic result and denial schemas field-by-field, including the
explicit-zero representation (F3). Cache invalidation, when a cache
exists, is witnessed per invalidator class (F4). Non-authoritative and
never-gate rules gain an import-graph/data-flow witness, and the
mutation check is aligned to contract 1 §6.7's both-table zero-write
assertion (F5). The fixture gains a second estate with distinct counts
and explicit company-, estate-, project-grant, and membership cases
(F6). The §5.3 legacy-row exclusion and pre-rank no-obligation rules
are disclosed as drafting additions (F7).
Revision 3 (terra re-review residuals): the partial-scope witnesses are
reconstructed at levels where chain grants can actually differ —
platform-project siblings under one estate and estate siblings under
one company — because contract 1 §3.1/§3.4 defines no workspace-level
grant target, so no reader can hold a chain grant on two of three
sibling workspaces (F2). §2.5 now defines one field-exact recursive
record — every node, including the queried node and every leaf, is the
same five-field shape with a required, deterministically ordered
`children` array that is empty at workspaces — and the whole-result
rules (no optional fields, denial envelope, wire faithfulness) are
their own §2.6 at section scope (F3). The fixture assigns workspaces
to named platform-projects, and §6.1's grant-level cases are the three
levels contract 1 defines, with workspace-level access covered by the
membership case and stated as having no direct chain grant (F6).
This contract binds the projection semantics (§2), reader authorization
semantics (§3), read-only enforcement (§4), dependencies and phase
timing (§5), witnesses (§6), and disclosed drafting additions (§7). It
defines the roll-up only: hierarchy shape stays with contract 1
(`hierarchy-schema.md`), grant vocabulary and evaluation with contract 2
(`rbac-grant-model.md`), the task lifecycle and status taxonomy with
`native-kanban-sot.md`'s typed surface, and the tool↔Gateway mapping
row with contract 5 (`tool-gateway-mapping.md`).
## 1. Definitions
1. **Roll-up**: the read-only projection of per-workspace task counts
by status, aggregated up the contract 1 parent chain (workspace →
platform-project → estate → company).
2. **Effective chain role** (at a node, for a reader): the role
contract 2 §3 evaluation yields at that node — from a grant on the
node itself (a **direct grant**) or from a grant on an ancestor
whose domain covers it (an **inherited grant**, contract 2 §3.2).
The role vocabulary is contract 2 §2's; this contract adds no role
and no new authority source.
3. **Chain-readable workspace** (for a reader): a workspace where the
reader's effective chain role permits reading task state.
4. **Member-readable workspace** (for a reader): a workspace readable
only through workspace membership under the SOT's own membership
rules (REQ-ID-001), with no effective chain role. Membership
confers workspace-local semantics only (contract 2 §3.1, §7.4): it
never contributes authority, visibility, or aggregation upward.
5. **Aggregation scope** (of a hierarchy node, for a reader): the set
of chain-readable workspaces in that node's descendant subtree;
plus, when the node is itself a workspace, that workspace if it is
chain-readable or member-readable. A member-readable workspace
therefore contributes to exactly one node's aggregation scope: its
own.
6. **Projection**: a generated, non-authoritative view in the sense of
`native-kanban-sot.md` §3 invariant 5 — derived from SOT rows,
never an import source, never authoritative.
## 2. Projection semantics
1. **Aggregate content.** The roll-up for a node reports, per
workspace in the reader's aggregation scope and as subtree totals:
task counts keyed by the typed lifecycle's status values (owned by
`native-kanban-sot.md`; this contract introduces no status), and
nothing else. Direct count/status aggregation is the entire
surface.
2. **Every level.** The roll-up is queryable at workspace,
platform-project, estate, and company level. A node's totals equal
the sum over its aggregation scope; chain resolution is contract 1
§2.5's (every workspace resolves to exactly one chain), so no
workspace is counted twice and none is orphaned.
3. **Carve-out boundary.** Everything beyond direct count/status
aggregation — metrics, trends, forecasting, scoring, velocity,
cross-workspace derived analytics, dashboards computed across
workspaces — remains a `native-kanban-sot.md` §6 non-goal
(§8.2.4). The response schema is closed (§2.5; §6.7 witness):
adding any field is an amendment to this contract.
4. **Non-authoritative.** No consumer may treat roll-up output as a
source of record; it is recomputable at any time from SOT rows and
is never imported, persisted as authoritative state, or used to
gate or deny work (witness §6.8 — both the write-path and the
decision-path prohibitions are witnessed).
5. **Closed semantic schema.** The successful result is exactly one
**roll-up node record**, a single recursive shape used at every
depth. A roll-up node record consists of exactly these five
fields, and no others:
- `id`: the node's identifier.
- `type`: one of the four contract 1 levels.
- `name`: the node's name.
- `totals`: one entry per status value of the typed lifecycle —
every status key present, a count of zero represented explicitly
as `0`, never by key absence. At a workspace node, `totals` is
that workspace's own counts; at any other node, `totals` is the
sum over the node's aggregation scope (§2.2). This is how §2.1's
"per workspace and as subtree totals" content is carried:
per-workspace counts are the leaf records' `totals`, subtree
totals are the interior records' `totals`.
- `children`: a required array, present on EVERY node record. Its
elements are the reader-visible (§3.2) child nodes of this node,
each itself a complete roll-up node record, recursing down to
the workspaces in the reader's aggregation scope. At a workspace
node the array is exactly `[]` — a workspace record never has
children. The array is ordered deterministically, ascending by
`id`; the implementing PR asserts that ordering. A node outside
§3.2 visibility never appears at any depth.
The queried node's record IS the whole result — there is no
wrapper field around it.
6. **Whole-result rules.** There are no optional result fields at any
depth. The denial/nonexistent response is the contract 5 §4.2
not-found-class error envelope with no fields beyond that
envelope. The wire DTO is expressed under contract 5 §4.1, and
MUST be a faithful serialization of exactly the §2.5 recursive
record: a wire field with no corresponding semantic field is a
conformance defect.
## 3. Reader authorization semantics
1. **Scope rule.** A reader's roll-up over any node aggregates ONLY
the reader's aggregation scope (§1.5). An unreadable workspace
contributes nothing to any total — not a count, not a row, not a
presence marker. A member-readable workspace contributes only at
the workspace node itself (§1.4–§1.5): querying it directly
succeeds; it never appears in, and never adds to, any ancestor's
response for that reader.
2. **Node visibility.** A node appears in a roll-up response iff the
reader's aggregation scope at that node is non-empty, or the
reader holds an effective chain role at the node (§1.2 — direct or
inherited; contract 2 §3.2 makes a grant's domain the node and its
subtree, so an ancestor grant makes empty descendants visible per
the ruling). Per the ruling below, a node with an effective chain
role but an empty aggregation scope appears with zero counts.
Workspace membership alone never makes any non-workspace node
visible. A node where the reader has neither an effective chain
role nor a non-empty aggregation scope does not appear at all.
3. **No existence oracle.** The response MUST NOT disclose the
existence, count, name, or any property of unreadable workspaces
or of nodes outside §3.2 visibility — no "N workspaces hidden"
fields, no total-vs-visible discrepancy fields. A query naming a
node outside §3.2 visibility MUST satisfy the §3.4 response
equivalence with a query naming a nonexistent node (fail closed,
`rbac-grant-model.md` §3.5 pattern: a decision path that cannot
read grant state denies).
4. **Response equivalence predicate.** Two responses are equivalent
when they carry the identical HTTP status, the identical contract
5 §4.2 error code, and byte-identical bodies after normalizing
exactly the declared volatile envelope fields — correlation id and
response timestamp, and nothing else. The implementing PR declares
that volatile-field list in the witness; any additional
normalization is a conformance defect. This is contract 5 §4.2's
same code/status/shape rule made executable.
5. **Live evaluation.** Readability is evaluated per contract 2 §3.5
(live rows or transactionally-invalidated cache). Revocation
propagates per contract 2 §6: the next roll-up query decided after
the revoking transaction commits excludes the revoked scope.
## 4. Read-only enforcement
1. **Never a write.** No roll-up path may mutate, claim, order, or
gate work in any workspace (§8.2.2). The roll-up ships as a
non-mutating query tool (A5 rank 5) — a query surface with no
command counterpart.
2. **Mechanical enforcement.** The implementing PR executes roll-up
database work inside read-only transactions (or an equivalently
privilege-restricted path), so a mutation attempt fails at the
database boundary, not only by convention.
3. **Freshness.** v1 computes the roll-up live from SOT rows at query
time. A cache is an implementation option only if it is
invalidated in the same transaction as any task, hierarchy, grant,
or membership mutation that affects it (each invalidator class
witnessed, §6.5), and it is never authoritative (§1.6).
## 5. Dependencies and phase timing
1. The roll-up depends on A5 ranks 13: contract 1's hierarchy tables
(the parent chain), contract 2's evaluator (readability), and the
typed Kanban lifecycle (the task state being counted). It ships
after them and reads their surfaces; it defines none of them.
2. The roll-up query is one tool with one Gateway mapping row under
contract 5's regime (request/result/error/audit contracts there);
this contract binds its semantics (§2.5 defines the semantic
fields the contract 5 §4.1 DTO serializes), not its wire encoding.
3. Legacy task rows outside the typed lifecycle are not aggregated;
the roll-up begins counting a workspace's tasks when they exist in
the typed surface. No roll-up obligation attaches to v1 before
ranks 13 exist. Both rules are drafting additions disclosed in §7
(they trace to no §8 sentence).
## 6. Verification requirements
Binding on the implementing PRs. Every witness names, in its
implementation, the exact endpoints/tools, tables, and fixtures it
exercises. The base fixture seeds two companies; under company A **two
estates with distinct, non-identical count profiles**: estate A1 with
two platform-projects — P1 holding workspaces W1 and W2, P2 holding
workspace W3 — and estate A2 with one platform-project P3 holding one
workspace W4, all with known task counts across at least three
statuses; under company B one workspace.
1. **Correctness witnesses:** for a reader holding a direct company-A
grant, roll-up totals at every level equal the seeded sums — each
workspace, each platform-project, estate A1 and estate A2
separately (their distinct profiles asserted distinct), and the
company total equal to A1+A2 — keyed by the typed status values,
with no double count across the chain. For a reader holding a
direct estate-A1 grant, the estate-A1 result equals the A1 sum and
a company-A query returns company A with exactly A1's contribution
(estate A2 invisible). Each of the three chain grant levels
contract 1 §3.1 defines — company, estate, platform-project
(below, §6.2) — has an explicit direct-grant case, none simulated
by unioning lower access. Workspace-level access has NO direct
chain grant (contract 1 §3.1/§3.4 define no workspace grant
target) and is covered by the §6.2 membership case.
2. **Scope witnesses:** a reader with a direct grant on
platform-project P1 only sees exactly P1's subtree counts
(W1+W2): a P1 query returns W1+W2; an estate-A1 query returns the
estate node with exactly P1's contribution, sibling project P2 and
its workspace W3 absent at every depth; a company-A query likewise
carries only P1's contribution. An estate-sibling case: a reader
with a direct grant on estate A1 only queries company A and
receives exactly A1's contribution, estate A2 absent. (Chain
grants exist only at company, estate, and platform-project —
contract 1 §3.1 — so partial scope among SIBLING WORKSPACES of
one project is not constructible by grants and is not witnessed;
the constructible partial-scope cases are the project- and
estate-sibling ones above.) **Membership locality (§1.4):** a member-only reader queries
the workspace directly and receives its counts; the same reader
querying the workspace's parent (or any ancestor) receives the
§3.4-equivalent nonexistent-node response, and no ancestor
response for any other reader changes because of that membership.
3. **No-oracle witnesses:** the P1-only reader's estate-A1 response
above contains no field disclosing P2's or W3's existence
(closed-schema comparison against an estate-A1-granted reader's
response: identical field set, differing only in counts and
visible nodes). **Partial-scope hidden node:** the P1-only reader
— who sees estate A1 and the P1 subtree — queries hidden sibling
project P2 by its real id, and separately hidden workspace W3 by
its real id; each response satisfies the §3.4 equivalence
predicate against the same query naming a nonexistent id, under
one fixed request context with the declared volatile-field
normalization. **Cross-tenant:** an unauthorized reader naming company B receives
a response §3.4-equivalent to naming a nonexistent id. Each
equivalence check is executable byte comparison after the declared
normalization, not a shape judgment.
4. **Empty-vs-hidden witness (ruling):** a reader granted (direct
chain grant) on an empty platform-project receives it with zero
counts — every status key present at `0` (§2.5); with the grant
deleted, the same query returns the §3.4-equivalent
nonexistent-node response. An inherited-grant case: a company
grant makes an empty descendant platform-project visible with zero
counts.
5. **Cache-invalidation witnesses (conditional):** bound only if the
implementation caches — for EACH invalidator class, prime the
cache, commit one mutation of that class, and assert the next
query reflects it: a task status change, a task creation, a
membership removal (the member-readable workspace disappears from
its own node's next query), a workspace reparenting (both old and
new parent totals correct), and a grant revocation. A live
(cacheless) v1 implementation records that fact and the witnesses
bind at the PR that introduces a cache.
6. **Mutation witnesses:** the roll-up surface rejects every mutating
verb/command; a crafted attempt to issue a write through the
roll-up's database path fails at the read-only boundary (§4.2);
after any roll-up query, the row diff is empty across BOTH the
workspace tables and the hierarchy tables (contract 1 §6.7's
both-table zero-write assertion).
7. **Closed-schema witness:** the response is asserted field-exact
against the §2.5 recursive record at every depth — exactly
`id`/`type`/`name`/`totals`/`children` on every node, every typed
status present with explicit zeros, `children: []` at every
workspace record, the declared ascending-`id` ordering, no
wrapper field — and a response carrying any field outside the
record at any depth fails the assertion (carve-out boundary,
§2.3). The denial envelope is asserted field-exact against
contract 5 §4.2's envelope (§2.6).
8. **Non-authoritative and never-gate witnesses:** (a) a static
production import-graph inventory (hierarchy contract §6.3 style,
production code over `apps/` and `packages/`, tests excluded)
shows no production module imports the roll-up query module or its
result DTO into any SOT write path, any authorization/gating
decision path, or any persistence beyond the response lifetime —
asserted in both directions (the roll-up module's consumers are
enumerated and each is a presentation surface); (b) a behavioral
probe: with roll-up output artificially perturbed (test double),
no authorization outcome and no work-gating decision anywhere in
the fixture suite changes — proving no gate consumes it.
9. **Revocation witness:** after revoking the grant that made a
subtree readable, the next roll-up query excludes it (contract 2
§6.2 bound).
## 7. Drafting additions (PRD §12.1 disclosure)
Proposed drafting additions, visible here for ratification, each
severable; the aggregation itself, its authorization scope, its
read-only nature, and the no-oracle acceptance are traced to
`native-kanban-sot.md` §8 and are not additions:
1. The §3.2 node-visibility rule and the granted-but-empty behavior
(the ruling below).
2. The §3.3–§3.4 nonexistent-node response equivalence, with its
normalized-byte-equality predicate, as the concrete no-oracle
mechanism.
3. The §4.2 read-only-transaction mechanical enforcement.
4. The §4.3 cache option with transactional invalidation and the
§6.5 per-invalidator witnesses.
5. The §2.5 closed response schema as an amendment boundary.
6. The §1.4 membership-locality rule — membership-only readability
contributes at the workspace node only (this contract's
reconciliation of `native-kanban-sot.md` §8.1.3 "authorized on"
with contract 2 §3.1/§7.4's workspace-local membership).
7. The §5.3 legacy-row exclusion and the §5.3 pre-rank no-obligation
rule.
## Ruling request
Ruling requested (one decision): shall a node the reader holds an
effective chain role on (direct or inherited, §1.2) but whose
aggregation scope is empty appear in the roll-up with zero counts
(recommended — it lets the UI show a granted-but-empty subtree
honestly) — or, as the alternative, be indistinguishable from a
nonexistent node until it contains a readable workspace?
@@ -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"
@@ -304,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
@@ -421,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,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"
@@ -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
;;
+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)."
@@ -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
+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)
+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 '{}'
;;
+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"
@@ -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,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"
}
@@ -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 && bash framework/tools/fleet/test-agent-session-broker-preflight.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:*",
@@ -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> => {
+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 };
}
/**
@@ -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. */
+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([]);
}
});