Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53e0fe912e | ||
|
|
836ec3cb1d |
+1
-8
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -372,87 +372,3 @@ The P0–P3 canon does not authorize:
|
||||
## 7. Global release evidence
|
||||
|
||||
P0–P3 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 1–7, 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 3–4), 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).
|
||||
|
||||
@@ -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 F1–F5): 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 §§2–4, 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 1–6 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
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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://full-width.invalid/o/r.git" "https://mosaic-stack.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 0). 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
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user