Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e31935a28 | ||
|
|
b2d40dada0 | ||
|
|
d30a4cce00 | ||
|
|
4cd280e48d | ||
|
|
8738a03893 | ||
|
|
04a01be992 | ||
|
|
812e2df1da | ||
|
|
8c292fb32f | ||
|
|
f45928c311 | ||
|
|
4d24ae8618 | ||
|
|
d790572e2e |
+175
-7
@@ -202,6 +202,20 @@ steps:
|
||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
||||
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
|
||||
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
|
||||
# #1404: snapshot every publishable manifest BEFORE the transform so the
|
||||
# workspace can be restored byte-exact after publish. The transform
|
||||
# rewrites package.json in place (needed: pnpm publish reads the
|
||||
# workspace manifests); without restore, later steps in this pipeline
|
||||
# (build-gateway kaniko COPY + pnpm install --frozen-lockfile) see
|
||||
# manifests that no longer match pnpm-lock.yaml and fail
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE. Snapshot dir is step-local tmp.
|
||||
SNAPSHOT_DIR="$(mktemp -d /tmp/publish-next-manifests.XXXXXX)"
|
||||
export SNAPSHOT_DIR
|
||||
find apps packages plugins -name package.json -not -path "*/node_modules/*" -not -path "*/dist/*" | while read -r mf; do
|
||||
mkdir -p "$SNAPSHOT_DIR/$(dirname "$mf")"
|
||||
cp -p "$mf" "$SNAPSHOT_DIR/$mf"
|
||||
done
|
||||
echo "[publish-next] snapshotted $(find "$SNAPSHOT_DIR" -name package.json | wc -l) manifests to $SNAPSHOT_DIR"
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
@@ -209,23 +223,38 @@ steps:
|
||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||
const roots = ['apps', 'packages', 'plugins'];
|
||||
const updated = [];
|
||||
const exactVersions = new Map(); // name -> bumped next version
|
||||
|
||||
function walk(dir) {
|
||||
function walk(dir, visit) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const packagePath = path.join(fullPath, 'package.json');
|
||||
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
||||
walk(fullPath);
|
||||
if (fs.existsSync(packagePath)) {
|
||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (manifest.name?.startsWith('@mosaicstack/') && !manifest.private) {
|
||||
visit(manifest, packagePath);
|
||||
}
|
||||
}
|
||||
walk(fullPath, visit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updatePackage(packagePath) {
|
||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
||||
// #1389: two passes. Pass 1 bumps every publishable manifest to
|
||||
// <stable+1>-next.<pipeline> exactly as before, recording name ->
|
||||
// bumped version. Pass 2 rewrites every published manifest's
|
||||
// @mosaicstack/* dependency entries (dependencies, devDependencies,
|
||||
// peerDependencies, optionalDependencies) to the EXACT same-pipeline
|
||||
// build. A caret range like ^0.0.3-next.2636 leaves the resolver free
|
||||
// to pick any later build — and on a host with a stale cache, an
|
||||
// installer-side scaffold pinned at stable, or a registry hiccup, that
|
||||
// freedom is how a "next" install ends up executing stable-era code
|
||||
// (web1 evidence: old tier validator, missing migrations). Exact pins
|
||||
// make the defect class unrepresentable regardless of resolver path.
|
||||
function bump(manifest, packagePath) {
|
||||
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
||||
if (!stableMatch) {
|
||||
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
||||
@@ -234,13 +263,40 @@ steps:
|
||||
const oldVersion = manifest.version;
|
||||
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
||||
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
exactVersions.set(manifest.name, manifest.version);
|
||||
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
||||
}
|
||||
|
||||
for (const root of roots) walk(root);
|
||||
const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
||||
let pinnedEntries = 0;
|
||||
function pin(manifest, packagePath) {
|
||||
let changed = false;
|
||||
for (const field of DEP_FIELDS) {
|
||||
const deps = manifest[field];
|
||||
if (!deps || typeof deps !== 'object') continue;
|
||||
for (const [name, range] of Object.entries(deps)) {
|
||||
if (!name.startsWith('@mosaicstack/')) continue;
|
||||
const exact = exactVersions.get(name);
|
||||
if (!exact) {
|
||||
throw new Error(
|
||||
manifest.name + ' depends on ' + name +
|
||||
' which has no bumped version in this publish set — cannot pin');
|
||||
}
|
||||
if (range === exact) continue;
|
||||
deps[name] = exact;
|
||||
pinnedEntries++;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
}
|
||||
|
||||
for (const root of roots) walk(root, bump);
|
||||
for (const root of roots) walk(root, pin);
|
||||
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
||||
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
||||
for (const line of updated) console.log('[publish-next] ' + line);
|
||||
console.log('[publish-next] pinned ' + pinnedEntries + ' @mosaicstack/* dep entries to exact same-pipeline versions across ' + updated.length + ' manifests');
|
||||
NODE
|
||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
||||
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
||||
@@ -250,6 +306,94 @@ steps:
|
||||
exit 1
|
||||
fi
|
||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||
# #1389 post-publish guard: every freshly published manifest must carry
|
||||
# EXACT same-pipeline @mosaicstack/* dep pins (no ranges, no stable
|
||||
# fallback). A leak here fails the pipeline instead of shipping.
|
||||
node <<'GUARD'
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||
const registry = 'https://git.mosaicstack.dev/api/packages/mosaicstack/npm/';
|
||||
const roots = ['apps', 'packages', 'plugins'];
|
||||
const published = [];
|
||||
function walk(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const packagePath = path.join(fullPath, 'package.json');
|
||||
if (fs.existsSync(packagePath)) {
|
||||
const m = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||
if (m.name?.startsWith('@mosaicstack/') && !m.private) published.push(m.name);
|
||||
}
|
||||
walk(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const root of roots) walk(root);
|
||||
let failures = 0;
|
||||
for (const name of published) {
|
||||
let manifest;
|
||||
try {
|
||||
const out = execFileSync('npm', ['view', name + '@next', '--json', '--registry', registry],
|
||||
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
||||
const arr = JSON.parse(out);
|
||||
manifest = Array.isArray(arr) ? arr[arr.length - 1] : arr;
|
||||
} catch (e) {
|
||||
console.error('[publish-next-guard] FAIL ' + name + ': npm view failed: ' + e.message);
|
||||
failures++;
|
||||
continue;
|
||||
}
|
||||
const fields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
||||
for (const field of fields) {
|
||||
const deps = manifest[field];
|
||||
if (!deps || typeof deps !== 'object') continue;
|
||||
for (const [dep, range] of Object.entries(deps)) {
|
||||
if (!dep.startsWith('@mosaicstack/')) continue;
|
||||
const expected = dep === name ? manifest.version : null;
|
||||
const isExactPin = /^\d+\.\d+\.\d+-next\./.test(range);
|
||||
const samePipeline = range.endsWith('-next.' + pipelineNumber);
|
||||
if (!isExactPin) {
|
||||
console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' range "' + range + '" is not an exact -next pin (stable-leak class, #1389)');
|
||||
failures++;
|
||||
} else if (!samePipeline) {
|
||||
console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' pinned "' + range + '" but this pipeline published -next.' + pipelineNumber + ' (cross-pipeline pin)');
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failures > 0) {
|
||||
console.error('[publish-next-guard] FATAL: ' + failures + ' dep-pin violation(s) — stable-dep leak into next publish (#1389)');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('[publish-next-guard] OK: all ' + published.length + ' published manifests carry exact same-pipeline @mosaicstack/* dep pins');
|
||||
GUARD
|
||||
# #1404 restore: put the workspace manifests back byte-exact so later
|
||||
# steps (build-gateway frozen-lockfile install) see the committed tree.
|
||||
RESTORE_FAIL=0
|
||||
while read -r mf; do
|
||||
if [ -f "$SNAPSHOT_DIR/$mf" ]; then
|
||||
cp -p "$SNAPSHOT_DIR/$mf" "$mf"
|
||||
else
|
||||
echo "[publish-next] FATAL: no snapshot for $mf — cannot restore (snapshot incomplete?)" >&2
|
||||
RESTORE_FAIL=1
|
||||
fi
|
||||
done < <(find apps packages plugins -name package.json -not -path "*/node_modules/*" -not -path "*/dist/*")
|
||||
# Pristine guard (#1404 red-first control): the publish step must leave
|
||||
# the workspace byte-identical to the checkout for every manifest.
|
||||
# git diff is the arbiter — any residual mutation fails THIS step
|
||||
# instead of surfacing as ERR_PNPM_OUTDATED_LOCKFILE in build-gateway.
|
||||
if ! git diff --exit-code -- '**/package.json' >/dev/null 2>&1; then
|
||||
echo "[publish-next] FATAL: workspace package.json files still differ from HEAD after restore (#1404 class)" >&2
|
||||
git diff --stat -- '**/package.json' >&2 || true
|
||||
RESTORE_FAIL=1
|
||||
fi
|
||||
rm -rf "$SNAPSHOT_DIR"
|
||||
if [ "$RESTORE_FAIL" -ne 0 ]; then exit 1; fi
|
||||
echo "[publish-next] workspace manifests restored byte-exact (git diff clean); later steps see the committed tree"
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
@@ -305,6 +449,14 @@ 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
|
||||
@@ -332,6 +484,14 @@ 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
|
||||
@@ -359,3 +519,11 @@ steps:
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
# #1411: publish-next-npm mutates workspace manifests in place during
|
||||
# its transform window and restores them at step end. Any step that
|
||||
# reads the pipeline workspace (kaniko COPY of manifests, later
|
||||
# installs) must run AFTER publish-next-npm, never concurrently —
|
||||
# pipeline 2648 raced a COPY inside the window and failed
|
||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
||||
# serialization invariant; add it to every new workspace consumer.
|
||||
- publish-next-npm
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// The module under test imports @mosaicstack/db at module scope; we replace only the
|
||||
// pieces DatabaseModule uses (partial mock — the real module also exports the
|
||||
// schema the storage adapter's import chain needs) so the test pins the #1392
|
||||
// contract (refuse to start on an incomplete schema) without a live database.
|
||||
vi.mock('@mosaicstack/db', async (importOriginal) => {
|
||||
const actual: object = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
createDb: vi.fn(),
|
||||
createPgliteDb: vi.fn(),
|
||||
getMigrationStatus: vi.fn(),
|
||||
runPgliteMigrations: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { DatabaseModule } from './database.module.js';
|
||||
import { getMigrationStatus } from '@mosaicstack/db';
|
||||
import type { DbHandle } from '@mosaicstack/db';
|
||||
import type { StorageAdapter } from '@mosaicstack/storage';
|
||||
import type { MosaicConfig } from '@mosaicstack/config';
|
||||
|
||||
function makeModule(storageType: 'postgres' | 'pglite', tier: string) {
|
||||
const storageAdapter = {
|
||||
name: storageType,
|
||||
migrate: vi.fn(),
|
||||
close: vi.fn(),
|
||||
} as unknown as StorageAdapter;
|
||||
const handle = { close: vi.fn() } as unknown as DbHandle;
|
||||
const config = {
|
||||
tier,
|
||||
storage: { type: storageType, url: 'postgresql://x' },
|
||||
} as unknown as MosaicConfig;
|
||||
return {
|
||||
mod: new DatabaseModule(handle, storageAdapter, config),
|
||||
storageAdapter,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DatabaseModule.onModuleInit — #1392 schema verification', () => {
|
||||
it('refuses to start when the postgres schema is incomplete', async () => {
|
||||
const { mod, storageAdapter } = makeModule('postgres', 'standalone');
|
||||
vi.mocked(getMigrationStatus).mockResolvedValue({
|
||||
appliedCount: 15,
|
||||
expectedCount: 17,
|
||||
expectedLastTag: '0016_salty_morlocks',
|
||||
complete: false,
|
||||
});
|
||||
await expect(mod.onModuleInit()).rejects.toThrow('Database schema incomplete: 15/17');
|
||||
expect(storageAdapter.migrate).toHaveBeenCalled(); // migrations attempted first
|
||||
});
|
||||
|
||||
it('starts normally when the schema is complete', async () => {
|
||||
const { mod } = makeModule('postgres', 'standalone');
|
||||
vi.mocked(getMigrationStatus).mockResolvedValue({
|
||||
appliedCount: 17,
|
||||
expectedCount: 17,
|
||||
expectedLastTag: '0016_salty_morlocks',
|
||||
complete: true,
|
||||
});
|
||||
await expect(mod.onModuleInit()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not verify postgres status for the local tier (PGlite migrates itself)', async () => {
|
||||
const { mod } = makeModule('pglite', 'local');
|
||||
vi.mocked(getMigrationStatus).mockClear();
|
||||
await expect(mod.onModuleInit()).resolves.toBeUndefined();
|
||||
expect(getMigrationStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
createDb,
|
||||
createPgliteDb,
|
||||
getMigrationStatus,
|
||||
runPgliteMigrations,
|
||||
type Db,
|
||||
type DbHandle,
|
||||
@@ -74,6 +75,11 @@ export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
|
||||
// the same DATABASE_URL, so a single call covers both the gateway DB and
|
||||
// the storage tables. We deliberately do NOT call runMigrations() here to
|
||||
// avoid opening a second short-lived connection and doubling startup cost.
|
||||
//
|
||||
// #1392: we DO verify afterwards (getMigrationStatus opens one short-lived
|
||||
// connection) and refuse to start on an incomplete schema. A gateway that
|
||||
// boots "healthy" on an empty or partial database is precisely the failure
|
||||
// that shipped in the T63 batch: silent at startup, catastrophic later.
|
||||
async onModuleInit(): Promise<void> {
|
||||
if (this.config.tier === 'local') {
|
||||
this.logger.log('Applying PGlite schema migrations...');
|
||||
@@ -81,6 +87,24 @@ export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
|
||||
}
|
||||
this.logger.log(`Initializing storage adapter (${this.storageAdapter.name})...`);
|
||||
await this.storageAdapter.migrate();
|
||||
|
||||
if (this.config.storage.type === 'postgres') {
|
||||
const status = await getMigrationStatus(this.config.storage.url);
|
||||
if (!status.complete) {
|
||||
this.logger.error(
|
||||
`Database schema incomplete: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied ` +
|
||||
`(last expected: ${status.expectedLastTag}). ` +
|
||||
'Refusing to start on a partial schema — see issues #1392/#1402. ' +
|
||||
"Remediation: re-run 'mosaic gateway install' (it now verifies), or apply migrations manually.",
|
||||
);
|
||||
throw new Error(
|
||||
`Database schema incomplete: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied`,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
`Database schema verified: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
|
||||
@@ -14,10 +14,16 @@ import { mountMcpHandler } from './mcp/mcp.controller.js';
|
||||
import { McpService } from './mcp/mcp.service.js';
|
||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||
import { resolveGatewayConfigPath } from './env.js';
|
||||
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
// Fail loud BEFORE anything else if the global ValidationPipe cannot see
|
||||
// the guarded DTOs' decorated properties (#1391): a broken metatype turns
|
||||
// every request body into a 400 at first use; this surfaces it at boot.
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
|
||||
if (!process.env['BETTER_AUTH_SECRET']) {
|
||||
throw new Error('BETTER_AUTH_SECRET is required');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Boot-time ValidationPipe metatype self-check (#1391).
|
||||
*
|
||||
* The check exists to fail loud at boot when the global pipe cannot see a
|
||||
* guarded DTO's decorated properties — the #436 class-erasure signature and
|
||||
* its dependency-graph cousins. Red/green arms:
|
||||
*
|
||||
* GREEN real module state: BootstrapSetupDto's three properties are
|
||||
* decorated and visible through the globalThis-shared storage.
|
||||
* RED a control class with NO decorators (the erasure shape): the
|
||||
* check throws PipeMetatypeCheckError naming every property.
|
||||
* RED-2 a control where one property is decorated and two are not: the
|
||||
* error names exactly the missing two — the miss list is precise,
|
||||
* not a blanket failure.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { IsString } from 'class-validator';
|
||||
import {
|
||||
assertValidationPipeSeesDtoDecorators,
|
||||
PipeMetatypeCheckError,
|
||||
} from './validation-pipe-check.js';
|
||||
|
||||
describe('assertValidationPipeSeesDtoDecorators (#1391 boot check)', () => {
|
||||
it('GREEN: passes on real module state (decorated DTO visible to the pipe)', () => {
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
|
||||
});
|
||||
|
||||
it('RED control: a class whose properties lost their decorators throws, naming them', async () => {
|
||||
// Simulate metatype erasure: an undecorated class standing where a
|
||||
// decorated DTO should be. Redefine the guard table for the test by
|
||||
// importing the module and pointing its table at the eroded class —
|
||||
// the check reads the table at call time, so a fresh module instance
|
||||
// with a swapped table reproduces the boot failure deterministically.
|
||||
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
|
||||
|
||||
class ErodedDto {
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const original = PIPE_GUARDED_DTOS[0];
|
||||
expect(original).toBeDefined();
|
||||
// Swap in the eroded target (same declared properties, zero decorators).
|
||||
(
|
||||
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
|
||||
).splice(0, PIPE_GUARDED_DTOS.length, {
|
||||
name: 'ErodedDto',
|
||||
target: ErodedDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).toThrow(PipeMetatypeCheckError);
|
||||
try {
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
expect(message).toContain('ErodedDto.name');
|
||||
expect(message).toContain('ErodedDto.email');
|
||||
expect(message).toContain('ErodedDto.password');
|
||||
}
|
||||
} finally {
|
||||
// Restore real module state for any later test in this file.
|
||||
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
|
||||
}
|
||||
// And confirm the restore is real.
|
||||
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
|
||||
});
|
||||
|
||||
it('RED-2 control: a partially decorated class names exactly the missing properties', async () => {
|
||||
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
|
||||
|
||||
class HalfErodedDto {
|
||||
@IsString()
|
||||
name?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
const original = PIPE_GUARDED_DTOS[0];
|
||||
(
|
||||
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
|
||||
).splice(0, PIPE_GUARDED_DTOS.length, {
|
||||
name: 'HalfErodedDto',
|
||||
target: HalfErodedDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
});
|
||||
|
||||
try {
|
||||
try {
|
||||
assertValidationPipeSeesDtoDecorators();
|
||||
expect.unreachable('partially decorated DTO must fail the boot check');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
expect(message).toContain('HalfErodedDto.email');
|
||||
expect(message).toContain('HalfErodedDto.password');
|
||||
expect(message).not.toContain('HalfErodedDto.name has no');
|
||||
}
|
||||
} finally {
|
||||
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'reflect-metadata';
|
||||
import { getMetadataStorage } from 'class-validator';
|
||||
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
|
||||
|
||||
/**
|
||||
* Boot-time self-check: the global ValidationPipe must be able to SEE the
|
||||
* decorated properties of the DTOs it guards (#1391, #436 class).
|
||||
*
|
||||
* WHY THIS EXISTS. When Nest resolves a @Body() metatype to Object — via
|
||||
* `import type` class erasure (#436), or a dependency graph where the
|
||||
* controller's decorators and the application's route enhancers disagree
|
||||
* (#1391's hypothesized dual-@nestjs/common on a mixed install) — the
|
||||
* ValidationPipe's whitelist treats every property as forbidden. The first
|
||||
* symptom is a 400 on the FIRST bootstrap attempt of a fresh install, the
|
||||
* worst place to discover wiring damage: the operator cannot tell a broken
|
||||
* payload from a broken daemon.
|
||||
*
|
||||
* This check fails LOUD at boot instead: if the pipe cannot see the DTO's
|
||||
* decorated properties, the gateway refuses to start with a named cause.
|
||||
* It catches the whole class — erasure, decorator metadata loss — on every
|
||||
* host, at the moment the damage exists rather than at first use.
|
||||
*
|
||||
* Storage sharing note: class-validator keys its metadata storage on
|
||||
* globalThis, so duplicate package copies do NOT hide metadata (measured,
|
||||
* #1391 diagnosis). What hides it is losing the metatype itself, which is
|
||||
* what this asserts against.
|
||||
*/
|
||||
|
||||
/**
|
||||
* DTOs the global pipe guards, mapped to the properties the whitelist must
|
||||
* admit. Target is the CONSTRUCTOR (the object class itself): class-validator
|
||||
* decorators register metadata keyed on the constructor, and its executor
|
||||
* looks up `object.constructor` (ValidationExecutor.js:50) — the probe
|
||||
* through `prototype` returns zero. Extend when adding DTOs to the app.
|
||||
*/
|
||||
export const PIPE_GUARDED_DTOS: Array<{
|
||||
name: string;
|
||||
target: abstract new (...args: never[]) => unknown;
|
||||
properties: string[];
|
||||
}> = [
|
||||
{
|
||||
name: 'BootstrapSetupDto',
|
||||
target: BootstrapSetupDto,
|
||||
properties: ['name', 'email', 'password'],
|
||||
},
|
||||
];
|
||||
|
||||
export class PipeMetatypeCheckError extends Error {
|
||||
constructor(missing: string[]) {
|
||||
super(
|
||||
'ValidationPipe metatype check failed: ' +
|
||||
missing.join('; ') +
|
||||
'. The global ValidationPipe cannot see decorated DTO properties — ' +
|
||||
'every request body would be rejected as non-whitelisted. ' +
|
||||
'Check for import-type erasure or decorator metadata loss in the ' +
|
||||
'dependency graph (see issues #436, #1391).',
|
||||
);
|
||||
this.name = 'PipeMetatypeCheckError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the pipe's whitelist can see every guarded DTO's decorated
|
||||
* properties. Throws PipeMetatypeCheckError (fail-loud at boot) listing
|
||||
* each miss. Pure function of module state: no I/O, safe to call twice.
|
||||
*/
|
||||
export function assertValidationPipeSeesDtoDecorators(): void {
|
||||
const storage = getMetadataStorage();
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const dto of PIPE_GUARDED_DTOS) {
|
||||
// class-validator records constraints keyed on the DTO's constructor
|
||||
// (decorators run on the class), and its executor resolves them via
|
||||
// object.constructor. A property with no recorded metadata is invisible
|
||||
// to the whitelist — whatever the cause — and fails here.
|
||||
// Signature mirrors ValidationExecutor.js:50 — (constructor, schema, always,
|
||||
// strictGroups, groups?). No schema, always=true, no groups: every
|
||||
// constraint regardless of grouping, which is what the whitelist sees.
|
||||
const metadatas = storage.getTargetValidationMetadatas(dto.target, '', true, false);
|
||||
const decorated = new Set(metadatas.map((m) => m.propertyName));
|
||||
|
||||
for (const property of dto.properties) {
|
||||
if (!decorated.has(property)) {
|
||||
missing.push(
|
||||
`${dto.name}.${property} has no class-validator constraints visible to the pipe`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new PipeMetatypeCheckError(missing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "accounts" ADD COLUMN "issuer" text;
|
||||
--> statement-breakpoint
|
||||
-- Backfill (#1395): better-auth >=1.7 sign-in filters accounts on
|
||||
-- (provider_id = 'credential' AND issuer = 'local:credential'). Existing
|
||||
-- credential rows predate the column and would fail that filter on upgraded
|
||||
-- installs. Credential rows ONLY: better-auth owns issuer semantics for
|
||||
-- oauth/sso rows going forward (each provider's real issuer value), so those
|
||||
-- stay NULL until the provider's next flow writes them.
|
||||
UPDATE "accounts" SET "issuer" = 'local:credential' WHERE "provider_id" = 'credential' AND "issuer" IS NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
||||
"when": 1784050648841,
|
||||
"tag": "0016_salty_morlocks",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1787609223282,
|
||||
"tag": "0017_accounts_issuer",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
export { createDb, type Db, type DbHandle } from './client.js';
|
||||
export { createPgliteDb } from './client-pglite.js';
|
||||
export { runMigrations, runPgliteMigrations } from './migrate.js';
|
||||
export {
|
||||
runMigrations,
|
||||
runPgliteMigrations,
|
||||
getMigrationStatus,
|
||||
readJournalTags,
|
||||
applyMigrationsByHash,
|
||||
type HashLedgerDeps,
|
||||
type MigrationPlanEntry,
|
||||
type MigrationStatus,
|
||||
} from './migrate.js';
|
||||
export * from './schema.js';
|
||||
export * from './federation.js';
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import {
|
||||
applyMigrationsByHash,
|
||||
readJournalTags,
|
||||
type HashLedgerDeps,
|
||||
type MigrationPlanEntry,
|
||||
} from './migrate.js';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* In-memory hash-ledger harness */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface LedgerHarness extends HashLedgerDeps {
|
||||
ledger: Map<string, number>;
|
||||
/** Recorded statement executions in order: `${hashPrefix}:${stmtIndex}`. */
|
||||
executed: string[];
|
||||
/** Optional: statements that should throw when executed. */
|
||||
failOn?: (migrationHash: string, stmtIdx: number) => boolean;
|
||||
}
|
||||
|
||||
function makeHarness(plan: MigrationPlanEntry[]): LedgerHarness {
|
||||
const hashToEntry = new Map(plan.map((p) => [p.hash, p]));
|
||||
const h: LedgerHarness = {
|
||||
ledger: new Map(),
|
||||
executed: [],
|
||||
ensureLedger: async () => {},
|
||||
appliedHashes: async () => [...h.ledger.keys()],
|
||||
recordApplied: async (hash, folderMillis) => {
|
||||
h.ledger.set(hash, folderMillis);
|
||||
},
|
||||
runStatement: async (statement) => {
|
||||
void statement;
|
||||
// runStatement does not know which migration it belongs to; the
|
||||
// executed log is filled by the wrapper below.
|
||||
},
|
||||
};
|
||||
// Wrap runStatement so the executed log records migration context. We
|
||||
// reconstruct context by tracking a cursor the core advances per migration.
|
||||
let cursor = 0;
|
||||
const flat: Array<{ hash: string; idx: number }> = [];
|
||||
for (const m of plan)
|
||||
for (const [i] of m.statements.entries()) flat.push({ hash: m.hash, idx: i });
|
||||
h.runStatement = async () => {
|
||||
const at = flat[cursor] ?? { hash: '??', idx: -1 };
|
||||
cursor += 1;
|
||||
if (h.failOn && at.hash !== '??' && h.failOn(at.hash, at.idx)) {
|
||||
throw new Error(`simulated failure in ${at.hash} #${at.idx.toString()}`);
|
||||
}
|
||||
h.executed.push(`${at.hash.slice(0, 6)}:${at.idx.toString()}`);
|
||||
};
|
||||
void hashToEntry;
|
||||
return h;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Fixtures */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
// Reproduces the REAL journal defect shape (#1402 D1): 0009/0010 carry
|
||||
// `when` timestamps BELOW 0008's. Under the old drizzle postgres-js
|
||||
// migrator these were silently skipped on any upgrade whose ledger was
|
||||
// last stamped in the 0008 era.
|
||||
const JOURNAL_FIXTURE: MigrationPlanEntry[] = [
|
||||
{ hash: 'aaaa0000', folderMillis: 1773368153122, statements: ['CREATE TABLE a (id int)'] },
|
||||
{ hash: 'bbbb0008', folderMillis: 1776822435828, statements: ['CREATE TABLE b (id int)'] },
|
||||
// Backdated entries, exactly as shipped:
|
||||
{
|
||||
hash: 'cccc0009',
|
||||
folderMillis: 1745280000000,
|
||||
statements: ['ALTER TYPE t ADD VALUE', "CREATE TABLE c (s t DEFAULT 'pending')"],
|
||||
},
|
||||
{ hash: 'dddd0010', folderMillis: 1745366400000, statements: ['CREATE TABLE d (id int)'] },
|
||||
];
|
||||
|
||||
/** A ledger last stamped at the 0008 era: only pre-0009 hashes recorded. */
|
||||
const LEDGER_AT_0008_ERA = new Map<string, number>([
|
||||
['aaaa0000', 1773368153122],
|
||||
['bbbb0008', 1776822435828],
|
||||
]);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The core: apply-by-hash in journal order */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe('applyMigrationsByHash', () => {
|
||||
it('applies backdated journal entries that a timestamp-based migrator would skip (#1402 D1)', async () => {
|
||||
const h = makeHarness(JOURNAL_FIXTURE);
|
||||
h.ledger = new Map(LEDGER_AT_0008_ERA);
|
||||
|
||||
const result = await applyMigrationsByHash(h, JOURNAL_FIXTURE);
|
||||
|
||||
// D1 in one sentence: 0009 and 0010 applied despite folderMillis < 0008.
|
||||
expect(result).toEqual({ applied: 2, skipped: 2 });
|
||||
expect(h.ledger.has('cccc0009')).toBe(true);
|
||||
expect(h.ledger.has('dddd0010')).toBe(true);
|
||||
});
|
||||
|
||||
it('executes statements individually (ALTER TYPE visibility, #1402 D2 shape)', async () => {
|
||||
const h = makeHarness(JOURNAL_FIXTURE);
|
||||
await applyMigrationsByHash(h, JOURNAL_FIXTURE);
|
||||
// 0009's two statements recorded as separate executions, in order.
|
||||
expect(h.executed).toContain('cccc00:0');
|
||||
expect(h.executed).toContain('cccc00:1');
|
||||
expect(h.executed.indexOf('cccc00:0')).toBeLessThan(h.executed.indexOf('cccc00:1'));
|
||||
});
|
||||
|
||||
it('is idempotent: a fully-applied ledger applies nothing', async () => {
|
||||
const h = makeHarness(JOURNAL_FIXTURE);
|
||||
const first = await applyMigrationsByHash(h, JOURNAL_FIXTURE);
|
||||
const second = await applyMigrationsByHash(h, JOURNAL_FIXTURE);
|
||||
expect(first.applied).toBe(4);
|
||||
expect(second).toEqual({ applied: 0, skipped: 4 });
|
||||
expect(h.executed).toHaveLength(5); // 5 statements; second run executed NONE (not 10)
|
||||
});
|
||||
|
||||
it('records no ledger row when a statement fails (crash prefix replays loudly)', async () => {
|
||||
const h = makeHarness(JOURNAL_FIXTURE);
|
||||
h.failOn = (hash, idx) => hash === 'cccc0009' && idx === 1;
|
||||
|
||||
await expect(applyMigrationsByHash(h, JOURNAL_FIXTURE)).rejects.toThrow(
|
||||
/cccc0009 statement #1 failed: simulated failure/,
|
||||
);
|
||||
// Statement 0 of 0009 executed, but NO ledger row for 0009: the next run
|
||||
// replays it and fails loudly on "already exists" instead of silently
|
||||
// believing 0009 applied.
|
||||
expect(h.ledger.has('cccc0009')).toBe(false);
|
||||
expect(h.executed).toContain('cccc00:0');
|
||||
});
|
||||
|
||||
it('applies in JOURNAL order, not timestamp order', async () => {
|
||||
const h = makeHarness(JOURNAL_FIXTURE);
|
||||
await applyMigrationsByHash(h, JOURNAL_FIXTURE);
|
||||
// 5 statements total (0009 has two); prefix per migration: journal order,
|
||||
// so 0009's pair sits between 0008 and 0010.
|
||||
const order = h.executed.map((e) => e.slice(0, 4));
|
||||
expect(order).toEqual(['aaaa', 'bbbb', 'cccc', 'cccc', 'dddd']);
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Journal integrity against the shipped folder */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe('readJournalTags', () => {
|
||||
it('reads the shipped journal in order and sees the known backdated pair', () => {
|
||||
const folder = resolve(__dirname, '../drizzle');
|
||||
const tags = readJournalTags(folder);
|
||||
expect(tags.length).toBeGreaterThan(0);
|
||||
// The shipped defect (#1402 D1): these two entries carry April-2025
|
||||
// timestamps below 0008's June-2026 one. If this assertion ever fails
|
||||
// because the journal was FIXED (timestamps corrected or drizzle-kit
|
||||
// regenerated), update #1402 — the hash-ledger core stays correct either
|
||||
// way; this test pins the shipped reality the core was built for.
|
||||
const t9 = tags.find((t) => t.startsWith('0009_'));
|
||||
const t10 = tags.find((t) => t.startsWith('0010_'));
|
||||
const t8 = tags.find((t) => t.startsWith('0008_'));
|
||||
expect([t8, t9, t10]).toBeDefined();
|
||||
const journal = JSON.parse(readFileSync(resolve(folder, 'meta', '_journal.json'), 'utf8')) as {
|
||||
entries: Array<{ tag: string; when: number }>;
|
||||
};
|
||||
const when = new Map(journal.entries.map((e) => [e.tag, e.when]));
|
||||
if (t8 && t9 && t10) {
|
||||
expect(when.get(t9)!).toBeLessThan(when.get(t8)!); // backdated below 0008
|
||||
expect(when.get(t10)!).toBeLessThan(when.get(t8)!); // backdated below 0008
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,75 @@ describe('runPgliteMigrations', () => {
|
||||
await expect(runPgliteMigrations(handle)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives accounts an issuer column (#1395) — better-auth >=1.7 requires it', async () => {
|
||||
await runPgliteMigrations(handle);
|
||||
|
||||
const result = (await handle.db.execute(sql`
|
||||
SELECT column_name, is_nullable, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'accounts' AND column_name = 'issuer'
|
||||
`)) as unknown as {
|
||||
rows: Array<{ column_name: string; is_nullable: string; data_type: string }>;
|
||||
};
|
||||
|
||||
// Nullable by design: the 1.5.x line this repo's lockfile resolves to does
|
||||
// not write the field; 1.7+ populates it. One schema serves both.
|
||||
expect(result.rows).toHaveLength(1);
|
||||
expect(result.rows[0]?.is_nullable).toBe('YES');
|
||||
expect(result.rows[0]?.data_type).toBe('text');
|
||||
});
|
||||
|
||||
it('backfills ONLY credential rows with the synthetic issuer (#1395 upgrade path)', async () => {
|
||||
// Simulate an upgraded install: migrate through 0016 only, seed pre-issuer
|
||||
// rows (one credential, one oauth), then apply 0017 and discriminate.
|
||||
const client = (handle.db as unknown as { $client: PgliteExec }).$client;
|
||||
|
||||
// Migrate to 0016 by replaying every ledger file except 0017 — the ledger
|
||||
// table gates re-application, so a plain replay of 0000..0016 is enough.
|
||||
const fs = await import('node:fs');
|
||||
const path = await import('node:path');
|
||||
const dir = path.join(import.meta.dirname, '..', 'drizzle');
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => /^\d{4}_.*\.sql$/.test(f) && f < '0017')
|
||||
.sort();
|
||||
for (const f of files) {
|
||||
const raw = fs.readFileSync(path.join(dir, f), 'utf-8');
|
||||
for (const stmt of raw.split('--> statement-breakpoint')) {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) await client.exec(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
await client.exec(`
|
||||
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||
VALUES ('u1', 'Legacy User', '[email protected]', true, now(), now());
|
||||
INSERT INTO accounts (id, account_id, provider_id, user_id, created_at, updated_at)
|
||||
VALUES
|
||||
('a1', '[email protected]', 'credential', 'u1', now(), now()),
|
||||
('a2', 'oauth-provider-1', 'google', 'u1', now(), now());
|
||||
`);
|
||||
|
||||
// Apply 0017 (column + backfill).
|
||||
const sql0017 = fs.readFileSync(path.join(dir, '0017_accounts_issuer.sql'), 'utf-8');
|
||||
for (const stmt of sql0017.split('--> statement-breakpoint')) {
|
||||
const trimmed = stmt.trim();
|
||||
if (trimmed) await client.exec(trimmed);
|
||||
}
|
||||
|
||||
const rows = (await handle.db.execute(sql`
|
||||
SELECT provider_id, issuer FROM accounts ORDER BY id
|
||||
`)) as unknown as { rows: Array<{ provider_id: string; issuer: string | null }> };
|
||||
|
||||
const byProvider = new Map(rows.rows.map((r) => [r.provider_id, r.issuer]));
|
||||
// Credential rows get better-auth's synthetic local issuer — the value
|
||||
// sign-in filters on (better-auth dist createLocalAccountIssuer).
|
||||
expect(byProvider.get('credential')).toBe('local:credential');
|
||||
// OAuth rows are LEFT NULL: better-auth owns their issuer semantics going
|
||||
// forward (each provider's real issuer on its next flow).
|
||||
expect(byProvider.get('google')).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces statement-level error context on failure and leaves no ledger row', async () => {
|
||||
// Pre-create a `users` table that conflicts with migration 0000's CREATE TABLE,
|
||||
// forcing it to fail without IF NOT EXISTS.
|
||||
|
||||
+222
-69
@@ -1,8 +1,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { drizzle as drizzlePostgres } from 'drizzle-orm/postgres-js';
|
||||
import { migrate as migratePostgres } from 'drizzle-orm/postgres-js/migrator';
|
||||
import { readMigrationFiles } from 'drizzle-orm/migrator';
|
||||
import postgres from 'postgres';
|
||||
import { DEFAULT_DATABASE_URL } from './defaults.js';
|
||||
@@ -21,89 +20,243 @@ function migrationsFolder(): string {
|
||||
return resolve(here, '../drizzle');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared hash-ledger migration core (#1392 / #1402) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
//
|
||||
// Both tiers migrate through this single core, which applies migrations in
|
||||
// JOURNAL ORDER, one statement at a time, and skips by HASH — never by
|
||||
// folderMillis timestamp. The previous postgres path delegated to drizzle's
|
||||
// postgres-js migrator, which:
|
||||
//
|
||||
// * applies only migrations with folderMillis > last-applied, silently
|
||||
// skipping journal entries whose `when` is older than the ledger's newest
|
||||
// stamp — 0009/0010 carry April-2025 timestamps below 0008's June-2026
|
||||
// one, so any database last migrated in the 0008 era silently loses
|
||||
// 0009/0010 forever (#1402 D1); and
|
||||
// * wraps each migration in ONE transaction, which breaks migrations that
|
||||
// do `ALTER TYPE ADD VALUE` and then reference the new value in the same
|
||||
// migration (0009) — Postgres' check_safe_enum_use rejects it (#1402 D2).
|
||||
//
|
||||
// Per-statement execution (each statement autocommits) and skip-by-hash fix
|
||||
// both. The PGlite path has run this way since it was written; this is the
|
||||
// TODO it left behind, now shared instead of duplicated.
|
||||
|
||||
/** One migration as loaded from the shipped drizzle/ folder. */
|
||||
export interface MigrationPlanEntry {
|
||||
hash: string;
|
||||
folderMillis: number;
|
||||
statements: string[];
|
||||
}
|
||||
|
||||
/** The persistence operations the hash-ledger core needs, per tier. */
|
||||
export interface HashLedgerDeps {
|
||||
/** Create the drizzle schema + ledger table if absent (idempotent). */
|
||||
ensureLedger(): Promise<void>;
|
||||
/** Hashes already recorded in the ledger. */
|
||||
appliedHashes(): Promise<string[]>;
|
||||
/** Record one fully-applied migration in the ledger. */
|
||||
recordApplied(hash: string, folderMillis: number): Promise<void>;
|
||||
/** Execute one SQL statement, autocommitting (never inside a wider tx). */
|
||||
runStatement(statement: string): Promise<void>;
|
||||
}
|
||||
|
||||
function loadPlan(): MigrationPlanEntry[] {
|
||||
return readMigrationFiles({ migrationsFolder: migrationsFolder() }).map((m) => ({
|
||||
hash: m.hash,
|
||||
folderMillis: m.folderMillis,
|
||||
statements: m.sql.map((s) => s.trim()).filter((s) => s.length > 0),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply every unapplied migration in journal order, skipping by hash.
|
||||
*
|
||||
* Failure model: each statement autocommits, and the ledger row is written
|
||||
* only after all statements of a migration succeed. A crash mid-migration
|
||||
* leaves the prefix applied with no ledger entry, so the next boot replays
|
||||
* those statements and fails loudly on "already exists". Recovery: drop the
|
||||
* partially-applied objects, or insert the migration's hash into
|
||||
* `drizzle.__drizzle_migrations` manually. The thrown error identifies the
|
||||
* statement and migration that failed.
|
||||
*/
|
||||
export async function applyMigrationsByHash(
|
||||
deps: HashLedgerDeps,
|
||||
plan: MigrationPlanEntry[] = loadPlan(),
|
||||
): Promise<{ applied: number; skipped: number }> {
|
||||
await deps.ensureLedger();
|
||||
const alreadyApplied = new Set(await deps.appliedHashes());
|
||||
|
||||
let applied = 0;
|
||||
let skipped = 0;
|
||||
for (const migration of plan) {
|
||||
if (alreadyApplied.has(migration.hash)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
for (const [stmtIdx, stmt] of migration.statements.entries()) {
|
||||
try {
|
||||
await deps.runStatement(stmt);
|
||||
} catch (err) {
|
||||
const cause = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`migration hash=${migration.hash} statement #${stmtIdx} failed: ${cause}\n` +
|
||||
`Statement: ${stmt.slice(0, 200)}${stmt.length > 200 ? '…' : ''}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
await deps.recordApplied(migration.hash, migration.folderMillis);
|
||||
applied += 1;
|
||||
}
|
||||
return { applied, skipped };
|
||||
}
|
||||
|
||||
const LEDGER_DDL = [
|
||||
'CREATE SCHEMA IF NOT EXISTS drizzle',
|
||||
`CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at bigint
|
||||
)`,
|
||||
];
|
||||
|
||||
function connectionString(url?: string): string {
|
||||
return url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Drizzle migrations against a postgres database, hash-ledger style.
|
||||
* Idempotent: re-running against a fully-migrated database applies nothing.
|
||||
*/
|
||||
export async function runMigrations(url?: string): Promise<void> {
|
||||
const connectionString = url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
|
||||
const sqlClient = postgres(connectionString, { max: 1 });
|
||||
const db = drizzlePostgres(sqlClient);
|
||||
const sqlClient = postgres(connectionString(url), { max: 1 });
|
||||
try {
|
||||
// TODO: postgres-tier first-install also fails because (a) Drizzle wraps every
|
||||
// migration in one transaction (breaks 0009's ALTER TYPE ADD VALUE → SET DEFAULT
|
||||
// sequence) and (b) drizzle/meta/_journal.json has 0009 ordered before 0008,
|
||||
// which the postgres-js migrator skips by `created_at < folderMillis`. The
|
||||
// PGlite path below sidesteps both. A follow-up should either share the
|
||||
// per-statement loop (see runPgliteMigrations) or fix the journal ordering.
|
||||
await migratePostgres(db, { migrationsFolder: migrationsFolder() });
|
||||
await applyMigrationsByHash({
|
||||
ensureLedger: async () => {
|
||||
for (const ddl of LEDGER_DDL) await sqlClient.unsafe(ddl);
|
||||
},
|
||||
appliedHashes: async () => {
|
||||
const rows = (await sqlClient.unsafe(
|
||||
'SELECT hash FROM drizzle.__drizzle_migrations',
|
||||
)) as Array<{ hash: string }>;
|
||||
return rows.map((r) => String(r.hash));
|
||||
},
|
||||
recordApplied: async (hash, folderMillis) => {
|
||||
await sqlClient.unsafe(
|
||||
'INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)',
|
||||
[hash, folderMillis],
|
||||
);
|
||||
},
|
||||
runStatement: async (stmt) => {
|
||||
await sqlClient.unsafe(stmt);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await sqlClient.end();
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Drizzle migrations against an embedded PGlite database.
|
||||
//
|
||||
// We don't reuse drizzle's pglite migrator because it wraps ALL migrations in
|
||||
// one outer transaction, which breaks Postgres' `check_safe_enum_use` rule —
|
||||
// e.g. migration 0009 does `ALTER TYPE ADD VALUE 'pending'` then references
|
||||
// `'pending'` as a default in the same tx. PGlite's `exec()` runs each
|
||||
// statement under the Simple Query protocol, autocommitting between them.
|
||||
//
|
||||
// We still write to the standard `drizzle.__drizzle_migrations` ledger so the
|
||||
// result is interoperable with `runMigrations()` on a postgres-backed deploy
|
||||
// (modulo the journal-ordering bug noted above).
|
||||
//
|
||||
// We skip-by-hash rather than skip-by-folderMillis (which is what Drizzle's
|
||||
// postgres-js migrator does). That's deliberate — out-of-order timestamps in
|
||||
// `_journal.json` won't silently drop migrations.
|
||||
//
|
||||
// Failure model: each statement autocommits, and the ledger row is written
|
||||
// only after all statements in a migration succeed. A crash mid-migration
|
||||
// leaves the prefix applied with no ledger entry, so the next boot will
|
||||
// replay those statements and fail loudly on "already exists". Recovery:
|
||||
// drop the partially-applied objects, or insert the migration's hash into
|
||||
// `drizzle.__drizzle_migrations` manually. The error log identifies which
|
||||
// statement of which migration was the culprit.
|
||||
/**
|
||||
* Apply Drizzle migrations against an embedded PGlite database.
|
||||
*
|
||||
* We don't reuse drizzle's pglite migrator for the same reasons as the
|
||||
* postgres path (single-transaction wrap; folderMillis skip). PGlite's
|
||||
* `exec()` runs each statement under the Simple Query protocol,
|
||||
* autocommitting between them — exactly the semantics the shared core needs.
|
||||
*
|
||||
* The ledger rows this writes are interoperable with the postgres path (same
|
||||
* schema, same hashes), because both consume the same shipped migrations.
|
||||
*/
|
||||
export async function runPgliteMigrations(handle: DbHandle): Promise<void> {
|
||||
const client = (handle.db as unknown as { $client?: PgliteExecutor }).$client;
|
||||
if (!client || typeof client.exec !== 'function') {
|
||||
throw new Error('runPgliteMigrations: handle.db is not backed by a PGlite client');
|
||||
}
|
||||
|
||||
await client.exec('CREATE SCHEMA IF NOT EXISTS drizzle');
|
||||
await client.exec(`
|
||||
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at bigint
|
||||
)
|
||||
`);
|
||||
await applyMigrationsByHash({
|
||||
ensureLedger: async () => {
|
||||
for (const ddl of LEDGER_DDL) await client.exec(ddl);
|
||||
},
|
||||
appliedHashes: async () => {
|
||||
const rows = (await handle.db.execute(
|
||||
sql`SELECT hash FROM drizzle.__drizzle_migrations`,
|
||||
)) as unknown as ExecuteRows<{ hash: string }>;
|
||||
return rows.rows.map((r) => String(r.hash));
|
||||
},
|
||||
recordApplied: async (hash, folderMillis) => {
|
||||
await handle.db.execute(
|
||||
sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (${hash}, ${folderMillis})`,
|
||||
);
|
||||
},
|
||||
runStatement: async (stmt) => {
|
||||
await client.exec(stmt);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const appliedRows = (await handle.db.execute(
|
||||
sql`SELECT hash FROM drizzle.__drizzle_migrations`,
|
||||
)) as unknown as ExecuteRows<{ hash: string }>;
|
||||
const applied = new Set(appliedRows.rows.map((r) => r.hash));
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Migration status (#1392: the installer must VERIFY, not assume) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const migrations = readMigrationFiles({ migrationsFolder: migrationsFolder() });
|
||||
for (const migration of migrations) {
|
||||
if (applied.has(migration.hash)) continue;
|
||||
/** Read the journal tags (migration folder names) in journal order. */
|
||||
export function readJournalTags(folder: string = migrationsFolder()): string[] {
|
||||
const journal = JSON.parse(readFileSync(resolve(folder, 'meta', '_journal.json'), 'utf8')) as {
|
||||
entries?: Array<{ tag?: string }>;
|
||||
};
|
||||
return (journal.entries ?? []).map((e) => e.tag ?? '').filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
// Run each statement-breakpoint chunk in its own exec() call so PGlite
|
||||
// commits between statements — this is what lets `ALTER TYPE ADD VALUE`
|
||||
// become visible before a subsequent statement references the new value.
|
||||
for (const [stmtIdx, stmt] of migration.sql.entries()) {
|
||||
const trimmed = stmt.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
await client.exec(trimmed);
|
||||
} catch (err) {
|
||||
const cause = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`runPgliteMigrations: migration hash=${migration.hash} statement #${stmtIdx} failed: ${cause}\n` +
|
||||
`Statement: ${trimmed.slice(0, 200)}${trimmed.length > 200 ? '…' : ''}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
export interface MigrationStatus {
|
||||
/** Hashes recorded in the database's ledger (0 if no ledger exists). */
|
||||
appliedCount: number;
|
||||
/** Migrations shipped in this package's drizzle/ folder. */
|
||||
expectedCount: number;
|
||||
/** The tag (folder name) of the last journal entry, for error messages. */
|
||||
expectedLastTag: string;
|
||||
/** True iff every shipped migration's hash is in the ledger. */
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Report whether a postgres database carries the full shipped schema.
|
||||
*
|
||||
* Read-only apart from `ensureLedger` semantics: it never creates the ledger
|
||||
* (unlike the migrators), so a database with NO ledger reports
|
||||
* appliedCount=0 / complete=false — the exact #1392/#1389 signature (an
|
||||
* install whose dependency set shipped no migrations at all).
|
||||
*/
|
||||
export async function getMigrationStatus(url?: string): Promise<MigrationStatus> {
|
||||
const sqlClient = postgres(connectionString(url), { max: 1 });
|
||||
try {
|
||||
const plan = loadPlan();
|
||||
const tags = readJournalTags();
|
||||
const expectedHashes = new Set(plan.map((m) => m.hash));
|
||||
|
||||
let appliedHashes: string[] = [];
|
||||
const regRows = (await sqlClient.unsafe(
|
||||
"SELECT to_regclass('drizzle.__drizzle_migrations') AS reg",
|
||||
)) as Array<{ reg: string | null }>;
|
||||
if (regRows[0]?.reg) {
|
||||
const rows = (await sqlClient.unsafe(
|
||||
'SELECT hash FROM drizzle.__drizzle_migrations',
|
||||
)) as Array<{ hash: string }>;
|
||||
appliedHashes = rows.map((r) => String(r.hash));
|
||||
}
|
||||
await handle.db.execute(
|
||||
sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (${migration.hash}, ${migration.folderMillis})`,
|
||||
);
|
||||
|
||||
const appliedSet = new Set(appliedHashes);
|
||||
return {
|
||||
appliedCount: appliedHashes.length,
|
||||
expectedCount: plan.length,
|
||||
expectedLastTag: tags[tags.length - 1] ?? '',
|
||||
complete:
|
||||
plan.length > 0 &&
|
||||
plan.every((m) => appliedSet.has(m.hash)) &&
|
||||
// A ledger with entries OUTSIDE the shipped plan means the database
|
||||
// came from a different (e.g. newer) build — not "complete" either.
|
||||
appliedHashes.every((h) => expectedHashes.has(h)),
|
||||
};
|
||||
} finally {
|
||||
await sqlClient.end();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,12 @@ export const accounts = pgTable(
|
||||
id: text('id').primaryKey(),
|
||||
accountId: text('account_id').notNull(),
|
||||
providerId: text('provider_id').notNull(),
|
||||
// better-auth >=1.7 requires an issuer on every account row: credential
|
||||
// sign-up writes the synthetic 'local:credential', OAuth rows carry the
|
||||
// provider's real issuer, and sign-in filters on (providerId, issuer).
|
||||
// Nullable because the 1.5.x line this repo's lockfile resolves to does
|
||||
// not know the field — 1.5 ignores it, 1.7 populates it (#1395).
|
||||
issuer: text('issuer'),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -23,8 +23,14 @@ absent. Do not use raw `tmux send-keys` for fleet messaging.
|
||||
```bash
|
||||
tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ...
|
||||
tools/git/ci-queue-wait.sh --purpose push|merge # REQUIRED before any push/merge
|
||||
tools/git/repo-decl.sh # shared .mosaic/repo.json consumption lib (sourced)
|
||||
```
|
||||
|
||||
**Reviewer grants** — `tools/git/grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]` adds a
|
||||
review seat to an org repo through an org team (Gitea only; code read + issues/pulls write, verified
|
||||
by read-back). Team approvals do not count as official under branch protection unless the team is
|
||||
whitelisted — see the tool header.
|
||||
|
||||
**GITEA_LOGIN gotcha** — the wrappers default to login `mosaicstack`; on a USC repo that fails with
|
||||
`gitea / Error: GetUserByName ... not found`. Pick the login from the repo's `origin` host first:
|
||||
|
||||
|
||||
@@ -49,6 +49,10 @@ supply it explicitly on any host where the provider CLI's default account is an
|
||||
| `milestone-list.sh` | List milestones |
|
||||
| `milestone-close.sh` | Close a milestone |
|
||||
|
||||
| Access grants | |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `grant-reviewer.sh` | Grant a review seat on an org repo via an org team (Gitea only): code read + issues/pulls write, verified by read-back. Team approvals count as official only if branch protection whitelists the team — see the tool header |
|
||||
|
||||
| Gates and guards | |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) |
|
||||
|
||||
@@ -11,7 +11,17 @@ PartOf=mosaic-tmux-holder.service
|
||||
# launcher would fail the unit. A skipped unit is the honest state for "enabled
|
||||
# but not yet configured"; systemd re-evaluates the condition on every start, so
|
||||
# the seat comes up on the next start once the reconciler has written env.
|
||||
ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated
|
||||
#
|
||||
# #1408: the reconciler writes projections into the BRAIN home when one is
|
||||
# active (~/.mosaic/fleet/agents, mirroring start-agent-session.sh's brain-home
|
||||
# resolution), and into MOSAIC_HOME on a legacy single-tree host. A single
|
||||
# config-home condition therefore skipped every seat on brain-home estates —
|
||||
# measured on two estates: 27 projections vs 0, and 5 vs 0, gate never fired.
|
||||
# Two TRIGGERING conditions (the `|` prefix ORs same-type conditions, which
|
||||
# otherwise AND): either shape arms the unit; the launcher still resolves the
|
||||
# authoritative copy itself.
|
||||
ConditionPathExists=|%h/.config/mosaic/fleet/agents/%i.env.generated
|
||||
ConditionPathExists=|%h/.mosaic/fleet/agents/%i.env.generated
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
|
||||
@@ -32,6 +32,17 @@ if grep -qF -- '/bin/bash -lc' "$HOLDER"; then
|
||||
fail "holder must not start tmux through a login shell"
|
||||
fi
|
||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
|
||||
# #1408: the projection condition must arm on EITHER home shape. Both lines must
|
||||
# carry the `|` triggering prefix — same-type conditions without it AND together,
|
||||
# which can never be true (one file cannot exist at two paths), so a bare-spelling
|
||||
# regression would disable autostart everywhere while reading as "has a condition".
|
||||
grep -qF 'ConditionPathExists=|%h/.config/mosaic/fleet/agents/%i.env.generated' "$AGENT" || \
|
||||
fail "agent lacks triggering condition for the config home projection"
|
||||
grep -qF 'ConditionPathExists=|%h/.mosaic/fleet/agents/%i.env.generated' "$AGENT" || \
|
||||
fail "agent lacks triggering condition for the brain home projection (#1408)"
|
||||
if grep -qE '^ConditionPathExists=[^|]' "$AGENT"; then
|
||||
fail "agent has a non-triggering ConditionPathExists — same-type conditions AND, re-arming #1408"
|
||||
fi
|
||||
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
|
||||
if grep -qE '^Environment(File)?=' "$AGENT" "$INTERACTION"; then
|
||||
fail "agent units must not accept ambient or projection environment before strict parsing"
|
||||
|
||||
@@ -304,6 +304,17 @@ if [ "$MODE" = stop ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# #1408 hazard: a seat still living on the DEFAULT tmux socket is invisible to the
|
||||
# declared-socket guard below, and launching over it creates a same-name duplicate that
|
||||
# name-addressed comms delivery cannot tell apart. Refuse with a distinct code (76,
|
||||
# after 75 broker-absent) so a cutover wave script can branch on "seat still on legacy
|
||||
# socket" vs "already running" (0) vs "broker absent" (75). Stopping the legacy session
|
||||
# belongs to the cutover procedure, never to this launcher.
|
||||
if [ -n "$MOSAIC_TMUX_SOCKET" ] && tmux has-session -t "=${AGENT_NAME}" 2>/dev/null; then
|
||||
echo "[fleet] FAIL_LAUNCH seat-on-legacy-socket: session '${AGENT_NAME}' exists on the DEFAULT tmux socket; stop it before launching on '${MOSAIC_TMUX_SOCKET}'." >&2
|
||||
exit 76
|
||||
fi
|
||||
|
||||
if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
||||
echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}"
|
||||
exit 0
|
||||
@@ -421,9 +432,22 @@ if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
|
||||
echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
||||
fi
|
||||
|
||||
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
||||
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
|
||||
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
|
||||
# #1408 hazard: prefer the seat's own launch.sh when the brain provides one. It is the
|
||||
# path that binds the auth profile (CLAUDE_SECURESTORAGE_CONFIG_DIR) and seeds the seat
|
||||
# config; `mosaic yolo` relocates CLAUDE_CONFIG_DIR to the seat dir (launch.ts
|
||||
# activeSeatDir/harnessEnv) but performs neither, so a yolo-launched seat points its
|
||||
# config at a directory holding no credentials. The env -i allowlist below still
|
||||
# applies: launch.sh reads its own launch.env.
|
||||
SEAT_LAUNCH="${BRAIN_HOME}/fleet/agents/${AGENT_NAME}/launch.sh"
|
||||
if [ -x "$SEAT_LAUNCH" ]; then
|
||||
LAUNCH_COMMAND=("$SEAT_LAUNCH")
|
||||
echo "[fleet] launch path: seat launch.sh ($SEAT_LAUNCH)"
|
||||
else
|
||||
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
||||
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
|
||||
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
|
||||
echo "[fleet] launch path: mosaic yolo (no executable seat launch.sh)"
|
||||
fi
|
||||
|
||||
# The tmux holder owns a named server. Explicitly clear the pane environment
|
||||
# so server/session variables cannot cross the launch boundary; retain only
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env bash
|
||||
# CI-fit regression suite for the #1408 legacy-socket guard in
|
||||
# start-agent-session.sh.
|
||||
#
|
||||
# Same hermeticity contract as test-agent-session-broker-preflight.sh: a fake
|
||||
# tmux on PATH that scripts its own answers, a real unix socket in a tmpdir so
|
||||
# the broker preflight passes, env -i with a fake HOME. No case depends on host
|
||||
# state.
|
||||
#
|
||||
# The failure this suite is written down to catch: during a socket cutover a
|
||||
# seat's session still lives on the DEFAULT tmux socket while the launcher
|
||||
# targets the named one. The declared-socket has-session check cannot see the
|
||||
# legacy session (measured 2026-08-24: rc=1, script proceeds), so launch
|
||||
# creates a same-name duplicate — and comms delivery, which addresses sessions
|
||||
# by NAME, cannot tell the two apart. The guard refuses with its own code
|
||||
# (exit 76, after 75 broker-absent) BEFORE any tmux mutation.
|
||||
#
|
||||
# Cases:
|
||||
# 1. legacy session present -> exit 76, message names seat-on-legacy-socket
|
||||
# + both sockets' roles, and NO tmux session was created.
|
||||
# 2. legacy session absent -> proceeds PAST the guard (the run then stops at
|
||||
# a later precondition; asserted: exit != 76, stderr lacks the guard's
|
||||
# code, proving the guard was not the refusal).
|
||||
# 3. MOSAIC_TMUX_SOCKET empty (single-socket host) -> guard is inert: the
|
||||
# default-socket probe must not fire at all.
|
||||
#
|
||||
# Sabotage control, run by the developer (not in-suite): remove the guard
|
||||
# block, re-run — case 1 fails (exit is not 76), cases 2-3 still pass;
|
||||
# restore byte-identically.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/agent-session-legacy-socket-guard}"
|
||||
FAKE_HOME="$WORK_DIR/home"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
SOCK_DIR="$WORK_DIR/sockets"
|
||||
LOG_FILE="$WORK_DIR/tmux-calls.log"
|
||||
LEGACY_FLAG="$WORK_DIR/legacy-session-present"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FAKE_HOME/.config/mosaic/fleet/agents" "$BIN_DIR" "$SOCK_DIR"
|
||||
chmod 700 "$FAKE_HOME/.config/mosaic" "$FAKE_HOME/.config/mosaic/fleet/agents"
|
||||
chmod 750 "$FAKE_HOME/.config/mosaic/fleet"
|
||||
cat > "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-test.env.generated" <<'ENVEOF'
|
||||
MOSAIC_AGENT_NAME=lsguard-test
|
||||
MOSAIC_GIT_IDENTITY=lsguard-test
|
||||
MOSAIC_AGENT_CLASS=worker
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=
|
||||
MOSAIC_AGENT_REASONING=
|
||||
MOSAIC_AGENT_TOOL_POLICY=code
|
||||
MOSAIC_AGENT_WORKDIR=/tmp
|
||||
MOSAIC_TMUX_SOCKET=mosaic-fleet
|
||||
ENVEOF
|
||||
chmod 600 "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-test.env.generated"
|
||||
|
||||
# A projection with NO named socket, for case 3. Same file minus the socket line.
|
||||
sed '/^MOSAIC_TMUX_SOCKET=/d; s/lsguard-test/lsguard-nosock/' \
|
||||
"$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-test.env.generated" \
|
||||
> "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-nosock.env.generated"
|
||||
echo 'MOSAIC_TMUX_SOCKET=' >> "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-nosock.env.generated"
|
||||
chmod 600 "$FAKE_HOME/.config/mosaic/fleet/agents/lsguard-nosock.env.generated"
|
||||
|
||||
# Ownership identity the launcher validates before anything touches tmux:
|
||||
# a 0600 uuid file plus a tmux global environment that matches it exactly.
|
||||
mkdir -p "$FAKE_HOME/.config/mosaic/fleet/run"
|
||||
chmod 750 "$FAKE_HOME/.config/mosaic/fleet/run"
|
||||
OWNER_UUID="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
printf '%s' "$OWNER_UUID" > "$FAKE_HOME/.config/mosaic/fleet/run/holder-owner"
|
||||
chmod 600 "$FAKE_HOME/.config/mosaic/fleet/run/holder-owner"
|
||||
|
||||
# The exact env block assert_owned_tmux_server expects; the socket value differs
|
||||
# per case, so cases rewrite it via write_tmux_env before each run.
|
||||
write_tmux_env() {
|
||||
printf '%s\n' \
|
||||
"HOME=$FAKE_HOME" \
|
||||
'PATH=/usr/bin:/bin' \
|
||||
"PWD=$FAKE_HOME" \
|
||||
"MOSAIC_FLEET_OWNER=$OWNER_UUID" \
|
||||
'MOSAIC_TMUX_HOLDER=_holder' \
|
||||
"MOSAIC_TMUX_SOCKET=$1" > "$WORK_DIR/tmux-env"
|
||||
}
|
||||
|
||||
# ─── Fake tmux ──────────────────────────────────────────────────────────────
|
||||
# Scripted answers: a DEFAULT-socket has-session (argv carries no -L) answers
|
||||
# by the flag file; every named-socket call succeeds (holder present, no
|
||||
# existing session is fine for these cases since refusal happens first).
|
||||
cat > "$BIN_DIR/tmux" <<SH
|
||||
#!/usr/bin/env bash
|
||||
printf 'tmux %s\n' "\$*" >> "$LOG_FILE"
|
||||
if [[ "\$*" == *new-session* ]]; then
|
||||
echo "TMUX-NEW-SESSION-INVOKED" >> "$LOG_FILE"
|
||||
fi
|
||||
if [[ "\$*" == *show-environment* ]]; then
|
||||
cat "$WORK_DIR/tmux-env"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "\$*" == *has-session* ]]; then
|
||||
# holder session always present; the seat's DEFAULT-socket presence is the
|
||||
# flag file; the seat is never already-running on the NAMED socket.
|
||||
[[ "\$*" == *_holder* ]] && exit 0
|
||||
if [[ "\$1" == "-L" ]]; then exit 1; fi
|
||||
[[ -e "$LEGACY_FLAG" ]] && exit 0 || exit 1
|
||||
fi
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tmux"
|
||||
|
||||
for bin in mosaic pi claude; do
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$BIN_DIR/$bin"
|
||||
chmod +x "$BIN_DIR/$bin"
|
||||
done
|
||||
|
||||
# Real socket so the #1292 broker preflight passes and the run reaches the guard.
|
||||
# Same idiom as the broker-preflight suite: AF_UNIX binds cap at 108 path bytes,
|
||||
# so the socket lives at a SHORT /tmp path held by a detached python holder (a
|
||||
# foreground bind would close on exit; -S on a closed-but-unlinked path fails).
|
||||
LIVE_SOCK="/tmp/mosaic-lsguard-$RANDOM-$$.sock"
|
||||
trap 'rm -f "$LIVE_SOCK"' EXIT
|
||||
rm -f "$LIVE_SOCK"
|
||||
cat > "$SOCK_DIR/holder.py" <<'PY'
|
||||
import socket, sys, time
|
||||
path = sys.argv[1]
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
s.bind(path)
|
||||
s.listen(1)
|
||||
time.sleep(120)
|
||||
PY
|
||||
python3 "$SOCK_DIR/holder.py" "$LIVE_SOCK" >/dev/null 2>"$SOCK_DIR/holder.err" &
|
||||
for _ in $(seq 1 50); do
|
||||
[ -S "$LIVE_SOCK" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[ -S "$LIVE_SOCK" ] || { echo "FAIL: could not create live socket" >&2; exit 1; }
|
||||
|
||||
run_session_script() {
|
||||
local agent="$1"; shift
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
env -i HOME="$FAKE_HOME" PATH="$BIN_DIR:/usr/bin:/bin" \
|
||||
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
|
||||
MOSAIC_HOME="$FAKE_HOME/.config/mosaic" \
|
||||
MOSAIC_LEASE_BROKER_SOCKET="$LIVE_SOCK" \
|
||||
"$@" \
|
||||
bash "$SCRIPT_DIR/start-agent-session.sh" "$agent"
|
||||
)
|
||||
}
|
||||
|
||||
fail=0
|
||||
assert() {
|
||||
local desc="$1" expected="$2" actual="$3"
|
||||
[[ "$expected" == "$actual" ]] || { echo "FAIL: $desc — expected '$expected', got '$actual'" >&2; fail=1; }
|
||||
}
|
||||
assert_contains() {
|
||||
local desc="$1" haystack="$2" needle="$3"
|
||||
[[ "$haystack" == *"$needle"* ]] || { echo "FAIL: $desc — missing '$needle'" >&2; fail=1; }
|
||||
}
|
||||
assert_not_contains() {
|
||||
local desc="$1" haystack="$2" needle="$3"
|
||||
if [[ "$haystack" == *"$needle"* ]]; then
|
||||
echo "FAIL: $desc — must not contain '$needle'" >&2
|
||||
fail=1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─── 1. Legacy session present → exit 76, no tmux mutation. ─────────────────
|
||||
write_tmux_env "mosaic-fleet"
|
||||
: > "$LOG_FILE"; touch "$LEGACY_FLAG"
|
||||
stderr_file="$WORK_DIR/stderr-1.tmp"
|
||||
set +e
|
||||
run_session_script lsguard-test >/dev/null 2>"$stderr_file"
|
||||
rc=$?
|
||||
set -e
|
||||
err=$(cat "$stderr_file")
|
||||
assert "legacy present exit code" "76" "$rc"
|
||||
assert_contains "names the failure" "$err" "FAIL_LAUNCH seat-on-legacy-socket"
|
||||
assert_contains "names the agent" "$err" "lsguard-test"
|
||||
assert_contains "names the target socket" "$err" "mosaic-fleet"
|
||||
assert_not_contains "no session created" "$(cat "$LOG_FILE")" "TMUX-NEW-SESSION-INVOKED"
|
||||
|
||||
# ─── 2. Legacy session absent → guard is not the refusal. ───────────────────
|
||||
write_tmux_env "mosaic-fleet"
|
||||
: > "$LOG_FILE"; rm -f "$LEGACY_FLAG"
|
||||
stderr_file="$WORK_DIR/stderr-2.tmp"
|
||||
set +e
|
||||
run_session_script lsguard-test >/dev/null 2>"$stderr_file"
|
||||
rc=$?
|
||||
set -e
|
||||
err=$(cat "$stderr_file")
|
||||
if [[ "$rc" == "76" ]]; then
|
||||
echo "FAIL: legacy absent must not exit 76" >&2; fail=1
|
||||
fi
|
||||
assert_not_contains "guard code absent from stderr" "$err" "seat-on-legacy-socket"
|
||||
|
||||
# ─── 3. Empty MOSAIC_TMUX_SOCKET → guard inert, no default-socket probe. ────
|
||||
write_tmux_env ""
|
||||
: > "$LOG_FILE"; touch "$LEGACY_FLAG" # even with a legacy session present
|
||||
stderr_file="$WORK_DIR/stderr-3.tmp"
|
||||
set +e
|
||||
run_session_script lsguard-nosock >/dev/null 2>"$stderr_file"
|
||||
rc=$?
|
||||
set -e
|
||||
err=$(cat "$stderr_file")
|
||||
if [[ "$rc" == "76" ]]; then
|
||||
echo "FAIL: empty socket must never exit 76 (single-socket host)" >&2; fail=1
|
||||
fi
|
||||
assert_not_contains "guard code absent on single-socket host" "$err" "seat-on-legacy-socket"
|
||||
|
||||
rm -f "$LEGACY_FLAG"
|
||||
if [[ "$fail" -ne 0 ]]; then
|
||||
echo "start-agent-session legacy-socket guard regression FAILED" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "start-agent-session legacy-socket guard regression passed"
|
||||
@@ -503,6 +503,28 @@ if [[ -z "$BRANCH" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# T51 WP5b (spec 4.1, review ruling C4/F8): the declaration adds ROUTE CONTEXT only.
|
||||
# Branch-selection semantics are UNCHANGED — the guard keeps inspecting the
|
||||
# exact head above. No declaration dependency gates the wait (4.3/DR2 R9:
|
||||
# blocking here adds a blocker with no safety gain); absence is silent.
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/ci-queue-wait.sh
|
||||
if git rev-parse --show-toplevel >/dev/null 2>&1 \
|
||||
&& [ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/repo-decl.sh" ]; then
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/repo-decl.sh"
|
||||
repo_decl_load
|
||||
if [[ "$DECL_STATE" == invalid ]]; then
|
||||
repo_decl_report_invalid
|
||||
elif [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
|
||||
route="feature"
|
||||
if [[ "$BRANCH" == "$DECL_TRUNK" ]]; then
|
||||
route="trunk (integration head)"
|
||||
elif [[ "$BRANCH" == "$DECL_RELEASE" ]]; then
|
||||
route="release branch"
|
||||
fi
|
||||
echo "repo-decl: route context flow=$DECL_FLOW trunk=$DECL_TRUNK release=$DECL_RELEASE; guarded head '$BRANCH' is a $route head (spec 4.1)" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
record_cannot_assert "github-cli-unavailable"
|
||||
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
#!/bin/bash
|
||||
# grant-reviewer.sh - Grant a reviewer read + review access to an org-owned
|
||||
# Gitea repository via an org team (default: fleet-reviewers).
|
||||
#
|
||||
# Usage: grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]
|
||||
#
|
||||
# The team carries `permission: read` with per-unit overrides
|
||||
# {repo.code: read, repo.issues: write, repo.pulls: write}: the reviewer can
|
||||
# read code and write issues/PR reviews, but cannot push. The grant is
|
||||
# idempotent — the team is looked up before it is created, and member/repo
|
||||
# additions are PUTs.
|
||||
#
|
||||
# KNOWN LIMITATION — branch protection counts these reviews as UNOFFICIAL.
|
||||
# Gitea computes a review's `official` flag at SUBMISSION time, from write
|
||||
# permission on the repo or from membership in the protected branch's
|
||||
# approvals whitelist (disabled by default). A team granted through this
|
||||
# script has read permission on code, so under branch protection with
|
||||
# required_approvals the reviewer's approval shows but does NOT count toward
|
||||
# the required total — the merge still fails with "not enough approvals".
|
||||
# Enabling the approvals whitelist and adding this team to it is review
|
||||
# policy (who counts as an official approver), an operator decision made in
|
||||
# the repo's branch-protection settings, deliberately NOT automated here.
|
||||
# Because `official` is fixed at submission, whitelisting after the fact
|
||||
# requires the review to be re-submitted before it counts.
|
||||
#
|
||||
# Platform: Gitea only. On a GitHub-remoted repo this script refuses to run —
|
||||
# GitHub review access is granted through collaborator/team facilities that
|
||||
# have no equivalent to Gitea's org-team unit map.
|
||||
#
|
||||
# Identity: the acting credential resolves exactly as in issue-comment.sh —
|
||||
# GITEA_LOGIN (when set) names a tea login whose token MUST resolve for the
|
||||
# remote host (fail closed, never downgrade to the host default identity);
|
||||
# otherwise the per-seat identity ladder in detect-platform.sh applies
|
||||
# (MOSAIC_GIT_IDENTITY / git config mosaic.gitIdentity → per-slot token,
|
||||
# fail-loud on fleet hosts). Managing org teams requires org owner/admin:
|
||||
# an HTTP 403 from any step is reported as "org admin required on <org>",
|
||||
# never as a silent partial grant.
|
||||
#
|
||||
# Verification is fail-closed: after the member and repo PUTs, the script
|
||||
# GETs the single resources back (GET /teams/{id}/members/{user} and
|
||||
# GET /teams/{id}/repos/{owner}/{repo}) and refuses to report success unless
|
||||
# both confirm the grant. A PUT that returns success without persisting
|
||||
# (the #865 defect class: an exit code is not evidence of a durable write)
|
||||
# therefore fails the run instead of reporting a grant that does not exist.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
usage() {
|
||||
echo "Usage: grant-reviewer.sh -u <user> [-r <owner>/<repo>] [-t <team>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -u, --user Gitea username to grant reviewer access (required)"
|
||||
echo " -r, --repo Target repository as <owner>/<repo>; defaults to the"
|
||||
echo " current repository's origin. The owner must be an"
|
||||
echo " organization."
|
||||
echo " -t, --team Org team to use/create (default: fleet-reviewers)"
|
||||
echo " -h, --help Show this help"
|
||||
echo ""
|
||||
echo "Environment:"
|
||||
echo " GITEA_LOGIN Override the acting identity with a named tea login"
|
||||
echo " (must resolve for the remote host; fails closed)."
|
||||
echo ""
|
||||
echo "Grants: code read + issues/pulls write via an org team. Gitea only."
|
||||
echo ""
|
||||
echo "LIMITATION: under branch protection with required approvals, reviews"
|
||||
echo "from a read-permission team are official=false and do not count"
|
||||
echo "toward the required total. Making them count means enabling the"
|
||||
echo "protected branch's approvals whitelist and adding the team — an"
|
||||
echo "operator review-policy decision this script does not automate. The"
|
||||
echo "official flag is computed at review submission, so a review made"
|
||||
echo "before whitelisting must be re-submitted afterwards."
|
||||
}
|
||||
|
||||
REVIEWER=""
|
||||
REPO_OVERRIDE=""
|
||||
TEAM="fleet-reviewers"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-u|--user)
|
||||
REVIEWER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-r|--repo)
|
||||
REPO_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-t|--team)
|
||||
TEAM="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$REVIEWER" ]]; then
|
||||
echo "Error: reviewer username is required (-u)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Gitea usernames and team names are AlphaDashDot. Validating here keeps the
|
||||
# values safe to interpolate into API paths without URL-encoding.
|
||||
NAME_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$'
|
||||
if ! [[ "$REVIEWER" =~ $NAME_RE ]]; then
|
||||
echo "Error: invalid reviewer username '$REVIEWER'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$TEAM" =~ $NAME_RE ]]; then
|
||||
echo "Error: invalid team name '$TEAM'" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$REPO_OVERRIDE" ]] && ! [[ "$REPO_OVERRIDE" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
|
||||
echo "Error: -r expects <owner>/<repo>, got '$REPO_OVERRIDE'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
|
||||
if [[ "$PLATFORM" != "gitea" ]]; then
|
||||
echo "Error: grant-reviewer.sh is Gitea only (detected platform: $PLATFORM)." >&2
|
||||
echo " On GitHub, grant review access via repository collaborators or org teams in the GitHub UI/CLI." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HOST=$(get_remote_host) || {
|
||||
echo "Error: could not resolve the remote host from origin" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Acting credential: GITEA_LOGIN (explicit, fail closed) or the identity
|
||||
# ladder. Same ordering contract as issue-comment.sh — an explicit override is
|
||||
# never silently downgraded to the host default identity.
|
||||
if [[ -n "${GITEA_LOGIN:-}" ]]; then
|
||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$GITEA_LOGIN" "$HOST") || {
|
||||
echo "Error: could not resolve a host-matched Gitea token for GITEA_LOGIN '$GITEA_LOGIN' on host '$HOST'; refusing to fall back to the host default identity (reviewer grant)" >&2
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
GITEA_API_TOKEN=$(get_gitea_token "$HOST") || {
|
||||
echo "Error: no Gitea credential resolved for the acting identity on host '$HOST' (reviewer grant). Set MOSAIC_GIT_IDENTITY=<agent-id>, or set GITEA_LOGIN=<name> to use a named tea credential." >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
CONFIGURED_URL=$(get_gitea_url_for_host "$HOST") || {
|
||||
echo "Error: configured Gitea URL not found for host '$HOST'" >&2
|
||||
exit 1
|
||||
}
|
||||
GITEA_API_ROOT="${CONFIGURED_URL%/}/api/v1"
|
||||
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
REPO_SLUG="$REPO_OVERRIDE"
|
||||
else
|
||||
REPO_SLUG=$(get_gitea_repo_slug_for_url "$CONFIGURED_URL") || {
|
||||
echo "Error: could not resolve <owner>/<repo> from origin; pass -r <owner>/<repo>" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
ORG="${REPO_SLUG%%/*}"
|
||||
REPO_NAME="${REPO_SLUG#*/}"
|
||||
|
||||
RESPONSE_FILE=$(mktemp "${TMPDIR:-/tmp}/mosaic-grant-reviewer-resp.XXXXXX")
|
||||
AUTH_CONFIG=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
rm -f "$RESPONSE_FILE"
|
||||
echo "Error: could not stage Gitea credential for reviewer grant" >&2
|
||||
exit 1
|
||||
}
|
||||
trap 'rm -f "$RESPONSE_FILE" "$AUTH_CONFIG"' EXIT
|
||||
|
||||
# gitea_api <step> <method> <path> [json-payload]
|
||||
# Runs one API call with the staged credential (token never in argv). Sets
|
||||
# GITEA_API_STATUS and leaves the body in $RESPONSE_FILE. Transport failure
|
||||
# and HTTP 403 are terminal here: 403 on ANY step means the acting identity
|
||||
# cannot manage org teams, and the run must stop rather than continue into a
|
||||
# partial grant.
|
||||
gitea_api() {
|
||||
local step="$1" method="$2" path="$3" payload="${4:-}"
|
||||
local -a payload_args=()
|
||||
if [[ -n "$payload" ]]; then
|
||||
payload_args=(-H 'Content-Type: application/json' -d "$payload")
|
||||
fi
|
||||
if ! GITEA_API_STATUS=$(curl -sS -o "$RESPONSE_FILE" -w '%{http_code}' \
|
||||
-X "$method" \
|
||||
--config "$AUTH_CONFIG" \
|
||||
"${payload_args[@]}" \
|
||||
"$GITEA_API_ROOT$path"); then
|
||||
echo "Error: Gitea transport failed during $step" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$GITEA_API_STATUS" == "403" ]]; then
|
||||
echo "Error: HTTP 403 during $step: org admin required on '$ORG' — managing org teams needs owner/admin on the organization. No grant was completed." >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# json_field <file> <key> — print a top-level scalar field or fail.
|
||||
json_field() {
|
||||
python3 - "$1" "$2" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
data = json.load(response)
|
||||
value = data.get(sys.argv[2]) if isinstance(data, dict) else None
|
||||
if value is None or isinstance(value, (dict, list, bool)):
|
||||
raise ValueError(f"missing or non-scalar field {sys.argv[2]!r}")
|
||||
except (OSError, json.JSONDecodeError, ValueError) as error:
|
||||
print(f"Error: unusable Gitea response: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(value)
|
||||
PY
|
||||
}
|
||||
|
||||
# 1. The owner must be an organization: teams are an org facility, and a
|
||||
# user-owned repo would fail later with a misleading team error.
|
||||
gitea_api "organization check" GET "/orgs/$ORG"
|
||||
if [[ "$GITEA_API_STATUS" == "404" ]]; then
|
||||
echo "Error: owner '$ORG' is not an organization on '$HOST'; grant-reviewer requires an org-owned repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$GITEA_API_STATUS" != "200" ]]; then
|
||||
echo "Error: organization check for '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Idempotent team resolution: exact-name lookup first, create only on miss.
|
||||
# The search endpoint substring-matches, so the exact-name filter is done
|
||||
# on the response, not trusted to the query.
|
||||
gitea_api "team lookup" GET "/orgs/$ORG/teams/search?q=$TEAM"
|
||||
if [[ "$GITEA_API_STATUS" != "200" ]]; then
|
||||
echo "Error: team lookup for '$TEAM' on '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
|
||||
exit 1
|
||||
fi
|
||||
TEAM_ID=$(TEAM_NAME="$TEAM" python3 - "$RESPONSE_FILE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
wanted = os.environ["TEAM_NAME"]
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
result = json.load(response)
|
||||
teams = result.get("data") if isinstance(result, dict) else None
|
||||
if not isinstance(teams, list):
|
||||
raise ValueError("team search response carried no data list")
|
||||
except (OSError, json.JSONDecodeError, ValueError) as error:
|
||||
print(f"Error: unusable team search response: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
for team in teams:
|
||||
if isinstance(team, dict) and team.get("name") == wanted:
|
||||
team_id = team.get("id")
|
||||
if not isinstance(team_id, int) or team_id <= 0:
|
||||
print("Error: matched team carried no positive id", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(team_id)
|
||||
raise SystemExit(0)
|
||||
print("")
|
||||
PY
|
||||
)
|
||||
|
||||
if [[ -z "$TEAM_ID" ]]; then
|
||||
CREATE_PAYLOAD=$(TEAM_NAME="$TEAM" python3 -c '
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({
|
||||
"name": os.environ["TEAM_NAME"],
|
||||
"description": "review seats: code read + issues/pulls write",
|
||||
"permission": "read",
|
||||
"includes_all_repositories": False,
|
||||
"can_create_org_repo": False,
|
||||
"units_map": {
|
||||
"repo.code": "read",
|
||||
"repo.issues": "write",
|
||||
"repo.pulls": "write",
|
||||
},
|
||||
}))
|
||||
')
|
||||
gitea_api "team create" POST "/orgs/$ORG/teams" "$CREATE_PAYLOAD"
|
||||
if [[ "$GITEA_API_STATUS" != "201" ]]; then
|
||||
echo "Error: team create for '$TEAM' on '$ORG' failed with HTTP $GITEA_API_STATUS" >&2
|
||||
exit 1
|
||||
fi
|
||||
TEAM_ID=$(json_field "$RESPONSE_FILE" id) || {
|
||||
echo "Error: team create returned no usable team id" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "Created team '$TEAM' (id $TEAM_ID) on org '$ORG'"
|
||||
else
|
||||
echo "Found existing team '$TEAM' (id $TEAM_ID) on org '$ORG'"
|
||||
fi
|
||||
|
||||
# 3. Membership and repo attachment — both PUTs, both idempotent in Gitea.
|
||||
gitea_api "member add" PUT "/teams/$TEAM_ID/members/$REVIEWER"
|
||||
if [[ "$GITEA_API_STATUS" != "204" ]]; then
|
||||
echo "Error: adding '$REVIEWER' to team '$TEAM' failed with HTTP $GITEA_API_STATUS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gitea_api "repo add" PUT "/teams/$TEAM_ID/repos/$ORG/$REPO_NAME"
|
||||
if [[ "$GITEA_API_STATUS" != "204" ]]; then
|
||||
echo "Error: adding repo '$REPO_SLUG' to team '$TEAM' failed with HTTP $GITEA_API_STATUS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 4. Fail-closed read-back: a 204 from a PUT is an exit code, not evidence the
|
||||
# grant persisted. GET the single resources back and require both.
|
||||
gitea_api "member read-back" GET "/teams/$TEAM_ID/members/$REVIEWER"
|
||||
if [[ "$GITEA_API_STATUS" != "200" ]]; then
|
||||
echo "Error: reviewer grant NOT verified — GET /teams/$TEAM_ID/members/$REVIEWER returned HTTP $GITEA_API_STATUS after a successful PUT. Treat the grant as not made." >&2
|
||||
exit 1
|
||||
fi
|
||||
READBACK_LOGIN=$(json_field "$RESPONSE_FILE" login) || exit 1
|
||||
if [[ "${READBACK_LOGIN,,}" != "${REVIEWER,,}" ]]; then
|
||||
echo "Error: reviewer grant NOT verified — member read-back returned login '$READBACK_LOGIN', expected '$REVIEWER'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gitea_api "repo read-back" GET "/teams/$TEAM_ID/repos/$ORG/$REPO_NAME"
|
||||
if [[ "$GITEA_API_STATUS" != "200" ]]; then
|
||||
echo "Error: reviewer grant NOT verified — GET /teams/$TEAM_ID/repos/$ORG/$REPO_NAME returned HTTP $GITEA_API_STATUS after a successful PUT. Treat the grant as not made." >&2
|
||||
exit 1
|
||||
fi
|
||||
READBACK_FULL_NAME=$(json_field "$RESPONSE_FILE" full_name) || exit 1
|
||||
if [[ "${READBACK_FULL_NAME,,}" != "${REPO_SLUG,,}" ]]; then
|
||||
echo "Error: reviewer grant NOT verified — repo read-back returned '$READBACK_FULL_NAME', expected '$REPO_SLUG'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Granted: '$REVIEWER' is a member of team '$TEAM' (id $TEAM_ID) with access to '$REPO_SLUG' (code read, issues/pulls write) — verified by read-back"
|
||||
echo "Note: under branch protection with required approvals this reviewer's approvals are official=false unless the branch's approvals whitelist includes the team (operator decision; reviews submitted before whitelisting must be re-submitted)."
|
||||
@@ -192,6 +192,47 @@ cmd_new() {
|
||||
local path; path="$(derive_path "$branch")"
|
||||
assert_not_home "$path"
|
||||
|
||||
# T51 WP5b (spec 4.4 staged rule + 4.5 advisory policy): placement stays
|
||||
# DERIVED; the declaration never moves the worktree. An INVALID declaration
|
||||
# fails branch-creation loud (a broken structure file must not ride a new
|
||||
# branch); an absent one warns (rollout window, Q-C); a valid one contributes
|
||||
# policy ADVICE only. The advisory worktree_root comparison runs only when
|
||||
# MOSAIC_HOST_ROOT is set (1.2a: warn-and-omit for advisory display).
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
|
||||
_rd="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)/repo-decl.sh"
|
||||
if [ -f ""$_rd"" ]; then
|
||||
source ""$_rd""
|
||||
repo_decl_load
|
||||
else
|
||||
DECL_STATE=absent; DECL_SCHEMA=""
|
||||
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
|
||||
repo_decl_report_invalid() { :; }
|
||||
repo_decl_warn_absent_reversible() { :; }
|
||||
repo_decl_warn_absent_irreversible() { :; }
|
||||
repo_decl_remote_matches() { return 0; }
|
||||
repo_decl_check_transition() { return 2; }
|
||||
fi
|
||||
case "$DECL_STATE" in
|
||||
invalid)
|
||||
repo_decl_report_invalid
|
||||
die "worktree branch-creation refused: the structure declaration is invalid (spec 4.4 — fix it before creating branches)"
|
||||
;;
|
||||
absent)
|
||||
repo_decl_warn_absent_irreversible "worktree branch-creation"
|
||||
;;
|
||||
valid)
|
||||
if [ "$DECL_SCHEMA" = 2 ] && [ "$DECL_POLICY" = "orchestrator-precreated" ]; then
|
||||
echo "repo-decl: worktree_policy=orchestrator-precreated (TRANSITIONAL, spec 4.5): tasking pre-creates worktrees; creating one directly is the interim path until the J3/#1174 amendment unblocks the wrapper consumer." >&2
|
||||
fi
|
||||
if [ -n "${MOSAIC_HOST_ROOT:-}" ] && [ -n "$DECL_WT_ROOT" ]; then
|
||||
dwt="$(repo_decl_path "$DECL_WT_ROOT" 2>/dev/null || true)"
|
||||
if [ -n "$dwt" ] && [ "${dwt%/}" != "${WT_ROOT%/}" ]; then
|
||||
echo "repo-decl: derived root $WT_ROOT diverges from the declared advisory worktree_root $dwt (advisory per spec 4.1/5.2 — placement stays derived)" >&2
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -e "$path" ]; then
|
||||
echo "exists: $path"
|
||||
echo "(already checked out — reuse it, or 'rm' it first)"
|
||||
|
||||
@@ -78,8 +78,8 @@ gitea_pr_create_api() {
|
||||
# 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 "$BASE_BRANCH" ]]; then
|
||||
api_base="$BASE_BRANCH"
|
||||
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
|
||||
@@ -198,6 +198,52 @@ if [[ -z "$HEAD_BRANCH" ]]; then
|
||||
HEAD_BRANCH=$(git branch --show-current)
|
||||
fi
|
||||
|
||||
# T51 WP5b: declaration-driven base resolution (spec 4.1). Precedence:
|
||||
# explicit -B -> validated as an ALLOWED transition (4.2: a flag is input,
|
||||
# not authority) when a consumable declaration exists
|
||||
# declared trunk (v2 declarations only) -> used directly
|
||||
# legacy -> WP5a forge-default floor (unmanaged/absent/v1, 4.3)
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
|
||||
if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
|
||||
source "$SCRIPT_DIR/repo-decl.sh"
|
||||
repo_decl_load
|
||||
else
|
||||
DECL_STATE=absent; DECL_SCHEMA=""
|
||||
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
|
||||
repo_decl_report_invalid() { :; }
|
||||
repo_decl_warn_absent_reversible() { :; }
|
||||
repo_decl_warn_absent_irreversible() { :; }
|
||||
repo_decl_remote_matches() { return 0; }
|
||||
repo_decl_check_transition() { return 2; }
|
||||
fi
|
||||
EFFECTIVE_BASE="$BASE_BRANCH"
|
||||
case "$DECL_STATE" in
|
||||
invalid) repo_decl_report_invalid ;;
|
||||
esac
|
||||
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" != 2 ]]; then
|
||||
repo_decl_warn "declaration is v$DECL_SCHEMA — carries no consumable flow/trunk fields; legacy behavior"
|
||||
fi
|
||||
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
|
||||
# Write path: a normalized-remote mismatch refuses (spec 5.3).
|
||||
if ! repo_decl_remote_matches; then
|
||||
echo "Error: origin remote does not match the declared canonical_remote (spec 5.3, write path) — refusing to create a PR against the wrong forge. Fix the origin remote or the declaration." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$BASE_BRANCH" ]]; then
|
||||
trc=0
|
||||
repo_decl_check_transition "$HEAD_BRANCH" "$BASE_BRANCH" || trc=$?
|
||||
if [[ "$trc" == 1 ]]; then
|
||||
echo "Error: -B '$BASE_BRANCH' is not an allowed transition for head '$HEAD_BRANCH' under the declared flow (spec 4.2). The declaration governs; supply an allowed base." >&2
|
||||
exit 1
|
||||
fi
|
||||
# trc 2 cannot happen here (state=valid): 0 = allowed
|
||||
else
|
||||
EFFECTIVE_BASE="$DECL_TRUNK"
|
||||
fi
|
||||
elif [[ -z "$BASE_BRANCH" ]]; then
|
||||
repo_decl_warn_absent_reversible "pr-create"
|
||||
fi
|
||||
|
||||
# Add issue reference to body if provided
|
||||
if [[ -n "$ISSUE" ]]; then
|
||||
if [[ -n "$BODY" ]]; then
|
||||
@@ -215,7 +261,7 @@ case "$PLATFORM" in
|
||||
github)
|
||||
CMD=(gh pr create --title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||
[[ -n "$EFFECTIVE_BASE" ]] && CMD+=(--base "$EFFECTIVE_BASE")
|
||||
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
|
||||
[[ -n "$LABELS" ]] && CMD+=(--label "$LABELS")
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||
@@ -240,7 +286,7 @@ case "$PLATFORM" in
|
||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
||||
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||
[[ -n "$EFFECTIVE_BASE" ]] && CMD+=(--base "$EFFECTIVE_BASE")
|
||||
[[ -n "$HEAD_BRANCH" ]] && CMD+=(--head "$HEAD_BRANCH")
|
||||
|
||||
# Handle labels for tea
|
||||
|
||||
@@ -137,9 +137,44 @@ HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=js
|
||||
BASE_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("baseRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||
PR_TITLE="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("title") or "").strip())')"
|
||||
PR_AUTHOR="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("author") or ""; print((value.get("login") or "").strip() if isinstance(value, dict) else str(value).strip())')"
|
||||
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
# T51 WP5b: transition validation against the declaration (spec 4.1/4.2).
|
||||
# Target branches are validated against the declaration, NEVER hardcoded;
|
||||
# the legacy main/next check survives only for undeclared repos during the
|
||||
# rollout window (4.3 irreversible class, loud warning).
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/repo-decl.sh
|
||||
if [ -f "$SCRIPT_DIR/repo-decl.sh" ]; then
|
||||
source "$SCRIPT_DIR/repo-decl.sh"
|
||||
repo_decl_load
|
||||
else
|
||||
DECL_STATE=absent; DECL_SCHEMA=""
|
||||
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
|
||||
repo_decl_report_invalid() { :; }
|
||||
repo_decl_warn_absent_reversible() { :; }
|
||||
repo_decl_warn_absent_irreversible() { :; }
|
||||
repo_decl_remote_matches() { return 0; }
|
||||
repo_decl_check_transition() { return 2; }
|
||||
fi
|
||||
if [[ "$DECL_STATE" == invalid ]]; then
|
||||
repo_decl_report_invalid
|
||||
fi
|
||||
if [[ "$DECL_STATE" == valid && "$DECL_SCHEMA" == 2 ]]; then
|
||||
if ! repo_decl_remote_matches; then
|
||||
echo "Error: origin remote does not match the declared canonical_remote (spec 5.3, write path) — refusing to merge against the wrong forge. Fix the origin remote or the declaration." >&2
|
||||
exit 1
|
||||
fi
|
||||
trc=0
|
||||
repo_decl_check_transition "$HEAD_BRANCH" "$BASE_BRANCH" || trc=$?
|
||||
if [[ "$trc" == 1 ]]; then
|
||||
echo "Error: PR '$HEAD_BRANCH' -> '$BASE_BRANCH' is not a declared transition (flow=$DECL_FLOW, trunk=$DECL_TRUNK, release=$DECL_RELEASE; spec 4.2)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "repo-decl: transition OK under flow=$DECL_FLOW (trunk=$DECL_TRUNK release=$DECL_RELEASE)" >&2
|
||||
else
|
||||
repo_decl_warn_absent_irreversible "pr-merge"
|
||||
if [[ "$BASE_BRANCH" != "main" && "$BASE_BRANCH" != "next" ]]; then
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' or 'next' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env bash
|
||||
# repo-decl.sh — shared .mosaic/repo.json consumption for the git wrappers (T51 WP5b).
|
||||
#
|
||||
# Spec of record: docs/plans/2026-08-23_repo-structure-declaration.md (brain
|
||||
# repo) sections 4 (consumption contract), 5.3 (normalization), 5.4
|
||||
# (enforcement points), 1.2a (root anchoring). ALL consumers invoke the SAME
|
||||
# WP1 validator (spec 5.1 — no in-process-only parsing of the declaration).
|
||||
#
|
||||
# Source this file, then call repo_decl_load once. It sets:
|
||||
# DECL_STATE absent | invalid | valid
|
||||
# DECL_FILE the declaration path that was inspected
|
||||
# DECL_ERROR the validator's error line when DECL_STATE=invalid
|
||||
# DECL_TRUNK / DECL_RELEASE / DECL_FLOW / DECL_REMOTE / DECL_POLICY /
|
||||
# DECL_WT_ROOT / DECL_CLONE (populated only when DECL_STATE=valid)
|
||||
# DECL_ORIGIN_N the normalized origin URL (when resolvable)
|
||||
#
|
||||
# Enforcement point 5.4(1): an invalid or unknown-version file counts as
|
||||
# ABSENT for behavior, PLUS a loud error naming the file and the validator's
|
||||
# key/reason — callers print DECL_ERROR (repo_decl_report_invalid) whenever
|
||||
# they loaded something that failed validation; they do not silently ignore a
|
||||
# broken file.
|
||||
#
|
||||
# Absence behavior (4.3) is the CALLER's policy (reversible vs irreversible;
|
||||
# managed vs unmanaged — the adoption register is WP6, so during rollout every
|
||||
# repo is unmanaged: warn + legacy). Helpers below provide the shared wordings.
|
||||
#
|
||||
# Root-dependent fields: consumers here read branch/flow/remote/policy only —
|
||||
# NO path resolution happens in this library. The one helper that would
|
||||
# resolve a host:/ path (repo_decl_path) fails closed while MOSAIC_HOST_ROOT
|
||||
# is unset (1.2a: never guess a root), for any future caller that needs it.
|
||||
#
|
||||
# No output on success; diagnostics go to stderr.
|
||||
|
||||
REPO_DECL_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DECL_VALIDATOR="$REPO_DECL_SCRIPT_DIR/../structure/validate-repo-json.sh"
|
||||
|
||||
repo_decl_warn() { printf 'repo-decl: %s\n' "$*" >&2; }
|
||||
|
||||
# Load and classify the declaration for the repo containing the current
|
||||
# directory. Never fatal — classification is the product.
|
||||
repo_decl_load() {
|
||||
DECL_STATE=absent
|
||||
DECL_FILE=""
|
||||
DECL_ERROR=""
|
||||
DECL_SCHEMA=""
|
||||
DECL_TRUNK=""; DECL_RELEASE=""; DECL_FLOW=""; DECL_REMOTE=""
|
||||
DECL_POLICY=""; DECL_WT_ROOT=""; DECL_CLONE=""
|
||||
DECL_ORIGIN_N=""
|
||||
|
||||
local root
|
||||
root="$(git rev-parse --show-toplevel 2>/dev/null)" || {
|
||||
repo_decl_warn "no git repository — declaration consumption skipped"
|
||||
return 0
|
||||
}
|
||||
DECL_FILE="$root/.mosaic/repo.json"
|
||||
[ -f "$DECL_FILE" ] || return 0
|
||||
|
||||
if [ ! -x "$REPO_DECL_VALIDATOR" ] && [ ! -f "$REPO_DECL_VALIDATOR" ]; then
|
||||
# 5.1 mandates the shared validator; a missing validator is an
|
||||
# infrastructure failure, not an absent declaration.
|
||||
repo_decl_warn "validator not found at $REPO_DECL_VALIDATOR — treating declaration as invalid"
|
||||
DECL_STATE=invalid
|
||||
DECL_ERROR="VALIDATION_ERROR validator: the shared validator is missing"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local vout
|
||||
if ! vout="$("$REPO_DECL_VALIDATOR" "$DECL_FILE" --mode display 2>&1)"; then
|
||||
DECL_STATE=invalid
|
||||
DECL_ERROR="$(printf '%s\n' "$vout" | grep -m1 'VALIDATION_ERROR' || printf '%s\n' "$vout" | head -1)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Valid: extract the consumed fields via the same python stdlib the
|
||||
# ecosystem already uses. Field-level grammar was the validator's job.
|
||||
eval "$(python3 - "$DECL_FILE" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
def q(k):
|
||||
v = d.get(k, "")
|
||||
return v if isinstance(v, str) else ""
|
||||
sv = d.get("schema_version", 1)
|
||||
print(f"DECL_SCHEMA={sv if isinstance(sv, int) and not isinstance(sv, bool) else 0!r}")
|
||||
print(f"DECL_TRUNK={q('integration_trunk')!r}")
|
||||
print(f"DECL_RELEASE={q('release_branch')!r}")
|
||||
print(f"DECL_FLOW={q('flow')!r}")
|
||||
print(f"DECL_REMOTE={q('canonical_remote')!r}")
|
||||
print(f"DECL_POLICY={q('worktree_policy')!r}")
|
||||
print(f"DECL_WT_ROOT={q('worktree_root')!r}")
|
||||
print(f"DECL_CLONE={q('canonical_clone')!r}")
|
||||
PY
|
||||
)" || {
|
||||
DECL_STATE=invalid
|
||||
DECL_ERROR="VALIDATION_ERROR internal: field extraction failed"
|
||||
return 0
|
||||
}
|
||||
DECL_STATE=valid
|
||||
|
||||
# 5.3: normalize origin once for remote comparisons (read callers warn,
|
||||
# write callers refuse). An unresolvable origin is left empty — callers
|
||||
# treat empty as "cannot compare" and act per their read/write policy.
|
||||
local ourl
|
||||
if ourl="$(git remote get-url origin 2>/dev/null)" && [ -n "$ourl" ]; then
|
||||
DECL_ORIGIN_N="$("$REPO_DECL_VALIDATOR" --normalize-remote "$ourl" 2>/dev/null || true)"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# The mandatory loud error for an invalid file (5.4 point 1). Callers invoke
|
||||
# this whenever DECL_STATE=invalid, regardless of their proceed/refuse policy.
|
||||
repo_decl_report_invalid() {
|
||||
repo_decl_warn "declaration INVALID at $DECL_FILE — $DECL_ERROR"
|
||||
repo_decl_warn "treating the declaration as ABSENT (spec 5.4); legacy behavior follows"
|
||||
}
|
||||
|
||||
# Shared absence wordings (4.3, rollout window: no adoption register yet, so
|
||||
# every repo is unmanaged; warn + legacy per the Q-C ruling).
|
||||
repo_decl_warn_absent_reversible() { # $1 = operation name
|
||||
repo_decl_warn "no .mosaic/repo.json — $1 is unmanaged during rollout: legacy behavior, no declaration guarantees (spec 4.3)"
|
||||
}
|
||||
repo_decl_warn_absent_irreversible() { # $1 = operation name
|
||||
repo_decl_warn "no .mosaic/repo.json — $1 proceeds on LEGACY assumptions during the rollout window; declaration-validated transitions unavailable (spec 4.3)"
|
||||
}
|
||||
|
||||
# Remote comparison (5.3). rc 0 match/unknown, rc 1 mismatch.
|
||||
repo_decl_remote_matches() {
|
||||
[ "$DECL_STATE" = valid ] || return 0
|
||||
[ -n "$DECL_ORIGIN_N" ] && [ -n "$DECL_REMOTE" ] || return 0
|
||||
local want
|
||||
want="$("$REPO_DECL_VALIDATOR" --normalize-remote "$DECL_REMOTE" 2>/dev/null || true)"
|
||||
[ -n "$want" ] || return 0
|
||||
[ "$DECL_ORIGIN_N" = "$want" ]
|
||||
}
|
||||
|
||||
# Resolve a host:/-anchored declaration path (1.2a). Fails CLOSED while
|
||||
# MOSAIC_HOST_ROOT is unset or empty — never guesses a root. No WP5b consumer
|
||||
# calls this today; it exists so the first one that needs a path cannot
|
||||
# silently guess.
|
||||
repo_decl_path() { # $1 = host:/... value; prints the resolved absolute path
|
||||
local v="${1:-}" root="${MOSAIC_HOST_ROOT:-}"
|
||||
case "$v" in
|
||||
host:/*) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
if [ -z "$root" ]; then
|
||||
repo_decl_warn "MOSAIC_HOST_ROOT is unset — refusing to resolve '$v' (spec 1.2a fail-closed; never guess a root)"
|
||||
return 1
|
||||
fi
|
||||
printf '%s/%s\n' "${root%/}" "${v#host:/}"
|
||||
}
|
||||
|
||||
# Transition validation (4.2: a CLI flag is input, not authority).
|
||||
# rc 0 = allowed; rc 1 = forbidden (message on stderr); rc 2 = no valid
|
||||
# declaration (caller applies its absence policy).
|
||||
# flow=direct: base must be the trunk (trunk == release); head must
|
||||
# differ from it.
|
||||
# flow=trunk-release: feature->trunk allowed; trunk->release allowed (release
|
||||
# promotion: head IS the trunk); anything else refused —
|
||||
# feature->release explicitly REJECTED.
|
||||
repo_decl_check_transition() { # $1 head, $2 base
|
||||
[ "$DECL_STATE" = valid ] || return 2
|
||||
local head="$1" base="$2"
|
||||
if [ -z "$head" ] || [ -z "$base" ]; then
|
||||
repo_decl_warn "transition check needs a head and a base (got head='$head' base='$base')"
|
||||
return 1
|
||||
fi
|
||||
if [ "$head" = "$base" ]; then
|
||||
repo_decl_warn "forbidden transition: head '$head' equals base '$base'"
|
||||
return 1
|
||||
fi
|
||||
case "$DECL_FLOW" in
|
||||
direct)
|
||||
if [ "$base" = "$DECL_TRUNK" ]; then
|
||||
return 0
|
||||
fi
|
||||
repo_decl_warn "forbidden transition (flow=direct): base must be the trunk '$DECL_TRUNK', got '$base'"
|
||||
return 1
|
||||
;;
|
||||
trunk-release)
|
||||
if [ "$base" = "$DECL_TRUNK" ] && [ "$head" != "$DECL_TRUNK" ] && [ "$head" != "$DECL_RELEASE" ]; then
|
||||
return 0 # feature -> trunk
|
||||
fi
|
||||
if [ "$head" = "$DECL_TRUNK" ] && [ "$base" = "$DECL_RELEASE" ]; then
|
||||
return 0 # release promotion: trunk -> release
|
||||
fi
|
||||
if [ "$base" = "$DECL_RELEASE" ] && [ "$head" != "$DECL_TRUNK" ]; then
|
||||
repo_decl_warn "forbidden transition (flow=trunk-release): feature->release is REJECTED (head '$head' -> release '$DECL_RELEASE'); promote via $DECL_TRUNK"
|
||||
return 1
|
||||
fi
|
||||
repo_decl_warn "forbidden transition (flow=trunk-release): '$head' -> '$base' is not a declared transition (feature->$DECL_TRUNK or $DECL_TRUNK->$DECL_RELEASE)"
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
repo_decl_warn "unknown declared flow '$DECL_FLOW'"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
+616
@@ -0,0 +1,616 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for grant-reviewer.sh (#1415): org-team reviewer grant
|
||||
# with fail-closed read-back verification.
|
||||
#
|
||||
# This harness models a REAL server: the curl stub keeps persistent team/
|
||||
# member/repo state on disk, the POST actually CREATES and PERSISTS the team,
|
||||
# the member/repo PUTs persist (except in the sabotage modes), and the
|
||||
# read-back GETs answer from that same state. There is no fabricated record
|
||||
# for the wrapper to "find" — verification passes only if the PUTs genuinely
|
||||
# persisted what the read-back retrieves. It proves the wrapper:
|
||||
# 1. creates the team with the EXACT reviewer payload (permission: read,
|
||||
# units_map {repo.code: read, repo.issues: write, repo.pulls: write}) —
|
||||
# the stub rejects any other payload;
|
||||
# 2. is idempotent: an existing team is found by EXACT name (a decoy team
|
||||
# whose name merely CONTAINS the wanted name is listed first and must
|
||||
# not be matched) and no create POST is issued;
|
||||
# 3. refuses to run against a GitHub-remoted repo (Gitea only);
|
||||
# 4. refuses when the owner is not an organization;
|
||||
# 5. maps HTTP 403 to "org admin required on <org>" and stops before any
|
||||
# partial grant;
|
||||
# 6. fails closed when the member PUT returns 204 without persisting (the
|
||||
# #865 defect class: an exit code is not evidence of a durable write);
|
||||
# 7. fails closed when the repo PUT returns 204 without persisting;
|
||||
# 8. with GITEA_LOGIN set, performs EVERY request under that login's token
|
||||
# (never the host default), and with an UNRESOLVABLE GITEA_LOGIN fails
|
||||
# closed with ZERO API calls instead of downgrading;
|
||||
# 9. never lets the bearer token ride in curl argv (curl --config only);
|
||||
# 10. leaves no temp files behind on success or failure paths.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/grant-reviewer}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
GH_REPO_DIR="$WORK_DIR/gh-repo"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
XDG_DIR="$WORK_DIR/xdg"
|
||||
TEA_LOG="$WORK_DIR/tea.log"
|
||||
CURL_LOG="$WORK_DIR/curl.log"
|
||||
# Full curl argv per invocation — proves the bearer token never rides in argv.
|
||||
CURL_ARGV_LOG="$WORK_DIR/curl-argv.log"
|
||||
AUTH_LOG="$WORK_DIR/auth.log"
|
||||
OUTPUT_FILE="$WORK_DIR/output.log"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
STATE_FILE="$WORK_DIR/grants.json"
|
||||
PAYLOAD_VIOLATION_FILE="$WORK_DIR/payload-violation"
|
||||
TMP_SCRATCH="$WORK_DIR/scratch"
|
||||
HOME_DIR="$WORK_DIR/home"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$REPO_DIR" "$GH_REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH" "$HOME_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
git -C "$GH_REPO_DIR" init -q
|
||||
git -C "$GH_REPO_DIR" remote add origin https://github.com/someorg/somerepo.git
|
||||
# HERMETICITY (#1007): get_gitea_token() step 0 resolves a per-agent identity
|
||||
# from `git config --get mosaic.gitIdentity`, which on a provisioned seat is
|
||||
# set GLOBALLY and leaks into this fresh repo, after which a REAL per-slot
|
||||
# token is read from $HOME and the fixture credential is silently ignored. An
|
||||
# empty repo-local value shadows the global one and reads back empty at rc=0.
|
||||
# (The env-var route does NOT neutralize step 0's git-config read — but the
|
||||
# run env below still pins MOSAIC_GIT_IDENTITY= empty so the ENV rung of the
|
||||
# ladder cannot resolve either: `${MOSAIC_GIT_IDENTITY:-}` treats set-but-empty
|
||||
# as unset.)
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
git -C "$GH_REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
ORG="mosaicstack"
|
||||
REPO_SLUG="mosaicstack/stack"
|
||||
API_ROOT="https://git.mosaicstack.dev/api/v1"
|
||||
REVIEWER="rev-user"
|
||||
TEAM_NAME="fleet-reviewers"
|
||||
TEAM_ID=42
|
||||
DECOY_TEAM_ID=99
|
||||
DEFAULT_TOKEN="test-only-placeholder"
|
||||
DEFAULT_IDENTITY="seat-default"
|
||||
OVERRIDE_LOGIN="granter"
|
||||
OVERRIDE_TOKEN="override-token-placeholder"
|
||||
|
||||
# tea config: the GITEA_LOGIN override login has its own host-bound token here.
|
||||
mkdir -p "$XDG_DIR/tea"
|
||||
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
|
||||
python3 - "$XDG_DIR/tea/config.yml" <<'PY'
|
||||
import os
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||
handle.write("logins:\n")
|
||||
handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n")
|
||||
handle.write(" url: https://git.mosaicstack.dev\n")
|
||||
handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n")
|
||||
PY
|
||||
|
||||
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
|
||||
json.dump({
|
||||
"gitea": {
|
||||
"mosaicstack": {
|
||||
"url": os.environ["CONFIGURED_GITEA_URL"],
|
||||
"token": "test-only-placeholder",
|
||||
}
|
||||
}
|
||||
}, credentials)
|
||||
PY
|
||||
|
||||
# tea stub: grant-reviewer.sh must never shell out to tea at all.
|
||||
cat > "$BIN_DIR/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "$*" >> "$GRANT_REVIEWER_TEA_LOG"
|
||||
echo "Unexpected tea command (grant-reviewer must not use tea): $*" >&2
|
||||
exit 92
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea"
|
||||
|
||||
# curl stub: a small REST server backed by persistent on-disk grant state.
|
||||
# GET /orgs/{org} -> org existence (404 in not-an-org mode)
|
||||
# GET /orgs/{org}/teams/search -> teams from state (decoy always listed FIRST)
|
||||
# POST /orgs/{org}/teams -> validate EXACT payload, CREATE + PERSIST
|
||||
# PUT /teams/{id}/members/{user} -> 204; persists unless member-put-noop
|
||||
# PUT /teams/{id}/repos/{org}/{repo} -> 204; persists unless repo-put-noop
|
||||
# GET /teams/{id}/members/{user} -> answers from persisted state only
|
||||
# GET /teams/{id}/repos/{org}/{repo} -> answers from persisted state only
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Record the FULL argv exactly as spawned, before consumption. The bearer token
|
||||
# must NOT appear here — it is delivered via a curl --config file, so only the
|
||||
# config file PATH may show up.
|
||||
printf '%s\n' "$*" >> "$GRANT_REVIEWER_CURL_ARGV_LOG"
|
||||
|
||||
output_file=""
|
||||
method="GET"
|
||||
url=""
|
||||
data=""
|
||||
auth_token=""
|
||||
config_file=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o) output_file="$2"; shift 2 ;;
|
||||
-H)
|
||||
[[ "$2" == Authorization:* ]] && auth_token="${2##* }"
|
||||
shift 2 ;;
|
||||
-K|--config) config_file="$2"; shift 2 ;;
|
||||
-w) shift 2 ;;
|
||||
-X) method="$2"; shift 2 ;;
|
||||
-d|--data) data="$2"; shift 2 ;;
|
||||
-s|-S|-sS) shift ;;
|
||||
http://*|https://*) url="$1"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Resolve the bearer token from the curl --config file (its real, secure
|
||||
# source). The config line is `header = "Authorization: token <value>"`.
|
||||
if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then
|
||||
config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)"
|
||||
if [[ "$config_hdr" == *"token "* ]]; then
|
||||
auth_token="${config_hdr##*token }"
|
||||
auth_token="${auth_token%\"}"
|
||||
fi
|
||||
fi
|
||||
|
||||
path="${url%%\?*}"
|
||||
printf '%s %s\n' "$method" "$url" >> "$GRANT_REVIEWER_CURL_LOG"
|
||||
|
||||
# Map the presented bearer token to the identity it authenticates as. Every
|
||||
# request the wrapper makes must carry the SAME credential, so the identity
|
||||
# recorded here reveals which credential actually performed each request.
|
||||
acting_identity=""
|
||||
case "$auth_token" in
|
||||
"$GRANT_REVIEWER_DEFAULT_TOKEN") acting_identity="$GRANT_REVIEWER_DEFAULT_IDENTITY" ;;
|
||||
"$GRANT_REVIEWER_OVERRIDE_TOKEN") acting_identity="$GRANT_REVIEWER_OVERRIDE_LOGIN" ;;
|
||||
esac
|
||||
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$GRANT_REVIEWER_AUTH_LOG"
|
||||
|
||||
write_response() {
|
||||
local status="$1" body="$2"
|
||||
[[ -n "$output_file" ]] || exit 96
|
||||
printf '%s' "$body" > "$output_file"
|
||||
printf '%s' "$status"
|
||||
}
|
||||
|
||||
[[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
|
||||
|
||||
mode="$GRANT_REVIEWER_TEST_MODE"
|
||||
org="$GRANT_REVIEWER_ORG"
|
||||
api="$GRANT_REVIEWER_API_ROOT"
|
||||
|
||||
if [[ "$method" == "GET" && "$path" == "$api/orgs/$org" ]]; then
|
||||
if [[ "$mode" == "not-an-org" ]]; then
|
||||
write_response 404 '{"message":"not found"}'
|
||||
else
|
||||
write_response 200 "{\"username\":\"$org\"}"
|
||||
fi
|
||||
elif [[ "$method" == "GET" && "$path" == "$api/orgs/$org/teams/search" ]]; then
|
||||
result=$(python3 - "$GRANT_REVIEWER_STATE" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
state = json.load(handle)
|
||||
print(json.dumps({"ok": True, "data": state["teams"]}))
|
||||
PY
|
||||
)
|
||||
write_response 200 "$result"
|
||||
elif [[ "$method" == "POST" && "$path" == "$api/orgs/$org/teams" ]]; then
|
||||
if [[ "$mode" == "create-403" ]]; then
|
||||
write_response 403 '{"message":"forbidden"}'
|
||||
exit 0
|
||||
fi
|
||||
result=$(GRANT_REVIEWER_DATA="$data" python3 - "$GRANT_REVIEWER_STATE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
payload = json.loads(os.environ["GRANT_REVIEWER_DATA"])
|
||||
expected = {
|
||||
"name": os.environ["GRANT_REVIEWER_TEAM_NAME"],
|
||||
"description": "review seats: code read + issues/pulls write",
|
||||
"permission": "read",
|
||||
"includes_all_repositories": False,
|
||||
"can_create_org_repo": False,
|
||||
"units_map": {
|
||||
"repo.code": "read",
|
||||
"repo.issues": "write",
|
||||
"repo.pulls": "write",
|
||||
},
|
||||
}
|
||||
if payload != expected:
|
||||
with open(os.environ["GRANT_REVIEWER_PAYLOAD_VIOLATION"], "w", encoding="utf-8") as handle:
|
||||
json.dump({"got": payload, "expected": expected}, handle, indent=2)
|
||||
print("422")
|
||||
print(json.dumps({"message": "payload mismatch"}))
|
||||
raise SystemExit(0)
|
||||
|
||||
state_path = sys.argv[1]
|
||||
with open(state_path, encoding="utf-8") as handle:
|
||||
state = json.load(handle)
|
||||
team = {"id": int(os.environ["GRANT_REVIEWER_TEAM_ID"]), "name": payload["name"]}
|
||||
state["teams"].append(team)
|
||||
with open(state_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(state, handle)
|
||||
print("201")
|
||||
print(json.dumps(team))
|
||||
PY
|
||||
)
|
||||
response_status="${result%%$'\n'*}"
|
||||
response_body="${result#*$'\n'}"
|
||||
write_response "$response_status" "$response_body"
|
||||
elif [[ "$method" == "PUT" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/members/$GRANT_REVIEWER_REVIEWER" ]]; then
|
||||
# Sabotage mode member-put-noop: 204 WITHOUT persisting — the exit-code lie.
|
||||
if [[ "$mode" != "member-put-noop" ]]; then
|
||||
python3 - "$GRANT_REVIEWER_STATE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
state_path = sys.argv[1]
|
||||
with open(state_path, encoding="utf-8") as handle:
|
||||
state = json.load(handle)
|
||||
member = os.environ["GRANT_REVIEWER_REVIEWER"]
|
||||
if member not in state["members"]:
|
||||
state["members"].append(member)
|
||||
with open(state_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(state, handle)
|
||||
PY
|
||||
fi
|
||||
write_response 204 ''
|
||||
elif [[ "$method" == "PUT" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/repos/$GRANT_REVIEWER_REPO_SLUG" ]]; then
|
||||
# Sabotage mode repo-put-noop: 204 WITHOUT persisting.
|
||||
if [[ "$mode" != "repo-put-noop" ]]; then
|
||||
python3 - "$GRANT_REVIEWER_STATE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
state_path = sys.argv[1]
|
||||
with open(state_path, encoding="utf-8") as handle:
|
||||
state = json.load(handle)
|
||||
slug = os.environ["GRANT_REVIEWER_REPO_SLUG"]
|
||||
if slug not in state["repos"]:
|
||||
state["repos"].append(slug)
|
||||
with open(state_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(state, handle)
|
||||
PY
|
||||
fi
|
||||
write_response 204 ''
|
||||
elif [[ "$method" == "GET" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/members/$GRANT_REVIEWER_REVIEWER" ]]; then
|
||||
if python3 - "$GRANT_REVIEWER_STATE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
state = json.load(handle)
|
||||
raise SystemExit(0 if os.environ["GRANT_REVIEWER_REVIEWER"] in state["members"] else 1)
|
||||
PY
|
||||
then
|
||||
write_response 200 "{\"login\":\"$GRANT_REVIEWER_REVIEWER\"}"
|
||||
else
|
||||
write_response 404 '{"message":"not a member"}'
|
||||
fi
|
||||
elif [[ "$method" == "GET" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/repos/$GRANT_REVIEWER_REPO_SLUG" ]]; then
|
||||
if python3 - "$GRANT_REVIEWER_STATE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
state = json.load(handle)
|
||||
raise SystemExit(0 if os.environ["GRANT_REVIEWER_REPO_SLUG"] in state["repos"] else 1)
|
||||
PY
|
||||
then
|
||||
write_response 200 "{\"full_name\":\"$GRANT_REVIEWER_REPO_SLUG\"}"
|
||||
else
|
||||
write_response 404 '{"message":"repo not on team"}'
|
||||
fi
|
||||
else
|
||||
echo "Unexpected curl request: $method $url" >&2
|
||||
exit 97
|
||||
fi
|
||||
SH
|
||||
chmod +x "$BIN_DIR/curl"
|
||||
|
||||
# Seed persistent server state for a mode: fresh (no team yet) or a pre-seeded
|
||||
# team. The DECOY team — whose name CONTAINS the wanted name — is always listed
|
||||
# FIRST, so a first-result or substring match would grab the wrong team.
|
||||
seed_state() {
|
||||
local seeded_team="$1"
|
||||
GRANT_REVIEWER_SEEDED_TEAM="$seeded_team" GRANT_REVIEWER_TEAM_NAME="$TEAM_NAME" \
|
||||
GRANT_REVIEWER_TEAM_ID="$TEAM_ID" GRANT_REVIEWER_DECOY_TEAM_ID="$DECOY_TEAM_ID" \
|
||||
python3 - "$STATE_FILE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
wanted = os.environ["GRANT_REVIEWER_TEAM_NAME"]
|
||||
teams = [{"id": int(os.environ["GRANT_REVIEWER_DECOY_TEAM_ID"]), "name": wanted + "-archive"}]
|
||||
if os.environ["GRANT_REVIEWER_SEEDED_TEAM"] == "yes":
|
||||
teams.append({"id": int(os.environ["GRANT_REVIEWER_TEAM_ID"]), "name": wanted})
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||
json.dump({"teams": teams, "members": [], "repos": []}, handle)
|
||||
PY
|
||||
}
|
||||
|
||||
# run_grant <mode> <seeded-team yes|no> [extra env VAR=value ...] -- [wrapper args ...]
|
||||
run_grant() {
|
||||
local mode="$1" seeded="$2"
|
||||
shift 2
|
||||
local -a extra_env=()
|
||||
while [[ $# -gt 0 && "$1" != "--" ]]; do
|
||||
extra_env+=("$1")
|
||||
shift
|
||||
done
|
||||
[[ $# -gt 0 ]] && shift
|
||||
: > "$TEA_LOG"
|
||||
: > "$CURL_LOG"
|
||||
: > "$CURL_ARGV_LOG"
|
||||
: > "$AUTH_LOG"
|
||||
: > "$OUTPUT_FILE"
|
||||
rm -f "$PAYLOAD_VIOLATION_FILE"
|
||||
seed_state "$seeded"
|
||||
(
|
||||
cd "$RUN_REPO_DIR"
|
||||
env \
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
TMPDIR="$TMP_SCRATCH" \
|
||||
HOME="$HOME_DIR" \
|
||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" \
|
||||
MOSAIC_GIT_IDENTITY= \
|
||||
GITEA_LOGIN= \
|
||||
GITEA_TOKEN= \
|
||||
GITEA_URL= \
|
||||
GRANT_REVIEWER_TEA_LOG="$TEA_LOG" \
|
||||
GRANT_REVIEWER_CURL_LOG="$CURL_LOG" \
|
||||
GRANT_REVIEWER_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
|
||||
GRANT_REVIEWER_AUTH_LOG="$AUTH_LOG" \
|
||||
GRANT_REVIEWER_STATE="$STATE_FILE" \
|
||||
GRANT_REVIEWER_TEST_MODE="$mode" \
|
||||
GRANT_REVIEWER_ORG="$ORG" \
|
||||
GRANT_REVIEWER_API_ROOT="$API_ROOT" \
|
||||
GRANT_REVIEWER_TEAM_NAME="$TEAM_NAME" \
|
||||
GRANT_REVIEWER_TEAM_ID="$TEAM_ID" \
|
||||
GRANT_REVIEWER_REVIEWER="$REVIEWER" \
|
||||
GRANT_REVIEWER_REPO_SLUG="$REPO_SLUG" \
|
||||
GRANT_REVIEWER_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
|
||||
GRANT_REVIEWER_DEFAULT_IDENTITY="$DEFAULT_IDENTITY" \
|
||||
GRANT_REVIEWER_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
|
||||
GRANT_REVIEWER_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
|
||||
GRANT_REVIEWER_PAYLOAD_VIOLATION="$PAYLOAD_VIOLATION_FILE" \
|
||||
"${extra_env[@]}" \
|
||||
"$SCRIPT_DIR/grant-reviewer.sh" -u "$REVIEWER" "$@"
|
||||
) > "$OUTPUT_FILE" 2>&1
|
||||
}
|
||||
|
||||
assert_no_temp_leak() {
|
||||
local context="$1" leaked
|
||||
# Includes the curl auth-config files (mosaic-gitea-auth-*), which carry the
|
||||
# bearer token and must be unlinked on every exit path.
|
||||
leaked=$(find "$TMP_SCRATCH" -type f \( -name 'mosaic-grant-reviewer-*' -o -name 'mosaic-gitea-auth-*' \) 2>/dev/null || true)
|
||||
if [[ -n "$leaked" ]]; then
|
||||
echo "FAIL: grant-reviewer temp files leaked ($context):" >&2
|
||||
printf '%s\n' "$leaked" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_token_not_in_argv() {
|
||||
local context="$1"
|
||||
if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" "$CURL_ARGV_LOG"; then
|
||||
echo "FAIL: a Gitea bearer token leaked into curl argv ($context)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '--config' "$CURL_ARGV_LOG"; then
|
||||
echo "FAIL: curl was not invoked with --config file auth ($context)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_no_payload_violation() {
|
||||
local context="$1"
|
||||
if [[ -f "$PAYLOAD_VIOLATION_FILE" ]]; then
|
||||
echo "FAIL: team create payload deviated from the reviewer contract ($context):" >&2
|
||||
cat "$PAYLOAD_VIOLATION_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
RUN_REPO_DIR="$REPO_DIR"
|
||||
|
||||
# Case 1: fresh grant — team absent, created with the exact reviewer payload,
|
||||
# member + repo PUTs persist, both read-backs verify against server state.
|
||||
run_grant normal no -- || {
|
||||
echo "FAIL: fresh grant exited nonzero" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -q "Created team '$TEAM_NAME' (id $TEAM_ID) on org '$ORG'" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: fresh grant did not create the team" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -q "Granted: '$REVIEWER' is a member of team '$TEAM_NAME' (id $TEAM_ID) with access to '$REPO_SLUG'" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: fresh grant did not report a verified grant" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
assert_no_payload_violation "fresh"
|
||||
assert_token_not_in_argv "fresh"
|
||||
assert_no_temp_leak "fresh"
|
||||
# The default path must have acted as the host-default identity on EVERY request.
|
||||
if grep -qv " $DEFAULT_IDENTITY\$" "$AUTH_LOG"; then
|
||||
echo "FAIL: fresh grant made a request under an unexpected identity" >&2
|
||||
cat "$AUTH_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
# grant-reviewer must never shell out to tea.
|
||||
if [[ -s "$TEA_LOG" ]]; then
|
||||
echo "FAIL: grant-reviewer invoked tea" >&2
|
||||
cat "$TEA_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Case 2: idempotent — the team already exists. It must be found by EXACT name
|
||||
# (the decoy is listed first), no create POST issued, and the decoy team must
|
||||
# never be touched.
|
||||
run_grant normal yes -- || {
|
||||
echo "FAIL: idempotent grant exited nonzero" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -q "Found existing team '$TEAM_NAME' (id $TEAM_ID) on org '$ORG'" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: idempotent grant did not find the existing team" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -q "Granted: '$REVIEWER'" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: idempotent grant did not report a verified grant" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
if grep -q "^POST " "$CURL_LOG"; then
|
||||
echo "FAIL: idempotent grant issued a create POST for an existing team" >&2
|
||||
cat "$CURL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q "/teams/$DECOY_TEAM_ID/" "$CURL_LOG"; then
|
||||
echo "FAIL: substring-named decoy team was operated on" >&2
|
||||
cat "$CURL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_temp_leak "idempotent"
|
||||
|
||||
# Case 3: GITEA_LOGIN override — every request must carry the override login's
|
||||
# token, never the host default credential.
|
||||
run_grant normal no GITEA_LOGIN="$OVERRIDE_LOGIN" -- || {
|
||||
echo "FAIL: GITEA_LOGIN override grant exited nonzero" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -q "Granted: '$REVIEWER'" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: GITEA_LOGIN override grant did not succeed" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
if grep -qv " $OVERRIDE_LOGIN\$" "$AUTH_LOG"; then
|
||||
echo "FAIL: GITEA_LOGIN override made a request under a different identity" >&2
|
||||
cat "$AUTH_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_token_not_in_argv "override"
|
||||
assert_no_temp_leak "override"
|
||||
|
||||
# Case 4: unresolvable GITEA_LOGIN — fail closed BEFORE any API call; no
|
||||
# downgrade to the host default identity.
|
||||
if run_grant normal no GITEA_LOGIN="no-such-login" --; then
|
||||
echo "FAIL: unresolvable GITEA_LOGIN did not fail" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "refusing to fall back to the host default identity" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: unresolvable GITEA_LOGIN missing the fail-closed message" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
if [[ -s "$CURL_LOG" ]]; then
|
||||
echo "FAIL: unresolvable GITEA_LOGIN still made API calls" >&2
|
||||
cat "$CURL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_temp_leak "unresolvable-login"
|
||||
|
||||
# Case 5: GitHub-remoted repo — refuse before any API call.
|
||||
RUN_REPO_DIR="$GH_REPO_DIR"
|
||||
if run_grant normal no --; then
|
||||
echo "FAIL: GitHub repo was not refused" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "Gitea only" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: GitHub refusal missing the 'Gitea only' message" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
if [[ -s "$CURL_LOG" ]]; then
|
||||
echo "FAIL: GitHub refusal still made API calls" >&2
|
||||
cat "$CURL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
RUN_REPO_DIR="$REPO_DIR"
|
||||
|
||||
# Case 6: owner is not an organization — clear refusal.
|
||||
if run_grant not-an-org no --; then
|
||||
echo "FAIL: non-org owner was not refused" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "is not an organization" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: non-org refusal missing its message" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
assert_no_temp_leak "not-an-org"
|
||||
|
||||
# Case 7: HTTP 403 on team create — reported as an org-admin requirement, and
|
||||
# the run stops before any member/repo PUT (no partial grant).
|
||||
if run_grant create-403 no --; then
|
||||
echo "FAIL: 403 on team create did not fail the run" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "org admin required on '$ORG'" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: 403 was not mapped to the org-admin message" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
if grep -q "^PUT " "$CURL_LOG"; then
|
||||
echo "FAIL: run continued into PUTs after a 403 (partial grant)" >&2
|
||||
cat "$CURL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_temp_leak "create-403"
|
||||
|
||||
# Cases 8-9: the exit-code lie — a PUT answers 204 without persisting. The
|
||||
# read-back must fail closed; no success line may appear.
|
||||
for noop_mode in member-put-noop repo-put-noop; do
|
||||
if run_grant "$noop_mode" no --; then
|
||||
echo "FAIL: $noop_mode was reported as success" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "NOT verified" "$OUTPUT_FILE" || {
|
||||
echo "FAIL: $noop_mode missing the fail-closed verification message" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
if grep -q "^Granted:" "$OUTPUT_FILE"; then
|
||||
echo "FAIL: $noop_mode still printed the success line" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_temp_leak "$noop_mode"
|
||||
done
|
||||
|
||||
echo "grant-reviewer.sh org-team grant + fail-closed read-back regression passed"
|
||||
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-repo-decl-consumption.sh — hermetic declaration-consumption suite for the
|
||||
# git wrappers (T51 WP5b).
|
||||
#
|
||||
# Spec of record (brain repo): docs/plans/2026-08-23_repo-structure-declaration.md
|
||||
# sections 4 (consumption), 5.3 (normalization), 5.4 (hostile-input classes),
|
||||
# 1.2a (root anchoring). Covers every §5.4 class applicable to consumed fields
|
||||
# plus the per-tool behaviors (base precedence, transition validation, remote
|
||||
# fail-closed, absence policy, route context, staged worktree rule).
|
||||
#
|
||||
# Red-first usage: WP5B_TOOLS=<dir with PRE-change tool copies> bash $0
|
||||
# exits nonzero — the declaration-driven arms fail against tools that predate
|
||||
# the change (evidence captured in the WP5b report).
|
||||
#
|
||||
# Hermetic: scratch repos under $TMPDIR, PATH-stubbed curl, sandboxed HOME; no
|
||||
# network, no live forge, no writes outside the sandbox.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TOOLS_SRC="${WP5B_TOOLS:-$SCRIPT_DIR}"
|
||||
TROOT="${TMPDIR:-/tmp}"
|
||||
|
||||
PASS=0 FAIL=0 FAILED_CASES=""
|
||||
FIXTURES=()
|
||||
cleanup_all() { local f; for f in "${FIXTURES[@]:-}"; do rm -rf -- "$f"; done; }
|
||||
trap cleanup_all EXIT INT TERM
|
||||
|
||||
ok() { PASS=$((PASS + 1)); }
|
||||
bad() { FAIL=$((FAIL + 1)); FAILED_CASES="$FAILED_CASES $1"; printf 'FAIL: %s\n' "$1" >&2; }
|
||||
assert_rc() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected rc=$e got rc=$a)"; }
|
||||
assert_eq() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected [$e] got [$a])"; }
|
||||
assert_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) ok ;; *) bad "$d (missing [$n])" ;; esac; }
|
||||
assert_not_contains() { local d="$1" h="$2" n="$3"; case "$h" in *"$n"*) bad "$d (unexpected [$n])" ;; *) ok ;; esac; }
|
||||
assert_count() { local d="$1" e="$2" a="$3"; [ "$e" = "$a" ] && ok || bad "$d (expected $e got $a)"; }
|
||||
|
||||
new_sb() { SB="$(mktemp -d "$TROOT/wp5b-test.XXXXXX")"; FIXTURES+=("$SB"); }
|
||||
|
||||
# A fixture repo with the (possibly pre-change) tools + validator installed.
|
||||
# $1 dir, stdin = declaration JSON ("" = none), $2 = origin url ("" = none)
|
||||
mkrepo() {
|
||||
local d="$1" decl_origin="${2:-}"
|
||||
mkdir -p "$d/tools/git" "$d/tools/structure" "$d/home"
|
||||
cp "$TOOLS_SRC/repo-decl.sh" "$d/tools/git/" 2>/dev/null || true
|
||||
cp "$TOOLS_SRC/pr-create.sh" "$TOOLS_SRC/pr-merge.sh" "$TOOLS_SRC/mosaic-worktree.sh" "$TOOLS_SRC/ci-queue-wait.sh" "$TOOLS_SRC/detect-platform.sh" "$d/tools/git/" 2>/dev/null || true
|
||||
cp "$SCRIPT_DIR/../structure/validate-repo-json.sh" "$d/tools/structure/"
|
||||
git -C "$d" init -q -b feature/x
|
||||
git -C "$d" config user.name fixture; git -C "$d" config user.email fixture@test
|
||||
mkdir -p "$d/.mosaic" "$d/sites"
|
||||
printf 'base\n' > "$d/f"; git -C "$d" add -A; git -C "$d" commit -q -m base
|
||||
[ -n "$decl_origin" ] && git -C "$d" remote add origin "$decl_origin"
|
||||
cat > "$d/.mosaic/repo.json"
|
||||
# whitespace-only input (the absent-decl arms use <<< "") must mean ABSENT,
|
||||
# not an invalid-file fixture: strip it to no file.
|
||||
grep -q '[^[:space:]]' "$d/.mosaic/repo.json" 2>/dev/null || rm -f "$d/.mosaic/repo.json"
|
||||
}
|
||||
|
||||
# The canonical v2 declaration used by default (custom branch names prove the
|
||||
# tools never hardcode): trunk=dev-trunk release=prod-rel flow=trunk-release.
|
||||
DECL_TR='{
|
||||
"schema_version": 2,
|
||||
"integration_trunk": "dev-trunk",
|
||||
"release_branch": "prod-rel",
|
||||
"flow": "trunk-release",
|
||||
"canonical_remote": "https://git.example.test/acme/widgets",
|
||||
"canonical_clone": "host:/src/widgets",
|
||||
"worktree_root": "host:/src/widgets-worktrees",
|
||||
"worktree_policy": "orchestrator-precreated"
|
||||
}'
|
||||
|
||||
decl_direct() { printf '{\n "schema_version": 2,\n "integration_trunk": "mainline",\n "release_branch": "mainline",\n "flow": "direct",\n "canonical_remote": "https://git.example.test/acme/widgets",\n "canonical_clone": "host:/src/widgets"\n}\n'; }
|
||||
|
||||
load_decl_in() { # $1 dir -> runs repo_decl_load in a subshell, prints STATE etc.
|
||||
( cd "$1" && source "$1/tools/git/repo-decl.sh" && repo_decl_load \
|
||||
&& printf 'STATE=%s SCHEMA=%s TRUNK=%s RELEASE=%s FLOW=%s ERROR=%s\n' \
|
||||
"$DECL_STATE" "${DECL_SCHEMA:-}" "${DECL_TRUNK:-}" "${DECL_RELEASE:-}" "${DECL_FLOW:-}" "${DECL_ERROR:-}" )
|
||||
}
|
||||
|
||||
echo "== A. lib classification + hostile inputs (spec 5.4 classes) =="
|
||||
new_sb; mkrepo "$SB/r1" <<< "$DECL_TR" "https://git.example.test/acme/widgets.git"
|
||||
A="$(load_decl_in "$SB/r1")"
|
||||
assert_contains "A1 valid v2 state" "$A" "STATE=valid"
|
||||
assert_contains "A1 trunk" "$A" "TRUNK=dev-trunk"
|
||||
assert_contains "A1 release" "$A" "RELEASE=prod-rel"
|
||||
assert_contains "A1 flow" "$A" "FLOW=trunk-release"
|
||||
assert_contains "A1 schema" "$A" "SCHEMA=2"
|
||||
|
||||
new_sb; mkrepo "$SB/r2" <<< "" "https://git.example.test/acme/widgets"
|
||||
A="$(load_decl_in "$SB/r2")"
|
||||
assert_contains "A2 missing file = absent" "$A" "STATE=absent"
|
||||
|
||||
new_sb; mkrepo "$SB/r3" <<< '{ not json'
|
||||
A="$(load_decl_in "$SB/r3")"
|
||||
assert_contains "A3 malformed JSON = invalid" "$A" "STATE=invalid"
|
||||
assert_contains "A3 error names the failure" "$A" "VALIDATION_ERROR"
|
||||
|
||||
new_sb; mkrepo "$SB/r4" <<< '{"schema_version": 99, "integration_trunk": "x", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b"}'
|
||||
A="$(load_decl_in "$SB/r4")"
|
||||
assert_contains "A4 unknown schema_version = invalid" "$A" "STATE=invalid"
|
||||
|
||||
new_sb; mkrepo "$SB/r5" <<< '{"integration_trunk": "x", "release_branch": "y"}'
|
||||
A="$(load_decl_in "$SB/r5")"
|
||||
assert_contains "A5 v1 validates" "$A" "STATE=valid"
|
||||
assert_contains "A5 v1 schema recorded" "$A" "SCHEMA=1"
|
||||
|
||||
new_sb; mkrepo "$SB/r6" <<< '{"schema_version": 2, "integration_trunk": "x", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b", "surprise": 1}'
|
||||
A="$(load_decl_in "$SB/r6")"
|
||||
assert_contains "A6 unknown top-level key = invalid" "$A" "STATE=invalid"
|
||||
|
||||
new_sb; mkrepo "$SB/r7" <<< '{"schema_version": 2, "integration_trunk": "bad..name", "release_branch": "y", "flow": "direct", "canonical_remote": "https://a/b"}'
|
||||
A="$(load_decl_in "$SB/r7")"
|
||||
assert_contains "A7 bad ref name = invalid" "$A" "STATE=invalid"
|
||||
|
||||
new_sb; mkrepo "$SB/r8" <<< '{"schema_version": 2, "integration_trunk": "a", "release_branch": "b", "flow": "direct", "canonical_remote": "https://a/b"}'
|
||||
A="$(load_decl_in "$SB/r8")"
|
||||
assert_contains "A8 cross-field violation = invalid" "$A" "STATE=invalid"
|
||||
|
||||
new_sb; mkrepo "$SB/r9" <<< '{"schema_version": 2, "integration_trunk": "a", "release_branch": "b", "flow": "trunk-release", "canonical_remote": "https://user:pw@a/b"}'
|
||||
A="$(load_decl_in "$SB/r9")"
|
||||
assert_contains "A9 userinfo URL = invalid" "$A" "STATE=invalid"
|
||||
|
||||
echo "== A2. transitions + remote + path anchoring =="
|
||||
new_sb; mkrepo "$SB/t1" <<< "$DECL_TR"
|
||||
T=(); rc=0
|
||||
T_out="$( cd "$SB/t1" && source tools/git/repo-decl.sh && repo_decl_load
|
||||
repo_decl_check_transition feat dev-trunk && echo "feat->trunk:ALLOWED"
|
||||
repo_decl_check_transition dev-trunk prod-rel && echo "trunk->rel:ALLOWED"
|
||||
repo_decl_check_transition feat prod-rel || echo "feat->rel:REFUSED"
|
||||
repo_decl_check_transition other other || echo "same:REFUSED"
|
||||
repo_decl_check_transition feat elsewhere || echo "arbitrary:REFUSED" )"
|
||||
assert_contains "T1 feature->trunk allowed" "$T_out" "feat->trunk:ALLOWED"
|
||||
assert_contains "T1 trunk->release allowed" "$T_out" "trunk->rel:ALLOWED"
|
||||
assert_contains "T1 feature->release refused" "$T_out" "feat->rel:REFUSED"
|
||||
assert_contains "T1 head==base refused" "$T_out" "same:REFUSED"
|
||||
assert_contains "T1 arbitrary target refused" "$T_out" "arbitrary:REFUSED"
|
||||
|
||||
new_sb; mkrepo "$SB/t2" <<< "$(decl_direct)"
|
||||
T_out="$( cd "$SB/t2" && source tools/git/repo-decl.sh && repo_decl_load
|
||||
repo_decl_check_transition feat mainline && echo "direct-ok:ALLOWED"
|
||||
repo_decl_check_transition feat other || echo "direct-other:REFUSED" )"
|
||||
assert_contains "T2 direct feature->trunk allowed" "$T_out" "direct-ok:ALLOWED"
|
||||
assert_contains "T2 direct other base refused" "$T_out" "direct-other:REFUSED"
|
||||
|
||||
new_sb; mkrepo "$SB/t3" <<< "$DECL_TR" "https://Git.Example.Test/acme/widgets.git/"
|
||||
M="$( cd "$SB/t3" && source tools/git/repo-decl.sh && repo_decl_load && repo_decl_remote_matches && echo MATCH )"
|
||||
assert_contains "T3 normalization: .git/case differences still MATCH" "$M" "MATCH"
|
||||
new_sb; mkrepo "$SB/t4" <<< "$DECL_TR" "https://git.example.test/acme/OTHER"
|
||||
M="$( cd "$SB/t4" && source tools/git/repo-decl.sh && repo_decl_load && { repo_decl_remote_matches && echo MATCH; } || echo MISMATCH )"
|
||||
assert_contains "T4 remote mismatch detected" "$M" "MISMATCH"
|
||||
|
||||
new_sb; mkrepo "$SB/t5" <<< "$DECL_TR"
|
||||
P="$( cd "$SB/t5" && source tools/git/repo-decl.sh && { repo_decl_path "host:/src/x" 2>/dev/null && echo RESOLVED; } || echo FAILCLOSED )"
|
||||
assert_contains "T5 host:/ resolution fails closed (root unset)" "$P" "FAILCLOSED"
|
||||
|
||||
echo "== B. pr-create consumption =="
|
||||
mkpr() { # $1 dir: install the curl stub + run env; sets PR_RC/PR_OUT/PR_ERR/PR_PAYLOAD
|
||||
mkdir -p "$1/stub"
|
||||
cat > "$1/stub/curl" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
url="${*: -1}"
|
||||
printf 'curl %s\n' "$*" >> "${STUB_DIR:?}/calls.log"
|
||||
case "$url" in
|
||||
*/api/v1/repos/acme/widgets) printf '%s\n' '{"default_branch":"forge-default"}'; exit 0 ;;
|
||||
*/pulls)
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in -d) printf '%s' "$2" > "${STUB_DIR:?}/payload.json"; shift 2 ;; *) shift ;; esac
|
||||
done
|
||||
printf '%s\n' '{"number":42}'; exit 0 ;;
|
||||
*) printf '%s\n' '{}'; exit 0 ;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$1/stub/curl"
|
||||
}
|
||||
run_pr() { # $1 dir, rest args -> pr-create
|
||||
local prdir="$1"; shift
|
||||
PR_RC=0
|
||||
PR_OUT="$(cd "$prdir" && env -i PATH="$prdir/stub:/usr/bin:/bin" HOME="$prdir/home" \
|
||||
GITEA_TOKEN=stub-token STUB_DIR="$prdir" \
|
||||
bash "$prdir/tools/git/pr-create.sh" "$@" < /dev/null 2>"$prdir/err.txt")" || PR_RC=$?
|
||||
PR_ERR="$(cat "$prdir/err.txt")"
|
||||
PR_PAYLOAD="$(cat "$prdir/payload.json" 2>/dev/null || true)"
|
||||
PR_GETS="$(grep -c 'repos/acme/widgets$' "$prdir/calls.log" 2>/dev/null || true)"; PR_GETS="${PR_GETS:-0}"
|
||||
PR_POSTS="$(grep -c '/pulls$' "$prdir/calls.log" 2>/dev/null || true)"; PR_POSTS="${PR_POSTS:-0}"
|
||||
: > "$prdir/calls.log" 2>/dev/null || true
|
||||
rm -f "$prdir/payload.json"
|
||||
}
|
||||
|
||||
new_sb; mkrepo "$SB/b1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"; mkpr "$SB/b1"
|
||||
git -C "$SB/b1" checkout -q -b feature/x 2>/dev/null || true
|
||||
run_pr "$SB/b1" -t "T"
|
||||
assert_rc "B1 declared trunk base rc 0" 0 "$PR_RC"
|
||||
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
|
||||
assert_eq "B1 payload base = declared trunk (no -B)" "dev-trunk" "$base"
|
||||
assert_count "B1 zero repo GETs (declared trunk consulted, not the forge default)" 0 "$PR_GETS"
|
||||
|
||||
run_pr "$SB/b1" -t "T" -B prod-rel
|
||||
assert_rc "B2 -B feature->release REFUSED (4.2)" 1 "$PR_RC"
|
||||
assert_contains "B2 names the transition rule" "$PR_ERR" "not an allowed transition"
|
||||
assert_count "B2 no POST issued" 0 "$PR_POSTS"
|
||||
|
||||
run_pr "$SB/b1" -t "T" -B dev-trunk
|
||||
assert_rc "B3 -B feature->trunk allowed" 0 "$PR_RC"
|
||||
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
|
||||
assert_eq "B3 payload base = explicit allowed -B" "dev-trunk" "$base"
|
||||
|
||||
run_pr "$SB/b1" -t "T" -B dev-trunk --head dev-trunk
|
||||
assert_rc "B4 -B trunk->trunk (head==base) refused" 1 "$PR_RC"
|
||||
|
||||
new_sb; mkrepo "$SB/b5" <<< "$DECL_TR" "https://git.example.test/acme/wrong"; mkpr "$SB/b5"
|
||||
run_pr "$SB/b5" -t "T"
|
||||
assert_rc "B5 remote mismatch on write path refuses" 1 "$PR_RC"
|
||||
assert_contains "B5 names the 5.3 rule" "$PR_ERR" "canonical_remote"
|
||||
assert_count "B5 no POST" 0 "$PR_POSTS"
|
||||
|
||||
new_sb; mkrepo "$SB/b6" <<< "" "https://git.example.test/acme/widgets"; mkpr "$SB/b6"
|
||||
run_pr "$SB/b6" -t "T"
|
||||
assert_rc "B6 absent decl: legacy forge default rc 0" 0 "$PR_RC"
|
||||
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
|
||||
assert_eq "B6 payload base = forge default" "forge-default" "$base"
|
||||
assert_count "B6 repo GET performed (WP5a floor preserved)" 1 "$PR_GETS"
|
||||
assert_contains "B6 absence warning present" "$PR_ERR" "unmanaged during rollout"
|
||||
|
||||
new_sb; mkrepo "$SB/b7" <<< '{ not json' "https://git.example.test/acme/widgets"; mkpr "$SB/b7"
|
||||
run_pr "$SB/b7" -t "T"
|
||||
assert_rc "B7 invalid decl: loud report + legacy proceed" 0 "$PR_RC"
|
||||
assert_contains "B7 validation error reported" "$PR_ERR" "declaration INVALID"
|
||||
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
|
||||
assert_eq "B7 legacy forge default used" "forge-default" "$base"
|
||||
|
||||
new_sb; mkrepo "$SB/b8" <<< '{"integration_trunk": "x", "release_branch": "y"}' "https://git.example.test/acme/widgets"; mkpr "$SB/b8"
|
||||
run_pr "$SB/b8" -t "T"
|
||||
assert_rc "B8 v1 decl: legacy proceed rc 0" 0 "$PR_RC"
|
||||
assert_contains "B8 v1 note present" "$PR_ERR" "no consumable flow/trunk fields"
|
||||
base="$(printf '%s' "$PR_PAYLOAD" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("base",""))' 2>/dev/null || true)"
|
||||
assert_eq "B8 legacy forge default used" "forge-default" "$base"
|
||||
|
||||
echo "== C. pr-merge transition validation (no hardcoded targets) =="
|
||||
mkmerge() { # $1 dir, $2 base, $3 head -> stubs pr-metadata; runs pr-merge; sets M_RC/M_OUT/M_ERR
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$1/tools/git/pr-merge.sh" 2>/dev/null || true
|
||||
cat > "$1/tools/git/pr-metadata.sh" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"$2","headRefName":"$3","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"acme/widgets","title":"t","author":{"login":"a"}}'
|
||||
EOF
|
||||
chmod +x "$1/tools/git/pr-metadata.sh"
|
||||
cat > "$1/tools/git/ci-queue-wait.sh" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$1/tools/git/ci-queue-wait.sh"
|
||||
mkdir -p "$1/stub"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$1/stub/curl"; chmod +x "$1/stub/curl"
|
||||
M_RC=0
|
||||
M_OUT="$(cd "$1" && env -i PATH="$1/stub:/usr/bin:/bin" HOME="$1/home" GITEA_TOKEN=stub-token \
|
||||
bash "$1/tools/git/pr-merge.sh" -n 7 --dry-run < /dev/null 2>"$1/merr.txt")" || M_RC=$?
|
||||
M_ERR="$(cat "$1/merr.txt")"
|
||||
}
|
||||
|
||||
new_sb; mkrepo "$SB/c1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"
|
||||
mkmerge "$SB/c1" dev-trunk feature/x
|
||||
assert_rc "C1 feature->trunk under decl (custom trunk name, no hardcode)" 0 "$M_RC"
|
||||
assert_contains "C1 transition context printed" "$M_ERR" "transition OK under flow=trunk-release"
|
||||
|
||||
mkmerge "$SB/c1" prod-rel feature/x
|
||||
assert_rc "C2 feature->release REJECTED under decl" 1 "$M_RC"
|
||||
assert_contains "C2 names the declared transition rule" "$M_ERR" "not a declared transition"
|
||||
|
||||
mkmerge "$SB/c1" prod-rel dev-trunk
|
||||
assert_rc "C3 trunk->release promotion allowed" 0 "$M_RC"
|
||||
|
||||
new_sb; mkrepo "$SB/c4" <<< "" "https://git.example.test/acme/widgets"
|
||||
mkmerge "$SB/c4" main feature/x
|
||||
assert_rc "C4 absent decl: legacy main/next check still enforced (main ok)" 0 "$M_RC"
|
||||
assert_contains "C4 legacy warning present" "$M_ERR" "LEGACY assumptions"
|
||||
mkmerge "$SB/c4" trunk-x feature/x
|
||||
assert_rc "C4 absent decl: unknown target rejected by legacy check" 1 "$M_RC"
|
||||
|
||||
new_sb; mkrepo "$SB/c5" <<< "$DECL_TR" "https://git.example.test/acme/wrong"
|
||||
mkmerge "$SB/c5" dev-trunk feature/x
|
||||
assert_rc "C5 remote mismatch refuses the merge" 1 "$M_RC"
|
||||
assert_contains "C5 names 5.3" "$M_ERR" "canonical_remote"
|
||||
|
||||
echo "== D. mosaic-worktree staged rule (4.4/4.5) =="
|
||||
mkwt() { # $1 dir: home outside the repo so derivation passes assert_not_home
|
||||
mv "$1/home" "$SB/wthome" 2>/dev/null || true
|
||||
}
|
||||
new_sb; mkrepo "$SB/d1" <<< '{ not json'; mkwt "$SB/d1"
|
||||
WT_RC=0
|
||||
WT_OUT="$(cd "$SB/d1" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
|
||||
bash "$SB/d1/tools/git/mosaic-worktree.sh" new feat2 < /dev/null 2>"$SB/d1/wterr.txt")" || WT_RC=$?
|
||||
assert_rc "D1 invalid decl fails branch-creation loud" 1 "$WT_RC"
|
||||
assert_contains "D1 names the invalid declaration" "$(cat "$SB/d1/wterr.txt")" "INVALID"
|
||||
|
||||
new_sb; mkrepo "$SB/d2" <<< ""; mkwt "$SB/d2"
|
||||
WT_RC=0
|
||||
WT_OUT="$(cd "$SB/d2" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
|
||||
bash "$SB/d2/tools/git/mosaic-worktree.sh" new feat3 < /dev/null 2>"$SB/d2/wterr.txt")" || WT_RC=$?
|
||||
assert_rc "D2 absent decl: warn + proceed" 0 "$WT_RC"
|
||||
assert_contains "D2 loud warning present" "$(cat "$SB/d2/wterr.txt")" "LEGACY assumptions"
|
||||
|
||||
new_sb; mkrepo "$SB/d3" <<< "$DECL_TR"; mkwt "$SB/d3"
|
||||
WT_RC=0
|
||||
WT_OUT="$(cd "$SB/d3" && env -i PATH="/usr/bin:/bin" HOME="$SB/wthome" \
|
||||
bash "$SB/d3/tools/git/mosaic-worktree.sh" new feat4 < /dev/null 2>"$SB/d3/wterr.txt")" || WT_RC=$?
|
||||
assert_rc "D3 valid decl: policy advisory + proceed" 0 "$WT_RC"
|
||||
assert_contains "D3 precreated policy note" "$(cat "$SB/d3/wterr.txt")" "orchestrator-precreated"
|
||||
assert_contains "D3 placement stays derived (no decl path used)" "$(cat "$SB/d3/wterr.txt")" "TRANSITIONAL"
|
||||
|
||||
echo "== E. ci-queue-wait route context (C4: context only, never a gate) =="
|
||||
new_sb; mkrepo "$SB/e1" <<< "$DECL_TR" "https://git.example.test/acme/widgets"
|
||||
git -C "$SB/e1" checkout -q -b dev-trunk 2>/dev/null || { git -C "$SB/e1" branch -q dev-trunk; git -C "$SB/e1" checkout -q dev-trunk; }
|
||||
E_RC=0
|
||||
E_OUT="$(cd "$SB/e1" && env -i PATH="/usr/bin:/bin" HOME="$SB/e1/home" \
|
||||
bash "$SB/e1/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e1/eerr.txt")" || E_RC=$?
|
||||
E_ERR="$(cat "$SB/e1/eerr.txt")"
|
||||
assert_contains "E1 route context names trunk head" "$E_ERR" "'dev-trunk' is a trunk (integration head) head"
|
||||
assert_contains "E1 context names the flow" "$E_ERR" "flow=trunk-release"
|
||||
|
||||
new_sb; mkrepo "$SB/e2" <<< "" "https://git.example.test/acme/widgets"
|
||||
E_RC=0
|
||||
E_OUT="$(cd "$SB/e2" && env -i PATH="/usr/bin:/bin" HOME="$SB/e2/home" \
|
||||
bash "$SB/e2/tools/git/ci-queue-wait.sh" < /dev/null 2>"$SB/e2/eerr.txt")" || E_RC=$?
|
||||
assert_not_contains "E2 absence is SILENT for the guard (N4)" "$(cat "$SB/e2/eerr.txt")" "repo-decl"
|
||||
|
||||
cleanup_all
|
||||
assert_count "final: zero scratch residue" 0 "$(ls -d "$TROOT"/wp5b-test.* 2>/dev/null | wc -l | tr -d ' ')"
|
||||
|
||||
echo
|
||||
echo "pass=$PASS fail=$FAIL"
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo "FAILED CASES:$FAILED_CASES"
|
||||
exit 1
|
||||
fi
|
||||
echo "ALL GREEN"
|
||||
@@ -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-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"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 framework/tools/quality/scripts/test-framework-drift-check.py && bash framework/tools/quality/scripts/test-framework-drift-doctor.sh && bash framework/systemd/user/test-fleet-units.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/lease-broker/revoke_noop_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-edit.sh && bash framework/tools/git/test-pr-create-fallback-default-base.sh && bash framework/tools/git/test-repo-decl-consumption.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-no-status.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-ci-queue-wait-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-no-ci-expected.sh && bash framework/tools/git/test-pr-merge-fork-ci-status.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/git/test-explain-diagnostic-status-neutral.sh && bash framework/tools/git/test-detect-platform-outside-repo.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh && bash framework/tools/_scripts/test-brain-home-check.sh && bash framework/tools/_scripts/test-structure-anchor-check.sh && bash framework/tools/fleet/test-agent-session-broker-preflight.sh && bash framework/tools/fleet/test-agent-session-legacy-socket-guard.sh && bash framework/tools/git/test-grant-reviewer.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -211,12 +211,23 @@ describe('mosaic fleet install — roster v2', (): void => {
|
||||
describe('[email protected]', (): void => {
|
||||
const unitPath = resolve(process.cwd(), 'framework', 'systemd', 'user', '[email protected]');
|
||||
|
||||
/** The single `ConditionPathExists=` value declared by the unit template. */
|
||||
async function conditionPath(): Promise<string> {
|
||||
/**
|
||||
* Every `ConditionPathExists=` value declared by the unit template, in file
|
||||
* order, `|` triggering prefix included.
|
||||
*
|
||||
* Until #1410 this helper pinned `toHaveLength(1)` — a count assertion, not
|
||||
* a content assertion, and the count pin was itself the defect: when #1408
|
||||
* required a second triggering line (the `%h/.mosaic` brain-home shape),
|
||||
* this spec was a second consumer of the unit template that the shell suite
|
||||
* and the enumeration guard could not see, so the fix failed here first
|
||||
* (CI 2651 — the installation-documentation.spec.ts lesson again). Assert
|
||||
* content per line, never count.
|
||||
*/
|
||||
async function conditionPaths(): Promise<string[]> {
|
||||
const unit = await readFile(unitPath, 'utf8');
|
||||
const matches = unit.match(/^ConditionPathExists=(.+)$/gm) ?? [];
|
||||
expect(matches).toHaveLength(1);
|
||||
return matches[0]!.slice('ConditionPathExists='.length).trim();
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
return matches.map((line) => line.slice('ConditionPathExists='.length).trim());
|
||||
}
|
||||
|
||||
it('will not attempt a seat before the reconciler has written its env', async (): Promise<void> => {
|
||||
@@ -224,7 +235,26 @@ describe('[email protected]', (): void => {
|
||||
// unit (WantedBy=default.target) but does not start it, so without this
|
||||
// condition a reboot between `install` and the first `apply` would run
|
||||
// ExecStart against an absent env file and fail every seat unit.
|
||||
expect(await conditionPath()).toBe('%h/.config/mosaic/fleet/agents/%i.env.generated');
|
||||
//
|
||||
// Two lines since #1408: the env projection lives under %h/.config/mosaic
|
||||
// on framework-home hosts and under %h/.mosaic on brain-home hosts. Both
|
||||
// carry the `|` triggering prefix — systemd ANDs same-type conditions
|
||||
// unless every line is triggering (then they OR), and a bare spelling
|
||||
// would demand BOTH home shapes on one host, which is never true, so
|
||||
// every seat would silently skip.
|
||||
expect(await conditionPaths()).toEqual([
|
||||
'|%h/.config/mosaic/fleet/agents/%i.env.generated',
|
||||
'|%h/.mosaic/fleet/agents/%i.env.generated',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses the bare ANDed spelling on every condition line', async (): Promise<void> => {
|
||||
// Invariant ported from test-fleet-units.sh, held separately from the
|
||||
// literal pin above so it survives future edits to the path set: every
|
||||
// ConditionPathExists line must stay triggering (`|`).
|
||||
for (const value of await conditionPaths()) {
|
||||
expect(value.startsWith('|')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -246,10 +276,18 @@ describe('[email protected]', (): void => {
|
||||
*/
|
||||
it('guards exactly the file the fleet writes, so the two cannot drift apart', async (): Promise<void> => {
|
||||
const mosaicHome = await v2Home();
|
||||
const rendered = (await conditionPath()).replace('%h', tempHome!).replace('%i', 'coder0');
|
||||
const rendered = (await conditionPaths()).map((value) =>
|
||||
value.replace(/^\|/, '').replace('%h', tempHome!).replace('%i', 'coder0'),
|
||||
);
|
||||
|
||||
// The path an installed fleet actually places for this agent.
|
||||
expect(rendered).toBe(join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'));
|
||||
// Per home shape, the guard must render to exactly the file the
|
||||
// reconciler writes there: mosaicHome (%h/.config/mosaic) on
|
||||
// framework-home hosts, %h/.mosaic on brain-home hosts (#1408) — each
|
||||
// line pinned to its file, not merely present.
|
||||
expect(rendered).toEqual([
|
||||
join(mosaicHome, 'fleet', 'agents', 'coder0.env.generated'),
|
||||
join(tempHome!, '.mosaic', 'fleet', 'agents', 'coder0.env.generated'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('guards a real failure — the launcher rejects an absent generated env', async (): Promise<void> => {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { ClackPrompter } from '../../prompter/clack-prompter.js';
|
||||
import type { VerifyResult } from './verify.js';
|
||||
import type { WizardState } from '../../types.js';
|
||||
|
||||
interface InstallOpts {
|
||||
@@ -89,12 +90,34 @@ export async function runInstall(opts: InstallOpts): Promise<void> {
|
||||
prompter.log(` Logs: mosaic gateway logs`);
|
||||
prompter.log(` Status: mosaic gateway status`);
|
||||
|
||||
// Post-install verification (CU-07-03) — non-fatal.
|
||||
// Post-install verification (CU-07-03). Health/token/bootstrap failures
|
||||
// stay non-fatal (courtesy checks), but a FAILED database schema check is
|
||||
// fatal (#1392): an install that reports success over an empty/partial
|
||||
// database is the exact T63 failure this command must never reproduce.
|
||||
let verifyResult: VerifyResult | undefined;
|
||||
let verificationThrew = false;
|
||||
try {
|
||||
const { runPostInstallVerification } = await import('./verify.js');
|
||||
await runPostInstallVerification(configResult.host, configResult.port);
|
||||
} catch {
|
||||
// Non-fatal — verification is a courtesy
|
||||
verifyResult = await runPostInstallVerification(configResult.host, configResult.port);
|
||||
} catch (err) {
|
||||
// Health/token/bootstrap courtesy failures are non-fatal, but a THROWN
|
||||
// schema verification must not let install report success either (N2,
|
||||
// rev-code-02 review 285): mark it and treat as fatal below.
|
||||
verificationThrew = true;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
prompter.warn(`Post-install verification errored: ${msg}`);
|
||||
}
|
||||
if (verifyResult && verifyResult.schemaMigrated === false) {
|
||||
prompter.warn(
|
||||
'Gateway install ABORTED: database schema verification failed (remediation above).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (verificationThrew) {
|
||||
prompter.warn(
|
||||
'Gateway install ABORTED: post-install verification errored (see above); refusing to report success on an unverified database.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
// Stages normally return structured results for expected failures.
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
checkDatabaseSchema,
|
||||
SCHEMA_FAIL_REMEDIATION,
|
||||
type SchemaCheckDeps,
|
||||
} from './schema-check.js';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Fixture config */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
|
||||
function writeConfig(cfg: Record<string, unknown>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'schema-check-'));
|
||||
tmpDirs.push(dir);
|
||||
const path = join(dir, 'mosaic.config.json');
|
||||
writeFileSync(path, JSON.stringify(cfg));
|
||||
return path;
|
||||
}
|
||||
|
||||
const STANDALONE_CFG = {
|
||||
tier: 'standalone',
|
||||
storage: { type: 'postgres', url: 'postgresql://u:p@localhost:5434/x' },
|
||||
queue: { type: 'bullmq', url: 'redis://localhost:6380' },
|
||||
memory: { type: 'keyword' },
|
||||
};
|
||||
|
||||
const LOCAL_CFG = {
|
||||
tier: 'local',
|
||||
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
||||
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
||||
memory: { type: 'keyword' },
|
||||
};
|
||||
|
||||
function deps(overrides: Partial<SchemaCheckDeps> = {}): SchemaCheckDeps {
|
||||
return {
|
||||
runMigrations: vi.fn().mockResolvedValue(undefined),
|
||||
getMigrationStatus: vi.fn().mockResolvedValue({
|
||||
appliedCount: 17,
|
||||
expectedCount: 17,
|
||||
expectedLastTag: '0016_x',
|
||||
complete: true,
|
||||
}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tests */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe('checkDatabaseSchema', () => {
|
||||
beforeEach(() => {
|
||||
process.env['MOSAIC_CONFIG'] = '';
|
||||
vi.stubEnv('DATABASE_URL', 'postgresql://env:env@localhost:9999/env');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('passes when the ledger matches the shipped journal', async () => {
|
||||
const cfg = writeConfig(STANDALONE_CFG);
|
||||
const d = deps();
|
||||
const result = await checkDatabaseSchema(d, cfg);
|
||||
expect(result.status).toBe('ok');
|
||||
expect(result.detail).toContain('17/17');
|
||||
expect(d.runMigrations).toHaveBeenCalledWith(STANDALONE_CFG.storage.url);
|
||||
expect(d.getMigrationStatus).toHaveBeenCalledWith(STANDALONE_CFG.storage.url);
|
||||
});
|
||||
|
||||
it('FAILS when the ledger is incomplete — the #1389 empty-database signature', async () => {
|
||||
const cfg = writeConfig(STANDALONE_CFG);
|
||||
const d = deps({
|
||||
getMigrationStatus: vi.fn().mockResolvedValue({
|
||||
appliedCount: 0,
|
||||
expectedCount: 17,
|
||||
expectedLastTag: '0016_x',
|
||||
complete: false,
|
||||
}),
|
||||
});
|
||||
const result = await checkDatabaseSchema(d, cfg);
|
||||
expect(result.status).toBe('fail');
|
||||
if (result.status !== 'fail') throw new Error('expected fail');
|
||||
expect(result.detail).toContain('0/17');
|
||||
expect(result.remediation).toBe(SCHEMA_FAIL_REMEDIATION);
|
||||
expect(result.remediation).toContain('#1389');
|
||||
});
|
||||
|
||||
it('FAILS when the ledger is only PARTIALLY migrated (#1402 upgrade case)', async () => {
|
||||
const cfg = writeConfig(STANDALONE_CFG);
|
||||
const d = deps({
|
||||
getMigrationStatus: vi.fn().mockResolvedValue({
|
||||
appliedCount: 9,
|
||||
expectedCount: 17,
|
||||
expectedLastTag: '0016_x',
|
||||
complete: false,
|
||||
}),
|
||||
});
|
||||
const result = await checkDatabaseSchema(d, cfg);
|
||||
expect(result.status).toBe('fail');
|
||||
expect(result.detail).toContain('9/17');
|
||||
});
|
||||
|
||||
it('FAILS (never crashes) when the migration run itself throws', async () => {
|
||||
const cfg = writeConfig(STANDALONE_CFG);
|
||||
const d = deps({
|
||||
runMigrations: vi.fn().mockRejectedValue(new Error('connection refused')),
|
||||
});
|
||||
const result = await checkDatabaseSchema(d, cfg);
|
||||
expect(result.status).toBe('fail');
|
||||
expect(result.detail).toContain('connection refused');
|
||||
});
|
||||
|
||||
it('skips the local tier (gateway migrates its own PGlite at startup)', async () => {
|
||||
const cfg = writeConfig(LOCAL_CFG);
|
||||
const d = deps();
|
||||
const result = await checkDatabaseSchema(d, cfg);
|
||||
expect(result.status).toBe('skipped');
|
||||
expect(d.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Config resolution priority */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
describe('resolveSchemaCheckConfigPath', () => {
|
||||
it('prefers the daemon-written gateway config over cwd copies', async () => {
|
||||
const { resolveSchemaCheckConfigPath } = await import('./schema-check.js');
|
||||
const cwdCfg = writeConfig(STANDALONE_CFG); // in a temp dir
|
||||
const daemonDir = mkdtempSync(join(tmpdir(), 'schema-check-daemon-'));
|
||||
tmpDirs.push(daemonDir);
|
||||
const daemonHome = join(daemonDir, '.config', 'mosaic', 'gateway');
|
||||
mkdirSync(daemonHome, { recursive: true });
|
||||
writeFileSync(join(daemonHome, 'mosaic.config.json'), JSON.stringify(LOCAL_CFG));
|
||||
|
||||
const prevHome = process.env['HOME'];
|
||||
vi.stubEnv('HOME', daemonDir);
|
||||
vi.stubEnv('MOSAIC_CONFIG', '');
|
||||
try {
|
||||
const resolved = resolveSchemaCheckConfigPath();
|
||||
// Must NOT pick the cwd copy (cwd is the vitest project dir, not our
|
||||
// temp dir — so the only resolvable candidates are daemon + $HOME/.mosaic).
|
||||
expect(resolved).toBe(join(daemonHome, 'mosaic.config.json'));
|
||||
void cwdCfg;
|
||||
} finally {
|
||||
if (prevHome !== undefined) vi.stubEnv('HOME', prevHome);
|
||||
}
|
||||
});
|
||||
|
||||
it('gives MOSAIC_CONFIG NO authority (N1, review 285): env never overrides file resolution', async () => {
|
||||
const { resolveSchemaCheckConfigPath } = await import('./schema-check.js');
|
||||
const daemonDir = mkdtempSync(join(tmpdir(), 'schema-check-env-'));
|
||||
tmpDirs.push(daemonDir);
|
||||
const daemonHome = join(daemonDir, '.config', 'mosaic', 'gateway');
|
||||
mkdirSync(daemonHome, { recursive: true });
|
||||
writeFileSync(join(daemonHome, 'mosaic.config.json'), JSON.stringify(LOCAL_CFG));
|
||||
// A stale env var pointing at a DIFFERENT file must be ignored entirely:
|
||||
const decoyDir = mkdtempSync(join(tmpdir(), 'schema-check-decoy-'));
|
||||
tmpDirs.push(decoyDir);
|
||||
const decoyPath = join(decoyDir, 'mosaic.config.json');
|
||||
writeFileSync(decoyPath, JSON.stringify(STANDALONE_CFG));
|
||||
|
||||
const prevHome = process.env['HOME'];
|
||||
vi.stubEnv('HOME', daemonDir);
|
||||
vi.stubEnv('MOSAIC_CONFIG', decoyPath);
|
||||
try {
|
||||
const resolved = resolveSchemaCheckConfigPath();
|
||||
expect(resolved).toBe(join(daemonHome, 'mosaic.config.json'));
|
||||
expect(resolved).not.toBe(decoyPath);
|
||||
} finally {
|
||||
if (prevHome !== undefined) vi.stubEnv('HOME', prevHome);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Install-time database schema verification (#1392).
|
||||
*
|
||||
* Fresh standalone (postgres) installs once ended "healthy" with a completely
|
||||
* empty database: the resolved dependency set shipped no migrations at all
|
||||
* (#1389), the gateway started fine, and nothing failed until the first real
|
||||
* query. The issue's own conclusion: the installer must "instruct and verify".
|
||||
*
|
||||
* This check runs AFTER migrations have been (re-)applied, so the only way it
|
||||
* fails is a genuinely broken migration set or database — which is exactly
|
||||
* when install must not report success. Failure is fatal by design (fast-fail
|
||||
* STANDARDS); callers print the remediation text and exit non-zero.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { loadConfig } from '@mosaicstack/config';
|
||||
|
||||
export interface SchemaStatusCounts {
|
||||
appliedCount: number;
|
||||
expectedCount: number;
|
||||
expectedLastTag: string;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/** Injectable migration surface — keeps this unit-testable without a DB. */
|
||||
export interface SchemaCheckDeps {
|
||||
runMigrations(url: string): Promise<void>;
|
||||
getMigrationStatus(url: string): Promise<SchemaStatusCounts>;
|
||||
}
|
||||
|
||||
export type SchemaCheckResult =
|
||||
| { status: 'ok'; detail: string }
|
||||
| { status: 'skipped'; detail: string }
|
||||
| { status: 'fail'; detail: string; remediation: string };
|
||||
|
||||
export const SCHEMA_FAIL_REMEDIATION = [
|
||||
'The gateway database does not carry the full schema.',
|
||||
'Causes seen in the wild: dependency set resolved without migrations (#1389), or a partially-migrated database (#1402).',
|
||||
'Remediation:',
|
||||
' 1. Re-run: mosaic gateway install (applies migrations and verifies again)',
|
||||
' 2. Check the resolved @mosaicstack/db version is the same pipeline as the gateway (npm ls -g @mosaicstack/db)',
|
||||
' 3. Manual apply: run runMigrations() from @mosaicstack/db against the storage URL, then re-verify',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* Resolve the config the INSTALLED gateway would use — same priority the
|
||||
* daemon applies (apps/gateway/src/env.ts resolveGatewayConfigPath), minus
|
||||
* the source-tree anchors that do not exist on an installed host. Verifying
|
||||
* against any other config could green-light a database the daemon never
|
||||
* reads (#1392: verify what runs, not what happens to lie in cwd).
|
||||
*/
|
||||
export function resolveSchemaCheckConfigPath(explicit?: string): string | undefined {
|
||||
if (explicit) return resolve(explicit);
|
||||
// NOTE: no env-var candidate, deliberately. apps/gateway/src/env.ts gives env
|
||||
// NO config authority (a stale MOSAIC_CONFIG could verify a database the
|
||||
// daemon never reads — rev-code-02 review 285, note N1). Resolution order
|
||||
// mirrors the daemon's file priorities only.
|
||||
const candidates = [
|
||||
join(homedir(), '.config', 'mosaic', 'gateway', 'mosaic.config.json'), // daemon-written
|
||||
resolve(process.cwd(), 'mosaic.config.json'),
|
||||
join(homedir(), '.mosaic', 'mosaic.config.json'),
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (c && existsSync(c)) return c;
|
||||
}
|
||||
return undefined; // loadConfig falls back to env-var detection
|
||||
}
|
||||
|
||||
export async function checkDatabaseSchema(
|
||||
deps: SchemaCheckDeps,
|
||||
configPath?: string,
|
||||
): Promise<SchemaCheckResult> {
|
||||
const config = loadConfig(resolveSchemaCheckConfigPath(configPath));
|
||||
|
||||
// Local tier: the gateway itself runs PGlite migrations at startup (see
|
||||
// DatabaseModule.onModuleInit), and a broken local tier fails the health
|
||||
// check instead. Nothing for the installer to verify here.
|
||||
if (config.storage.type !== 'postgres') {
|
||||
return {
|
||||
status: 'skipped',
|
||||
detail: 'database schema (local tier — migrated by gateway at startup)',
|
||||
};
|
||||
}
|
||||
|
||||
const url = config.storage.url;
|
||||
try {
|
||||
await deps.runMigrations(url);
|
||||
const status = await deps.getMigrationStatus(url);
|
||||
if (status.complete) {
|
||||
return {
|
||||
status: 'ok',
|
||||
detail: `database schema (${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations)`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'fail',
|
||||
detail: `database schema incomplete (${status.appliedCount.toString()}/${status.expectedCount.toString()} applied, last expected: ${status.expectedLastTag})`,
|
||||
remediation: SCHEMA_FAIL_REMEDIATION,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
status: 'fail',
|
||||
detail: `database schema check errored: ${msg}`,
|
||||
remediation: SCHEMA_FAIL_REMEDIATION,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,8 @@ export interface VerifyResult {
|
||||
gatewayHealthy: boolean;
|
||||
adminTokenOnFile: boolean;
|
||||
bootstrapReachable: boolean;
|
||||
/** False only on a FAILED postgres schema check; true when ok or skipped. */
|
||||
schemaMigrated: boolean;
|
||||
allPassed: boolean;
|
||||
}
|
||||
|
||||
@@ -89,7 +91,36 @@ export async function runPostInstallVerification(
|
||||
fail('bootstrap endpoint reach', 'Run: mosaic gateway status / mosaic gateway logs');
|
||||
}
|
||||
|
||||
const allPassed = gatewayHealthy && adminTokenOnFile && bootstrapReachable;
|
||||
// ─── Check 4: Database schema migrated (#1392) ────────────────────────────
|
||||
// Fatal-on-failure for install: the #1389 failure mode was an install that
|
||||
// reported success over an empty database. Local tiers skip (the gateway
|
||||
// migrates its own PGlite at startup and would fail health if it couldn't).
|
||||
let schemaMigrated = true;
|
||||
try {
|
||||
const { checkDatabaseSchema } = await import('./schema-check.js');
|
||||
const { runMigrations, getMigrationStatus } = await import('@mosaicstack/db');
|
||||
const result = await checkDatabaseSchema(
|
||||
{ runMigrations, getMigrationStatus },
|
||||
undefined, // resolver mirrors daemon file priorities; env has no config authority (N1)
|
||||
);
|
||||
if (result.status === 'ok') {
|
||||
ok(result.detail);
|
||||
} else if (result.status === 'skipped') {
|
||||
ok(result.detail);
|
||||
} else {
|
||||
fail(result.detail, result.remediation);
|
||||
schemaMigrated = false;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
fail(
|
||||
`database schema check errored: ${msg}`,
|
||||
'See #1392/#1389; re-run: mosaic gateway install',
|
||||
);
|
||||
schemaMigrated = false;
|
||||
}
|
||||
|
||||
const allPassed = gatewayHealthy && adminTokenOnFile && bootstrapReachable && schemaMigrated;
|
||||
|
||||
if (!allPassed) {
|
||||
console.log(
|
||||
@@ -98,7 +129,7 @@ export async function runPostInstallVerification(
|
||||
);
|
||||
}
|
||||
|
||||
return { gatewayHealthy, adminTokenOnFile, bootstrapReachable, allPassed };
|
||||
return { gatewayHealthy, adminTokenOnFile, bootstrapReachable, schemaMigrated, allPassed };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user