Compare commits

..
Author SHA1 Message Date
code-infra-01 53e0fe912e fix(#1367): mint-seat-credential — secrets out of argv; discriminating pins (review 259 blocker, review 260 SF1-SF3)
ci/woodpecker/pr/ci Pipeline was successful
Blocker (rev-security-01 review 259): admin token, generated password and
minted seat token travelled curl ARGV (three Authorization sites, -u at
the mint, inline -d bodies), leaking to /proc cmdline and bash -x traces
— the durable password under must_change_password:false is a live
credential. All three now travel in 0600 staging files: --config for the
Authorization header (the landed gitea_write_auth_config pattern), a
user= directive for basic auth at the mint, --data @file for bodies.
Unlinked after each use; M6 asserts no call is unauthenticated and no
body is inline.

Scope pin (SF2 + rev-security-01 M1, same defect): a mutant writing the
REQUESTED scopes passed green because the grep target appears in both
sets. M7 now asserts write:issue (requested, not granted) is ABSENT;
mutant killed.

SF3: hyphenated instance overrides map hyphen->underscore exactly like
seat-logins.sh (url_override_var); M8 pins it; the uppercase-only mutant
dies at the invalid-variable-name refusal again, now by design.

SF1: mint-seat-credential.sh mode 755 (update-index), README invocation
updated to name the now-required MOSAIC_SEAT_EMAIL_DOMAIN.

Framework-PR firewall answer (rev-security-01): the email domain has NO
default — unset is rc=3 with a named variable (M9); the instance host
map stays per the seat-logins.sh precedent already on next. Estate
domains belong to the estate, not the tree.
2026-08-21 22:38:31 -05:00
fred 836ec3cb1d feat(fleet-tools): mint-seat-credential.sh joins the framework toolkit
ci/woodpecker/pr/ci Pipeline was successful
Moves seat credential minting out of a brain-local fleet/bin into
packages/mosaic/framework/tools/fleet/, parameterized for any deployment:

- MOSAIC_ADMIN_SEAT (or --admin-seat) names the seat whose admin token calls
  the Gitea admin API; no seat name is hardcoded.
- MOSAIC_GITEA_INSTANCES / MOSAIC_GITEA_URL_<INSTANCE> select and override
  instances, the same convention seat-logins.sh already uses.
- MOSAIC_SEAT_EMAIL_DOMAIN sets the account email domain.
- tea projection calls the sibling seat-logins.sh, not a brain-local copy.

Hermetic suite test-mint-seat-credential.sh (mock curl, sandboxed brain home,
no tea, no network) pins: slot written from the mint response at mode 600;
admin seat must be configured (rc=3) and its token present (rc=1, no API
call); instance selection and URL override; admin token value never echoed.
Joins ci.yml and the verify-release canonical list. Adds tools/fleet/README.md.

Plan: docs/plans/2026-08-21_git-operations-toolkit.md step 6 (first of three
scripts; new-seat.sh and launch-seat.sh need a design ruling, see the plan).
2026-08-21 19:24:03 -05:00
72 changed files with 1661 additions and 12342 deletions
+1 -8
View File
@@ -1,11 +1,4 @@
{
"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": "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."
"release_branch": "main"
}
+9 -40
View File
@@ -38,12 +38,10 @@ when:
- event: push
branch: main
# 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.
# 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.
steps:
install:
@@ -102,6 +100,11 @@ steps:
# repo. Pins that comment BODIES render on both paths and that a tea
# failure is named as what it was (git-config vs credential).
- bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh
# Hermetic regression for mint-seat-credential.sh (fleet onboarding moved into
# the framework): mock curl, sandboxed brain home, no tea, no network. Pins
# that the admin seat is configured rather than hardcoded and that the seat
# slot is written from the mint response at mode 600.
- bash packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
# it still blocks the three mistakes AND still lets reads, unwrapped
# endpoints and ordinary commands through. Both directions are asserted —
@@ -115,40 +118,6 @@ 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
+21 -216
View File
@@ -32,11 +32,6 @@ 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]
@@ -49,6 +44,16 @@ 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]
@@ -68,13 +73,6 @@ 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
@@ -110,13 +108,6 @@ 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
@@ -211,20 +202,6 @@ 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');
@@ -232,38 +209,23 @@ 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, visit) {
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 manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (manifest.name?.startsWith('@mosaicstack/') && !manifest.private) {
visit(manifest, packagePath);
}
}
walk(fullPath, visit);
if (fs.existsSync(packagePath)) updatePackage(packagePath);
walk(fullPath);
}
}
}
// #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) {
function updatePackage(packagePath) {
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
if (!stableMatch) {
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
@@ -272,40 +234,13 @@ 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);
}
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);
for (const root of roots) walk(root);
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")"
@@ -315,94 +250,6 @@ 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
@@ -458,18 +305,10 @@ 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: *image_build_when
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
@@ -483,17 +322,8 @@ 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" = "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
if [ "$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"
@@ -502,18 +332,10 @@ 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: *image_build_when
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: REGISTRY_USERNAME
@@ -527,17 +349,8 @@ 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" = "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
if [ "$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"
@@ -546,11 +359,3 @@ 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
@@ -1,71 +0,0 @@
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,7 +12,6 @@ import {
import {
createDb,
createPgliteDb,
getMigrationStatus,
runPgliteMigrations,
type Db,
type DbHandle,
@@ -75,11 +74,6 @@ 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...');
@@ -87,24 +81,6 @@ 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,16 +14,10 @@ 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');
}
@@ -1,104 +0,0 @@
/**
* 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
@@ -1,94 +0,0 @@
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);
}
}
@@ -1,123 +0,0 @@
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 });
});
});
+7 -45
View File
@@ -1,68 +1,30 @@
import {
Controller,
ForbiddenException,
Get,
NotFoundException,
Param,
UseGuards,
} from '@nestjs/common';
import { Controller, Get, 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(@CurrentUser() user: RequestUser) {
if (user.role === 'admin') {
return this.teams.findAll();
}
return this.teams.findAllForUser(user.id);
async list() {
return this.teams.findAll();
}
@Get(':teamId')
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
return this.getAccessibleTeam(teamId, user);
async findOne(@Param('teamId') teamId: string) {
return this.teams.findById(teamId);
}
@Get(':teamId/members')
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
await this.getAccessibleTeam(teamId, user);
async listMembers(@Param('teamId') teamId: string) {
return this.teams.listMembers(teamId);
}
@Get(':teamId/members/:userId')
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);
}
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
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;
}
}
+1 -16
View File
@@ -1,5 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { eq, and, inArray, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
import { DB } from '../database/database.module.js';
@Injectable()
@@ -56,21 +56,6 @@ 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.
*/
+1014 -242
View File
File diff suppressed because it is too large Load Diff
-77
View File
@@ -1,77 +0,0 @@
---
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
-84
View File
@@ -372,87 +372,3 @@ 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).
-197
View File
@@ -1,197 +0,0 @@
# Tool↔Gateway Mapping Contract (D8)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 5 of 9).
Authority: PRD D8/D12 (Part I §8) — the webUI sits OVER official tooling:
every webUI operation goes through the Gateway API backed by the same
official framework tooling the CLI uses, and a webUI operation with no
backing tool is scored **blocked on tooling** and the tool is built
first. Measured input: the webui-audit A5 tooling baseline
(operation-by-operation inventory of the current Gateway surface and the
P1 gaps, cross-reviewed; `fleet/lanes/webui-audit/findings/
A5-tooling-baseline.md` in the estate brain). The T10 ruling adopted the
targeted-update plan including building the D8 tools in A5's rank order.
Revision 2 (GLM review F1F5): the §2 table completed against an
independent re-measurement of the live `apps/web` surface (mission
reads, coordination status, capability-gated `turn:send` added); rank-6
composition corrected to ranks 1 and 4; SOT citations corrected to §3
invariant 11 / REQ-TASK-001 / §5+A1; the §3.2 retirement clause
softened to match what the owning contracts actually schedule; §6.1
scoped to outbound calls with an extractability lint, and §6.3 given
static companions for §4.1 and §4.3.
This contract binds three things: the operation→tool mapping itself
(§2–§3), the command envelope every mapped operation satisfies
(§4), and the process rule that keeps the mapping closed (§5). Domain
semantics stay with their owning contracts — hierarchy (contract 1,
`hierarchy-schema.md`), grants (contract 2, `rbac-grant-model.md`),
wizard (contract 3, `onboarding-wizard.md`), identity
(`identity-lifecycle.md`), kanban lifecycle (`native-kanban-sot.md`
§5 and Amendment A1), roll-up (contract 8), API artifact format
(contract 9).
## 1. Definitions
1. **Official tool**: a command implemented in the framework packages and
exposed through the Gateway API; the CLI remains the primary execution
method for the same command (D8). The webUI is a Gateway client only.
2. **Mapped operation**: a webUI operation with a named official path in
§2 or §3. Anything else the webUI wants to do is unmapped and follows
§5.
3. **Legacy non-substitute**: an existing endpoint that resembles a P1
need but is contractually barred from backing it (§3.2).
## 2. P0 mapping (current operations, ratified as-is)
This table is the complete measured P0 surface: every Gateway call the
web app's production sources make at this revision's head appears as a
row (independently re-measured at review; the three calls the first
measurement missed — mission reads, coordination status, and the
capability-gated `turn:send` emit — are rows below). The surface stays
bound to these paths:
| WebUI operation | Official path |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Register / log in / log out / OIDC callback | better-auth mount `/api/auth/*`; `GET /api/sso/providers` |
| List/show projects (legacy read) | `GET /api/projects`, `GET /api/projects/:id` |
| List tasks / task detail (legacy read) | `GET /api/tasks`, `GET /api/tasks/:id` — with the filtered legacy project/mission reads the same surfaces use |
| Mission list (legacy read) | `GET /api/missions` |
| Coordination status (legacy read) | `GET /api/coord/status` |
| Conversation CRUD/search/messages | `/api/conversations*` |
| Chat turn / stop / thinking / command execute+approve / streaming | `/chat` socket events `message`, `abort`, `set:thinking`, `command:execute`, `command:approve`; `turn:send` (capability-gated — emitted only when the server advertises the pi turn-runtime capability, which the current Gateway does not) |
| Harness/model selection | `GET /api/harnesses*`, `GET/PUT /api/chat/preferences/selection` |
| Preferences; provider inspect/test | `/api/memory/preferences`, `GET /api/providers`, `POST /api/providers/test` |
| Admin users / roles / ban / health | `/api/admin/users*`, `/api/admin/health` |
P0 rows inherit §4 obligations as their backing controllers are next
touched; they are not required to be retrofitted in one sweep.
## 3. P1 mapping (bound to the build-first tools)
1. Every P1 operation maps to exactly one build-first command family, in
the T10-ruled rank order:
| Rank | Command family (owning contract) | P1 webUI operations it backs |
| ---- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Hierarchy command family (contract 1 §5; grants attach per contract 2) | Company/estate/platform-project/workspace CRUD, parentage and reparenting, hierarchy reads; the wizard's initial-hierarchy step (contract 3 §3.4) |
| 2 | Hierarchy RBAC command/evaluator (contract 2) | Grant create/change/revoke at company/estate/platform-project; inherited evaluation down to workspace; authorization-safe hierarchy queries |
| 3 | Typed kanban command/query surface (SOT §5, Amendment A1) | Workspace task lifecycle (create/edit/cancel/archive/move), board rank, typed queries |
| 4 | Agent enrollment command | Enroll one agent: harness, credential reference/API-key intake (values never echoed), name/persona, assignment scope (contract 3 §3.5) |
| 5 | Authorized roll-up query (contract 8) | Read-only aggregated task counts/statuses at every hierarchy level over readable workspaces only |
| 6 | Onboarding orchestration (contract 3) | The re-runnable wizard flow, composing ranks 1 and 4 (its only grant write rides inside the rank-1 company-create command, contract 2 §4.3) |
2. **Legacy non-substitutes.** The following MUST NOT back any P1
operation, matching the audit findings: legacy `/api/projects` and
`/api/tasks` CRUD (planning-data records, not hierarchy nodes and not
the typed kanban boundary); `POST /api/workspaces` (filesystem
bootstrap, not audited hierarchy parentage); `/api/teams` reads (no
grants, no inheritance); `POST /api/bootstrap/setup` (one-shot
epoch transition, identity §3 — not the re-runnable wizard); the MCP
`brain_*` task mutations (legacy Brain writes, not the typed kanban
commands). These stay serving their existing P0/host consumers until
the owning contract (or a successor amendment) schedules each
retirement — no such migration is scheduled at this revision; the
freeze stands on its own.
3. New P1 mapping rows (operations this table does not list) are added by
amending this contract, not ad hoc (§5).
## 4. Command envelope (request / result / error / audit)
Binding on every mapped operation the build-first families expose:
1. **Typed request and result.** Each command and query has an explicit
request DTO and result DTO in the shared types package, validated at
the Gateway boundary; unvalidated pass-through and `any`-typed
payloads are non-conformant. Mutations on records with an
expected-version rule in their owning contract carry the expected
version in the request and fail on mismatch with the conflict error
class (SOT §3 invariant 11 and REQ-TASK-001's concurrent-update
conflict acceptance; hierarchy per contract 1).
2. **Error taxonomy.** Every error result carries a stable
machine-readable code from a closed per-family enum plus an HTTP
status mapping, distinguishing at minimum: validation failure,
authentication failure, authorization refusal, not-found, conflict
(version/uniqueness), precondition/state refusal (e.g. bootstrap
epoch, suspended team subjects), and internal fault. Where contract
2's no-existence-oracle rule applies, authorization refusal and
not-found are indistinguishable on the wire for unauthorized readers
— same code, same status, same shape.
3. **Audit linkage.** A mutating mapped operation emits exactly the
audit events its owning contract defines (contract 1 §5.2, contract 2
§4.4, identity §§24, SOT audit rules); the envelope contributes the
correlation: every request accepts/generates a correlation id,
carried into the audit events and returned in the result, so a UI
action is traceable end to end. The mapping layer itself adds no
second audit stream.
4. **Fail-closed.** A mapped operation that cannot evaluate its
authorization or reach its owning tool refuses (contract 2 §3.5); the
envelope never degrades to an unauthorized fallback read or a direct
data access.
5. **CLI parity.** Each build-first family is invocable through the
official CLI against the same Gateway commands with the same
request/result/error contracts. No webUI-only command exists; a
Gateway command without CLI exposure is a conformance gap tracked at
the family's implementing issue.
## 5. Closure rule (blocked on tooling)
1. A webUI change that needs an operation with no mapping row is
**blocked on tooling**: the backing tool is built and mapped first
(D8). Scoring a gap "blocked on tooling" is mandatory, not
discretionary; working around it in the UI (direct DB or filesystem
access, calling a legacy non-substitute, embedding domain logic in
the web app) is non-conformant.
2. The mapping is enforced closed by §6.1's inventory witness: the web
app's network surface must be a subset of the mapped paths.
## 6. Verification requirements
Binding on the implementing PRs:
1. **Network-surface inventory witness:** a CI assertion extracting the
web app's outbound Gateway calls — route literals at request call
sites and outbound socket emits in `apps/web` sources (inbound
handler registrations are not calls and are out of scope) — and
failing on any call outside the §2/§3 mapped paths. The inventory is
closed like contract 1 §6.3's allowlist: a new call fails until a
mapping row exists in the same PR. Dynamic route construction that
evades extraction is resolved toward the witness, enforced by an
extractability lint: every request call site takes a literal or
template-literal path, and a call site that does not fails the
assertion itself (the web-side analogue of contract 1's
raw-execution prong), never an exemption for the caller.
2. **Non-substitute witness:** the P1 surfaces (hierarchy, RBAC, kanban,
enrollment, roll-up, wizard UI) make zero calls to the §3.2 legacy
endpoints — asserted by the same inventory, scoped per surface.
3. **Envelope witnesses per family:** for each build-first family — a
request with an invalid DTO is refused with the validation code; a
version-mismatch mutation returns the conflict code; an unauthorized
read of an existing node and a read of a nonexistent node return
indistinguishable results where the no-existence-oracle rule applies;
a correlation id submitted on a mutation appears in its audit
event(s) and result. Two static companions: a type-level assertion
that the family's boundary accepts no `any`-typed or unvalidated
pass-through payload (§4.1), and a single-emitter assertion that the
mapped operation's audit events originate only from the owning
contract's audit emitter (§4.3's no-second-audit-stream, made
checkable).
4. **CLI-parity witness:** for each family, a CLI smoke invocation of at
least one command and one query against the Gateway succeeds with the
same typed result the web client receives.
5. **Fail-closed witness:** with the owning tool or grant state
unreachable (fault injection), the mapped operation returns the
internal-fault or authorization-refusal class and performs no
fallback read/write (extends contract 2 §7.6 to the mapping layer).
## Ruling request
Ratify sections 16 as written, with one decision embedded:
- Decision (§3.2): the legacy endpoints named there are **frozen for new
consumers** as of ratification — existing P0/host consumers keep
working, new UI or tool code may not call them, and each is retired by
the migration its owning contract schedules. Alternative if rejected:
allow P1 surfaces to reuse legacy endpoints as interim backends —
rejected by the audit's finding that they cannot satisfy the
hierarchy/kanban/RBAC contracts, so the interim would ship
non-conformant semantics.
@@ -1,9 +0,0 @@
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
+1 -8
View File
@@ -120,13 +120,6 @@
"when": 1784050648841,
"tag": "0016_salty_morlocks",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1787609223282,
"tag": "0017_accounts_issuer",
"breakpoints": true
}
]
}
}
+1 -10
View File
@@ -1,15 +1,6 @@
export { createDb, type Db, type DbHandle } from './client.js';
export { createPgliteDb } from './client-pglite.js';
export {
runMigrations,
runPgliteMigrations,
getMigrationStatus,
readJournalTags,
applyMigrationsByHash,
type HashLedgerDeps,
type MigrationPlanEntry,
type MigrationStatus,
} from './migrate.js';
export { runMigrations, runPgliteMigrations } from './migrate.js';
export * from './schema.js';
export * from './federation.js';
export {
-169
View File
@@ -1,169 +0,0 @@
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,75 +51,6 @@ 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.
+69 -222
View File
@@ -1,7 +1,8 @@
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';
@@ -20,243 +21,89 @@ 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 sqlClient = postgres(connectionString(url), { max: 1 });
const connectionString = url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
const sqlClient = postgres(connectionString, { max: 1 });
const db = drizzlePostgres(sqlClient);
try {
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);
},
});
// 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() });
} finally {
await sqlClient.end();
}
}
/**
* 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.
*/
// 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.
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 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);
},
});
}
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
)
`);
/* ------------------------------------------------------------------ */
/* Migration status (#1392: the installer must VERIFY, not assume) */
/* ------------------------------------------------------------------ */
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));
/** 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);
}
const migrations = readMigrationFiles({ migrationsFolder: migrationsFolder() });
for (const migration of migrations) {
if (applied.has(migration.hash)) continue;
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));
// 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 },
);
}
}
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();
await handle.db.execute(
sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (${migration.hash}, ${migration.folderMillis})`,
);
}
}
-6
View File
@@ -63,12 +63,6 @@ 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,14 +23,8 @@ 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,10 +49,6 @@ 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) |
@@ -115,7 +111,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. 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`.
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`.
### Code Review (Codex)
@@ -223,23 +219,6 @@ 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,17 +11,7 @@ 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.
#
# #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
ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated
[Service]
Type=oneshot
@@ -32,17 +32,6 @@ 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,54 +379,8 @@ 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.
@@ -1,131 +0,0 @@
#!/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"
@@ -0,0 +1,24 @@
# Fleet tools
Seat lifecycle tools for a Mosaic fleet. Paths are relative to
`packages/mosaic/framework/tools/fleet/` (deployed to `~/.config/mosaic/tools/fleet/`).
| Script | Purpose |
| ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `start-agent-session.sh` | launch, stop, or attach a roster-driven agent session (reads `<seat>.env.generated`, honours `MOSAIC_TMUX_SOCKET`) |
| `seat-logins.sh` | project seat tokens into `tea` logins named `<instance>-<seat>` (dry-run by default, `--apply`, `--adopt`) |
| `mint-seat-credential.sh` | create the Gitea account for a seat on every configured instance, mint a token, write the seat's credential slot, then project it into `tea` |
| `start-interaction-service.sh`, `print-interaction-effective-policy.sh`, `start-tmux-holder.sh` | operator interaction service and tmux holder |
## Onboarding a seat's credential
```
MOSAIC_ADMIN_SEAT=<admin-seat> MOSAIC_SEAT_EMAIL_DOMAIN=<domain> mint-seat-credential.sh <seat>
```
- The admin token is read from `$MOSAIC_BRAIN_HOME/fleet/agents/<admin-seat>/secrets/gitea-<instance>-<admin-seat>.token`. It is never printed.
- `MOSAIC_SEAT_EMAIL_DOMAIN` is required (no default): the framework ships no estate-specific domain.
- Instances default to the map shared with `seat-logins.sh`; `MOSAIC_GITEA_INSTANCES="a b"` limits the set and `MOSAIC_GITEA_URL_<INSTANCE>` overrides a server URL (hyphens in the instance name become underscores in the variable, as in `seat-logins.sh`).
- The seat slot is written from the mint response: `.token`, `.scopes` (what was granted), `.principal`, each mode 600.
- `tea` absent is a warning, not a failure: REST-path wrappers work with the token alone.
- Regression suite: `test-mint-seat-credential.sh` (hermetic, mock curl, no network).
@@ -0,0 +1,198 @@
#!/usr/bin/env bash
# mint-seat-credential.sh — create the Gitea account and mint a token for one seat,
# on every configured instance, writing the result into that seat's credential slot.
#
# mint-seat-credential.sh [--admin-seat <seat>] [--instances "<a> <b>"] <seat>
#
# Configuration (environment; flags win over environment):
# MOSAIC_ADMIN_SEAT seat whose admin token is used to call the Gitea
# admin API. Required. Its token is read from
# $MOSAIC_BRAIN_HOME/fleet/agents/<admin>/secrets/
# gitea-<instance>-<admin>.token. Never printed.
# MOSAIC_GITEA_INSTANCES space-separated instance names to mint on.
# Default: every instance in the map below.
# MOSAIC_GITEA_URL_<INSTANCE> server URL override per instance (same
# convention as seat-logins.sh).
# MOSAIC_SEAT_EMAIL_DOMAIN domain for the account email (<seat>@<domain>).
# Required, no default: the framework tree
# carries no estate-specific domain
# (framework-PR firewall; the instance host
# map stays per seat-logins.sh precedent).
# MOSAIC_BRAIN_HOME brain checkout; default ~/.mosaic.
#
# Exit codes: 0 minted and projected on every instance; 1 at least one instance
# failed (the others are untouched or complete); 3 usage error.
#
# WHY BASIC AUTH, WHICH LOOKS WRONG AT FIRST
# Gitea refuses token auth on POST /users/{user}/tokens by design, and the Sudo
# header and sudo query parameter are both rejected there (probed 2026-08-19, probe
# token deleted). So minting for another account needs a password: this script
# generates a random one, uses it once, and never stores or prints it. Agents
# authenticate by token; the password is not a credential anyone keeps.
#
# The .scopes file is written from the mint RESPONSE rather than from what was
# requested, so the record is what was granted rather than what was asked for.
#
# SECRETS NEVER TOUCH ARGV (#1343 class, rev-security-01 review 259): the admin
# token, the generated password, and the minted seat token all pass through
# 0600 curl --config / --data files — the landed in-tree standard
# (gitea_write_auth_config in detect-platform.sh). argv is world-readable via
# /proc/<pid>/cmdline for the life of each request, and a bash -x trace would
# print every secret otherwise. The staging files are unlinked after each use.
set -Eeuo pipefail
# Stage secrets into 0600 files; nothing secret reaches argv or a trace.
# write_auth_config <token> -> curl --config carrying the Authorization header
# (same shape as gitea_write_auth_config in
# detect-platform.sh, local so this script stays
# standalone under tools/fleet).
# write_user_config <u> <pw> -> curl --config with `user =` (covers -u).
# write_body <json> -> 0600 file for --data @file.
write_auth_config() {
local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-auth.XXXXXX") || return 1
printf 'header = "Authorization: token %s"\n' "$1" >"$f" || { rm -f "$f"; return 1; }
chmod 600 "$f"; printf '%s' "$f"
}
write_user_config() {
local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-user.XXXXXX") || return 1
printf 'user = "%s:%s"\n' "$1" "$2" >"$f" || { rm -f "$f"; return 1; }
chmod 600 "$f"; printf '%s' "$f"
}
write_body() {
local f; f=$(mktemp "${TMPDIR:-/tmp}/mosaic-mint-body.XXXXXX") || return 1
printf '%s' "$1" >"$f" || { rm -f "$f"; return 1; }
chmod 600 "$f"; printf '%s' "$f"
}
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BRAIN="${MOSAIC_BRAIN_HOME:-$HOME/.mosaic}"
ADMIN="${MOSAIC_ADMIN_SEAT:-}"
INSTANCES="${MOSAIC_GITEA_INSTANCES:-}"
EMAIL_DOMAIN="${MOSAIC_SEAT_EMAIL_DOMAIN:-}"
SEAT=""
usage() { sed -n '2,20p' "${BASH_SOURCE[0]}" >&2; exit 3; }
while [[ $# -gt 0 ]]; do
case "$1" in
--admin-seat) ADMIN="${2:-}"; shift 2 ;;
--instances) INSTANCES="${2:-}"; shift 2 ;;
-h|--help) usage ;;
-*) echo "mint: unknown flag: $1" >&2; exit 3 ;;
*) [[ -z "$SEAT" ]] || { echo "mint: one seat only" >&2; exit 3; }; SEAT="$1"; shift ;;
esac
done
[[ -n "$SEAT" ]] || usage
[[ "$SEAT" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "mint: bad seat name: $SEAT" >&2; exit 3; }
[[ -n "$ADMIN" ]] || { echo "mint: no admin seat. Set MOSAIC_ADMIN_SEAT or pass --admin-seat." >&2; exit 3; }
[[ "$ADMIN" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "mint: bad admin seat name: $ADMIN" >&2; exit 3; }
[[ -n "$EMAIL_DOMAIN" ]] || { echo "mint: no email domain. Set MOSAIC_SEAT_EMAIL_DOMAIN (the framework ships no estate default)." >&2; exit 3; }
# Instance -> server URL. Same map and override convention as seat-logins.sh:
# hyphens in instance names map to underscores in the override variable
# (MOSAIC_GITEA_URL_MY-INST is not a valid shell name; MY_INST is).
url_override_var() { printf 'MOSAIC_GITEA_URL_%s' "$(printf '%s' "$1" | tr '[:lower:]-' '[:upper:]_')"; }
declare -A INSTANCE_URL=(
[mosaicstack]="https://git.mosaicstack.dev"
[usc]="https://git.uscllc.com"
)
for inst in "${!INSTANCE_URL[@]}"; do
ov="$(url_override_var "$inst")"
[[ -n "${!ov:-}" ]] && INSTANCE_URL[$inst]="${!ov}"
done
[[ -n "$INSTANCES" ]] || INSTANCES="$(printf '%s\n' "${!INSTANCE_URL[@]}" | sort | tr '\n' ' ')"
SCOPES='["read:user","write:repository","write:issue","read:organization"]'
D="$BRAIN/fleet/agents/$SEAT/secrets"
mkdir -p "$D"; chmod 700 "$D"
rc=0
for KEY in $INSTANCES; do
ov="$(url_override_var "$KEY")"
BASE="${INSTANCE_URL[$KEY]:-${!ov:-}}"
[[ -n "$BASE" ]] || { echo " $KEY: no URL known for this instance (set $ov), skipped" >&2; rc=1; continue; }
ADMIN_TOKEN_FILE="$BRAIN/fleet/agents/$ADMIN/secrets/gitea-$KEY-$ADMIN.token"
[[ -r "$ADMIN_TOKEN_FILE" ]] || { echo " $KEY: no admin token for seat '$ADMIN' ($ADMIN_TOKEN_FILE), skipped" >&2; rc=1; continue; }
T="$(cat "$ADMIN_TOKEN_FILE")"
AUTH_CFG="$(write_auth_config "$T")"
PW="$(openssl rand -base64 33 | tr -d '\n/+=' | head -c 32)"
USER_CFG="$(write_user_config "$SEAT" "$PW")"
if curl -sf -o /dev/null --config "$AUTH_CFG" "$BASE/api/v1/users/$SEAT"; then
BODY="$(write_body "{\"login_name\":\"$SEAT\",\"source_id\":0,\"password\":\"$PW\",\"must_change_password\":false}")"
curl -s -o /dev/null -X PATCH -H "Content-Type: application/json" \
--config "$AUTH_CFG" --data "@$BODY" \
"$BASE/api/v1/admin/users/$SEAT"
rm -f "$BODY"; BODY=""
act="reset-pw"
else
BODY="$(write_body "{\"username\":\"$SEAT\",\"email\":\"$SEAT@$EMAIL_DOMAIN\",\"password\":\"$PW\",\"must_change_password\":false,\"full_name\":\"Mosaic fleet seat $SEAT\"}")"
curl -s -o /dev/null -X POST -H "Content-Type: application/json" \
--config "$AUTH_CFG" --data "@$BODY" \
"$BASE/api/v1/admin/users"
rm -f "$BODY"; BODY=""
act="create"
fi
tmp="$(mktemp)"; chmod 600 "$tmp"
MINT_BODY="$(write_body "{\"name\":\"mosaic-seat\",\"scopes\":$SCOPES}")"
code="$(curl -s -o "$tmp" -w '%{http_code}' -X POST -H "Content-Type: application/json" \
--config "$USER_CFG" --data "@$MINT_BODY" "$BASE/api/v1/users/$SEAT/tokens")"
rm -f "$MINT_BODY"; MINT_BODY=""
if [[ "$code" != "201" ]]; then
echo " $KEY: mint FAILED http=$code ($act)" >&2; rm -f "$tmp"; rc=1; PW=""; rm -f "$AUTH_CFG" "$USER_CFG"; continue
fi
python3 - "$tmp" "$D" "$KEY" "$SEAT" <<'PY'
import json,sys,pathlib
tmp,d,key,seat=sys.argv[1:5]
t=json.load(open(tmp))
p=pathlib.Path(d)
(p/f"gitea-{key}-{seat}.token").write_text(t["sha1"]+"\n")
(p/f"gitea-{key}-{seat}.scopes").write_text(json.dumps(t.get("scopes",[]))+"\n")
(p/f"gitea-{key}-{seat}.principal").write_text(seat+"\n")
for suf in ("token","scopes","principal"):
(p/f"gitea-{key}-{seat}.{suf}").chmod(0o600)
PY
rm -f "$tmp"; PW=""; rm -f "$AUTH_CFG" "$USER_CFG"
VERIFY_CFG="$(write_auth_config "$(cat "$D/gitea-$KEY-$SEAT.token")")"
login="$(curl -s --config "$VERIFY_CFG" "$BASE/api/v1/user" \
| python3 -c 'import json,sys;print(json.load(sys.stdin).get("login","ERR"))' 2>/dev/null || echo ERR)"
rm -f "$VERIFY_CFG"
if [[ "$login" == "$SEAT" ]]; then
echo " $KEY: $act, minted, GET /user -> $login"
else
echo " $KEY: minted but identity check returned '$login', expected '$SEAT'" >&2; rc=1
fi
done
# ── Project into tea ─────────────────────────────────────────────────────────
# A token in the secrets dir is only half a credential. tea 0.14.0 cannot read
# that store, it only uses logins already in its own config, so a seat minted
# but not projected holds a working token and no login. Minting and projecting
# are therefore ONE operation.
#
# --adopt is deliberately NOT passed. Adopting deletes an operator-made login,
# which is a human decision. A collision reports BLOCK and a nonzero rc instead.
#
# tea absent is not a minting failure. The REST-path wrappers still work with
# the token that was just written, so warn and carry on.
SEAT_LOGINS="$SCRIPT_DIR/seat-logins.sh"
if [[ "$rc" -eq 0 ]]; then
if command -v tea >/dev/null 2>&1; then
if "$SEAT_LOGINS" --apply --seat "$SEAT"; then
:
else
echo " projection FAILED: token is minted and valid, but no tea login exists for $SEAT." >&2
echo " tea-path wrappers will not act as this seat. Re-run:" >&2
echo " $SEAT_LOGINS --apply --seat $SEAT" >&2
rc=1
fi
else
echo " tea not on PATH: token minted, no login projected (REST-path wrappers still work)." >&2
fi
fi
exit $rc
@@ -304,17 +304,6 @@ 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
@@ -432,22 +421,9 @@ if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
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
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
# The tmux holder owns a named server. Explicitly clear the pane environment
# so server/session variables cannot cross the launch boundary; retain only
@@ -1,216 +0,0 @@
#!/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"
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# Hermetic regression for mint-seat-credential.sh: mock curl on PATH, sandboxed
# brain home, no tea, no network, no real credentials.
#
# Pins:
# M1 the seat slot is written from the mint RESPONSE (token, granted scopes,
# principal), each file mode 600, and the identity check passes.
# M2 the admin token is read from MOSAIC_ADMIN_SEAT's slot, never hardcoded;
# a missing admin token is reported per instance and exits nonzero.
# M3 MOSAIC_GITEA_INSTANCES limits which instances are touched, and the URL
# override MOSAIC_GITEA_URL_<INSTANCE> is honoured.
# M4 no admin seat configured is a usage error (rc=3), nothing written.
# M5 the admin token value never appears on stdout or stderr.
# M6 secrets never touch argv: no Authorization header, no -u user:pass, no
# inline --data JSON carrying the password, on any curl invocation; auth
# travels in --config files and bodies in --data @files (#1343 class,
# rev-security-01 review 259 blocker).
# M7 the scopes record discriminates: a requested-but-not-granted scope
# (write:issue) must be ABSENT from .scopes — the pin is on the RESPONSE,
# and a mutant writing the requested set fails here (both reviewers).
# M8 hyphenated instance names resolve their override through the underscore
# variable, matching seat-logins.sh (SF3).
# M9 MOSAIC_SEAT_EMAIL_DOMAIN is required: unset is a usage error (rc=3),
# nothing written (framework-PR firewall answer).
set -euo pipefail
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
SANDBOX="$WORK_ROOT/mint-seat-credential-test-$$"
MOCK_BIN="$SANDBOX/bin"; BRAIN="$SANDBOX/brain"; CALLS="$SANDBOX/calls.log"
cleanup() { rm -rf "$SANDBOX"; }
trap cleanup EXIT
fail() { echo "FAIL: $*"; exit 1; }
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET="$SCRIPT_DIR/mint-seat-credential.sh"
[ -f "$TARGET" ] || fail "mint-seat-credential.sh not found beside this test"
mkdir -p "$MOCK_BIN" "$BRAIN/fleet/agents/admin-seat/secrets" || fail "setup: sandbox"
: > "$CALLS"
ADMIN_TOKEN_VALUE="admin-token-value-sentinel-4491"
printf '%s\n' "$ADMIN_TOKEN_VALUE" > "$BRAIN/fleet/agents/admin-seat/secrets/gitea-alpha-admin-seat.token"
chmod 600 "$BRAIN/fleet/agents/admin-seat/secrets/gitea-alpha-admin-seat.token"
# A PATH with only the mock bin plus the system tools the script needs, and no tea.
SYS_BIN="$SANDBOX/sys"; mkdir -p "$SYS_BIN"
for t in bash sed cat mktemp openssl tr head python3 sort printf chmod mkdir rm dirname grep stat; do
p="$(command -v "$t" 2>/dev/null || true)"; [ -n "$p" ] && ln -s "$p" "$SYS_BIN/$t"
done
export PATH="$MOCK_BIN:$SYS_BIN" CALLS
export MOSAIC_BRAIN_HOME="$BRAIN"
export MOSAIC_GITEA_URL_ALPHA="https://alpha.example.test"
export MOSAIC_SEAT_EMAIL_DOMAIN="seats.example.test"
unset MOSAIC_ADMIN_SEAT MOSAIC_GITEA_INSTANCES
# --- mock curl: records method + URL + a REDACTED auth marker, answers minting --
cat > "$MOCK_BIN/curl" <<'EOF'
#!/bin/bash
method=GET; url=""; out=""; wcode=0; auth=""; body=""
while [ $# -gt 0 ]; do
case "$1" in
-X) method="$2"; shift 2 ;;
-o) out="$2"; shift 2 ;;
-w) wcode=1; shift 2 ;;
--config)
if grep -q 'Authorization: token' "$2" 2>/dev/null; then auth="${auth}token,"; fi
if grep -q '^user = ' "$2" 2>/dev/null; then auth="${auth}user,"; fi
shift 2 ;;
--data)
case "$2" in
@*) body="@file" ;;
*) body="inline" ;;
esac
shift 2 ;;
-H|-u) shift 2 ;;
http*) url="$1"; shift ;;
*) shift ;;
esac
done
printf '%s %s auth=%s body=%s\n' "$method" "$url" "${auth:-NONE}" "$body" >> "$CALLS"
emit() { if [ -n "$out" ]; then printf '%s' "$1" > "$out"; else printf '%s' "$1"; fi; }
case "$method $url" in
"GET "*/api/v1/users/newseat) exit 22 ;; # 404 under -f: account does not exist yet
"POST "*/api/v1/admin/users) emit '{}'; exit 0 ;;
"POST "*/api/v1/users/newseat/tokens) emit '{"id":9,"name":"mosaic-seat","sha1":"minted-token-7f3a","scopes":["read:user","write:repository"]}'
[ "$wcode" = 1 ] && printf '201'; exit 0 ;;
"GET "*/api/v1/user) emit '{"login":"newseat"}'; exit 0 ;;
*) emit '{}'; exit 0 ;;
esac
EOF
chmod +x "$MOCK_BIN/curl"
[ "$(command -v curl)" = "$MOCK_BIN/curl" ] || fail "setup: curl does not resolve to the mock"
command -v tea >/dev/null 2>&1 && fail "setup: tea must be absent from the sandbox PATH"
run() { bash "$TARGET" "$@" >"$SANDBOX/out" 2>"$SANDBOX/err"; echo $?; }
# M4: no admin seat configured.
rc=$(run newseat)
[ "$rc" = 3 ] || fail "M4: expected rc=3 without an admin seat, got $rc: $(cat "$SANDBOX/err")"
grep -q 'MOSAIC_ADMIN_SEAT' "$SANDBOX/err" || fail "M4: error does not name MOSAIC_ADMIN_SEAT"
[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M4: a token was written without an admin seat"
# M9: email domain is required, unset is a usage error, nothing written.
rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha MOSAIC_SEAT_EMAIL_DOMAIN= run newseat)
[ "$rc" = 3 ] || fail "M9: expected rc=3 with no email domain, got $rc: $(cat "$SANDBOX/err")"
grep -q 'MOSAIC_SEAT_EMAIL_DOMAIN' "$SANDBOX/err" || fail "M9: error does not name MOSAIC_SEAT_EMAIL_DOMAIN"
[ ! -s "$CALLS" ] || fail "M9: API called without an email domain"
[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M9: token written without an email domain"
# M1 + M3 + M5: mint on the single configured instance.
: > "$CALLS"
rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=alpha run newseat)
[ "$rc" = 0 ] || fail "M1: expected rc=0, got $rc: $(cat "$SANDBOX/err")"
SLOT="$BRAIN/fleet/agents/newseat/secrets"
[ "$(cat "$SLOT/gitea-alpha-newseat.token")" = "minted-token-7f3a" ] || fail "M1: token file not written from the mint response"
grep -q 'write:repository' "$SLOT/gitea-alpha-newseat.scopes" || fail "M1: scopes file not written from the response"
[ "$(cat "$SLOT/gitea-alpha-newseat.principal")" = "newseat" ] || fail "M1: principal file wrong"
for suf in token scopes principal; do
m=$(stat -c '%a' "$SLOT/gitea-alpha-newseat.$suf"); [ "$m" = 600 ] || fail "M1: $suf is mode $m, expected 600"
done
grep -q 'alpha: create, minted, GET /user -> newseat' "$SANDBOX/out" || fail "M1: success line missing: $(cat "$SANDBOX/out")"
grep -q 'https://alpha.example.test/api/v1/admin/users' "$CALLS" || fail "M3: URL override not honoured: $(cat "$CALLS")"
if grep -q 'usc\|mosaicstack' "$CALLS"; then fail "M3: an instance outside MOSAIC_GITEA_INSTANCES was touched: $(cat "$CALLS")"; fi
grep -q 'tea not on PATH' "$SANDBOX/err" || fail "tea-absent path should warn, not fail: $(cat "$SANDBOX/err")"
if grep -q "$ADMIN_TOKEN_VALUE" "$SANDBOX/out" "$SANDBOX/err" "$CALLS"; then fail "M5: admin token value leaked to output or call log"; fi
# M7: scopes pin discriminates — requested-but-not-granted scope is ABSENT.
if grep -q 'write:issue' "$SLOT/gitea-alpha-newseat.scopes"; then
fail "M7: write:issue appears in .scopes — the record is the REQUESTED set, not the response"
fi
# M6: no secret ever travels argv — every call authenticates via --config
# (token header or user= basic-auth directive) and bodies go as --data @file.
while IFS= read -r line; do
case "$line" in
*auth=NONE*) fail "M6: unauthenticated call: $line" ;;
*body=inline*) fail "M6: inline body (secret in argv risk): $line" ;;
esac
done < "$CALLS"
[ "$(grep -c 'auth=token' "$CALLS")" -eq 3 ] || fail "M6: expected exactly 3 token-auth calls (exists-check, admin write, verify), got: $(cat "$CALLS")"
grep -q 'auth=user' "$CALLS" || fail "M6: mint call did not use the user= directive: $(cat "$CALLS")"
# M8: hyphenated instance name resolves its override via the underscore variable.
printf '%s\n' "$ADMIN_TOKEN_VALUE" > "$BRAIN/fleet/agents/admin-seat/secrets/gitea-my-inst-admin-seat.token"
chmod 600 "$BRAIN/fleet/agents/admin-seat/secrets/gitea-my-inst-admin-seat.token"
export MOSAIC_GITEA_URL_MY_INST="https://myinst.example.test"
: > "$CALLS"; rm -rf "$BRAIN/fleet/agents/newseat"
rc=$(MOSAIC_ADMIN_SEAT=admin-seat MOSAIC_GITEA_INSTANCES=my-inst run newseat)
[ "$rc" = 0 ] || fail "M8: hyphenated instance mint failed rc=$rc: $(cat "$SANDBOX/err")"
grep -q 'https://myinst.example.test/api/v1/admin/users' "$CALLS" || fail "M8: hyphen override (MY_INST) not honoured: $(cat "$CALLS")"
unset MOSAIC_GITEA_URL_MY_INST
# M2: admin token missing for the instance is reported, rc=1, nothing written.
rm -rf "$BRAIN/fleet/agents/newseat"
: > "$CALLS"
rc=$(MOSAIC_ADMIN_SEAT=other-admin MOSAIC_GITEA_INSTANCES=alpha run newseat)
[ "$rc" = 1 ] || fail "M2: expected rc=1 with no admin token, got $rc"
grep -q "no admin token for seat 'other-admin'" "$SANDBOX/err" || fail "M2: missing-admin-token not reported: $(cat "$SANDBOX/err")"
[ ! -s "$CALLS" ] || fail "M2: API was called without an admin token: $(cat "$CALLS")"
[ ! -e "$BRAIN/fleet/agents/newseat/secrets/gitea-alpha-newseat.token" ] || fail "M2: token written without an admin token"
echo "mint-seat-credential regression harness 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] [--no-ci-expected]
# Usage: ci-queue-wait.sh [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
set -euo pipefail
@@ -14,11 +14,10 @@ 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] [--no-ci-expected]
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
Options:
-B, --branch BRANCH Branch head to inspect (default: current branch)
@@ -28,7 +27,6 @@ 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:
@@ -177,50 +175,6 @@ 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"
@@ -228,24 +182,6 @@ 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"
@@ -370,41 +306,6 @@ 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)
@@ -435,10 +336,6 @@ while [[ $# -gt 0 ]]; do
REQUIRE_STATUS=1
shift
;;
--no-ci-expected)
NO_CI_EXPECTED=1
shift
;;
-h|--help)
usage
exit 0
@@ -468,10 +365,6 @@ 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"
@@ -503,28 +396,6 @@ 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"
@@ -613,48 +484,6 @@ 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
;;
@@ -1,343 +0,0 @@
#!/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,47 +192,6 @@ 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,41 +19,6 @@ 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) || {
@@ -73,28 +38,14 @@ gitea_pr_create_api() {
echo "Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup." >&2
fi
# 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'
payload=$(TITLE="$TITLE" BODY="$BODY" HEAD_BRANCH="$HEAD_BRANCH" BASE_BRANCH="$BASE_BRANCH" python3 - <<'PY'
import json
import os
payload = {
"title": os.environ["TITLE"],
"head": os.environ["HEAD_BRANCH"],
"base": os.environ["API_BASE"],
"base": os.environ["BASE_BRANCH"] or "main",
}
body = os.environ.get("BODY", "")
if body:
@@ -121,7 +72,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: the forge repository's default branch)
-B, --base BRANCH Base branch to merge into (default: main/master)
-H, --head BRANCH Head branch with changes (default: current branch)
-l, --labels LABELS Comma-separated labels
-m, --milestone NAME Milestone name
@@ -198,52 +149,6 @@ 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
@@ -261,7 +166,7 @@ case "$PLATFORM" in
github)
CMD=(gh pr create --title "$TITLE")
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
[[ -n "$EFFECTIVE_BASE" ]] && CMD+=(--base "$EFFECTIVE_BASE")
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
@@ -286,7 +191,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 "$EFFECTIVE_BASE" ]] && CMD+=(--base "$EFFECTIVE_BASE")
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
# Handle labels for tea
+10 -56
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] [--no-ci-expected] [--co-author-trailers --escalate-to PRINCIPAL]
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--expect-head SHA] [--co-author-trailers --escalate-to PRINCIPAL]
set -euo pipefail
@@ -16,7 +16,6 @@ DRY_RUN=false
EXPECT_HEAD=""
CO_AUTHOR_TRAILERS=false
ESCALATE_TO=""
NO_CI_EXPECTED=false
usage() {
cat <<EOF
@@ -30,7 +29,6 @@ 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
@@ -72,10 +70,6 @@ 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
@@ -137,44 +131,9 @@ 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())')"
# 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
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
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
@@ -195,18 +154,13 @@ if [[ "$DRY_RUN" != true ]]; then
if [[ -z "$BASE_REPO" ]]; then
BASE_REPO="$(get_repo_owner)/$(get_repo_name)"
fi
guard_args=(
--purpose merge
-B "$HEAD_BRANCH"
-R "$BASE_REPO"
--sha "$HEAD_SHA"
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}"
"$SCRIPT_DIR/ci-queue-wait.sh" \
--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)
@@ -1,198 +0,0 @@
#!/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
}
@@ -1,323 +0,0 @@
#!/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,15 +100,6 @@ 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 '{}'
;;
@@ -1,616 +0,0 @@
#!/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,18 +67,7 @@ cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
# 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
printf '%s\n' '{"number":703}'
SH
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
@@ -1,178 +0,0 @@
#!/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"
@@ -1,84 +0,0 @@
#!/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"
@@ -1,333 +0,0 @@
#!/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,40 +515,12 @@ 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
@@ -558,15 +530,10 @@ 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:-}" ]; 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; }
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
fi
printf 'ok %s\n' "$why"
done < "$FIXTURES"
@@ -605,15 +572,10 @@ home_case() {
fail=1
return
fi
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; }
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
fi
printf 'ok %s\n' "$why"
}
@@ -703,15 +665,10 @@ lone_case() {
fail=1
return
fi
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; }
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
fi
printf 'ok %s [standalone]\n' "$why"
}
@@ -1,289 +0,0 @@
#!/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"
@@ -1,386 +0,0 @@
#!/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
+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-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"
"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"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -211,23 +211,12 @@ describe('mosaic fleet install — roster v2', (): void => {
describe('[email protected]', (): void => {
const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', '[email protected]');
/**
* 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[]> {
/** The single `ConditionPathExists=` value declared by the unit template. */
async function conditionPath(): Promise<string> {
const unit = await readFile(unitPath, 'utf8');
const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? [];
expect(matches.length).toBeGreaterThan(0);
return matches.map((line) => line.slice('ConditionPathExists='.length).trim());
expect(matches).toHaveLength(1);
return matches[0]!.slice('ConditionPathExists='.length).trim();
}
it('will not attempt a seat before the reconciler has written its env', async (): Promise<void> => {
@@ -235,26 +224,7 @@ 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.
//
// 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);
}
expect(await conditionPath()).toBe('%h/.config/mosaic/fleet/agents/%i.env.generated');
});
/**
@@ -276,18 +246,10 @@ 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 conditionPaths()).map((value) =>
value.replace(/^\|/, '').replace('%h', tempHome!).replace('%i', 'coder0'),
);
const rendered = (await conditionPath()).replace('%h', tempHome!).replace('%i', 'coder0');
// 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'),
]);
// The path an installed fleet actually places for this agent.
expect(rendered).toBe(join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'));
});
it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => {
+4 -10
View File
@@ -172,10 +172,9 @@ 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)')
.option('-e, --email <email>', 'Headless: account email (password read from stdin line 2)')
.action(async (cmdOpts: { gateway?: string; email?: string }) => {
.action(async (cmdOpts: { gateway?: string }) => {
const { runRecoverToken } = await import('./gateway/token-ops.js');
await runRecoverToken(cmdOpts.gateway, cmdOpts.email);
await runRecoverToken(cmdOpts.gateway);
});
// ─── logs ───────────────────────────────────────────────────────────────
@@ -203,14 +202,9 @@ export function registerGatewayCommand(program: Command): void {
gw.command('uninstall')
.description('Uninstall the gateway daemon and optionally remove data')
.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 }) => {
.action(async () => {
const { runUninstall } = await import('./gateway/uninstall.js');
await runUninstall(cmdOpts);
await runUninstall();
});
// ─── doctor ─────────────────────────────────────────────────────────────────
@@ -15,7 +15,6 @@
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 {
@@ -90,34 +89,12 @@ 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). 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;
// Post-install verification (CU-07-03) — non-fatal.
try {
const { runPostInstallVerification } = await import('./verify.js');
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);
await runPostInstallVerification(configResult.host, configResult.port);
} catch {
// Non-fatal — verification is a courtesy
}
} catch (err) {
// Stages normally return structured results for expected failures.
@@ -1,31 +0,0 @@
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' });
});
});
@@ -1,26 +0,0 @@
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,20 +16,11 @@ vi.mock('./daemon.js', () => ({
vi.mock('./login.js', () => ({
getGatewayUrl: vi.fn().mockReturnValue('http://localhost:14242'),
// promptLine/promptSecret are used by ensureSession on the TTY path; return fixed
// values so tests never block on stdin.
// promptLine/promptSecret are used by ensureSession; return fixed values so tests don't 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);
@@ -74,7 +65,7 @@ describe('ensureSession', () => {
expect(mockSignIn).not.toHaveBeenCalled();
});
it('resolves piped-stdin credentials and signs in when stored session is invalid', async () => {
it('prompts for 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]' };
@@ -85,7 +76,7 @@ describe('ensureSession', () => {
expect(mockSaveSession).toHaveBeenCalledWith(baseUrl, newAuth);
});
it('resolves piped-stdin credentials when no session exists', async () => {
it('prompts for credentials when no session exists', async () => {
mockLoadSession.mockReturnValueOnce(null);
const newAuth = { cookie: fakeCookie, userId: 'u2', email: '[email protected]' };
mockSignIn.mockResolvedValueOnce(newAuth);
@@ -93,10 +84,6 @@ 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 () => {
@@ -124,7 +111,7 @@ describe('runRecoverToken', () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('signs in via piped stdin, mints a token, and persists it when no session exists', async () => {
it('prompts for login, 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);
@@ -1,181 +0,0 @@
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);
}
});
});
@@ -1,110 +0,0 @@
/**
* 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,
};
}
}
@@ -1,101 +0,0 @@
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,7 +1,6 @@
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;
@@ -108,24 +107,8 @@ 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 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> {
export async function ensureSession(gatewayUrl: string): Promise<string> {
// Try the stored session first
const session = loadSession(gatewayUrl);
if (session) {
@@ -136,25 +119,10 @@ export async function ensureSession(
console.log(`No session found for ${gatewayUrl}. Please sign in.`);
}
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: ');
}
// 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: ');
const auth = await signIn(gatewayUrl, email, password).catch((err: unknown) => {
console.error(err instanceof Error ? err.message : String(err));
@@ -178,12 +146,11 @@ export async function runRotateToken(gatewayUrl?: string): Promise<void> {
}
/**
* `mosaic gateway config recover-token` signs in if no session exists.
* Passes the --email flag through to ensureSession (#1394 dual path).
* `mosaic gateway config recover-token` prompts for login if no session exists.
*/
export async function runRecoverToken(gatewayUrl?: string, email?: string): Promise<void> {
export async function runRecoverToken(gatewayUrl?: string): Promise<void> {
const url = getGatewayUrl(gatewayUrl);
const cookie = await ensureSession(url, { email });
const cookie = await ensureSession(url);
const label = `CLI recovery token (${new Date().toISOString().slice(0, 16).replace('T', ' ')})`;
const minted = await mintAdminToken(url, cookie, label);
persistToken(url, minted);
@@ -1,86 +0,0 @@
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,65 +8,30 @@ import {
uninstallGatewayPackage,
} from './daemon.js';
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 });
export async function runUninstall(): Promise<void> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
await doUninstall(rl as NonNullable<typeof rl>, opts, nonInteractive);
await doUninstall(rl);
} finally {
rl?.close();
rl.close();
}
}
function prompt(
rl: NonNullable<ReturnType<typeof createInterface>>,
question: string,
): Promise<string> {
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
return new Promise((resolve) => rl.question(question, resolve));
}
async function doUninstall(
rl: ReturnType<typeof createInterface>,
opts: UninstallOptions,
nonInteractive: boolean,
): Promise<void> {
async function doUninstall(rl: ReturnType<typeof createInterface>): Promise<void> {
const meta = readMeta();
if (!meta) {
console.log('Gateway is not installed.');
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;
}
const answer = await prompt(rl, 'Uninstall Mosaic Gateway? [y/N] ');
if (answer.toLowerCase() !== 'y') {
console.log('Aborted.');
return;
}
// Stop if running
@@ -80,20 +45,13 @@ async function doUninstall(
}
}
// 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) {
// Remove config/data
const removeData = await prompt(rl, `Remove all gateway data at ${GATEWAY_HOME}? [y/N] `);
if (removeData.toLowerCase() === 'y') {
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
+2 -33
View File
@@ -44,8 +44,6 @@ 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;
}
@@ -91,36 +89,7 @@ export async function runPostInstallVerification(
fail('bootstrap endpoint reach', 'Run: mosaic gateway status / mosaic gateway logs');
}
// ─── 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;
const allPassed = gatewayHealthy && adminTokenOnFile && bootstrapReachable;
if (!allPassed) {
console.log(
@@ -129,7 +98,7 @@ export async function runPostInstallVerification(
);
}
return { gatewayHealthy, adminTokenOnFile, bootstrapReachable, schemaMigrated, allPassed };
return { gatewayHealthy, adminTokenOnFile, bootstrapReachable, allPassed };
}
/**
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
chmodSync,
mkdtempSync,
@@ -16,7 +16,6 @@ import {
renderPeerReach,
readFleetCommsBlock,
resolveCommsBlock,
resolveFleetIdentity,
resolvePeerCommand,
renderToolsContractStatus,
} from './comms-onboarding.js';
@@ -62,68 +61,6 @@ 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,
@@ -556,11 +493,6 @@ 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 });
@@ -569,10 +501,7 @@ describe('readFleetCommsBlock — spawned-agent context', () => {
writeFileSync(helper, '#!/bin/sh\n');
chmodSync(helper, 0o755);
});
afterEach(() => {
vi.unstubAllEnvs();
rmSync(home, { recursive: true, force: true });
});
afterEach(() => 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');
@@ -635,27 +564,23 @@ describe('readFleetCommsBlock — spawned-agent context', () => {
},
],
[
'symlink escaping the install home',
'symlink',
() => {
const helper = join(home, 'tools', 'tmux', 'agent-send.sh');
rmSync(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);
writeFileSync(join(home, 'real-send.sh'), '#!/bin/sh\n');
symlinkSync(join(home, 'real-send.sh'), helper);
},
],
['non-executable', () => chmodSync(join(home, 'tools', 'tmux', 'agent-send.sh'), 0o644)],
])(
'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');
},
);
])('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');
});
it('does not rewrite the roster while resolving context', () => {
const path = join(home, 'fleet', 'roster.yaml');
@@ -678,11 +603,11 @@ describe('renderToolsContractStatus — non-mutating install drift', () => {
});
afterEach(() => rmSync(home, { recursive: true, force: true }));
it('names operator-verified recovery instead of a forbidden remedy when installed TOOLS.md is missing', () => {
it('uses the supported repair command when installed TOOLS.md is missing', () => {
const status = renderToolsContractStatus(home);
expect(status).toContain('authorized operator');
expect(status).not.toContain('--repair-tools'); // stack#1380 M5a
expect(status).toContain('mosaic update --repair-tools');
expect(status).not.toContain('--reseed');
expect(status).toContain('authorized operator');
});
it('reports stale preserved content without rewriting it', () => {
@@ -691,8 +616,8 @@ describe('renderToolsContractStatus — non-mutating install drift', () => {
writeFileSync(path, stale);
const status = renderToolsContractStatus(home);
expect(status).toContain('fleet-comms-contract: 1');
expect(status).toContain('authorized operator');
expect(status).not.toContain('--repair-tools'); // stack#1380 M5a
expect(status).toContain('digest-qualified backup');
expect(status).toContain('mosaic update --repair-tools');
expect(status).toContain('active context was not rewritten');
expect(readFileSync(path, 'utf8')).toBe(stale);
});
@@ -723,7 +648,7 @@ describe('renderToolsContractStatus — non-mutating install drift', () => {
expect(renderToolsContractStatus(home)).not.toBe('');
});
it('reads an installed TOOLS.md symlink whose validated target diverges (stack#1380 resolve-then-validate)', () => {
it('treats installed TOOLS.md symlinks as stale without following or rewriting them', () => {
const external = join(home, 'external-tools.md');
const externalContent = '# external\n<!-- fleet-comms-contract: 1 -->\n';
writeFileSync(external, externalContent);
@@ -731,23 +656,24 @@ describe('renderToolsContractStatus — non-mutating install drift', () => {
const status = renderToolsContractStatus(home);
expect(status).toContain('does not byte-match');
expect(status).toContain('unavailable');
expect(status).toContain('mosaic update --repair-tools');
expect(readFileSync(external, 'utf8')).toBe(externalContent);
});
it('treats a source TOOLS.md symlink escaping the install home as unavailable', () => {
const outside = mkdtempSync(join(tmpdir(), 'mosaic-source-outside-'));
it('treats source TOOLS.md symlinks as unavailable without following them', () => {
const external = join(home, 'external-source.md');
const content = '# authoritative tools\n<!-- fleet-comms-contract: 1 -->\n';
writeFileSync(join(outside, 'external-source.md'), content);
writeFileSync(external, content);
rmSync(join(home, 'defaults', 'TOOLS.md'));
symlinkSync(join(outside, 'external-source.md'), join(home, 'defaults', 'TOOLS.md'));
symlinkSync(external, 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(join(outside, 'external-source.md'), 'utf8')).toBe(content);
expect(readFileSync(external, 'utf8')).toBe(content);
});
it('accepts byte-equal bounded source and installed contracts', () => {
@@ -804,91 +730,3 @@ 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'),
);
});
});
+8 -66
View File
@@ -9,9 +9,8 @@
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { homedir, hostname } from 'node:os';
import { join, resolve } from 'node:path';
import { join } from 'node:path';
import { readRegularFileSecure } from './secure-file.js';
import { resolveBrainHome } from './brain-home.js';
import {
parseFleetRosterV1,
resolveInstalledFleetRosterPath,
@@ -261,13 +260,7 @@ 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,
// 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)],
});
readRegularFileSecure(path, { root: mosaicHome, executable: true });
return undefined;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
@@ -275,48 +268,8 @@ 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 {
// 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.`;
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.`;
}
export function resolveFleetIdentity(
@@ -325,15 +278,9 @@ export function resolveFleetIdentity(
localHost: string = shortHostname(),
): FleetIdentityResult {
if (!requestedName) return { ok: true };
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)];
const agentSendPath = join(mosaicHome, 'tools', 'tmux', 'agent-send.sh');
const helperError = validateAgentSendHelper(agentSendPath, mosaicHome);
if (helperError) return { ok: false, error: helperFailureGuidance(helperError) };
let rosterPath: string;
try {
@@ -354,10 +301,7 @@ export function resolveFleetIdentity(
let roster: FleetRoster;
try {
roster = parseFleetRosterV1(
readRegularFileSecure(rosterPath, {
root: mosaicHome,
symlinkTargetRoots: rosterSymlinkRoots,
}).content.toString('utf8'),
readRegularFileSecure(rosterPath, { root: mosaicHome }).content.toString('utf8'),
rosterPath.endsWith('.json') ? 'json' : 'yaml',
);
} catch (error) {
@@ -455,9 +399,7 @@ function boundedContractDigest(
}
function replacementGuidance(): string {
// 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.`;
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.`;
}
/** Detect preserved installed TOOLS.md drift without changing it. */
+1 -65
View File
@@ -7,7 +7,6 @@ import { canonicalizeRoleClass } from '../commands/fleet-personas.js';
interface RawFleetRoster {
version?: unknown;
transport?: unknown;
generation?: unknown;
tmux?: {
socket_name?: unknown;
socketName?: unknown;
@@ -42,10 +41,6 @@ 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;
@@ -221,17 +216,7 @@ 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', [
@@ -246,8 +231,6 @@ 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) {
@@ -260,9 +243,7 @@ function normalizeFleetRosterV1Unchecked(raw: RawFleetRoster): FleetRoster {
]);
}
}
if (raw.version !== 1 && raw.version !== 2) {
throw new Error('Fleet roster version must be 1 or 2.');
}
if (raw.version !== 1) throw new Error('Fleet roster version must be 1.');
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.');
@@ -337,52 +318,7 @@ 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,
+11 -81
View File
@@ -10,14 +10,12 @@ 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, resolve } from 'node:path';
import { join } from 'node:path';
interface FilesystemRaceState {
afterLstat?: (path: string) => void;
afterOpen?: (path: string) => void;
afterStat?: (path: string, stats: Stats) => Stats;
}
const filesystemRaceState = vi.hoisted<FilesystemRaceState>(() => ({}));
@@ -31,10 +29,6 @@ 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));
@@ -52,13 +46,11 @@ 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 });
});
@@ -68,87 +60,25 @@ describe('secure file reads', () => {
);
});
// 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', () => {
it('rejects a symlink in a file ancestor', () => {
const external = join(root, 'external');
mkdirSync(external);
writeFileSync(join(external, 'file'), 'external\n');
symlinkSync(external, join(root, 'linked'));
const snapshot = readRegularFileSecure(join(root, 'linked', 'file'), { root });
expect(snapshot.content.toString('utf8')).toBe('external\n');
});
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'));
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/,
expect(() => readRegularFileSecure(join(root, 'linked', 'file'), { root })).toThrow(
'path ancestor is a symbolic link',
);
});
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'));
it('rejects a symlink target', () => {
const external = join(root, 'external');
writeFileSync(external, 'external\n');
symlinkSync(external, join(root, 'linked-file'));
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;
}
expect(() => readRegularFileSecure(join(root, 'linked-file'), { root })).toThrow(
'file is a symbolic link',
);
});
it('keeps ancestor traversal bound when an opened directory is substituted', () => {
+26 -100
View File
@@ -7,25 +7,14 @@ import {
mkdirSync,
openSync,
readFileSync,
readlinkSync,
statSync,
} from 'node:fs';
import { platform } from 'node:os';
import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
import { 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 {
@@ -99,57 +88,7 @@ function openDirectoryChain(absoluteDirectory: string): { fd: number; descriptor
}
}
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[] } {
function openFileBeneathRoot(root: string, target: string): { fd: number; descriptors: number[] } {
const canonicalRoot = resolve(root);
const canonicalTarget = resolve(target);
assertCanonicalContainment(canonicalRoot, canonicalTarget);
@@ -157,52 +96,39 @@ function openFileBeneathRoot(
const fileName = components.pop();
if (fileName === undefined) throw new Error('managed file path names the managed root');
// 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));
const rootChain = openDirectoryChain(canonicalRoot);
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(chain.fd, basename(realTarget)),
procDescriptorPath(parentFd, fileName),
constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW,
);
} catch (error) {
throw secureFilesystemError('file is a symbolic link or unavailable', error);
}
chain.descriptors.push(fd);
return { fd, descriptors: chain.descriptors };
rootChain.descriptors.push(fd);
return { fd, descriptors: rootChain.descriptors };
} catch (error) {
closeDescriptors(chain.descriptors);
closeDescriptors(rootChain.descriptors);
if (error instanceof Error) throw error;
throw new Error('secure managed file open failed');
}
@@ -277,7 +203,7 @@ export function readRegularFileSecure(
path: string,
options: SecureFileReadOptions,
): SecureFileSnapshot {
const openedFile = openFileBeneathRoot(options.root, path, options.symlinkTargetRoots ?? []);
const openedFile = openFileBeneathRoot(options.root, path);
try {
const opened = fstatSync(openedFile.fd);
if (!opened.isFile()) throw new Error('managed file is not a regular file');
@@ -168,9 +168,7 @@ describe('repairFleetCommsTools', () => {
const result = repairFleetCommsTools(framework, home);
expect(result).toMatchObject({ ok: false, changed: false });
// 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(result.reason).toContain('symbolic link');
expect(readFileSync(target, 'utf8')).toBe('do not touch\n');
expect(lstatSync(join(home, 'tools', 'tmux', 'agent-send.sh')).isSymbolicLink()).toBe(true);
});
@@ -224,10 +222,7 @@ describe('repairFleetCommsTools', () => {
const result = repairFleetCommsTools(framework, targetHome);
expect(result, testCase.name).toMatchObject({ ok: false, changed: false });
// 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(result.reason, testCase.name).toContain('symbolic link');
expect(readdirSync(external), testCase.name).toEqual([]);
}
});
+1
View File
@@ -63,6 +63,7 @@ export const STAGES = [
'bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh',
'bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh',
'bash packages/mosaic/framework/tools/git/test-issue-view-comments.sh',
'bash packages/mosaic/framework/tools/fleet/test-mint-seat-credential.sh',
'bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh',
'bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh',
],